From ff0e7f0d3fc5410cf6677afa8737e178937c9957 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Wed, 12 Feb 2025 22:21:36 -0500 Subject: [PATCH] Adds token approval, debounces quote calls, fixes other bugs runs format moves flameExplorerUrl to constants formating fix Fixes lint errors fixes build errors format --- .../ConfirmationModal/ConfirmationModal.tsx | 10 +- apps/flame-defi/app/config/hooks/useConfig.ts | 18 +- apps/flame-defi/app/constants.ts | 5 + .../ConnectEvmWalletButton.tsx | 15 +- .../SingleWalletButton/SingleWalletButton.tsx | 112 -------- .../SingleWalletConnect.tsx | 19 +- .../EvmWallet/contexts/EvmWalletContext.tsx | 111 +++++++- .../services/SwapServices/SwapService.ts | 60 ----- .../app/swap/components/SwapInput.tsx | 4 +- .../app/swap/components/SwapTxnSteps.tsx | 250 ++++++++---------- .../app/swap/components/TxnInfo.tsx | 41 +-- apps/flame-defi/app/swap/page.tsx | 186 ++++++++----- apps/flame-defi/app/swap/useGetQuote.tsx | 105 +++++--- apps/flame-defi/app/swap/useOneToOneQuote.tsx | 43 +-- apps/flame-defi/app/swap/useSwapButton.tsx | 227 ++++++++++------ apps/flame-defi/app/swap/useUsdQuote.tsx | 26 +- apps/flame-defi/app/utils/utils.ts | 16 ++ apps/flame-defi/tailwind.config.js | 24 +- packages/flame-types/src/types.ts | 9 + .../{DownArrowIcon.tsx => ErrorIcon.tsx} | 19 +- packages/ui/src/icons/index.ts | 2 +- 21 files changed, 731 insertions(+), 571 deletions(-) delete mode 100644 apps/flame-defi/app/features/EvmWallet/components/SingleWalletButton/SingleWalletButton.tsx rename packages/ui/src/icons/{DownArrowIcon.tsx => ErrorIcon.tsx} (52%) diff --git a/apps/flame-defi/app/components/ConfirmationModal/ConfirmationModal.tsx b/apps/flame-defi/app/components/ConfirmationModal/ConfirmationModal.tsx index 660167e..4cf2ea1 100644 --- a/apps/flame-defi/app/components/ConfirmationModal/ConfirmationModal.tsx +++ b/apps/flame-defi/app/components/ConfirmationModal/ConfirmationModal.tsx @@ -27,15 +27,15 @@ interface ConfirmationModalProps { export default function ConfirmationModal({ onSubmitCallback, - setTxnStatus, - txnStatus, - handleResetInputs, buttonText, children, - title, - actionButtonText, + handleResetInputs, isCloseModalAction, + setTxnStatus, skipIdleTxnStatus, + title, + txnStatus, + actionButtonText, }: ConfirmationModalProps): React.ReactElement { const [open, setOpen] = useState(false); diff --git a/apps/flame-defi/app/config/hooks/useConfig.ts b/apps/flame-defi/app/config/hooks/useConfig.ts index 4e01bbf..bdb964f 100644 --- a/apps/flame-defi/app/config/hooks/useConfig.ts +++ b/apps/flame-defi/app/config/hooks/useConfig.ts @@ -4,6 +4,7 @@ import { useContext } from "react"; import { ConfigContext } from "../contexts/ConfigContext"; import { EvmChainInfo } from "@repo/flame-types"; +import { Chain } from "viem"; /** * Hook to use the config context. @@ -21,5 +22,20 @@ export const useEvmChainData = () => { const evmChainsData = Object.values(evmChains); const selectedChain = evmChainsData[0] as EvmChainInfo; - return { selectedChain }; + const evmChainConfig: Chain = { + id: selectedChain?.chainId, + name: selectedChain?.chainName, + nativeCurrency: { + name: selectedChain?.currencies[0]?.title, + symbol: selectedChain?.currencies[0]?.coinDenom, + decimals: selectedChain?.currencies[0]?.coinDecimals || 18, + }, + rpcUrls: { + default: { + http: selectedChain?.rpcUrls, + }, + }, + }; + + return { selectedChain, evmChainConfig }; }; diff --git a/apps/flame-defi/app/constants.ts b/apps/flame-defi/app/constants.ts index 47ddb07..8cf4b05 100644 --- a/apps/flame-defi/app/constants.ts +++ b/apps/flame-defi/app/constants.ts @@ -15,6 +15,11 @@ export enum TXN_STATUS { FAILED = "failed", } +export const flameExplorerUrl = "https://explorer.flame.astria.org"; + +export const defaultApprovalAmount = + "115792089237316195423570985008687907853269984665640564039457"; + export const defaultSlippageTolerance = 0.1; export const feeData = [ diff --git a/apps/flame-defi/app/features/EvmWallet/components/ConnectEvmWalletButton/ConnectEvmWalletButton.tsx b/apps/flame-defi/app/features/EvmWallet/components/ConnectEvmWalletButton/ConnectEvmWalletButton.tsx index 4cff05a..c6bfe96 100644 --- a/apps/flame-defi/app/features/EvmWallet/components/ConnectEvmWalletButton/ConnectEvmWalletButton.tsx +++ b/apps/flame-defi/app/features/EvmWallet/components/ConnectEvmWalletButton/ConnectEvmWalletButton.tsx @@ -12,6 +12,7 @@ import { AccordionItem, AccordionTrigger, } from "@repo/ui/shadcn-primitives"; +import { flameExplorerUrl } from "../../../../constants"; interface ConnectEvmWalletButtonProps { // Label to show before the user is connected to a wallet. @@ -61,10 +62,16 @@ export default function ConnectEvmWalletButton({
- + + + -
- - -
-
- {isLoadingEvmNativeTokenBalance &&
Loading...
} - {!isLoadingEvmNativeTokenBalance && evmNativeTokenBalance && ( -
- {formatBalance(evmNativeTokenBalance.value)}{" "} - {evmNativeTokenBalance.symbol} -
- )} - {/* TODO - price in USD */} -
$0.00 USD
-
- - {/* Transactions Section - TODO */} - {/*
- - - {showTransactions && ( -
-
No recent transactions
-
- )} -
*/} -
- - - - ) : ( - - ); -} diff --git a/apps/flame-defi/app/features/EvmWallet/components/SingleWalletConnect/SingleWalletConnect.tsx b/apps/flame-defi/app/features/EvmWallet/components/SingleWalletConnect/SingleWalletConnect.tsx index 75c565f..4f07e18 100644 --- a/apps/flame-defi/app/features/EvmWallet/components/SingleWalletConnect/SingleWalletConnect.tsx +++ b/apps/flame-defi/app/features/EvmWallet/components/SingleWalletConnect/SingleWalletConnect.tsx @@ -9,6 +9,7 @@ import { PopoverContent, PopoverTrigger, } from "@repo/ui/shadcn-primitives"; +import { flameExplorerUrl } from "../../../../constants"; export function SingleWalletContent({ address, @@ -32,7 +33,7 @@ export function SingleWalletContent({ }; return ( -
+
@@ -43,10 +44,16 @@ export function SingleWalletContent({
- + + + -
-
- ); + oneToOneQuote: OneToOneQuoteProps; } export function TxnDetails({ @@ -71,7 +39,7 @@ export function TxnDetails({ expectedOutputFormatted, priceImpact, minimumReceived, - isTiaWtia, + oneToOneQuote, }: TxnDetailsProps) { const { tokenOneSymbol, @@ -81,100 +49,90 @@ export function TxnDetails({ // error, setFlipDirection, flipDirection, - } = useOneToOneQuote(inputOne, inputTwo); + } = oneToOneQuote; const slippageTolerance = getSlippageTolerance(); return ( <> - {isTiaWtia ? ( - - ) : ( - <> -
-
- {formatDecimalValues(inputOne?.value || "0", 6)} - - {inputOne?.token?.IconComponent && - inputOne?.token?.IconComponent({ size: 24 })} - {inputOne?.token?.coinDenom} - -
-
- {expectedOutputFormatted} - - {inputTwo?.token?.IconComponent && - inputTwo?.token?.IconComponent({ size: 24 })} - {inputTwo?.token?.coinDenom} - -
-
- -
-
-
- -
setFlipDirection(!flipDirection)} - > -
- {formatDecimalValues("1", 0)} - {tokenOneSymbol} -
-
=
-
- {tokenTwoValue} - {tokenTwoSymbol} -
-
-
+
+
+ {formatDecimalValues(inputOne?.value || "0", 6)} + + {inputOne?.token?.IconComponent && + inputOne?.token?.IconComponent({ size: 24 })} + {inputOne?.token?.coinDenom} + +
+
+ {expectedOutputFormatted} + + {inputTwo?.token?.IconComponent && + inputTwo?.token?.IconComponent({ size: 24 })} + {inputTwo?.token?.coinDenom} + +
+
+
+
-
-
- - Expected Output{" "} - - - - {expectedOutputFormatted}{" "} - {inputTwo?.token?.coinDenom} - -
-
- - Price Impact{" "} - - - - {priceImpact}% - +
+
+
+ +
setFlipDirection(!flipDirection)} + > +
+ {formatDecimalValues("1", 0)} + {tokenOneSymbol}
-
- - Minimum received after slippage ({slippageTolerance}%){" "} - - - - {minimumReceived} {inputTwo?.token?.coinDenom} - +
=
+
+ {tokenTwoValue} + {tokenTwoSymbol}
- - )} + +
+
+
+ + Expected Output{" "} + + + + {expectedOutputFormatted} {inputTwo?.token?.coinDenom} + +
+
+ + Price Impact{" "} + + + + {priceImpact}% + +
+
+ + Minimum received after slippage ({slippageTolerance}%){" "} + + + + {minimumReceived} {inputTwo?.token?.coinDenom} + +
+
); } @@ -213,13 +171,14 @@ function TxnSuccess({ inputOne, inputTwo, isTiaWtia, + txnHash, }: TxnStepsProps) { return (
Success -
+
Swapped {formatDecimalValues(inputOne?.value || "0", 6)}{" "} @@ -233,20 +192,41 @@ function TxnSuccess({ {inputTwo?.token?.coinDenom}
+
); } -function TxnFailed(props: TxnStepsProps) { - console.log("TxnFailed props", props); - return
TxnFailed
; +function TxnFailed({ txnMsg }: TxnStepsProps) { + return ( +
+ +
+
+ {txnMsg || "An error occurred"} +
+
+
+ ); } export function SwapTxnSteps({ - swapPairs, txnStatus, + swapPairs, isTiaWtia, + txnHash, + txnMsg, + oneToOneQuote, }: SwapTxnStepsProps) { const inputOne = swapPairs[0]?.inputToken; const inputTwo = swapPairs[1]?.inputToken; @@ -264,6 +244,7 @@ export function SwapTxnSteps({ priceImpact={priceImpact} minimumReceived={minimumReceived} isTiaWtia={isTiaWtia} + oneToOneQuote={oneToOneQuote} /> )} {txnStatus === TXN_STATUS.PENDING && ( @@ -280,6 +261,7 @@ export function SwapTxnSteps({ inputTwo={inputTwo} expectedOutputFormatted={expectedOutputFormatted} isTiaWtia={isTiaWtia} + txnHash={txnHash} /> )} {txnStatus === TXN_STATUS.FAILED && ( @@ -288,6 +270,8 @@ export function SwapTxnSteps({ inputTwo={inputTwo} expectedOutputFormatted={expectedOutputFormatted} isTiaWtia={isTiaWtia} + txnHash={txnHash} + txnMsg={txnMsg} /> )}
diff --git a/apps/flame-defi/app/swap/components/TxnInfo.tsx b/apps/flame-defi/app/swap/components/TxnInfo.tsx index c646952..31bfdcb 100644 --- a/apps/flame-defi/app/swap/components/TxnInfo.tsx +++ b/apps/flame-defi/app/swap/components/TxnInfo.tsx @@ -10,8 +10,11 @@ import { import { InfoTooltip } from "@repo/ui/components"; import { GasIcon } from "@repo/ui/icons"; import { formatDecimalValues, getSlippageTolerance } from "utils/utils"; -import { TokenState, GetQuoteResult } from "@repo/flame-types"; -import useOneToOneQuote from "swap/useOneToOneQuote"; +import { + TokenState, + GetQuoteResult, + OneToOneQuoteProps, +} from "@repo/flame-types"; import { TOKEN_INPUTS } from "../../constants"; import { useTxnInfo } from "swap/useTxnInfo"; @@ -23,10 +26,16 @@ export interface TxnInfoProps { txnQuoteError: string | null; } -export function TxnInfo({ swapPairs }: { swapPairs: TxnInfoProps[] }) { - const inputTokenOne = swapPairs[0]?.inputToken; +export function TxnInfo({ + swapPairs, + oneToOneQuote, +}: { + swapPairs: TxnInfoProps[]; + oneToOneQuote: OneToOneQuoteProps; +}) { const inputTokenTwo = swapPairs[1]?.inputToken; const slippageTolerance = getSlippageTolerance(); + const { gasUseEstimateUSD, formattedGasUseEstimateUSD, @@ -34,15 +43,6 @@ export function TxnInfo({ swapPairs }: { swapPairs: TxnInfoProps[] }) { priceImpact, minimumReceived, } = useTxnInfo({ swapPairs }); - const { - tokenOneSymbol, - tokenTwoSymbol, - tokenTwoValue, - oneToOneLoading, - // error, - setFlipDirection, - flipDirection, - } = useOneToOneQuote(inputTokenOne, inputTokenTwo); if (swapPairs[0]?.txnQuoteLoading) { return
; @@ -55,19 +55,24 @@ export function TxnInfo({ swapPairs }: { swapPairs: TxnInfoProps[] }) { className="text-grey-light text-sm border-b-0" >
- +
setFlipDirection(!flipDirection)} + onClick={() => + oneToOneQuote?.setFlipDirection(!oneToOneQuote?.flipDirection) + } >
{formatDecimalValues("1", 0)} - {tokenOneSymbol} + {oneToOneQuote?.tokenOneSymbol}
=
- {tokenTwoValue} - {tokenTwoSymbol} + {oneToOneQuote?.tokenTwoValue} + {oneToOneQuote?.tokenTwoSymbol}
diff --git a/apps/flame-defi/app/swap/page.tsx b/apps/flame-defi/app/swap/page.tsx index 4717c1e..be9fbf4 100644 --- a/apps/flame-defi/app/swap/page.tsx +++ b/apps/flame-defi/app/swap/page.tsx @@ -1,27 +1,27 @@ "use client"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { useAccount } from "wagmi"; import { SettingsPopover } from "components/SettingsPopover/SettingsPopover"; import { useEvmChainData } from "config"; -import { DownArrowIcon } from "@repo/ui/icons"; +import { ArrowDownIcon } from "@repo/ui/icons"; import { ActionButton } from "@repo/ui/components"; import { EvmCurrency, TokenState } from "@repo/flame-types"; import { TRADE_TYPE, TOKEN_INPUTS } from "../constants"; import { useTokenBalance } from "features/EvmWallet/hooks/useTokenBalance"; - import { useSwapButton } from "./useSwapButton"; import { SwapInput } from "./components/SwapInput"; import { TxnInfo } from "./components/TxnInfo"; import ConfirmationModal from "components/ConfirmationModal/ConfirmationModal"; import { SwapTxnSteps } from "./components/SwapTxnSteps"; import { useGetQuote } from "./useGetQuote"; +import debounce from "lodash.debounce"; +import useOneToOneQuote from "./useOneToOneQuote"; export default function SwapPage(): React.ReactElement { const { selectedChain } = useEvmChainData(); const { currencies } = selectedChain; - const userAccount = useAccount(); const [inputOne, setInputOne] = useState({ token: currencies?.[0], @@ -43,21 +43,26 @@ export default function SwapPage(): React.ReactElement { }, [inputOne, inputTwo]); const [flipTokens, setFlipTokens] = useState(false); - const [quoteType, setQuoteType] = useState(TRADE_TYPE.EXACT_IN); + const [tradeType, setTradeType] = useState(TRADE_TYPE.EXACT_IN); const { quote, loading, error, getQuote, setQuote } = useGetQuote(); const tokenOne = !flipTokens ? inputOne : inputTwo; const tokenTwo = !flipTokens ? inputTwo : inputOne; const tokenOneBalance = useTokenBalance(tokenOne.token, selectedChain).balance?.value || "0"; + const oneToOneQuote = useOneToOneQuote(tokenOne.token, tokenTwo.token); const { + titleText, + txnHash, onSubmitCallback, buttonText, actionButtonText, validSwapInputs, txnStatus, setTxnStatus, + txnMsg, isCloseModalAction, + tokenApprovalNeeded, } = useSwapButton({ tokenOne, tokenTwo, @@ -65,14 +70,42 @@ export default function SwapPage(): React.ReactElement { quote, loading, error, - quoteType, + tradeType, }); - const handleQuoteType = useCallback((index: number) => { + const debouncedGetQuoteRef = useRef( + debounce( + ( + tradeType: TRADE_TYPE, + tokenData: { token: EvmCurrency; value: string }, + token: TokenState, + tokenInput: TOKEN_INPUTS, + ) => { + getQuote(tradeType, tokenData, token).then((res) => { + if (tokenInput === TOKEN_INPUTS.TOKEN_ONE && res) { + setInputTwo((prev) => ({ + ...prev, + value: res.quoteDecimals, + isQuoteValue: true, + })); + } else if (tokenInput === TOKEN_INPUTS.TOKEN_TWO && res) { + setInputOne((prev) => ({ + ...prev, + value: res.quoteDecimals, + isQuoteValue: true, + })); + } + }); + }, + 500, + ), + ); + + const handleTradeType = useCallback((index: number) => { if (index === 0) { - setQuoteType(TRADE_TYPE.EXACT_IN); + setTradeType(TRADE_TYPE.EXACT_IN); } else if (index === 1) { - setQuoteType(TRADE_TYPE.EXACT_OUT); + setTradeType(TRADE_TYPE.EXACT_OUT); } }, []); @@ -95,8 +128,9 @@ export default function SwapPage(): React.ReactElement { return; } - handleQuoteType(index); - const quoteType = + handleTradeType(index); + + const tradeType = index === 0 ? TRADE_TYPE.EXACT_IN : TRADE_TYPE.EXACT_OUT; if (tokenInput === TOKEN_INPUTS.TOKEN_ONE) { @@ -107,8 +141,18 @@ export default function SwapPage(): React.ReactElement { setInputOne((prev) => ({ ...prev, value: "", isQuoteValue: true })); } - if (tokenOne.value && tokenTwo.value) { - getQuote(quoteType, { token: tokenOne.token, value: value }, tokenTwo); + if ( + value !== "" && + parseFloat(value) > 0 && + tokenOne.token && + tokenTwo.token + ) { + debouncedGetQuoteRef.current( + tradeType, + { token: tokenOne.token, value }, + tokenTwo, + tokenInput, + ); } if (value === "" || value === "0") { @@ -116,38 +160,65 @@ export default function SwapPage(): React.ReactElement { setInputTwo((prev) => ({ ...prev, value: value })); } }, - [tokenOne, tokenTwo, getQuote, handleQuoteType, isTiaWtia], + [tokenOne, tokenTwo, handleTradeType, isTiaWtia, debouncedGetQuoteRef], ); - useEffect(() => { - if (isTiaWtia) { - return; - } - if ( - tokenOne.token && - tokenTwo.token && - !tokenTwo.value && - tokenTwo.isQuoteValue - ) { - getQuote(quoteType, tokenOne, tokenTwo); - } - if ( - tokenOne.token && - tokenTwo.token && - !tokenOne.value && - tokenOne.isQuoteValue - ) { - getQuote(quoteType, tokenOne, tokenTwo); - } - }, [isTiaWtia, tokenOne, tokenTwo, getQuote, quoteType]); + const handleTokenSelect = useCallback( + (selectedToken: EvmCurrency, tokenInput: TOKEN_INPUTS, index: number) => { + if (tokenInput === TOKEN_INPUTS.TOKEN_ONE) { + setInputOne((prev) => ({ ...prev, token: selectedToken })); + } else if (tokenInput === TOKEN_INPUTS.TOKEN_TWO) { + setInputTwo((prev) => ({ ...prev, token: selectedToken })); + } + + const tradeType = tokenTwo.isQuoteValue + ? TRADE_TYPE.EXACT_IN + : TRADE_TYPE.EXACT_OUT; + const exactInToken = index === 0 ? selectedToken : tokenOne.token; + const userInputAmount = !tokenOne.isQuoteValue + ? tokenOne.value + : tokenTwo.value; + const exactOutToken = + index === 0 && tokenTwo.token ? tokenTwo.token : selectedToken; + + if ( + userInputAmount !== "" && + parseFloat(userInputAmount) > 0 && + tokenOne.token && + exactInToken && + exactOutToken + ) { + getQuote( + tradeType, + { token: exactInToken, value: userInputAmount }, + { token: exactOutToken, value: "" }, + ).then((res) => { + if (tokenOne.isQuoteValue && res) { + setInputOne((prev) => ({ ...prev, value: res.quoteDecimals })); + } else if (tokenTwo.isQuoteValue && res) { + setInputTwo((prev) => ({ ...prev, value: res.quoteDecimals })); + } + }); + } + }, + [getQuote, tokenOne, tokenTwo], + ); + + const handleArrowClick = () => { + const newTradeType = + tradeType === TRADE_TYPE.EXACT_IN + ? TRADE_TYPE.EXACT_OUT + : TRADE_TYPE.EXACT_IN; + setFlipTokens((prev) => !prev); + setTradeType(newTradeType); - useEffect(() => { - if (quote?.quoteDecimals && inputOne.isQuoteValue) { - setInputOne((prev) => ({ ...prev, value: quote.quoteDecimals })); - } else if (quote?.quoteDecimals && inputTwo.isQuoteValue) { - setInputTwo((prev) => ({ ...prev, value: quote.quoteDecimals })); + const preFlipTokenOne = !flipTokens ? inputTwo : inputOne; + const preFlipTokenTwo = !flipTokens ? inputOne : inputTwo; + + if (preFlipTokenOne.value !== "" || preFlipTokenTwo.value !== "") { + getQuote(newTradeType, preFlipTokenOne, preFlipTokenTwo); } - }, [quote, inputOne.isQuoteValue, inputTwo.isQuoteValue]); + }; const swapInputs = [ { @@ -158,8 +229,8 @@ export default function SwapPage(): React.ReactElement { availableTokens: currencies, oppositeToken: inputTwo, balance: useTokenBalance(inputOne.token, selectedChain).balance, - onTokenSelect: (token: EvmCurrency) => - setInputOne((prev) => ({ ...prev, value: "", token })), + onTokenSelect: (token: EvmCurrency, index: number) => + handleTokenSelect(token, TOKEN_INPUTS.TOKEN_ONE, index), label: flipTokens ? "Buy" : "Sell", txnQuoteData: quote, txnQuoteLoading: loading, @@ -173,8 +244,8 @@ export default function SwapPage(): React.ReactElement { availableTokens: currencies, oppositeToken: inputOne, balance: useTokenBalance(inputTwo.token, selectedChain).balance, - onTokenSelect: (token: EvmCurrency) => - setInputTwo((prev) => ({ ...prev, value: "", token })), + onTokenSelect: (token: EvmCurrency, index: number) => + handleTokenSelect(token, TOKEN_INPUTS.TOKEN_TWO, index), label: flipTokens ? "Sell" : "Buy", txnQuoteData: quote, txnQuoteLoading: loading, @@ -182,16 +253,6 @@ export default function SwapPage(): React.ReactElement { }, ]; - const handleArrowClick = () => { - setFlipTokens((prev) => !prev); - setInputOne((prev) => ({ ...prev, isQuoteValue: !prev.isQuoteValue })); - setInputTwo((prev) => ({ ...prev, isQuoteValue: !prev.isQuoteValue })); - - const preFlipTokenOne = !flipTokens ? inputTwo : inputOne; - const preFlipTokenTwo = !flipTokens ? inputOne : inputTwo; - getQuote(TRADE_TYPE.EXACT_IN, preFlipTokenOne, preFlipTokenTwo); - }; - const swapPairs = flipTokens ? swapInputs.reverse() : swapInputs; return ( @@ -213,16 +274,16 @@ export default function SwapPage(): React.ReactElement { className="z-10 cursor-pointer p-1 bg-grey-dark hover:bg-black transition rounded-xl border-4 border-black" onClick={handleArrowClick} > - +
- {userAccount.address && !validSwapInputs && ( + {userAccount.address && !validSwapInputs && !tokenApprovalNeeded && (
{buttonText}
)} - {onSubmitCallback && validSwapInputs && ( + {validSwapInputs && !tokenApprovalNeeded && ( )} - {onSubmitCallback && !userAccount.address && ( + {(!userAccount.address || tokenApprovalNeeded) && ( )} {inputOne.token && inputTwo.token && !isTiaWtia && validSwapInputs && ( - + )}
diff --git a/apps/flame-defi/app/swap/useGetQuote.tsx b/apps/flame-defi/app/swap/useGetQuote.tsx index ce94f35..371b3c2 100644 --- a/apps/flame-defi/app/swap/useGetQuote.tsx +++ b/apps/flame-defi/app/swap/useGetQuote.tsx @@ -14,11 +14,22 @@ export function useGetQuote() { const [error, setError] = useState(null); const getQuote = useCallback( - async (type: TRADE_TYPE, tokenOne: TokenState, tokenTwo: TokenState) => { + async ( + type: TRADE_TYPE, + tokenOne: TokenState, + tokenTwo: TokenState, + ): Promise => { + if ( + (tokenOne.token?.coinDenom === "TIA" && + tokenTwo.token?.coinDenom === "WTIA") || + (tokenOne.token?.coinDenom === "WTIA" && + tokenTwo.token?.coinDenom === "TIA") + ) { + return; + } if (tokenOne.token?.coinDenom === tokenTwo.token?.coinDenom) { return; } - const amount = tokenOne?.value ? parseUnits( tokenOne.value, @@ -26,6 +37,7 @@ export function useGetQuote() { ).toString() : ""; + // This is a fix for TIA not having a ERC20 contract address const tokenInAddress = tokenOne?.token?.erc20ContractAddress || "0x61B7794B6A0Cc383B367c327B91E5Ba85915a071"; @@ -38,53 +50,60 @@ export function useGetQuote() { const tokenOutSymbol = tokenTwo?.token?.coinDenom.toLocaleLowerCase(); if ( - chainId && - tokenInAddress && - tokenInDecimals !== undefined && - tokenInSymbol && - tokenOutAddress && - tokenOutDecimals !== undefined && - tokenOutSymbol && - amount && - parseFloat(amount) > 0 && - type + !( + chainId && + tokenInAddress && + tokenInDecimals !== undefined && + tokenInSymbol && + tokenOutAddress && + tokenOutDecimals !== undefined && + tokenOutSymbol && + amount && + parseFloat(amount) > 0 && + type + ) ) { - try { - setLoading(true); - setError(null); + return; + } - const url = - `https://us-west2-swap-routing-api-dev.cloudfunctions.net/get-quote?` + - `chainId=${chainId}` + - `&tokenInAddress=${tokenInAddress}` + - `&tokenInDecimals=${tokenInDecimals}` + - `&tokenInSymbol=${tokenInSymbol}` + - `&tokenOutAddress=${tokenOutAddress}` + - `&tokenOutDecimals=${tokenOutDecimals}` + - `&tokenOutSymbol=${tokenOutSymbol}` + - `&amount=${amount}` + - `&type=${type}`; + try { + setLoading(true); + setError(null); - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); + const url = + `https://us-west2-swap-routing-api-dev.cloudfunctions.net/get-quote?` + + `chainId=${chainId}` + + `&tokenInAddress=${tokenInAddress}` + + `&tokenInDecimals=${tokenInDecimals}` + + `&tokenInSymbol=${tokenInSymbol}` + + `&tokenOutAddress=${tokenOutAddress}` + + `&tokenOutDecimals=${tokenOutDecimals}` + + `&tokenOutSymbol=${tokenOutSymbol}` + + `&amount=${amount}` + + `&type=${type}`; - if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`); - } + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); - const { data } = await response.json(); - setQuote({ ...data }); - return data; - } catch (err) { - console.warn(err); - setError("error message"); - } finally { - setLoading(false); + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); } + + const { data } = await response.json(); + setQuote({ ...data }); + + return data; + } catch (err) { + console.warn(err); + setError("error message"); + + throw err; + } finally { + setLoading(false); } }, [chainId], diff --git a/apps/flame-defi/app/swap/useOneToOneQuote.tsx b/apps/flame-defi/app/swap/useOneToOneQuote.tsx index 8c59d3b..fd0ce08 100644 --- a/apps/flame-defi/app/swap/useOneToOneQuote.tsx +++ b/apps/flame-defi/app/swap/useOneToOneQuote.tsx @@ -1,57 +1,60 @@ import { useState, useMemo, useEffect } from "react"; import { useGetQuote } from "./useGetQuote"; -import { GetQuoteResult, TokenState } from "@repo/flame-types"; +import { EvmCurrency, GetQuoteResult } from "@repo/flame-types"; import { formatDecimalValues } from "utils/utils"; import { TRADE_TYPE } from "../constants"; -import debounce from "lodash.debounce"; -function useOneToOneQuote(inputOne?: TokenState, inputTwo?: TokenState) { +function useOneToOneQuote( + inputOne?: EvmCurrency | null, + inputTwo?: EvmCurrency | null, +) { const [flipDirection, setFlipDirection] = useState(true); const [quoteOne, setQuoteOne] = useState(null); const [quoteTwo, setQuoteTwo] = useState(null); + const [quoteLoading, setQuoteLoading] = useState(false); const oneToOneValueTokens = useMemo( () => (flipDirection ? [inputOne, inputTwo] : [inputTwo, inputOne]), [flipDirection, inputOne, inputTwo], ); - const { loading, error, getQuote } = useGetQuote(); - const tokenOneSymbol = oneToOneValueTokens[0]?.token?.coinDenom; - const tokenTwoSymbol = oneToOneValueTokens[1]?.token?.coinDenom; + const { error, getQuote } = useGetQuote(); + const tokenOneSymbol = oneToOneValueTokens[0]?.coinDenom; + const tokenTwoSymbol = oneToOneValueTokens[1]?.coinDenom; const tokenTwoValue = flipDirection ? formatDecimalValues(quoteOne?.quoteDecimals, 6) : formatDecimalValues(quoteTwo?.quoteDecimals, 6); useEffect(() => { const fetchQuotes = async () => { + setQuoteLoading(true); const quote1 = await getQuote( TRADE_TYPE.EXACT_IN, - { token: inputOne?.token, value: "1" }, - { token: inputTwo?.token, value: "0" }, + { token: inputOne, value: "1" }, + { token: inputTwo, value: "0" }, ); - setQuoteOne({ ...quote1 }); - + if (quote1) { + setQuoteOne({ ...quote1 }); + } const quote2 = await getQuote( TRADE_TYPE.EXACT_IN, - { token: inputTwo?.token, value: "1" }, - { token: inputOne?.token, value: "0" }, + { token: inputTwo, value: "1" }, + { token: inputOne, value: "0" }, ); - setQuoteTwo({ ...quote2 }); + if (quote2) { + setQuoteTwo({ ...quote2 }); + } + setQuoteLoading(false); }; - const debouncedFetchQuotes = debounce(fetchQuotes, 300); - debouncedFetchQuotes(); - - return () => { - debouncedFetchQuotes.cancel(); - }; + fetchQuotes(); }, [inputOne, inputTwo, getQuote]); return { tokenOneSymbol, tokenTwoSymbol, tokenTwoValue, - oneToOneLoading: loading, + oneToOneLoading: quoteLoading, error, setFlipDirection, flipDirection, diff --git a/apps/flame-defi/app/swap/useSwapButton.tsx b/apps/flame-defi/app/swap/useSwapButton.tsx index 87a5e3c..985ac44 100644 --- a/apps/flame-defi/app/swap/useSwapButton.tsx +++ b/apps/flame-defi/app/swap/useSwapButton.tsx @@ -6,7 +6,6 @@ import { useWaitForTransactionReceipt, useWalletClient, } from "wagmi"; -import { Chain } from "viem"; import { useEvmChainData } from "config"; import { @@ -15,9 +14,10 @@ import { createWrapService, SwapRouter, } from "features/EvmWallet"; -import { GetQuoteResult, TokenState } from "@repo/flame-types"; -import { getSlippageTolerance } from "utils/utils"; +import { EvmCurrency, GetQuoteResult, TokenState } from "@repo/flame-types"; +import { getSlippageTolerance, parseToBigInt } from "utils/utils"; import { TRADE_TYPE, TXN_STATUS } from "../constants"; +import JSBI from "jsbi"; interface SwapButtonProps { tokenOne: TokenState; @@ -26,9 +26,47 @@ interface SwapButtonProps { quote: GetQuoteResult | null; loading: boolean; error: string | null; - quoteType: TRADE_TYPE; + tradeType: TRADE_TYPE; } +interface ErrorWithMessage { + message: string; +} + +const useTokenApproval = (tokenOne: TokenState, tokenTwo: TokenState) => { + const { tokenAllowances } = useEvmWallet(); + // NOTE: this ensures the input token values are in bigInt format + const tokenOneValueBigInt = tokenOne?.value + ? parseToBigInt(tokenOne.value, tokenOne.token?.coinDecimals || 18) + : JSBI.BigInt(0); + const tokenTwoValueBigInt = tokenTwo?.value + ? parseToBigInt(tokenTwo.value, tokenTwo.token?.coinDecimals || 18) + : JSBI.BigInt(0); + + const tokenOneApproval = tokenAllowances.find( + (token) => token.symbol === tokenOne?.token?.coinDenom, + ); + const tokenTwoApproval = tokenAllowances.find( + (token) => token.symbol === tokenTwo?.token?.coinDenom, + ); + + // NOTE: these check if the tokens input values are greater than the token allowances and returns the token if true + if ( + tokenOneApproval && + JSBI.GT(tokenOneValueBigInt, tokenOneApproval?.allowance) + ) { + return tokenOne.token || null; + } + if ( + tokenTwoApproval && + JSBI.GT(tokenTwoValueBigInt, tokenTwoApproval?.allowance) + ) { + return tokenTwo.token || null; + } + + return null; +}; + export function useSwapButton({ tokenOne, tokenTwo, @@ -36,9 +74,10 @@ export function useSwapButton({ quote, loading, // error, - quoteType, + tradeType, }: SwapButtonProps) { - const { selectedChain } = useEvmChainData(); + const { selectedChain, evmChainConfig } = useEvmChainData(); + const { approveToken } = useEvmWallet(); const wagmiConfig = useWagmiConfig(); const userAccount = useAccount(); const slippageTolerance = getSlippageTolerance(); @@ -48,56 +87,77 @@ export function useSwapButton({ const { connectEvmWallet } = useEvmWallet(); const { data: walletClient } = useWalletClient(); const [txnStatus, setTxnStatus] = useState(undefined); + const [txnMsg, setTxnMsg] = useState(undefined); const [txnHash, setTxnHash] = useState<`0x${string}` | undefined>(undefined); + const result = useWaitForTransactionReceipt({ hash: txnHash }); + const tokenApprovalNeeded = useTokenApproval(tokenOne, tokenTwo); + const wrapTia = tokenOne?.token?.coinDenom === "TIA" && tokenTwo?.token?.coinDenom === "WTIA"; const unwrapTia = tokenOne?.token?.coinDenom === "WTIA" && tokenTwo?.token?.coinDenom === "TIA"; - const result = useWaitForTransactionReceipt({ hash: txnHash }); + + const handleErrorMsgs = (error?: string, defaultMsg?: string) => { + if (error?.includes("rejected")) { + setTxnMsg("Transaction rejected"); + } else if (defaultMsg) { + setTxnMsg(defaultMsg); + } else { + setTxnMsg("Transaction error"); + } + }; useEffect(() => { if (result.data?.status === "success") { setTxnStatus(TXN_STATUS.SUCCESS); } else if (result.data?.status === "reverted") { setTxnStatus(TXN_STATUS.FAILED); + handleErrorMsgs("", "Transaction reverted"); } else if (result.data?.status === "error") { setTxnStatus(TXN_STATUS.FAILED); + handleErrorMsgs("", "Transaction failed"); } }, [result.data]); const handleWrap = async (type: "wrap" | "unwrap") => { - setTxnStatus(TXN_STATUS.PENDING); - if (!selectedChain?.chainId) { - // TODO - handle error better? - console.error("Chain ID is not defined. Cannot wrap/unwrap."); - return; - } const wtiaAddress = selectedChain.contracts?.wrappedCelestia?.address; - if (!wtiaAddress) { - // TODO - handle error better? - console.error("WTIA address is not defined. Cannot wrap/unwrap."); + if (!selectedChain?.chainId || !wtiaAddress) { return; } + setTxnStatus(TXN_STATUS.PENDING); const wrapService = createWrapService(wagmiConfig, wtiaAddress); + if (type === "wrap") { - const tx = await wrapService.deposit( - selectedChain.chainId, - tokenOne.value, - tokenOne.token?.coinDecimals || 18, - ); - setTxnHash(tx); - // TODO: Add loading state for these txns. This loading state will be displayed in the buttonText component. - // TODO: Also add text pointing the user to complete the txn in their wallet + try { + const tx = await wrapService.deposit( + selectedChain.chainId, + tokenOne.value, + tokenOne.token?.coinDecimals || 18, + ); + setTxnHash(tx); + } catch (error) { + const errorMessage = + (error as ErrorWithMessage).message || "Error unwrapping"; + setTxnStatus(TXN_STATUS.FAILED); + handleErrorMsgs(errorMessage); + } } else { - const tx = await wrapService.withdraw( - selectedChain.chainId, - tokenOne.value, - tokenOne.token?.coinDecimals || 18, - ); - setTxnHash(tx); + try { + const tx = await wrapService.withdraw( + selectedChain.chainId, + tokenOne.value, + tokenOne.token?.coinDecimals || 18, + ); + setTxnHash(tx); + } catch (error) { + const errorMessage = + (error as ErrorWithMessage).message || "Error unwrapping"; + setTxnStatus(TXN_STATUS.FAILED); + handleErrorMsgs(errorMessage); + } } }; @@ -105,49 +165,29 @@ export function useSwapButton({ if (!quote) { return null; } - return createTradeFromQuote(quote, quoteType); - }, [quote, quoteType]); + return createTradeFromQuote(quote, tradeType); + }, [quote, tradeType]); const handleSwap = useCallback(async () => { - if (!trade || !userAccount.address || !selectedChain?.chainId) { - return; - } - if (!walletClient || !walletClient.account) { - console.error("No wallet connected or account is undefined."); - return; - } - if (!publicClient) { - console.error("Public client is not configured."); + if ( + !trade || + !userAccount.address || + !selectedChain?.chainId || + !walletClient || + !walletClient.account || + !publicClient + ) { return; } setTxnStatus(TXN_STATUS.PENDING); - try { - const chainConfig: Chain = { - id: selectedChain?.chainId, - name: selectedChain?.chainName, - nativeCurrency: { - name: selectedChain?.currencies[0]?.title, // Assuming the first currency is the native one - symbol: selectedChain?.currencies[0]?.coinDenom, // Assuming the first currency is the native one - decimals: selectedChain?.currencies[0]?.coinDecimals || 18, // Assuming the first currency is the native one - }, - rpcUrls: { - default: { - http: selectedChain?.rpcUrls, // Assuming these are the RPC URLs - }, - }, - }; - const swapRouterAddress = selectedChain.contracts?.swapRouter?.address; if (!swapRouterAddress) { - console.error("Swap router address is not defined. Cannot swap."); + console.warn("Swap router address is not defined. Cannot swap."); return; } - - const router = new SwapRouter(swapRouterAddress, chainConfig); - - // Set up the swap options + const router = new SwapRouter(swapRouterAddress, evmChainConfig); const options = { recipient: userAccount.address, slippageTolerance: slippageTolerance, @@ -161,12 +201,15 @@ export function useSwapButton({ publicClient, ); setTxnHash(tx); - console.log("Transaction hash:", tx); } catch (error) { - console.error("Error executing swap:", error); + const errorMessage = + (error as ErrorWithMessage).message || "Error executing swap"; + setTxnStatus(TXN_STATUS.FAILED); + handleErrorMsgs(errorMessage); } }, [ trade, + evmChainConfig, userAccount, selectedChain, walletClient, @@ -174,25 +217,42 @@ export function useSwapButton({ slippageTolerance, ]); + const handleTokenApproval = async (token: EvmCurrency | null) => { + if (!token) { + return; + } + const txHash = await approveToken(token); + + if (txHash) { + setTxnHash(txHash); + } + return txHash; + }; + const validSwapInputs = !loading && tokenOne?.token && tokenTwo?.token && tokenOne?.value !== undefined && + tokenTwo?.value !== undefined && parseFloat(tokenOne?.value) > 0 && + parseFloat(tokenTwo?.value) > 0 && parseFloat(tokenOne?.value) <= parseFloat(tokenOneBalance); const onSubmitCallback = () => { - if (!userAccount.address) { - return connectEvmWallet(); - } else if (unwrapTia) { - return handleWrap("unwrap"); - } else if (wrapTia) { - return handleWrap("wrap"); - } else if (validSwapInputs) { - return handleSwap(); - } else { - return undefined; + switch (true) { + case !userAccount.address: + return connectEvmWallet(); + case tokenApprovalNeeded !== null: + return handleTokenApproval(tokenApprovalNeeded); + case unwrapTia: + return handleWrap("unwrap"); + case wrapTia: + return handleWrap("wrap"); + case validSwapInputs: + return handleSwap(); + default: + return undefined; } }; @@ -202,6 +262,8 @@ export function useSwapButton({ return "Connect Wallet"; case !tokenOne?.token || !tokenTwo?.token: return "Select a token"; + case tokenApprovalNeeded !== null: + return `Approve ${tokenApprovalNeeded?.coinDenom}`; case tokenOne?.value === undefined: return "Enter an amount"; case parseFloat(tokenOne?.value) === 0 || parseFloat(tokenOne?.value) < 0: @@ -239,18 +301,35 @@ export function useSwapButton({ } }; + const getTitleText = () => { + switch (true) { + case wrapTia: + return "Confirm Wrap"; + case unwrapTia: + return "Confirm Unwrap"; + case txnStatus === TXN_STATUS.FAILED: + return "Error"; + default: + return "Confirm Swap"; + } + }; + const isCloseModalAction = txnStatus === TXN_STATUS.SUCCESS || txnStatus === TXN_STATUS.FAILED || txnStatus === TXN_STATUS.PENDING; return { + txnHash, + titleText: getTitleText(), onSubmitCallback, buttonText: getButtonText(), actionButtonText: getActionButtonText(), validSwapInputs, txnStatus, setTxnStatus, + txnMsg, isCloseModalAction, + tokenApprovalNeeded: tokenApprovalNeeded !== null, }; } diff --git a/apps/flame-defi/app/swap/useUsdQuote.tsx b/apps/flame-defi/app/swap/useUsdQuote.tsx index 558ab99..929ace7 100644 --- a/apps/flame-defi/app/swap/useUsdQuote.tsx +++ b/apps/flame-defi/app/swap/useUsdQuote.tsx @@ -1,7 +1,8 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; +import debounce from "lodash.debounce"; import { useEvmChainData } from "config"; -import { TokenState } from "@repo/flame-types"; +import { EvmCurrency, TokenState } from "@repo/flame-types"; import { useGetQuote } from "./useGetQuote"; import { TRADE_TYPE } from "../constants"; @@ -12,15 +13,28 @@ function useUsdQuote(inputToken?: TokenState) { ); const { quote, loading, error, getQuote } = useGetQuote(); + const debouncedGetQuoteRef = useRef( + debounce( + ( + tradeType: TRADE_TYPE, + tokenData: { token: EvmCurrency; value: string }, + usdcToken: EvmCurrency, + ) => { + getQuote(tradeType, tokenData, { token: usdcToken, value: "" }); + }, + 500, + ), + ); + useEffect(() => { - if (inputToken && inputToken.value) { - getQuote( + if (inputToken && inputToken.token && inputToken.value && usdcToken) { + debouncedGetQuoteRef.current( TRADE_TYPE.EXACT_IN, { token: inputToken.token, value: inputToken.value }, - { token: usdcToken, value: "" }, + usdcToken, ); } - }, [inputToken, getQuote, usdcToken]); + }, [inputToken, usdcToken]); return { quote, loading, error }; } diff --git a/apps/flame-defi/app/utils/utils.ts b/apps/flame-defi/app/utils/utils.ts index 852612d..20fcdda 100644 --- a/apps/flame-defi/app/utils/utils.ts +++ b/apps/flame-defi/app/utils/utils.ts @@ -1,3 +1,4 @@ +import JSBI from "jsbi"; import { defaultSlippageTolerance } from "../constants"; export function getFromLocalStorage(item: string) { @@ -75,3 +76,18 @@ export function isDustAmount( return Math.abs(amountNumber) < threshold; } + +/** + * Parses a string value into a JSBI BigInt + * @param value - The string value to parse + * @param decimals - The number of decimal places in the value + * @returns The parsed JSBI BigInt + */ +export const parseToBigInt = (value: string, decimals: number): JSBI => { + const [integerPart, fractionalPart = ""] = value.split("."); + const fractionalPartPadded = fractionalPart + .padEnd(decimals, "0") + .slice(0, decimals); + const wholeNumberString = `${integerPart}${fractionalPartPadded}`; + return JSBI.BigInt(wholeNumberString); +}; diff --git a/apps/flame-defi/tailwind.config.js b/apps/flame-defi/tailwind.config.js index 3b24f34..60d1540 100644 --- a/apps/flame-defi/tailwind.config.js +++ b/apps/flame-defi/tailwind.config.js @@ -129,18 +129,18 @@ export default { height: "0", }, }, - }, - "success-tick": { - "0%": { "stroke-dashoffset": "16px", opacity: "1" }, - "100%": { "stroke-dashoffset": "31px", opacity: "1" }, - }, - "success-circle-outline": { - "0%": { "stroke-dashoffset": "72px", opacity: "1" }, - "100%": { "stroke-dashoffset": "0px", opacity: "1" }, - }, - "success-circle-fill": { - "0%": { opacity: "0" }, - "100%": { opacity: "1" }, + "success-tick": { + "0%": { "stroke-dashoffset": "16px", opacity: "1" }, + "100%": { "stroke-dashoffset": "31px", opacity: "1" }, + }, + "success-circle-outline": { + "0%": { "stroke-dashoffset": "72px", opacity: "1" }, + "100%": { "stroke-dashoffset": "0px", opacity: "1" }, + }, + "success-circle-fill": { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, }, animation: { "fade-in": "fade-in 0.3s ease-in-out", diff --git a/packages/flame-types/src/types.ts b/packages/flame-types/src/types.ts index 854fb9c..5ba7029 100644 --- a/packages/flame-types/src/types.ts +++ b/packages/flame-types/src/types.ts @@ -344,6 +344,15 @@ export enum ChainId { FLAME_TESTNET = 16604737732183, } +export interface OneToOneQuoteProps { + tokenOneSymbol: string | undefined; + tokenTwoSymbol: string | undefined; + tokenTwoValue: string | undefined; + oneToOneLoading: boolean; + flipDirection: boolean; + setFlipDirection: (flipDirection: boolean) => void; +} + export interface GetQuoteParams { // no cross chain swaps yet, so we only need to specify one chain right now, // which will be one of the Flame networks diff --git a/packages/ui/src/icons/DownArrowIcon.tsx b/packages/ui/src/icons/ErrorIcon.tsx similarity index 52% rename from packages/ui/src/icons/DownArrowIcon.tsx rename to packages/ui/src/icons/ErrorIcon.tsx index 806ca23..fd25248 100644 --- a/packages/ui/src/icons/DownArrowIcon.tsx +++ b/packages/ui/src/icons/ErrorIcon.tsx @@ -1,28 +1,25 @@ -import type { IconProps } from "@repo/flame-types"; +import React from "react"; -/** - * @deprecated Use `ArrowDownIcon` instead. - */ -export const DownArrowIcon: React.FC = ({ +export const ErrorIcon: React.FC<{ className?: string; size?: number }> = ({ className = "", size = 24, -}: IconProps) => { +}) => { return ( ); diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 6d4ab39..d546392 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -7,8 +7,8 @@ export * from "./ChevronDownIcon"; export * from "./ClipboardIcon"; export * from "./CloseIcon"; export * from "./CosmosIcon"; -export * from "./DownArrowIcon"; export * from "./EditIcon"; +export * from "./ErrorIcon"; export * from "./FlameIcon"; export * from "./GasIcon"; export * from "./GearIcon";