diff --git a/src/__swaps__/safe-math/SafeMath.ts b/src/__swaps__/safe-math/SafeMath.ts index af5062e562b..74c62502fdb 100644 --- a/src/__swaps__/safe-math/SafeMath.ts +++ b/src/__swaps__/safe-math/SafeMath.ts @@ -391,7 +391,6 @@ export function floorWorklet(num: string | number): string { export function roundWorklet(num: string | number): string { 'worklet'; const numStr = toStringWorklet(num); - if (!isNumberStringWorklet(numStr)) { throw new Error('Argument must be a numeric string or number'); } diff --git a/src/__swaps__/screens/Swap/__tests__/NiceIncrementerFormatter.test.ts b/src/__swaps__/screens/Swap/__tests__/NiceIncrementerFormatter.test.ts new file mode 100644 index 00000000000..490be57ca41 --- /dev/null +++ b/src/__swaps__/screens/Swap/__tests__/NiceIncrementerFormatter.test.ts @@ -0,0 +1,98 @@ +import { niceIncrementFormatter } from '@/__swaps__/utils/swaps'; +import { SLIDER_WIDTH } from '../constants'; + +type TestCase = { + incrementDecimalPlaces: number; + inputAssetBalance: number | string; + assetBalanceDisplay: string; + inputAssetUsdPrice: number; + niceIncrement: number | string; + percentageToSwap: number; + sliderXPosition: number; + stripSeparators?: boolean; + isStablecoin?: boolean; +} & { + testName: string; + expectedResult: string; +}; + +const TEST_CASES: TestCase[] = [ + { + incrementDecimalPlaces: 0, + inputAssetBalance: 45.47364224817269, + assetBalanceDisplay: '45.47364225', + inputAssetUsdPrice: 0.9995363790000001, + niceIncrement: '1', + percentageToSwap: 0.5, + sliderXPosition: SLIDER_WIDTH / 2, + stripSeparators: true, + isStablecoin: true, + testName: 'DAI Stablecoin', + expectedResult: '22.74', + }, + { + incrementDecimalPlaces: 2, + inputAssetBalance: 100, + assetBalanceDisplay: '100.00', + inputAssetUsdPrice: 10, + niceIncrement: '0.1', + percentageToSwap: 0, + sliderXPosition: 0, + stripSeparators: false, + isStablecoin: false, + testName: 'Zero percent swap', + expectedResult: '0.00', + }, + { + incrementDecimalPlaces: 2, + inputAssetBalance: 100, + assetBalanceDisplay: '100.00', + inputAssetUsdPrice: 10, + niceIncrement: '0.1', + percentageToSwap: 1, + sliderXPosition: SLIDER_WIDTH, + stripSeparators: false, + isStablecoin: false, + testName: 'Full swap', + expectedResult: '100.00', + }, + { + incrementDecimalPlaces: 2, + inputAssetBalance: 123.456, + assetBalanceDisplay: '123.46', + inputAssetUsdPrice: 1, + niceIncrement: '0.05', + percentageToSwap: 0.25, + sliderXPosition: SLIDER_WIDTH / 4, + stripSeparators: true, + isStablecoin: false, + testName: 'Quarter swap with fractional increment', + expectedResult: '30.86', + }, + { + incrementDecimalPlaces: 0, + inputAssetBalance: '1000', + assetBalanceDisplay: '1,000', + inputAssetUsdPrice: 0.5, + niceIncrement: '100', + percentageToSwap: 0.75, + sliderXPosition: (3 * SLIDER_WIDTH) / 4, + stripSeparators: false, + isStablecoin: false, + testName: 'Large increment test', + expectedResult: '750', + }, +]; + +describe('NiceIncrementFormatter', () => { + beforeAll(() => { + jest.mock('react-native-reanimated', () => require('react-native-reanimated/mock')); + }); + + TEST_CASES.forEach(({ testName, expectedResult, ...params }, index) => { + // eslint-disable-next-line jest/valid-title + test(testName || `test-${index}`, () => { + expect(niceIncrementFormatter({ ...params })).toBe(expectedResult); + }); + }); +}); diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx index 811a989805e..9405fd12344 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx @@ -57,7 +57,7 @@ const TokenToSellListComponent = () => { ListEmptyComponent={} keyExtractor={uniqueId => uniqueId} renderItem={({ item: uniqueId }) => { - return handleSelectToken(asset)} output={false} uniqueId={uniqueId} />; + return handleSelectToken(asset)} output={false} uniqueId={uniqueId} />; }} /> diff --git a/src/__swaps__/screens/Swap/constants.ts b/src/__swaps__/screens/Swap/constants.ts index ba39be48e1a..5387d1cf794 100644 --- a/src/__swaps__/screens/Swap/constants.ts +++ b/src/__swaps__/screens/Swap/constants.ts @@ -44,6 +44,9 @@ export const ETH_COLOR_DARK_ACCENT = '#9CA4AD'; export const LONG_PRESS_DELAY_DURATION = 200; export const LONG_PRESS_REPEAT_DURATION = 69; + +export const STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS = 2; +export const MAXIMUM_SIGNIFICANT_DECIMALS = 6; // // /---- END constants ----/ // diff --git a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts index cfb9daead9d..ad6750d1fa9 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts @@ -33,17 +33,21 @@ import { divWorklet, equalWorklet, greaterThanWorklet, mulWorklet, toFixedWorkle function getInitialInputValues(initialSelectedInputAsset: ExtendedAnimatedAssetWithColors | null) { const initialBalance = Number(initialSelectedInputAsset?.balance.amount) || 0; + const assetBalanceDisplay = initialSelectedInputAsset?.balance.display ?? ''; const initialNiceIncrement = findNiceIncrement(initialBalance); const initialDecimalPlaces = countDecimalPlaces(initialNiceIncrement); + const isStablecoin = initialSelectedInputAsset?.type === 'stablecoin'; const initialInputAmount = niceIncrementFormatter({ incrementDecimalPlaces: initialDecimalPlaces, inputAssetBalance: initialBalance, + assetBalanceDisplay, inputAssetUsdPrice: initialSelectedInputAsset?.price?.value ?? 0, niceIncrement: initialNiceIncrement, percentageToSwap: 0.5, sliderXPosition: SLIDER_WIDTH / 2, stripSeparators: true, + isStablecoin, }); const initialInputNativeValue = addCommasToNumber( @@ -119,10 +123,13 @@ export function useSwapInputsController({ return addCommasToNumber(inputValues.value.inputAmount, '0'); } + const assetBalanceDisplay = internalSelectedInputAsset.value.balance.display ?? ''; + if (inputMethod.value === 'outputAmount') { return valueBasedDecimalFormatter({ amount: inputValues.value.inputAmount, usdTokenPrice: inputNativePrice.value, + assetBalanceDisplay, roundingMode: 'up', precisionAdjustment: -1, isStablecoin: internalSelectedInputAsset.value?.type === 'stablecoin' ?? false, @@ -131,14 +138,17 @@ export function useSwapInputsController({ } const balance = internalSelectedInputAsset.value?.balance.amount || 0; + const isStablecoin = internalSelectedInputAsset.value?.type === 'stablecoin' ?? false; return niceIncrementFormatter({ incrementDecimalPlaces: incrementDecimalPlaces.value, inputAssetBalance: balance, + assetBalanceDisplay, inputAssetUsdPrice: inputNativePrice.value, niceIncrement: niceIncrement.value, percentageToSwap: percentageToSwap.value, sliderXPosition: sliderXPosition.value, + isStablecoin, }); }); @@ -166,9 +176,13 @@ export function useSwapInputsController({ if (inputMethod.value === 'outputAmount' || typeof inputValues.value.outputAmount === 'string') { return addCommasToNumber(inputValues.value.outputAmount, '0'); } + + const assetBalanceDisplay = internalSelectedOutputAsset.value.balance.display ?? ''; + return valueBasedDecimalFormatter({ amount: inputValues.value.outputAmount, usdTokenPrice: outputNativePrice.value, + assetBalanceDisplay, roundingMode: 'down', precisionAdjustment: -1, isStablecoin: internalSelectedOutputAsset.value?.type === 'stablecoin' ?? false, @@ -674,6 +688,7 @@ export function useSwapInputsController({ if (!internalSelectedInputAsset.value) return; const balance = Number(internalSelectedInputAsset.value.balance.amount); + if (!balance) { inputValues.modify(values => { return { @@ -687,14 +702,18 @@ export function useSwapInputsController({ return; } + const assetBalanceDisplay = internalSelectedInputAsset.value.balance.display; + const inputAmount = niceIncrementFormatter({ incrementDecimalPlaces: incrementDecimalPlaces.value, inputAssetBalance: balance, + assetBalanceDisplay, inputAssetUsdPrice: inputNativePrice.value, niceIncrement: niceIncrement.value, percentageToSwap: percentageToSwap.value, sliderXPosition: sliderXPosition.value, stripSeparators: true, + isStablecoin: internalSelectedInputAsset.value?.type === 'stablecoin' ?? false, }); const inputNativeValue = mulWorklet(inputAmount, inputNativePrice.value); inputValues.modify(values => { @@ -844,14 +863,18 @@ export function useSwapInputsController({ return; } + const assetBalanceDisplay = internalSelectedInputAsset.value?.balance.display ?? ''; + const inputAmount = niceIncrementFormatter({ incrementDecimalPlaces: incrementDecimalPlaces.value, inputAssetBalance: balance, + assetBalanceDisplay, inputAssetUsdPrice: inputNativePrice.value, niceIncrement: niceIncrement.value, percentageToSwap: percentageToSwap.value, sliderXPosition: sliderXPosition.value, stripSeparators: true, + isStablecoin: internalSelectedInputAsset.value?.type === 'stablecoin' ?? false, }); const inputNativeValue = mulWorklet(inputAmount, inputNativePrice.value); @@ -869,11 +892,14 @@ export function useSwapInputsController({ const inputNativePrice = internalSelectedInputAsset.value?.nativePrice || internalSelectedInputAsset.value?.price?.value || 0; const outputNativePrice = internalSelectedOutputAsset.value?.nativePrice || internalSelectedOutputAsset.value?.price?.value || 0; + const assetBalanceDisplay = current.assetToSell?.balance.display ?? ''; + const inputAmount = Number( valueBasedDecimalFormatter({ amount: inputNativePrice > 0 ? divWorklet(inputValues.value.inputNativeValue, inputNativePrice) : inputValues.value.outputAmount, usdTokenPrice: inputNativePrice, + assetBalanceDisplay, roundingMode: 'up', precisionAdjustment: -1, isStablecoin: current.assetToSell?.type === 'stablecoin' ?? false, diff --git a/src/__swaps__/utils/swaps.ts b/src/__swaps__/utils/swaps.ts index 501c15fd7a1..3186a369bb5 100644 --- a/src/__swaps__/utils/swaps.ts +++ b/src/__swaps__/utils/swaps.ts @@ -3,7 +3,14 @@ import { SharedValue, convertToRGBA, isColor } from 'react-native-reanimated'; import * as i18n from '@/languages'; import { globalColors } from '@/design-system'; -import { ETH_COLOR, ETH_COLOR_DARK, SCRUBBER_WIDTH, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; +import { + ETH_COLOR, + ETH_COLOR_DARK, + MAXIMUM_SIGNIFICANT_DECIMALS, + SCRUBBER_WIDTH, + SLIDER_WIDTH, + STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS, +} from '@/__swaps__/screens/Swap/constants'; import { chainNameFromChainId, chainNameFromChainIdWorklet } from '@/__swaps__/utils/chains'; import { ChainId, ChainName } from '@/__swaps__/types/chains'; import { RainbowConfig } from '@/model/remoteConfig'; @@ -221,41 +228,54 @@ export function trimTrailingZeros(value: string) { return withTrimmedZeros.endsWith('.') ? withTrimmedZeros.slice(0, -1) : withTrimmedZeros; } +export function precisionBasedOffMagnitude(amount: number | string, isStablecoin = false): number { + 'worklet'; + + const magnitude = -Number(floorWorklet(sumWorklet(log10Worklet(amount), 0))); + // don't let stablecoins go beneath 2nd order + if (magnitude < -2 && isStablecoin) { + return -STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS; + } + return magnitude; +} + export function valueBasedDecimalFormatter({ amount, usdTokenPrice, + assetBalanceDisplay, roundingMode, precisionAdjustment, isStablecoin, stripSeparators = true, + isMaxAmount = false, }: { amount: number | string; usdTokenPrice: number; + assetBalanceDisplay?: string; roundingMode?: 'up' | 'down'; precisionAdjustment?: number; isStablecoin?: boolean; stripSeparators?: boolean; + isMaxAmount?: boolean; }): string { 'worklet'; - function precisionBasedOffMagnitude(amount: number | string): number { - const magnitude = -Number(floorWorklet(sumWorklet(log10Worklet(amount), 1))); - return (precisionAdjustment ?? 0) + magnitude; - } - function calculateDecimalPlaces(usdTokenPrice: number): number { - const fallbackDecimalPlaces = 2; + const fallbackDecimalPlaces = STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS; if (usdTokenPrice <= 0) { return fallbackDecimalPlaces; } const unitsForOneCent = 0.01 / usdTokenPrice; if (unitsForOneCent >= 1) { - return 0; + return isStablecoin ? STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS : 0; } - return Math.max(Math.ceil(Math.log10(1 / unitsForOneCent)) + precisionBasedOffMagnitude(amount), 0); + return Math.max( + Math.ceil(Math.log10(1 / unitsForOneCent)) + (precisionAdjustment ?? 0), + isStablecoin ? STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS : 0 + ); } - const decimalPlaces = isStablecoin ? 2 : calculateDecimalPlaces(usdTokenPrice); + const decimalPlaces = calculateDecimalPlaces(usdTokenPrice); let roundedAmount; const factor = Math.pow(10, decimalPlaces); @@ -270,10 +290,35 @@ export function valueBasedDecimalFormatter({ roundedAmount = divWorklet(roundWorklet(mulWorklet(amount, factor)), factor); } + const maximumFractionDigits = () => { + // if we're selling max amount, we want to match what's displayed on the balance badge + // let's base the decimal places based on that (capped at 6) + if (isMaxAmount && assetBalanceDisplay) { + const decimals = assetBalanceDisplay.split('.'); + if (decimals.length > 1) { + const [, decimalPlacesFromDisplay] = decimals; + if (decimalPlacesFromDisplay.length < STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS && isStablecoin) { + return STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS; + } + + return Math.min(decimalPlacesFromDisplay.length, MAXIMUM_SIGNIFICANT_DECIMALS); + } + } + + if (!isNaN(decimalPlaces)) { + return isStablecoin && decimalPlaces < STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS + ? STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS + : decimalPlaces; + } + + // default to 6 precision if we have no calculation + return MAXIMUM_SIGNIFICANT_DECIMALS; + }; + // Format the number to add separators and trim trailing zeros const numberFormatter = new Intl.NumberFormat('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: !isNaN(decimalPlaces) ? decimalPlaces : 2, // Allow up to the required precision + minimumFractionDigits: isStablecoin ? STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS : 0, + maximumFractionDigits: maximumFractionDigits(), useGrouping: true, }); @@ -285,50 +330,71 @@ export function valueBasedDecimalFormatter({ export function niceIncrementFormatter({ incrementDecimalPlaces, inputAssetBalance, + assetBalanceDisplay, inputAssetUsdPrice, niceIncrement, percentageToSwap, sliderXPosition, stripSeparators, + isStablecoin = false, }: { incrementDecimalPlaces: number; inputAssetBalance: number | string; + assetBalanceDisplay: string; inputAssetUsdPrice: number; niceIncrement: number | string; percentageToSwap: number; sliderXPosition: number; stripSeparators?: boolean; + isStablecoin?: boolean; }) { 'worklet'; + if (percentageToSwap === 0) return '0'; - if (percentageToSwap === 0.25) + if (percentageToSwap === 0.25) { + const amount = mulWorklet(inputAssetBalance, 0.25); return valueBasedDecimalFormatter({ - amount: mulWorklet(inputAssetBalance, 0.25), + amount, usdTokenPrice: inputAssetUsdPrice, + assetBalanceDisplay, roundingMode: 'up', - precisionAdjustment: -3, + precisionAdjustment: precisionBasedOffMagnitude(amount, isStablecoin), + isStablecoin, }); - if (percentageToSwap === 0.5) + } + if (percentageToSwap === 0.5) { + const amount = mulWorklet(inputAssetBalance, 0.5); return valueBasedDecimalFormatter({ - amount: mulWorklet(inputAssetBalance, 0.5), + amount, usdTokenPrice: inputAssetUsdPrice, + assetBalanceDisplay, roundingMode: 'up', - precisionAdjustment: -3, + precisionAdjustment: precisionBasedOffMagnitude(amount, isStablecoin), + isStablecoin, }); - if (percentageToSwap === 0.75) + } + if (percentageToSwap === 0.75) { + const amount = mulWorklet(inputAssetBalance, 0.75); return valueBasedDecimalFormatter({ - amount: mulWorklet(inputAssetBalance, 0.75), + amount, usdTokenPrice: inputAssetUsdPrice, + assetBalanceDisplay, roundingMode: 'up', - precisionAdjustment: -3, + precisionAdjustment: precisionBasedOffMagnitude(amount, isStablecoin), + isStablecoin, }); - if (percentageToSwap === 1) + } + if (percentageToSwap === 1) { return valueBasedDecimalFormatter({ amount: inputAssetBalance, usdTokenPrice: inputAssetUsdPrice, - roundingMode: 'up', + assetBalanceDisplay, + isStablecoin, + isMaxAmount: true, }); + } + const decimals = isStablecoin ? STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS : incrementDecimalPlaces; const exactIncrement = divWorklet(inputAssetBalance, 100); const isIncrementExact = equalWorklet(niceIncrement, exactIncrement); const numberOfIncrements = divWorklet(inputAssetBalance, niceIncrement); @@ -344,12 +410,12 @@ export function niceIncrementFormatter({ const rawAmount = mulWorklet(roundWorklet(divWorklet(mulWorklet(percentage, inputAssetBalance), niceIncrement)), niceIncrement); - const amountToFixedDecimals = toFixedWorklet(rawAmount, incrementDecimalPlaces); + const amountToFixedDecimals = toFixedWorklet(rawAmount, decimals); const formattedAmount = `${Number(amountToFixedDecimals).toLocaleString('en-US', { useGrouping: true, - minimumFractionDigits: 0, - maximumFractionDigits: 8, + minimumFractionDigits: isStablecoin ? STABLECOIN_MINIMUM_SIGNIFICANT_DECIMALS : 0, + maximumFractionDigits: MAXIMUM_SIGNIFICANT_DECIMALS, })}`; if (stripSeparators) return stripCommas(formattedAmount); diff --git a/src/hooks/useSwapDerivedOutputs.ts b/src/hooks/useSwapDerivedOutputs.ts index f175946fd4c..84d6abb7ee4 100644 --- a/src/hooks/useSwapDerivedOutputs.ts +++ b/src/hooks/useSwapDerivedOutputs.ts @@ -111,9 +111,7 @@ const getInputAmount = async ( // Do not deleeeet the comment below 😤 // @ts-ignore About to get quote - console.log(JSON.stringify(quoteParams, null, 2)); const quote = await getQuote(quoteParams); - console.log(JSON.stringify(quote, null, 2)); // if no quote, if quote is error or there's no sell amount if (!quote || (quote as QuoteError).error || !(quote as Quote).sellAmount) { @@ -204,8 +202,6 @@ const getOutputAmount = async ( refuel, }; - console.log(JSON.stringify(quoteParams, null, 2)); - const rand = Math.floor(Math.random() * 100); logger.debug('[getOutputAmount]: Getting quote', { rand, quoteParams }); // Do not deleeeet the comment below 😤 @@ -213,8 +209,6 @@ const getOutputAmount = async ( const quote: Quote | CrosschainQuote | QuoteError | null = await (isCrosschainSwap ? getCrosschainQuote : getQuote)(quoteParams); logger.debug('[getOutputAmount]: Got quote', { rand, quote }); - console.log(JSON.stringify(quote, null, 2)); - if (!quote || (quote as QuoteError)?.error || !(quote as Quote)?.buyAmount) { const quoteError = quote as QuoteError; if (quoteError.error) { diff --git a/src/raps/actions/swap.ts b/src/raps/actions/swap.ts index 50d65999d43..bd1c3af9ef0 100644 --- a/src/raps/actions/swap.ts +++ b/src/raps/actions/swap.ts @@ -374,8 +374,6 @@ export const swap = async ({ swapMetadataStorage.set(swap.hash.toLowerCase(), JSON.stringify({ type: 'swap', data: parameters.meta })); } - console.log(JSON.stringify(transaction, null, 2)); - addNewTransaction({ address: parameters.quote.from as Address, // chainId: parameters.chainId as ChainId,