diff --git a/assets/web_pages/checkout_status_redirect.html b/assets/web_pages/checkout_status_redirect.html index d9aa2631c1..fdb69020fc 100644 --- a/assets/web_pages/checkout_status_redirect.html +++ b/assets/web_pages/checkout_status_redirect.html @@ -1,48 +1,45 @@ + Komodo Payment Redirect + + \ No newline at end of file diff --git a/assets/web_pages/fiat_widget.html b/assets/web_pages/fiat_widget.html index a42d14db13..67427c2307 100644 --- a/assets/web_pages/fiat_widget.html +++ b/assets/web_pages/fiat_widget.html @@ -2,109 +2,129 @@ - Fiat OnRamp - - + Fiat OnRamp + + + - - + } + + if (targetUrl) { + document.getElementById('fiat-onramp-iframe').src = targetUrl; + } else { + console.error('No URL parameter provided'); + } + } + + /** + * Get URL parameter by name + * + * @param {string} name - The name of the URL parameter to retrieve + * @returns {string|null} - The value of the URL parameter or null if not found + */ + function _komodoGetUrlParameter(name) { + const params = new URLSearchParams(window.location.search); + return params.get(name); + } + + /** + * Handle messages from the iframe + * + * @param {MessageEvent} messageEvent + */ + function _komodoOnMessageHandler(messageEvent) { + let messageData; + try { + messageData = typeof messageEvent.data === 'string' ? JSON.parse(messageEvent.data) : messageEvent.data; + } catch (parseError) { + messageData = messageEvent.data; + } + + try { + _komodoPostMessageToParent(messageData); + } catch (postError) { + console.error('Error posting message', postError); + } + } + + /** + * Post a message to the parent window + * + * @param {string|object} messageData + */ + function _komodoPostMessageToParent(messageData) { + const messageString = (typeof messageData === 'object') ? JSON.stringify(messageData) : String(messageData); + + // flutter_inappwebview + console.log(messageString); + + // universal_url opener + if (window.opener) { + return window.opener.postMessage(messageString, "*"); + } + + if (window.parent && window.parent !== window) { + return window.parent.postMessage(messageString, "*"); + } + + // Windows WebView2 (desktop_webview_window) + // https://learn.microsoft.com/en-us/microsoft-edge/webview2/how-to/communicate-btwn-web-native + if (window.chrome && window.chrome.webview) { + return window.chrome.webview.postMessage(messageString); + } + + console.error('No valid postMessage target found'); + } + \ No newline at end of file diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart index 7e2aa67527..e7bcbbf7a9 100644 --- a/lib/app_config/app_config.dart +++ b/lib/app_config/app_config.dart @@ -99,6 +99,24 @@ const List excludedAssetListTrezor = [ 'VAL', ]; +/// Some coins returned by the Banxa API are returning errors when attempting +/// to create an order. This is a temporary workaround to filter out those coins +/// until the issue is resolved. +const banxaUnsupportedCoinsList = [ + 'APE', // chain not configured for APE + 'AVAX', // avax & bep20 - invalid wallet address error + 'DOT', // bep20 - invalid wallet address error + 'FIL', // bep20 - invalid wallet address error + 'ONE', // invalid wallet address error (one**** (native) format expected) + 'TON', // erc20 - invalid wallet address error + 'TRX', // bep20 - invalid wallet address error + 'XML', // invalid wallet address error +]; + +const rampUnsupportedCoinsList = [ + 'ONE', // invalid wallet address error (one**** format expected) +]; + // Assets in wallet-only mode on app level, // global wallet-only assets are defined in coins config files. const List appWalletOnlyAssetList = [ diff --git a/lib/bloc/fiat/banxa_fiat_provider.dart b/lib/bloc/fiat/banxa_fiat_provider.dart index bd6e8cc018..27c79fd05e 100644 --- a/lib/bloc/fiat/banxa_fiat_provider.dart +++ b/lib/bloc/fiat/banxa_fiat_provider.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:decimal/decimal.dart'; import 'package:logging/logging.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; @@ -24,7 +25,7 @@ class BanxaFiatProvider extends BaseFiatProvider { FiatOrderStatus _parseStatusFromResponse(Map response) { final statusString = response['data']?['order']?['status'] as String?; - return _parseOrderStatus(statusString ?? ''); + return FiatOrderStatus.fromString(statusString ?? ''); } Future _getPaymentMethods( @@ -96,34 +97,6 @@ class BanxaFiatProvider extends BaseFiatProvider { }, ); - FiatOrderStatus _parseOrderStatus(String status) { - // The case statements are references to Banxa's order statuses. See the - // docs link here for more info: https://docs.banxa.com/docs/order-status - switch (status) { - case 'complete': - return FiatOrderStatus.success; - - case 'cancelled': - case 'declined': - case 'expired': - case 'refunded': - return FiatOrderStatus.failed; - - case 'extraVerification': - case 'pendingPayment': - case 'waitingPayment': - return FiatOrderStatus.pending; - - case 'paymentReceived': - case 'inProgress': - case 'coinTransferred': - return FiatOrderStatus.inProgress; - - default: - throw Exception('Unknown status: $status'); - } - } - // These will be in BLOC: @override Stream watchOrderStatus(String orderId) async* { @@ -162,8 +135,9 @@ class BanxaFiatProvider extends BaseFiatProvider { return data .map( (item) => FiatCurrency( - item['fiat_code'] as String, - item['fiat_name'] as String, + symbol: item['fiat_code'] as String, + name: item['fiat_name'] as String, + minPurchaseAmount: Decimal.zero, ), ) .toList(); @@ -171,12 +145,19 @@ class BanxaFiatProvider extends BaseFiatProvider { @override Future> getCoinList() async { + // TODO: add model classes to parse responses like these when migrating to + // the SDK final response = await _getCoins(); final data = response['data']['coins'] as List; final List currencyList = []; for (final item in data) { final coinCode = item['coin_code'] as String; + if (banxaUnsupportedCoinsList.contains(coinCode)) { + _log.warning('Banxa does not support $coinCode'); + continue; + } + final coinName = item['coin_name'] as String; final blockchains = item['blockchains'] as List; @@ -186,11 +167,32 @@ class BanxaFiatProvider extends BaseFiatProvider { continue; } + // Parse min_value which can be a string, int, or double + final dynamic minValue = blockchain['min_value']; + Decimal minPurchaseAmount; + + if (minValue == null) { + minPurchaseAmount = Decimal.fromInt(0); + } else if (minValue is String) { + minPurchaseAmount = Decimal.fromJson(minValue); + } else if (minValue is int) { + minPurchaseAmount = Decimal.fromInt(minValue); + } else if (minValue is double) { + minPurchaseAmount = Decimal.parse(minValue.toString()); + } else { + // Default to zero for any other unexpected types + minPurchaseAmount = Decimal.fromInt(0); + _log.warning( + 'Unexpected type for min_value: ${minValue.runtimeType}', + ); + } + currencyList.add( CryptoCurrency( - coinCode, - coinName, - coinType, + symbol: coinCode, + name: coinName, + chainType: coinType, + minPurchaseAmount: minPurchaseAmount, ), ); } @@ -206,6 +208,11 @@ class BanxaFiatProvider extends BaseFiatProvider { String sourceAmount, ) async { try { + if (banxaUnsupportedCoinsList.contains(target.configSymbol)) { + _log.warning('Banxa does not support ${target.configSymbol}'); + return []; + } + final response = await _getPaymentMethods(source, target, sourceAmount: sourceAmount); final List paymentMethods = (response['data'] diff --git a/lib/bloc/fiat/base_fiat_provider.dart b/lib/bloc/fiat/base_fiat_provider.dart index 40c1cc745d..f9c34968ab 100644 --- a/lib/bloc/fiat/base_fiat_provider.dart +++ b/lib/bloc/fiat/base_fiat_provider.dart @@ -58,11 +58,6 @@ abstract class BaseFiatProvider { final domainUri = Uri.parse(domain); Uri url; - // Remove the leading '/' if it exists in /api/fiats kind of an endpoint - if (endpoint.startsWith('/')) { - endpoint = endpoint.substring(1); - } - // Add `is_test_mode` query param to all requests if we are in debug mode final passedQueryParams = {} ..addAll(queryParams ?? {}) @@ -73,7 +68,8 @@ abstract class BaseFiatProvider { url = Uri( scheme: domainUri.scheme, host: domainUri.host, - path: endpoint, + // Remove the leading '/' if it exists in /api/fiats kind of an endpoint + path: endpoint.startsWith('/') ? endpoint.substring(1) : endpoint, query: Uri(queryParameters: passedQueryParams).query, ); @@ -98,7 +94,13 @@ abstract class BaseFiatProvider { return json.decode(response.body); } else { _log.warning('Request failed with status: ${response.statusCode}'); - return Future.error(json.decode(response.body) as Object); + dynamic decoded; + try { + decoded = json.decode(response.body); + } catch (_) { + decoded = response.body; + } + return Future.error(decoded as Object); } } catch (e, s) { _log.severe('Network error', e, s); @@ -132,7 +134,6 @@ abstract class BaseFiatProvider { return 'MATIC'; case CoinType.mvr20: return 'MOVR'; - // ignore: no_default_cases default: return null; } @@ -234,6 +235,7 @@ abstract class BaseFiatProvider { return CoinType.etc; case 'FTM': return CoinType.ftm20; + case 'ARBITRUM': case 'ARB': return CoinType.arb20; case 'HARMONY': diff --git a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart index b0d8ba3bf8..1712098fc2 100644 --- a/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart +++ b/lib/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart @@ -1,10 +1,12 @@ import 'dart:convert'; +import 'dart:io' show Platform; import 'package:bloc/bloc.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:collection/collection.dart'; import 'package:decimal/decimal.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:formz/formz.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; @@ -15,10 +17,10 @@ import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; import 'package:web_dex/bloc/fiat/fiat_repository.dart'; import 'package:web_dex/bloc/fiat/models/models.dart'; import 'package:web_dex/bloc/fiat/payment_status_type.dart'; -import 'package:web_dex/model/coin_type.dart'; import 'package:web_dex/model/forms/fiat/currency_input.dart'; import 'package:web_dex/model/forms/fiat/fiat_amount_input.dart'; import 'package:web_dex/shared/utils/extensions/string_extensions.dart'; +import 'package:web_dex/views/fiat/webview_dialog.dart' show WebViewDialogMode; part 'fiat_form_event.dart'; part 'fiat_form_state.dart'; @@ -43,8 +45,8 @@ class FiatFormBloc extends Bloc { on(_onWebViewClosed); on(_onAssetAddressUpdated); - // debounce used here instead of restartable, since multiple user actions - // can trigger this event, and restartable results in hitching + // transformer used here to restart the stream when a new event is added + // (i.e. from user input). on(_onRefreshForm, transformer: restartable()); on( _onLoadCurrencyLists, @@ -90,7 +92,7 @@ class FiatFormBloc extends Bloc { try { if (!await _sdk.auth.isSignedIn()) { - return emit(state.copyWith(selectedAssetAddress: null)); + return emit(state.copyWith(selectedAssetAddress: () => null)); } final asset = event.selectedCoin.toAsset(_sdk); @@ -99,13 +101,13 @@ class FiatFormBloc extends Bloc { emit( state.copyWith( - selectedAssetAddress: address, - selectedCoinPubkeys: assetPubkeys, + selectedAssetAddress: address != null ? () => address : null, + selectedCoinPubkeys: () => assetPubkeys, ), ); } catch (e, s) { _log.shout('Error getting pubkeys for selected coin', e, s); - emit(state.copyWith(selectedAssetAddress: null)); + emit(state.copyWith(selectedAssetAddress: () => null)); } } @@ -129,7 +131,7 @@ class FiatFormBloc extends Bloc { state.fiatAmount.value, selectedPaymentMethod: event.paymentMethod, ), - fiatOrderStatus: FiatOrderStatus.pending, + fiatOrderStatus: FiatOrderStatus.initial, status: FiatFormStatus.initial, ), ); @@ -151,13 +153,14 @@ class FiatFormBloc extends Bloc { try { final newOrder = await _fiatRepository.buyCoin( - state.selectedAssetAddress!.address, - state.selectedFiat.value!.symbol, - state.selectedAsset.value!, - state.selectedAssetAddress!.address, - state.selectedPaymentMethod, - state.fiatAmount.value, - BaseFiatProvider.successUrl(state.selectedAssetAddress!.address), + accountReference: state.selectedAssetAddress!.address, + source: state.selectedFiat.value!.getAbbr(), + target: state.selectedAsset.value!, + walletAddress: state.selectedAssetAddress!.address, + paymentMethod: state.selectedPaymentMethod, + sourceAmount: state.fiatAmount.value, + returnUrlOnSuccess: + BaseFiatProvider.successUrl(state.selectedAssetAddress!.address), ); if (!newOrder.error.isNone) { @@ -165,7 +168,7 @@ class FiatFormBloc extends Bloc { return emit(_parseOrderError(newOrder.error)); } - final checkoutUrl = newOrder.checkoutUrl as String? ?? ''; + var checkoutUrl = newOrder.checkoutUrl as String? ?? ''; if (checkoutUrl.isEmpty) { _log.severe('Invalid checkout URL received.'); return emit( @@ -175,12 +178,19 @@ class FiatFormBloc extends Bloc { ); } + // Only Ramp on web requires the intermediate html page to satisfy cors + // rules and allow for console.log and postMessage events to be handled. + // Banxa does not use `postMessage` and does not require this. + checkoutUrl = BaseFiatProvider.fiatWrapperPageUrl(checkoutUrl); + final webViewMode = _determineWebViewMode(); + emit( state.copyWith( checkoutUrl: checkoutUrl, orderId: newOrder.id, status: FiatFormStatus.success, fiatOrderStatus: FiatOrderStatus.submitted, + webViewMode: webViewMode, ), ); } catch (e, s) { @@ -194,6 +204,25 @@ class FiatFormBloc extends Bloc { } } + /// Determines the appropriate WebViewDialogMode based on platform and + /// environment + WebViewDialogMode _determineWebViewMode() { + final bool isLinux = !kIsWeb && !kIsWasm && Platform.isLinux; + const bool isWeb = kIsWeb || kIsWasm; + final bool isBanxa = state.selectedPaymentMethod.providerId == 'Banxa'; + + // Banxa "Return to Komodo" button attempts to navigate the top window to + // the return URL, which is not supported in a dialog. So we need to open + // it in a new tab. + if (isLinux || (isWeb && isBanxa)) { + return WebViewDialogMode.newTab; + } else if (isWeb) { + return WebViewDialogMode.dialog; + } else { + return WebViewDialogMode.fullscreen; + } + } + Future _onRefreshForm( FiatFormRefreshed event, Emitter emit, @@ -219,14 +248,14 @@ class FiatFormBloc extends Bloc { return state; } - final asset = - _sdk.getSdkAsset(state.selectedAsset.value?.symbol ?? 'BTC-segwit'); + final asset = _sdk + .getSdkAsset(state.selectedAsset.value?.getAbbr() ?? 'BTC-segwit'); final pubkeys = await _sdk.pubkeys.getPubkeys(asset); final address = pubkeys.keys.firstOrNull; return state.copyWith( - selectedAssetAddress: address, - selectedCoinPubkeys: pubkeys, + selectedAssetAddress: address != null ? () => address : null, + selectedCoinPubkeys: () => pubkeys, ); } catch (e, s) { if (attempts >= maxRetries) { @@ -235,7 +264,9 @@ class FiatFormBloc extends Bloc { e, s, ); - return state.copyWith(selectedAssetAddress: null); + if (state.selectedAssetAddress == null) { + return state.copyWith(selectedAssetAddress: () => null); + } } _log.warning( @@ -244,11 +275,11 @@ class FiatFormBloc extends Bloc { s, ); - await Future.delayed(Duration(milliseconds: 500 * attempts)); + await Future.delayed(Duration(milliseconds: 500 * attempts)); } } - return state.copyWith(selectedAssetAddress: null); + return state.copyWith(selectedAssetAddress: () => null); } void _onPaymentStatusMessage( @@ -274,15 +305,15 @@ class FiatFormBloc extends Bloc { case PaymentStatusType.widgetClose: case PaymentStatusType.widgetCloseRequest: updatedStatus = FiatOrderStatus.windowCloseRequested; - break; case PaymentStatusType.purchaseCreated: updatedStatus = FiatOrderStatus.inProgress; - break; case PaymentStatusType.paymentStatus: final status = data['status'] as String? ?? 'declined'; updatedStatus = FiatOrderStatus.fromString(status); - break; - default: + case PaymentStatusType.widgetConfigFailed: + case PaymentStatusType.widgetConfigDone: + case PaymentStatusType.widgetCloseRequestCancelled: + case PaymentStatusType.offrampSaleCreated: break; } @@ -300,7 +331,7 @@ class FiatFormBloc extends Bloc { ) { emit( state.copyWith( - selectedAssetAddress: event.address, + selectedAssetAddress: () => event.address, ), ); } @@ -322,11 +353,13 @@ class FiatFormBloc extends Bloc { emit(state.copyWith(fiatList: fiatList, coinList: coinList)); } catch (e, s) { _log.shout('Error loading currency list', e, s); - emit(state.copyWith( - fiatList: [], - coinList: [], - status: FiatFormStatus.failure, - )); + emit( + state.copyWith( + fiatList: [], + coinList: [], + status: FiatFormStatus.failure, + ), + ); } } @@ -368,13 +401,16 @@ class FiatFormBloc extends Bloc { // to allow the user to submit another order if (state.fiatOrderStatus != FiatOrderStatus.inProgress) { _log.info('WebView closed, resetting order status to pending'); - emit(state.copyWith( - fiatOrderStatus: FiatOrderStatus.pending, - checkoutUrl: '', - )); + emit( + state.copyWith( + fiatOrderStatus: FiatOrderStatus.initial, + checkoutUrl: '', + ), + ); } else { _log.info( - 'WebView closed, but order is in progress. Keeping current status.'); + 'WebView closed, but order is in progress. Keeping current status.', + ); } } @@ -382,7 +418,9 @@ class FiatFormBloc extends Bloc { FiatFormAssetAddressUpdated event, Emitter emit, ) { - emit(state.copyWith(selectedAssetAddress: event.selectedAssetAddress)); + emit( + state.copyWith(selectedAssetAddress: () => event.selectedAssetAddress), + ); } FiatFormState _parseOrderError(FiatBuyOrderError error) { @@ -394,6 +432,7 @@ class FiatFormBloc extends Bloc { checkoutUrl: '', status: FiatFormStatus.failure, fiatOrderStatus: FiatOrderStatus.failed, + providerError: () => error.title, ); } @@ -423,6 +462,22 @@ class FiatFormBloc extends Bloc { maxAmount = Decimal.tryParse(firstLimit.max.toString()); } + // Use the minimum transaction amount provided by Ramp and Banxa per coin + // to determine the minimum amount that can be purchased. The payment + // method list provides a minimum amount for the fiat currency, but this is + // not always the same as the minimum amount for the coin. + final coinAmount = paymentMethod.priceInfo.coinAmount; + final fiatAmount = paymentMethod.priceInfo.fiatAmount; + final minPurchaseAmount = + state.selectedAsset.value?.minPurchaseAmount ?? Decimal.zero; + if (coinAmount < minPurchaseAmount && coinAmount > Decimal.zero) { + final minFiatAmount = ((minPurchaseAmount * fiatAmount) / coinAmount) + .toDecimal(scaleOnInfinitePrecision: 18); + minAmount = minAmount != null && minAmount > minFiatAmount + ? minAmount + : minFiatAmount; + } + return FiatAmountInput.dirty( amount, minValue: minAmount, @@ -435,6 +490,7 @@ class FiatFormBloc extends Bloc { yield state.copyWith( fiatAmount: _getAmountInputWithBounds(state.fiatAmount.value), + providerError: () => null, ); try { @@ -445,6 +501,7 @@ class FiatFormBloc extends Bloc { yield state.copyWith( paymentMethods: [], status: FiatFormStatus.failure, + providerError: () => null, ); } } @@ -453,7 +510,8 @@ class FiatFormBloc extends Bloc { if (_hasValidFiatAmount()) { yield state.copyWith( status: FiatFormStatus.loading, - fiatOrderStatus: FiatOrderStatus.pending, + fiatOrderStatus: FiatOrderStatus.initial, + providerError: () => null, ); } else { yield _defaultPaymentMethods(); @@ -462,7 +520,7 @@ class FiatFormBloc extends Bloc { Stream _fetchAndUpdatePaymentMethods() async* { final methodsStream = _fiatRepository.getPaymentMethodsList( - state.selectedFiat.value!.symbol, + state.selectedFiat.value!.getAbbr(), state.selectedAsset.value!, _getSourceAmount(), ); @@ -476,6 +534,7 @@ class FiatFormBloc extends Bloc { yield state.copyWith( paymentMethods: [], status: FiatFormStatus.failure, + providerError: () => null, ); } } @@ -507,6 +566,7 @@ class FiatFormBloc extends Bloc { paymentMethods: methods, selectedPaymentMethod: method, status: FiatFormStatus.success, + providerError: () => null, fiatAmount: _getAmountInputWithBounds( state.fiatAmount.value, selectedPaymentMethod: method, @@ -514,10 +574,16 @@ class FiatFormBloc extends Bloc { ); } - return state.copyWith(status: FiatFormStatus.success); + return state.copyWith( + status: FiatFormStatus.success, + providerError: () => null, + ); } catch (e, s) { _log.shout('Error updating payment methods', e, s); - return state.copyWith(paymentMethods: []); + return state.copyWith( + paymentMethods: [], + providerError: () => null, + ); } } @@ -526,7 +592,8 @@ class FiatFormBloc extends Bloc { paymentMethods: defaultFiatPaymentMethods, selectedPaymentMethod: defaultFiatPaymentMethods.first, status: FiatFormStatus.initial, - fiatOrderStatus: FiatOrderStatus.pending, + fiatOrderStatus: FiatOrderStatus.initial, + providerError: () => null, ); } } diff --git a/lib/bloc/fiat/fiat_onramp_form/fiat_form_state.dart b/lib/bloc/fiat/fiat_onramp_form/fiat_form_state.dart index a736470ee2..2c266731e1 100644 --- a/lib/bloc/fiat/fiat_onramp_form/fiat_form_state.dart +++ b/lib/bloc/fiat/fiat_onramp_form/fiat_form_state.dart @@ -18,20 +18,18 @@ final class FiatFormState extends Equatable with FormzMixin { required this.fiatList, required this.coinList, this.status = FiatFormStatus.initial, - this.fiatOrderStatus = FiatOrderStatus.pending, + this.fiatOrderStatus = FiatOrderStatus.initial, this.fiatMode = FiatMode.onramp, this.selectedAssetAddress, this.selectedCoinPubkeys, + this.webViewMode = WebViewDialogMode.fullscreen, + this.providerError, }); /// Creates an initial state with default values. FiatFormState.initial() - : selectedFiat = const CurrencyInput.dirty( - FiatCurrency('USD', 'United States Dollar'), - ), - selectedAsset = const CurrencyInput.dirty( - CryptoCurrency('BTC-segwit', 'Bitcoin', CoinType.utxo), - ), + : selectedFiat = CurrencyInput.dirty(FiatCurrency.usd()), + selectedAsset = CurrencyInput.dirty(CryptoCurrency.bitcoin()), fiatAmount = const FiatAmountInput.pure(), selectedAssetAddress = null, selectedPaymentMethod = FiatPaymentMethod.none, @@ -41,9 +39,11 @@ final class FiatFormState extends Equatable with FormzMixin { paymentMethods = const [], fiatList = const [], coinList = const [], - fiatOrderStatus = FiatOrderStatus.pending, + fiatOrderStatus = FiatOrderStatus.initial, fiatMode = FiatMode.onramp, - selectedCoinPubkeys = null; + selectedCoinPubkeys = null, + webViewMode = WebViewDialogMode.fullscreen, + providerError = null; /// The selected fiat currency to use to purchase [selectedAsset]. final CurrencyInput selectedFiat; @@ -90,15 +90,21 @@ final class FiatFormState extends Equatable with FormzMixin { /// once the order history tab is implemented final FiatMode fiatMode; + /// The mode to use for displaying the WebView dialog + final WebViewDialogMode webViewMode; + + /// Raw error message from the provider when there is an order error + final String? providerError; + /// Gets the transaction limit from the selected payment method FiatTransactionLimit? get transactionLimit => selectedPaymentMethod.transactionLimits.firstOrNull; /// The minimum fiat amount that is allowed for the selected payment method - Decimal? get minFiatAmount => transactionLimit?.min; + Decimal? get minFiatAmount => fiatAmount.minValue ?? transactionLimit?.min; /// The maximum fiat amount that is allowed for the selected payment method - Decimal? get maxFiatAmount => transactionLimit?.max; + Decimal? get maxFiatAmount => fiatAmount.maxValue ?? transactionLimit?.max; /// Whether currencies are still being loaded bool get isLoadingCurrencies => fiatList.length < 2 || coinList.length < 2; @@ -119,7 +125,7 @@ final class FiatFormState extends Equatable with FormzMixin { CurrencyInput? selectedAsset, FiatAmountInput? fiatAmount, FiatPaymentMethod? selectedPaymentMethod, - PubkeyInfo? selectedAssetAddress, + ValueGetter? selectedAssetAddress, String? checkoutUrl, String? orderId, FiatFormStatus? status, @@ -128,14 +134,18 @@ final class FiatFormState extends Equatable with FormzMixin { Iterable? coinList, FiatOrderStatus? fiatOrderStatus, FiatMode? fiatMode, - AssetPubkeys? selectedCoinPubkeys, + ValueGetter? selectedCoinPubkeys, + WebViewDialogMode? webViewMode, + ValueGetter? providerError, }) { return FiatFormState( selectedFiat: selectedFiat ?? this.selectedFiat, selectedAsset: selectedAsset ?? this.selectedAsset, selectedPaymentMethod: selectedPaymentMethod ?? this.selectedPaymentMethod, - selectedAssetAddress: selectedAssetAddress ?? this.selectedAssetAddress, + selectedAssetAddress: selectedAssetAddress != null + ? selectedAssetAddress() + : this.selectedAssetAddress, checkoutUrl: checkoutUrl ?? this.checkoutUrl, orderId: orderId ?? this.orderId, fiatAmount: fiatAmount ?? this.fiatAmount, @@ -145,7 +155,12 @@ final class FiatFormState extends Equatable with FormzMixin { coinList: coinList ?? this.coinList, fiatOrderStatus: fiatOrderStatus ?? this.fiatOrderStatus, fiatMode: fiatMode ?? this.fiatMode, - selectedCoinPubkeys: selectedCoinPubkeys ?? this.selectedCoinPubkeys, + selectedCoinPubkeys: selectedCoinPubkeys != null + ? selectedCoinPubkeys() + : this.selectedCoinPubkeys, + webViewMode: webViewMode ?? this.webViewMode, + providerError: + providerError != null ? providerError() : this.providerError, ); } @@ -172,5 +187,7 @@ final class FiatFormState extends Equatable with FormzMixin { fiatOrderStatus, fiatMode, selectedCoinPubkeys, + webViewMode, + providerError, ]; } diff --git a/lib/bloc/fiat/fiat_order_status.dart b/lib/bloc/fiat/fiat_order_status.dart index 2a39a337cc..72bcf77a31 100644 --- a/lib/bloc/fiat/fiat_order_status.dart +++ b/lib/bloc/fiat/fiat_order_status.dart @@ -1,12 +1,17 @@ // TODO: Differentiate between different error and in-progress statuses +import 'package:logging/logging.dart'; + enum FiatOrderStatus { - /// User has not yet started the payment process - pending, + /// Initial status: User has not yet started the payment process + initial, /// User has started the process, and the payment method has been opened. /// E.g. Ramp or Banxa websites have been opened submitted, + /// Payment is awaiting user action (e.g., user needs to complete payment) + pendingPayment, + /// Payment has been submitted with the provider, and is being processed inProgress, @@ -14,16 +19,18 @@ enum FiatOrderStatus { success, /// Payment has been cancelled, declined, expired or refunded - failed, - - /// The user closed the payment window using the provider close button + failed, + + /// The user closed the payment window using the provider close button /// or "return to Komodo Wallet" button windowCloseRequested; bool get isTerminal => this == FiatOrderStatus.success || this == FiatOrderStatus.failed; bool get isSubmitting => - this == FiatOrderStatus.inProgress || this == FiatOrderStatus.submitted; + this == FiatOrderStatus.inProgress || + this == FiatOrderStatus.submitted || + this == FiatOrderStatus.pendingPayment; bool get isFailed => this == FiatOrderStatus.failed; bool get isSuccess => this == FiatOrderStatus.success; @@ -32,7 +39,8 @@ enum FiatOrderStatus { static FiatOrderStatus fromString(String status) { // The case statements are references to Banxa's order statuses. See the // docs link here for more info: https://docs.banxa.com/docs/order-status - switch (status) { + final normalized = status.toLowerCase(); + switch (normalized) { case 'complete': return FiatOrderStatus.success; @@ -42,18 +50,25 @@ enum FiatOrderStatus { case 'refunded': return FiatOrderStatus.failed; - case 'extraVerification': - case 'pendingPayment': - case 'waitingPayment': - return FiatOrderStatus.pending; + case 'extraverification': + case 'pendingpayment': + case 'waitingpayment': + return FiatOrderStatus.pendingPayment; - case 'paymentReceived': - case 'inProgress': - case 'coinTransferred': + case 'paymentreceived': + case 'inprogress': + case 'cointransferred': + case 'cryptotransferred': return FiatOrderStatus.inProgress; default: - throw Exception('Unknown status: $status'); + // Default to in progress if the status is not recognized + // to avoid alarming users with "Payment failed" popup messages + // unless we are sure that the payment has failed. + // Ideally, this section should not be reached. + Logger('FiatOrderStatus') + .warning('Unknown status: $status, defaulting to in progress'); + return FiatOrderStatus.inProgress; } } } diff --git a/lib/bloc/fiat/fiat_repository.dart b/lib/bloc/fiat/fiat_repository.dart index f006032102..a6444b5267 100644 --- a/lib/bloc/fiat/fiat_repository.dart +++ b/lib/bloc/fiat/fiat_repository.dart @@ -1,5 +1,4 @@ import 'package:decimal/decimal.dart'; -import 'package:universal_html/html.dart'; import 'package:web_dex/app_config/app_config.dart'; import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; @@ -51,8 +50,12 @@ class FiatRepository { for (final currencyList in results) { for (final currency in currencyList) { - bool isCoinUnknown() => !knownCoins.containsKey(currency.getAbbr()); - if (isCoin && (currency.isFiat || isCoinUnknown())) { + final isCoinSupported = knownCoins.containsKey(currency.getAbbr()); + if (isCoin && (currency.isFiat || !isCoinSupported)) { + _log.fine( + 'Skipping ${currency.getAbbr()} because it is not a coin or ' + 'not supported (${currency.configSymbol})', + ); continue; } @@ -252,15 +255,15 @@ class FiatRepository { ); } - Future buyCoin( - String accountReference, - String source, - ICurrency target, - String walletAddress, - FiatPaymentMethod paymentMethod, - String sourceAmount, - String returnUrlOnSuccess, - ) async { + Future buyCoin({ + required String accountReference, + required String source, + required ICurrency target, + required String walletAddress, + required FiatPaymentMethod paymentMethod, + required String sourceAmount, + required String returnUrlOnSuccess, + }) async { final provider = _getPaymentMethodProvider(paymentMethod); if (provider == null) return Future.error('Provider not found'); diff --git a/lib/bloc/fiat/models/fiat_buy_order_error.dart b/lib/bloc/fiat/models/fiat_buy_order_error.dart index d4d436e8d4..ddfe524cbd 100644 --- a/lib/bloc/fiat/models/fiat_buy_order_error.dart +++ b/lib/bloc/fiat/models/fiat_buy_order_error.dart @@ -18,6 +18,11 @@ class FiatBuyOrderError extends Equatable { const FiatBuyOrderError.none() : this(code: 0, status: 0, title: ''); + /// Error indicating a parsing issue with the response data + const FiatBuyOrderError.parsing({ + String message = 'Failed to parse response data', + }) : this(code: -1, status: 400, title: message); + bool get isNone => this == const FiatBuyOrderError.none(); final int code; diff --git a/lib/bloc/fiat/models/fiat_buy_order_info.dart b/lib/bloc/fiat/models/fiat_buy_order_info.dart index 3c7a3e78cc..cdf29a0ee9 100644 --- a/lib/bloc/fiat/models/fiat_buy_order_info.dart +++ b/lib/bloc/fiat/models/fiat_buy_order_info.dart @@ -21,7 +21,7 @@ class FiatBuyOrderInfo extends Equatable { required this.error, }); - FiatBuyOrderInfo.info() + FiatBuyOrderInfo.fromCheckoutUrl(String url) : this( id: '', accountId: '', @@ -34,12 +34,12 @@ class FiatBuyOrderInfo extends Equatable { extAccountId: '', network: '', paymentCode: '', - checkoutUrl: '', + checkoutUrl: url, createdAt: '', error: const FiatBuyOrderError.none(), ); - FiatBuyOrderInfo.fromCheckoutUrl(String url) + FiatBuyOrderInfo.empty() : this( id: '', accountId: '', @@ -52,18 +52,30 @@ class FiatBuyOrderInfo extends Equatable { extAccountId: '', network: '', paymentCode: '', - checkoutUrl: url, + checkoutUrl: '', createdAt: '', error: const FiatBuyOrderError.none(), ); factory FiatBuyOrderInfo.fromJson(Map json) { - Map data = json; - if (json['data'] != null) { - final orderData = json['data'] as Map? ?? {}; - data = orderData['order'] as Map? ?? {}; + final jsonData = json['data'] as Map?; + final errors = json['errors'] as Map?; + + if (json['data'] == null && errors == null) { + return FiatBuyOrderInfo.empty().copyWith( + error: + const FiatBuyOrderError.parsing(message: 'Missing order payload'), + ); } + if (jsonData == null && errors != null) { + return FiatBuyOrderInfo.empty().copyWith( + error: FiatBuyOrderError.fromJson(errors), + ); + } + + final data = jsonData!['order'] as Map; + return FiatBuyOrderInfo( id: data['id'] as String? ?? '', accountId: data['account_id'] as String? ?? '', @@ -78,8 +90,8 @@ class FiatBuyOrderInfo extends Equatable { paymentCode: data['payment_code'] as String? ?? '', checkoutUrl: data['checkout_url'] as String? ?? '', createdAt: assertString(data['created_at']) ?? '', - error: data['errors'] != null - ? FiatBuyOrderError.fromJson(data['errors'] as Map) + error: errors != null + ? FiatBuyOrderError.fromJson(errors) : const FiatBuyOrderError.none(), ); } diff --git a/lib/bloc/fiat/models/fiat_price_info.dart b/lib/bloc/fiat/models/fiat_price_info.dart index eed0081407..774a6e19d7 100644 --- a/lib/bloc/fiat/models/fiat_price_info.dart +++ b/lib/bloc/fiat/models/fiat_price_info.dart @@ -10,25 +10,25 @@ class FiatPriceInfo extends Equatable { required this.spotPriceIncludingFee, }); - static final zero = FiatPriceInfo( - fiatAmount: Decimal.zero, - coinAmount: Decimal.zero, - fiatCode: '', - coinCode: '', - spotPriceIncludingFee: Decimal.zero, - ); - factory FiatPriceInfo.fromJson(Map json) { return FiatPriceInfo( - fiatAmount: Decimal.parse(json['fiat_amount']?.toString() ?? '0'), - coinAmount: Decimal.parse(json['coin_amount']?.toString() ?? '0'), + fiatAmount: _safeParseDecimal(json['fiat_amount']), + coinAmount: _safeParseDecimal(json['coin_amount']), fiatCode: json['fiat_code'] as String? ?? '', coinCode: json['coin_code'] as String? ?? '', spotPriceIncludingFee: - Decimal.parse(json['spot_price_including_fee']?.toString() ?? '0'), + _safeParseDecimal(json['spot_price_including_fee']), ); } + static final zero = FiatPriceInfo( + fiatAmount: Decimal.zero, + coinAmount: Decimal.zero, + fiatCode: '', + coinCode: '', + spotPriceIncludingFee: Decimal.zero, + ); + final Decimal fiatAmount; final Decimal coinAmount; final String fiatCode; @@ -62,6 +62,14 @@ class FiatPriceInfo extends Equatable { }; } + static Decimal _safeParseDecimal(dynamic value) { + try { + return Decimal.parse(value?.toString() ?? '0'); + } on FormatException { + return Decimal.zero; + } + } + @override List get props => [ fiatAmount, diff --git a/lib/bloc/fiat/models/i_currency.dart b/lib/bloc/fiat/models/i_currency.dart index 6ed95577e8..623251ea13 100644 --- a/lib/bloc/fiat/models/i_currency.dart +++ b/lib/bloc/fiat/models/i_currency.dart @@ -1,3 +1,4 @@ +import 'package:decimal/decimal.dart'; import 'package:komodo_defi_sdk/komodo_defi_sdk.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:web_dex/bloc/coins_bloc/asset_coin_extension.dart'; @@ -6,11 +7,24 @@ import 'package:web_dex/model/coin_utils.dart'; /// Base class for all currencies abstract class ICurrency { - const ICurrency(this.symbol, this.name); + const ICurrency({ + required this.symbol, + required this.name, + required this.minPurchaseAmount, + }); + /// The symbol/code of the currency (e.g. BTC, USD). Note that this usually + /// excludes any chain identifiers like "-segwit" or "-erc20". final String symbol; + + /// The full name of the currency (e.g. Bitcoin, US Dollar). final String name; + /// The minimum purchase amount for this currency. This is used to + /// determine the minimum amount that can be purchased in a fiat + /// transaction. + final Decimal minPurchaseAmount; + /// Returns true if the currency is a fiat currency (e.g. USD) bool get isFiat; @@ -29,11 +43,22 @@ abstract class ICurrency { ICurrency copyWith({ String? symbol, String? name, + Decimal? minPurchaseAmount, }); } class FiatCurrency extends ICurrency { - const FiatCurrency(super.symbol, super.name); + const FiatCurrency({ + required super.symbol, + required super.name, + required super.minPurchaseAmount, + }); + + factory FiatCurrency.usd() => FiatCurrency( + symbol: 'USD', + name: 'United States Dollar', + minPurchaseAmount: Decimal.zero, + ); @override bool get isFiat => true; @@ -41,7 +66,6 @@ class FiatCurrency extends ICurrency { @override bool get isCrypto => false; - @override String get configSymbol => symbol; @@ -49,23 +73,41 @@ class FiatCurrency extends ICurrency { FiatCurrency copyWith({ String? symbol, String? name, + Decimal? minPurchaseAmount, }) { return FiatCurrency( - symbol ?? this.symbol, - name ?? this.name, + symbol: symbol ?? this.symbol, + name: name ?? this.name, + minPurchaseAmount: minPurchaseAmount ?? this.minPurchaseAmount, ); } } class CryptoCurrency extends ICurrency { - const CryptoCurrency(super.symbol, super.name, this.chainType); + const CryptoCurrency({ + required super.symbol, + required super.name, + required this.chainType, + required super.minPurchaseAmount, + }); + + factory CryptoCurrency.bitcoin() => CryptoCurrency( + symbol: 'BTC-segwit', + name: 'Bitcoin', + chainType: CoinType.utxo, + minPurchaseAmount: Decimal.zero, + ); - factory CryptoCurrency.fromAsset(Asset asset) { + factory CryptoCurrency.fromAsset( + Asset asset, { + required Decimal minPurchaseAmount, + }) { final coin = asset.toCoin(); return CryptoCurrency( - coin.id.id, - coin.name, - coin.type, + symbol: coin.id.id, + name: coin.name, + chainType: coin.type, + minPurchaseAmount: minPurchaseAmount, ); } @@ -82,10 +124,26 @@ class CryptoCurrency extends ICurrency { @override String getAbbr() { - return symbol; - - // TODO: figure out if this is still necessary - // return '$symbol-${getCoinTypeName(chainType).replaceAll('-', '')}'; + // TODO: look into a better way to do this when migrating to the SDK + // Providers return "ETH" with chain type "ERC20", resultning in abbr of + // "ETH-ERC20", which is not how it is stored in our coins configuration + // files. "ETH" is the expected abbreviation, which would just be `symbol`. + if (chainType == CoinType.utxo || + (chainType == CoinType.cosmos && symbol == 'ATOM') || + (chainType == CoinType.erc20 && symbol == 'ETH') || + (chainType == CoinType.bep20 && symbol == 'BNB') || + (chainType == CoinType.avx20 && symbol == 'AVAX') || + (chainType == CoinType.etc && symbol == 'ETC') || + (chainType == CoinType.ftm20 && symbol == 'FTM') || + (chainType == CoinType.arb20 && symbol == 'ARB') || + (chainType == CoinType.hrc20 && symbol == 'ONE') || + (chainType == CoinType.plg20 && symbol == 'MATIC') || + (chainType == CoinType.mvr20 && symbol == 'MOVR') || + (chainType == CoinType.krc20 && symbol == 'KCS')) { + return symbol; + } + + return '$symbol-${getCoinTypeName(chainType).replaceAll('-', '')}'; } @override @@ -103,11 +161,13 @@ class CryptoCurrency extends ICurrency { String? symbol, String? name, CoinType? chainType, + Decimal? minPurchaseAmount, }) { return CryptoCurrency( - symbol ?? this.symbol, - name ?? this.name, - chainType ?? this.chainType, + symbol: symbol ?? this.symbol, + name: name ?? this.name, + chainType: chainType ?? this.chainType, + minPurchaseAmount: minPurchaseAmount ?? this.minPurchaseAmount, ); } } diff --git a/lib/bloc/fiat/payment_status_type.dart b/lib/bloc/fiat/payment_status_type.dart index 7e691efe1c..7f4200af90 100644 --- a/lib/bloc/fiat/payment_status_type.dart +++ b/lib/bloc/fiat/payment_status_type.dart @@ -1,18 +1,18 @@ enum PaymentStatusType { - widgetClose("WIDGET_CLOSE"), - widgetConfigDone("WIDGET_CONFIG_DONE"), - widgetConfigFailed("WIDGET_CONFIG_FAILED"), - widgetCloseRequest("WIDGET_CLOSE_REQUEST"), - widgetCloseRequestCancelled("WIDGET_CLOSE_REQUEST_CANCELLED"), - widgetCloseRequestConfirmed("WIDGET_CLOSE_REQUEST_CONFIRMED"), - purchaseCreated("PURCHASE_CREATED"), - offrampSaleCreated("OFFRAMP_SALE_CREATED"), - paymentStatus("PAYMENT-STATUS"); - - final String value; + widgetClose('WIDGET_CLOSE'), + widgetConfigDone('WIDGET_CONFIG_DONE'), + widgetConfigFailed('WIDGET_CONFIG_FAILED'), + widgetCloseRequest('WIDGET_CLOSE_REQUEST'), + widgetCloseRequestCancelled('WIDGET_CLOSE_REQUEST_CANCELLED'), + widgetCloseRequestConfirmed('WIDGET_CLOSE_REQUEST_CONFIRMED'), + purchaseCreated('PURCHASE_CREATED'), + offrampSaleCreated('OFFRAMP_SALE_CREATED'), + paymentStatus('PAYMENT-STATUS'); const PaymentStatusType(this.value); + final String value; + /// Creates a RampWidgetStatusEvents from a JSON input static PaymentStatusType fromJson(Map json) { try { @@ -21,7 +21,7 @@ enum PaymentStatusType { if (json.containsKey('type')) { typeValue = json['type'] as String; } else { - throw FormatException('Missing "type" field in JSON object'); + throw const FormatException('Missing "type" field in JSON object'); } return PaymentStatusType.values.firstWhere( diff --git a/lib/bloc/fiat/ramp/models/ramp_asset_info.dart b/lib/bloc/fiat/ramp/models/ramp_asset_info.dart index 40ac520f4d..72add2ffc6 100644 --- a/lib/bloc/fiat/ramp/models/ramp_asset_info.dart +++ b/lib/bloc/fiat/ramp/models/ramp_asset_info.dart @@ -58,9 +58,6 @@ class RampAssetInfo { required this.symbol, required this.decimals, required this.price, - this.minPurchaseAmount, - this.maxPurchaseAmount, - this.address, required this.chain, required this.currencyCode, required this.enabled, @@ -69,6 +66,9 @@ class RampAssetInfo { required this.minPurchaseCryptoAmount, required this.networkFee, required this.type, + this.minPurchaseAmount, + this.maxPurchaseAmount, + this.address, }); /// Returns true if this asset has a valid minimum purchase amount. diff --git a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart index bcabcca3cd..a25b8a06a3 100644 --- a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart +++ b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart @@ -33,13 +33,13 @@ class RampFiatProvider extends BaseFiatProvider { return providerId; } - String getFullCoinCode(ICurrency target) { - return '${getCoinChainId(target as CryptoCurrency)}_${target.configSymbol}'; + String getFullCoinCode(CryptoCurrency target) { + return '${getCoinChainId(target)}_${target.configSymbol}'; } Future _getPaymentMethods( String source, - ICurrency target, { + CryptoCurrency target, { String? sourceAmount, }) => apiRequest( @@ -51,6 +51,8 @@ class RampFiatProvider extends BaseFiatProvider { body: { 'fiatCurrency': source, 'cryptoAssetSymbol': getFullCoinCode(target), + // fiatValue has to be a number, and not a string. Force it to be a + // double here to ensure that it is in the expected format. 'fiatValue': sourceAmount != null ? Decimal.tryParse(sourceAmount)?.toDouble() : null, @@ -59,7 +61,7 @@ class RampFiatProvider extends BaseFiatProvider { Future _getPricesWithPaymentMethod( String source, - ICurrency target, + CryptoCurrency target, String sourceAmount, FiatPaymentMethod paymentMethod, ) => @@ -101,8 +103,9 @@ class RampFiatProvider extends BaseFiatProvider { .where((item) => item['onrampAvailable'] as bool) .map( (item) => FiatCurrency( - item['fiatCurrency'] as String, - item['name'] as String, + symbol: item['fiatCurrency'] as String, + name: item['name'] as String, + minPurchaseAmount: Decimal.zero, ), ) .toList(); @@ -110,23 +113,37 @@ class RampFiatProvider extends BaseFiatProvider { @override Future> getCoinList() async { - final response = await _getCoins(); - final data = response['assets'] as List; - return data - .map((item) { - final coinType = getCoinType(item['chain'] as String); - if (coinType == null) { - return null; - } - return CryptoCurrency( - item['symbol'] as String, - item['name'] as String, - coinType, - ); - }) - .where((e) => e != null) - .cast() - .toList(); + try { + final response = await _getCoins(); + final config = + HostAssetsConfig.fromJson(response as Map); + + return config.assets + .map((asset) { + final coinType = getCoinType(asset.chain); + if (coinType == null) { + return null; + } + + if (rampUnsupportedCoinsList.contains(asset.symbol)) { + _log.warning('Ramp does not support ${asset.symbol}'); + return null; + } + + return CryptoCurrency( + symbol: asset.symbol, + name: asset.name, + chainType: coinType, + minPurchaseAmount: asset.minPurchaseAmount ?? Decimal.zero, + ); + }) + .where((e) => e != null) + .cast() + .toList(); + } catch (e, s) { + _log.severe('Failed to parse coin list from Ramp', e, s); + return []; + } } // Turns `APPLE_PAY` to `Apple Pay` @@ -144,6 +161,10 @@ class RampFiatProvider extends BaseFiatProvider { String sourceAmount, ) async { try { + if (target is! CryptoCurrency) { + throw ArgumentError('Target currency must be a CryptoCurrency'); + } + final List paymentMethodsList = []; final paymentMethodsFuture = @@ -210,11 +231,12 @@ class RampFiatProvider extends BaseFiatProvider { return paymentMethod.transactionFees.first.fees.first.amount; } - Decimal _getFeeAdjustedPrice( - FiatPaymentMethod paymentMethod, - Decimal price, - ) { - return (price / (Decimal.one - _getPaymentMethodFee(paymentMethod))) + Decimal _getFeeAdjustedPrice(FiatPaymentMethod paymentMethod, Decimal price) { + final fee = _getPaymentMethodFee(paymentMethod); + if (fee >= Decimal.one) { + throw ArgumentError.value(fee, 'fee', 'Fee ratio must be < 1'); + } + return (price / (Decimal.one - fee)) .toDecimal(scaleOnInfinitePrecision: scaleOnInfinitePrecision); } @@ -233,6 +255,10 @@ class RampFiatProvider extends BaseFiatProvider { String sourceAmount, FiatPaymentMethod paymentMethod, ) async { + if (target is! CryptoCurrency) { + throw ArgumentError('Target currency must be a CryptoCurrency'); + } + final response = await _getPricesWithPaymentMethod( source, target, @@ -272,6 +298,10 @@ class RampFiatProvider extends BaseFiatProvider { String sourceAmount, String returnUrlOnSuccess, ) async { + if (target is! CryptoCurrency) { + throw ArgumentError('Target currency must be a CryptoCurrency'); + } + final payload = { 'hostApiKey': hostId, 'hostAppName': appShortTitle, @@ -283,6 +313,8 @@ class RampFiatProvider extends BaseFiatProvider { 'fiatCurrency': source, 'fiatValue': sourceAmount, 'defaultAsset': getFullCoinCode(target), + 'hideExitButton': 'true', + // 'variant': 'hosted', // desktop, mobile, auto, hosted-mobile // if(coinsBloc.walletCoins.isNotEmpty) // "swapAsset": coinsBloc.walletCoins.map((e) => e.abbr).toList().toString(), // "swapAsset": fullAssetCode, // This limits the crypto asset list at the redirect page diff --git a/lib/shared/widgets/coin_item/coin_item.dart b/lib/shared/widgets/coin_item/coin_item.dart index 0e593547ca..b1005da540 100644 --- a/lib/shared/widgets/coin_item/coin_item.dart +++ b/lib/shared/widgets/coin_item/coin_item.dart @@ -6,8 +6,8 @@ import 'package:web_dex/shared/widgets/coin_item/coin_logo.dart'; class CoinItem extends StatelessWidget { const CoinItem({ - super.key, required this.coin, + super.key, this.amount, this.size = CoinItemSize.medium, this.subtitleText, diff --git a/lib/views/fiat/fiat_asset_icon.dart b/lib/views/fiat/fiat_asset_icon.dart index 24474f1cd4..27bbf99c9f 100644 --- a/lib/views/fiat/fiat_asset_icon.dart +++ b/lib/views/fiat/fiat_asset_icon.dart @@ -24,12 +24,15 @@ class FiatAssetIcon extends StatelessWidget { @override Widget build(BuildContext context) { if (currency.isFiat) { - return FiatIcon(symbol: currency.symbol); + return FiatIcon(symbol: currency.getAbbr()); } + // TODO: standardise the icon layout. CoinItem contains the icon and the + // coin name + protocol, but the provided icon Widget could be anything + // and on failure it's usually just the Icon if (assetExists ?? false) { final sdk = RepositoryProvider.of(context); - final asset = sdk.getSdkAsset(currency.symbol); + final asset = sdk.getSdkAsset(currency.getAbbr()); return CoinItem(coin: asset.toCoin(), size: CoinItemSize.large); } else { return icon; diff --git a/lib/views/fiat/fiat_currency_list_tile.dart b/lib/views/fiat/fiat_currency_list_tile.dart index 6923a79897..7ce77fb18f 100644 --- a/lib/views/fiat/fiat_currency_list_tile.dart +++ b/lib/views/fiat/fiat_currency_list_tile.dart @@ -51,7 +51,7 @@ class FiatCurrencyListTile extends StatelessWidget { ), ], ) - : SizedBox.shrink(), + : const SizedBox.shrink(), onTap: onTap, ); } diff --git a/lib/views/fiat/fiat_form.dart b/lib/views/fiat/fiat_form.dart index b3e3070643..15e5188aa7 100644 --- a/lib/views/fiat/fiat_form.dart +++ b/lib/views/fiat/fiat_form.dart @@ -2,14 +2,11 @@ import 'dart:async'; import 'package:app_theme/app_theme.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:komodo_defi_types/komodo_defi_types.dart'; import 'package:komodo_ui_kit/komodo_ui_kit.dart'; import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; -import 'package:web_dex/bloc/coins_bloc/coins_bloc.dart'; -import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; import 'package:web_dex/bloc/fiat/fiat_onramp_form/fiat_form_bloc.dart'; import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; import 'package:web_dex/bloc/fiat/models/fiat_mode.dart'; @@ -22,6 +19,7 @@ import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dar import 'package:web_dex/views/fiat/fiat_action_tab.dart'; import 'package:web_dex/views/fiat/fiat_inputs.dart'; import 'package:web_dex/views/fiat/fiat_payment_methods_grid.dart'; +import 'package:web_dex/views/fiat/fiat_provider_web_view_settings.dart'; import 'package:web_dex/views/fiat/webview_dialog.dart'; import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; @@ -152,7 +150,7 @@ class _FiatFormState extends State { } void _completeOrder() => - context.read().add(FiatFormSubmitted()); + context.read().add(const FiatFormSubmitted()); void _onFiatChanged(FiatCurrency value) => context.read() ..add(FiatFormFiatSelected(value)) @@ -185,31 +183,6 @@ class _FiatFormState extends State { } } - void _showOrderFailedSnackbar() { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(LocaleKeys.orderFailedTryAgain.tr()), - ), - ); - } - - Future _openCheckoutPage(String checkoutUrl, String orderId) async { - if (checkoutUrl.isEmpty) return; - - // Only web requires the intermediate html page to satisfy cors rules and - // allow for console.log and postMessage events to be handled. - final url = - kIsWeb ? BaseFiatProvider.fiatWrapperPageUrl(checkoutUrl) : checkoutUrl; - - return WebViewDialog.show( - context, - url: url, - title: LocaleKeys.buy.tr(), - onConsoleMessage: _onConsoleMessage, - onCloseWindow: _onCloseWebView, - ); - } - void _onConsoleMessage(String message) { context .read() @@ -230,61 +203,65 @@ class _FiatFormState extends State { final status = stateSnapshot.fiatOrderStatus; if (status == FiatOrderStatus.submitted) { - // ignore: use_build_context_synchronously context.read().add(const FiatFormOrderStatusWatchStarted()); - await _openCheckoutPage(stateSnapshot.checkoutUrl, stateSnapshot.orderId); - return; - } - if (status == FiatOrderStatus.failed) { - _showOrderFailedSnackbar(); + await WebViewDialog.show( + context, + url: stateSnapshot.checkoutUrl, + mode: stateSnapshot.webViewMode, + title: LocaleKeys.buy.tr(), + onMessage: _onConsoleMessage, + onCloseWindow: _onCloseWebView, + settings: FiatProviderWebViewSettings.createSecureProviderSettings(), + ); } if (status == FiatOrderStatus.windowCloseRequested) { Navigator.of(context).pop(); } - if (status != FiatOrderStatus.pending) { - await _showPaymentStatusDialog(status); + if (status != FiatOrderStatus.initial) { + await _showPaymentStatusDialog(stateSnapshot); } } - Future _showPaymentStatusDialog(FiatOrderStatus status) async { + Future _showPaymentStatusDialog(FiatFormState state) async { if (!mounted) return; String? title; String? content; - - // TODO: Use theme-based semantic colors Icon? icon; + final status = state.fiatOrderStatus; + switch (status) { case FiatOrderStatus.inProgress: case FiatOrderStatus.windowCloseRequested: - case FiatOrderStatus.pending: + case FiatOrderStatus.initial: + case FiatOrderStatus.pendingPayment: debugPrint('Pending status should not be shown in dialog.'); return; - case FiatOrderStatus.submitted: title = LocaleKeys.fiatPaymentSubmittedTitle.tr(); content = LocaleKeys.fiatPaymentSubmittedMessage.tr(); icon = const Icon(Icons.open_in_new); - case FiatOrderStatus.success: title = LocaleKeys.fiatPaymentSuccessTitle.tr(); content = LocaleKeys.fiatPaymentSuccessMessage.tr(); icon = const Icon(Icons.check_circle_outline); - case FiatOrderStatus.failed: title = LocaleKeys.fiatPaymentFailedTitle.tr(); - // TODO: If we implement provider-specific error messages, - // we can include support details. content = LocaleKeys.fiatPaymentFailedMessage.tr(); + if (state.providerError != null && state.providerError!.isNotEmpty) { + content = '$content\n\n${LocaleKeys.errorDetails.tr()}: ' + '${state.providerError}'; + } icon = const Icon(Icons.error_outline, color: Colors.red); } await showAdaptiveDialog( context: context, + barrierDismissible: true, builder: (context) => AlertDialog.adaptive( title: Text(title!), icon: icon, @@ -311,7 +288,7 @@ extension on FiatAmountValidationError { case FiatAmountValidationError.belowMinimum: return LocaleKeys.fiatMinimumAmount.tr( args: [ - state.minFiatAmount?.toString() ?? '', + state.minFiatAmount?.toStringAsFixed(2) ?? '', fiatId, ], ); diff --git a/lib/views/fiat/fiat_inputs.dart b/lib/views/fiat/fiat_inputs.dart index fbd2fbe936..4045971097 100644 --- a/lib/views/fiat/fiat_inputs.dart +++ b/lib/views/fiat/fiat_inputs.dart @@ -116,10 +116,12 @@ class FiatInputsState extends State { final fiatListLoading = widget.fiatList.length <= 1; final coinListLoading = widget.coinList.length <= 1; - final boundariesString = widget.fiatMaxAmount == null && - widget.fiatMinAmount == null - ? '' - : '(${widget.fiatMinAmount ?? '1'} - ${widget.fiatMaxAmount ?? '∞'})'; + final minFiatAmount = widget.fiatMinAmount?.toStringAsFixed(2); + final maxFiatAmount = widget.fiatMaxAmount?.toStringAsFixed(2); + final boundariesString = + widget.fiatMaxAmount == null && widget.fiatMinAmount == null + ? '' + : '(${minFiatAmount ?? '1'} - ${maxFiatAmount ?? '∞'})'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -135,8 +137,8 @@ class FiatInputsState extends State { disabled: fiatListLoading, currency: widget.initialFiat, icon: FiatIcon( - key: Key('fiat_icon_${widget.initialFiat.symbol}'), - symbol: widget.initialFiat.symbol, + key: Key('fiat_icon_${widget.initialFiat.getAbbr()}'), + symbol: widget.initialFiat.getAbbr(), ), onTap: () => _showAssetSelectionDialog('fiat'), isListTile: false, diff --git a/lib/views/fiat/fiat_payment_methods_grid.dart b/lib/views/fiat/fiat_payment_methods_grid.dart index c3c340e050..6fb7893dfb 100644 --- a/lib/views/fiat/fiat_payment_methods_grid.dart +++ b/lib/views/fiat/fiat_payment_methods_grid.dart @@ -43,8 +43,8 @@ class FiatPaymentMethodsGrid extends StatelessWidget { child: Text( LocaleKeys.noOptionsToPurchase.tr( args: [ - state.selectedAsset.value!.symbol, - state.selectedFiat.value!.symbol, + state.selectedAsset.value!.getAbbr(), + state.selectedFiat.value!.getAbbr(), ], ), textAlign: TextAlign.center, diff --git a/lib/views/fiat/fiat_provider_web_view_settings.dart b/lib/views/fiat/fiat_provider_web_view_settings.dart new file mode 100644 index 0000000000..ea62c52b23 --- /dev/null +++ b/lib/views/fiat/fiat_provider_web_view_settings.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +/// Default trusted domains for the WebView content blockers +const List kDefaultTrustedDomainFilters = [ + r'komodo\.banxa\.com.*', + r'app\.demo\.ramp\.network.*', + r'app\.ramp\.network.*', + r'embed\.bitrefill\.com.*', +]; + +/// Factory methods for creating webview settings for specific providers +class FiatProviderWebViewSettings { + /// Creates secure webview settings for fiat providers like Banxa, Ramp, etc. + /// + /// The [trustedDomainFilters] parameter allows filtering content to only + /// trusted domains for security. + static InAppWebViewSettings createSecureProviderSettings({ + List trustedDomainFilters = kDefaultTrustedDomainFilters, + }) { + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options + return InAppWebViewSettings( + isInspectable: kDebugMode, + iframeSandbox: { + // Required for cookies and localStorage access + Sandbox.ALLOW_SAME_ORIGIN, + // Required for dynamic iframe content to load in Banxa and Ramp + // webviews. + Sandbox.ALLOW_SCRIPTS, + // Required for Ramp and Banxa form submissions throughout the KYC + // and payment process. + Sandbox.ALLOW_FORMS, + // Required for Ramp "Check transaction status" button after payment + // to work. + Sandbox.ALLOW_POPUPS, + // Deliberately NOT including ALLOW_TOP_NAVIGATION to prevent + // parent navigation + }, + contentBlockers: [ + // Block all content by default + ContentBlocker( + trigger: ContentBlockerTrigger( + urlFilter: '.*', + ), + action: ContentBlockerAction( + type: ContentBlockerActionType.BLOCK, + ), + ), + // Allow the specific domains we trust + ...trustedDomainFilters.map( + (urlFilter) => ContentBlocker( + trigger: ContentBlockerTrigger( + urlFilter: urlFilter, + ), + action: ContentBlockerAction( + type: ContentBlockerActionType.IGNORE_PREVIOUS_RULES, + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/fiat/webview_dialog.dart b/lib/views/fiat/webview_dialog.dart index e31311a675..edb1734791 100644 --- a/lib/views/fiat/webview_dialog.dart +++ b/lib/views/fiat/webview_dialog.dart @@ -5,47 +5,80 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:web_dex/common/screen.dart'; import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/utils/window/window.dart'; + +/// The display mode for the webview dialog. +enum WebViewDialogMode { + /// Show the webview in a dialog or popup window. + dialog, + + /// Show the webview in fullscreen mode, as a new material navigation route. + fullscreen, + + /// Show the webview in a new browser tab (web) or external browser. + newTab, +} class WebViewDialog { + /// Shows a webview dialog with the given [url] and [title]. + /// The [onMessage] callback is called with the console messages from + /// the webview. + /// The [onCloseWindow] callback is called when the webview is closed. + /// The [settings] parameter allows you to customize [InAppWebViewSettings] + /// The [mode] parameter allows you to choose how the webview is shown. + /// The [width] and [height] parameters allow you to customize the size of the + /// dialog. static Future show( BuildContext context, { required String url, required String title, - void Function(String)? onConsoleMessage, + void Function(String)? onMessage, VoidCallback? onCloseWindow, InAppWebViewSettings? settings, + WebViewDialogMode? mode, + double width = 700, + double height = 700, }) async { + final webviewSettings = settings ?? + InAppWebViewSettings(isInspectable: kDebugMode, iframeSandbox: { + Sandbox.ALLOW_SAME_ORIGIN, + Sandbox.ALLOW_SCRIPTS, + Sandbox.ALLOW_FORMS, + Sandbox.ALLOW_POPUPS, + }); + + final bool isLinux = !kIsWeb && !kIsWasm && Platform.isLinux; + final bool isWeb = (kIsWeb || kIsWasm) && !isMobile; + final WebViewDialogMode defaultMode = + isWeb ? WebViewDialogMode.dialog : WebViewDialogMode.fullscreen; + final WebViewDialogMode resolvedMode = mode ?? defaultMode; + + // If on Linux, always use newTab mode (open in external browser) // `flutter_inappwebview` does not yet support Linux, so use `url_launcher` // to launch the URL in the default browser. - if (!kIsWeb && !kIsWasm && Platform.isLinux) { - return launchURLString(url); + final bool shouldOpenInNewTab = + resolvedMode == WebViewDialogMode.newTab || isLinux; + if (shouldOpenInNewTab) { + await launchURLString(url, inSeparateTab: true); + return; } - final webviewSettings = settings ?? - InAppWebViewSettings( - isInspectable: kDebugMode, - iframeSandbox: { - Sandbox.ALLOW_SAME_ORIGIN, - Sandbox.ALLOW_SCRIPTS, - Sandbox.ALLOW_FORMS, - Sandbox.ALLOW_POPUPS, - }, - ); - - if (kIsWeb && !isMobile) { + if (resolvedMode == WebViewDialogMode.dialog) { await showDialog( context: context, builder: (BuildContext context) { return InAppWebviewDialog( title: title, webviewSettings: webviewSettings, - onConsoleMessage: onConsoleMessage ?? (_) {}, + onConsoleMessage: onMessage ?? (_) {}, onCloseWindow: onCloseWindow, url: url, + width: width, + height: height, ); }, ); - } else { + } else if (resolvedMode == WebViewDialogMode.fullscreen) { await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, @@ -53,7 +86,7 @@ class WebViewDialog { return FullscreenInAppWebview( title: title, webviewSettings: webviewSettings, - onConsoleMessage: onConsoleMessage ?? (_) {}, + onConsoleMessage: onMessage ?? (_) {}, onCloseWindow: onCloseWindow, url: url, ); @@ -71,6 +104,8 @@ class InAppWebviewDialog extends StatelessWidget { required this.onConsoleMessage, required this.url, this.onCloseWindow, + this.width = 700, + this.height = 700, super.key, }); @@ -79,17 +114,20 @@ class InAppWebviewDialog extends StatelessWidget { final void Function(String) onConsoleMessage; final String url; final VoidCallback? onCloseWindow; + final double width; + final double height; @override Widget build(BuildContext context) { return Dialog( shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(12.0), // Match your app's corner radius + borderRadius: BorderRadius.circular(12.0), ), + insetPadding: + const EdgeInsets.symmetric(horizontal: 40.0, vertical: 24.0), child: SizedBox( - width: 700, - height: 700, + width: width, + height: height, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -156,6 +194,13 @@ class FullscreenInAppWebview extends StatelessWidget { title: Text(title), foregroundColor: Theme.of(context).textTheme.bodyMedium?.color, elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + onCloseWindow?.call(); + Navigator.of(context).pop(); + }, + ), ), body: SafeArea( child: MessageInAppWebView( @@ -198,30 +243,10 @@ class _MessageInAppWebviewState extends State { initialUrlRequest: urlRequest, onConsoleMessage: _onConsoleMessage, onUpdateVisitedHistory: _onUpdateHistory, - onCloseWindow: (_) { - widget.onCloseWindow?.call(); - }, - onLoadStop: (controller, url) async { - await controller.evaluateJavascript( - source: ''' - window.addEventListener("message", (event) => { - let messageData; - try { - messageData = JSON.parse(event.data); - } catch (parseError) { - messageData = event.data; - } - - try { - const messageString = (typeof messageData === 'object') ? JSON.stringify(messageData) : String(messageData); - console.log(messageString); - } catch (postError) { - console.error('Error posting message', postError); - } - }, false); - ''', - ); - }, + onCloseWindow: (_) => widget.onCloseWindow?.call(), + // injected JS is done in the HTML wrapper iframe in fiat_widget.html, + // so we don't need to inject it here. E.g. onLoadStop, + // evaluateJavascript, etc. ); } @@ -229,12 +254,15 @@ class _MessageInAppWebviewState extends State { widget.onConsoleMessage(consoleMessage.message); } + // Banxa and Ramp both redirect to the provided success URL on completion, + // and Banxa recommends closing the webview when this happens. + // https://docs.banxa.com/v1.3/docs/mobile-applications-webview void _onUpdateHistory( InAppWebViewController controller, WebUri? url, bool? isReload, ) { - if (url.toString() == 'https://app.komodoplatform.com/') { + if (url.toString() == getOriginUrl()) { Navigator.of(context).pop(); } }