diff --git a/assets/images/mayachain.png b/assets/images/mayachain.png new file mode 100644 index 0000000000..f1694a8640 Binary files /dev/null and b/assets/images/mayachain.png differ diff --git a/lib/exchange/exchange_provider_description.dart b/lib/exchange/exchange_provider_description.dart index 9f37233564..34d8ad0915 100644 --- a/lib/exchange/exchange_provider_description.dart +++ b/lib/exchange/exchange_provider_description.dart @@ -31,6 +31,8 @@ class ExchangeProviderDescription extends EnumerableItem with Serializable< ExchangeProviderDescription(title: 'LetsExchange', raw: 10, image: 'assets/images/letsexchange_icon.svg'); static const stealthEx = ExchangeProviderDescription(title: 'StealthEx', raw: 11, image: 'assets/images/stealthex.png'); + static const mayaChain = + ExchangeProviderDescription(title: 'MayaChain', raw: 12, image: 'assets/images/mayachain.png'); static ExchangeProviderDescription deserialize({required int raw}) { switch (raw) { @@ -58,6 +60,8 @@ class ExchangeProviderDescription extends EnumerableItem with Serializable< return letsExchange; case 11: return stealthEx; + case 12: + return mayaChain; default: throw Exception('Unexpected token: $raw for ExchangeProviderDescription deserialize'); } diff --git a/lib/exchange/provider/mayachain_exchange.provider.dart b/lib/exchange/provider/mayachain_exchange.provider.dart new file mode 100644 index 0000000000..557e81c4d4 --- /dev/null +++ b/lib/exchange/provider/mayachain_exchange.provider.dart @@ -0,0 +1,294 @@ +import 'dart:convert'; + +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:cake_wallet/exchange/limits.dart'; +import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; +import 'package:cake_wallet/exchange/trade.dart'; +import 'package:cake_wallet/exchange/trade_request.dart'; +import 'package:cake_wallet/exchange/trade_state.dart'; +import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:hive/hive.dart'; +import 'package:http/http.dart' as http; + +class MayaChainExchangeProvider extends ExchangeProvider { + MayaChainExchangeProvider({required this.tradesStore}) + : super(pairList: supportedPairs(_notSupported)); + + static final List _notSupported = [ + ...(CryptoCurrency.all + .where((element) => ![ + CryptoCurrency.btc, + CryptoCurrency.dash, + CryptoCurrency.eth, + CryptoCurrency.pepe, + CryptoCurrency.usdc, + CryptoCurrency.usdterc20, + ].contains(element)) + .toList()) + ]; + + static final isRefundAddressSupported = [CryptoCurrency.eth]; + + static const _baseNodeURL = 'https://mayanode.mayachain.info'; + static const _baseURL = 'https://midgard.mayachain.info'; + static const _quotePath = '/mayachain/quote/swap'; + static const _txInfoPath = '/mayachain/tx/status/'; + static const _affiliateName = 'cakewallet'; // register a shorter one + static const _affiliateBps = '175'; + static const _nameLookUpPath = 'v2/mayaname/lookup/'; + static const _affiliateBps = '175'; + static const _toleranceBps = '100'; + static const _streamingInterval = '3'; + + final Box tradesStore; + + @override + String get title => 'MAYAChain'; + + @override + bool get isAvailable => true; + + @override + bool get isEnabled => true; + + @override + bool get supportsFixedRate => false; + + @override + ExchangeProviderDescription get description => ExchangeProviderDescription.mayaChain; + + @override + Future checkIsAvailable() async => true; + + @override + Future fetchRate( + {required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount}) async { + try { + if (amount == 0) return 0.0; + + final params = { + 'from_asset': _normalizeCurrency(from), + 'to_asset': _normalizeCurrency(to), + 'amount': _doubleToMayaChainString(amount), + 'streaming_interval': _streamingInterval, + 'tolerance_bps': _toleranceBps, + 'affiliate': _affiliateName, + 'affiliate_bps': _affiliateBps + }; + + final responseJSON = await _getSwapQuote(params); + + final expectedAmountOut = responseJSON['expected_amount_out'] as String? ?? '0.0'; + + return _mayaChainAmountToDouble(expectedAmountOut) / amount; + } catch (e) { + print(e.toString()); + return 0.0; + } + } + + @override + Future fetchLimits( + {required CryptoCurrency from, + required CryptoCurrency to, + required bool isFixedRateMode}) async { + final params = { + 'from_asset': _normalizeCurrency(from), + 'to_asset': _normalizeCurrency(to), + 'amount': _doubleToMayaChainString(1), + 'streaming_interval': _streamingInterval, + 'tolerance_bps': _toleranceBps, + 'affiliate': _affiliateName, + 'affiliate_bps': _affiliateBps + }; + + final responseJSON = await _getSwapQuote(params); + final minAmountIn = responseJSON['recommended_min_amount_in'] as String? ?? '0.0'; + + return Limits(min: _mayaChainAmountToDouble(minAmountIn)); + } + + @override + Future createTrade({ + required TradeRequest request, + required bool isFixedRateMode, + required bool isSendAll, + }) async { + String formattedToAddress = request.toAddress; + + final formattedFromAmount = double.parse(request.fromAmount); + + final params = { + 'from_asset': _normalizeCurrency(request.fromCurrency), + 'to_asset': _normalizeCurrency(request.toCurrency), + 'amount': _doubleToMayaChainString(formattedFromAmount), + 'destination': formattedToAddress, + 'streaming_interval': _streamingInterval, + 'tolerance_bps': _toleranceBps, + 'affiliate': _affiliateName, + 'affiliate_bps': _affiliateBps, + 'refund_address': + isRefundAddressSupported.contains(request.fromCurrency) ? request.refundAddress : '', + }; + + final responseJSON = await _getSwapQuote(params); + + final inputAddress = responseJSON['inbound_address'] as String?; + final memo = responseJSON['memo'] as String?; + final directAmountOutResponse = responseJSON['expected_amount_out'] as String?; + + String? receiveAmount; + if (directAmountOutResponse != null) { + receiveAmount = _mayaChainAmountToDouble(directAmountOutResponse).toString(); + } + + return Trade( + id: '', + from: request.fromCurrency, + to: request.toCurrency, + provider: description, + inputAddress: inputAddress, + createdAt: DateTime.now(), + amount: request.fromAmount, + receiveAmount: receiveAmount ?? request.toAmount, + state: TradeState.notFound, + payoutAddress: request.toAddress, + memo: memo, + isSendAll: isSendAll, + ); + } + + @override + Future findTradeById({required String id}) async { + if (id.isEmpty) throw Exception('Trade id is empty'); + final formattedId = id.startsWith('0x') ? id.substring(2) : id; + final uri = Uri.https(_baseNodeURL, '$_txInfoPath$formattedId'); + final response = await http.get(uri); + + if (response.statusCode == 404) { + throw Exception('Trade not found for id: $formattedId'); + } else if (response.statusCode != 200) { + throw Exception('Unexpected HTTP status: ${response.statusCode}'); + } + + final responseJSON = json.decode(response.body); + final Map stagesJson = responseJSON['stages'] as Map; + + final inboundObservedStarted = stagesJson['inbound_observed']?['started'] as bool? ?? true; + if (!inboundObservedStarted) { + throw Exception('Trade has not started for id: $formattedId'); + } + + final currentState = _updateStateBasedOnStages(stagesJson) ?? TradeState.notFound; + + final tx = responseJSON['tx']; + final String fromAddress = tx['from_address'] as String? ?? ''; + final String toAddress = tx['to_address'] as String? ?? ''; + final List coins = tx['coins'] as List; + final String? memo = tx['memo'] as String?; + + final parts = memo?.split(':') ?? []; + + final String toChain = parts.length > 1 ? parts[1].split('.')[0] : ''; + final String toAsset = parts.length > 1 && parts[1].split('.').length > 1 + ? parts[1].split('.')[1].split('-')[0] + : ''; + + final formattedToChain = CryptoCurrency.fromString(toChain); + final toAssetWithChain = CryptoCurrency.fromString(toAsset, walletCurrency: formattedToChain); + + final plannedOutTxs = responseJSON['planned_out_txs'] as List?; + final isRefund = plannedOutTxs?.any((tx) => tx['refund'] == true) ?? false; + + return Trade( + id: id, + from: CryptoCurrency.fromString(tx['chain'] as String? ?? ''), + to: toAssetWithChain, + provider: description, + inputAddress: fromAddress, + payoutAddress: toAddress, + amount: coins.first['amount'] as String? ?? '0.0', + state: currentState, + memo: memo, + isRefund: isRefund, + ); + } + + static Future?>? lookupAddressByName(String name) async { + final uri = Uri.https(_baseURL, '$_nameLookUpPath$name'); + final response = await http.get(uri); + + if (response.statusCode != 200) { + return null; + } + + final body = json.decode(response.body) as Map; + final entries = body['entries'] as List?; + + if (entries == null || entries.isEmpty) { + return null; + } + + Map chainToAddressMap = {}; + + for (final entry in entries) { + final chain = entry['chain'] as String; + final address = entry['address'] as String; + chainToAddressMap[chain] = address; + } + + return chainToAddressMap; + } + + Future> _getSwapQuote(Map params) async { + Uri uri = Uri.https(_baseNodeURL, _quotePath, params); + + final response = await http.get(uri); + + if (response.statusCode != 200) { + throw Exception('Unexpected HTTP status: ${response.statusCode}'); + } + + if (response.body.contains('error')) { + throw Exception('Unexpected response: ${response.body}'); + } + + return json.decode(response.body) as Map; + } + + String _normalizeCurrency(CryptoCurrency currency) { + final networkTitle = currency.tag == 'ETH' ? 'ETH' : currency.title; + return '$networkTitle.${currency.title}'; + } + + String _doubleTomayaChainString(double amount) => (amount * 1e8).toInt().toString(); + + double _mayaChainAmountToDouble(String amount) => double.parse(amount) / 1e8; + + TradeState? _updateStateBasedOnStages(Map stages) { + TradeState? currentState; + + if (stages['inbound_observed']['completed'] as bool? ?? false) { + currentState = TradeState.confirmation; + } + if (stages['inbound_confirmation_counted']['completed'] as bool? ?? false) { + currentState = TradeState.confirmed; + } + if (stages['inbound_finalised']['completed'] as bool? ?? false) { + currentState = TradeState.processing; + } + if (stages['swap_finalised']['completed'] as bool? ?? false) { + currentState = TradeState.traded; + } + if (stages['outbound_signed']['completed'] as bool? ?? false) { + currentState = TradeState.success; + } + + return currentState; + } +} diff --git a/lib/src/screens/exchange/exchange_page.dart b/lib/src/screens/exchange/exchange_page.dart index 2f8e3eb5ce..9d247f2e64 100644 --- a/lib/src/screens/exchange/exchange_page.dart +++ b/lib/src/screens/exchange/exchange_page.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/exchange/exchange_provider_description.dart'; import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; +import 'package:cake_wallet/exchange/provider/mayachain_exchange.provider.dart'; import 'package:cake_wallet/themes/extensions/exchange_page_theme.dart'; import 'package:cake_wallet/themes/extensions/keyboard_theme.dart'; import 'package:cake_wallet/core/auth_service.dart'; @@ -442,7 +443,7 @@ class ExchangePage extends BasePage { } if (state is TradeIsCreatedSuccessfully) { exchangeViewModel.reset(); - (exchangeViewModel.tradesStore.trade?.provider == ExchangeProviderDescription.thorChain) + (exchangeViewModel.tradesStore.trade?.provider == ExchangeProviderDescription.thorChain || exchangeViewModel.tradesStore.trade?.provider == ExchangeProviderDescription.mayaChain) ? Navigator.of(context).pushReplacementNamed(Routes.exchangeTrade) : Navigator.of(context).pushReplacementNamed(Routes.exchangeConfirm); } @@ -485,8 +486,10 @@ class ExchangePage extends BasePage { exchangeViewModel.isSendAllEnabled = false; final isThorChain = exchangeViewModel.selectedProviders .any((provider) => provider is ThorChainExchangeProvider); + final isMayaChain = exchangeViewModel.selectedProviders + .any((provider) => provider is MayaChainExchangeProvider); - _depositAmountDebounce = isThorChain + _depositAmountDebounce = (isThorChain || isMayaChain) ? Debounce(Duration(milliseconds: 1000)) : Debounce(Duration(milliseconds: 500)); diff --git a/lib/store/dashboard/trade_filter_store.dart b/lib/store/dashboard/trade_filter_store.dart index c1e462cd6c..1647db1675 100644 --- a/lib/store/dashboard/trade_filter_store.dart +++ b/lib/store/dashboard/trade_filter_store.dart @@ -17,6 +17,7 @@ abstract class TradeFilterStoreBase with Store { displayTrocador = true, displayExolix = true, displayThorChain = true, + displayMayaChain = true, displayLetsExchange = true, displayStealthEx = true; @@ -44,6 +45,9 @@ abstract class TradeFilterStoreBase with Store { @observable bool displayThorChain; + @observable + bool displayMayaChain; + @observable bool displayLetsExchange; @@ -58,6 +62,7 @@ abstract class TradeFilterStoreBase with Store { displayTrocador && displayExolix && displayThorChain && + displayMayaChain && displayLetsExchange && displayStealthEx; @@ -88,6 +93,9 @@ abstract class TradeFilterStoreBase with Store { case ExchangeProviderDescription.thorChain: displayThorChain = !displayThorChain; break; + case ExchangeProviderDescription.mayaChain: + displayMayaChain = !displayMayaChain; + break; case ExchangeProviderDescription.letsExchange: displayLetsExchange = !displayLetsExchange; case ExchangeProviderDescription.stealthEx: @@ -103,6 +111,7 @@ abstract class TradeFilterStoreBase with Store { displayTrocador = false; displayExolix = false; displayThorChain = false; + displayMayaChain = false; displayLetsExchange = false; displayStealthEx = false; } else { @@ -114,6 +123,7 @@ abstract class TradeFilterStoreBase with Store { displayTrocador = true; displayExolix = true; displayThorChain = true; + displayMayaChain = true; displayLetsExchange = true; displayStealthEx = true; } @@ -143,6 +153,8 @@ abstract class TradeFilterStoreBase with Store { (displayExolix && item.trade.provider == ExchangeProviderDescription.exolix) || (displayThorChain && item.trade.provider == ExchangeProviderDescription.thorChain) || + (displayMayaChain && + item.trade.provider == ExchangeProviderDescription.mayaChain) || (displayLetsExchange && item.trade.provider == ExchangeProviderDescription.letsExchange) || (displayStealthEx && item.trade.provider == ExchangeProviderDescription.stealthEx)) diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 808657f66c..99f1c06e07 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -137,6 +137,11 @@ abstract class DashboardViewModelBase with Store { caption: ExchangeProviderDescription.thorChain.title, onChanged: () => tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.thorChain)), + FilterItem( + value: () => tradeFilterStore.displayMayaChain, + caption: ExchangeProviderDescription.mayaChain.title, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.mayaChain)), FilterItem( value: () => tradeFilterStore.displayLetsExchange, caption: ExchangeProviderDescription.letsExchange.title, diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index 4cb7e4cadc..80572b38d0 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/exchange/exchange_provider_description.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/mayachain_exchange.provider.dart'; import 'package:cake_wallet/exchange/provider/quantex_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart'; @@ -58,6 +59,9 @@ abstract class ExchangeTradeViewModelBase with Store { case ExchangeProviderDescription.thorChain: _provider = ThorChainExchangeProvider(tradesStore: trades); break; + case ExchangeProviderDescription.mayaChain: + _provider = MayaChainExchangeProvider(tradesStore: trades); + break; } _updateItems(); @@ -111,11 +115,11 @@ abstract class ExchangeTradeViewModelBase with Store { final output = sendViewModel.outputs.first; output.address = trade.inputAddress ?? ''; output.setCryptoAmount(trade.amount); - if (_provider is ThorChainExchangeProvider) output.memo = trade.memo; + if (_provider is ThorChainExchangeProvider || _provider is MayaChainExchangeProvider) output.memo = trade.memo; if (trade.isSendAll == true) output.sendAll = true; sendViewModel.selectedCryptoCurrency = trade.from; final pendingTransaction = await sendViewModel.createTransaction(provider: _provider); - if (_provider is ThorChainExchangeProvider) { + if (_provider is ThorChainExchangeProvider || _provider is MayaChainExchangeProvider) { trade.id = pendingTransaction?.id ?? ''; trades.add(trade); } @@ -149,7 +153,7 @@ abstract class ExchangeTradeViewModelBase with Store { final tagTo = tradesStore.trade!.to.tag != null ? '${tradesStore.trade!.to.tag}' + ' ' : ''; items.clear(); - if (trade.provider != ExchangeProviderDescription.thorChain) + if (trade.provider != ExchangeProviderDescription.thorChain && trade.provider != ExchangeProviderDescription.mayaChain) items.add( ExchangeTradeItem( title: "${trade.provider.title} ${S.current.id}", diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index d29b7df6b6..7b2ffdb54e 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -32,6 +32,7 @@ import 'package:cake_wallet/exchange/limits_state.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/mayachain_exchange.provider.dart'; import 'package:cake_wallet/exchange/provider/quantex_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart'; @@ -170,6 +171,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with SideShiftExchangeProvider(), SimpleSwapExchangeProvider(), ThorChainExchangeProvider(tradesStore: trades), + MayaChainExchangeProvider(tradesStore: trades), if (FeatureFlag.isExolixEnabled) ExolixExchangeProvider(), QuantexExchangeProvider(), LetsExchangeExchangeProvider(), @@ -580,7 +582,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } tradesStore.setTrade(trade); - if (trade.provider != ExchangeProviderDescription.thorChain) await trades.add(trade); + if (trade.provider != ExchangeProviderDescription.thorChain && trade.provider != ExchangeProviderDescription.mayaChain) await trades.add(trade); tradeState = TradeIsCreatedSuccessfully(trade: trade); /// return after the first successful trade @@ -846,7 +848,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with int get receiveMaxDigits => receiveCurrency.decimals; Future isCanCreateTrade(Trade trade) async { - if (trade.provider == ExchangeProviderDescription.thorChain) { + if (trade.provider == ExchangeProviderDescription.thorChain || trade.provider == ExchangeProviderDescription.mayaChain) { final payoutAddress = trade.payoutAddress ?? ''; final fromWalletAddress = trade.fromWalletAddress ?? ''; final tapRootPattern = RegExp(P2trAddress.regex.pattern); diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 69011aa746..960a9c550c 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/entities/priority_for_wallet_type.dart'; import 'package:cake_wallet/entities/transaction_description.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/mayachain_exchange.provider.dart'; import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; @@ -389,7 +390,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor pendingTransaction = await wallet.createTransaction(_credentials()); - if (provider is ThorChainExchangeProvider) { + if (provider is ThorChainExchangeProvider || provider is MayaChainExchangeProvider) { final outputCount = pendingTransaction?.outputCount ?? 0; if (outputCount > 10) { throw Exception("THORChain does not support more than 10 outputs"); diff --git a/lib/view_model/trade_details_view_model.dart b/lib/view_model/trade_details_view_model.dart index 19315f40d5..c7293e8252 100644 --- a/lib/view_model/trade_details_view_model.dart +++ b/lib/view_model/trade_details_view_model.dart @@ -5,6 +5,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/mayachain_exchange.provider.dart'; import 'package:cake_wallet/exchange/provider/quantex_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/sideshift_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart'; @@ -59,6 +60,9 @@ abstract class TradeDetailsViewModelBase with Store { case ExchangeProviderDescription.thorChain: _provider = ThorChainExchangeProvider(tradesStore: trades); break; + case ExchangeProviderDescription.mayaChain: + _provider = MayaChainExchangeProvider(tradesStore: trades); + break; case ExchangeProviderDescription.quantex: _provider = QuantexExchangeProvider(); case ExchangeProviderDescription.letsExchange: @@ -91,6 +95,8 @@ abstract class TradeDetailsViewModelBase with Store { return 'https://exolix.com/transaction/${trade.id}'; case ExchangeProviderDescription.thorChain: return 'https://track.ninerealms.com/${trade.id}'; + case ExchangeProviderDescription.mayaChain: + return 'https://mayascan.org/${trade.id}'; case ExchangeProviderDescription.quantex: return 'https://myquantex.com/send/${trade.id}'; case ExchangeProviderDescription.letsExchange: