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>
This commit is contained in:
Serhii
2025-10-20 23:22:01 +02:00
committed by GitHub
co-authored by Omar Hatem
parent 697ec0ab82
commit ee0996f535
37 changed files with 1557 additions and 77 deletions
@@ -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 = '',
@@ -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<void> 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;
@@ -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),
+251 -1
View File
@@ -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<PendingTransaction?> createTransaction({ExchangeProvider? provider}) async {
Future<PendingTransaction?> 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<PendingTransaction?> 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<Erc20Token>().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;
+15 -1
View File
@@ -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) {