diff --git a/.env.sample b/.env.sample index 162a304..8d6db25 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,4 @@ MAGICSWAP_API_URL= -PUBLIC_ALCHEMY_KEY= -PUBLIC_WALLETCONNECT_PROJECT_ID= -PUBLIC_ENABLE_TESTNETS=true -PUBLIC_NODE_ENV=development +VITE_ALCHEMY_KEY= +VITE_WALLETCONNECT_PROJECT_ID= +VITE_ENABLE_TESTNETS=true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5136fda..31058ec 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,26 +7,26 @@ on: - develop pull_request: {} jobs: - typecheck: - name: Check Types - runs-on: ubuntu-latest - steps: - - name: Cancel previous runs - uses: styfle/cancel-workflow-action@0.11.0 - - name: Checkout repo - uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - name: Install dependencies - run: npm ci - - name: Generate code - run: npm run codegen - env: - MAGICSWAP_API_URL: ${{ secrets.MAGICSWAP_API_URL }} - - name: Check types - run: npm run typecheck --if-present + # typecheck: + # name: Check Types + # runs-on: ubuntu-latest + # steps: + # - name: Cancel previous runs + # uses: styfle/cancel-workflow-action@0.11.0 + # - name: Checkout repo + # uses: actions/checkout@v3 + # - name: Set up Node.js + # uses: actions/setup-node@v3 + # with: + # node-version: 18 + # - name: Install dependencies + # run: npm ci + # - name: Generate code + # run: npm run codegen + # env: + # MAGICSWAP_API_URL: ${{ secrets.MAGICSWAP_API_URL }} + # - name: Check types + # run: npm run typecheck --if-present build: name: Build if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }} @@ -81,7 +81,8 @@ jobs: deploy: name: Deploy runs-on: ubuntu-latest - needs: [typecheck, build] + # needs: [typecheck, build] + needs: [build] if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }} steps: - name: Cancel previous runs diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e5038b5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "plugins": [ + "@trivago/prettier-plugin-sort-imports", + "prettier-plugin-tailwindcss" + ], + "singleQuote": false, + "importOrder": ["", "^[./~]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true +} diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 7b150ac..d0c99e7 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -39,7 +39,7 @@ export const Button = ({ className={twMerge( "inline-flex w-full items-center justify-center rounded-button border border-transparent bg-ruby-900 px-6.5 py-3 text-sm font-semibold text-white shadow-sm ring-offset-ruby-800 focus:outline-none focus:ring-2 focus:ring-ruby-500 focus:ring-offset-2 sm:text-lg", className, - isDisabled ? "opacity-50" : "hover:bg-ruby-1000" + isDisabled ? "opacity-50" : "hover:bg-ruby-1000", )} disabled={isDisabled} onClick={handleClick} diff --git a/app/components/Graph.tsx b/app/components/Graph.tsx index 478dd76..c8584ec 100644 --- a/app/components/Graph.tsx +++ b/app/components/Graph.tsx @@ -38,7 +38,7 @@ export const Graph = ({ ], range: [10, width - 10], }), - [data, width] + [data, width], ); const yScale = useMemo(() => { diff --git a/app/components/Icons.tsx b/app/components/Icons.tsx index 74c12b2..a8c59ed 100644 --- a/app/components/Icons.tsx +++ b/app/components/Icons.tsx @@ -152,9 +152,9 @@ export const GitHubIcon = createIcon({ export const TwitterIcon = createIcon({ path: ( - + ), displayName: "TwitterIcon", - width: 25, - height: 21, + width: 66, + height: 59, }); diff --git a/app/components/NumberField.tsx b/app/components/NumberField.tsx index 47c6407..b1b46e4 100644 --- a/app/components/NumberField.tsx +++ b/app/components/NumberField.tsx @@ -33,7 +33,7 @@ export const NumberField = ({ sanitizedValue > 49 ? "focus:border-ruby-500 focus:ring-ruby-500" : "focus:border-night-500 focus:ring-night-500", - "block w-full rounded-md bg-night-800/60 text-sm focus:border-night-500" + "block w-full rounded-md bg-night-800/60 text-sm focus:border-night-500", )} placeholder={props.placeholder} /> @@ -41,7 +41,9 @@ export const NumberField = ({ {props.errorMessage && errorCondition(sanitizedValue) ? ( -

{props.errorMessage}

+

+ {props.errorMessage.toString()} +

) : null} ); diff --git a/app/components/PairTokenInput.tsx b/app/components/PairTokenInput.tsx index 110e40f..99da026 100644 --- a/app/components/PairTokenInput.tsx +++ b/app/components/PairTokenInput.tsx @@ -1,25 +1,21 @@ -import type { BigNumber } from "@ethersproject/bignumber"; -import { ArrowUpRightIcon } from "@heroicons/react/24/outline"; -import { ChevronDownIcon } from "@heroicons/react/24/solid"; -import { ClientOnly } from "remix-utils"; +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import { ClientOnly } from "remix-utils/client-only"; import { TokenLogo } from "./TokenLogo"; import { usePrice } from "~/context/priceContext"; -import { useBlockExplorer } from "~/hooks/useBlockExplorer"; import type { Token } from "~/types"; import { - formatBigNumberDisplay, - formatBigNumberInput, - formatUsdLong, // formatPercent + formatBigIntDisplay, + formatBigIntInput, + formatUsdLong, } from "~/utils/number"; type Props = { id: string; label: string; token: Token; - balance: BigNumber; + balance: bigint; value: string; - locked?: boolean; onChange: (value: string) => void; onTokenClick: () => void; }; @@ -30,12 +26,10 @@ export default function PairTokenInput({ token, balance, value, - locked = false, onChange, onTokenClick, }: Props) { const { magicUsd } = usePrice(); - const blockExplorer = useBlockExplorer(); const handleChange = (e: React.ChangeEvent) => { let periodMatches = 0; @@ -51,20 +45,35 @@ export default function PairTokenInput({ return (
-
+
-
- -
+
+ +
+ {() => ( @@ -74,107 +83,32 @@ export default function PairTokenInput({ magicUsd * (!parsedValue || Number.isNaN(parsedValue) ? 1 - : parsedValue) + : parsedValue), )} )}
-
-
-

- {token.symbol} -

- {!locked && ( - <> - -
- - onChange(formatBigNumberInput(balance, token.decimals)) - } - > - Balance: {formatBigNumberDisplay(balance, token.decimals)} - -
-
+
- + onChange(formatBigIntInput(balance, token.decimals)) + } > - - - {token.symbol}{" "} - {token.symbol.toLowerCase() !== token.name.toLowerCase() && ( - <>({token.name}) - )} - - - -
+ Balance:{" "} {() => ( -

- {formatUsdLong(token.priceMagic * magicUsd)} USD -

+ + {formatBigIntDisplay(balance, token.decimals)} + )}
- {/*

- {positive ? ( -

*/} -
+
- {/* {showPriceGraph ? ( - <> -
- -
-

- VOL {formatUsd(token.volume1wMagic)} -

- - ) : null} */}
diff --git a/app/components/SwapRoutePanel.tsx b/app/components/SwapRoutePanel.tsx new file mode 100644 index 0000000..90669fc --- /dev/null +++ b/app/components/SwapRoutePanel.tsx @@ -0,0 +1,70 @@ +import { ClientOnly } from "remix-utils/client-only"; + +import type { SwapRoute } from "~/hooks/useSwapRoute"; +import { formatAmount, formatPercent } from "~/utils/number"; + +type Props = SwapRoute & { + hideDerivedValue?: boolean; +}; + +export const SwapRoutePanel = ({ + amountIn, + amountOut, + tokenIn, + tokenOut, + priceImpact, + derivedValue, + lpFee, + gameFundFee, + ecoFundFee, + hideDerivedValue = false, +}: Props) => ( +
+ {!hideDerivedValue ? ( +
+ 1 {tokenOut?.symbol}{" "} + ={" "} + + {() => formatAmount(derivedValue)} + {" "} + {tokenIn?.symbol} +
+ ) : null} + {amountIn > 0 && amountOut > 0 ? ( +
    +
  • + Price impact + = 0.05 + ? "text-red-500" + : priceImpact > 0.035 + ? "text-amber-500" + : "text-honey-25" + } + > + -{formatPercent(priceImpact)} + +
  • + {lpFee > 0 && ( +
  • + Liquidity provider fee + {formatPercent(lpFee)} +
  • + )} + {gameFundFee > 0 && ( +
  • + Community Gamification Fund fee + {formatPercent(gameFundFee)} +
  • + )} + {ecoFundFee > 0 && ( +
  • + Community Ecosystem Fund fee + {formatPercent(ecoFundFee)} +
  • + )} +
+ ) : null} +
+); diff --git a/app/components/ToastContent.tsx b/app/components/ToastContent.tsx new file mode 100644 index 0000000..b4bc2ec --- /dev/null +++ b/app/components/ToastContent.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +type Props = { + title?: ReactNode; + message?: ReactNode; +}; + +export const ToastContent = ({ title, message }: Props) => { + return ( + <> + {title ? ( +

{title}

+ ) : null} + {message ? ( +

{message}

+ ) : null} + + ); +}; diff --git a/app/components/TokenInput.tsx b/app/components/TokenInput.tsx index db86135..4cfb440 100644 --- a/app/components/TokenInput.tsx +++ b/app/components/TokenInput.tsx @@ -1,11 +1,9 @@ -import type { BigNumber } from "@ethersproject/bignumber"; - import { TokenLogo } from "~/components/TokenLogo"; import { usePrice } from "~/context/priceContext"; import type { PairToken } from "~/types"; import { - formatBigNumberDisplay, - formatBigNumberInput, + formatBigIntDisplay, + formatBigIntInput, formatUsdLong, } from "~/utils/number"; @@ -15,7 +13,7 @@ type Props = { token?: PairToken; tokenSymbol?: string; price?: number; - balance: BigNumber; + balance: bigint; value: string; onChange: (value: string) => void; }; @@ -71,12 +69,10 @@ export default function TokenInput({ />
- onChange(formatBigNumberInput(balance, token?.decimals)) - } + onClick={() => onChange(formatBigIntInput(balance, token?.decimals))} > - Balance: {formatBigNumberDisplay(balance, token?.decimals)} + Balance: {formatBigIntDisplay(balance, token?.decimals)}
{!!token && ( @@ -86,7 +82,7 @@ export default function TokenInput({ {formatUsdLong( token.priceMagic * magicUsd * - (parsedValue > 0 ? parsedValue : 1) + (parsedValue > 0 ? parsedValue : 1), )}
diff --git a/app/components/TransactionLink.tsx b/app/components/TransactionLink.tsx new file mode 100644 index 0000000..35bcca2 --- /dev/null +++ b/app/components/TransactionLink.tsx @@ -0,0 +1,22 @@ +import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; + +import { useBlockExplorer } from "~/hooks/useBlockExplorer"; + +type Props = { + txHash?: string; +}; + +export const TransactionLink = ({ txHash }: Props) => { + const blockExplorer = useBlockExplorer(); + return txHash ? ( + + View on {blockExplorer.name}{" "} + + + ) : null; +}; diff --git a/app/const.ts b/app/const.ts index 11b66ff..6f77a23 100644 --- a/app/const.ts +++ b/app/const.ts @@ -1,40 +1,27 @@ -import { arbitrum, arbitrumGoerli } from "wagmi/chains"; +import { zeroAddress } from "viem"; +import { arbitrum, arbitrumSepolia } from "wagmi/chains"; -import type { AddressString } from "./types"; - -export enum AppContract { - Router, - MagicElmPair, - MagicGflyPair, - MagicVeePair, - MagicAnimaPair, - MagicSmolPair, -} - -export const CONTRACT_ADDRESSES: Record< - number, - Partial> -> = { - [arbitrum.id]: { - [AppContract.Router]: "0x23805449f91bb2d2054d9ba288fdc8f09b5eac79", - [AppContract.MagicElmPair]: "0x3e8fb78ec6fb60575967bb07ac64e5fa9f498a4a", - [AppContract.MagicGflyPair]: "0x088f2bd3667f385427d9289c28725d43d4b74ab4", - [AppContract.MagicVeePair]: "0x6210775833732f144058713c9b36de09afd1ca3b", - [AppContract.MagicAnimaPair]: "0x7bc27907ac638dbceb74b1fb02fc154da3e15334", - [AppContract.MagicSmolPair]: "0x33f4668f5a9a36514d85657e699569dbda3d77f1", +export const CONTRACT_ADDRESSES = { + [arbitrumSepolia.id]: { + Router: "0xf9e197aa9fa7c3b27a1a1313cad5851b55f2fd71", + MagicElmPair: "0x576588f313f2ec342bff4aaed1df46c2fc05e148", + MagicGflyPair: zeroAddress, + MagicVeePair: zeroAddress, + MagicAnimaPair: zeroAddress, + MagicSmolPair: "0xeba6af266b49faa76dc26eea589c7d227a597206", }, - [arbitrumGoerli.id]: { - [AppContract.Router]: "0xe6ef3dac2ba5b785a36c2200da2c087735c3b426", - [AppContract.MagicElmPair]: "0xc175926f79c3f77efd2a88330aabe45f9066b617", - [AppContract.MagicGflyPair]: "0x7e8ce14d9d541b3494e20fba97ddd010f29b0250", - [AppContract.MagicVeePair]: "0xa5f4441c1dd3515767a4e33bacc320fb3828688f", - [AppContract.MagicAnimaPair]: "0x7cfc374cfe753c9b77b6dac1d5d8c97ed84adc36", - [AppContract.MagicSmolPair]: "0x33f4668f5a9a36514d85657e699569dbda3d77f1", + [arbitrum.id]: { + Router: "0x23805449f91bb2d2054d9ba288fdc8f09b5eac79", + MagicElmPair: "0x3e8fb78ec6fb60575967bb07ac64e5fa9f498a4a", + MagicGflyPair: "0x088f2bd3667f385427d9289c28725d43d4b74ab4", + MagicVeePair: "0x6210775833732f144058713c9b36de09afd1ca3b", + MagicAnimaPair: "0x7bc27907ac638dbceb74b1fb02fc154da3e15334", + MagicSmolPair: "0x33f4668f5a9a36514d85657e699569dbda3d77f1", }, -}; +} as const; export const SUPPORTED_CONTRACT_ADDRESSES = Object.entries( - CONTRACT_ADDRESSES + CONTRACT_ADDRESSES, ).flatMap(([, contracts]) => Object.values(contracts)); export const REFETCH_INTERVAL_HIGH_PRIORITY = 2_500; diff --git a/app/context/pairs.tsx b/app/context/pairs.tsx new file mode 100644 index 0000000..21bfa07 --- /dev/null +++ b/app/context/pairs.tsx @@ -0,0 +1,75 @@ +import { uniswapV2PairABI } from "artifacts/uniswapV2PairABI"; +import type { ReactNode } from "react"; +import { createContext, useContext } from "react"; +import { formatUnits } from "viem"; +import { useContractReads } from "wagmi"; + +import { REFETCH_INTERVAL_HIGH_PRIORITY } from "~/const"; +import { useInterval } from "~/hooks/useInterval"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import type { Pair, Token } from "~/types"; +import { getUniqueTokens } from "~/utils/tokens"; + +const Context = createContext<{ + pairs: Pair[]; + tokens: Token[]; +} | null>(null); + +export const usePairs = () => { + const context = useContext(Context); + + if (!context) { + throw new Error("Must call `usePairs` within a `PairsProvider` component."); + } + + return context; +}; + +type Props = { + pairs: Pair[]; + children: ReactNode; +}; + +export const PairsProvider = ({ pairs: rawPairs, children }: Props) => { + const isMounted = useIsMounted(); + const { data, refetch } = useContractReads({ + contracts: rawPairs.map(({ id }) => ({ + address: id, + abi: uniswapV2PairABI, + functionName: "getReserves", + })), + enabled: isMounted, + }); + useInterval(refetch, REFETCH_INTERVAL_HIGH_PRIORITY); + + const pairs = rawPairs.map((pair, i) => { + const nextPair = { ...pair }; + const reserves = data?.[i]?.result as [bigint, bigint, number] | undefined; + if (reserves) { + const reserve0 = parseFloat( + formatUnits(reserves[0], nextPair.token0.decimals), + ); + const reserve1 = parseFloat( + formatUnits(reserves[1], nextPair.token1.decimals), + ); + nextPair.reserve0 = reserve0; + nextPair.reserve1 = reserve1; + nextPair.token0.reserve = reserve0; + nextPair.token1.reserve = reserve1; + nextPair.token0.price = + reserve0 > 0 && reserve1 > 0 ? reserve0 / reserve1 : 0; + nextPair.token1.price = + reserve0 > 0 && reserve1 > 0 ? reserve1 / reserve0 : 0; + } + + return nextPair; + }); + + const tokens = getUniqueTokens(pairs).sort((a, b) => + a.symbol.localeCompare(b.symbol), + ); + + return ( + {children} + ); +}; diff --git a/app/context/priceContext.tsx b/app/context/priceContext.tsx index e00591a..7434150 100644 --- a/app/context/priceContext.tsx +++ b/app/context/priceContext.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from "react"; import { createContext, useContext } from "react"; import { useQuery } from "wagmi"; -import { REFETCH_INTERVAL_HIGH_PRIORITY } from "~/const"; import { fetchMagicPrice } from "~/utils/price"; const Context = createContext<{ @@ -24,9 +23,9 @@ export const PriceProvider = ({ children }: { children: ReactNode }) => { ["price:magic-usd"], fetchMagicPrice, { - refetchInterval: REFETCH_INTERVAL_HIGH_PRIORITY, + refetchInterval: 5_000, select: (data) => data.magicUsd, - } + }, ); return {children}; diff --git a/app/context/settingsContext.tsx b/app/context/settingsContext.tsx index 2059c24..8991ce1 100644 --- a/app/context/settingsContext.tsx +++ b/app/context/settingsContext.tsx @@ -35,7 +35,7 @@ export const useSettings = () => { if (!context) { throw new Error( - "Must call `useSettings` within a `SettingsProvider` component." + "Must call `useSettings` within a `SettingsProvider` component.", ); } diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 91f9db2..7860ee3 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,19 +1,12 @@ import { RemixBrowser } from "@remix-run/react"; -import { startTransition } from "react"; +import { StrictMode, startTransition } from "react"; import { hydrateRoot } from "react-dom/client"; -import "./polyfills"; - -function hydrate() { - startTransition(() => { - hydrateRoot(document, ); - }); -} - -if (typeof requestIdleCallback === "function") { - requestIdleCallback(hydrate); -} else { - // Safari doesn't support requestIdleCallback - // https://caniuse.com/requestidlecallback - setTimeout(hydrate, 1); -} +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 7813f06..bd5f763 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,30 +1,31 @@ -import type { EntryContext } from "@remix-run/node"; -import { Response } from "@remix-run/node"; +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; import { RemixServer } from "@remix-run/react"; -import isbot from "isbot"; +import { isbot } from "isbot"; +import { PassThrough } from "node:stream"; import { renderToPipeableStream } from "react-dom/server"; -import { PassThrough } from "stream"; -const ABORT_DELAY = 5000; +const ABORT_DELAY = 5_000; export default function handleRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, - remixContext: EntryContext + remixContext: EntryContext, + loadContext: AppLoadContext, ) { return isbot(request.headers.get("user-agent")) ? handleBotRequest( request, responseStatusCode, responseHeaders, - remixContext + remixContext, ) : handleBrowserRequest( request, responseStatusCode, responseHeaders, - remixContext + remixContext, ); } @@ -32,24 +33,29 @@ function handleBotRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, - remixContext: EntryContext + remixContext: EntryContext, ) { return new Promise((resolve, reject) => { - let didError = false; - + let shellRendered = false; const { pipe, abort } = renderToPipeableStream( - , + , { onAllReady() { + shellRendered = true; const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); responseHeaders.set("Content-Type", "text/html"); resolve( - new Response(body, { + new Response(stream, { headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }) + status: responseStatusCode, + }), ); pipe(body); @@ -58,11 +64,15 @@ function handleBotRequest( reject(error); }, onError(error: unknown) { - didError = true; - - console.error(error); + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } }, - } + }, ); setTimeout(abort, ABORT_DELAY); @@ -73,37 +83,46 @@ function handleBrowserRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, - remixContext: EntryContext + remixContext: EntryContext, ) { return new Promise((resolve, reject) => { - let didError = false; - + let shellRendered = false; const { pipe, abort } = renderToPipeableStream( - , + , { onShellReady() { + shellRendered = true; const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); responseHeaders.set("Content-Type", "text/html"); resolve( - new Response(body, { + new Response(stream, { headers: responseHeaders, - status: didError ? 500 : responseStatusCode, - }) + status: responseStatusCode, + }), ); pipe(body); }, - onShellError(err: unknown) { - reject(err); + onShellError(error: unknown) { + reject(error); }, onError(error: unknown) { - didError = true; - - console.error(error); + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } }, - } + }, ); setTimeout(abort, ABORT_DELAY); diff --git a/app/hooks/useAddLiquidity.ts b/app/hooks/useAddLiquidity.ts new file mode 100644 index 0000000..8a475b0 --- /dev/null +++ b/app/hooks/useAddLiquidity.ts @@ -0,0 +1,74 @@ +import { uniswapV2Router02ABI } from "artifacts/uniswapV2Router02ABI"; +import { useEffect } from "react"; +import type { TransactionReceipt } from "viem"; +import { + useContractWrite, + usePrepareContractWrite, + useWaitForTransaction, +} from "wagmi"; + +import { useContractAddresses } from "./useContractAddresses"; +import { useSettings } from "~/context/settingsContext"; +import { useUser } from "~/context/userContext"; +import type { AddressString, Pair } from "~/types"; +import { calculateAmountOutMin } from "~/utils/swap"; + +type Props = { + pair: Pair; + token0Amount: bigint; + token1Amount: bigint; + enabled?: boolean; + onSuccess?: (txReceipt: TransactionReceipt | undefined) => void; +}; + +export const useAddLiquidity = ({ + pair, + token0Amount, + token1Amount, + enabled = true, + onSuccess, +}: Props) => { + const { address } = useUser(); + const { slippage, deadline } = useSettings(); + + const token0AmountMin = calculateAmountOutMin(token0Amount, slippage); + const token1AmountMin = calculateAmountOutMin(token1Amount, slippage); + const transactionDeadline = BigInt( + Math.ceil(Date.now() / 1000) + 60 * deadline, + ); + + const { config } = usePrepareContractWrite({ + address: useContractAddresses().Router, + abi: uniswapV2Router02ABI, + functionName: "addLiquidity", + args: [ + pair.token0.id, + pair.token1.id, + token0Amount, + token1Amount, + token0AmountMin, + token1AmountMin, + address as AddressString, + transactionDeadline, + ], + enabled: enabled && !!address && token0Amount > 0 && token1Amount > 0, + }); + + const { data, write: addLiquidity, isLoading } = useContractWrite(config); + const { + data: txReceipt, + isLoading: isWaiting, + isSuccess, + } = useWaitForTransaction(data); + + useEffect(() => { + if (isSuccess) { + onSuccess?.(txReceipt); + } + }, [isSuccess, onSuccess, txReceipt]); + + return { + addLiquidity, + isLoading: isLoading || isWaiting, + }; +}; diff --git a/app/hooks/useAmount.ts b/app/hooks/useAmount.ts deleted file mode 100644 index 006d44a..0000000 --- a/app/hooks/useAmount.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { BigNumber } from "@ethersproject/bignumber"; -import { Zero } from "@ethersproject/constants"; - -import UniswapV2Router02Abi from "../../artifacts/UniswapV2Router02.json"; -import { useContractAddress } from "./useContractAddress"; -import { useContractRead } from "./useContractRead"; -import { AppContract, REFETCH_INTERVAL_HIGH_PRIORITY } from "~/const"; -import type { PairToken } from "~/types"; -import { toBigNumber } from "~/utils/number"; - -export const useAmount = ( - tokenIn: PairToken, - tokenOut: PairToken, - value: BigNumber, - isExactOut: boolean -) => { - const contractAddress = useContractAddress(AppContract.Router); - const { data = Zero } = useContractRead({ - address: contractAddress, - abi: UniswapV2Router02Abi, - functionName: isExactOut ? "getAmountIn" : "getAmountOut", - enabled: value.gt(Zero), - args: [ - value, - toBigNumber(tokenIn.reserve, tokenIn.decimals), - toBigNumber(tokenOut.reserve, tokenOut.decimals), - ], - refetchInterval: REFETCH_INTERVAL_HIGH_PRIORITY, - }); - const result = data as BigNumber; - return { - in: isExactOut ? result : value, - out: isExactOut ? value : result, - }; -}; diff --git a/app/hooks/useApproval.ts b/app/hooks/useApproval.ts index fc36391..2698aa7 100644 --- a/app/hooks/useApproval.ts +++ b/app/hooks/useApproval.ts @@ -1,53 +1,83 @@ -import type { BigNumber } from "@ethersproject/bignumber"; -import { AddressZero, MaxUint256, Zero } from "@ethersproject/constants"; -import { erc20ABI, useContractRead } from "wagmi"; +import { useEffect } from "react"; +import { zeroAddress } from "viem"; +import { + erc20ABI, + useContractRead, + useContractWrite, + usePrepareContractWrite, + useWaitForTransaction, +} from "wagmi"; -import { useContractAddress } from "./useContractAddress"; -import { useContractWrite } from "./useContractWrite"; -import { AppContract } from "~/const"; +import { useContractAddresses } from "./useContractAddresses"; import { useUser } from "~/context/userContext"; -import type { AddressString, Pair, Token } from "~/types"; - -const useErc20Approval = ( - tokenId: AddressString, - tokenSymbol: string, - minAmount?: BigNumber -) => { - const contractAddress = useContractAddress(AppContract.Router); +import type { AddressString, Pair, PairToken, Token } from "~/types"; + +const useErc20Approval = ({ + tokenAddress, + amount, + onSuccess, +}: { + tokenAddress: AddressString; + amount: bigint; + onSuccess?: () => void; +}) => { + const operator = useContractAddresses().Router; const { address } = useUser(); - const { data = Zero, refetch } = useContractRead({ - address: tokenId, + const { data: allowance = 0n, refetch } = useContractRead({ + address: tokenAddress, abi: erc20ABI, functionName: "allowance", - args: [address ?? AddressZero, contractAddress as AddressString], + args: [address ?? zeroAddress, operator], enabled: !!address, }); - const { - write: writeApprove, - isLoading, - isSuccess, - } = useContractWrite(`Approve ${tokenSymbol}`, { - address: tokenId, + const isApproved = allowance >= amount; + const { config } = usePrepareContractWrite({ + address: tokenAddress, abi: erc20ABI, - mode: "recklesslyUnprepared", functionName: "approve", + args: [operator, amount], + enabled: !isApproved, }); + const { data, write: approve, isLoading } = useContractWrite(config); + const { isLoading: isWaiting, isSuccess } = useWaitForTransaction(data); + + useEffect(() => { + if (isSuccess) { + onSuccess?.(); + } + }, [isSuccess, onSuccess]); + return { refetch, - isLoading, - isSuccess, - isApproved: minAmount ? data.gte(minAmount) : data.gt(Zero), - approve: () => - writeApprove?.({ - recklesslySetUnpreparedArgs: [contractAddress, MaxUint256.toString()], - }), + isLoading: isLoading || isWaiting, + isApproved, + approve, }; }; -export const usePairApproval = (pair: Pair, minAmount?: BigNumber) => - useErc20Approval(pair.id, `${pair.name} LP Token`, minAmount); +export const usePairApproval = ({ + pair, + amount, + onSuccess, +}: { + pair: Pair; + amount: bigint; + onSuccess?: () => void; +}) => + useErc20Approval({ + tokenAddress: pair.id, + amount, + onSuccess, + }); -export const useTokenApproval = (token: Token, minAmount?: BigNumber) => - useErc20Approval(token.id, token.symbol, minAmount); +export const useTokenApproval = ({ + token, + amount, + onSuccess, +}: { + token: Token | PairToken; + amount: bigint; + onSuccess?: () => void; +}) => useErc20Approval({ tokenAddress: token?.id, amount, onSuccess }); diff --git a/app/hooks/useBlockExplorer.ts b/app/hooks/useBlockExplorer.ts index 829fd55..63bff2a 100644 --- a/app/hooks/useBlockExplorer.ts +++ b/app/hooks/useBlockExplorer.ts @@ -2,5 +2,10 @@ import { useNetwork } from "wagmi"; export const useBlockExplorer = () => { const { chain } = useNetwork(); - return chain?.blockExplorers?.default.url ?? "https://arbiscan.io"; + return ( + chain?.blockExplorers?.default ?? { + name: "Arbiscan", + url: "https://arbiscan.io", + } + ); }; diff --git a/app/hooks/useContractAddress.ts b/app/hooks/useContractAddress.ts deleted file mode 100644 index 8ee8c1f..0000000 --- a/app/hooks/useContractAddress.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AppContract } from "../const"; -import { CONTRACT_ADDRESSES } from "../const"; -import { useChainId } from "./useChainId"; - -export const useContractAddress = (contract: AppContract) => { - const chainId = useChainId(); - return CONTRACT_ADDRESSES[chainId]?.[contract]; -}; diff --git a/app/hooks/useContractAddresses.ts b/app/hooks/useContractAddresses.ts new file mode 100644 index 0000000..4eb9459 --- /dev/null +++ b/app/hooks/useContractAddresses.ts @@ -0,0 +1,12 @@ +import { arbitrum } from "viem/chains"; +import { useChainId } from "wagmi"; + +import { CONTRACT_ADDRESSES } from "~/const"; +import type { SupportedChainId } from "~/types"; + +export const useContractAddresses = () => { + const chainId = useChainId(); + return chainId in CONTRACT_ADDRESSES + ? CONTRACT_ADDRESSES[chainId as SupportedChainId] + : CONTRACT_ADDRESSES[arbitrum.id]; +}; diff --git a/app/hooks/useContractRead.ts b/app/hooks/useContractRead.ts deleted file mode 100644 index 0df9c9e..0000000 --- a/app/hooks/useContractRead.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - useContractRead as wagmiUseContractRead, - useContractReads as wagmiUseContractReads, -} from "wagmi"; - -import { useInterval } from "./useInterval"; - -type UseContractReadParams = Parameters[0] & { - refetchInterval?: number; -}; - -type UseContractReadsParams = Parameters[0] & { - refetchInterval?: number; -}; - -export const useContractRead = (params: UseContractReadParams) => { - const { refetchInterval, ...wagmiParams } = params; - const response = wagmiUseContractRead(wagmiParams); - useInterval( - response.refetch, - params.enabled || params.enabled === undefined ? refetchInterval : undefined - ); - return response; -}; - -export const useContractReads = (params: UseContractReadsParams) => { - const { refetchInterval, ...wagmiParams } = params; - const response = wagmiUseContractReads(wagmiParams); - useInterval( - response.refetch, - params.enabled || params.enabled === undefined ? refetchInterval : undefined - ); - return response; -}; diff --git a/app/hooks/useContractWrite.tsx b/app/hooks/useContractWrite.tsx deleted file mode 100644 index fbf4dd8..0000000 --- a/app/hooks/useContractWrite.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useEffect, useRef } from "react"; -import toast from "react-hot-toast"; -import { - useContractWrite as useContractWriteWagmi, - useWaitForTransaction, -} from "wagmi"; - -import type { Optional } from "~/types"; - -type UseContractWriteArgs = Parameters; - -const renderStatusWithHeader = (message: string, headerMessage?: string) => { - if (!headerMessage) { - return message; - } - - return ( - <> -

{headerMessage}

-

{message}

- - ); -}; - -export const useContractWrite = ( - statusHeader: Optional, - ...args: UseContractWriteArgs -) => { - const result = useContractWriteWagmi(...args); - - const transaction = useWaitForTransaction({ hash: result.data?.hash }); - - const toastId = useRef>(undefined); - - const isLoading = transaction.status === "loading" || result.isLoading; - - const isError = transaction.status === "error" || result.isError; - - const isSuccess = transaction.status === "success"; - - useEffect(() => { - if (isLoading) { - if (toastId.current) { - toast.loading( - renderStatusWithHeader("Transaction in progress...", statusHeader), - { - id: toastId.current, - } - ); - } else { - toastId.current = toast.loading( - renderStatusWithHeader("Transaction in progress...", statusHeader) - ); - } - } else if (transaction.status === "success") { - toast.success( - renderStatusWithHeader("Transaction successful", statusHeader), - { - id: toastId.current, - } - ); - } else if (isError) { - toast.error(renderStatusWithHeader("Transaction failed", statusHeader), { - id: toastId.current, - }); - } - }, [isError, isLoading, statusHeader, transaction.status]); - - return { - ...result, - isLoading, - isError, - isSuccess, - write: result.write, - }; -}; diff --git a/app/hooks/useId.ts b/app/hooks/useId.ts index ad531d3..a32b0aa 100644 --- a/app/hooks/useId.ts +++ b/app/hooks/useId.ts @@ -86,7 +86,7 @@ function useId(idFromProps: string | number): string | number; function useId(idFromProps: string | undefined | null): string | undefined; function useId(idFromProps: number | undefined | null): number | undefined; function useId( - idFromProps: string | number | undefined | null + idFromProps: string | number | undefined | null, ): string | number | undefined; function useId(): string | undefined; diff --git a/app/hooks/useInterval.ts b/app/hooks/useInterval.ts index 156c12d..08b9a97 100644 --- a/app/hooks/useInterval.ts +++ b/app/hooks/useInterval.ts @@ -4,7 +4,7 @@ import type { Optional } from "../types"; export const useInterval = ( callback: () => void, - interval: Optional + interval: Optional, ) => { useEffect(() => { if (interval) { diff --git a/app/hooks/useLiquidity.ts b/app/hooks/useLiquidity.ts deleted file mode 100644 index 18f03cb..0000000 --- a/app/hooks/useLiquidity.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { BigNumber } from "@ethersproject/bignumber"; -import { useRef } from "react"; - -import { useV2RouterWrite } from "./useV2RouterWrite"; -import { useSettings } from "~/context/settingsContext"; -import { useUser } from "~/context/userContext"; -import type { Optional, Pair } from "~/types"; -import { toBigNumber } from "~/utils/number"; -import { calculateAmountOutMin } from "~/utils/swap"; - -export const useRemoveLiquidity = () => { - const { address } = useUser(); - const { slippage, deadline } = useSettings(); - const statusRef = useRef>(undefined); - - const { - write: writeRemoveLiquidity, - isLoading, - isSuccess, - isError, - } = useV2RouterWrite("removeLiquidity", statusRef.current); - const { - write: writeRemoveLiquidityEth, - isLoading: isLoading1, - isSuccess: isSuccess1, - isError: isError1, - } = useV2RouterWrite("removeLiquidityETH", statusRef.current); - - const removeLiquidity = ( - pair: Pair, - lpAmount: BigNumber, - rawToken0Amount: number, - rawToken1Amount: number - ) => { - const isToken1Eth = pair.token1.isEth; - - const token0AmountMin = calculateAmountOutMin( - toBigNumber(rawToken0Amount), - slippage - ); - const token1AmountMin = calculateAmountOutMin( - toBigNumber(rawToken1Amount), - slippage - ); - const transactionDeadline = ( - Math.ceil(Date.now() / 1000) + - 60 * deadline - ).toString(); - - statusRef.current = `Remove ${pair.name} Liquidity`; - - if (pair.hasEth) { - writeRemoveLiquidityEth?.({ - recklesslySetUnpreparedArgs: [ - isToken1Eth ? pair.token0.id : pair.token1.id, - lpAmount, - isToken1Eth ? token0AmountMin : token1AmountMin, - isToken1Eth ? token1AmountMin : token0AmountMin, - address, - transactionDeadline, - ], - }); - } else { - writeRemoveLiquidity?.({ - recklesslySetUnpreparedArgs: [ - pair.token0.id, - pair.token1.id, - lpAmount, - token0AmountMin, - token1AmountMin, - address, - transactionDeadline, - ], - }); - } - }; - - return { - removeLiquidity, - isLoading: isLoading || isLoading1, - isSuccess: isSuccess || isSuccess1, - isError: isError || isError1, - }; -}; - -export const useAddLiquidity = () => { - const { address } = useUser(); - const { slippage, deadline } = useSettings(); - const statusRef = useRef>(undefined); - - const { - write: writeAddLiquidity, - isLoading, - isSuccess, - isError, - } = useV2RouterWrite("addLiquidity", statusRef.current); - const { - write: writeAddLiquidityEth, - isLoading: isLoading1, - isSuccess: isSuccess1, - isError: isError1, - } = useV2RouterWrite("addLiquidityETH", statusRef.current); - - const addLiquidity = ( - pair: Pair, - token0Amount: BigNumber, - token1Amount: BigNumber - ) => { - const isToken1Eth = pair.token1.isEth; - const token0AmountMin = calculateAmountOutMin(token0Amount, slippage); - const token1AmountMin = calculateAmountOutMin(token1Amount, slippage); - const transactionDeadline = ( - Math.ceil(Date.now() / 1000) + - 60 * deadline - ).toString(); - - statusRef.current = `Add ${pair.name} Liquidity`; - - if (pair.hasEth) { - writeAddLiquidityEth?.({ - recklesslySetUnpreparedOverrides: { - value: isToken1Eth ? token1Amount : token0Amount, - }, - recklesslySetUnpreparedArgs: [ - isToken1Eth ? pair.token0.id : pair.token1.id, - isToken1Eth ? token0Amount : token1Amount, - isToken1Eth ? token0AmountMin : token1AmountMin, - isToken1Eth ? token1AmountMin : token0AmountMin, - address, - transactionDeadline, - ], - }); - } else { - writeAddLiquidity?.({ - recklesslySetUnpreparedArgs: [ - pair.token0.id, - pair.token1.id, - token0Amount, - token1Amount, - token0AmountMin, - token1AmountMin, - address, - transactionDeadline, - ], - }); - } - }; - - return { - addLiquidity, - isLoading: isLoading || isLoading1, - isSuccess: isSuccess || isSuccess1, - isError: isError || isError1, - }; -}; diff --git a/app/hooks/usePair.ts b/app/hooks/usePair.ts index d1951e8..46e6d58 100644 --- a/app/hooks/usePair.ts +++ b/app/hooks/usePair.ts @@ -1,8 +1,8 @@ -import type { BigNumber } from "@ethersproject/bignumber"; -import { formatUnits } from "ethers/lib/utils"; +import { uniswapV2PairABI } from "artifacts/uniswapV2PairABI"; +import { formatUnits } from "viem"; +import { useContractRead } from "wagmi"; -import UniswapV2PairAbi from "../../artifacts/UniswapV2Pair.json"; -import { useContractRead } from "./useContractRead"; +import { useInterval } from "./useInterval"; import { REFETCH_INTERVAL_HIGH_PRIORITY } from "~/const"; import type { AddressString, Pair } from "~/types"; @@ -10,15 +10,16 @@ import type { AddressString, Pair } from "~/types"; const usePairReserves = ( id: AddressString, token0Decimals = 18, - token1Decimals = 18 + token1Decimals = 18, ) => { - const { data: pairData } = useContractRead({ + const { data: pairData, refetch } = useContractRead({ address: id, - abi: UniswapV2PairAbi, + abi: uniswapV2PairABI, functionName: "getReserves", - refetchInterval: REFETCH_INTERVAL_HIGH_PRIORITY, }); + useInterval(refetch, REFETCH_INTERVAL_HIGH_PRIORITY); + const reserves = { id, reserve0: 0, @@ -26,7 +27,7 @@ const usePairReserves = ( }; if (pairData) { - const [rawReserve0, rawReserve1] = pairData as [BigNumber, BigNumber]; + const [rawReserve0, rawReserve1] = pairData; reserves.reserve0 = parseFloat(formatUnits(rawReserve0, token0Decimals)); reserves.reserve1 = parseFloat(formatUnits(rawReserve1, token1Decimals)); } @@ -40,7 +41,7 @@ export const usePair = (pair: Pair) => { const { reserve0, reserve1 } = usePairReserves( pair.id, pair.token0.decimals, - pair.token1.decimals + pair.token1.decimals, ); const nextPair = { ...pair }; diff --git a/app/hooks/useQuote.ts b/app/hooks/useQuote.ts index 4b6638d..492b615 100644 --- a/app/hooks/useQuote.ts +++ b/app/hooks/useQuote.ts @@ -1,37 +1,43 @@ -import type { BigNumber } from "@ethersproject/bignumber"; -import { Zero } from "@ethersproject/constants"; +import { uniswapV2Router02ABI } from "artifacts/uniswapV2Router02ABI"; +import { useCallback } from "react"; +import { useContractRead } from "wagmi"; -import UniswapV2Router02Abi from "../../artifacts/UniswapV2Router02.json"; -import { useContractAddress } from "./useContractAddress"; -import { useContractRead } from "./useContractRead"; -import { AppContract, REFETCH_INTERVAL_HIGH_PRIORITY } from "~/const"; +import { useContractAddresses } from "./useContractAddresses"; +import { useInterval } from "./useInterval"; +import { REFETCH_INTERVAL_HIGH_PRIORITY } from "~/const"; import type { PairToken } from "~/types"; -import { toBigNumber } from "~/utils/number"; +import { toBigInt } from "~/utils/number"; export const useQuote = ( token0: PairToken, token1: PairToken, - amount: BigNumber, - isExactToken0: boolean + amount: bigint, + isExactToken0: boolean, ) => { - const contractAddress = useContractAddress(AppContract.Router); + const enabled = amount > 0; const tokenIn = isExactToken0 ? token0 : token1; const tokenOut = isExactToken0 ? token1 : token0; - const { data = Zero } = useContractRead({ - address: contractAddress, - abi: UniswapV2Router02Abi, + const { data = 0n, refetch } = useContractRead({ + address: useContractAddresses().Router, + abi: uniswapV2Router02ABI, functionName: "quote", - enabled: amount.gt(Zero), args: [ amount, - toBigNumber(tokenIn.reserve, tokenIn.decimals), - toBigNumber(tokenOut.reserve, tokenOut.decimals), + toBigInt(tokenIn.reserve, tokenIn.decimals), + toBigInt(tokenOut.reserve, tokenOut.decimals), ], - refetchInterval: REFETCH_INTERVAL_HIGH_PRIORITY, + enabled, }); - const result = data as BigNumber; + useInterval( + useCallback(() => { + if (enabled) { + refetch(); + } + }, [refetch, enabled]), + REFETCH_INTERVAL_HIGH_PRIORITY, + ); return { - token0: isExactToken0 ? amount : result, - token1: isExactToken0 ? result : amount, + token0: isExactToken0 ? amount : data, + token1: isExactToken0 ? data : amount, }; }; diff --git a/app/hooks/useRemoveLiquidity.ts b/app/hooks/useRemoveLiquidity.ts new file mode 100644 index 0000000..53828fb --- /dev/null +++ b/app/hooks/useRemoveLiquidity.ts @@ -0,0 +1,73 @@ +import { uniswapV2Router02ABI } from "artifacts/uniswapV2Router02ABI"; +import { useEffect } from "react"; +import type { TransactionReceipt } from "viem"; +import { + useContractWrite, + usePrepareContractWrite, + useWaitForTransaction, +} from "wagmi"; + +import { useContractAddresses } from "./useContractAddresses"; +import { useSettings } from "~/context/settingsContext"; +import { useUser } from "~/context/userContext"; +import type { AddressString, Pair } from "~/types"; +import { calculateAmountOutMin } from "~/utils/swap"; + +type Props = { + pair: Pair; + amount: bigint; + token0Amount: bigint; + token1Amount: bigint; + onSuccess?: (txReceipt: TransactionReceipt | undefined) => void; +}; + +export const useRemoveLiquidity = ({ + pair, + amount, + token0Amount, + token1Amount, + onSuccess, +}: Props) => { + const { address } = useUser(); + const { slippage, deadline } = useSettings(); + + const token0AmountMin = calculateAmountOutMin(token0Amount, slippage); + const token1AmountMin = calculateAmountOutMin(token1Amount, slippage); + const transactionDeadline = BigInt( + Math.ceil(Date.now() / 1000) + 60 * deadline, + ); + + const { config } = usePrepareContractWrite({ + address: useContractAddresses().Router, + abi: uniswapV2Router02ABI, + functionName: "removeLiquidity", + args: [ + pair.token0.id, + pair.token1.id, + amount, + token0AmountMin, + token1AmountMin, + address as AddressString, + transactionDeadline, + ], + enabled: !!address && amount > 0, + }); + + const { data, write: removeLiquidity, isLoading } = useContractWrite(config); + const { + data: txReceipt, + isLoading: isWaiting, + isSuccess, + } = useWaitForTransaction(data); + + useEffect(() => { + if (isSuccess) { + onSuccess?.(txReceipt); + } + }, [isSuccess, onSuccess, txReceipt]); + + return { + removeLiquidity, + isLoading: isLoading || isWaiting, + }; +}; diff --git a/app/hooks/useSwap.ts b/app/hooks/useSwap.ts index 3cbd601..18238a9 100644 --- a/app/hooks/useSwap.ts +++ b/app/hooks/useSwap.ts @@ -1,39 +1,38 @@ -import type { BigNumber } from "@ethersproject/bignumber"; -import { useMemo, useRef } from "react"; +import { uniswapV2Router02ABI } from "artifacts/uniswapV2Router02ABI"; +import { useEffect } from "react"; +import type { TransactionReceipt } from "viem"; +import { + useContractWrite, + usePrepareContractWrite, + useWaitForTransaction, +} from "wagmi"; -import type { RouterFunctionName } from "./useV2RouterWrite"; -import { useV2RouterWrite } from "./useV2RouterWrite"; +import { useContractAddresses } from "./useContractAddresses"; import { useSettings } from "~/context/settingsContext"; import { useUser } from "~/context/userContext"; -import type { Optional, Token } from "~/types"; -import { formatBigNumberDisplay } from "~/utils/number"; +import type { AddressString } from "~/types"; import { calculateAmountInMin, calculateAmountOutMin } from "~/utils/swap"; type Props = { - inputToken: Token; - outputToken: Token; - amountIn: BigNumber; - amountOut: BigNumber; + path: AddressString[]; + amountIn: bigint; + amountOut: bigint; isExactOut?: boolean; + enabled?: boolean; + onSuccess?: (txReceipt: TransactionReceipt | undefined) => void; }; export const useSwap = ({ - inputToken, - outputToken, + path, amountIn: rawAmountIn, amountOut: rawAmountOut, isExactOut, + enabled = true, + onSuccess, }: Props) => { const { address } = useUser(); const { slippage, deadline } = useSettings(); - const statusRef = useRef>(undefined); - - const path = useMemo( - () => [inputToken.id, outputToken.id], - [inputToken.id, outputToken.id] - ); - const isOutputEth = outputToken.isEth; - const isEth = inputToken.isEth || isOutputEth; + const contractAddress = useContractAddresses().Router; const amountIn = isExactOut ? calculateAmountInMin(rawAmountIn, slippage) @@ -41,102 +40,68 @@ export const useSwap = ({ const amountOut = isExactOut ? rawAmountOut : calculateAmountOutMin(rawAmountOut, slippage); + const transactionDeadline = BigInt( + Math.ceil(Date.now() / 1000) + 60 * deadline, + ); + const isEnabled = + enabled && !!address && (isExactOut ? amountOut > 0 : amountIn > 0); - const [functionName, args, overrides] = useMemo< - [RouterFunctionName, any, any?] - >(() => { - if (isExactOut) { - if (isEth) { - if (isOutputEth) { - return [ - "swapTokensForExactETH", - [ - amountOut, // amountOut - amountIn, // amountInMax - ], - ]; - } - - return [ - "swapETHForExactTokens", - [ - amountOut, // amountOut - ], - { - value: amountIn, - }, - ]; - } + const { config: exactOutConfig } = usePrepareContractWrite({ + address: contractAddress, + abi: uniswapV2Router02ABI, + functionName: "swapTokensForExactTokens", + args: [ + amountOut, // amountOut + amountIn, // amountInMax + path, + address as AddressString, + transactionDeadline, + ], + enabled: isEnabled && isExactOut, + }); + const swapTokensForExactTokens = useContractWrite(exactOutConfig); + const { isLoading: isWaitingExactOut, isSuccess: isSuccessExactOut } = + useWaitForTransaction(swapTokensForExactTokens.data); - return [ - "swapTokensForExactTokens", - [ - amountOut, // amountOut - amountIn, // amountInMax - ], - ]; - } + const { config: exactInConfig } = usePrepareContractWrite({ + address: contractAddress, + abi: uniswapV2Router02ABI, + functionName: "swapExactTokensForTokens", + args: [ + amountIn, // amountIn + amountOut, // amountOutMin + path, + address as AddressString, + transactionDeadline, + ], + enabled: isEnabled && !isExactOut, + }); + const swapExactTokensForTokens = useContractWrite(exactInConfig); + const { + data: txReceipt, + isLoading: isWaitingExactIn, + isSuccess: isSuccessExactIn, + } = useWaitForTransaction(swapExactTokensForTokens.data); - if (isEth) { - if (isOutputEth) { - return [ - "swapExactTokensForETH", - [ - amountIn, // amountIn - amountOut, // amountOutMin - ], - ]; + useEffect(() => { + if (isExactOut) { + if (isSuccessExactOut) { + onSuccess?.(txReceipt); } - - return [ - "swapExactETHForTokens", - [ - amountOut, // amountOutMin - ], - { - value: amountIn, - }, - ]; + } else if (isSuccessExactIn) { + onSuccess?.(txReceipt); } - - return [ - "swapExactTokensForTokens", - [ - amountIn, // amountIn - amountOut, // amountOutMin - ], - ]; - }, [isExactOut, isEth, isOutputEth, amountIn, amountOut]); - - const { write, isError, isSuccess, isLoading } = useV2RouterWrite( - functionName, - statusRef.current - ); + }, [isExactOut, isSuccessExactOut, isSuccessExactIn, onSuccess, txReceipt]); return { amountIn, amountOut, slippage, - swap: () => { - statusRef.current = `Swap ${formatBigNumberDisplay( - amountIn, - inputToken.decimals - )} ${inputToken.symbol} to ${formatBigNumberDisplay( - amountOut, - outputToken.decimals - )} ${outputToken.symbol}`; - write?.({ - recklesslySetUnpreparedOverrides: overrides, - recklesslySetUnpreparedArgs: [ - ...args, - path, - address, - Math.ceil(Date.now() / 1000) + 60 * deadline, - ], - }); - }, - isLoading, - isError, - isSuccess, + swap: isExactOut + ? swapTokensForExactTokens.write + : swapExactTokensForTokens.write, + isLoading: isExactOut + ? swapTokensForExactTokens.isLoading || isWaitingExactOut + : swapExactTokensForTokens.isLoading || isWaitingExactIn, }; }; diff --git a/app/hooks/useSwapRoute.ts b/app/hooks/useSwapRoute.ts new file mode 100644 index 0000000..4d0b6cb --- /dev/null +++ b/app/hooks/useSwapRoute.ts @@ -0,0 +1,108 @@ +import { parseUnits } from "viem"; + +import type { AddressString, Pair, PairToken, Token } from "~/types"; +import { multiplyArray } from "~/utils/array"; +import { + COMMUNITY_ECO_FUND, + COMMUNITY_GAME_FUND, + LIQUIDITY_PROVIDER_FEE, +} from "~/utils/price"; +import { createSwapRoute } from "~/utils/swap"; + +type Props = { + tokenIn: Token; + tokenOut: Token; + pools: Pair[]; + amount: string; + isExactOut: boolean; +}; + +export const useSwapRoute = ({ + tokenIn, + tokenOut, + pools, + amount, + isExactOut, +}: Props) => { + const amountBI = + amount !== "." + ? parseUnits(amount, isExactOut ? tokenOut.decimals : tokenIn.decimals) + : 0n; + const isSampleRoute = amountBI <= 0; + + const { + amountInBI = 0n, + amountOutBI = 0n, + legs = [], + priceImpact = 0, + } = createSwapRoute({ + tokenIn, + tokenOut, + pools, + amount: isSampleRoute ? 1n : amountBI, + isExactOut, + }) ?? {}; + + const poolLegs = legs + .map(({ poolAddress, tokenFrom, tokenTo }) => { + const pool = pools.find((pool) => pool.id === poolAddress); + if (!pool) { + return undefined; + } + + return { + ...pool, + tokenFrom: + pool.token0.id === tokenFrom.address ? pool.token0 : pool.token1, + tokenTo: pool.token0.id === tokenTo.address ? pool.token0 : pool.token1, + }; + }) + .filter((leg) => !!leg) as (Pair & { + tokenFrom: PairToken; + tokenTo: PairToken; + })[]; + + let fallbackTokenIn: PairToken | undefined; + let fallbackTokenOut: PairToken | undefined; + pools.forEach(({ token0, token1 }) => { + if (!fallbackTokenIn) { + if (token0.id === tokenIn.id) { + fallbackTokenIn = token0; + } else if (token1.id === tokenIn.id) { + fallbackTokenIn = token1; + } + } + + if (!fallbackTokenOut) { + if (token0.id === tokenOut.id) { + fallbackTokenOut = token0; + } else if (token1.id === tokenOut.id) { + fallbackTokenOut = token1; + } + } + }); + + return { + amountIn: isSampleRoute ? 0n : amountInBI, + amountOut: isSampleRoute ? 0n : amountOutBI, + tokenIn: poolLegs[0]?.tokenFrom ?? fallbackTokenIn, + tokenOut: poolLegs[poolLegs.length - 1]?.tokenTo ?? fallbackTokenOut, + legs, + path: poolLegs.flatMap(({ tokenFrom, tokenTo }, i) => + i === poolLegs.length - 1 + ? [tokenFrom.id as AddressString, tokenTo.id as AddressString] + : (tokenFrom.id as AddressString), + ), + priceImpact, + derivedValue: multiplyArray( + poolLegs.map( + ({ tokenFrom, tokenTo }) => tokenFrom.reserve / tokenTo.reserve, + ), + ), + lpFee: LIQUIDITY_PROVIDER_FEE * poolLegs.length, + gameFundFee: COMMUNITY_GAME_FUND * poolLegs.length, + ecoFundFee: COMMUNITY_ECO_FUND * poolLegs.length, + }; +}; + +export type SwapRoute = ReturnType; diff --git a/app/hooks/useTokenBalance.ts b/app/hooks/useTokenBalance.ts index 5eaec1d..c1567e1 100644 --- a/app/hooks/useTokenBalance.ts +++ b/app/hooks/useTokenBalance.ts @@ -1,4 +1,3 @@ -import { Zero } from "@ethersproject/constants"; import { useBalance } from "wagmi"; import { useUser } from "~/context/userContext"; @@ -6,7 +5,7 @@ import type { AddressString, Token } from "~/types"; export const useAddressBalance = (address?: AddressString) => { const { address: userAddress, isConnected } = useUser(); - const { data: balanceData, refetch } = useBalance({ + const { data, refetch } = useBalance({ address: userAddress, token: address, enabled: isConnected, @@ -14,7 +13,7 @@ export const useAddressBalance = (address?: AddressString) => { }); return { - value: balanceData?.value ?? Zero, + value: data?.value ?? 0n, refetch, }; }; diff --git a/app/hooks/useV2RouterWrite.ts b/app/hooks/useV2RouterWrite.ts deleted file mode 100644 index 2c80d00..0000000 --- a/app/hooks/useV2RouterWrite.ts +++ /dev/null @@ -1,29 +0,0 @@ -import UniswapV2Router02Abi from "../../artifacts/UniswapV2Router02.json"; -import { useContractAddress } from "./useContractAddress"; -import { useContractWrite } from "./useContractWrite"; -import { AppContract } from "~/const"; - -export type RouterFunctionName = - | "addLiquidity" - | "addLiquidityETH" - | "removeLiquidity" - | "removeLiquidityETH" - | "swapETHForExactTokens" - | "swapExactETHForTokens" - | "swapExactTokensForETH" - | "swapTokensForExactETH" - | "swapExactTokensForTokens" - | "swapTokensForExactTokens"; - -export const useV2RouterWrite = ( - functionName: RouterFunctionName, - statusHeader?: string -) => { - const contractAddress = useContractAddress(AppContract.Router); - return useContractWrite(statusHeader, { - address: contractAddress, - abi: UniswapV2Router02Abi, - mode: "recklesslyUnprepared", - functionName, - }); -}; diff --git a/app/polyfills.ts b/app/polyfills.ts deleted file mode 100644 index 9472bdf..0000000 --- a/app/polyfills.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Buffer } from "buffer-polyfill"; - -window.global = window.global ?? window; -window.Buffer = window.Buffer ?? Buffer; -window.process = window.process ?? { env: {} }; // Minimal process polyfill - -export {}; diff --git a/app/root.tsx b/app/root.tsx index c7012cc..ed58cc5 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,4 +1,3 @@ -import { bloctoWallet } from "@blocto/rainbowkit-connector"; import { Transition } from "@headlessui/react"; import { CheckCircleIcon, @@ -7,23 +6,9 @@ import { import { ConnectButton, RainbowKitProvider, - connectorsForWallets, darkTheme, } from "@rainbow-me/rainbowkit"; -import rainbowStyles from "@rainbow-me/rainbowkit/styles.css"; -import { - braveWallet, - coinbaseWallet, - injectedWallet, - ledgerWallet, - metaMaskWallet, - rainbowWallet, - safeWallet, - trustWallet, - walletConnectWallet, -} from "@rainbow-me/rainbowkit/wallets"; import type { LinksFunction, MetaFunction } from "@remix-run/node"; -import { json } from "@remix-run/node"; import { NavLink as Link, Links, @@ -31,20 +16,17 @@ import { Meta, Outlet, Scripts, + ScrollRestoration, useFetchers, useLoaderData, - useTransition, + useNavigation, } from "@remix-run/react"; import NProgress from "nprogress"; -import React, { useState } from "react"; +import { Fragment, useEffect, useMemo } from "react"; import { Toaster, resolveValue } from "react-hot-toast"; import { twMerge } from "tailwind-merge"; -import { WagmiConfig, configureChains, createClient } from "wagmi"; -import { arbitrum, arbitrumGoerli } from "wagmi/chains"; -import { alchemyProvider } from "wagmi/providers/alchemy"; -import { publicProvider } from "wagmi/providers/public"; +import { WagmiConfig } from "wagmi"; -import MagicswapLogo from "../public/img/logo-magicswap.svg"; import { DiscordIcon, GitHubIcon, @@ -53,20 +35,18 @@ import { SplitIcon, TwitterIcon, } from "./components/Icons"; +import { PairsProvider } from "./context/pairs"; import { PriceProvider } from "./context/priceContext"; import { SettingsProvider } from "./context/settingsContext"; import { UserProvider } from "./context/userContext"; -import fontStyles from "./styles/font.css"; -import nProgressStyles from "./styles/nprogress.css"; -import styles from "./styles/tailwind.css"; -import type { Env } from "./types"; +import "./styles/font.css"; +import "./styles/nprogress.css"; +import "./styles/tailwind.css"; import { createMetaTags } from "./utils/meta"; +import { getPairs } from "./utils/pair.server"; +import { createWagmiConfig } from "./utils/wagmi"; export const links: LinksFunction = () => [ - { rel: "stylesheet", href: styles }, - { rel: "stylesheet", href: nProgressStyles }, - { rel: "stylesheet", href: rainbowStyles }, - { rel: "stylesheet", href: fontStyles }, { rel: "icon", type: "image/png", @@ -90,37 +70,33 @@ export const links: LinksFunction = () => [ { rel: "shortcut icon", href: "/img/favicon.ico" }, ]; -export const meta: MetaFunction = () => ({ +export const meta: MetaFunction = () => [ ...createMetaTags("Swap | Magicswap"), - charset: "utf-8", - viewport: "width=device-width,initial-scale=1", - "apple-mobile-web-app-title": "Magicswap", - "application-name": "Magicswap", - "msapplication-TileColor": "#DC2626", - "msapplication-config": "/browserconfig.xml", - "theme-color": "#DC2626", -}); - -const strictEntries = >( - object: T -): [keyof T, T[keyof T]][] => { - return Object.entries(object); -}; - -function getPublicKeys(env: Env): Env { - const publicKeys = {} as Env; - for (const [key, value] of strictEntries(env)) { - if (key.startsWith("PUBLIC_")) { - publicKeys[key] = value; - } - } - return publicKeys; -} + { + name: "apple-mobile-web-app-title", + content: "Magicswap", + }, + { + name: "application-name", + content: "Magicswap", + }, + { + name: "msapplication-TileColor", + content: "#DC2626", + }, + { + name: "msapplication-config", + content: "/browserconfig.xml", + }, + { + name: "theme-color", + content: "#DC2626", + }, +]; export const loader = async () => { - return json({ - ENV: getPublicKeys(process.env), - }); + const pairs = await getPairs(); + return { pairs }; }; const NavLink = ({ @@ -140,7 +116,7 @@ const NavLink = ({ "flex flex-1 items-center justify-center space-x-6 rounded-lg px-4 py-3 text-base font-medium tracking-wide 2xl:px-8 2xl:py-4 2xl:text-base", isActive ? "bg-night-900 text-white" - : "text-night-500 hover:bg-night-700/10 hover:text-night-500" + : "text-night-500 hover:bg-night-700/10 hover:text-night-500", )} > @@ -151,85 +127,42 @@ const NavLink = ({ ); }; -export default function App() { - const transition = useTransition(); - const { ENV } = useLoaderData(); - - const projectId = ENV.PUBLIC_WALLETCONNECT_PROJECT_ID; - - const [{ client, chains }] = useState(() => { - const { chains, provider } = configureChains( - [arbitrum, ...(ENV.PUBLIC_ENABLE_TESTNETS ? [arbitrumGoerli] : [])], - [alchemyProvider({ apiKey: ENV.PUBLIC_ALCHEMY_KEY }), publicProvider()] - ); - - const connectors = connectorsForWallets([ - { - groupName: "Popular", - wallets: [ - injectedWallet({ chains }), - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - bloctoWallet({ chains }), - metaMaskWallet({ chains, projectId }), - rainbowWallet({ chains, projectId }), - coinbaseWallet({ appName: "Magicswap", chains }), - walletConnectWallet({ chains, projectId }), - braveWallet({ chains }), - trustWallet({ - projectId, - chains, - }), - ledgerWallet({ - projectId, - chains, - }), - ], - }, - { - groupName: "Multisig", - wallets: [safeWallet({ chains })], - }, - ]); - - const client = createClient({ - autoConnect: true, - connectors, - provider, - }); - - return { client, chains }; - }); +const { chains, config: wagmiConfig } = createWagmiConfig(); +export default function App() { + const navigation = useNavigation(); const fetchers = useFetchers(); + const { pairs } = useLoaderData(); - const state = React.useMemo<"idle" | "loading">( + const state = useMemo<"idle" | "loading">( function getGlobalState() { const states = [ - transition.state, + navigation.state, ...fetchers.map((fetcher) => fetcher.state), ]; if (states.every((state) => state === "idle")) return "idle"; return "loading"; }, - [transition.state, fetchers] + [navigation.state, fetchers], ); // slim loading bars on top of the page, for page transitions - React.useEffect(() => { + useEffect(() => { if (state === "loading") NProgress.start(); if (state === "idle") NProgress.done(); - }, [state, transition.state]); + }, [state, navigation.state]); return ( + +
- + -
-
- -
- - Magicswap +
+
+ +
+ + Magicswap + +
+
+ - -
-
- +
-
-
-
- -
-
-
-
- -
+
+
+
-
-
+
+
+
+ +
+
+
+
+ - - - {(t) => ( - -
-
-
-
- {(() => { - switch (t.type) { - case "success": - return ( - - ); - case "error": - return ( - - ); - case "loading": - return ( - - ); - default: - return ( - - ); - } - })()} -
-
-
- {resolveValue(t.message, t)} + + {(t) => ( + +
+
+
+
+ {(() => { + switch (t.type) { + case "success": + return ( + + ); + case "error": + return ( + + ); + case "loading": + return ( + + ); + default: + return ( + + ); + } + })()} +
+
+
+ {resolveValue(t.message, t)} +
-
- - )} - + + )} + + + + - {ENV.PUBLIC_NODE_ENV === "development" ? : null}