diff --git a/package.json b/package.json index b348e8c54..2b3be0499 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "react-dom": "v18", "react-feather": "^2.0.9", "react-hotkeys-hook": "^5.1.0", + "react-number-format": "^5.4.4", "react-pro-sidebar": "^1.1.0", "react-router-dom": "v5", "react-tooltip": "^5.18.0", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 517143af7..016bd9721 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -18,4 +18,3 @@ export * from "./useQueue"; export * from "./useAmplitude"; export * from "./useRewardSummary"; export * from "./feature-flags/useFeatureFlag"; -export * from "./useTokenInput"; diff --git a/src/hooks/useTokenInput.ts b/src/hooks/useTokenInput.ts deleted file mode 100644 index 75d935550..000000000 --- a/src/hooks/useTokenInput.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { BigNumber, utils } from "ethers"; -import { convertTokenToUSD, convertUSDToToken } from "utils"; -import { EnrichedToken } from "views/SwapAndBridge/components/ChainTokenSelector/ChainTokenSelectorModal"; -import { formatUnitsWithMaxFractions } from "utils"; - -export type UnitType = "usd" | "token"; - -type UseTokenInputProps = { - token: EnrichedToken | null; - setAmount: (amount: BigNumber | null) => void; - expectedAmount: BigNumber | undefined; - shouldUpdate: boolean; - isUpdateLoading: boolean; - // Optional: Allow unit state to be controlled from parent - unit?: UnitType; - setUnit?: (unit: UnitType) => void; -}; - -type UseTokenInputReturn = { - amountString: string; - setAmountString: (value: string) => void; - unit: UnitType; - convertedAmount: BigNumber | undefined; - toggleUnit: () => void; - handleInputChange: (value: string) => void; - handleBalanceClick: (amount: BigNumber, decimals: number) => void; -}; - -export function useTokenInput({ - token, - setAmount, - expectedAmount, - shouldUpdate, - isUpdateLoading, - unit: externalUnit, - setUnit: externalSetUnit, -}: UseTokenInputProps): UseTokenInputReturn { - const [amountString, setAmountString] = useState(""); - const [internalUnit, setInternalUnit] = useState("token"); - const [convertedAmount, setConvertedAmount] = useState(); - const [justTyped, setJustTyped] = useState(false); - - // Use external unit if provided, otherwise use internal state - const unit = externalUnit ?? internalUnit; - const setUnit = externalSetUnit ?? setInternalUnit; - - // Handle user input changes - propagate to parent - useEffect(() => { - if (!justTyped) { - return; - } - setJustTyped(false); - try { - if (!token) { - setAmount(null); - return; - } - // If the input is empty or effectively zero, set amount to null - if (!amountString || !Number(amountString)) { - setAmount(null); - return; - } - if (unit === "token") { - const parsed = utils.parseUnits(amountString, token.decimals); - // If parsed amount is zero or negative, set to null - if (parsed.lte(0)) { - setAmount(null); - return; - } - setAmount(parsed); - } else { - const tokenValue = convertUSDToToken(amountString, token); - // If converted value is zero or negative, set to null - if (tokenValue.lte(0)) { - setAmount(null); - return; - } - setAmount(tokenValue); - } - } catch (e) { - setAmount(null); - } - }, [amountString, justTyped, token, unit, setAmount]); - - // Reset amount when token changes - useEffect(() => { - if (token) { - setAmountString(""); - setConvertedAmount(undefined); - setAmount(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token?.chainId, token?.symbol]); - - // Handle quote updates - only update the field that should receive the quote - useEffect(() => { - if (shouldUpdate && isUpdateLoading) { - setAmountString(""); - } - - if (shouldUpdate && token) { - // Clear the field when there's no expected amount and not loading - if (!expectedAmount && !isUpdateLoading) { - setAmountString(""); - } else { - if (expectedAmount) { - if (unit === "token") { - // Display as token amount - setAmountString( - formatUnitsWithMaxFractions(expectedAmount, token.decimals) - ); - } else { - // Display as USD amount - convert token to USD - const tokenAmountFormatted = formatUnitsWithMaxFractions( - expectedAmount, - token.decimals - ); - const usdValue = convertTokenToUSD(tokenAmountFormatted, token); - // convertTokenToUSD returns in 18 decimal precision - setAmountString(utils.formatUnits(usdValue, 18)); - } - } - } - } - }, [expectedAmount, isUpdateLoading, shouldUpdate, token, unit]); - - // Set converted value for display - useEffect(() => { - if (!token || !amountString) { - setConvertedAmount(undefined); - return; - } - try { - if (unit === "token") { - // User typed token amount - convert to USD for display - const usdValue = convertTokenToUSD(amountString, token); - setConvertedAmount(usdValue); - } else { - // User typed USD amount - convert to token for display - const tokenValue = convertUSDToToken(amountString, token); - setConvertedAmount(tokenValue); - } - } catch (e) { - // getting an underflow error here - setConvertedAmount(undefined); - } - }, [token, amountString, unit]); - - // Toggle between token and USD units - const toggleUnit = useCallback(() => { - if (unit === "token") { - // Convert token amount to USD string for display - if (amountString && token && convertedAmount) { - try { - // convertedAmount is USD value in 18 decimals - const a = utils.formatUnits(convertedAmount, 18); - setAmountString(a); - } catch (e) { - setAmountString("0"); - } - } - setUnit("usd"); - } else { - // Convert USD amount to token string for display - if (amountString && token && convertedAmount) { - try { - // convertedAmount is token value in token's native decimals - const a = utils.formatUnits(convertedAmount, token.decimals); - setAmountString(a); - } catch (e) { - setAmountString("0"); - } - } - setUnit("token"); - } - }, [unit, amountString, token, convertedAmount, setUnit]); - - // Handle input field changes - const handleInputChange = useCallback((value: string) => { - if (value === "" || /^\d*\.?\d*$/.test(value)) { - setJustTyped(true); - setAmountString(value); - } - }, []); - - // Handle balance selector click - const handleBalanceClick = useCallback( - (amount: BigNumber, decimals: number) => { - setAmount(amount); - if (unit === "usd" && token) { - // Convert token amount to USD for display - const tokenAmountFormatted = formatUnitsWithMaxFractions( - amount, - decimals - ); - const usdValue = convertTokenToUSD(tokenAmountFormatted, token); - // convertTokenToUSD returns in 18 decimal precision - setAmountString(utils.formatUnits(usdValue, 18)); - } else { - // Display as token amount - setAmountString(formatUnitsWithMaxFractions(amount, decimals)); - } - }, - [setAmount, unit, token] - ); - - return { - amountString, - setAmountString, - unit, - convertedAmount, - toggleUnit, - handleInputChange, - handleBalanceClick, - }; -} diff --git a/src/utils/format.ts b/src/utils/format.ts index 95362e5d3..9b6b7856a 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -50,35 +50,6 @@ export function shortenString( )}`; } -/** - * Shortens an arbitrary string to a fixed number of characters, mindful of the character length of the delimiter - * @param str The string to be potentially shortened - * @param delimiter The delimiter - * @param maxChars The number of characters to constrain this string - * @returns `str` if string is less than maxChars. The first `maxChars` chars if the delimiter is too large. A collapsed version with the delimiter in the middle. - */ -export function shortenStringToLength( - str: string, - delimiter: string, - maxChars: number -) { - if (str.length <= maxChars) { - return str; - } else { - const charsNeeded = maxChars - delimiter.length; - // Delimiter is out of bounds - if (charsNeeded <= 0) { - return str.slice(0, maxChars); - } else { - const charDivision = charsNeeded / 2; - const left = str.slice(0, Math.ceil(charDivision)); - const right = - charDivision < 1 ? "" : str.slice(-Math.floor(charDivision)); - return `${left}${delimiter}${right}`; - } - } -} - export function shortenTransactionHash(hash: string): string { return `${hash.substring(0, 5)}...`; } @@ -128,21 +99,10 @@ export function formatUnitsWithMaxFractionsFnBuilder(decimals: number) { return closure; } -export function formatEtherRaw(wei: ethers.BigNumberish): string { - return ethers.utils.formatUnits(wei, 18); -} - export function parseUnits(value: string, decimals: number): ethers.BigNumber { return ethers.utils.parseUnits(value, decimals); } -export function parseUnitsFnBuilder(decimals: number) { - function closure(value: string) { - return parseUnits(value, decimals); - } - return closure; -} - export function parseEtherLike(value: string): ethers.BigNumber { return parseUnits(value, 18); } @@ -182,10 +142,6 @@ export function formattedBigNumberToNumber( } } -export function stringToHex(value: string) { - return ethers.utils.hexlify(ethers.utils.toUtf8Bytes(value)); -} - // appends hex tag to data export function tagHex( dataHex: string, @@ -196,11 +152,6 @@ export function tagHex( return ethers.utils.hexConcat([dataHex, delimitterHex, tagHex]); } -// converts a string tag to hex and appends, currently not in use -export function tagString(dataHex: string, tagString: string) { - return tagHex(dataHex, stringToHex(tagString)); -} - // tags only an address export function tagAddress( dataHex: string, @@ -227,13 +178,6 @@ export function convertToCapitalCase(str: string) { .join(" "); } -const twoSigFormatter = new Intl.NumberFormat("en-US", { - maximumSignificantDigits: 2, -}); - -export const formatNumberTwoSigDigits = - twoSigFormatter.format.bind(twoSigFormatter); - const threeMaxFracFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 3, }); @@ -242,13 +186,6 @@ export const formatNumberMaxFracDigits = threeMaxFracFormatter.format.bind( threeMaxFracFormatter ); -const twoMaxFracFormatter = new Intl.NumberFormat("en-US", { - maximumFractionDigits: 2, -}); - -export const formatNumberTwoFracDigits = - twoMaxFracFormatter.format.bind(twoMaxFracFormatter); - export function formatMaxFracDigits(number: number, maxFracDigits: number) { const formatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: maxFracDigits, @@ -256,15 +193,6 @@ export function formatMaxFracDigits(number: number, maxFracDigits: number) { return formatter.format(number); } -export function formatPoolAPY( - wei: ethers.BigNumberish, - decimals: number -): string { - return formatNumberMaxFracDigits( - Number(ethers.utils.formatUnits(wei, decimals)) - ); -} - export function formatWeiPct(wei?: ethers.BigNumberish, precision: number = 3) { if (wei === undefined) { return undefined; @@ -275,20 +203,6 @@ export function formatWeiPct(wei?: ethers.BigNumberish, precision: number = 3) { }).format(Number(ethers.utils.formatEther(wei)) * 100); } -/** - * Formats a number into a human readable format - * @param num The number to format - * @returns A human readable format. I.e. 1000 -> 1K, 1001 -> 1K+ - */ -export function humanReadableNumber(num: number, decimals = 0): string { - if (num <= 0) return "0"; - return ( - numeral(num) - .format(decimals <= 0 ? "0a" : `0.${"0".repeat(decimals)}a`) - .toUpperCase() + "+" - ); -} - /** * Formats an 18 decimal WEI representation of USD into standard USD format * @param value A 18 decimal fixed-point integer representation of USD @@ -343,3 +257,25 @@ export function parseUnitsWithExtendedDecimals( } return ethers.utils.parseUnits(valueToParse, decimals); } + +export function formatNumberWithSeparators( + value: string, + maxDecimals: number = 18 +): string { + if (!value || value === ".") return value; + + const parts = value.split("."); + const integerPart = parts[0] || "0"; + const decimalPart = parts[1]; + + const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + + if (parts.length === 2) { + const limitedDecimals = decimalPart.substring(0, maxDecimals); + return `${formattedInteger}.${limitedDecimals}`; + } else if (value.endsWith(".")) { + return `${formattedInteger}.`; + } + + return formattedInteger; +} diff --git a/src/utils/tests/format.test.ts b/src/utils/tests/format.test.ts index d9467348c..168862fbc 100644 --- a/src/utils/tests/format.test.ts +++ b/src/utils/tests/format.test.ts @@ -1,13 +1,15 @@ import { utils } from "ethers"; import { + formatNumberWithSeparators, + formatUSD, isValidString, shortenAddress, shortenString, shortenTransactionHash, smallNumberFormatter, - formatUSD, } from "../format"; + const VALID_ADDRESS = "0x04Fa0d235C4abf4BcF4787aF4CF447DE572eF828"; const TX_HASH = "0xe5a0c976ca4d09ce6ea034101e78b1a6a9536940cb8f246e7b54d1fe16b8c125"; @@ -72,3 +74,31 @@ describe("#formatUSD", () => { expect(formatUSD(utils.parseEther("100000.123456"))).toEqual("100,000.12"); }); }); + +describe("#formatNumberWithSeparators", () => { + it("adds thousand separators to integers", () => { + expect(formatNumberWithSeparators("1234567")).toEqual("1,234,567"); + }); + + it("preserves decimals while adding separators", () => { + expect(formatNumberWithSeparators("1234567.89")).toEqual("1,234,567.89"); + }); + + it("handles numbers under 1000", () => { + expect(formatNumberWithSeparators("999")).toEqual("999"); + expect(formatNumberWithSeparators("42.5")).toEqual("42.5"); + }); + + it("preserves trailing decimal point during input", () => { + expect(formatNumberWithSeparators("1234.")).toEqual("1,234."); + }); + + it("truncates decimals to maxDecimals", () => { + expect(formatNumberWithSeparators("1.123456789", 4)).toEqual("1.1234"); + }); + + it("returns empty/special values unchanged", () => { + expect(formatNumberWithSeparators("")).toEqual(""); + expect(formatNumberWithSeparators(".")).toEqual("."); + }); +}); diff --git a/src/utils/token.ts b/src/utils/token.ts index e9cc77872..f27edecdc 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -8,11 +8,13 @@ import { getChainInfo, parseUnits, } from "utils"; +import { formatUnitsWithMaxFractions } from "utils/format"; import { ERC20__factory } from "utils/typechain"; import { SwapToken } from "utils/serverless-api/types"; import { TokenInfo } from "constants/tokens"; import { chainsWithUsdt0Enabled, getToken, tokenTable } from "utils/constants"; import usdt0Logo from "assets/token-logos/usdt0.svg"; +import { UnitType } from "views/SwapAndBridge/components/TokenInput/OriginTokenInput"; export async function getNativeBalance( chainId: ChainId, @@ -231,3 +233,39 @@ export function getIntermediaryTokenInfo(tokenInfo: { }): { symbol: string; chainId: number } | undefined { return INTERMEDIARY_TOKEN_MAPPING?.[tokenInfo.chainId]?.[tokenInfo.symbol]; } + +export function formatAmountForDisplay( + amount: BigNumber, + token: LifiToken, + unit: UnitType +): string { + if (unit === "token") { + return formatUnitsWithMaxFractions(amount, token.decimals); + } else { + const tokenFormatted = formatUnitsWithMaxFractions(amount, token.decimals); + const usdValue = convertTokenToUSD(tokenFormatted, token); + return ethers.utils.formatUnits(usdValue, 18); + } +} + +export function parseInputValue( + input: string, + token: LifiToken, + unit: UnitType +): BigNumber | null { + if (!input || !Number(input)) { + return null; + } + + if (unit === "token") { + const parsed = ethers.utils.parseUnits(input, token.decimals); + return parsed.lte(0) ? null : parsed; + } else { + const tokenValue = convertUSDToToken(input, token); + return tokenValue.lte(0) ? null : tokenValue; + } +} + +export function isValidNumberInput(value: string): boolean { + return value === "" || /^\d*\.?\d*$/.test(value); +} diff --git a/src/views/SwapAndBridge/SwapAndBridge.tsx b/src/views/SwapAndBridge/SwapAndBridge.tsx index 856932a03..e802f2a13 100644 --- a/src/views/SwapAndBridge/SwapAndBridge.tsx +++ b/src/views/SwapAndBridge/SwapAndBridge.tsx @@ -6,31 +6,25 @@ import { useQuoteRequestContext, } from "./hooks/useQuoteRequest/QuoteRequestContext"; import { ConfirmationButton } from "./components/Confirmation/ConfirmationButton"; -import { useMemo } from "react"; +import { useEffect } from "react"; import useSwapQuote from "./hooks/useSwapQuote"; import { useDefaultRoute } from "./hooks/useDefaultRoute"; function SwapAndBridgeContent() { - const { quoteRequest, setOriginToken, setDestinationToken } = + const { quoteRequest, setOriginToken, setDestinationToken, setQuoteOutput } = useQuoteRequestContext(); + useDefaultRoute(setOriginToken, setDestinationToken); const { swapQuote, quoteError, isQuoteLoading } = useSwapQuote(quoteRequest); - const expectedInputAmount = useMemo(() => { - return swapQuote?.inputAmount; - }, [swapQuote]); - const expectedOutputAmount = useMemo(() => { - return swapQuote?.expectedOutputAmount; - }, [swapQuote]); + useEffect(() => { + setQuoteOutput(swapQuote?.expectedOutputAmount ?? null); + }, [swapQuote, setQuoteOutput]); return ( - + = ({ const onConfirm = useOnConfirm(quoteRequest, approvalAction); const validation = useValidateSwapAndBridge( - quoteRequest.amount, - quoteRequest.tradeType === "exactInput", + quoteRequest.userInputAmount, + quoteRequest.userInputField === "origin", quoteRequest.originToken, quoteRequest.destinationToken, !!depositor, diff --git a/src/views/SwapAndBridge/components/InputForm.tsx b/src/views/SwapAndBridge/components/InputForm.tsx index 683232c5c..c55595e3c 100644 --- a/src/views/SwapAndBridge/components/InputForm.tsx +++ b/src/views/SwapAndBridge/components/InputForm.tsx @@ -2,28 +2,17 @@ import { COLORS } from "utils"; import styled from "@emotion/styled"; import { useState } from "react"; import { ReactComponent as ArrowDown } from "assets/icons/arrow-down.svg"; -import { UnitType } from "hooks"; import { useQuoteRequestContext } from "../hooks/useQuoteRequest/QuoteRequestContext"; -import { BigNumber } from "ethers"; -import { OriginTokenInput } from "./TokenInput/OriginTokenInput"; -import { DestinationTokenDisplay } from "./TokenInput/DestinationTokenDisplay"; +import { OriginTokenInput, UnitType } from "./TokenInput/OriginTokenInput"; +import { DestinationTokenInput } from "./TokenInput/DestinationTokenInput"; -export const InputForm = ({ - isQuoteLoading, - expectedOutputAmount, - expectedInputAmount, -}: { - isQuoteLoading: boolean; - expectedOutputAmount: BigNumber | undefined; - expectedInputAmount: BigNumber | undefined; -}) => { +export const InputForm = ({ isQuoteLoading }: { isQuoteLoading: boolean }) => { const { quickSwap } = useQuoteRequestContext(); const [unit, setUnit] = useState("token"); return ( - void; }; -export const DestinationTokenDisplay = ({ - expectedOutputAmount, +export const DestinationTokenInput = ({ isUpdateLoading, unit, setUnit, }: DestinationTokenDisplayProps) => { - const { - quoteRequest, - setDestinationAmount, - setOriginToken, - setDestinationToken, - } = useQuoteRequestContext(); - - const shouldUpdate = quoteRequest.tradeType === "exactInput"; + const { quoteRequest, setUserInput, setOriginToken, setDestinationToken } = + useQuoteRequestContext(); const { destinationToken, originToken } = quoteRequest; const { - amountString, - convertedAmount, - toggleUnit, + displayValue, + formattedConvertedAmount, + inputDisabled, handleInputChange, handleBalanceClick, - } = useTokenInput({ + toggleUnit, + } = useTokenAmountInput({ token: destinationToken, - setAmount: setDestinationAmount, - expectedAmount: expectedOutputAmount, - shouldUpdate, - isUpdateLoading, + fieldName: "destination", unit, setUnit, + isUpdateLoading, + setUserInput, + quoteRequest, }); - const inputDisabled = (() => { - if (!quoteRequest.destinationToken) return true; - return Boolean(shouldUpdate && isUpdateLoading); - })(); - - const formattedConvertedAmount = (() => { - if (unit === "token") { - if (!convertedAmount) return "$0.00"; - return "$" + formatUSD(convertedAmount); - } - if (!convertedAmount) return "0.00"; - return `${formatUnits(convertedAmount, destinationToken?.decimals)} ${destinationToken?.symbol}`; - })(); - return ( @@ -82,14 +60,14 @@ export const DestinationTokenDisplay = ({ handleInputChange(e.target.value)} disabled={inputDisabled} error={false} @@ -119,7 +97,7 @@ export const DestinationTokenDisplay = ({ error={false} setAmount={(amount) => { if (amount) { - handleBalanceClick(amount, destinationToken.decimals); + handleBalanceClick(amount); } }} /> diff --git a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx index ba91c228e..739d5c34d 100644 --- a/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx +++ b/src/views/SwapAndBridge/components/TokenInput/OriginTokenInput.tsx @@ -1,8 +1,5 @@ import { useEffect, useRef } from "react"; -import { formatUnits } from "ethers/lib/utils"; import { ReactComponent as ArrowsCross } from "assets/icons/arrows-cross.svg"; -import { formatUSD } from "utils"; -import { UnitType, useTokenInput } from "hooks"; import SelectorButton from "../ChainTokenSelector/SelectorButton"; import { BalanceSelector } from "../BalanceSelector"; import { @@ -16,60 +13,50 @@ import { UnitToggleButtonWrapper, } from "./styles"; import { useQuoteRequestContext } from "../../hooks/useQuoteRequest/QuoteRequestContext"; -import { BigNumber } from "ethers"; import { hasInsufficientBalance } from "../../utils/balance"; import { useTokenBalance } from "views/SwapAndBridge/hooks/useTokenBalance"; +import { useTokenAmountInput } from "../../hooks"; + +export type UnitType = "usd" | "token"; type OriginTokenInputProps = { - expectedAmount: BigNumber | undefined; isUpdateLoading: boolean; unit: UnitType; setUnit: (unit: UnitType) => void; }; export const OriginTokenInput = ({ - expectedAmount, isUpdateLoading, unit, setUnit, }: OriginTokenInputProps) => { - const { quoteRequest, setOriginAmount, setOriginToken, setDestinationToken } = + const { quoteRequest, setUserInput, setOriginToken, setDestinationToken } = useQuoteRequestContext(); const amountInputRef = useRef(null); const hasAutoFocusedRef = useRef(false); const { originToken, destinationToken } = quoteRequest; - const shouldUpdate = quoteRequest.tradeType === "minOutput"; - const { - amountString, - convertedAmount, - toggleUnit, + displayValue, + formattedConvertedAmount, + inputDisabled, handleInputChange, handleBalanceClick, - } = useTokenInput({ + toggleUnit, + } = useTokenAmountInput({ token: originToken, - setAmount: setOriginAmount, - expectedAmount, - shouldUpdate, - isUpdateLoading, + fieldName: "origin", unit, setUnit, + isUpdateLoading, + setUserInput, + quoteRequest, }); - const inputDisabled = (() => { - if (!quoteRequest.destinationToken) return true; - return Boolean(shouldUpdate && isUpdateLoading); - })(); - const balance = useTokenBalance(quoteRequest?.originToken); - const insufficientBalance = hasInsufficientBalance( - quoteRequest, - expectedAmount, - balance - ); + const insufficientBalance = hasInsufficientBalance(quoteRequest, balance); useEffect(() => { if ( @@ -82,15 +69,6 @@ export const OriginTokenInput = ({ } }, [inputDisabled]); - const formattedConvertedAmount = (() => { - if (unit === "token") { - if (!convertedAmount) return "$0.00"; - return "$" + formatUSD(convertedAmount); - } - if (!convertedAmount) return "0.00"; - return `${formatUnits(convertedAmount, originToken?.decimals)} ${originToken?.symbol}`; - })(); - return ( @@ -98,7 +76,7 @@ export const OriginTokenInput = ({ handleInputChange(e.target.value)} disabled={inputDisabled} error={insufficientBalance} @@ -137,7 +115,7 @@ export const OriginTokenInput = ({ error={insufficientBalance} setAmount={(amount) => { if (amount) { - handleBalanceClick(amount, originToken.decimals); + handleBalanceClick(amount); } }} /> diff --git a/src/views/SwapAndBridge/hooks/index.ts b/src/views/SwapAndBridge/hooks/index.ts new file mode 100644 index 000000000..2a36a9c17 --- /dev/null +++ b/src/views/SwapAndBridge/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useTokenAmountInput"; diff --git a/src/views/SwapAndBridge/hooks/useButtonState.ts b/src/views/SwapAndBridge/hooks/useButtonState.ts index 01fd2b34c..aab91a1b2 100644 --- a/src/views/SwapAndBridge/hooks/useButtonState.ts +++ b/src/views/SwapAndBridge/hooks/useButtonState.ts @@ -117,14 +117,14 @@ export const useButtonState = ( !!validation.error || !quoteRequest.originToken || !quoteRequest.destinationToken || - !quoteRequest.amount || - quoteRequest.amount.lte(0), + !quoteRequest.userInputAmount || + quoteRequest.userInputAmount.lte(0), [ approvalAction.buttonDisabled, validation.error, quoteRequest.originToken, quoteRequest.destinationToken, - quoteRequest.amount, + quoteRequest.userInputAmount, ] ); diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx b/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx index 7ee3604e7..aff482753 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/QuoteRequestContext.tsx @@ -9,8 +9,11 @@ interface QuoteRequestContextValue { quoteRequest: QuoteRequest; setOriginToken: (token: EnrichedToken | null) => void; setDestinationToken: (token: EnrichedToken | null) => void; - setOriginAmount: (amount: BigNumber | null) => void; - setDestinationAmount: (amount: BigNumber | null) => void; + setUserInput: ( + field: "origin" | "destination", + amount: BigNumber | null + ) => void; + setQuoteOutput: (amount: BigNumber | null) => void; setCustomDestinationAccount: (account: QuoteAccount) => void; resetCustomDestinationAccount: () => void; quickSwap: () => void; @@ -40,12 +43,15 @@ export const QuoteRequestProvider = ({ dispatch({ type: "SET_DESTINATION_TOKEN", payload: token }); }, []); - const setOriginAmount = useCallback((amount: BigNumber | null) => { - dispatch({ type: "SET_ORIGIN_AMOUNT", payload: amount }); - }, []); + const setUserInput = useCallback( + (field: "origin" | "destination", amount: BigNumber | null) => { + dispatch({ type: "SET_USER_INPUT", payload: { field, amount } }); + }, + [] + ); - const setDestinationAmount = useCallback((amount: BigNumber | null) => { - dispatch({ type: "SET_DESTINATION_AMOUNT", payload: amount }); + const setQuoteOutput = useCallback((amount: BigNumber | null) => { + dispatch({ type: "SET_QUOTE_OUTPUT", payload: amount }); }, []); const setCustomDestinationAccount = useCallback((account: QuoteAccount) => { @@ -66,8 +72,8 @@ export const QuoteRequestProvider = ({ quoteRequest, setOriginToken, setDestinationToken, - setOriginAmount, - setDestinationAmount, + setUserInput, + setQuoteOutput, setCustomDestinationAccount, resetCustomDestinationAccount, quickSwap, diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts index 78bd4c74d..c69b69f86 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/initialQuote.ts @@ -1,9 +1,10 @@ import { QuoteRequest } from "./quoteRequestAction"; export const initialQuote: QuoteRequest = { - tradeType: "exactInput", originToken: null, destinationToken: null, customDestinationAccount: null, - amount: null, + userInputField: "origin", + userInputAmount: null, + quoteOutputAmount: null, }; diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts index 4a52929a0..30ff35f27 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestAction.ts @@ -14,11 +14,14 @@ export type QuoteRequestAction = payload: EnrichedToken | null; } | { - type: "SET_ORIGIN_AMOUNT"; - payload: BigNumber | null; + type: "SET_USER_INPUT"; + payload: { + field: "origin" | "destination"; + amount: BigNumber | null; + }; } | { - type: "SET_DESTINATION_AMOUNT"; + type: "SET_QUOTE_OUTPUT"; payload: BigNumber | null; } | { type: "SET_CUSTOM_DESTINATION_ACCOUNT"; payload: QuoteAccount } @@ -26,9 +29,10 @@ export type QuoteRequestAction = | { type: "QUICK_SWAP"; payload: undefined }; export interface QuoteRequest { - tradeType: "minOutput" | "exactInput"; originToken: EnrichedToken | null; destinationToken: EnrichedToken | null; customDestinationAccount: QuoteAccount | null; - amount: BigNumber | null; + userInputField: "origin" | "destination"; + userInputAmount: BigNumber | null; + quoteOutputAmount: BigNumber | null; } diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts index 901d15760..f85a38c67 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.test.ts @@ -28,24 +28,33 @@ describe("quoteRequestReducer", () => { expect(result.destinationToken).toBe(token); }); - it("sets origin amount with exactInput trade type", () => { + it("sets user input for origin field", () => { const amount = BigNumber.from(100); const result = quoteRequestReducer(initialQuote, { - type: "SET_ORIGIN_AMOUNT", - payload: amount, + type: "SET_USER_INPUT", + payload: { field: "origin", amount }, }); - expect(result.amount).toBe(amount); - expect(result.tradeType).toBe("exactInput"); + expect(result.userInputAmount).toBe(amount); + expect(result.userInputField).toBe("origin"); }); - it("sets destination amount with minOutput trade type", () => { + it("sets user input for destination field", () => { const amount = BigNumber.from(100); const result = quoteRequestReducer(initialQuote, { - type: "SET_DESTINATION_AMOUNT", + type: "SET_USER_INPUT", + payload: { field: "destination", amount }, + }); + expect(result.userInputAmount).toBe(amount); + expect(result.userInputField).toBe("destination"); + }); + + it("sets quote output amount", () => { + const amount = BigNumber.from(200); + const result = quoteRequestReducer(initialQuote, { + type: "SET_QUOTE_OUTPUT", payload: amount, }); - expect(result.amount).toBe(amount); - expect(result.tradeType).toBe("minOutput"); + expect(result.quoteOutputAmount).toBe(amount); }); it("sets custom destination account", () => { @@ -87,22 +96,28 @@ describe("quoteRequestReducer", () => { expect(result.destinationToken).toBe(eth); }); - it("converts exactInput to minOutput", () => { - const state: QuoteRequest = { ...initialQuote, tradeType: "exactInput" }; + it("converts origin input field to destination", () => { + const state: QuoteRequest = { + ...initialQuote, + userInputField: "origin", + }; const result = quoteRequestReducer(state, { type: "QUICK_SWAP", payload: undefined, }); - expect(result.tradeType).toBe("minOutput"); + expect(result.userInputField).toBe("destination"); }); - it("converts minOutput to exactInput", () => { - const state: QuoteRequest = { ...initialQuote, tradeType: "minOutput" }; + it("converts destination input field to origin", () => { + const state: QuoteRequest = { + ...initialQuote, + userInputField: "destination", + }; const result = quoteRequestReducer(state, { type: "QUICK_SWAP", payload: undefined, }); - expect(result.tradeType).toBe("exactInput"); + expect(result.userInputField).toBe("origin"); }); }); diff --git a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts index c8783407f..3dcfb9f96 100644 --- a/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts +++ b/src/views/SwapAndBridge/hooks/useQuoteRequest/quoteRequestReducer.ts @@ -6,20 +6,29 @@ export const quoteRequestReducer = ( ): QuoteRequest => { switch (action.type) { case "SET_ORIGIN_TOKEN": - return { ...prevState, originToken: action.payload }; + return { + ...prevState, + originToken: action.payload, + userInputAmount: null, + quoteOutputAmount: null, + }; case "SET_DESTINATION_TOKEN": - return { ...prevState, destinationToken: action.payload }; - case "SET_DESTINATION_AMOUNT": return { ...prevState, - amount: action.payload, - tradeType: "minOutput", + destinationToken: action.payload, + userInputAmount: null, + quoteOutputAmount: null, + }; + case "SET_USER_INPUT": + return { + ...prevState, + userInputField: action.payload.field, + userInputAmount: action.payload.amount, }; - case "SET_ORIGIN_AMOUNT": + case "SET_QUOTE_OUTPUT": return { ...prevState, - amount: action.payload, - tradeType: "exactInput", + quoteOutputAmount: action.payload, }; case "SET_CUSTOM_DESTINATION_ACCOUNT": return { ...prevState, customDestinationAccount: action.payload }; @@ -30,8 +39,8 @@ export const quoteRequestReducer = ( ...prevState, originToken: prevState.destinationToken, destinationToken: prevState.originToken, - tradeType: - prevState.tradeType === "exactInput" ? "minOutput" : "exactInput", + userInputField: + prevState.userInputField === "origin" ? "destination" : "origin", }; default: return prevState; diff --git a/src/views/SwapAndBridge/hooks/useSwapQuote.ts b/src/views/SwapAndBridge/hooks/useSwapQuote.ts index 8d1b3d9a0..e2f741070 100644 --- a/src/views/SwapAndBridge/hooks/useSwapQuote.ts +++ b/src/views/SwapAndBridge/hooks/useSwapQuote.ts @@ -13,11 +13,11 @@ import { useEcosystemAccounts } from "../../../hooks/useEcosystemAccounts"; export type SwapQuote = ReturnType["swapQuote"]; const useSwapQuote = ({ - amount, + userInputAmount, + userInputField, customDestinationAccount, destinationToken, originToken, - tradeType, }: QuoteRequest) => { const { depositor, depositorOrPlaceholder, recipientOrPlaceholder } = useEcosystemAccounts({ @@ -26,7 +26,9 @@ const useSwapQuote = ({ customDestinationAccount, }); - const debouncedAmount = useDebounce(amount, 300); + const debouncedAmount = useDebounce(userInputAmount, 300); + + const tradeType = userInputField === "origin" ? "exactInput" : "minOutput"; const skipOriginTxEstimation = !depositor; @@ -34,12 +36,12 @@ const useSwapQuote = ({ queryKey: [ "swap-quote", debouncedAmount, + userInputField, customDestinationAccount?.address, destinationToken?.address, destinationToken?.chainId, originToken?.address, originToken?.chainId, - tradeType, depositor, depositorOrPlaceholder, recipientOrPlaceholder, diff --git a/src/views/SwapAndBridge/hooks/useTokenAmountInput.ts b/src/views/SwapAndBridge/hooks/useTokenAmountInput.ts new file mode 100644 index 000000000..9dfbc866b --- /dev/null +++ b/src/views/SwapAndBridge/hooks/useTokenAmountInput.ts @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BigNumber } from "ethers"; +import { formatUnits } from "ethers/lib/utils"; +import { + convertTokenToUSD, + convertUSDToToken, + formatAmountForDisplay, + formatUSD, + isValidNumberInput, + parseInputValue, +} from "utils"; +import { UnitType } from "../components/TokenInput/OriginTokenInput"; +import { QuoteRequest } from "./useQuoteRequest/quoteRequestAction"; +import { EnrichedToken } from "../components/ChainTokenSelector/ChainTokenSelectorModal"; + +interface UseTokenAmountInputParams { + token: EnrichedToken | null; + fieldName: "origin" | "destination"; + unit: UnitType; + setUnit: (unit: UnitType) => void; + isUpdateLoading: boolean; + setUserInput: ( + field: "origin" | "destination", + amount: BigNumber | null + ) => void; + quoteRequest: QuoteRequest; +} + +interface UseTokenAmountInputReturn { + displayValue: string; + convertedAmount: BigNumber | undefined; + formattedConvertedAmount: string; + inputDisabled: boolean; + handleInputChange: (value: string) => void; + handleBalanceClick: (amount: BigNumber) => void; + toggleUnit: () => void; + isUserInput: boolean; +} + +export const useTokenAmountInput = ({ + token, + fieldName, + unit, + setUnit, + isUpdateLoading, + setUserInput, + quoteRequest, +}: UseTokenAmountInputParams): UseTokenAmountInputReturn => { + const isUserInput = quoteRequest.userInputField === fieldName; + + const handleSetInputValue = useCallback( + (value: string) => { + if (!token) { + setUserInput(fieldName, null); + return; + } + + try { + const parsed = parseInputValue(value, token, unit); + setUserInput(fieldName, parsed); + } catch (e) { + setUserInput(fieldName, null); + } + }, + [setUserInput, token, unit, fieldName] + ); + + const handleBalanceClick = useCallback( + (amount: BigNumber) => { + if (!token) return; + setUserInput(fieldName, amount); + }, + [token, setUserInput, fieldName] + ); + + const displayValue = useMemo(() => { + if (!isUserInput && isUpdateLoading) { + return ""; + } + + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!amount || !token) { + return ""; + } + + return formatAmountForDisplay(amount, token, unit); + }, [ + isUserInput, + isUpdateLoading, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + token, + unit, + ]); + + const [convertedAmount, setConvertedAmount] = useState(); + + useEffect(() => { + const amount = isUserInput + ? quoteRequest.userInputAmount + : quoteRequest.quoteOutputAmount; + + if (!token || !amount) { + setConvertedAmount(undefined); + return; + } + + try { + const formatted = formatAmountForDisplay(amount, token, unit); + if (unit === "token") { + const usdValue = convertTokenToUSD(formatted, token); + setConvertedAmount(usdValue); + } else { + const tokenValue = convertUSDToToken(formatted, token); + setConvertedAmount(tokenValue); + } + } catch (e) { + setConvertedAmount(undefined); + } + }, [ + token, + quoteRequest.userInputAmount, + quoteRequest.quoteOutputAmount, + unit, + isUserInput, + ]); + + const toggleUnit = useCallback(() => { + if (!token || !convertedAmount) { + setUnit(unit === "token" ? "usd" : "token"); + return; + } + + const newUnit = unit === "token" ? "usd" : "token"; + setUnit(newUnit); + + if (isUserInput && quoteRequest.userInputAmount) { + setUserInput(fieldName, convertedAmount); + } + }, [ + unit, + token, + convertedAmount, + isUserInput, + quoteRequest.userInputAmount, + setUnit, + setUserInput, + fieldName, + ]); + + const handleInputChange = useCallback( + (value: string) => { + if (!isValidNumberInput(value)) { + return; + } + handleSetInputValue(value); + }, + [handleSetInputValue] + ); + + const inputDisabled = (() => { + if (!quoteRequest.destinationToken) return true; + return Boolean(!isUserInput && isUpdateLoading); + })(); + + const formattedConvertedAmount = useMemo(() => { + if (unit === "token") { + if (!convertedAmount) return "$0.00"; + return "$" + formatUSD(convertedAmount); + } + if (!convertedAmount) return "0.00"; + return `${formatUnits(convertedAmount, token?.decimals)} ${token?.symbol}`; + }, [unit, convertedAmount, token]); + + return { + displayValue, + convertedAmount, + formattedConvertedAmount, + inputDisabled, + handleInputChange, + handleBalanceClick, + toggleUnit, + isUserInput, + }; +}; diff --git a/src/views/SwapAndBridge/utils/balance.ts b/src/views/SwapAndBridge/utils/balance.ts index 6990dc4ff..5c5e85352 100644 --- a/src/views/SwapAndBridge/utils/balance.ts +++ b/src/views/SwapAndBridge/utils/balance.ts @@ -2,20 +2,18 @@ import { QuoteRequest } from "../hooks/useQuoteRequest/quoteRequestAction"; import { BigNumber } from "ethers"; export const hasInsufficientBalance = ( - { amount, tradeType }: QuoteRequest, - expectedAmount: BigNumber | undefined, + { userInputAmount, userInputField, quoteOutputAmount }: QuoteRequest, balance: BigNumber | undefined ) => { - if (!amount) { + if (!userInputAmount) { return false; } - if (tradeType === "exactInput" && balance) { - // isAmountorigin - if (amount.gt(balance)) { + if (userInputField === "origin" && balance) { + if (userInputAmount.gt(balance)) { return true; } - } else if (tradeType === "minOutput" && expectedAmount && balance) { - if (expectedAmount.gt(balance)) { + } else if (userInputField === "destination" && quoteOutputAmount && balance) { + if (quoteOutputAmount.gt(balance)) { return true; } } diff --git a/yarn.lock b/yarn.lock index 7ae17dec6..0316d159d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23278,6 +23278,11 @@ react-modal@^3.12.1: react-lifecycles-compat "^3.0.0" warning "^4.0.3" +react-number-format@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.4.4.tgz#d31f0e260609431500c8d3f81bbd3ae1fb7cacad" + integrity sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA== + react-pro-sidebar@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/react-pro-sidebar/-/react-pro-sidebar-1.1.0.tgz#e8f4ca0d7c4ff9fd2c38f8a0b85664083173b7ac"