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 <omarh.ismail1@gmail.com> * add missing support for Base --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

Serhii committed Oct 21, 2025 at 00:22 UTC ee0996f535ba31fc07850db2a6a4090739ba1490
37 files changed +1557 -77
.github/workflows/automated_integration_test.yml
+1
@@ -190,6 +190,7 @@ jobs:
190 echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart
191 echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart
192 echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart
193 + echo "const swapsXyzApiKey = '${{ secrets.SWAPSXYZ_API_KEY }}';" >> lib/.secrets.g.dart
194
195 - name: prepare monero_c and cache
196 run: |
.github/workflows/pr_test_build_android.yml
+1
@@ -180,6 +180,7 @@ jobs:
180 echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart
181 echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart
182 echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart
183 + echo "const swapsXyzApiKey = '${{ secrets.SWAPSXYZ_API_KEY }}';" >> lib/.secrets.g.dart
184
185 - name: Generate KeyStore
186 run: |
.github/workflows/pr_test_build_linux.yml
+1
@@ -176,6 +176,7 @@ jobs:
176 echo "const chainflipAffiliateFee = '${{ secrets.CHAINFLIP_AFFILIATE_FEE }}';" >> lib/.secrets.g.dart
177 echo "const kryptonimApiKey = '${{ secrets.KRYPTONIM_API_KEY }}';" >> lib/.secrets.g.dart
178 echo "const walletGroupSalt = '${{ secrets.WALLET_GROUP_SALT }}';" >> lib/.secrets.g.dart
179 + echo "const swapsXyzApiKey = '${{ secrets.SWAPSXYZ_API_KEY }}';" >> lib/.secrets.g.dart
180
181 - name: Fetch prebuilt Torch
182 run: |
assets/images/swaps_xyz.svg new
+42
@@ -0,0 +1,42 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.468 0.614 498.759 500.158" width="500
3 +px" height="500px">
4 + <defs>
5 + <linearGradient id="paint0_linear_306_24" x1="66.49" y1="0.0200195" x2="66.49" y2="133" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.875312, 0, 0, 1.880575, 0.468736, 0.576712)">
6 + <stop stop-color="#D5A2FF"/>
7 + <stop offset="0.5" stop-color="#4B68B3"/>
8 + <stop offset="1" stop-color="#30001A"/>
9 + </linearGradient>
10 + <linearGradient id="paint1_linear_306_24" x1="199.47" y1="0.0200195" x2="199.47" y2="126.85" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.875312, 0, 0, 1.880575, 0.468736, 0.576712)">
11 + <stop stop-color="#D5A2FF"/>
12 + <stop offset="0.5" stop-color="#4B68B3"/>
13 + <stop offset="1" stop-color="#30001A"/>
14 + </linearGradient>
15 + <linearGradient id="paint2_linear_306_24" x1="199.47" y1="133" x2="199.47" y2="265.98" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.875312, 0, 0, 1.880575, 0.468736, 0.576712)">
16 + <stop stop-color="#30001A"/>
17 + <stop offset="0.5" stop-color="#4B68B3"/>
18 + <stop offset="1" stop-color="#D5A2FF"/>
19 + </linearGradient>
20 + <linearGradient id="paint3_linear_306_24" x1="66.49" y1="139.15" x2="66.49" y2="265.98" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.875312, 0, 0, 1.880575, 0.468736, 0.576712)">
21 + <stop stop-color="#30001A"/>
22 + <stop offset="0.5" stop-color="#4B68B3"/>
23 + <stop offset="1" stop-color="#D5A2FF"/>
24 + </linearGradient>
25 + <linearGradient id="paint4_linear_306_24" x1="196.7" y1="85.6736" x2="133.028" y2="133.067" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.875312, 0, 0, 1.880575, 0.468736, 0.576712)">
26 + <stop stop-color="#D5A2FF"/>
27 + <stop offset="0.5" stop-color="#4B68B3"/>
28 + <stop offset="1" stop-color="#30001A"/>
29 + </linearGradient>
30 + <linearGradient id="paint5_linear_306_24" x1="69.2504" y1="180.326" x2="132.922" y2="132.933" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.875312, 0, 0, 1.880575, 0.468736, 0.576712)">
31 + <stop stop-color="#D5A2FF"/>
32 + <stop offset="0.5" stop-color="#4B68B3"/>
33 + <stop offset="1" stop-color="#30001A"/>
34 + </linearGradient>
35 + </defs>
36 + <path d="M 249.847 0.614 L 249.847 250.694 L 125.158 250.694 C 90.728 250.694 59.559 236.683 36.981 214.078 C 14.421 191.454 0.468 160.181 0.468 125.652 C 0.468 91.127 14.421 59.852 36.981 37.23 C 59.559 14.625 90.728 0.614 125.158 0.614 L 249.847 0.614 Z" fill="url(#paint0_linear_306_24)" style="stroke-width: 1;" transform="matrix(1, 0, 0, 1, 0, 1.4210854715202004e-14)"/>
37 + <path d="M 499.227 125.652 C 499.227 175.94 469.634 219.287 426.952 239.127 L 249.847 0.614 L 374.537 0.614 C 408.967 0.614 440.135 14.625 462.713 37.23 C 485.293 59.852 499.227 91.108 499.227 125.652 Z" fill="url(#paint1_linear_306_24)" style="stroke-width: 1;" transform="matrix(1, 0, 0, 1, 0, 1.4210854715202004e-14)"/>
38 + <path d="M 499.227 375.732 C 499.227 444.786 443.398 500.772 374.537 500.772 L 249.847 500.772 L 249.847 250.694 L 374.537 250.694 C 408.967 250.694 440.135 264.702 462.713 287.308 C 485.293 309.931 499.227 341.187 499.227 375.732 Z" fill="url(#paint2_linear_306_24)" style="stroke-width: 1;" transform="matrix(1, 0, 0, 1, 0, 1.4210854715202004e-14)"/>
39 + <path d="M 249.847 500.772 L 125.158 500.772 C 90.728 500.772 59.559 486.761 36.981 464.138 C 14.421 441.534 0.468 410.259 0.468 375.732 C 0.468 325.446 30.061 282.099 72.744 262.259 L 249.829 500.772 L 249.847 500.772 Z" fill="url(#paint3_linear_306_24)" style="stroke-width: 1;" transform="matrix(1, 0, 0, 1, 0, 1.4210854715202004e-14)"/>
40 + <path d="M 462.695 287.308 C 440.117 264.684 408.949 250.694 374.518 250.694 L 249.829 250.694 L 249.829 0.614 L 426.914 239.127 L 462.676 287.308 L 462.695 287.308 Z" fill="url(#paint4_linear_306_24)" style="stroke-width: 1;" transform="matrix(1, 0, 0, 1, 0, 1.4210854715202004e-14)"/>
41 + <path d="M 36.981 214.078 C 59.559 236.701 90.728 250.694 125.158 250.694 L 249.847 250.694 L 249.847 500.772 L 72.762 262.259 L 36.999 214.078 L 36.981 214.078 Z" fill="url(#paint5_linear_306_24)" style="stroke-width: 1;" transform="matrix(1, 0, 0, 1, 0, 1.4210854715202004e-14)"/>
42 +</svg>
\ No newline at end of file
cw_core/lib/amount_converter.dart
+50
@@ -71,4 +71,54 @@ class AmountConverter {
71
72 static String _moneroAmountToStringUsingDecimals(int amount) => _moneroAmountFormat.format(
73 DecimalIntl(cryptoAmountToDecimal(amount: amount, divider: _moneroAmountDivider)));
74 +
75 +
76 + static String toBaseUnits(String amount, int decimals) {
77 + amount = amount.trim();
78 + if (amount.isEmpty) return '0';
79 +
80 + final neg = amount.startsWith('-');
81 + if (neg) amount = amount.substring(1);
82 +
83 + amount = amount.replaceAll(',', '');
84 + final parts = amount.split('.');
85 + final whole = parts[0].isEmpty ? '0' : parts[0];
86 + final frac = parts.length > 1 ? parts[1] : '';
87 +
88 + final fracPadded = (frac + '0' * decimals).substring(0, decimals);
89 +
90 + final pow10 = BigInt.from(10).pow(decimals);
91 + final wholeBI = BigInt.parse(whole);
92 + final fracBI = fracPadded.isEmpty ? BigInt.zero : BigInt.parse(fracPadded);
93 +
94 + final res = wholeBI * pow10 + fracBI;
95 + return neg ? '-${res.toString()}' : res.toString();
96 + }
97 +
98 +
99 + static String fromBaseUnits(String units, int decimals) {
100 + units = units.trim();
101 + if (units.isEmpty) return '0';
102 +
103 + final neg = units.startsWith('-');
104 + if (neg) units = units.substring(1);
105 +
106 + if (decimals == 0) return neg ? '-$units' : units;
107 +
108 + // pad if shorter than decimals
109 + if (units.length <= decimals) {
110 + final s = units.padLeft(decimals + 1, '0'); // ensures at least "0xxxx"
111 + final intPart = s.substring(0, s.length - decimals);
112 + var fracPart = s.substring(s.length - decimals);
113 + fracPart = fracPart.replaceFirst(RegExp(r'0+$'), '');
114 + final out = fracPart.isEmpty ? intPart : '$intPart.$fracPart';
115 + return neg ? '-$out' : out;
116 + }
117 +
118 + final intPart = units.substring(0, units.length - decimals);
119 + var fracPart = units.substring(units.length - decimals);
120 + fracPart = fracPart.replaceFirst(RegExp(r'0+$'), '');
121 + final out = fracPart.isEmpty ? intPart : '$intPart.$fracPart';
122 + return neg ? '-$out' : out;
123 + }
124 }
cw_core/lib/pending_transaction.dart
+1
@@ -12,6 +12,7 @@ mixin PendingTransaction {
12 String get feeFormattedValue;
13 String? feeRate;
14 String get hex;
15 + String? get evmTxHashFromRawHex => null;
16 int? get outputCount => null;
17 PendingChange? change;
18
cw_evm/lib/evm_chain_client.dart
+1
@@ -111,6 +111,7 @@ abstract class EVMChainClient {
111 sender: senderAddress,
112 to: toAddress,
113 value: value,
114 + data: data,
115 );
116
117 return estimatedGas.toInt();
cw_evm/lib/evm_chain_wallet.dart
+76
@@ -40,6 +40,7 @@ import 'package:web3dart/crypto.dart';
40 import 'package:web3dart/web3dart.dart';
41 import 'package:eth_sig_util/eth_sig_util.dart';
42
43 +import 'contract/erc20.dart';
44 import 'evm_chain_transaction_info.dart';
45 import 'evm_erc20_balance.dart';
46
@@ -611,6 +612,50 @@ abstract class EVMChainWalletBase
612 return pendingEVMChainTransaction;
613 }
614
615 + Future<PendingTransaction> createCallDataTransaction(
616 + String to,
617 + String dataHex,
618 + BigInt valueWei,
619 + EVMChainTransactionPriority priority,
620 + ) async {
621 +
622 + // Estimate gas with the SAME call (sender, to, value, data)
623 + final gas = await calculateActualEstimatedFeeForCreateTransaction(
624 + amount: valueWei, // native value (usually 0 for ERC20 transfer)
625 + receivingAddressHex: to,
626 + priority: priority,
627 + contractAddress: null,
628 + data: _client.hexToBytes(dataHex),
629 + );
630 +
631 + final nativeCurrency = switch (_client.chainId) {
632 + 137 => CryptoCurrency.maticpoly,
633 + 8453 => CryptoCurrency.baseEth,
634 + _ => CryptoCurrency.eth,
635 + };
636 +
637 + // Fallback for nodes that fail estimate (non-zero)
638 + final gasUnits = gas.estimatedGasUnits == 0 ? 65000 : gas.estimatedGasUnits;
639 +
640 + // Sign raw (native) tx with callData
641 + return _client.signTransaction(
642 + privateKey: _evmChainPrivateKey,
643 + toAddress: to,
644 + amount: valueWei,
645 + gasFee: BigInt.from(gas.estimatedGasFee),
646 + estimatedGasUnits: gasUnits,
647 + maxFeePerGas: gas.maxFeePerGas,
648 + priority: priority,
649 + currency: nativeCurrency,
650 + feeCurrency: nativeCurrency.title,
651 + exponent: 18,
652 + contractAddress: null,
653 + data: dataHex,
654 + gasPrice: gas.gasPrice,
655 + );
656 + }
657 +
658 +
659 Future<PendingTransaction> createApprovalTransaction(BigInt amount, String spender,
660 CryptoCurrency token, EVMChainTransactionPriority priority, String feeCurrency) async {
661 final CryptoCurrency transactionCurrency =
@@ -793,6 +838,37 @@ abstract class EVMChainWalletBase
838 }
839 }
840
841 + Future<bool> isApprovalRequired(
842 + String tokenContract,
843 + String spender,
844 + BigInt requiredAmount) async {
845 +
846 + const zero = '0x0000000000000000000000000000000000000000';
847 + const evmNative = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
848 +
849 + final token = tokenContract.toLowerCase();
850 + if (token == zero || token == evmNative.toLowerCase()) return false;
851 + if (requiredAmount <= BigInt.zero) return false;
852 +
853 + try {
854 + final owner = _evmChainPrivateKey.address;
855 + final erc20 = ERC20(
856 + client: _client.getWeb3Client()!,
857 + address: EthereumAddress.fromHex(tokenContract),
858 + chainId: _client.chainId,
859 + );
860 +
861 + final allowance = await erc20.allowance(
862 + owner,
863 + EthereumAddress.fromHex(spender));
864 +
865 + return allowance < requiredAmount;
866 + } catch (e) {
867 + printV('approval-check error: $e');
868 + return true;
869 + }
870 + }
871 +
872 Future<EthPrivateKey> getPrivateKey({
873 String? mnemonic,
874 String? privateKey,
cw_evm/lib/pending_evm_chain_transaction.dart
+9 -1
@@ -51,7 +51,15 @@ class PendingEVMChainTransaction with PendingTransaction {
51
52 return '0x${Hex.HEX.encode(txid)}';
53 }
54 -
54 +
55 + @override
56 + String? get evmTxHashFromRawHex {
57 + final no0x = hex.startsWith('0x') ? hex.substring(2) : hex;
58 + final bytes = Uint8List.fromList(Hex.HEX.decode(no0x));
59 + final digest = keccak256(bytes);
60 + return '0x${Hex.HEX.encode(digest)}';
61 + }
62 +
63 @override
64 Future<Map<String, String>> commitUR() {
65 throw UnimplementedError();
lib/base/cw_base.dart
+10
@@ -189,6 +189,10 @@ class CWBase extends Base {
189 @override
190 String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress;
191
192 + @override
193 + Future<bool> isApprovalRequired(WalletBase wallet, String tokenContract,String spender, BigInt requiredAmount) =>
194 + (wallet as EVMChainWallet).isApprovalRequired(tokenContract, spender, requiredAmount);
195 +
196 @override
197 Future<PendingTransaction> createTokenApproval(
198 WalletBase wallet,
@@ -205,6 +209,12 @@ class CWBase extends Base {
209 "BASE",
210 );
211
212 + @override
213 + Future<PendingTransaction> createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei,
214 + TransactionPriority priority) =>
215 + (wallet as EVMChainWallet).createCallDataTransaction(to, dataHex, valueWei, priority as EVMChainTransactionPriority);
216 +
217 +
218 @override
219 void setHardwareWalletService(WalletBase wallet, HardwareWalletService service) {
220 if (service is EVMChainLedgerService) {
lib/core/trade_monitor.dart
+3
@@ -1,5 +1,6 @@
1 import 'dart:async';
2 import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart';
3 +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
4 import 'package:cake_wallet/exchange/trade.dart';
5 import 'package:cake_wallet/exchange/trade_state.dart';
6 import 'package:cake_wallet/store/dashboard/trades_store.dart';
@@ -62,6 +63,8 @@ class TradeMonitor {
63 return ChainflipExchangeProvider(tradesStore: trades);
64 case ExchangeProviderDescription.xoSwap:
65 return XOSwapExchangeProvider();
66 + case ExchangeProviderDescription.swapsXyz:
67 + return SwapsXyzExchangeProvider();
68 }
69 return null;
70 }
lib/ethereum/cw_ethereum.dart
+10
@@ -217,11 +217,21 @@ class CWEthereum extends Ethereum {
217 return ethereumWallet.erc20Currencies.any((element) => element.contractAddress.toLowerCase() == contractAddress.toLowerCase());
218 }
219
220 + @override
221 + Future<bool> isApprovalRequired(WalletBase wallet, String tokenContract,String spender, BigInt requiredAmount) =>
222 + (wallet as EVMChainWallet).isApprovalRequired(tokenContract, spender, requiredAmount);
223 +
224 + @override
225 Future<PendingTransaction> createTokenApproval(WalletBase wallet, BigInt amount, String spender,
226 CryptoCurrency token, TransactionPriority priority) =>
227 (wallet as EVMChainWallet).createApprovalTransaction(
228 amount, spender, token, priority as EVMChainTransactionPriority, "ETH");
229
230 + @override
231 + Future<PendingTransaction> createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei,
232 + TransactionPriority priority) =>
233 + (wallet as EVMChainWallet).createCallDataTransaction(to, dataHex, valueWei, priority as EVMChainTransactionPriority);
234 +
235 // Integrations
236 @override
237 Future<BigInt> getDEuroSavingsBalance(WalletBase wallet) =>
lib/exchange/exchange_provider_description.dart
+4
@@ -36,6 +36,8 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
36 ExchangeProviderDescription(title: 'Chainflip', raw: 12, image: 'assets/images/chainflip.png');
37 static const xoSwap =
38 ExchangeProviderDescription(title: 'XOSwap', raw: 13, image: 'assets/images/xoswap.svg');
39 + static const swapsXyz =
40 + ExchangeProviderDescription(title: 'Swaps.XYZ', raw: 14, image: 'assets/images/swaps_xyz.svg');
41
42 static ExchangeProviderDescription deserialize({required int raw}) {
43 switch (raw) {
@@ -67,6 +69,8 @@ class ExchangeProviderDescription extends EnumerableItem<int> with Serializable<
69 return chainflip;
70 case 13:
71 return xoSwap;
72 + case 14:
73 + return swapsXyz;
74 default:
75 throw Exception('Unexpected token: $raw for ExchangeProviderDescription deserialize');
76 }
lib/exchange/provider/chainflip_exchange_provider.dart
+7 -6
@@ -91,12 +91,13 @@ class ChainflipExchangeProvider extends ExchangeProvider {
91 }
92
93 @override
94 - Future<double> fetchRate(
95 - {required CryptoCurrency from,
96 - required CryptoCurrency to,
97 - required double amount,
98 - required bool isFixedRateMode,
99 - required bool isReceiveAmount}) async {
94 + Future<double> fetchRate({
95 + required CryptoCurrency from,
96 + required CryptoCurrency to,
97 + required double amount,
98 + required bool isFixedRateMode,
99 + required bool isReceiveAmount
100 + }) async {
101 // TODO: It seems this rate is getting cached, and re-used for different amounts, can we not do this?
102
103 try {
lib/exchange/provider/changenow_exchange_provider.dart
+7 -6
@@ -91,12 +91,13 @@ class ChangeNowExchangeProvider extends ExchangeProvider {
91 }
92
93 @override
94 - Future<double> fetchRate(
95 - {required CryptoCurrency from,
96 - required CryptoCurrency to,
97 - required double amount,
98 - required bool isFixedRateMode,
99 - required bool isReceiveAmount}) async {
94 + Future<double> fetchRate({
95 + required CryptoCurrency from,
96 + required CryptoCurrency to,
97 + required double amount,
98 + required bool isFixedRateMode,
99 + required bool isReceiveAmount
100 + }) async {
101 try {
102 if (amount == 0) return 0.0;
103
lib/exchange/provider/exchange_provider.dart
+7 -6
@@ -33,12 +33,13 @@ abstract class ExchangeProvider {
33
34 Future<Trade> findTradeById({required String id});
35
36 - Future<double> fetchRate(
37 - {required CryptoCurrency from,
38 - required CryptoCurrency to,
39 - required double amount,
40 - required bool isFixedRateMode,
41 - required bool isReceiveAmount});
36 + Future<double> fetchRate({
37 + required CryptoCurrency from,
38 + required CryptoCurrency to,
39 + required double amount,
40 + required bool isFixedRateMode,
41 + required bool isReceiveAmount
42 + });
43
44 Future<bool> checkIsAvailable();
45 }
lib/exchange/provider/exolix_exchange_provider.dart
+7 -6
@@ -112,12 +112,13 @@ class ExolixExchangeProvider extends ExchangeProvider {
112 }
113
114 @override
115 - Future<double> fetchRate(
116 - {required CryptoCurrency from,
117 - required CryptoCurrency to,
118 - required double amount,
119 - required bool isFixedRateMode,
120 - required bool isReceiveAmount}) async {
115 + Future<double> fetchRate({
116 + required CryptoCurrency from,
117 + required CryptoCurrency to,
118 + required double amount,
119 + required bool isFixedRateMode,
120 + required bool isReceiveAmount
121 + }) async {
122 try {
123 if (amount == 0) return 0.0;
124
lib/exchange/provider/letsexchange_exchange_provider.dart
+7 -6
@@ -78,12 +78,13 @@ class LetsExchangeExchangeProvider extends ExchangeProvider {
78 }
79
80 @override
81 - Future<double> fetchRate(
82 - {required CryptoCurrency from,
83 - required CryptoCurrency to,
84 - required double amount,
85 - required bool isFixedRateMode,
86 - required bool isReceiveAmount}) async {
81 + Future<double> fetchRate({
82 + required CryptoCurrency from,
83 + required CryptoCurrency to,
84 + required double amount,
85 + required bool isFixedRateMode,
86 + required bool isReceiveAmount
87 + }) async {
88 final networkFrom = _getNetworkType(from);
89 final networkTo = _getNetworkType(to);
90 try {
lib/exchange/provider/sideshift_exchange_provider.dart
+7 -6
@@ -120,12 +120,13 @@ class SideShiftExchangeProvider extends ExchangeProvider {
120 }
121
122 @override
123 - Future<double> fetchRate(
124 - {required CryptoCurrency from,
125 - required CryptoCurrency to,
126 - required double amount,
127 - required bool isFixedRateMode,
128 - required bool isReceiveAmount}) async {
123 + Future<double> fetchRate({
124 + required CryptoCurrency from,
125 + required CryptoCurrency to,
126 + required double amount,
127 + required bool isFixedRateMode,
128 + required bool isReceiveAmount
129 + }) async {
130 try {
131 if (amount == 0) return 0.0;
132
lib/exchange/provider/simpleswap_exchange_provider.dart
+7 -6
@@ -89,12 +89,13 @@ class SimpleSwapExchangeProvider extends ExchangeProvider {
89 }
90
91 @override
92 - Future<double> fetchRate(
93 - {required CryptoCurrency from,
94 - required CryptoCurrency to,
95 - required double amount,
96 - required bool isFixedRateMode,
97 - required bool isReceiveAmount}) async {
92 + Future<double> fetchRate({
93 + required CryptoCurrency from,
94 + required CryptoCurrency to,
95 + required double amount,
96 + required bool isFixedRateMode,
97 + required bool isReceiveAmount
98 + }) async {
99 try {
100 if (amount == 0) return 0.0;
101
lib/exchange/provider/swapsxyz_exchange_provider.dart new
+823
@@ -0,0 +1,823 @@
1 +import 'dart:convert';
2 +
3 +import 'package:cake_wallet/.secrets.g.dart' as secrets;
4 +import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
5 +import 'package:cake_wallet/exchange/exchange_provider_description.dart';
6 +import 'package:cake_wallet/exchange/limits.dart';
7 +import 'package:cake_wallet/exchange/trade.dart';
8 +import 'package:cake_wallet/exchange/trade_not_created_exception.dart';
9 +import 'package:cake_wallet/exchange/trade_request.dart';
10 +import 'package:cake_wallet/exchange/trade_state.dart';
11 +import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart';
12 +import 'package:cw_core/amount_converter.dart';
13 +import 'package:cw_core/utils/print_verbose.dart';
14 +import 'package:cw_core/utils/proxy_wrapper.dart';
15 +import 'package:cw_core/crypto_currency.dart';
16 +
17 +class SwapsXyzExchangeProvider extends ExchangeProvider {
18 + SwapsXyzExchangeProvider() : super(pairList: supportedPairs(_notSupported));
19 +
20 + static const List<CryptoCurrency> _notSupported = [];
21 +
22 + static final _apiKey = secrets.swapsXyzApiKey;
23 + static const _baseUrl = 'api-v2.swaps.xyz';
24 + static const _getChainList = 'api/getChainList';
25 + static const _getPaths = 'api/getPaths';
26 + static const _getQuotePaths = 'api/getQuote';
27 + static const _getAction = 'api/getAction';
28 + static const _registerTxs = 'api/registerTxs';
29 + static const _getStatus = 'api/getStatus';
30 +
31 + static final _headers = {'x-api-key': _apiKey};
32 +
33 + static final _supportedChainList = <Chain>[];
34 + final Map<int, List<TokenPathInfo>> _tokensCache = {};
35 +
36 + @override
37 + String get title => 'Swaps.XYZ';
38 +
39 + @override
40 + bool get isAvailable => true;
41 +
42 + @override
43 + bool get isEnabled => true;
44 +
45 + @override
46 + bool get supportsFixedRate => true;
47 +
48 + @override
49 + ExchangeProviderDescription get description =>
50 + ExchangeProviderDescription.swapsXyz;
51 +
52 + @override
53 + Future<bool> checkIsAvailable() async => true;
54 +
55 + @override
56 + Future<Limits> fetchLimits({
57 + required CryptoCurrency from,
58 + required CryptoCurrency to,
59 + required bool isFixedRateMode,
60 + }) async {
61 + try {
62 + final chains = await _geSupportedChain();
63 + if (chains.isEmpty) throw Exception('Failed to fetch supported chains');
64 +
65 + final srcChain = _findChainByCurrency(from, chains);
66 + final dstChain = _findChainByCurrency(to, chains);
67 +
68 + await _ensureTokensCached(
69 + fromChain: srcChain, toChain: dstChain, from: from, to: to);
70 +
71 + final srcToken = _getTokenAddress(currency: from, chain: srcChain);
72 + final dstToken = _getTokenAddress(currency: to, chain: dstChain);
73 +
74 + // parameters to and from are swapped in this endpoint
75 + // i.e. to get limits for FROM -> TO, we request paths for TO -> FROM
76 + final params = {
77 + 'srcChainId': '${dstChain.chainId}',
78 + 'srcToken': dstToken,
79 + 'dstChainId': '${srcChain.chainId}',
80 + 'dstToken': srcToken,
81 + };
82 +
83 + final uri = Uri.https(_baseUrl, _getPaths, params);
84 + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
85 + if (res.statusCode != 200) {
86 + throw Exception('Unexpected http status: ${res.statusCode}');
87 + }
88 +
89 + final body = json.decode(res.body) as Map<String, dynamic>;
90 +
91 + final paths = (body['paths'] as List?) ?? const [];
92 + if (paths.isEmpty) {
93 + throw Exception('No paths for ${from.title} -> ${to.title}');
94 + }
95 +
96 + final path0 = paths.first as Map<String, dynamic>;
97 + final tokens = path0['tokens'];
98 +
99 + // If tokens == "all", use path-level amountLimits
100 + if (tokens is String && tokens == 'all') {
101 + final amountLimits = path0['amountLimits'] as Map<String, dynamic>?;
102 +
103 + final min =
104 + double.tryParse(amountLimits?['minAmount']?.toString() ?? '');
105 + final max =
106 + double.tryParse(amountLimits?['maxAmount']?.toString() ?? '');
107 +
108 + return Limits(min: min, max: max);
109 + }
110 +
111 + // Otherwise tokens is a list -> find the specific token entry
112 + final tokenList = (tokens as List?) ?? const [];
113 + final token = findTokenBySymbol(title: from.title, tokens: tokenList);
114 +
115 + if (token == null) {
116 + throw Exception('Token info not found for ${from.title}');
117 + }
118 +
119 + final min = double.tryParse(token['minAmount']?.toString() ?? '');
120 + final max = double.tryParse(token['maxAmount']?.toString() ?? '');
121 +
122 + return Limits(min: min, max: max);
123 + } catch (e) {
124 + printV('fetchLimits error: $e');
125 + throw Exception('Error fetching limits: $e');
126 + }
127 + }
128 +
129 + Future<_PathInfo?> _pickPath({
130 + required int srcChainId,
131 + required String srcToken,
132 + required int dstChainId,
133 + required String dstToken,
134 + }) async {
135 + final uri = Uri.https(_baseUrl, _getPaths, {
136 + 'srcChainId': '$srcChainId',
137 + 'srcToken': srcToken,
138 + 'dstChainId': '$dstChainId',
139 + 'dstToken': dstToken,
140 + });
141 + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
142 + if (res.statusCode != 200) return null;
143 + final body = json.decode(res.body) as Map<String, dynamic>;
144 + final paths =
145 + (body['paths'] as List?)?.cast<Map<String, dynamic>>() ?? const [];
146 + if (paths.isEmpty) return null;
147 + final p = paths.first;
148 + return _PathInfo(
149 + supportsExactOut: p['supportsExactAmountOut'] == true,
150 + minToAmountHuman: (p['amountLimits']?['minAmount'] as String?) ?? '0',
151 + );
152 + }
153 +
154 + @override
155 + Future<double> fetchRate(
156 + {required CryptoCurrency from,
157 + required CryptoCurrency to,
158 + required double amount,
159 + required bool isFixedRateMode,
160 + required bool isReceiveAmount}) async {
161 + try {
162 + final chains = await _geSupportedChain();
163 + if (chains.isEmpty) return 0.0;
164 +
165 + final srcChain = _findChainByCurrency(from, chains);
166 + final dstChain = _findChainByCurrency(to, chains);
167 +
168 + await _ensureTokensCached(
169 + fromChain: srcChain, toChain: dstChain, from: from, to: to);
170 +
171 + final srcToken = _getTokenAddress(currency: from, chain: srcChain);
172 + final dstToken = _getTokenAddress(currency: to, chain: dstChain);
173 +
174 + if (isReceiveAmount) {
175 + final path = await _pickPath(
176 + srcChainId: srcChain.chainId,
177 + srcToken: srcToken,
178 + dstChainId: dstChain.chainId,
179 + dstToken: dstToken,
180 + );
181 + if (path == null || !path.supportsExactOut) {
182 + printV(
183 + 'fetchRate: route does not support exact-amount-out for ${from.title} -> ${to.title}');
184 + return 0.0;
185 + }
186 + }
187 +
188 + final humanAmountStr = amount.toString();
189 + final formattedAmount = AmountConverter.toBaseUnits(
190 + humanAmountStr,
191 + isReceiveAmount ? to.decimals : from.decimals,
192 + );
193 +
194 + final params = {
195 + 'swapDirection':
196 + isReceiveAmount ? 'exact-amount-out' : 'exact-amount-in',
197 + 'srcToken': srcToken,
198 + 'dstToken': dstToken,
199 + 'srcChainId': '${srcChain.chainId}',
200 + 'dstChainId': '${dstChain.chainId}',
201 + 'amount': formattedAmount,
202 + };
203 +
204 + final uri = Uri.https(_baseUrl, _getQuotePaths, params);
205 + final response =
206 + await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
207 +
208 + if (response.statusCode != 200) {
209 + printV('fetchRate failed: ${response.body}');
210 + return 0.0;
211 + }
212 +
213 + final data = json.decode(response.body) as Map<String, dynamic>;
214 + final exchangeRate = (data['exchangeRate'] as num?)?.toDouble() ?? 0.0;
215 + return exchangeRate;
216 + } catch (e) {
217 + printV('fetchRate error: $e');
218 + return 0.0;
219 + }
220 + }
221 +
222 + @override
223 + Future<Trade> createTrade({
224 + required TradeRequest request,
225 + required bool isFixedRateMode,
226 + required bool isSendAll,
227 + }) async {
228 + try {
229 + final sender = request.refundAddress.trim();
230 + final recipient = request.toAddress.trim();
231 + if (sender.isEmpty || recipient.isEmpty) {
232 + throw Exception(
233 + 'Sender (refundAddress) or recipient (toAddress) is empty');
234 + }
235 +
236 + final chains = await _geSupportedChain();
237 + if (chains.isEmpty) throw Exception('Failed to fetch supported chains');
238 + final srcChain = _findChainByCurrency(request.fromCurrency, chains);
239 + final dstChain = _findChainByCurrency(request.toCurrency, chains);
240 +
241 + await _ensureTokensCached(
242 + fromChain: srcChain,
243 + toChain: dstChain,
244 + from: request.fromCurrency,
245 + to: request.toCurrency,
246 + );
247 +
248 + final srcToken =
249 + _getTokenAddress(currency: request.fromCurrency, chain: srcChain);
250 + final dstToken =
251 + _getTokenAddress(currency: request.toCurrency, chain: dstChain);
252 +
253 + // Optional: ensure path supports exact-out before attempting fixed rate.
254 + if (isFixedRateMode) {
255 + final path = await _pickPath(
256 + srcChainId: srcChain.chainId,
257 + srcToken: srcToken,
258 + dstChainId: dstChain.chainId,
259 + dstToken: dstToken,
260 + );
261 + if (path == null || !path.supportsExactOut) {
262 + throw Exception(
263 + 'This route does not support fixed receive (exact-amount-out)');
264 + }
265 + }
266 +
267 + final amountStr = isFixedRateMode ? request.toAmount : request.fromAmount;
268 + final rawAmount = double.tryParse(amountStr) ?? 0.0;
269 + if (rawAmount <= 0) throw Exception('Invalid amount');
270 +
271 + final formattedAmount = AmountConverter.toBaseUnits(
272 + amountStr,
273 + isFixedRateMode
274 + ? request.toCurrency.decimals
275 + : request.fromCurrency.decimals,
276 + );
277 +
278 + final params = {
279 + 'actionType': 'swap-action',
280 + 'sender': sender,
281 + 'srcChainId': '${srcChain.chainId}',
282 + 'srcToken': srcToken,
283 + 'dstChainId': '${dstChain.chainId}',
284 + 'dstToken': dstToken,
285 + 'slippage': '100',
286 + 'swapDirection':
287 + isFixedRateMode ? 'exact-amount-out' : 'exact-amount-in',
288 + 'amount': formattedAmount,
289 + 'recipient': recipient,
290 + };
291 +
292 + final uri = Uri.https(_baseUrl, _getAction, params);
293 + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
294 +
295 + if (res.statusCode != 200) {
296 + throw Exception('getAction failed: ${res.statusCode} ${res.body}');
297 + }
298 +
299 + final data = json.decode(res.body) as Map<String, dynamic>;
300 +
301 + final txId = data['txId'] as String? ?? '';
302 + final vmId = data['vmId'] as String? ?? '';
303 + final txObj = (data['tx'] as Map?) ?? const {};
304 +
305 + final txTo = txObj['to']?.toString();
306 + final chainId = txObj['chainId']?.toString();
307 + final routerData = txObj['data']?.toString();
308 + final txValue = txObj['value']?.toString() ?? '0';
309 +
310 + final bridgeIds = (data['bridgeIds'] as List?) ?? const [];
311 + if (txId.isEmpty) throw Exception('No txId returned by getAction');
312 +
313 + final amtIn = (data['amountIn'] as Map?) ?? const {};
314 + final amtInMax = (data['amountInMax'] as Map?) ?? const {};
315 + final srcTokenAddr = amtIn['address']?.toString();
316 + final srcTokenDecs =
317 + (amtIn['decimals'] as num?)?.toInt() ?? request.fromCurrency.decimals;
318 + final requiresTokenApproval =
319 + data['requiresTokenApproval'] as bool? ?? false;
320 +
321 + final reqAmountStr =
322 + (amtInMax['amount'] ?? amtIn['amount'])?.toString() ?? '0';
323 + final reqAmountRaw = reqAmountStr.replaceAll('n', '');
324 +
325 + final needToRegisterInSwapXyz =
326 + vmId == 'alt-vm' || bridgeIds.contains('alt-vm');
327 +
328 + final trade = Trade(
329 + id: txId,
330 + router: chainId,
331 + providerId: vmId,
332 + from: request.fromCurrency,
333 + to: request.toCurrency,
334 + provider: description,
335 + inputAddress: txTo,
336 + refundAddress: request.refundAddress,
337 + state: TradeState.created,
338 + providerName: title,
339 + createdAt: DateTime.now(),
340 + amount: request.fromAmount,
341 + receiveAmount: request.toAmount,
342 + payoutAddress: request.toAddress,
343 + isSendAll: isSendAll,
344 + needToRegisterInSwapXyz: needToRegisterInSwapXyz,
345 + sourceTokenAddress: srcTokenAddr ?? srcToken,
346 + sourceTokenDecimals: srcTokenDecs,
347 + sourceTokenAmountRaw: reqAmountRaw,
348 + requiresTokenApproval: requiresTokenApproval,
349 + routerData: routerData,
350 + routerValue: txValue,
351 + userCurrencyFromRaw:
352 + '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}',
353 + userCurrencyToRaw:
354 + '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}',
355 + );
356 +
357 + return trade;
358 + } catch (e) {
359 + printV('createTrade error: $e');
360 + throw TradeNotCreatedException(description, description: e.toString());
361 + }
362 + }
363 +
364 + /// Register a broadcasted tx with Swaps.xyz (required for alt-vm).
365 + Future<bool> registerAltVmTx({
366 + required String txId,
367 + required String txHash,
368 + required int chainId,
369 + required String vmId,
370 + }) async {
371 + try {
372 + final uri = Uri.https(_baseUrl, _registerTxs);
373 + final payload = {
374 + 'txId': txId,
375 + 'vmId': vmId,
376 + 'txHash': txHash,
377 + 'chainId': chainId,
378 + };
379 +
380 + final res = await ProxyWrapper().post(
381 + clearnetUri: uri,
382 + headers: {
383 + ..._headers,
384 + 'content-type': 'application/json',
385 + },
386 + body: jsonEncode(payload),
387 + );
388 +
389 + if (res.statusCode != 200) {
390 + printV('registerTxs failed: ${res.statusCode} ${res.body}');
391 + return false;
392 + }
393 + final List<dynamic> body = json.decode(res.body) as List<dynamic>;
394 + if (body.isEmpty) return false;
395 +
396 + final isSuccess =
397 + (body[0] as Map<String, dynamic>)['success'] as bool? ?? false;
398 +
399 + return isSuccess;
400 + } catch (e) {
401 + printV('registerAltVmTx error: $e');
402 + return false;
403 + }
404 + }
405 +
406 + @override
407 + Future<Trade> findTradeById({required String id}) async {
408 + if (id.isEmpty) {
409 + throw Exception('Trade id is empty');
410 + }
411 +
412 + final uri = Uri.https(_baseUrl, _getStatus, {'txId': id});
413 + final resp = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
414 +
415 + if (resp.statusCode != 200) {
416 + throw Exception('getStatus failed: ${resp.statusCode} ${resp.body}');
417 + }
418 +
419 + final data = json.decode(resp.body) as Map<String, dynamic>;
420 + final isSuccess = (data['success'] as bool?);
421 +
422 + if (isSuccess != null && !isSuccess) {
423 + final error = data['error'] as Map<String, dynamic>?;
424 + if (error != null) {
425 + final code = error['code']?.toString() ?? 'unknown';
426 + throw Exception(
427 + 'SwapXyzExchangeProvider findTradeById error: ($id) $code');
428 + }
429 + }
430 +
431 + final statusStr = (data['status'] as String?)?.toLowerCase() ?? 'NOT_FOUND';
432 + final state = _mapSwapsStatusToTradeState(statusStr);
433 +
434 + final refundAddress = data['sender']?.toString();
435 +
436 + final srcTransaction = (data['srcTx'] as Map?)?.cast<String, dynamic>();
437 + final dstTransaction = (data['dstTx'] as Map?)?.cast<String, dynamic>();
438 +
439 + final inputAddress = srcTransaction?['toAddress']?.toString();
440 +
441 + final payoutAddress = dstTransaction?['toAddress']?.toString();
442 +
443 + final srcPaymentToken =
444 + (srcTransaction?['paymentToken'] as Map?)?.cast<String, dynamic>();
445 + final dstPaymentToken =
446 + (dstTransaction?['paymentToken'] as Map?)?.cast<String, dynamic>();
447 +
448 + final fromSymbol = (srcPaymentToken?['symbol'] as String?) ?? '';
449 + final toSymbol = (dstPaymentToken?['symbol'] as String?) ?? '';
450 +
451 + CryptoCurrency? toCurrency;
452 + if (toSymbol.isNotEmpty) {
453 + toCurrency = CryptoCurrency.safeParseCurrencyFromString(toSymbol);
454 + }
455 +
456 + final srcDecimals = (srcPaymentToken?['decimals'] as num?)?.toInt() ?? 0;
457 + final dstDecimals = (dstPaymentToken?['decimals'] as num?)?.toInt() ?? 0;
458 +
459 + final txHash = srcTransaction?['txHash'] as String?;
460 +
461 + // Minimal-unit amounts like "12000n"
462 + final srcAmountRaw = srcPaymentToken?['amount']?.toString();
463 + final dstAmountRaw = dstPaymentToken?['amount']?.toString();
464 + String? receiveAmount;
465 + if (dstAmountRaw != null) {
466 + final dstAmountMinimal = _stripN(dstAmountRaw);
467 + receiveAmount =
468 + AmountConverter.fromBaseUnits(dstAmountMinimal, dstDecimals);
469 + }
470 +
471 + final srcAmountMinimal = _stripN(srcAmountRaw);
472 + final amount = AmountConverter.fromBaseUnits(srcAmountMinimal, srcDecimals);
473 + final fromCurrency = CryptoCurrency.safeParseCurrencyFromString(fromSymbol);
474 +
475 + // Timestamps can be num or "123n" handle both
476 + final srcTs = _parseUnixSeconds(srcTransaction?['timestamp']);
477 + final dstTs = _parseUnixSeconds(dstTransaction?['timestamp']);
478 + final timestamp = srcTs ?? dstTs;
479 +
480 + final createdAt = timestamp != null
481 + ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000, isUtc: true)
482 + .toLocal()
483 + : null;
484 +
485 + return Trade(
486 + id: (data['txId'] as String?) ?? id,
487 + from: fromCurrency,
488 + to: toCurrency,
489 + provider: description,
490 + inputAddress: inputAddress,
491 + payoutAddress: payoutAddress,
492 + amount: amount,
493 + receiveAmount: receiveAmount,
494 + txId: txHash,
495 + state: state,
496 + createdAt: createdAt,
497 + refundAddress: refundAddress,
498 + userCurrencyFromRaw: '${fromSymbol.toUpperCase()}' + '_',
499 + userCurrencyToRaw: '${toSymbol.toUpperCase()}' + '_',
500 + );
501 + }
502 +
503 + TradeState _mapSwapsStatusToTradeState(String s) {
504 + switch (s) {
505 + case 'pending':
506 + case 'processing':
507 + return TradeState.pending;
508 + case 'success':
509 + case 'completed':
510 + case 'complete':
511 + return TradeState.finished;
512 + case 'failed':
513 + case 'cancelled':
514 + case 'canceled':
515 + case 'error':
516 + return TradeState.failed;
517 + default:
518 + return TradeState.pending;
519 + }
520 + }
521 +
522 + // Load & cache supported chains once
523 + Future<List<Chain>> _geSupportedChain() async {
524 + if (_supportedChainList.isNotEmpty) return _supportedChainList;
525 + try {
526 + final uri = Uri.https(_baseUrl, _getChainList);
527 + final response =
528 + await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
529 + if (response.statusCode != 200) return [];
530 +
531 + final data = json.decode(response.body) as List<dynamic>;
532 + _supportedChainList
533 + ..clear()
534 + ..addAll(data.map((e) => Chain.fromJson(e as Map<String, dynamic>)));
535 + return _supportedChainList;
536 + } catch (e) {
537 + printV(e);
538 + return [];
539 + }
540 + }
541 +
542 + // Ensure tokens for this pair are cached (fills both src & dst chain entries)
543 + // Replace your _ensureTokensCached with this one
544 + Future<void> _ensureTokensCached({
545 + required Chain fromChain,
546 + required Chain toChain,
547 + required CryptoCurrency from,
548 + required CryptoCurrency to,
549 + }) async {
550 + bool needSrc = !_tokensCache.containsKey(fromChain.chainId) ||
551 + (_tokensCache[fromChain.chainId]?.isEmpty ?? true);
552 + bool needDst = !_tokensCache.containsKey(toChain.chainId) ||
553 + (_tokensCache[toChain.chainId]?.isEmpty ?? true);
554 +
555 + if (!needSrc && !needDst) return;
556 +
557 + // First try: from -> to
558 + await _fetchAndCacheTokens(
559 + srcChainId: fromChain.chainId,
560 + srcToken: from.title.toUpperCase(),
561 + dstChainId: toChain.chainId,
562 + dstToken: to.title.toUpperCase(),
563 + );
564 +
565 + // Re-check after first call
566 + needSrc = !_tokensCache.containsKey(fromChain.chainId) ||
567 + (_tokensCache[fromChain.chainId]?.isEmpty ?? true);
568 + needDst = !_tokensCache.containsKey(toChain.chainId) ||
569 + (_tokensCache[toChain.chainId]?.isEmpty ?? true);
570 +
571 + // If any still missing, try swapped: to -> from
572 + if (needSrc || needDst) {
573 + await _fetchAndCacheTokens(
574 + srcChainId: toChain.chainId,
575 + srcToken: to.title.toUpperCase(),
576 + dstChainId: fromChain.chainId,
577 + dstToken: from.title.toUpperCase(),
578 + );
579 + }
580 + }
581 +
582 + // call getPaths and merge tokens into cache keyed by the chainId
583 + Future<void> _fetchAndCacheTokens({
584 + required int srcChainId,
585 + required String srcToken,
586 + required int dstChainId,
587 + required String dstToken,
588 + }) async {
589 + final params = <String, String>{
590 + 'srcChainId': '$srcChainId',
591 + 'srcToken': srcToken,
592 + 'dstChainId': '$dstChainId',
593 + };
594 +
595 + final uri = Uri.https(_baseUrl, _getPaths, params);
596 + final res = await ProxyWrapper().get(clearnetUri: uri, headers: _headers);
597 + if (res.statusCode != 200) {
598 + printV('getPaths failed: ${res.statusCode} ${res.body}');
599 + return;
600 + }
601 +
602 + final body = json.decode(res.body) as Map<String, dynamic>;
603 + final paths = (body['paths'] as List?) ?? const [];
604 + if (paths.isEmpty) return;
605 +
606 + final path0 = paths.first as Map<String, dynamic>;
607 + final pathChainId = (path0['chainId'] as num?)?.toInt();
608 + final tokens = (path0['tokens'] as List?) ?? const [];
609 +
610 + if (pathChainId == null || tokens.isEmpty) return;
611 +
612 + final parsed = tokens
613 + .map((t) => TokenPathInfo.fromJson(t as Map<String, dynamic>))
614 + .toList();
615 +
616 + _mergeCache(pathChainId, parsed);
617 + }
618 +
619 +// Merge by symbol, prefer entries that have a non-empty address/decimals
620 + void _mergeCache(int chainId, List<TokenPathInfo> incoming) {
621 + final existing = _tokensCache[chainId] ?? const <TokenPathInfo>[];
622 + final bySymbol = <String, TokenPathInfo>{
623 + for (final t in existing) t.symbol: t
624 + };
625 +
626 + for (final t in incoming) {
627 + final cur = bySymbol[t.symbol];
628 + if (cur == null) {
629 + bySymbol[t.symbol] = t;
630 + } else {
631 + // If incoming has a real address/decimals, prefer it
632 + final hasBetterAddr = (t.address != null && t.address!.isNotEmpty) &&
633 + (cur.address == null || cur.address!.isEmpty);
634 + final hasBetterDec = (t.decimals != null) && (cur.decimals == null);
635 + if (hasBetterAddr || hasBetterDec) {
636 + bySymbol[t.symbol] = t;
637 + }
638 + }
639 + }
640 +
641 + _tokensCache[chainId] = bySymbol.values.toList();
642 + }
643 +
644 + String _nativeSymbolForChain(Chain chain) {
645 + final network = chain.name.toUpperCase();
646 + switch (network) {
647 + case 'BTC':
648 + case 'BITCOIN':
649 + return 'BTC';
650 + case 'XMR':
651 + case 'MONERO':
652 + return 'XMR';
653 + case 'TRX':
654 + case 'TRON':
655 + return 'TRX';
656 + case 'ETH':
657 + case 'ETHEREUM':
658 + return 'ETH';
659 + case 'LTC':
660 + case 'LITECOIN':
661 + return 'LTC';
662 + case 'DOGE':
663 + case 'DOGECOIN':
664 + return 'DOGE';
665 + case 'XRP':
666 + case 'RIPPLE':
667 + return 'XRP';
668 + case 'XLM':
669 + case 'STELLAR':
670 + return 'XLM';
671 + case 'SOL':
672 + case 'SOLANA':
673 + return 'SOL';
674 + case 'CARDANO':
675 + return 'ADA';
676 + case 'Bitcoin Cash':
677 + return 'BCH';
678 + case 'POLYGON':
679 + return 'POL';
680 + default:
681 + return network;
682 + }
683 + }
684 +
685 + String _getTokenAddress({
686 + required CryptoCurrency currency,
687 + required Chain chain,
688 + }) {
689 + final symbol = currency.title.toUpperCase();
690 + final list = _tokensCache[chain.chainId];
691 +
692 + // Try cache hit
693 + if (list != null && list.isNotEmpty) {
694 + for (final t in list) {
695 + if (t.symbol == symbol && t.address != null && t.address!.isNotEmpty) {
696 + return t.address!;
697 + }
698 + }
699 + }
700 +
701 + // If it's the native coin for this chain, return native placeholder
702 + final nativeSym = _nativeSymbolForChain(chain);
703 + if (nativeSym == symbol) {
704 + return _nativePlaceholderForVm(chain.vmId);
705 + }
706 +
707 + // May fail for non-native Alt-VM assets
708 + return symbol;
709 + }
710 +
711 + String _nativePlaceholderForVm(String vmId) {
712 + switch (vmId.toLowerCase()) {
713 + case 'evm':
714 + return '0x0000000000000000000000000000000000000000';
715 + case 'alt-vm':
716 + return '0x0000000000000000000000000000000000000000';
717 + default:
718 + return '0x0000000000000000000000000000000000000000';
719 + }
720 + }
721 +
722 + Map<String, dynamic>? findTokenBySymbol(
723 + {required String title, required List<dynamic> tokens}) {
724 + final reqSymbol = title.toUpperCase();
725 + for (final token in tokens) {
726 + final map = token as Map<String, dynamic>;
727 + final symbol = (map['symbol'] as String?)?.toUpperCase();
728 + if (symbol == reqSymbol) return map;
729 + }
730 + return null;
731 + }
732 +
733 + Chain _findChainByCurrency(CryptoCurrency cur, List<Chain> chains) {
734 + final network = _normalizeCakeNetwork(cur.tag ?? cur.title);
735 + return chains.firstWhere(
736 + (c) => c.name.toUpperCase() == network,
737 + orElse: () => throw Exception('Unsupported chain for ${cur.title}'),
738 + );
739 + }
740 +
741 + String _normalizeCakeNetwork(String network) {
742 + return switch (network.toUpperCase()) {
743 + 'ETH' => 'ETHEREUM',
744 + 'BSC' => 'BNB SMART CHAIN',
745 + 'POL' => 'POLYGON',
746 + 'AVAXC' => 'AVALANCHE',
747 + 'TRX' => 'TRON',
748 + 'SOL' => 'SOLANA',
749 + 'CRO' => 'CRONOS',
750 + 'ADA' => 'CARDANO',
751 + 'KAS' => 'KASPA',
752 + 'TON' => 'TONCOIN',
753 + 'BCH' => 'BITCOIN CASH',
754 + _ => network.toUpperCase(),
755 + };
756 + }
757 +
758 + int? _parseUnixSeconds(dynamic value) {
759 + if (value == null) return null;
760 + if (value is num) return value.toInt();
761 + if (value is String) {
762 + final clean = _stripN(value);
763 + return int.tryParse(clean);
764 + }
765 + return null;
766 + }
767 +
768 + String _stripN(String? str) {
769 + final s = str ?? '0';
770 + return s.endsWith('n') ? s.substring(0, s.length - 1) : s;
771 + }
772 +}
773 +
774 +class TokenPathInfo {
775 + final String symbol;
776 + final String? address;
777 + final int? decimals;
778 + final String? minAmount;
779 + final String? maxAmount;
780 +
781 + TokenPathInfo({
782 + required this.symbol,
783 + required this.address,
784 + required this.decimals,
785 + required this.minAmount,
786 + required this.maxAmount,
787 + });
788 +
789 + factory TokenPathInfo.fromJson(Map<String, dynamic> json) => TokenPathInfo(
790 + symbol: (json['symbol'] as String?)?.toUpperCase() ?? '',
791 + address: json['address'] as String?,
792 + decimals: json['decimals'] as int?,
793 + minAmount: json['minAmount']?.toString(),
794 + maxAmount: json['maxAmount']?.toString(),
795 + );
796 +}
797 +
798 +class Chain {
799 + final int chainId;
800 + final String name;
801 + final String vmId;
802 +
803 + Chain({
804 + required this.chainId,
805 + required this.name,
806 + required this.vmId,
807 + });
808 +
809 + factory Chain.fromJson(Map<String, dynamic> json) {
810 + return Chain(
811 + chainId: json['chainId'] as int,
812 + name: json['name'] as String,
813 + vmId: json['vmId'] as String,
814 + );
815 + }
816 +}
817 +
818 +class _PathInfo {
819 + final bool supportsExactOut;
820 + final String minToAmountHuman;
821 +
822 + _PathInfo({required this.supportsExactOut, required this.minToAmountHuman});
823 +}
lib/exchange/provider/swaptrade_exchange_provider.dart
+1 -1
@@ -105,7 +105,7 @@ class SwapTradeExchangeProvider extends ExchangeProvider {
105 required CryptoCurrency to,
106 required double amount,
107 required bool isFixedRateMode,
108 - required bool isReceiveAmount,
108 + required bool isReceiveAmount
109 }) async {
110 try {
111 if (amount == 0) return 0.0;
lib/exchange/provider/thorchain_exchange.provider.dart
+7 -6
@@ -66,12 +66,13 @@ class ThorChainExchangeProvider extends ExchangeProvider {
66 Future<bool> checkIsAvailable() async => true;
67
68 @override
69 - Future<double> fetchRate(
70 - {required CryptoCurrency from,
71 - required CryptoCurrency to,
72 - required double amount,
73 - required bool isFixedRateMode,
74 - required bool isReceiveAmount}) async {
69 + Future<double> fetchRate({
70 + required CryptoCurrency from,
71 + required CryptoCurrency to,
72 + required double amount,
73 + required bool isFixedRateMode,
74 + required bool isReceiveAmount
75 + }) async {
76 try {
77 if (amount == 0) return 0.0;
78
lib/exchange/provider/trocador_exchange_provider.dart
+7 -6
@@ -120,12 +120,13 @@ class TrocadorExchangeProvider extends ExchangeProvider {
120 }
121
122 @override
123 - Future<double> fetchRate(
124 - {required CryptoCurrency from,
125 - required CryptoCurrency to,
126 - required double amount,
127 - required bool isFixedRateMode,
128 - required bool isReceiveAmount}) async {
123 + Future<double> fetchRate({
124 + required CryptoCurrency from,
125 + required CryptoCurrency to,
126 + required double amount,
127 + required bool isFixedRateMode,
128 + required bool isReceiveAmount
129 + }) async {
130 try {
131 if (amount == 0) return 0.0;
132
lib/exchange/provider/xoswap_exchange_provider.dart
+2 -1
@@ -142,12 +142,13 @@ class XOSwapExchangeProvider extends ExchangeProvider {
142 }
143 }
144
145 + @override
146 Future<double> fetchRate({
147 required CryptoCurrency from,
148 required CryptoCurrency to,
149 required double amount,
150 required bool isFixedRateMode,
150 - required bool isReceiveAmount,
151 + required bool isReceiveAmount
152 }) async {
153 try {
154 final rates = await getRatesForPair(from: from, to: to);
lib/exchange/trade.dart
+59 -1
@@ -33,6 +33,15 @@ class Trade extends HiveObject {
33 this.router,
34 this.userCurrencyFromRaw,
35 this.userCurrencyToRaw,
36 + // The following fields are used for SwapXyz trades only
37 + this.needToRegisterInSwapXyz,
38 + this.sourceTokenAddress,
39 + this.sourceTokenDecimals,
40 + this.routerData,
41 + this.routerValue,
42 + this.routerChainId,
43 + this.sourceTokenAmountRaw,
44 + this.requiresTokenApproval
45 }) {
46 if (provider != null) providerRaw = provider.raw;
47
@@ -133,6 +142,31 @@ class Trade extends HiveObject {
142 @HiveField(25, defaultValue: '')
143 String? userCurrencyToRaw;
144
145 + // The following fields are used for SwapXyz trades only
146 + @HiveField(26)
147 + bool? needToRegisterInSwapXyz;
148 +
149 + @HiveField(27)
150 + String? sourceTokenAddress;
151 +
152 + @HiveField(28)
153 + int? sourceTokenDecimals;
154 +
155 + @HiveField(29)
156 + String? routerData;
157 +
158 + @HiveField(30)
159 + String? routerValue;
160 +
161 + @HiveField(31)
162 + int? routerChainId;
163 +
164 + @HiveField(32)
165 + String? sourceTokenAmountRaw;
166 +
167 + @HiveField(33, defaultValue: false)
168 + bool? requiresTokenApproval;
169 +
170 CryptoCurrency? get userCurrencyFrom {
171 if (userCurrencyFromRaw == null || userCurrencyFromRaw!.isEmpty) {
172 return null;
@@ -249,6 +283,14 @@ class TradeAdapter extends TypeAdapter<Trade> {
283 router: fields[22] as String?,
284 userCurrencyFromRaw: fields[24] as String?,
285 userCurrencyToRaw: fields[25] as String?,
286 + needToRegisterInSwapXyz: fields[26] as bool?,
287 + sourceTokenAddress: fields[27] as String?,
288 + sourceTokenDecimals: fields[28] as int?,
289 + routerData: fields[29] as String?,
290 + routerValue: fields[30] as String?,
291 + routerChainId: fields[31] as int?,
292 + sourceTokenAmountRaw: fields[32] as String?,
293 + requiresTokenApproval: fields[33] as bool?,
294 )
295 ..providerRaw = fields[1] == null ? 0 : fields[1] as int
296 ..fromRaw = (fields[2] as int?) ?? -1
@@ -311,7 +353,23 @@ class TradeAdapter extends TypeAdapter<Trade> {
353 ..writeByte(24)
354 ..write(obj.userCurrencyFromRaw)
355 ..writeByte(25)
314 - ..write(obj.userCurrencyToRaw);
356 + ..write(obj.userCurrencyToRaw)
357 + ..writeByte(26)
358 + ..write(obj.needToRegisterInSwapXyz)
359 + ..writeByte(27)
360 + ..write(obj.sourceTokenAddress)
361 + ..writeByte(28)
362 + ..write(obj.sourceTokenDecimals)
363 + ..writeByte(29)
364 + ..write(obj.routerData)
365 + ..writeByte(30)
366 + ..write(obj.routerValue)
367 + ..writeByte(31)
368 + ..write(obj.routerChainId)
369 + ..writeByte(32)
370 + ..write(obj.sourceTokenAmountRaw)
371 + ..writeByte(33)
372 + ..write(obj.requiresTokenApproval);
373 }
374
375 @override
lib/polygon/cw_polygon.dart
+7
@@ -183,6 +183,13 @@ class CWPolygon extends Polygon {
183 @override
184 String getTokenAddress(CryptoCurrency asset) => (asset as Erc20Token).contractAddress;
185
186 + Future<bool> isApprovalRequired(WalletBase wallet, String tokenContract,String spender, BigInt requiredAmount) =>
187 + (wallet as PolygonWallet).isApprovalRequired(tokenContract, spender, requiredAmount);
188 +
189 + Future<PendingTransaction> createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei,
190 + TransactionPriority priority) =>
191 + (wallet as EVMChainWallet).createCallDataTransaction(to, dataHex, valueWei, priority as EVMChainTransactionPriority);
192 +
193 @override
194 Future<PendingTransaction> createTokenApproval(
195 WalletBase wallet,
lib/src/screens/exchange_trade/exchange_confirm_page.dart
+10 -9
@@ -65,15 +65,16 @@ class ExchangeConfirmPage extends BasePage {
65 fontWeight: FontWeight.w500,
66 ),
67 ),
68 - Text(
69 - trade.id,
70 - maxLines: 1,
71 - overflow: TextOverflow.ellipsis,
72 - style: Theme.of(context).textTheme.titleLarge?.copyWith(
73 - fontSize: 20,
74 - fontWeight: FontWeight.w600,
75 - color: Theme.of(context).colorScheme.onSurface,
76 - ),
68 + Expanded(
69 + child: Text(
70 + trade.id,
71 + overflow: TextOverflow.visible,
72 + style: Theme.of(context).textTheme.titleLarge?.copyWith(
73 + fontSize: 20,
74 + fontWeight: FontWeight.w600,
75 + color: Theme.of(context).colorScheme.onSurface,
76 + ),
77 + ),
78 ),
79 ],
80 ),
lib/src/screens/exchange_trade/exchange_trade_page.dart
+12 -2
@@ -1,3 +1,4 @@
1 +import 'package:cake_wallet/exchange/exchange_provider_description.dart';
2 import 'package:cake_wallet/reactions/wallet_connect.dart';
3 import 'package:cake_wallet/routes.dart';
4 import 'package:cake_wallet/src/screens/exchange/widgets/desktop_exchange_cards_section.dart';
@@ -173,6 +174,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
174 textColor: widget.exchangeTradeViewModel.isSendable
175 ? Theme.of(context).colorScheme.onSecondaryContainer
176 : Theme.of(context).colorScheme.onPrimary,
177 + isDisabled: widget.exchangeTradeViewModel.isSwapsXyzSendingEVMTokenSwap,
178 ),
179 SizedBox(height: 16),
180 Observer(
@@ -265,6 +267,14 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
267
268 if (state is ExecutedSuccessfullyState) {
269 WidgetsBinding.instance.addPostFrameCallback((_) async {
270 + final trade = widget.exchangeTradeViewModel.trade;
271 + final isSwapsXyz = trade.provider == ExchangeProviderDescription.swapsXyz;
272 + final isEVMWallet = widget.exchangeTradeViewModel.sendViewModel.isEVMWallet;
273 +
274 + final amountValue = isSwapsXyz && isEVMWallet
275 + ? trade.amount
276 + : widget.exchangeTradeViewModel.sendViewModel.pendingTransaction!.amountFormatted;
277 +
278 if (context.mounted) {
279 final result = await showModalBottomSheet<bool>(
280 context: context,
@@ -280,8 +290,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
290 widget.exchangeTradeViewModel.sendViewModel.selectedCryptoCurrency.iconPath,
291 currency: widget.exchangeTradeViewModel.sendViewModel.selectedCryptoCurrency,
292 amount: S.of(bottomSheetContext).send_amount,
283 - amountValue: widget
284 - .exchangeTradeViewModel.sendViewModel.pendingTransaction!.amountFormatted,
293 + amountValue: amountValue,
294 fiatAmountValue: widget
295 .exchangeTradeViewModel.sendViewModel.pendingTransactionFiatAmountFormatted,
296 fee:
@@ -298,6 +307,7 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
307 Navigator.of(bottomSheetContext).pop(true);
308 }
309 widget.exchangeTradeViewModel.sendViewModel.commitTransaction(context);
310 + widget.exchangeTradeViewModel.registerSwapsXyzTransaction();
311 },
312 );
313 },
lib/store/dashboard/trade_filter_store.dart
+15 -3
@@ -21,7 +21,8 @@ abstract class TradeFilterStoreBase with Store {
21 displayLetsExchange = true,
22 displayStealthEx = true,
23 displayXOSwap = true,
24 - displaySwapTrade = true;
24 + displaySwapTrade = true,
25 + displaySwapXyz = true;
26
27 @observable
28 bool displayXMRTO;
@@ -62,6 +63,9 @@ abstract class TradeFilterStoreBase with Store {
63 @observable
64 bool displaySwapTrade;
65
66 + @observable
67 + bool displaySwapXyz;
68 +
69 @computed
70 bool get displayAllTrades =>
71 displayChangeNow &&
@@ -74,7 +78,8 @@ abstract class TradeFilterStoreBase with Store {
78 displayLetsExchange &&
79 displayStealthEx &&
80 displayXOSwap &&
77 - displaySwapTrade;
81 + displaySwapTrade &&
82 + displaySwapXyz;
83
84 @action
85 void toggleDisplayExchange(ExchangeProviderDescription provider) {
@@ -118,6 +123,9 @@ abstract class TradeFilterStoreBase with Store {
123 case ExchangeProviderDescription.swapTrade:
124 displaySwapTrade = !displaySwapTrade;
125 break;
126 + case ExchangeProviderDescription.swapsXyz:
127 + displaySwapXyz = !displaySwapXyz;
128 + break;
129 case ExchangeProviderDescription.all:
130 if (displayAllTrades) {
131 displayChangeNow = false;
@@ -133,6 +141,7 @@ abstract class TradeFilterStoreBase with Store {
141 displayStealthEx = false;
142 displayXOSwap = false;
143 displaySwapTrade = false;
144 + displaySwapXyz = false;
145 } else {
146 displayChangeNow = true;
147 displaySideShift = true;
@@ -147,6 +156,7 @@ abstract class TradeFilterStoreBase with Store {
156 displayStealthEx = true;
157 displayXOSwap = true;
158 displaySwapTrade = true;
159 + displaySwapXyz = true;
160 }
161 break;
162 }
@@ -180,7 +190,9 @@ abstract class TradeFilterStoreBase with Store {
190 item.trade.provider == ExchangeProviderDescription.letsExchange) ||
191 (displayStealthEx && item.trade.provider == ExchangeProviderDescription.stealthEx) ||
192 (displayXOSwap && item.trade.provider == ExchangeProviderDescription.xoSwap) ||
183 - (displaySwapTrade && item.trade.provider == ExchangeProviderDescription.swapTrade))
193 + (displaySwapTrade && item.trade.provider == ExchangeProviderDescription.swapTrade) ||
194 + (displaySwapXyz &&
195 + item.trade.provider == ExchangeProviderDescription.swapsXyz))
196 .toList()
197 : _trades;
198 }
lib/view_model/dashboard/dashboard_view_model.dart
+5
@@ -181,6 +181,11 @@ abstract class DashboardViewModelBase with Store {
181 caption: ExchangeProviderDescription.swapTrade.title,
182 onChanged: () =>
183 tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapTrade)),
184 + FilterItem(
185 + value: () => tradeFilterStore.displaySwapXyz,
186 + caption: ExchangeProviderDescription.swapsXyz.title,
187 + onChanged: () =>
188 + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.swapsXyz)),
189 ]
190 },
191 subname = '',
lib/view_model/exchange/exchange_trade_view_model.dart
+75 -2
@@ -8,6 +8,7 @@ import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart';
8 import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
9 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
10 import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
11 +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
12 import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
13 import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart';
14 import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart';
@@ -80,6 +81,10 @@ abstract class ExchangeTradeViewModelBase with Store {
81 break;
82 case ExchangeProviderDescription.xoSwap:
83 _provider = XOSwapExchangeProvider();
84 + break;
85 + case ExchangeProviderDescription.swapsXyz:
86 + _provider = SwapsXyzExchangeProvider();
87 + break;
88 }
89
90 _updateItems();
@@ -104,6 +109,15 @@ abstract class ExchangeTradeViewModelBase with Store {
109 @observable
110 bool isSendable;
111
112 +
113 + bool get isSwapsXyzSendingEVMTokenSwap => (_provider is SwapsXyzExchangeProvider) &&
114 + (wallet.type == WalletType.ethereum &&
115 + wallet.currency != trade.from ||
116 + (wallet.type == WalletType.polygon &&
117 + wallet.currency != trade.from) ||
118 + (wallet.type == WalletType.base &&
119 + wallet.currency != trade.from));
120 +
121 String get extraInfo => trade.extraId != null && trade.extraId!.isNotEmpty
122 ? '\n\n' + S.current.exchange_extra_info
123 : '';
@@ -189,7 +203,19 @@ abstract class ExchangeTradeViewModelBase with Store {
203
204 sendViewModel.selectedCryptoCurrency = selected;
205
192 - final pendingTransaction = await sendViewModel.createTransaction(provider: _provider);
206 + final pendingTransaction = await sendViewModel.createTransaction(provider: _provider, trade: trade);
207 +
208 + if (_provider is SwapsXyzExchangeProvider) {
209 + final hash = pendingTransaction?.evmTxHashFromRawHex ?? pendingTransaction?.id ?? '';
210 + trade.txId = hash;
211 +
212 + if (trade.isInBox) {
213 + await trade.save();
214 + } else {
215 + await trades.add(trade);
216 + }
217 + }
218 +
219 if (_provider is ThorChainExchangeProvider) {
220 trade.id = pendingTransaction?.id ?? '';
221 trades.add(trade);
@@ -308,7 +334,7 @@ abstract class ExchangeTradeViewModelBase with Store {
334 tradeFrom?.tag == CryptoCurrency.maticpoly.tag;
335
336 bool _isBaseToken() =>
311 - wallet.currency == CryptoCurrency.baseEth && tradeFrom?.tag == CryptoCurrency.baseEth.title;
337 + wallet.currency == CryptoCurrency.baseEth && tradeFrom?.tag == CryptoCurrency.baseEth.tag;
338
339 bool _isTronToken() =>
340 wallet.currency == CryptoCurrency.trx && tradeFrom?.tag == CryptoCurrency.trx.title;
@@ -324,6 +350,53 @@ abstract class ExchangeTradeViewModelBase with Store {
350 _isTronToken() ||
351 _isBaseToken();
352 }
353 +
354 + Future<void> registerSwapsXyzTransaction() async {
355 + try {
356 + if (!(_provider is SwapsXyzExchangeProvider)) return;
357 + final swaps = _provider as SwapsXyzExchangeProvider;
358 +
359 + // register only for vmId is alt-vm or bridgeId is alt-vm (trade.needToRegisterInSwapXyz)
360 + final needToRegister = trade.needToRegisterInSwapXyz ?? false;
361 + if (!needToRegister) return;
362 +
363 + final vmId = (trade.providerId ?? '').toLowerCase();
364 + if (vmId.isEmpty) {
365 + printV('SwapsXyz: transaction register: skipped (vmId empty)');
366 + return;
367 + }
368 +
369 + final txHash = sendViewModel.pendingTransaction?.evmTxHashFromRawHex ?? sendViewModel.pendingTransaction?.id ?? '';
370 +
371 + if (txHash.isEmpty) {
372 + printV('SwapsXyz: transaction register: skipped (txHash empty)');
373 + return;
374 + }
375 +
376 + final chainId = int.tryParse(trade.router ?? '') ?? 0;
377 + if (chainId <= 0) {
378 + printV('SwapsXyz: transaction register: skipped (invalid chainId)');
379 + return;
380 + }
381 +
382 + printV('SwapsXyz: attempting to register transaction: tradeId = ${trade.id}, txHash = $txHash, chainId = $chainId, vmId = $vmId');
383 +
384 + final registered = await swaps.registerAltVmTx(
385 + txId: trade.id,
386 + txHash: txHash,
387 + chainId: chainId,
388 + vmId: vmId,
389 + );
390 +
391 + if (!registered) {
392 + printV('SwapsXyz: transaction register: failed');
393 + } else {
394 + printV('SwapsXyz: transaction register: success');
395 + }
396 + } catch (e) {
397 + printV('registerSwapsXyzTransaction error: $e');
398 + }
399 + }
400
401 PaymentURI? get paymentUri {
402 final inputAddress = trade.inputAddress;
lib/view_model/exchange/exchange_view_model.dart
+3 -1
@@ -27,6 +27,7 @@ import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
27 import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
28 import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart';
29 import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart';
30 +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
31 import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
32 import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart';
33 import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart';
@@ -214,6 +215,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
215 LetsExchangeExchangeProvider(),
216 StealthExExchangeProvider(),
217 XOSwapExchangeProvider(),
218 + SwapsXyzExchangeProvider(),
219 TrocadorExchangeProvider(
220 useTorOnly: _useTorOnly, providerStates: _settingsStore.trocadorProviderStates),
221 ];
@@ -536,7 +538,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with
538 to: receiveCurrency,
539 amount: amount,
540 isFixedRateMode: isFixedRateMode,
539 - isReceiveAmount: isFixedRateMode,
541 + isReceiveAmount: isFixedRateMode
542 )
543 .timeout(
544 Duration(seconds: 7),
lib/view_model/send/send_view_model.dart
+251 -1
@@ -22,7 +22,9 @@ import 'package:cake_wallet/entities/transaction_description.dart';
22 import 'package:cake_wallet/entities/wallet_contact.dart';
23 import 'package:cake_wallet/ethereum/ethereum.dart';
24 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
25 +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
26 import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart';
27 +import 'package:cake_wallet/exchange/trade.dart';
28 import 'package:cake_wallet/generated/i18n.dart';
29 import 'package:cake_wallet/monero/monero.dart';
30 import 'package:cake_wallet/nano/nano.dart';
@@ -46,6 +48,7 @@ import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_mod
48 import 'package:cake_wallet/wownero/wownero.dart';
49 import 'package:cake_wallet/zano/zano.dart';
50 import 'package:cw_core/crypto_currency.dart';
51 +import 'package:cw_core/erc20_token.dart';
52 import 'package:cw_core/currency_for_wallet_type.dart';
53 import 'package:cw_core/exceptions.dart';
54 import 'package:cw_core/pending_transaction.dart';
@@ -119,6 +122,9 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
122 });
123 }
124
125 + PendingTransaction? _pendingApprovalTx;
126 + bool _isSwapsXYZCallDataTx = false;
127 +
128 @observable
129 ExecutionState state;
130
@@ -133,6 +139,9 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
139
140 bool get isMwebEnabled => balanceViewModel.mwebEnabled;
141
142 + bool get isEVMWallet => walletType == WalletType.ethereum || walletType == WalletType.polygon ||
143 + walletType == WalletType.base;
144 +
145 @action
146 void setShowAddressBookPopup(bool value) {
147 _settingsStore.showAddressBookPopupEnabled = value;
@@ -490,7 +499,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
499 Timer? _ledgerTxStateTimer;
500
501 @action
493 - Future<PendingTransaction?> createTransaction({ExchangeProvider? provider}) async {
502 + Future<PendingTransaction?> createTransaction({ExchangeProvider? provider, Trade? trade}) async {
503 try {
504 if (!(state is IsExecutingState)) state = IsExecutingState();
505
@@ -505,6 +514,151 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
514 });
515 }
516
517 +
518 + // Swaps.xyz (EVM) path
519 +
520 + if (isEVMWallet && trade != null && provider is SwapsXyzExchangeProvider) {
521 + final routerTo = trade.inputAddress;
522 + final routerData = trade.routerData;
523 + final routerValueWei =
524 + BigInt.tryParse((trade.routerValue ?? '0').toString()) ?? BigInt.zero;
525 +
526 + if (routerTo?.isNotEmpty == true && routerData?.isNotEmpty == true) {
527 +
528 + // detect prepared ERC-20 transfer(...) (alt-vm deposit pattern)
529 + String _selector(String s) =>
530 + (s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : '';
531 + const _transferSig = '0xa9059cbb';
532 + final _sel = _selector(routerData!);
533 + final _isPreparedTransfer = _sel == _transferSig &&
534 + (trade.sourceTokenAddress ?? '').toLowerCase() == (routerTo ?? '').toLowerCase();
535 +
536 + _pendingApprovalTx = null;
537 +
538 + // Optionally prebuild approval (SKIP for prepared transfer)
539 + final tokenContract = trade.sourceTokenAddress ?? '';
540 + final requiredAmount = BigInt.tryParse(
541 + (trade.sourceTokenAmountRaw ?? '0').replaceAll('n', ''),
542 + ) ?? BigInt.zero;
543 +
544 + // Only do approval when NOT a prepared transfer, and only if the API hinted we might need it
545 + final requiresTokenApproval = (trade.requiresTokenApproval ?? false) && !_isPreparedTransfer;
546 +
547 + if (requiresTokenApproval && tokenContract.isNotEmpty && requiredAmount > BigInt.zero) {
548 + if (walletType == WalletType.ethereum) {
549 + final priority = _settingsStore.priority[WalletType.ethereum]!;
550 + _pendingApprovalTx = await buildApprovalIfNeeded(
551 + spender: routerTo!, // if API provides a specific spender, use that instead
552 + tokenContract: tokenContract,
553 + requiredAmount: requiredAmount,
554 + sourceTokenDecimals: trade.sourceTokenDecimals,
555 + );
556 +
557 + // Build the callData tx
558 + pendingTransaction = await ethereum!.createRawCallDataTransaction(
559 + wallet,
560 + routerTo,
561 + routerData,
562 + routerValueWei,
563 + priority,
564 + );
565 +
566 + _isSwapsXYZCallDataTx = true;
567 + state = ExecutedSuccessfullyState();
568 + return pendingTransaction; // do NOT fall back to regular flow
569 + }
570 + if (walletType == WalletType.polygon) {
571 + final priority = _settingsStore.priority[WalletType.polygon]!;
572 + _pendingApprovalTx = await buildApprovalIfNeeded(
573 + spender: routerTo!,
574 + tokenContract: tokenContract,
575 + requiredAmount: requiredAmount,
576 + sourceTokenDecimals: trade.sourceTokenDecimals,
577 + );
578 +
579 + // Build the callData tx
580 + pendingTransaction = await polygon!.createRawCallDataTransaction(
581 + wallet,
582 + routerTo,
583 + routerData,
584 + routerValueWei,
585 + priority,
586 + );
587 +
588 + _isSwapsXYZCallDataTx = true;
589 + state = ExecutedSuccessfullyState();
590 + return pendingTransaction; // do NOT fall back to regular flow
591 + }
592 + if (walletType == WalletType.base) {
593 + final priority = _settingsStore.priority[WalletType.base]!;
594 + _pendingApprovalTx = await buildApprovalIfNeeded(
595 + spender: routerTo!,
596 + tokenContract: tokenContract,
597 + requiredAmount: requiredAmount,
598 + sourceTokenDecimals: trade.sourceTokenDecimals,
599 + );
600 +
601 + // Build the callData tx
602 + pendingTransaction = await base!.createRawCallDataTransaction(
603 + wallet,
604 + routerTo,
605 + routerData,
606 + routerValueWei,
607 + priority,
608 + );
609 +
610 + _isSwapsXYZCallDataTx = true;
611 + state = ExecutedSuccessfullyState();
612 + return pendingTransaction; // do NOT fall back to regular flow
613 + }
614 + }
615 +
616 + // No approval needed (or prepared transfer): send exactly what backend prepared
617 + if (walletType == WalletType.ethereum) {
618 + final priority = _settingsStore.priority[WalletType.ethereum]!;
619 + pendingTransaction = await ethereum!.createRawCallDataTransaction(
620 + wallet,
621 + routerTo!,
622 + routerData,
623 + routerValueWei,
624 + priority,
625 + );
626 + _isSwapsXYZCallDataTx = true;
627 + state = ExecutedSuccessfullyState();
628 + return pendingTransaction;
629 + }
630 + if (walletType == WalletType.polygon) {
631 + final priority = _settingsStore.priority[WalletType.polygon]!;
632 + pendingTransaction = await polygon!.createRawCallDataTransaction(
633 + wallet,
634 + routerTo!,
635 + routerData,
636 + routerValueWei,
637 + priority,
638 + );
639 + _isSwapsXYZCallDataTx = true;
640 + state = ExecutedSuccessfullyState();
641 + return pendingTransaction;
642 + }
643 + if (walletType == WalletType.base) {
644 + final priority = _settingsStore.priority[WalletType.polygon]!;
645 + pendingTransaction = await base!.createRawCallDataTransaction(
646 + wallet,
647 + routerTo!,
648 + routerData,
649 + routerValueWei,
650 + priority,
651 + );
652 + _isSwapsXYZCallDataTx = true;
653 + state = ExecutedSuccessfullyState();
654 + return pendingTransaction;
655 + }
656 + }
657 + }
658 +
659 +
660 + // Regular flow
661 +
662 pendingTransaction = await wallet.createTransaction(_credentials(provider));
663
664 if (provider is ThorChainExchangeProvider) {
@@ -602,6 +756,37 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
756 return;
757 }
758
759 + // Swaps.xyz approval (if any), then commit the prebuilt router tx
760 + if (_isSwapsXYZCallDataTx) {
761 + if (_pendingApprovalTx != null) {
762 + await _pendingApprovalTx!.commit();
763 + _pendingApprovalTx = null;
764 + // Small pause to ensure allowance is indexed
765 + await Future.delayed(const Duration(milliseconds: 300));
766 + }
767 +
768 + await pendingTransaction!.commit();
769 + _isSwapsXYZCallDataTx = false;
770 +
771 + state = TransactionCommitted();
772 + return; // skip the regular flow below
773 + }
774 +
775 + // Regular flow (non-Swaps)
776 + if (pendingTransaction!.shouldCommitUR()) {
777 + final urstr = await pendingTransaction!.commitUR();
778 + final result = await Navigator.of(context).pushNamed(
779 + Routes.urqrAnimatedPage,
780 + arguments: urstr,
781 + );
782 + if (result == null) {
783 + state = FailureState("Canceled by user");
784 + return;
785 + }
786 + } else {
787 + await pendingTransaction!.commit();
788 + }
789 +
790 String address = outputs.fold('', (acc, value) {
791 return value.isParsedAddress
792 ? '$acc${value.address}\n${value.extractedAddress}\n\n'
@@ -933,6 +1118,71 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
1118 return false;
1119 }
1120
1121 + Future<PendingTransaction?> buildApprovalIfNeeded({
1122 + required String spender,
1123 + required String tokenContract,
1124 + required BigInt requiredAmount,
1125 + int? sourceTokenDecimals,
1126 + }) async {
1127 +
1128 + // Only EVM chains support ERC20 approvals
1129 + if (!isEVMWallet) return null;
1130 +
1131 + const zero = '0x0000000000000000000000000000000000000000';
1132 + const evmNative = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
1133 +
1134 + final tokenLc = tokenContract.toLowerCase();
1135 + if (tokenLc == zero || tokenLc == evmNative.toLowerCase()) return null;
1136 + if (requiredAmount <= BigInt.zero) return null;
1137 +
1138 + bool needsApproval = false;
1139 + if (walletType == WalletType.ethereum) {
1140 + needsApproval = await ethereum!.isApprovalRequired(
1141 + wallet, tokenContract, spender, requiredAmount,
1142 + );
1143 + } else if (walletType == WalletType.polygon) {
1144 + needsApproval = await polygon!.isApprovalRequired(
1145 + wallet, tokenContract, spender, requiredAmount,
1146 + );
1147 + } else if (walletType == WalletType.base) {
1148 + needsApproval = await base!.isApprovalRequired(
1149 + wallet, tokenContract, spender, requiredAmount,
1150 + );
1151 + }
1152 +
1153 + if (!needsApproval) return null;
1154 +
1155 + final erc20Token = wallet.balance.keys.whereType<Erc20Token>().firstWhere(
1156 + (t) => t.contractAddress.toLowerCase() == tokenLc,
1157 + orElse: () => Erc20Token(
1158 + name: '',
1159 + symbol: '',
1160 + contractAddress: tokenContract,
1161 + decimal: sourceTokenDecimals ?? 18,
1162 + enabled: true,
1163 + ),
1164 + );
1165 +
1166 + if (walletType == WalletType.ethereum) {
1167 + final priority = _settingsStore.priority[WalletType.ethereum]!;
1168 + return await ethereum!.createTokenApproval(
1169 + wallet, requiredAmount, spender, erc20Token, priority,
1170 + );
1171 + } else if (walletType == WalletType.polygon) {
1172 + final priority = _settingsStore.priority[WalletType.polygon]!;
1173 + return await polygon!.createTokenApproval(
1174 + wallet, requiredAmount, spender, erc20Token, priority,
1175 + );
1176 + } else if (walletType == WalletType.base) {
1177 + final priority = _settingsStore.priority[WalletType.base]!;
1178 + return await base!.createTokenApproval(
1179 + wallet, requiredAmount, spender, erc20Token, priority,
1180 + );
1181 + }
1182 +
1183 + return null;
1184 + }
1185 +
1186 @computed
1187 bool get usePayjoin => _settingsStore.usePayjoin;
1188
lib/view_model/trade_details_view_model.dart
+15 -1
@@ -6,6 +6,7 @@ import 'package:cake_wallet/exchange/provider/changenow_exchange_provider.dart';
6 import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
7 import 'package:cake_wallet/exchange/provider/exolix_exchange_provider.dart';
8 import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart';
9 +import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
10 import 'package:cake_wallet/exchange/provider/swaptrade_exchange_provider.dart';
11 import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart';
12 import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart';
@@ -77,6 +78,9 @@ abstract class TradeDetailsViewModelBase with Store {
78 case ExchangeProviderDescription.xoSwap:
79 _provider = XOSwapExchangeProvider();
80 break;
81 + case ExchangeProviderDescription.swapsXyz:
82 + _provider = SwapsXyzExchangeProvider();
83 + break;
84 }
85
86 _updateItems();
@@ -134,8 +138,13 @@ abstract class TradeDetailsViewModelBase with Store {
138 try {
139 final updatedTrade = await _provider!.findTradeById(id: trade.id);
140
137 - if (updatedTrade.createdAt == null && trade.createdAt != null)
141 + if (updatedTrade.createdAt == null && trade.createdAt != null) {
142 updatedTrade.createdAt = trade.createdAt;
143 + }
144 +
145 + if (updatedTrade.toRaw == -1 && trade.toRaw != -1) {
146 + updatedTrade.toRaw = trade.toRaw;
147 + }
148
149 Trade? foundElement = trades.values.firstWhereOrNull((element) => element.id == trade.id);
150 if (foundElement != null) {
@@ -205,6 +214,11 @@ abstract class TradeDetailsViewModelBase with Store {
214 title: '${trade.providerName} ${S.current.password}', value: trade.password ?? ''));
215 }
216 }
217 +
218 + if (trade.provider == ExchangeProviderDescription.swapsXyz && trade.txId != null && trade.txId!.isNotEmpty) {
219 + items.add(StandartListItem(
220 + title: 'Transaction ID', value: trade.txId!));
221 + }
222 }
223
224 void _launchUrl(String url) {
tool/configure.dart
+6
@@ -776,7 +776,9 @@ abstract class Ethereum {
776 Web3Client? getWeb3Client(WalletBase wallet);
777 String getTokenAddress(CryptoCurrency asset);
778
779 + Future<bool> isApprovalRequired(WalletBase wallet, String tokenContract, String spender, BigInt requiredAmount);
780 Future<PendingTransaction> createTokenApproval(WalletBase wallet, BigInt amount, String spender, CryptoCurrency token, TransactionPriority priority);
781 + Future<PendingTransaction> createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, TransactionPriority priority);
782
783 Future<BigInt> getDEuroSavingsBalance(WalletBase wallet);
784 Future<BigInt> getDEuroAccruedInterest(WalletBase wallet);
@@ -899,7 +901,9 @@ abstract class Polygon {
901 Future<void> removeTokenTransactionsInHistory(WalletBase wallet, CryptoCurrency token);
902 Future<Erc20Token?> getErc20Token(WalletBase wallet, String contractAddress);
903
904 + Future<bool> isApprovalRequired(WalletBase wallet, String tokenContract, String spender, BigInt requiredAmount);
905 Future<PendingTransaction> createTokenApproval(WalletBase wallet, BigInt amount, String spender, CryptoCurrency token, TransactionPriority priority);
906 + Future<PendingTransaction> createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, TransactionPriority priority);
907
908 CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction);
909 void updatePolygonScanUsageState(WalletBase wallet, bool isEnabled);
@@ -1595,8 +1599,10 @@ abstract class Base {
1599 Future<void> removeTokenTransactionsInHistory(WalletBase wallet, CryptoCurrency token);
1600 Future<Erc20Token?> getErc20Token(WalletBase wallet, String contractAddress);
1601
1602 + Future<bool> isApprovalRequired(WalletBase wallet, String tokenContract, String spender, BigInt requiredAmount);
1603 Future<PendingTransaction> createTokenApproval(WalletBase wallet, BigInt amount, String spender,
1604 CryptoCurrency token, TransactionPriority priority);
1605 + Future<PendingTransaction> createRawCallDataTransaction(WalletBase wallet, String to, String dataHex, BigInt valueWei, TransactionPriority priority);
1606
1607 CryptoCurrency assetOfTransaction(WalletBase wallet, TransactionInfo transaction);
1608 void updateBaseScanUsageState(WalletBase wallet, bool isEnabled);
tool/utils/secret_key.dart
+1
@@ -82,6 +82,7 @@ class SecretKey {
82 SecretKey('chainflipAffiliateFee', () => ''),
83 SecretKey('kryptonimApiKey', () => ''),
84 SecretKey('walletGroupSalt', () => hex.encode(encrypt.Key.fromSecureRandom(16).bytes)),
85 + SecretKey('swapsXyzApiKey', () => ''),
86 ];
87
88 static final evmChainsSecrets = [