From ee0996f535ba31fc07850db2a6a4090739ba1490 Mon Sep 17 00:00:00 2001 From: Serhii Date: Tue, 21 Oct 2025 00:22:01 +0300 Subject: [PATCH] CW-1175-Implement-swaps.xyz-swap-provider (#2526) * Integrate SwapsXYZ * Remove print * enhance SwapsXyz EVM token swap handling * Disable fixed rate support in SwapsXyz provider * enable fixed rate swap * fix timestamp parsing * remove sender and recipient address from fetchRate * Improve SwapsXYZ trade approval logic * fix trade ID overflow * add Base chain support for swaps xyz * Update lib/view_model/exchange/exchange_trade_view_model.dart Co-authored-by: Omar Hatem * add missing support for Base --------- Co-authored-by: Omar Hatem --- .../workflows/automated_integration_test.yml | 1 + .github/workflows/pr_test_build_android.yml | 1 + .github/workflows/pr_test_build_linux.yml | 1 + assets/images/swaps_xyz.svg | 42 + cw_core/lib/amount_converter.dart | 50 ++ cw_core/lib/pending_transaction.dart | 1 + cw_evm/lib/evm_chain_client.dart | 1 + cw_evm/lib/evm_chain_wallet.dart | 76 ++ cw_evm/lib/pending_evm_chain_transaction.dart | 10 +- lib/base/cw_base.dart | 10 + lib/core/trade_monitor.dart | 3 + lib/ethereum/cw_ethereum.dart | 10 + .../exchange_provider_description.dart | 4 + .../provider/chainflip_exchange_provider.dart | 13 +- .../provider/changenow_exchange_provider.dart | 13 +- lib/exchange/provider/exchange_provider.dart | 13 +- .../provider/exolix_exchange_provider.dart | 13 +- .../letsexchange_exchange_provider.dart | 13 +- .../provider/sideshift_exchange_provider.dart | 13 +- .../simpleswap_exchange_provider.dart | 13 +- .../provider/swapsxyz_exchange_provider.dart | 823 ++++++++++++++++++ .../provider/swaptrade_exchange_provider.dart | 2 +- .../provider/thorchain_exchange.provider.dart | 13 +- .../provider/trocador_exchange_provider.dart | 13 +- .../provider/xoswap_exchange_provider.dart | 3 +- lib/exchange/trade.dart | 60 +- lib/polygon/cw_polygon.dart | 7 + .../exchange_trade/exchange_confirm_page.dart | 19 +- .../exchange_trade/exchange_trade_page.dart | 14 +- lib/store/dashboard/trade_filter_store.dart | 18 +- .../dashboard/dashboard_view_model.dart | 5 + .../exchange/exchange_trade_view_model.dart | 77 +- .../exchange/exchange_view_model.dart | 4 +- lib/view_model/send/send_view_model.dart | 252 +++++- lib/view_model/trade_details_view_model.dart | 16 +- tool/configure.dart | 6 + tool/utils/secret_key.dart | 1 + 37 files changed, 1557 insertions(+), 77 deletions(-) create mode 100644 assets/images/swaps_xyz.svg create mode 100644 lib/exchange/provider/swapsxyz_exchange_provider.dart diff --git a/.github/workflows/automated_integration_test.yml b/.github/workflows/automated_integration_test.yml index b64897c06..5ccf4aacb 100644 --- a/.github/workflows/automated_integration_test.yml +++ b/.github/workflows/automated_integration_test.yml @@ -190,6 +190,7 @@ jobs: echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart + echo "const swapsXyzApiKey = '${{ secrets.SWAPSXYZ_API_KEY }}';" >> lib/.secrets.g.dart - name: prepare monero_c and cache run: | diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml index 521cc6ecd..cd7cd400a 100644 --- a/.github/workflows/pr_test_build_android.yml +++ b/.github/workflows/pr_test_build_android.yml @@ -180,6 +180,7 @@ jobs: echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart + echo "const swapsXyzApiKey = '${{ secrets.SWAPSXYZ_API_KEY }}';" >> lib/.secrets.g.dart - name: Generate KeyStore run: | diff --git a/.github/workflows/pr_test_build_linux.yml b/.github/workflows/pr_test_build_linux.yml index 6d39bb85e..0365ec2d8 100644 --- a/.github/workflows/pr_test_build_linux.yml +++ b/.github/workflows/pr_test_build_linux.yml @@ -176,6 +176,7 @@ jobs: echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart + echo "const swapsXyzApiKey = '${{ secrets.SWAPSXYZ_API_KEY }}';" >> lib/.secrets.g.dart - name: Fetch prebuilt Torch run: | diff --git a/assets/images/swaps_xyz.svg b/assets/images/swaps_xyz.svg new file mode 100644 index 000000000..e65d157da --- /dev/null +++ b/assets/images/swaps_xyz.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/cw_core/lib/amount_converter.dart b/cw_core/lib/amount_converter.dart index 8a095dffa..3c48cde09 100644 --- a/cw_core/lib/amount_converter.dart +++ b/cw_core/lib/amount_converter.dart @@ -71,4 +71,54 @@ class AmountConverter { static String _moneroAmountToStringUsingDecimals(int amount) => _moneroAmountFormat.format( DecimalIntl(cryptoAmountToDecimal(amount: amount, divider: _moneroAmountDivider))); + + + static String toBaseUnits(String amount, int decimals) { + amount = amount.trim(); + if (amount.isEmpty) return '0'; + + final neg = amount.startsWith('-'); + if (neg) amount = amount.substring(1); + + amount = amount.replaceAll(',', ''); + final parts = amount.split('.'); + final whole = parts[0].isEmpty ? '0' : parts[0]; + final frac = parts.length > 1 ? parts[1] : ''; + + final fracPadded = (frac + '0' * decimals).substring(0, decimals); + + final pow10 = BigInt.from(10).pow(decimals); + final wholeBI = BigInt.parse(whole); + final fracBI = fracPadded.isEmpty ? BigInt.zero : BigInt.parse(fracPadded); + + final res = wholeBI * pow10 + fracBI; + return neg ? '-${res.toString()}' : res.toString(); + } + + + static String fromBaseUnits(String units, int decimals) { + units = units.trim(); + if (units.isEmpty) return '0'; + + final neg = units.startsWith('-'); + if (neg) units = units.substring(1); + + if (decimals == 0) return neg ? '-$units' : units; + + // pad if shorter than decimals + if (units.length <= decimals) { + final s = units.padLeft(decimals + 1, '0'); // ensures at least "0xxxx" + final intPart = s.substring(0, s.length - decimals); + var fracPart = s.substring(s.length - decimals); + fracPart = fracPart.replaceFirst(RegExp(r'0+$'), ''); + final out = fracPart.isEmpty ? intPart : '$intPart.$fracPart'; + return neg ? '-$out' : out; + } + + final intPart = units.substring(0, units.length - decimals); + var fracPart = units.substring(units.length - decimals); + fracPart = fracPart.replaceFirst(RegExp(r'0+$'), ''); + final out = fracPart.isEmpty ? intPart : '$intPart.$fracPart'; + return neg ? '-$out' : out; + } } diff --git a/cw_core/lib/pending_transaction.dart b/cw_core/lib/pending_transaction.dart index 599047fbd..78cd4c874 100644 --- a/cw_core/lib/pending_transaction.dart +++ b/cw_core/lib/pending_transaction.dart @@ -12,6 +12,7 @@ mixin PendingTransaction { String get feeFormattedValue; String? feeRate; String get hex; + String? get evmTxHashFromRawHex => null; int? get outputCount => null; PendingChange? change; diff --git a/cw_evm/lib/evm_chain_client.dart b/cw_evm/lib/evm_chain_client.dart index 814979d6d..fbc4bad95 100644 --- a/cw_evm/lib/evm_chain_client.dart +++ b/cw_evm/lib/evm_chain_client.dart @@ -111,6 +111,7 @@ abstract class EVMChainClient { sender: senderAddress, to: toAddress, value: value, + data: data, ); return estimatedGas.toInt(); diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index b752aefd4..c1c375482 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -40,6 +40,7 @@ import 'package:web3dart/crypto.dart'; import 'package:web3dart/web3dart.dart'; import 'package:eth_sig_util/eth_sig_util.dart'; +import 'contract/erc20.dart'; import 'evm_chain_transaction_info.dart'; import 'evm_erc20_balance.dart'; @@ -611,6 +612,50 @@ abstract class EVMChainWalletBase return pendingEVMChainTransaction; } + Future createCallDataTransaction( + String to, + String dataHex, + BigInt valueWei, + EVMChainTransactionPriority priority, + ) async { + + // Estimate gas with the SAME call (sender, to, value, data) + final gas = await calculateActualEstimatedFeeForCreateTransaction( + amount: valueWei, // native value (usually 0 for ERC20 transfer) + receivingAddressHex: to, + priority: priority, + contractAddress: null, + data: _client.hexToBytes(dataHex), + ); + + final nativeCurrency = switch (_client.chainId) { + 137 => CryptoCurrency.maticpoly, + 8453 => CryptoCurrency.baseEth, + _ => CryptoCurrency.eth, + }; + + // Fallback for nodes that fail estimate (non-zero) + final gasUnits = gas.estimatedGasUnits == 0 ? 65000 : gas.estimatedGasUnits; + + // Sign raw (native) tx with callData + return _client.signTransaction( + privateKey: _evmChainPrivateKey, + toAddress: to, + amount: valueWei, + gasFee: BigInt.from(gas.estimatedGasFee), + estimatedGasUnits: gasUnits, + maxFeePerGas: gas.maxFeePerGas, + priority: priority, + currency: nativeCurrency, + feeCurrency: nativeCurrency.title, + exponent: 18, + contractAddress: null, + data: dataHex, + gasPrice: gas.gasPrice, + ); + } + + Future createApprovalTransaction(BigInt amount, String spender, CryptoCurrency token, EVMChainTransactionPriority priority, String feeCurrency) async { final CryptoCurrency transactionCurrency = @@ -793,6 +838,37 @@ abstract class EVMChainWalletBase } } + Future isApprovalRequired( + String tokenContract, + String spender, + BigInt requiredAmount) async { + + const zero = '0x0000000000000000000000000000000000000000'; + const evmNative = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; + + final token = tokenContract.toLowerCase(); + if (token == zero || token == evmNative.toLowerCase()) return false; + if (requiredAmount <= BigInt.zero) return false; + + try { + final owner = _evmChainPrivateKey.address; + final erc20 = ERC20( + client: _client.getWeb3Client()!, + address: EthereumAddress.fromHex(tokenContract), + chainId: _client.chainId, + ); + + final allowance = await erc20.allowance( + owner, + EthereumAddress.fromHex(spender)); + + return allowance < requiredAmount; + } catch (e) { + printV('approval-check error: $e'); + return true; + } + } + Future getPrivateKey({ String? mnemonic, String? privateKey, diff --git a/cw_evm/lib/pending_evm_chain_transaction.dart b/cw_evm/lib/pending_evm_chain_transaction.dart index 50213d6a4..3e9ad39f2 100644 --- a/cw_evm/lib/pending_evm_chain_transaction.dart +++ b/cw_evm/lib/pending_evm_chain_transaction.dart @@ -51,7 +51,15 @@ class PendingEVMChainTransaction with PendingTransaction { return '0x${Hex.HEX.encode(txid)}'; } - + + @override + String? get evmTxHashFromRawHex { + final no0x = hex.startsWith('0x') ? hex.substring(2) : hex; + final bytes = Uint8List.fromList(Hex.HEX.decode(no0x)); + final digest = keccak256(bytes); + return '0x${Hex.HEX.encode(digest)}'; + } + @override Future> commitUR() { throw UnimplementedError(); diff --git a/lib/base/cw_base.dart b/lib/base/cw_base.dart index 02b966938..a3bbe530b 100644 --- a/lib/base/cw_base.dart +++ b/lib/base/cw_base.dart @@ -189,6 +189,10 @@ class CWBase extends Base { @override String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress; + @override + Future isApprovalRequired(WalletBase wallet, String tokenContract,String spender, BigInt requiredAmount) => + (wallet as EVMChainWallet).isApprovalRequired(tokenContract, spender, requiredAmount); + @override Future createTokenApproval( WalletBase wallet, @@ -205,6 +209,12 @@ class CWBase extends Base { "BASE", ); + @override + Future createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, + TransactionPriority priority) => + (wallet as EVMChainWallet).createCallDataTransaction(to, dataHex, valueWei, priority as EVMChainTransactionPriority); + + @override void setHardwareWalletService(WalletBase wallet, HardwareWalletService service) { if (service is EVMChainLedgerService) { diff --git a/lib/core/trade_monitor.dart b/lib/core/trade_monitor.dart index 408f7faa7..cacc8ef0b 100644 --- a/lib/core/trade_monitor.dart +++ b/lib/core/trade_monitor.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/exchange/trade_state.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; @@ -62,6 +63,8 @@ class TradeMonitor { return ChainflipExchangeProvider(tradesStore: trades); case ExchangeProviderDescription.xoSwap: return XOSwapExchangeProvider(); + case ExchangeProviderDescription.swapsXyz: + return SwapsXyzExchangeProvider(); } return null; } diff --git a/lib/ethereum/cw_ethereum.dart b/lib/ethereum/cw_ethereum.dart index 840f3500e..92d8bbec4 100644 --- a/lib/ethereum/cw_ethereum.dart +++ b/lib/ethereum/cw_ethereum.dart @@ -217,11 +217,21 @@ class CWEthereum extends Ethereum { return ethereumWallet.erc20Currencies.any((element) => element.contractAddress.toLowerCase() == contractAddress.toLowerCase()); } + @override + Future isApprovalRequired(WalletBase wallet, String tokenContract,String spender, BigInt requiredAmount) => + (wallet as EVMChainWallet).isApprovalRequired(tokenContract, spender, requiredAmount); + + @override Future createTokenApproval(WalletBase wallet, BigInt amount, String spender, CryptoCurrency token, TransactionPriority priority) => (wallet as EVMChainWallet).createApprovalTransaction( amount, spender, token, priority as EVMChainTransactionPriority, "ETH"); + @override + Future createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, + TransactionPriority priority) => + (wallet as EVMChainWallet).createCallDataTransaction(to, dataHex, valueWei, priority as EVMChainTransactionPriority); + // Integrations @override Future getDEuroSavingsBalance(WalletBase wallet) => diff --git a/lib/exchange/exchange_provider_description.dart b/lib/exchange/exchange_provider_description.dart index b3761ab73..d542ff308 100644 --- a/lib/exchange/exchange_provider_description.dart +++ b/lib/exchange/exchange_provider_description.dart @@ -36,6 +36,8 @@ class ExchangeProviderDescription extends EnumerableItem with Serializable< ExchangeProviderDescription(title: 'Chainflip', raw: 12, image: 'assets/images/chainflip.png'); static const xoSwap = ExchangeProviderDescription(title: 'XOSwap', raw: 13, image: 'assets/images/xoswap.svg'); + static const swapsXyz = + ExchangeProviderDescription(title: 'Swaps.XYZ', raw: 14, image: 'assets/images/swaps_xyz.svg'); static ExchangeProviderDescription deserialize({required int raw}) { switch (raw) { @@ -67,6 +69,8 @@ class ExchangeProviderDescription extends EnumerableItem with Serializable< return chainflip; case 13: return xoSwap; + case 14: + return swapsXyz; default: throw Exception('Unexpected token: $raw for ExchangeProviderDescription deserialize'); } diff --git a/lib/exchange/provider/chainflip_exchange_provider.dart b/lib/exchange/provider/chainflip_exchange_provider.dart index de1642a96..cbd9fa85f 100644 --- a/lib/exchange/provider/chainflip_exchange_provider.dart +++ b/lib/exchange/provider/chainflip_exchange_provider.dart @@ -91,12 +91,13 @@ class ChainflipExchangeProvider extends ExchangeProvider { } @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { // TODO: It seems this rate is getting cached, and re-used for different amounts, can we not do this? try { diff --git a/lib/exchange/provider/changenow_exchange_provider.dart b/lib/exchange/provider/changenow_exchange_provider.dart index 5a01f38ba..8f6e7bc7c 100644 --- a/lib/exchange/provider/changenow_exchange_provider.dart +++ b/lib/exchange/provider/changenow_exchange_provider.dart @@ -91,12 +91,13 @@ class ChangeNowExchangeProvider extends ExchangeProvider { } @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { try { if (amount == 0) return 0.0; diff --git a/lib/exchange/provider/exchange_provider.dart b/lib/exchange/provider/exchange_provider.dart index a91a7ac9d..45c61d7c8 100644 --- a/lib/exchange/provider/exchange_provider.dart +++ b/lib/exchange/provider/exchange_provider.dart @@ -33,12 +33,13 @@ abstract class ExchangeProvider { Future findTradeById({required String id}); - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}); + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }); Future checkIsAvailable(); } diff --git a/lib/exchange/provider/exolix_exchange_provider.dart b/lib/exchange/provider/exolix_exchange_provider.dart index fa0c16f96..252222b31 100644 --- a/lib/exchange/provider/exolix_exchange_provider.dart +++ b/lib/exchange/provider/exolix_exchange_provider.dart @@ -112,12 +112,13 @@ class ExolixExchangeProvider extends ExchangeProvider { } @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { try { if (amount == 0) return 0.0; diff --git a/lib/exchange/provider/letsexchange_exchange_provider.dart b/lib/exchange/provider/letsexchange_exchange_provider.dart index 1841dffed..d37c6a207 100644 --- a/lib/exchange/provider/letsexchange_exchange_provider.dart +++ b/lib/exchange/provider/letsexchange_exchange_provider.dart @@ -78,12 +78,13 @@ class LetsExchangeExchangeProvider extends ExchangeProvider { } @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { final networkFrom = _getNetworkType(from); final networkTo = _getNetworkType(to); try { diff --git a/lib/exchange/provider/sideshift_exchange_provider.dart b/lib/exchange/provider/sideshift_exchange_provider.dart index 7727f4809..763064ec1 100644 --- a/lib/exchange/provider/sideshift_exchange_provider.dart +++ b/lib/exchange/provider/sideshift_exchange_provider.dart @@ -120,12 +120,13 @@ class SideShiftExchangeProvider extends ExchangeProvider { } @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { try { if (amount == 0) return 0.0; diff --git a/lib/exchange/provider/simpleswap_exchange_provider.dart b/lib/exchange/provider/simpleswap_exchange_provider.dart index e88562baa..1118e7ba7 100644 --- a/lib/exchange/provider/simpleswap_exchange_provider.dart +++ b/lib/exchange/provider/simpleswap_exchange_provider.dart @@ -89,12 +89,13 @@ class SimpleSwapExchangeProvider extends ExchangeProvider { } @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { try { if (amount == 0) return 0.0; diff --git a/lib/exchange/provider/swapsxyz_exchange_provider.dart b/lib/exchange/provider/swapsxyz_exchange_provider.dart new file mode 100644 index 000000000..6452b34f5 --- /dev/null +++ b/lib/exchange/provider/swapsxyz_exchange_provider.dart @@ -0,0 +1,823 @@ +import 'dart:convert'; + +import 'package:cake_wallet/.secrets.g.dart' as secrets; +import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/exchange/limits.dart'; +import 'package:cake_wallet/exchange/trade.dart'; +import 'package:cake_wallet/exchange/trade_not_created_exception.dart'; +import 'package:cake_wallet/exchange/trade_request.dart'; +import 'package:cake_wallet/exchange/trade_state.dart'; +import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; +import 'package:cw_core/amount_converter.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cw_core/crypto_currency.dart'; + +class SwapsXyzExchangeProvider extends ExchangeProvider { + SwapsXyzExchangeProvider() : super(pairList: supportedPairs(_notSupported)); + + static const List _notSupported = []; + + static final _apiKey = secrets.swapsXyzApiKey; + static const _baseUrl = 'api-v2.swaps.xyz'; + static const _getChainList = 'api/getChainList'; + static const _getPaths = 'api/getPaths'; + static const _getQuotePaths = 'api/getQuote'; + static const _getAction = 'api/getAction'; + static const _registerTxs = 'api/registerTxs'; + static const _getStatus = 'api/getStatus'; + + static final _headers = {'x-api-key': _apiKey}; + + static final _supportedChainList = []; + final Map> _tokensCache = {}; + + @override + String get title => 'Swaps.XYZ'; + + @override + bool get isAvailable => true; + + @override + bool get isEnabled => true; + + @override + bool get supportsFixedRate => true; + + @override + ExchangeProviderDescription get description => + ExchangeProviderDescription.swapsXyz; + + @override + Future checkIsAvailable() async => true; + + @override + Future fetchLimits({ + required CryptoCurrency from, + required CryptoCurrency to, + required bool isFixedRateMode, + }) async { + try { + final chains = await _geSupportedChain(); + if (chains.isEmpty) throw Exception('Failed to fetch supported chains'); + + final srcChain = _findChainByCurrency(from, chains); + final dstChain = _findChainByCurrency(to, chains); + + await _ensureTokensCached( + fromChain: srcChain, toChain: dstChain, from: from, to: to); + + final srcToken = _getTokenAddress(currency: from, chain: srcChain); + final dstToken = _getTokenAddress(currency: to, chain: dstChain); + + // parameters to and from are swapped in this endpoint + // i.e. to get limits for FROM -> TO, we request paths for TO -> FROM + final params = { + 'srcChainId': '${dstChain.chainId}', + 'srcToken': dstToken, + 'dstChainId': '${srcChain.chainId}', + 'dstToken': srcToken, + }; + + final uri = Uri.https(_baseUrl, _getPaths, params); + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers); + if (res.statusCode != 200) { + throw Exception('Unexpected http status: ${res.statusCode}'); + } + + final body = json.decode(res.body) as Map; + + final paths = (body['paths'] as List?) ?? const []; + if (paths.isEmpty) { + throw Exception('No paths for ${from.title} -> ${to.title}'); + } + + final path0 = paths.first as Map; + final tokens = path0['tokens']; + + // If tokens == "all", use path-level amountLimits + if (tokens is String && tokens == 'all') { + final amountLimits = path0['amountLimits'] as Map?; + + final min = + double.tryParse(amountLimits?['minAmount']?.toString() ?? ''); + final max = + double.tryParse(amountLimits?['maxAmount']?.toString() ?? ''); + + return Limits(min: min, max: max); + } + + // Otherwise tokens is a list -> find the specific token entry + final tokenList = (tokens as List?) ?? const []; + final token = findTokenBySymbol(title: from.title, tokens: tokenList); + + if (token == null) { + throw Exception('Token info not found for ${from.title}'); + } + + final min = double.tryParse(token['minAmount']?.toString() ?? ''); + final max = double.tryParse(token['maxAmount']?.toString() ?? ''); + + return Limits(min: min, max: max); + } catch (e) { + printV('fetchLimits error: $e'); + throw Exception('Error fetching limits: $e'); + } + } + + Future<_PathInfo?> _pickPath({ + required int srcChainId, + required String srcToken, + required int dstChainId, + required String dstToken, + }) async { + final uri = Uri.https(_baseUrl, _getPaths, { + 'srcChainId': '$srcChainId', + 'srcToken': srcToken, + 'dstChainId': '$dstChainId', + 'dstToken': dstToken, + }); + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers); + if (res.statusCode != 200) return null; + final body = json.decode(res.body) as Map; + final paths = + (body['paths'] as List?)?.cast>() ?? const []; + if (paths.isEmpty) return null; + final p = paths.first; + return _PathInfo( + supportsExactOut: p['supportsExactAmountOut'] == true, + minToAmountHuman: (p['amountLimits']?['minAmount'] as String?) ?? '0', + ); + } + + @override + Future fetchRate( + {required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount}) async { + try { + final chains = await _geSupportedChain(); + if (chains.isEmpty) return 0.0; + + final srcChain = _findChainByCurrency(from, chains); + final dstChain = _findChainByCurrency(to, chains); + + await _ensureTokensCached( + fromChain: srcChain, toChain: dstChain, from: from, to: to); + + final srcToken = _getTokenAddress(currency: from, chain: srcChain); + final dstToken = _getTokenAddress(currency: to, chain: dstChain); + + if (isReceiveAmount) { + final path = await _pickPath( + srcChainId: srcChain.chainId, + srcToken: srcToken, + dstChainId: dstChain.chainId, + dstToken: dstToken, + ); + if (path == null || !path.supportsExactOut) { + printV( + 'fetchRate: route does not support exact-amount-out for ${from.title} -> ${to.title}'); + return 0.0; + } + } + + final humanAmountStr = amount.toString(); + final formattedAmount = AmountConverter.toBaseUnits( + humanAmountStr, + isReceiveAmount ? to.decimals : from.decimals, + ); + + final params = { + 'swapDirection': + isReceiveAmount ? 'exact-amount-out' : 'exact-amount-in', + 'srcToken': srcToken, + 'dstToken': dstToken, + 'srcChainId': '${srcChain.chainId}', + 'dstChainId': '${dstChain.chainId}', + 'amount': formattedAmount, + }; + + final uri = Uri.https(_baseUrl, _getQuotePaths, params); + final response = + await ProxyWrapper().get(clearnetUri: uri, headers: _headers); + + if (response.statusCode != 200) { + printV('fetchRate failed: ${response.body}'); + return 0.0; + } + + final data = json.decode(response.body) as Map; + final exchangeRate = (data['exchangeRate'] as num?)?.toDouble() ?? 0.0; + return exchangeRate; + } catch (e) { + printV('fetchRate error: $e'); + return 0.0; + } + } + + @override + Future createTrade({ + required TradeRequest request, + required bool isFixedRateMode, + required bool isSendAll, + }) async { + try { + final sender = request.refundAddress.trim(); + final recipient = request.toAddress.trim(); + if (sender.isEmpty || recipient.isEmpty) { + throw Exception( + 'Sender (refundAddress) or recipient (toAddress) is empty'); + } + + final chains = await _geSupportedChain(); + if (chains.isEmpty) throw Exception('Failed to fetch supported chains'); + final srcChain = _findChainByCurrency(request.fromCurrency, chains); + final dstChain = _findChainByCurrency(request.toCurrency, chains); + + await _ensureTokensCached( + fromChain: srcChain, + toChain: dstChain, + from: request.fromCurrency, + to: request.toCurrency, + ); + + final srcToken = + _getTokenAddress(currency: request.fromCurrency, chain: srcChain); + final dstToken = + _getTokenAddress(currency: request.toCurrency, chain: dstChain); + + // Optional: ensure path supports exact-out before attempting fixed rate. + if (isFixedRateMode) { + final path = await _pickPath( + srcChainId: srcChain.chainId, + srcToken: srcToken, + dstChainId: dstChain.chainId, + dstToken: dstToken, + ); + if (path == null || !path.supportsExactOut) { + throw Exception( + 'This route does not support fixed receive (exact-amount-out)'); + } + } + + final amountStr = isFixedRateMode ? request.toAmount : request.fromAmount; + final rawAmount = double.tryParse(amountStr) ?? 0.0; + if (rawAmount <= 0) throw Exception('Invalid amount'); + + final formattedAmount = AmountConverter.toBaseUnits( + amountStr, + isFixedRateMode + ? request.toCurrency.decimals + : request.fromCurrency.decimals, + ); + + final params = { + 'actionType': 'swap-action', + 'sender': sender, + 'srcChainId': '${srcChain.chainId}', + 'srcToken': srcToken, + 'dstChainId': '${dstChain.chainId}', + 'dstToken': dstToken, + 'slippage': '100', + 'swapDirection': + isFixedRateMode ? 'exact-amount-out' : 'exact-amount-in', + 'amount': formattedAmount, + 'recipient': recipient, + }; + + final uri = Uri.https(_baseUrl, _getAction, params); + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers); + + if (res.statusCode != 200) { + throw Exception('getAction failed: ${res.statusCode} ${res.body}'); + } + + final data = json.decode(res.body) as Map; + + final txId = data['txId'] as String? ?? ''; + final vmId = data['vmId'] as String? ?? ''; + final txObj = (data['tx'] as Map?) ?? const {}; + + final txTo = txObj['to']?.toString(); + final chainId = txObj['chainId']?.toString(); + final routerData = txObj['data']?.toString(); + final txValue = txObj['value']?.toString() ?? '0'; + + final bridgeIds = (data['bridgeIds'] as List?) ?? const []; + if (txId.isEmpty) throw Exception('No txId returned by getAction'); + + final amtIn = (data['amountIn'] as Map?) ?? const {}; + final amtInMax = (data['amountInMax'] as Map?) ?? const {}; + final srcTokenAddr = amtIn['address']?.toString(); + final srcTokenDecs = + (amtIn['decimals'] as num?)?.toInt() ?? request.fromCurrency.decimals; + final requiresTokenApproval = + data['requiresTokenApproval'] as bool? ?? false; + + final reqAmountStr = + (amtInMax['amount'] ?? amtIn['amount'])?.toString() ?? '0'; + final reqAmountRaw = reqAmountStr.replaceAll('n', ''); + + final needToRegisterInSwapXyz = + vmId == 'alt-vm' || bridgeIds.contains('alt-vm'); + + final trade = Trade( + id: txId, + router: chainId, + providerId: vmId, + from: request.fromCurrency, + to: request.toCurrency, + provider: description, + inputAddress: txTo, + refundAddress: request.refundAddress, + state: TradeState.created, + providerName: title, + createdAt: DateTime.now(), + amount: request.fromAmount, + receiveAmount: request.toAmount, + payoutAddress: request.toAddress, + isSendAll: isSendAll, + needToRegisterInSwapXyz: needToRegisterInSwapXyz, + sourceTokenAddress: srcTokenAddr ?? srcToken, + sourceTokenDecimals: srcTokenDecs, + sourceTokenAmountRaw: reqAmountRaw, + requiresTokenApproval: requiresTokenApproval, + routerData: routerData, + routerValue: txValue, + userCurrencyFromRaw: + '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', + userCurrencyToRaw: + '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', + ); + + return trade; + } catch (e) { + printV('createTrade error: $e'); + throw TradeNotCreatedException(description, description: e.toString()); + } + } + + /// Register a broadcasted tx with Swaps.xyz (required for alt-vm). + Future registerAltVmTx({ + required String txId, + required String txHash, + required int chainId, + required String vmId, + }) async { + try { + final uri = Uri.https(_baseUrl, _registerTxs); + final payload = { + 'txId': txId, + 'vmId': vmId, + 'txHash': txHash, + 'chainId': chainId, + }; + + final res = await ProxyWrapper().post( + clearnetUri: uri, + headers: { + ..._headers, + 'content-type': 'application/json', + }, + body: jsonEncode(payload), + ); + + if (res.statusCode != 200) { + printV('registerTxs failed: ${res.statusCode} ${res.body}'); + return false; + } + final List body = json.decode(res.body) as List; + if (body.isEmpty) return false; + + final isSuccess = + (body[0] as Map)['success'] as bool? ?? false; + + return isSuccess; + } catch (e) { + printV('registerAltVmTx error: $e'); + return false; + } + } + + @override + Future findTradeById({required String id}) async { + if (id.isEmpty) { + throw Exception('Trade id is empty'); + } + + final uri = Uri.https(_baseUrl, _getStatus, {'txId': id}); + final resp = await ProxyWrapper().get(clearnetUri: uri, headers: _headers); + + if (resp.statusCode != 200) { + throw Exception('getStatus failed: ${resp.statusCode} ${resp.body}'); + } + + final data = json.decode(resp.body) as Map; + final isSuccess = (data['success'] as bool?); + + if (isSuccess != null && !isSuccess) { + final error = data['error'] as Map?; + if (error != null) { + final code = error['code']?.toString() ?? 'unknown'; + throw Exception( + 'SwapXyzExchangeProvider findTradeById error: ($id) $code'); + } + } + + final statusStr = (data['status'] as String?)?.toLowerCase() ?? 'NOT_FOUND'; + final state = _mapSwapsStatusToTradeState(statusStr); + + final refundAddress = data['sender']?.toString(); + + final srcTransaction = (data['srcTx'] as Map?)?.cast(); + final dstTransaction = (data['dstTx'] as Map?)?.cast(); + + final inputAddress = srcTransaction?['toAddress']?.toString(); + + final payoutAddress = dstTransaction?['toAddress']?.toString(); + + final srcPaymentToken = + (srcTransaction?['paymentToken'] as Map?)?.cast(); + final dstPaymentToken = + (dstTransaction?['paymentToken'] as Map?)?.cast(); + + final fromSymbol = (srcPaymentToken?['symbol'] as String?) ?? ''; + final toSymbol = (dstPaymentToken?['symbol'] as String?) ?? ''; + + CryptoCurrency? toCurrency; + if (toSymbol.isNotEmpty) { + toCurrency = CryptoCurrency.safeParseCurrencyFromString(toSymbol); + } + + final srcDecimals = (srcPaymentToken?['decimals'] as num?)?.toInt() ?? 0; + final dstDecimals = (dstPaymentToken?['decimals'] as num?)?.toInt() ?? 0; + + final txHash = srcTransaction?['txHash'] as String?; + + // Minimal-unit amounts like "12000n" + final srcAmountRaw = srcPaymentToken?['amount']?.toString(); + final dstAmountRaw = dstPaymentToken?['amount']?.toString(); + String? receiveAmount; + if (dstAmountRaw != null) { + final dstAmountMinimal = _stripN(dstAmountRaw); + receiveAmount = + AmountConverter.fromBaseUnits(dstAmountMinimal, dstDecimals); + } + + final srcAmountMinimal = _stripN(srcAmountRaw); + final amount = AmountConverter.fromBaseUnits(srcAmountMinimal, srcDecimals); + final fromCurrency = CryptoCurrency.safeParseCurrencyFromString(fromSymbol); + + // Timestamps can be num or "123n" handle both + final srcTs = _parseUnixSeconds(srcTransaction?['timestamp']); + final dstTs = _parseUnixSeconds(dstTransaction?['timestamp']); + final timestamp = srcTs ?? dstTs; + + final createdAt = timestamp != null + ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000, isUtc: true) + .toLocal() + : null; + + return Trade( + id: (data['txId'] as String?) ?? id, + from: fromCurrency, + to: toCurrency, + provider: description, + inputAddress: inputAddress, + payoutAddress: payoutAddress, + amount: amount, + receiveAmount: receiveAmount, + txId: txHash, + state: state, + createdAt: createdAt, + refundAddress: refundAddress, + userCurrencyFromRaw: '${fromSymbol.toUpperCase()}' + '_', + userCurrencyToRaw: '${toSymbol.toUpperCase()}' + '_', + ); + } + + TradeState _mapSwapsStatusToTradeState(String s) { + switch (s) { + case 'pending': + case 'processing': + return TradeState.pending; + case 'success': + case 'completed': + case 'complete': + return TradeState.finished; + case 'failed': + case 'cancelled': + case 'canceled': + case 'error': + return TradeState.failed; + default: + return TradeState.pending; + } + } + + // Load & cache supported chains once + Future> _geSupportedChain() async { + if (_supportedChainList.isNotEmpty) return _supportedChainList; + try { + final uri = Uri.https(_baseUrl, _getChainList); + final response = + await ProxyWrapper().get(clearnetUri: uri, headers: _headers); + if (response.statusCode != 200) return []; + + final data = json.decode(response.body) as List; + _supportedChainList + ..clear() + ..addAll(data.map((e) => Chain.fromJson(e as Map))); + return _supportedChainList; + } catch (e) { + printV(e); + return []; + } + } + + // Ensure tokens for this pair are cached (fills both src & dst chain entries) + // Replace your _ensureTokensCached with this one + Future _ensureTokensCached({ + required Chain fromChain, + required Chain toChain, + required CryptoCurrency from, + required CryptoCurrency to, + }) async { + bool needSrc = !_tokensCache.containsKey(fromChain.chainId) || + (_tokensCache[fromChain.chainId]?.isEmpty ?? true); + bool needDst = !_tokensCache.containsKey(toChain.chainId) || + (_tokensCache[toChain.chainId]?.isEmpty ?? true); + + if (!needSrc && !needDst) return; + + // First try: from -> to + await _fetchAndCacheTokens( + srcChainId: fromChain.chainId, + srcToken: from.title.toUpperCase(), + dstChainId: toChain.chainId, + dstToken: to.title.toUpperCase(), + ); + + // Re-check after first call + needSrc = !_tokensCache.containsKey(fromChain.chainId) || + (_tokensCache[fromChain.chainId]?.isEmpty ?? true); + needDst = !_tokensCache.containsKey(toChain.chainId) || + (_tokensCache[toChain.chainId]?.isEmpty ?? true); + + // If any still missing, try swapped: to -> from + if (needSrc || needDst) { + await _fetchAndCacheTokens( + srcChainId: toChain.chainId, + srcToken: to.title.toUpperCase(), + dstChainId: fromChain.chainId, + dstToken: from.title.toUpperCase(), + ); + } + } + + // call getPaths and merge tokens into cache keyed by the chainId + Future _fetchAndCacheTokens({ + required int srcChainId, + required String srcToken, + required int dstChainId, + required String dstToken, + }) async { + final params = { + 'srcChainId': '$srcChainId', + 'srcToken': srcToken, + 'dstChainId': '$dstChainId', + }; + + final uri = Uri.https(_baseUrl, _getPaths, params); + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers); + if (res.statusCode != 200) { + printV('getPaths failed: ${res.statusCode} ${res.body}'); + return; + } + + final body = json.decode(res.body) as Map; + final paths = (body['paths'] as List?) ?? const []; + if (paths.isEmpty) return; + + final path0 = paths.first as Map; + final pathChainId = (path0['chainId'] as num?)?.toInt(); + final tokens = (path0['tokens'] as List?) ?? const []; + + if (pathChainId == null || tokens.isEmpty) return; + + final parsed = tokens + .map((t) => TokenPathInfo.fromJson(t as Map)) + .toList(); + + _mergeCache(pathChainId, parsed); + } + +// Merge by symbol, prefer entries that have a non-empty address/decimals + void _mergeCache(int chainId, List incoming) { + final existing = _tokensCache[chainId] ?? const []; + final bySymbol = { + for (final t in existing) t.symbol: t + }; + + for (final t in incoming) { + final cur = bySymbol[t.symbol]; + if (cur == null) { + bySymbol[t.symbol] = t; + } else { + // If incoming has a real address/decimals, prefer it + final hasBetterAddr = (t.address != null && t.address!.isNotEmpty) && + (cur.address == null || cur.address!.isEmpty); + final hasBetterDec = (t.decimals != null) && (cur.decimals == null); + if (hasBetterAddr || hasBetterDec) { + bySymbol[t.symbol] = t; + } + } + } + + _tokensCache[chainId] = bySymbol.values.toList(); + } + + String _nativeSymbolForChain(Chain chain) { + final network = chain.name.toUpperCase(); + switch (network) { + case 'BTC': + case 'BITCOIN': + return 'BTC'; + case 'XMR': + case 'MONERO': + return 'XMR'; + case 'TRX': + case 'TRON': + return 'TRX'; + case 'ETH': + case 'ETHEREUM': + return 'ETH'; + case 'LTC': + case 'LITECOIN': + return 'LTC'; + case 'DOGE': + case 'DOGECOIN': + return 'DOGE'; + case 'XRP': + case 'RIPPLE': + return 'XRP'; + case 'XLM': + case 'STELLAR': + return 'XLM'; + case 'SOL': + case 'SOLANA': + return 'SOL'; + case 'CARDANO': + return 'ADA'; + case 'Bitcoin Cash': + return 'BCH'; + case 'POLYGON': + return 'POL'; + default: + return network; + } + } + + String _getTokenAddress({ + required CryptoCurrency currency, + required Chain chain, + }) { + final symbol = currency.title.toUpperCase(); + final list = _tokensCache[chain.chainId]; + + // Try cache hit + if (list != null && list.isNotEmpty) { + for (final t in list) { + if (t.symbol == symbol && t.address != null && t.address!.isNotEmpty) { + return t.address!; + } + } + } + + // If it's the native coin for this chain, return native placeholder + final nativeSym = _nativeSymbolForChain(chain); + if (nativeSym == symbol) { + return _nativePlaceholderForVm(chain.vmId); + } + + // May fail for non-native Alt-VM assets + return symbol; + } + + String _nativePlaceholderForVm(String vmId) { + switch (vmId.toLowerCase()) { + case 'evm': + return '0x0000000000000000000000000000000000000000'; + case 'alt-vm': + return '0x0000000000000000000000000000000000000000'; + default: + return '0x0000000000000000000000000000000000000000'; + } + } + + Map? findTokenBySymbol( + {required String title, required List tokens}) { + final reqSymbol = title.toUpperCase(); + for (final token in tokens) { + final map = token as Map; + final symbol = (map['symbol'] as String?)?.toUpperCase(); + if (symbol == reqSymbol) return map; + } + return null; + } + + Chain _findChainByCurrency(CryptoCurrency cur, List chains) { + final network = _normalizeCakeNetwork(cur.tag ?? cur.title); + return chains.firstWhere( + (c) => c.name.toUpperCase() == network, + orElse: () => throw Exception('Unsupported chain for ${cur.title}'), + ); + } + + String _normalizeCakeNetwork(String network) { + return switch (network.toUpperCase()) { + 'ETH' => 'ETHEREUM', + 'BSC' => 'BNB SMART CHAIN', + 'POL' => 'POLYGON', + 'AVAXC' => 'AVALANCHE', + 'TRX' => 'TRON', + 'SOL' => 'SOLANA', + 'CRO' => 'CRONOS', + 'ADA' => 'CARDANO', + 'KAS' => 'KASPA', + 'TON' => 'TONCOIN', + 'BCH' => 'BITCOIN CASH', + _ => network.toUpperCase(), + }; + } + + int? _parseUnixSeconds(dynamic value) { + if (value == null) return null; + if (value is num) return value.toInt(); + if (value is String) { + final clean = _stripN(value); + return int.tryParse(clean); + } + return null; + } + + String _stripN(String? str) { + final s = str ?? '0'; + return s.endsWith('n') ? s.substring(0, s.length - 1) : s; + } +} + +class TokenPathInfo { + final String symbol; + final String? address; + final int? decimals; + final String? minAmount; + final String? maxAmount; + + TokenPathInfo({ + required this.symbol, + required this.address, + required this.decimals, + required this.minAmount, + required this.maxAmount, + }); + + factory TokenPathInfo.fromJson(Map json) => TokenPathInfo( + symbol: (json['symbol'] as String?)?.toUpperCase() ?? '', + address: json['address'] as String?, + decimals: json['decimals'] as int?, + minAmount: json['minAmount']?.toString(), + maxAmount: json['maxAmount']?.toString(), + ); +} + +class Chain { + final int chainId; + final String name; + final String vmId; + + Chain({ + required this.chainId, + required this.name, + required this.vmId, + }); + + factory Chain.fromJson(Map json) { + return Chain( + chainId: json['chainId'] as int, + name: json['name'] as String, + vmId: json['vmId'] as String, + ); + } +} + +class _PathInfo { + final bool supportsExactOut; + final String minToAmountHuman; + + _PathInfo({required this.supportsExactOut, required this.minToAmountHuman}); +} diff --git a/lib/exchange/provider/swaptrade_exchange_provider.dart b/lib/exchange/provider/swaptrade_exchange_provider.dart index a7e50459a..539c026a8 100644 --- a/lib/exchange/provider/swaptrade_exchange_provider.dart +++ b/lib/exchange/provider/swaptrade_exchange_provider.dart @@ -105,7 +105,7 @@ class SwapTradeExchangeProvider extends ExchangeProvider { required CryptoCurrency to, required double amount, required bool isFixedRateMode, - required bool isReceiveAmount, + required bool isReceiveAmount }) async { try { if (amount == 0) return 0.0; diff --git a/lib/exchange/provider/thorchain_exchange.provider.dart b/lib/exchange/provider/thorchain_exchange.provider.dart index d3673eb2c..023b021ee 100644 --- a/lib/exchange/provider/thorchain_exchange.provider.dart +++ b/lib/exchange/provider/thorchain_exchange.provider.dart @@ -66,12 +66,13 @@ class ThorChainExchangeProvider extends ExchangeProvider { Future checkIsAvailable() async => true; @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { try { if (amount == 0) return 0.0; diff --git a/lib/exchange/provider/trocador_exchange_provider.dart b/lib/exchange/provider/trocador_exchange_provider.dart index b9ac09456..65dcfe0a7 100644 --- a/lib/exchange/provider/trocador_exchange_provider.dart +++ b/lib/exchange/provider/trocador_exchange_provider.dart @@ -120,12 +120,13 @@ class TrocadorExchangeProvider extends ExchangeProvider { } @override - Future fetchRate( - {required CryptoCurrency from, - required CryptoCurrency to, - required double amount, - required bool isFixedRateMode, - required bool isReceiveAmount}) async { + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount + }) async { try { if (amount == 0) return 0.0; diff --git a/lib/exchange/provider/xoswap_exchange_provider.dart b/lib/exchange/provider/xoswap_exchange_provider.dart index 6d2517140..b9d4aed13 100644 --- a/lib/exchange/provider/xoswap_exchange_provider.dart +++ b/lib/exchange/provider/xoswap_exchange_provider.dart @@ -142,12 +142,13 @@ class XOSwapExchangeProvider extends ExchangeProvider { } } + @override Future fetchRate({ required CryptoCurrency from, required CryptoCurrency to, required double amount, required bool isFixedRateMode, - required bool isReceiveAmount, + required bool isReceiveAmount }) async { try { final rates = await getRatesForPair(from: from, to: to); diff --git a/lib/exchange/trade.dart b/lib/exchange/trade.dart index b22a64f8a..68248ba65 100644 --- a/lib/exchange/trade.dart +++ b/lib/exchange/trade.dart @@ -33,6 +33,15 @@ class Trade extends HiveObject { this.router, this.userCurrencyFromRaw, this.userCurrencyToRaw, + // The following fields are used for SwapXyz trades only + this.needToRegisterInSwapXyz, + this.sourceTokenAddress, + this.sourceTokenDecimals, + this.routerData, + this.routerValue, + this.routerChainId, + this.sourceTokenAmountRaw, + this.requiresTokenApproval }) { if (provider != null) providerRaw = provider.raw; @@ -133,6 +142,31 @@ class Trade extends HiveObject { @HiveField(25, defaultValue: '') String? userCurrencyToRaw; + // The following fields are used for SwapXyz trades only + @HiveField(26) + bool? needToRegisterInSwapXyz; + + @HiveField(27) + String? sourceTokenAddress; + + @HiveField(28) + int? sourceTokenDecimals; + + @HiveField(29) + String? routerData; + + @HiveField(30) + String? routerValue; + + @HiveField(31) + int? routerChainId; + + @HiveField(32) + String? sourceTokenAmountRaw; + + @HiveField(33, defaultValue: false) + bool? requiresTokenApproval; + CryptoCurrency? get userCurrencyFrom { if (userCurrencyFromRaw == null || userCurrencyFromRaw!.isEmpty) { return null; @@ -249,6 +283,14 @@ class TradeAdapter extends TypeAdapter { router: fields[22] as String?, userCurrencyFromRaw: fields[24] as String?, userCurrencyToRaw: fields[25] as String?, + needToRegisterInSwapXyz: fields[26] as bool?, + sourceTokenAddress: fields[27] as String?, + sourceTokenDecimals: fields[28] as int?, + routerData: fields[29] as String?, + routerValue: fields[30] as String?, + routerChainId: fields[31] as int?, + sourceTokenAmountRaw: fields[32] as String?, + requiresTokenApproval: fields[33] as bool?, ) ..providerRaw = fields[1] == null ? 0 : fields[1] as int ..fromRaw = (fields[2] as int?) ?? -1 @@ -311,7 +353,23 @@ class TradeAdapter extends TypeAdapter { ..writeByte(24) ..write(obj.userCurrencyFromRaw) ..writeByte(25) - ..write(obj.userCurrencyToRaw); + ..write(obj.userCurrencyToRaw) + ..writeByte(26) + ..write(obj.needToRegisterInSwapXyz) + ..writeByte(27) + ..write(obj.sourceTokenAddress) + ..writeByte(28) + ..write(obj.sourceTokenDecimals) + ..writeByte(29) + ..write(obj.routerData) + ..writeByte(30) + ..write(obj.routerValue) + ..writeByte(31) + ..write(obj.routerChainId) + ..writeByte(32) + ..write(obj.sourceTokenAmountRaw) + ..writeByte(33) + ..write(obj.requiresTokenApproval); } @override diff --git a/lib/polygon/cw_polygon.dart b/lib/polygon/cw_polygon.dart index e04e81f42..e9bf9a965 100644 --- a/lib/polygon/cw_polygon.dart +++ b/lib/polygon/cw_polygon.dart @@ -183,6 +183,13 @@ class CWPolygon extends Polygon { @override String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress; + Future isApprovalRequired(WalletBase wallet, String tokenContract,String spender, BigInt requiredAmount) => + (wallet as PolygonWallet).isApprovalRequired(tokenContract, spender, requiredAmount); + + Future createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, + TransactionPriority priority) => + (wallet as EVMChainWallet).createCallDataTransaction(to, dataHex, valueWei, priority as EVMChainTransactionPriority); + @override Future createTokenApproval( WalletBase wallet, diff --git a/lib/src/screens/exchange_trade/exchange_confirm_page.dart b/lib/src/screens/exchange_trade/exchange_confirm_page.dart index 948dac49d..dbb99fc12 100644 --- a/lib/src/screens/exchange_trade/exchange_confirm_page.dart +++ b/lib/src/screens/exchange_trade/exchange_confirm_page.dart @@ -65,15 +65,16 @@ class ExchangeConfirmPage extends BasePage { fontWeight: FontWeight.w500, ), ), - Text( - trade.id, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), + Expanded( + child: Text( + trade.id, + overflow: TextOverflow.visible, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurface, + ), + ), ), ], ), diff --git a/lib/src/screens/exchange_trade/exchange_trade_page.dart b/lib/src/screens/exchange_trade/exchange_trade_page.dart index 840fbcf01..67f05c74b 100644 --- a/lib/src/screens/exchange_trade/exchange_trade_page.dart +++ b/lib/src/screens/exchange_trade/exchange_trade_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/desktop_exchange_cards_section.dart'; @@ -173,6 +174,7 @@ class ExchangeTradeState extends State { textColor: widget.exchangeTradeViewModel.isSendable ? Theme.of(context).colorScheme.onSecondaryContainer : Theme.of(context).colorScheme.onPrimary, + isDisabled: widget.exchangeTradeViewModel.isSwapsXyzSendingEVMTokenSwap, ), SizedBox(height: 16), Observer( @@ -265,6 +267,14 @@ class ExchangeTradeState extends State { if (state is ExecutedSuccessfullyState) { WidgetsBinding.instance.addPostFrameCallback((_) async { + final trade = widget.exchangeTradeViewModel.trade; + final isSwapsXyz = trade.provider == ExchangeProviderDescription.swapsXyz; + final isEVMWallet = widget.exchangeTradeViewModel.sendViewModel.isEVMWallet; + + final amountValue = isSwapsXyz && isEVMWallet + ? trade.amount + : widget.exchangeTradeViewModel.sendViewModel.pendingTransaction!.amountFormatted; + if (context.mounted) { final result = await showModalBottomSheet( context: context, @@ -280,8 +290,7 @@ class ExchangeTradeState extends State { widget.exchangeTradeViewModel.sendViewModel.selectedCryptoCurrency.iconPath, currency: widget.exchangeTradeViewModel.sendViewModel.selectedCryptoCurrency, amount: S.of(bottomSheetContext).send_amount, - amountValue: widget - .exchangeTradeViewModel.sendViewModel.pendingTransaction!.amountFormatted, + amountValue: amountValue, fiatAmountValue: widget .exchangeTradeViewModel.sendViewModel.pendingTransactionFiatAmountFormatted, fee: @@ -298,6 +307,7 @@ class ExchangeTradeState extends State { Navigator.of(bottomSheetContext).pop(true); } widget.exchangeTradeViewModel.sendViewModel.commitTransaction(context); + widget.exchangeTradeViewModel.registerSwapsXyzTransaction(); }, ); }, diff --git a/lib/store/dashboard/trade_filter_store.dart b/lib/store/dashboard/trade_filter_store.dart index e3212fd11..16be01b62 100644 --- a/lib/store/dashboard/trade_filter_store.dart +++ b/lib/store/dashboard/trade_filter_store.dart @@ -21,7 +21,8 @@ abstract class TradeFilterStoreBase with Store { displayLetsExchange = true, displayStealthEx = true, displayXOSwap = true, - displaySwapTrade = true; + displaySwapTrade = true, + displaySwapXyz = true; @observable bool displayXMRTO; @@ -62,6 +63,9 @@ abstract class TradeFilterStoreBase with Store { @observable bool displaySwapTrade; + @observable + bool displaySwapXyz; + @computed bool get displayAllTrades => displayChangeNow && @@ -74,7 +78,8 @@ abstract class TradeFilterStoreBase with Store { displayLetsExchange && displayStealthEx && displayXOSwap && - displaySwapTrade; + displaySwapTrade && + displaySwapXyz; @action void toggleDisplayExchange(ExchangeProviderDescription provider) { @@ -118,6 +123,9 @@ abstract class TradeFilterStoreBase with Store { case ExchangeProviderDescription.swapTrade: displaySwapTrade = !displaySwapTrade; break; + case ExchangeProviderDescription.swapsXyz: + displaySwapXyz = !displaySwapXyz; + break; case ExchangeProviderDescription.all: if (displayAllTrades) { displayChangeNow = false; @@ -133,6 +141,7 @@ abstract class TradeFilterStoreBase with Store { displayStealthEx = false; displayXOSwap = false; displaySwapTrade = false; + displaySwapXyz = false; } else { displayChangeNow = true; displaySideShift = true; @@ -147,6 +156,7 @@ abstract class TradeFilterStoreBase with Store { displayStealthEx = true; displayXOSwap = true; displaySwapTrade = true; + displaySwapXyz = true; } break; } @@ -180,7 +190,9 @@ abstract class TradeFilterStoreBase with Store { item.trade.provider == ExchangeProviderDescription.letsExchange) || (displayStealthEx && item.trade.provider == ExchangeProviderDescription.stealthEx) || (displayXOSwap && item.trade.provider == ExchangeProviderDescription.xoSwap) || - (displaySwapTrade && item.trade.provider == ExchangeProviderDescription.swapTrade)) + (displaySwapTrade && item.trade.provider == ExchangeProviderDescription.swapTrade) || + (displaySwapXyz && + item.trade.provider == ExchangeProviderDescription.swapsXyz)) .toList() : _trades; } diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 265a48308..fc4ef7af3 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -181,6 +181,11 @@ abstract class DashboardViewModelBase with Store { caption: ExchangeProviderDescription.swapTrade.title, onChanged: () => tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)), + FilterItem( + value: () => tradeFilterStore.displaySwapXyz, + caption: ExchangeProviderDescription.swapsXyz.title, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)), ] }, subname = '', diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index 635bf5c65..8fdf029b5 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -8,6 +8,7 @@ import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart'; @@ -80,6 +81,10 @@ abstract class ExchangeTradeViewModelBase with Store { break; case ExchangeProviderDescription.xoSwap: _provider = XOSwapExchangeProvider(); + break; + case ExchangeProviderDescription.swapsXyz: + _provider = SwapsXyzExchangeProvider(); + break; } _updateItems(); @@ -104,6 +109,15 @@ abstract class ExchangeTradeViewModelBase with Store { @observable bool isSendable; + + bool get isSwapsXyzSendingEVMTokenSwap => (_provider is SwapsXyzExchangeProvider) && + (wallet.type == WalletType.ethereum && + wallet.currency != trade.from || + (wallet.type == WalletType.polygon && + wallet.currency != trade.from) || + (wallet.type == WalletType.base && + wallet.currency != trade.from)); + String get extraInfo => trade.extraId != null && trade.extraId!.isNotEmpty ? '\n\n' + S.current.exchange_extra_info : ''; @@ -189,7 +203,19 @@ abstract class ExchangeTradeViewModelBase with Store { sendViewModel.selectedCryptoCurrency = selected; - final pendingTransaction = await sendViewModel.createTransaction(provider: _provider); + final pendingTransaction = await sendViewModel.createTransaction(provider: _provider, trade: trade); + + if (_provider is SwapsXyzExchangeProvider) { + final hash = pendingTransaction?.evmTxHashFromRawHex ?? pendingTransaction?.id ?? ''; + trade.txId = hash; + + if (trade.isInBox) { + await trade.save(); + } else { + await trades.add(trade); + } + } + if (_provider is ThorChainExchangeProvider) { trade.id = pendingTransaction?.id ?? ''; trades.add(trade); @@ -308,7 +334,7 @@ abstract class ExchangeTradeViewModelBase with Store { tradeFrom?.tag == CryptoCurrency.maticpoly.tag; bool _isBaseToken() => - wallet.currency == CryptoCurrency.baseEth && tradeFrom?.tag == CryptoCurrency.baseEth.title; + wallet.currency == CryptoCurrency.baseEth && tradeFrom?.tag == CryptoCurrency.baseEth.tag; bool _isTronToken() => wallet.currency == CryptoCurrency.trx && tradeFrom?.tag == CryptoCurrency.trx.title; @@ -324,6 +350,53 @@ abstract class ExchangeTradeViewModelBase with Store { _isTronToken() || _isBaseToken(); } + + Future registerSwapsXyzTransaction() async { + try { + if (!(_provider is SwapsXyzExchangeProvider)) return; + final swaps = _provider as SwapsXyzExchangeProvider; + + // register only for vmId is alt-vm or bridgeId is alt-vm (trade.needToRegisterInSwapXyz) + final needToRegister = trade.needToRegisterInSwapXyz ?? false; + if (!needToRegister) return; + + final vmId = (trade.providerId ?? '').toLowerCase(); + if (vmId.isEmpty) { + printV('SwapsXyz: transaction register: skipped (vmId empty)'); + return; + } + + final txHash = sendViewModel.pendingTransaction?.evmTxHashFromRawHex ?? sendViewModel.pendingTransaction?.id ?? ''; + + if (txHash.isEmpty) { + printV('SwapsXyz: transaction register: skipped (txHash empty)'); + return; + } + + final chainId = int.tryParse(trade.router ?? '') ?? 0; + if (chainId <= 0) { + printV('SwapsXyz: transaction register: skipped (invalid chainId)'); + return; + } + + printV('SwapsXyz: attempting to register transaction: tradeId = ${trade.id}, txHash = $txHash, chainId = $chainId, vmId = $vmId'); + + final registered = await swaps.registerAltVmTx( + txId: trade.id, + txHash: txHash, + chainId: chainId, + vmId: vmId, + ); + + if (!registered) { + printV('SwapsXyz: transaction register: failed'); + } else { + printV('SwapsXyz: transaction register: success'); + } + } catch (e) { + printV('registerSwapsXyzTransaction error: $e'); + } + } PaymentURI? get paymentUri { final inputAddress = trade.inputAddress; diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index 4ee51176f..0642362c1 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -27,6 +27,7 @@ import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart'; @@ -214,6 +215,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with LetsExchangeExchangeProvider(), StealthExExchangeProvider(), XOSwapExchangeProvider(), + SwapsXyzExchangeProvider(), TrocadorExchangeProvider( useTorOnly: _useTorOnly, providerStates: _settingsStore.trocadorProviderStates), ]; @@ -536,7 +538,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with to: receiveCurrency, amount: amount, isFixedRateMode: isFixedRateMode, - isReceiveAmount: isFixedRateMode, + isReceiveAmount: isFixedRateMode ) .timeout( Duration(seconds: 7), diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 55bfabb4c..4ba87f97c 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -22,7 +22,9 @@ import 'package:cake_wallet/entities/transaction_description.dart'; import 'package:cake_wallet/entities/wallet_contact.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; +import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/nano/nano.dart'; @@ -46,6 +48,7 @@ import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_mod import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cake_wallet/zano/zano.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/currency_for_wallet_type.dart'; import 'package:cw_core/exceptions.dart'; import 'package:cw_core/pending_transaction.dart'; @@ -119,6 +122,9 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor }); } + PendingTransaction? _pendingApprovalTx; + bool _isSwapsXYZCallDataTx = false; + @observable ExecutionState state; @@ -133,6 +139,9 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor bool get isMwebEnabled => balanceViewModel.mwebEnabled; + bool get isEVMWallet => walletType == WalletType.ethereum || walletType == WalletType.polygon || + walletType == WalletType.base; + @action void setShowAddressBookPopup(bool value) { _settingsStore.showAddressBookPopupEnabled = value; @@ -490,7 +499,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor Timer? _ledgerTxStateTimer; @action - Future createTransaction({ExchangeProvider? provider}) async { + Future createTransaction({ExchangeProvider? provider, Trade? trade}) async { try { if (!(state is IsExecutingState)) state = IsExecutingState(); @@ -505,6 +514,151 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor }); } + + // Swaps.xyz (EVM) path + + if (isEVMWallet && trade != null && provider is SwapsXyzExchangeProvider) { + final routerTo = trade.inputAddress; + final routerData = trade.routerData; + final routerValueWei = + BigInt.tryParse((trade.routerValue ?? '0').toString()) ?? BigInt.zero; + + if (routerTo?.isNotEmpty == true && routerData?.isNotEmpty == true) { + + // detect prepared ERC-20 transfer(...) (alt-vm deposit pattern) + String _selector(String s) => + (s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : ''; + const _transferSig = '0xa9059cbb'; + final _sel = _selector(routerData!); + final _isPreparedTransfer = _sel == _transferSig && + (trade.sourceTokenAddress ?? '').toLowerCase() == (routerTo ?? '').toLowerCase(); + + _pendingApprovalTx = null; + + // Optionally prebuild approval (SKIP for prepared transfer) + final tokenContract = trade.sourceTokenAddress ?? ''; + final requiredAmount = BigInt.tryParse( + (trade.sourceTokenAmountRaw ?? '0').replaceAll('n', ''), + ) ?? BigInt.zero; + + // Only do approval when NOT a prepared transfer, and only if the API hinted we might need it + final requiresTokenApproval = (trade.requiresTokenApproval ?? false) && !_isPreparedTransfer; + + if (requiresTokenApproval && tokenContract.isNotEmpty && requiredAmount > BigInt.zero) { + if (walletType == WalletType.ethereum) { + final priority = _settingsStore.priority[WalletType.ethereum]!; + _pendingApprovalTx = await buildApprovalIfNeeded( + spender: routerTo!, // if API provides a specific spender, use that instead + tokenContract: tokenContract, + requiredAmount: requiredAmount, + sourceTokenDecimals: trade.sourceTokenDecimals, + ); + + // Build the callData tx + pendingTransaction = await ethereum!.createRawCallDataTransaction( + wallet, + routerTo, + routerData, + routerValueWei, + priority, + ); + + _isSwapsXYZCallDataTx = true; + state = ExecutedSuccessfullyState(); + return pendingTransaction; // do NOT fall back to regular flow + } + if (walletType == WalletType.polygon) { + final priority = _settingsStore.priority[WalletType.polygon]!; + _pendingApprovalTx = await buildApprovalIfNeeded( + spender: routerTo!, + tokenContract: tokenContract, + requiredAmount: requiredAmount, + sourceTokenDecimals: trade.sourceTokenDecimals, + ); + + // Build the callData tx + pendingTransaction = await polygon!.createRawCallDataTransaction( + wallet, + routerTo, + routerData, + routerValueWei, + priority, + ); + + _isSwapsXYZCallDataTx = true; + state = ExecutedSuccessfullyState(); + return pendingTransaction; // do NOT fall back to regular flow + } + if (walletType == WalletType.base) { + final priority = _settingsStore.priority[WalletType.base]!; + _pendingApprovalTx = await buildApprovalIfNeeded( + spender: routerTo!, + tokenContract: tokenContract, + requiredAmount: requiredAmount, + sourceTokenDecimals: trade.sourceTokenDecimals, + ); + + // Build the callData tx + pendingTransaction = await base!.createRawCallDataTransaction( + wallet, + routerTo, + routerData, + routerValueWei, + priority, + ); + + _isSwapsXYZCallDataTx = true; + state = ExecutedSuccessfullyState(); + return pendingTransaction; // do NOT fall back to regular flow + } + } + + // No approval needed (or prepared transfer): send exactly what backend prepared + if (walletType == WalletType.ethereum) { + final priority = _settingsStore.priority[WalletType.ethereum]!; + pendingTransaction = await ethereum!.createRawCallDataTransaction( + wallet, + routerTo!, + routerData, + routerValueWei, + priority, + ); + _isSwapsXYZCallDataTx = true; + state = ExecutedSuccessfullyState(); + return pendingTransaction; + } + if (walletType == WalletType.polygon) { + final priority = _settingsStore.priority[WalletType.polygon]!; + pendingTransaction = await polygon!.createRawCallDataTransaction( + wallet, + routerTo!, + routerData, + routerValueWei, + priority, + ); + _isSwapsXYZCallDataTx = true; + state = ExecutedSuccessfullyState(); + return pendingTransaction; + } + if (walletType == WalletType.base) { + final priority = _settingsStore.priority[WalletType.polygon]!; + pendingTransaction = await base!.createRawCallDataTransaction( + wallet, + routerTo!, + routerData, + routerValueWei, + priority, + ); + _isSwapsXYZCallDataTx = true; + state = ExecutedSuccessfullyState(); + return pendingTransaction; + } + } + } + + + // Regular flow + pendingTransaction = await wallet.createTransaction(_credentials(provider)); if (provider is ThorChainExchangeProvider) { @@ -602,6 +756,37 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor return; } + // Swaps.xyz approval (if any), then commit the prebuilt router tx + if (_isSwapsXYZCallDataTx) { + if (_pendingApprovalTx != null) { + await _pendingApprovalTx!.commit(); + _pendingApprovalTx = null; + // Small pause to ensure allowance is indexed + await Future.delayed(const Duration(milliseconds: 300)); + } + + await pendingTransaction!.commit(); + _isSwapsXYZCallDataTx = false; + + state = TransactionCommitted(); + return; // skip the regular flow below + } + + // Regular flow (non-Swaps) + if (pendingTransaction!.shouldCommitUR()) { + final urstr = await pendingTransaction!.commitUR(); + final result = await Navigator.of(context).pushNamed( + Routes.urqrAnimatedPage, + arguments: urstr, + ); + if (result == null) { + state = FailureState("Canceled by user"); + return; + } + } else { + await pendingTransaction!.commit(); + } + String address = outputs.fold('', (acc, value) { return value.isParsedAddress ? '$acc${value.address}\n${value.extractedAddress}\n\n' @@ -933,6 +1118,71 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor return false; } + Future buildApprovalIfNeeded({ + required String spender, + required String tokenContract, + required BigInt requiredAmount, + int? sourceTokenDecimals, + }) async { + + // Only EVM chains support ERC20 approvals + if (!isEVMWallet) return null; + + const zero = '0x0000000000000000000000000000000000000000'; + const evmNative = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; + + final tokenLc = tokenContract.toLowerCase(); + if (tokenLc == zero || tokenLc == evmNative.toLowerCase()) return null; + if (requiredAmount <= BigInt.zero) return null; + + bool needsApproval = false; + if (walletType == WalletType.ethereum) { + needsApproval = await ethereum!.isApprovalRequired( + wallet, tokenContract, spender, requiredAmount, + ); + } else if (walletType == WalletType.polygon) { + needsApproval = await polygon!.isApprovalRequired( + wallet, tokenContract, spender, requiredAmount, + ); + } else if (walletType == WalletType.base) { + needsApproval = await base!.isApprovalRequired( + wallet, tokenContract, spender, requiredAmount, + ); + } + + if (!needsApproval) return null; + + final erc20Token = wallet.balance.keys.whereType().firstWhere( + (t) => t.contractAddress.toLowerCase() == tokenLc, + orElse: () => Erc20Token( + name: '', + symbol: '', + contractAddress: tokenContract, + decimal: sourceTokenDecimals ?? 18, + enabled: true, + ), + ); + + if (walletType == WalletType.ethereum) { + final priority = _settingsStore.priority[WalletType.ethereum]!; + return await ethereum!.createTokenApproval( + wallet, requiredAmount, spender, erc20Token, priority, + ); + } else if (walletType == WalletType.polygon) { + final priority = _settingsStore.priority[WalletType.polygon]!; + return await polygon!.createTokenApproval( + wallet, requiredAmount, spender, erc20Token, priority, + ); + } else if (walletType == WalletType.base) { + final priority = _settingsStore.priority[WalletType.base]!; + return await base!.createTokenApproval( + wallet, requiredAmount, spender, erc20Token, priority, + ); + } + + return null; + } + @computed bool get usePayjoin => _settingsStore.usePayjoin; diff --git a/lib/view_model/trade_details_view_model.dart b/lib/view_model/trade_details_view_model.dart index 64c05c19b..3b42b1232 100644 --- a/lib/view_model/trade_details_view_model.dart +++ b/lib/view_model/trade_details_view_model.dart @@ -6,6 +6,7 @@ import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart'; @@ -77,6 +78,9 @@ abstract class TradeDetailsViewModelBase with Store { case ExchangeProviderDescription.xoSwap: _provider = XOSwapExchangeProvider(); break; + case ExchangeProviderDescription.swapsXyz: + _provider = SwapsXyzExchangeProvider(); + break; } _updateItems(); @@ -134,8 +138,13 @@ abstract class TradeDetailsViewModelBase with Store { try { final updatedTrade = await _provider!.findTradeById(id: trade.id); - if (updatedTrade.createdAt == null && trade.createdAt != null) + if (updatedTrade.createdAt == null && trade.createdAt != null) { updatedTrade.createdAt = trade.createdAt; + } + + if (updatedTrade.toRaw == -1 && trade.toRaw != -1) { + updatedTrade.toRaw = trade.toRaw; + } Trade? foundElement = trades.values.firstWhereOrNull((element) => element.id == trade.id); if (foundElement != null) { @@ -205,6 +214,11 @@ abstract class TradeDetailsViewModelBase with Store { title: '${trade.providerName} ${S.current.password}', value: trade.password ?? '')); } } + + if (trade.provider == ExchangeProviderDescription.swapsXyz && trade.txId != null && trade.txId!.isNotEmpty) { + items.add(StandartListItem( + title: 'Transaction ID', value: trade.txId!)); + } } void _launchUrl(String url) { diff --git a/tool/configure.dart b/tool/configure.dart index 50930c591..09c37e12d 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -776,7 +776,9 @@ abstract class Ethereum { Web3Client? getWeb3Client(WalletBase wallet); String getTokenAddress(CryptoCurrency asset); + Future isApprovalRequired(WalletBase wallet, String tokenContract, String spender, BigInt requiredAmount); Future createTokenApproval(WalletBase wallet, BigInt amount, String spender, CryptoCurrency token, TransactionPriority priority); + Future createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, TransactionPriority priority); Future getDEuroSavingsBalance(WalletBase wallet); Future getDEuroAccruedInterest(WalletBase wallet); @@ -899,7 +901,9 @@ abstract class Polygon { Future removeTokenTransactionsInHistory(WalletBase wallet, CryptoCurrency token); Future getErc20Token(WalletBase wallet, String contractAddress); + Future isApprovalRequired(WalletBase wallet, String tokenContract, String spender, BigInt requiredAmount); Future createTokenApproval(WalletBase wallet, BigInt amount, String spender, CryptoCurrency token, TransactionPriority priority); + Future createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, TransactionPriority priority); CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction); void updatePolygonScanUsageState(WalletBase wallet, bool isEnabled); @@ -1595,8 +1599,10 @@ abstract class Base { Future removeTokenTransactionsInHistory(WalletBase wallet, CryptoCurrency token); Future getErc20Token(WalletBase wallet, String contractAddress); + Future isApprovalRequired(WalletBase wallet, String tokenContract, String spender, BigInt requiredAmount); Future createTokenApproval(WalletBase wallet, BigInt amount, String spender, CryptoCurrency token, TransactionPriority priority); + Future createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, TransactionPriority priority); CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction); void updateBaseScanUsageState(WalletBase wallet, bool isEnabled); diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index 6a009ff36..f6e501877 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -82,6 +82,7 @@ class SecretKey { SecretKey('chainflipAffiliateFee', () => ''), SecretKey('kryptonimApiKey', () => ''), SecretKey('walletGroupSalt', () => hex.encode(encrypt.Key.fromSecureRandom(16).bytes)), + SecretKey('swapsXyzApiKey', () => ''), ]; static final evmChainsSecrets = [