diff --git a/src/components/scenes/RampCreateScene.tsx b/src/components/scenes/RampCreateScene.tsx index 628e095ac66..f2e1a6de689 100644 --- a/src/components/scenes/RampCreateScene.tsx +++ b/src/components/scenes/RampCreateScene.tsx @@ -1,6 +1,11 @@ import { useFocusEffect } from '@react-navigation/native' import { useQuery } from '@tanstack/react-query' import { div, gt, mul, round, toBns } from 'biggystring' +import type { + EdgeCurrencyWallet, + EdgeDenomination, + EdgeTokenId +} from 'edge-core-js' import * as React from 'react' import { useState } from 'react' import { ActivityIndicator, Text, View } from 'react-native' @@ -15,6 +20,7 @@ import { } from '../../actions/SettingsActions' import { FLAG_LOGO_URL } from '../../constants/CdnConstants' import { COUNTRY_CODES, FIAT_COUNTRY } from '../../constants/CountryConstants' +import { getSpecialCurrencyInfo } from '../../constants/WalletAndCurrencyConstants' import { useHandler } from '../../hooks/useHandler' import { useRampLastCryptoSelection } from '../../hooks/useRampLastCryptoSelection' import { useRampPlugins } from '../../hooks/useRampPlugins' @@ -43,7 +49,11 @@ import type { GuiFiatType } from '../../types/types' import { getCurrencyCode } from '../../util/CurrencyInfoHelpers' import { getHistoricalFiatRate } from '../../util/exchangeRates' import { logEvent } from '../../util/tracking' -import { DECIMAL_PRECISION, mulToPrecision } from '../../util/utils' +import { + convertNativeToDenomination, + DECIMAL_PRECISION, + mulToPrecision +} from '../../util/utils' import { DropdownInputButton } from '../buttons/DropdownInputButton' import { EdgeButton } from '../buttons/EdgeButton' import { PillButton } from '../buttons/PillButton' @@ -330,7 +340,7 @@ export const RampCreateScene: React.FC = (props: Props) => { // Fetch quotes using the custom hook const { - quotes: sortedQuotes, + quotes: allQuotes, isLoading: isLoadingQuotes, isFetching: isFetchingQuotes, errors: quoteErrors @@ -342,19 +352,19 @@ export const RampCreateScene: React.FC = (props: Props) => { }) // Get the best quote using .find because we want to preserve undefined in its type - const bestQuote = sortedQuotes.find((_, index) => index === 0) + const bestQuote = allQuotes.find((_, index) => index === 0) // For Max flow, select the quote with the largest supported amount const maxQuoteForMaxFlow = React.useMemo(() => { - if (!('max' in exchangeAmount) || sortedQuotes.length === 0) return null + if (!('max' in exchangeAmount) || allQuotes.length === 0) return null - const picked = sortedQuotes.reduce((a, b): RampQuote => { + const picked = allQuotes.reduce((a, b): RampQuote => { const aAmount = lastUsedInput === 'crypto' ? a.cryptoAmount : a.fiatAmount const bAmount = lastUsedInput === 'crypto' ? b.cryptoAmount : b.fiatAmount return gt(bAmount, aAmount) ? b : a }) return picked - }, [exchangeAmount, sortedQuotes, lastUsedInput]) + }, [exchangeAmount, allQuotes, lastUsedInput]) // Calculate exchange rate from best quote const quoteExchangeRate = React.useMemo(() => { @@ -387,10 +397,7 @@ export const RampCreateScene: React.FC = (props: Props) => { if ('empty' in exchangeAmount) return '' if ('max' in exchangeAmount) { - if (maxQuoteForMaxFlow != null) { - return maxQuoteForMaxFlow.fiatAmount ?? '' - } - return '' + return maxQuoteForMaxFlow?.fiatAmount ?? '' } if (lastUsedInput === 'fiat') { @@ -422,10 +429,10 @@ export const RampCreateScene: React.FC = (props: Props) => { if ('empty' in exchangeAmount || lastUsedInput === null) return '' if ('max' in exchangeAmount) { - if (maxQuoteForMaxFlow != null) { - return maxQuoteForMaxFlow.cryptoAmount ?? '' - } - return '' + return ( + maxQuoteForMaxFlow?.cryptoAmount ?? + (typeof exchangeAmount.max === 'string' ? exchangeAmount.max : '') + ) } if (lastUsedInput === 'crypto') { @@ -561,20 +568,36 @@ export const RampCreateScene: React.FC = (props: Props) => { setLastUsedInput('crypto') }) - const handleMaxPress = useHandler(() => { + const handleMaxPress = useHandler(async () => { // Preconditions to submit a max request if ( - selectedWallet == null || + countryCode === '' || + denomination == null || + selectedCrypto == null || selectedCryptoCurrencyCode == null || - countryCode === '' + selectedWallet == null ) { return } // Trigger a transient max flow: request quotes with {max:true} and auto-navigate when ready setPendingMaxNav(true) - setLastUsedInput('fiat') - setExchangeAmount({ max: true }) + setLastUsedInput(direction === 'buy' ? 'fiat' : 'crypto') + + if (direction === 'sell') { + const maxSpendExchangeAmount = await getMaxSpendExchangeAmount( + selectedWallet, + selectedCrypto.tokenId, + denomination + ) + setExchangeAmount({ + max: maxSpendExchangeAmount + }) + } else { + setExchangeAmount({ + max: true + }) + } }) // Auto-navigate once a best quote arrives for the transient max flow @@ -588,21 +611,6 @@ export const RampCreateScene: React.FC = (props: Props) => { maxQuoteForMaxFlow != null && !isLoadingQuotes ) { - // Persist the chosen amount so it remains after returning - if ( - !amountTypeSupport.onlyCrypto && - maxQuoteForMaxFlow.fiatAmount != null - ) { - setLastUsedInput('fiat') - setExchangeAmount({ amount: maxQuoteForMaxFlow.fiatAmount }) - } else if ( - !amountTypeSupport.onlyFiat && - maxQuoteForMaxFlow.cryptoAmount != null - ) { - setLastUsedInput('crypto') - setExchangeAmount({ amount: maxQuoteForMaxFlow.cryptoAmount }) - } - navigation.navigate('rampSelectOption', { rampQuoteRequest }) @@ -637,13 +645,9 @@ export const RampCreateScene: React.FC = (props: Props) => { ) } - const fiatInputDisabled = - ('max' in exchangeAmount && sortedQuotes.length > 0) || - amountTypeSupport.onlyCrypto + const fiatInputDisabled = amountTypeSupport.onlyCrypto const cryptoInputDisabled = - isLoadingPersistedCryptoSelection || - ('max' in exchangeAmount && sortedQuotes.length > 0) || - amountTypeSupport.onlyFiat + isLoadingPersistedCryptoSelection || amountTypeSupport.onlyFiat // Render trade form view return ( @@ -778,7 +782,7 @@ export const RampCreateScene: React.FC = (props: Props) => { denomination == null || 'empty' in exchangeAmount || lastUsedInput == null || - (!isLoadingQuotes && sortedQuotes.length === 0) ? null : ( + (!isLoadingQuotes && allQuotes.length === 0) ? null : ( <> {lstrings.trade_create_exchange_rate} @@ -799,7 +803,7 @@ export const RampCreateScene: React.FC = (props: Props) => { // Nothing is loading !isResultLoading && // Nothing was returned - sortedQuotes.length === 0 && + allQuotes.length === 0 && quoteErrors.length === 0 && // User has queried !('empty' in exchangeAmount) && @@ -825,7 +829,7 @@ export const RampCreateScene: React.FC = (props: Props) => { } {!isResultLoading && - sortedQuotes.length === 0 && + allQuotes.length === 0 && supportedPlugins.length > 0 && !('empty' in exchangeAmount) ? ( supportedPluginsError != null ? ( @@ -860,7 +864,7 @@ export const RampCreateScene: React.FC = (props: Props) => { 'empty' in exchangeAmount || lastUsedInput === null || supportedPlugins.length === 0 || - sortedQuotes.length === 0 || + allQuotes.length === 0 || (lastUsedInput === 'fiat' && amountTypeSupport.onlyCrypto) || (lastUsedInput === 'crypto' && amountTypeSupport.onlyFiat) } @@ -998,3 +1002,28 @@ function getRoundedFiatEquivalent(fiatAmount: string, rate: string): string { usdAmount = round(usdAmount, usdAmount.length - 1) return usdAmount } + +async function getMaxSpendExchangeAmount( + wallet: EdgeCurrencyWallet, + tokenId: EdgeTokenId, + denomination: EdgeDenomination +): Promise { + async function getDummyAddress(): Promise { + const pluginId = wallet.currencyInfo.pluginId + const dummyPublicAddress = + getSpecialCurrencyInfo(pluginId).dummyPublicAddress + if (dummyPublicAddress != null) { + return dummyPublicAddress + } + const addresses = await wallet.getAddresses({ tokenId: null }) + return addresses.length > 0 ? addresses[0].publicAddress : '' + } + const maxSpendNativeAmount = await wallet.getMaxSpendable({ + tokenId, + spendTargets: [{ publicAddress: await getDummyAddress() }] + }) + const maxSpendExchangeAmount = convertNativeToDenomination( + denomination.multiplier + )(maxSpendNativeAmount) + return maxSpendExchangeAmount +} diff --git a/src/plugins/ramps/banxa/banxaRampPlugin.ts b/src/plugins/ramps/banxa/banxaRampPlugin.ts index 072d8cde816..eb4dcdc699b 100644 --- a/src/plugins/ramps/banxa/banxaRampPlugin.ts +++ b/src/plugins/ramps/banxa/banxaRampPlugin.ts @@ -890,10 +890,14 @@ export const banxaRampPlugin: RampPluginFactory = ( } = request const currencyPluginId = request.wallet.currencyInfo.pluginId - const isMaxAmount = - 'max' in request.exchangeAmount && request.exchangeAmount.max + const isMaxAmount = 'max' in request.exchangeAmount const exchangeAmount = 'amount' in request.exchangeAmount ? request.exchangeAmount.amount : '' + const maxAmountLimit = + 'max' in request.exchangeAmount && + typeof request.exchangeAmount.max === 'string' + ? request.exchangeAmount.max + : undefined // Fetch provider configuration (cached or fresh) const config = await fetchProviderConfig() @@ -1059,6 +1063,13 @@ export const banxaRampPlugin: RampPluginFactory = ( continue } maxAmountString = maxPriceRow.coin_amount + + if ( + maxAmountLimit != null && + gt(maxAmountString, maxAmountLimit) + ) { + maxAmountString = maxAmountLimit + } } } diff --git a/src/plugins/ramps/bity/bityRampPlugin.ts b/src/plugins/ramps/bity/bityRampPlugin.ts index b856801f5b0..fd6ca8d9cc2 100644 --- a/src/plugins/ramps/bity/bityRampPlugin.ts +++ b/src/plugins/ramps/bity/bityRampPlugin.ts @@ -685,10 +685,14 @@ export const bityRampPlugin = (pluginConfig: RampPluginConfig): RampPlugin => { const currencyPluginId = request.wallet.currencyInfo.pluginId const isBuy = direction === 'buy' - const isMaxAmount = - 'max' in request.exchangeAmount && request.exchangeAmount.max + const isMaxAmount = 'max' in request.exchangeAmount const exchangeAmount = 'amount' in request.exchangeAmount ? request.exchangeAmount.amount : '' + const maxAmountLimit = + 'max' in request.exchangeAmount && + typeof request.exchangeAmount.max === 'string' + ? request.exchangeAmount.max + : undefined // Validate region using helper function if (!isRegionSupported(regionCode)) { @@ -782,6 +786,10 @@ export const bityRampPlugin = (pluginConfig: RampPluginConfig): RampPlugin => { return [] } } + + if (maxAmountLimit != null && gt(amount, maxAmountLimit)) { + amount = maxAmountLimit + } } else { amount = toFixed(exchangeAmount, amountPrecision) } diff --git a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts index 0ec8f065db5..587205f7430 100644 --- a/src/plugins/ramps/moonpay/moonpayRampPlugin.ts +++ b/src/plugins/ramps/moonpay/moonpayRampPlugin.ts @@ -517,10 +517,14 @@ export const moonpayRampPlugin: RampPluginFactory = ( const { direction, regionCode, displayCurrencyCode, tokenId } = request const fiatCurrencyCode = ensureIsoPrefix(request.fiatCurrencyCode) - const isMaxAmount = - 'max' in request.exchangeAmount && request.exchangeAmount.max + const isMaxAmount = 'max' in request.exchangeAmount const exchangeAmountString = 'amount' in request.exchangeAmount ? request.exchangeAmount.amount : '' + const maxAmountLimitString = + 'max' in request.exchangeAmount && + typeof request.exchangeAmount.max === 'string' + ? request.exchangeAmount.max + : undefined // Fetch provider configuration (with caching) const config = await fetchProviderConfig() @@ -648,9 +652,17 @@ export const moonpayRampPlugin: RampPluginFactory = ( Infinity } + const maxAmountLimit = + maxAmountLimitString != null + ? parseFloat(maxAmountLimitString) + : undefined + let exchangeAmount: number if (isMaxAmount) { exchangeAmount = request.amountType === 'fiat' ? maxFiat : maxCrypto + if (maxAmountLimit != null && isFinite(maxAmountLimit)) { + exchangeAmount = Math.min(exchangeAmount, maxAmountLimit) + } } else { exchangeAmount = parseFloat(exchangeAmountString) } diff --git a/src/plugins/ramps/paybis/paybisRampPlugin.ts b/src/plugins/ramps/paybis/paybisRampPlugin.ts index 429b8bae0e3..84011fa4480 100644 --- a/src/plugins/ramps/paybis/paybisRampPlugin.ts +++ b/src/plugins/ramps/paybis/paybisRampPlugin.ts @@ -747,10 +747,14 @@ export const paybisRampPlugin: RampPluginFactory = ( } = request const currencyPluginId = request.wallet.currencyInfo.pluginId - const isMaxAmount = - 'max' in request.exchangeAmount && request.exchangeAmount.max + const isMaxAmount = 'max' in request.exchangeAmount const exchangeAmount = 'amount' in request.exchangeAmount ? request.exchangeAmount.amount : '' + const maxAmountLimit = + 'max' in request.exchangeAmount && + typeof request.exchangeAmount.max === 'string' + ? request.exchangeAmount.max + : undefined // Validate region restrictions if (regionCode != null) { @@ -838,6 +842,16 @@ export const paybisRampPlugin: RampPluginFactory = ( if (isMaxAmount) { // Use default max amounts amount = amountType === 'fiat' ? '10000' : '10' + + if (maxAmountLimit != null) { + const maxCapNumber = parseFloat(maxAmountLimit) + const amountNumber = parseFloat(amount) + if (!Number.isNaN(maxCapNumber) && !Number.isNaN(amountNumber)) { + if (amountNumber > maxCapNumber) { + amount = maxAmountLimit + } + } + } } else { amount = exchangeAmount } diff --git a/src/plugins/ramps/rampPluginTypes.ts b/src/plugins/ramps/rampPluginTypes.ts index 0e7e6539273..e870655eaab 100644 --- a/src/plugins/ramps/rampPluginTypes.ts +++ b/src/plugins/ramps/rampPluginTypes.ts @@ -39,7 +39,14 @@ export interface RampSupportResult { export type RampExchangeAmount = | { - max: true + /** + * Requests a quote for the maximum amount that the provider supports. + * If a string amount is provided (in units of the amountType), then + * the quote amount must not exceed this amount. + * If `true` is provided then the maximum is the amount that the provider + * supports. + * */ + max: string | true } | { amount: string } diff --git a/src/plugins/ramps/revolut/revolutRampPlugin.ts b/src/plugins/ramps/revolut/revolutRampPlugin.ts index 9a7992bb71f..a63acbc7239 100644 --- a/src/plugins/ramps/revolut/revolutRampPlugin.ts +++ b/src/plugins/ramps/revolut/revolutRampPlugin.ts @@ -191,10 +191,14 @@ export const revolutRampPlugin: RampPluginFactory = ( } = request const currencyPluginId = request.wallet.currencyInfo.pluginId - const isMaxAmount = - 'max' in request.exchangeAmount && request.exchangeAmount.max + const isMaxAmount = 'max' in request.exchangeAmount const exchangeAmount = 'amount' in request.exchangeAmount ? request.exchangeAmount.amount : '' + const maxAmountLimit = + 'max' in request.exchangeAmount && + typeof request.exchangeAmount.max === 'string' + ? request.exchangeAmount.max + : undefined // Constraints per request const constraintOk = validateRampQuoteRequest( @@ -287,7 +291,7 @@ export const revolutRampPlugin: RampPluginFactory = ( } // Fetch quote from Revolut (API only needs country code) - const quoteData = await fetchRevolutQuote( + let quoteData = await fetchRevolutQuote( { fiat: revolutFiat.currency, amount, @@ -298,8 +302,42 @@ export const revolutRampPlugin: RampPluginFactory = ( { apiKey, baseUrl: apiUrl } ) + if (isMaxAmount && maxAmountLimit != null) { + const capValue = parseFloat(maxAmountLimit) + const quotedCrypto = parseFloat(quoteData.crypto.amount.toString()) + const currentFiat = parseFloat(amount) + if ( + !Number.isNaN(capValue) && + !Number.isNaN(quotedCrypto) && + !Number.isNaN(currentFiat) && + quotedCrypto > 0 && + capValue < quotedCrypto + ) { + const scaledFiat = (currentFiat * capValue) / quotedCrypto + if (scaledFiat < revolutFiat.min_limit) { + throw new FiatProviderError({ + providerId: pluginId, + errorType: 'underLimit', + errorAmount: revolutFiat.min_limit, + displayCurrencyCode: revolutFiat.currency + }) + } + amount = scaledFiat.toString() + quoteData = await fetchRevolutQuote( + { + fiat: revolutFiat.currency, + amount, + crypto: revolutCrypto.id, + payment: 'revolut', + region: regionCode.countryCode + }, + { apiKey, baseUrl: apiUrl } + ) + } + } + const cryptoAmount = quoteData.crypto.amount.toString() - const fiatAmount = exchangeAmount + const fiatAmount = amount // Assume 1 minute expiration const expirationDate = new Date(Date.now() + 1000 * 60) diff --git a/src/plugins/ramps/simplex/simplexRampPlugin.ts b/src/plugins/ramps/simplex/simplexRampPlugin.ts index 824ada37ec9..eec646cb672 100644 --- a/src/plugins/ramps/simplex/simplexRampPlugin.ts +++ b/src/plugins/ramps/simplex/simplexRampPlugin.ts @@ -480,10 +480,14 @@ export const simplexRampPlugin: RampPluginFactory = ( } = request const currencyPluginId = request.wallet.currencyInfo.pluginId - const isMaxAmount = - 'max' in request.exchangeAmount && request.exchangeAmount.max + const isMaxAmount = 'max' in request.exchangeAmount const exchangeAmount = 'amount' in request.exchangeAmount ? request.exchangeAmount.amount : '' + const maxAmountLimit = + 'max' in request.exchangeAmount && + typeof request.exchangeAmount.max === 'string' + ? request.exchangeAmount.max + : undefined // Validate direction if (!validateDirection(direction)) { @@ -531,6 +535,13 @@ export const simplexRampPlugin: RampPluginFactory = ( if (isMaxAmount) { // Use reasonable max amounts sourceAmount = amountType === 'fiat' ? 50000 : 100 + + if (amountType !== 'fiat' && maxAmountLimit != null) { + const capValue = parseFloat(maxAmountLimit) + if (isFinite(capValue)) { + sourceAmount = Math.min(sourceAmount, capValue) + } + } } else { sourceAmount = parseFloat(exchangeAmount) }