From 8a3b00d5ecb3cde861132ec29e05534e75b59aa7 Mon Sep 17 00:00:00 2001 From: Alec Ananian <1013230+alecananian@users.noreply.github.com> Date: Wed, 17 Jan 2024 10:12:00 -0800 Subject: [PATCH] remix v2; arbsepolia support; swap routing (#99) * upgrade dependencies * upgrade to remix v2 with vite * fix routes * upgrade dependencies * upgrade to wagmi 1.x * remove unused code * add swap router * hook up success toasts * add magic-smol testnet pair * upgrade dependencies * update twitter icon * fix typecheck * remove file name from afterAllFileWrite hook * temporarily disable typechecking * redesign swap token inputs * fix dom warnings * upgrade dependencies --- .env.sample | 7 +- .github/workflows/deploy.yml | 43 +- .prettierrc | 10 + app/components/Button.tsx | 2 +- app/components/Graph.tsx | 2 +- app/components/Icons.tsx | 6 +- app/components/NumberField.tsx | 6 +- app/components/PairTokenInput.tsx | 154 +- app/components/SwapRoutePanel.tsx | 70 + app/components/ToastContent.tsx | 19 + app/components/TokenInput.tsx | 16 +- app/components/TransactionLink.tsx | 22 + app/const.ts | 51 +- app/context/pairs.tsx | 75 + app/context/priceContext.tsx | 5 +- app/context/settingsContext.tsx | 2 +- app/entry.client.tsx | 25 +- app/entry.server.tsx | 83 +- app/hooks/useAddLiquidity.ts | 74 + app/hooks/useAmount.ts | 35 - app/hooks/useApproval.ts | 100 +- app/hooks/useBlockExplorer.ts | 7 +- app/hooks/useContractAddress.ts | 8 - app/hooks/useContractAddresses.ts | 12 + app/hooks/useContractRead.ts | 34 - app/hooks/useContractWrite.tsx | 76 - app/hooks/useId.ts | 2 +- app/hooks/useInterval.ts | 2 +- app/hooks/useLiquidity.ts | 155 - app/hooks/usePair.ts | 21 +- app/hooks/useQuote.ts | 46 +- app/hooks/useRemoveLiquidity.ts | 73 + app/hooks/useSwap.ts | 179 +- app/hooks/useSwapRoute.ts | 108 + app/hooks/useTokenBalance.ts | 5 +- app/hooks/useV2RouterWrite.ts | 29 - app/polyfills.ts | 7 - app/root.tsx | 387 +- app/routes/{index.tsx => _index.tsx} | 508 +- .../index.tsx => pools.$poolId._index.tsx} | 0 ...lytics.tsx => pools.$poolId.analytics.tsx} | 19 +- .../manage.tsx => pools.$poolId.manage.tsx} | 279 +- app/routes/pools.tsx | 25 +- app/routes/pools/index.tsx | 16 - app/types.ts | 14 +- app/utils/address.ts | 5 - app/utils/api.ts | 2 +- app/utils/array.ts | 4 + app/utils/cookie.server.ts | 4 +- app/utils/meta.ts | 26 +- app/utils/number.ts | 41 +- app/utils/pair.server.ts | 12 +- app/utils/price.ts | 6 +- app/utils/swap.ts | 105 +- app/utils/time.server.ts | 33 - app/utils/{tokens.server.ts => tokens.ts} | 8 +- app/utils/wagmi.ts | 74 + ...UniswapV2Pair.json => uniswapV2PairABI.ts} | 4 +- ...2Router02.json => uniswapV2Router02ABI.ts} | 4 +- codegen.ts | 18 + codegen.yml | 9 - env.d.ts | 12 + global.d.ts | 24 - package-lock.json | 52556 +++++----------- package.json | 60 +- postcss.config.mjs | 6 + prettier.config.js | 29 - remix.config.js | 16 - test/setup-test-env.ts | 7 - tsconfig.json | 7 +- vite.config.ts | 23 + vitest.config.js | 2 - 72 files changed, 18371 insertions(+), 37545 deletions(-) create mode 100644 .prettierrc create mode 100644 app/components/SwapRoutePanel.tsx create mode 100644 app/components/ToastContent.tsx create mode 100644 app/components/TransactionLink.tsx create mode 100644 app/context/pairs.tsx create mode 100644 app/hooks/useAddLiquidity.ts delete mode 100644 app/hooks/useAmount.ts delete mode 100644 app/hooks/useContractAddress.ts create mode 100644 app/hooks/useContractAddresses.ts delete mode 100644 app/hooks/useContractRead.ts delete mode 100644 app/hooks/useContractWrite.tsx delete mode 100644 app/hooks/useLiquidity.ts create mode 100644 app/hooks/useRemoveLiquidity.ts create mode 100644 app/hooks/useSwapRoute.ts delete mode 100644 app/hooks/useV2RouterWrite.ts delete mode 100644 app/polyfills.ts rename app/routes/{index.tsx => _index.tsx} (53%) rename app/routes/{pools/$poolId/index.tsx => pools.$poolId._index.tsx} (100%) rename app/routes/{pools/$poolId/analytics.tsx => pools.$poolId.analytics.tsx} (96%) rename app/routes/{pools/$poolId/manage.tsx => pools.$poolId.manage.tsx} (80%) delete mode 100644 app/routes/pools/index.tsx create mode 100644 app/utils/array.ts delete mode 100644 app/utils/time.server.ts rename app/utils/{tokens.server.ts => tokens.ts} (98%) create mode 100644 app/utils/wagmi.ts rename artifacts/{UniswapV2Pair.json => uniswapV2PairABI.ts} (99%) rename artifacts/{UniswapV2Router02.json => uniswapV2Router02ABI.ts} (99%) create mode 100644 codegen.ts delete mode 100644 codegen.yml create mode 100644 env.d.ts delete mode 100644 global.d.ts create mode 100644 postcss.config.mjs delete mode 100644 prettier.config.js delete mode 100644 remix.config.js delete mode 100644 test/setup-test-env.ts create mode 100644 vite.config.ts 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}