Skip to content

Commit

Permalink
feat(ramp): add sell quick amounts with gas estimations (#8080)
Browse files Browse the repository at this point in the history
Co-authored-by: Nico MASSART <[email protected]>
  • Loading branch information
wachunei and NicolasMassart authored Jan 11, 2024
1 parent a78c0c6 commit f22e0eb
Show file tree
Hide file tree
Showing 13 changed files with 786 additions and 77 deletions.
121 changes: 112 additions & 9 deletions app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import initialBackgroundState from '../../../../../../util/test/initial-backgrou
import useCryptoCurrencies from '../../hooks/useCryptoCurrencies';
import useFiatCurrencies from '../../hooks/useFiatCurrencies';
import usePaymentMethods from '../../hooks/usePaymentMethods';
import useGasPriceEstimation from '../../../common/hooks/useGasPriceEstimation';
import {
mockCryptoCurrenciesData,
mockFiatCurrenciesData,
Expand All @@ -22,6 +23,7 @@ import useAddressBalance from '../../../../../hooks/useAddressBalance/useAddress
import useBalance from '../../../common/hooks/useBalance';
import { toTokenMinimalUnit } from '../../../../../../util/number';
import { RampType } from '../../../../../../reducers/fiatOrders/types';
import { NATIVE_ADDRESS } from '../../../../../../constants/on-ramp';

const getByRoleButton = (name?: string | RegExp) =>
screen.getByRole('button', { name });
Expand Down Expand Up @@ -188,7 +190,7 @@ const mockUseBalanceInitialValue: Partial<ReturnType<typeof useBalance>> = {
balanceBN: toTokenMinimalUnit('5.36385', 18) as BN,
};

const mockUseBalanceValues = {
let mockUseBalanceValues: Partial<ReturnType<typeof useBalance>> = {
...mockUseBalanceInitialValue,
};

Expand Down Expand Up @@ -232,6 +234,22 @@ let mockUseParamsValues: {
showBack: undefined,
};

const mockUseGasPriceEstimationInitialValue: ReturnType<
typeof useGasPriceEstimation
> = {
estimatedGasFee: toTokenMinimalUnit(
'0.01',
mockUseRampSDKInitialValues.selectedAsset?.decimals || 18,
) as BN,
};

let mockUseGasPriceEstimationValue: ReturnType<typeof useGasPriceEstimation> =
mockUseGasPriceEstimationInitialValue;

jest.mock('../../../common/hooks/useGasPriceEstimation', () =>
jest.fn(() => mockUseGasPriceEstimationValue),
);

jest.mock('../../../../../../util/navigation/navUtils', () => ({
...jest.requireActual('../../../../../../util/navigation/navUtils'),
useParams: jest.fn(() => mockUseParamsValues),
Expand Down Expand Up @@ -272,6 +290,9 @@ describe('BuildQuote View', () => {
mockUseParamsValues = {
showBack: undefined,
};
mockUseGasPriceEstimationValue = {
...mockUseGasPriceEstimationInitialValue,
};
});

//
Expand Down Expand Up @@ -599,13 +620,6 @@ describe('BuildQuote View', () => {
beforeEach(() => {
mockUseRampSDKValues.isBuy = false;
mockUseRampSDKValues.isSell = true;
mockUseLimitsValues = {
...mockUseLimitsInitialValues,
limits: {
...(mockUseLimitsInitialValues.limits as Limits),
quickAmounts: undefined,
},
};
});

it('updates the amount input', async () => {
Expand Down Expand Up @@ -662,6 +676,95 @@ describe('BuildQuote View', () => {
screen.getByText('This amount is higher than your balance'),
).toBeTruthy();
});

it('updates the amount input with quick amount buttons', async () => {
render(BuildQuote);
const initialAmount = '0';

mockUseBalanceValues.balanceBN = toTokenMinimalUnit(
'1',
mockUseRampSDKValues.selectedAsset?.decimals || 18,
) as BN;
const symbol = mockUseRampSDKValues.selectedAsset?.symbol;
fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`));
fireEvent.press(getByRoleButton('25%'));
expect(getByRoleButton(`0.25 ${symbol}`)).toBeTruthy();

fireEvent.press(getByRoleButton(`0.25 ${symbol}`));
fireEvent.press(getByRoleButton('MAX'));
expect(getByRoleButton(`1 ${symbol}`)).toBeTruthy();
});

it('updates the amount input up to the max considering gas for native asset', async () => {
render(BuildQuote);
const initialAmount = '0';
const quickAmount = 'MAX';
mockUseRampSDKValues = {
...mockUseRampSDKInitialValues,
isBuy: false,
isSell: true,
selectedAsset: {
...mockCryptoCurrenciesData[0],
address: NATIVE_ADDRESS,
},
};

mockUseBalanceValues = {
balance: '1',
balanceFiat: '$1.00',
balanceBN: toTokenMinimalUnit(
'1',
mockUseRampSDKValues.selectedAsset?.decimals || 18,
) as BN,
};
mockUseGasPriceEstimationValue = {
estimatedGasFee: toTokenMinimalUnit(
'0.27',
mockUseRampSDKValues.selectedAsset?.decimals || 18,
) as BN,
};
const symbol = mockUseRampSDKValues.selectedAsset?.symbol;
fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`));
fireEvent.press(getByRoleButton(quickAmount));
expect(getByRoleButton(`0.73 ${symbol}`)).toBeTruthy();
});

it('updates the amount input up to the percentage considering gas', async () => {
render(BuildQuote);
const initialAmount = '0';
mockUseRampSDKValues = {
...mockUseRampSDKInitialValues,
isBuy: false,
isSell: true,
selectedAsset: {
...mockCryptoCurrenciesData[0],
address: NATIVE_ADDRESS,
},
};

mockUseBalanceValues = {
balance: '1',
balanceFiat: '$1.00',
balanceBN: toTokenMinimalUnit(
'1',
mockUseRampSDKValues.selectedAsset?.decimals || 18,
) as BN,
};
mockUseGasPriceEstimationValue = {
estimatedGasFee: toTokenMinimalUnit(
'0.27',
mockUseRampSDKValues.selectedAsset?.decimals || 18,
) as BN,
};
const symbol = mockUseRampSDKValues.selectedAsset?.symbol;
fireEvent.press(getByRoleButton(`${initialAmount} ${symbol}`));
fireEvent.press(getByRoleButton('75%'));
expect(getByRoleButton(`0.73 ${symbol}`)).toBeTruthy();

fireEvent.press(getByRoleButton(`0.73 ${symbol}`));
fireEvent.press(getByRoleButton('50%'));
expect(getByRoleButton(`0.5 ${symbol}`)).toBeTruthy();
});
});

//
Expand Down Expand Up @@ -721,7 +824,7 @@ describe('BuildQuote View', () => {
fireEvent.press(submitBtn);

expect(mockNavigate).toHaveBeenCalledWith(Routes.RAMP.QUOTES, {
amount: VALID_AMOUNT,
amount: validAmount,
asset: mockUseRampSDKValues.selectedAsset,
fiatCurrency: mockUseFiatCurrenciesValues.currentFiatCurrency,
});
Expand Down
121 changes: 106 additions & 15 deletions app/components/UI/Ramp/buy/Views/BuildQuote/BuildQuote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,16 +62,21 @@ import {
import Routes from '../../../../../../constants/navigation/Routes';
import { formatAmount } from '../../../common/utils';
import { createQuotesNavDetails } from '../Quotes/Quotes';
import { Region, ScreenLocation } from '../../../common/types';
import { QuickAmount, Region, ScreenLocation } from '../../../common/types';
import { useStyles } from '../../../../../../component-library/hooks';

import styleSheet from './BuildQuote.styles';
import { toTokenMinimalUnit } from '../../../../../../util/number';
import {
toTokenMinimalUnit,
fromTokenMinimalUnitString,
} from '../../../../../../util/number';
import useGasPriceEstimation from '../../../common/hooks/useGasPriceEstimation';

// TODO: Convert into typescript and correctly type
const ListItem = BaseListItem as any;
const SelectorButton = BaseSelectorButton as any;

const TRANSFER_GAS_LIMIT = 21000;
interface BuildQuoteParams {
showBack?: boolean;
}
Expand Down Expand Up @@ -175,6 +180,12 @@ const BuildQuote = () => {
const { limits, isAmountBelowMinimum, isAmountAboveMaximum, isAmountValid } =
useLimits();

const gasPriceEstimation = useGasPriceEstimation({
// 0 is set when buying since there's no transaction involved
gasLimit: isBuy ? 0 : TRANSFER_GAS_LIMIT,
estimateRange: 'high',
});

const assetForBalance =
selectedAsset && selectedAsset.address !== NATIVE_ADDRESS
? {
Expand All @@ -200,6 +211,11 @@ const BuildQuote = () => {
: undefined,
);

const maxSellAmount =
balanceBN && gasPriceEstimation
? balanceBN?.sub(gasPriceEstimation.estimatedGasFee)
: null;

const amountIsBelowMinimum = useMemo(
() => isAmountBelowMinimum(amountNumber),
[amountNumber, isAmountBelowMinimum],
Expand All @@ -215,9 +231,16 @@ const BuildQuote = () => {
[amountNumber, isAmountValid],
);

const amountIsOverGas = useMemo(() => {
if (isBuy || !maxSellAmount) {
return false;
}
return Boolean(amountBNMinimalUnit?.gt(maxSellAmount));
}, [amountBNMinimalUnit, isBuy, maxSellAmount]);

const hasInsufficientBalance = useMemo(() => {
if (!balanceBN || !amountBNMinimalUnit) {
return null;
return false;
}
return balanceBN.lt(amountBNMinimalUnit);
}, [balanceBN, amountBNMinimalUnit]);
Expand Down Expand Up @@ -306,10 +329,48 @@ const BuildQuote = () => {
[isSell, selectedAsset?.decimals],
);

const handleQuickAmountPress = useCallback((value) => {
setAmount(`${value}`);
setAmountNumber(value);
}, []);
const handleQuickAmountPress = useCallback(
({ value }: QuickAmount) => {
if (isBuy) {
setAmount(`${value}`);
setAmountNumber(value);
} else {
const percentage = value * 100;
const amountPercentage = balanceBN
?.mul(new BN(percentage))
.div(new BN(100));

if (!amountPercentage) {
return;
}

let amountToSet = amountPercentage;

if (
selectedAsset?.address === NATIVE_ADDRESS &&
maxSellAmount &&
maxSellAmount.lt(amountPercentage)
) {
amountToSet = maxSellAmount;
}

const newAmountString = fromTokenMinimalUnitString(
amountToSet.toString(10),
selectedAsset?.decimals ?? 18,
);
setAmountBNMinimalUnit(amountToSet);
setAmount(newAmountString);
setAmountNumber(Number(newAmountString));
}
},
[
balanceBN,
isBuy,
maxSellAmount,
selectedAsset?.address,
selectedAsset?.decimals,
],
);

const onKeypadLayout = useCallback((event) => {
const { height } = event.nativeEvent.layout;
Expand Down Expand Up @@ -413,7 +474,7 @@ const BuildQuote = () => {
if (selectedAsset && currentFiatCurrency) {
navigation.navigate(
...createQuotesNavDetails({
amount: amountNumber,
amount: isBuy ? amountNumber : amount,
asset: selectedAsset,
fiatCurrency: currentFiatCurrency,
}),
Expand Down Expand Up @@ -443,6 +504,7 @@ const BuildQuote = () => {
}
}, [
screenLocation,
amount,
amountNumber,
currentFiatCurrency,
isBuy,
Expand Down Expand Up @@ -611,6 +673,27 @@ const BuildQuote = () => {
displayAmount = `${amount} ${selectedAsset?.symbol}`;
}

let quickAmounts: QuickAmount[] = [];

if (isBuy) {
quickAmounts =
limits?.quickAmounts?.map((quickAmount) => ({
value: quickAmount,
label: currentFiatCurrency?.denomSymbol + quickAmount.toString(),
})) ?? [];
} else if (balanceBN && !balanceBN.isZero() && maxSellAmount?.gt(new BN(0))) {
quickAmounts = [
{ value: 0.25, label: '25%' },
{ value: 0.5, label: '50%' },
{ value: 0.75, label: '75%' },
{
value: 1,
label: strings('fiat_on_ramp_aggregator.max'),
isNative: selectedAsset?.address === NATIVE_ADDRESS,
},
];
}

return (
<ScreenLayout>
<ScreenLayout.Body>
Expand Down Expand Up @@ -681,11 +764,23 @@ const BuildQuote = () => {
isBuy ? currentFiatCurrency?.denomSymbol : undefined
}
amount={displayAmount}
highlightedError={!amountIsValid}
highlightedError={
amountNumber > 0 && (!amountIsValid || amountIsOverGas)
}
currencyCode={isBuy ? currentFiatCurrency?.symbol : undefined}
onPress={onAmountInputPress}
onCurrencyPress={isBuy ? handleFiatSelectorPress : undefined}
/>
{amountNumber > 0 &&
amountIsValid &&
!hasInsufficientBalance &&
amountIsOverGas && (
<Row>
<Text red small>
{strings('fiat_on_ramp_aggregator.enter_lower_gas_fees')}
</Text>
</Row>
)}
{hasInsufficientBalance && (
<Row>
<Text red small>
Expand Down Expand Up @@ -775,13 +870,9 @@ const BuildQuote = () => {
onLayout={onKeypadLayout}
>
<QuickAmounts
isBuy={isBuy}
onAmountPress={handleQuickAmountPress}
amounts={
limits?.quickAmounts?.map((limit) => ({
value: limit,
label: currentFiatCurrency?.denomSymbol + limit.toString(),
})) || []
}
amounts={quickAmounts}
/>
<Keypad
value={amount}
Expand Down
Loading

0 comments on commit f22e0eb

Please sign in to comment.