From 7c191d94219d82d8c825cef82f4c93bcfa7c8883 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 1 Sep 2024 21:01:56 -0700 Subject: [PATCH] Refactor async server state management using tanstack (#72) * refactore add liquidity * refactor remove liquidity * Refactor swap UI * fix swap query expected amount * refactor pool action results * improve component and hook organization * Update to using reference amount for input of add liquidity proportional --- .../HooksCards.tsx => HookDetails.tsx} | 33 +- packages/nextjs/app/hooks/page.tsx | 18 +- packages/nextjs/app/hooks/types.ts | 11 + packages/nextjs/app/page.tsx | 12 - .../pools/_components/PoolPageSkeleton.tsx | 29 ++ .../app/pools/_components/UserLiquidity.tsx | 74 ----- .../_components/actions/AddLiquidityForm.tsx | 253 --------------- .../app/pools/_components/actions/Alerts.tsx | 105 ------- .../actions/RemoveLiquidityForm.tsx | 143 --------- .../pools/_components/actions/SwapForm.tsx | 291 ------------------ .../nextjs/app/pools/_components/index.tsx | 9 +- .../_components/{ => info}/HooksConfig.tsx | 0 .../_components/{ => info}/PoolAttributes.tsx | 0 .../{ => info}/PoolComposition.tsx | 21 +- .../_components/{ => info}/PoolConfig.tsx | 0 .../pools/_components/info/UserLiquidity.tsx | 56 ++++ .../app/pools/_components/info/index.tsx | 5 + .../operations/AddLiquidityForm.tsx | 165 ++++++++++ .../PoolOperations.tsx} | 115 ++++--- .../operations/RemoveLiquidityForm.tsx | 147 +++++++++ .../_components/operations/ResultsDisplay.tsx | 52 ++++ .../pools/_components/operations/SwapForm.tsx | 229 ++++++++++++++ .../{actions => operations}/TokenField.tsx | 53 +++- .../TransactionButton.tsx} | 2 +- .../{actions => operations}/index.tsx | 5 +- packages/nextjs/app/pools/page.tsx | 62 +--- packages/nextjs/app/router/layout.tsx | 18 -- packages/nextjs/app/router/page.tsx | 22 -- packages/nextjs/app/subgraph/layout.tsx | 18 -- packages/nextjs/app/subgraph/page.tsx | 26 -- .../ScaffoldEthAppWithProviders.tsx | 21 +- packages/nextjs/components/common/Alert.tsx | 29 ++ .../components/common/TokenAmountDisplay.tsx | 18 ++ packages/nextjs/components/common/index.tsx | 3 + .../hooks/balancer/addLiquidity/index.ts | 2 + .../balancer/addLiquidity/useAddLiquidity.ts | 47 +++ .../addLiquidity/useQueryAddLiquidity.ts | 53 ++++ packages/nextjs/hooks/balancer/index.ts | 9 +- .../hooks/balancer/removeLiquidity/index.ts | 2 + .../useQueryRemoveLiquidity.ts | 47 +++ .../removeLiquidity/useRemoveLiquidity.ts | 46 +++ packages/nextjs/hooks/balancer/swap/index.ts | 2 + .../hooks/balancer/swap/useQuerySwap.ts | 47 +++ .../nextjs/hooks/balancer/swap/useSwap.ts | 60 ++++ packages/nextjs/hooks/balancer/types.ts | 63 +--- .../nextjs/hooks/balancer/useAddLiquidity.ts | 104 ------- packages/nextjs/hooks/balancer/useApprove.ts | 34 -- .../hooks/balancer/useRemoveLiquidity.ts | 101 ------ packages/nextjs/hooks/balancer/useSwap.ts | 118 ------- packages/nextjs/hooks/balancer/useToken.ts | 37 --- packages/nextjs/hooks/token/index.ts | 6 + .../hooks/token/useAllowanceOnPermit2.ts | 18 ++ .../nextjs/hooks/token/useAllowanceOnToken.ts | 15 + .../nextjs/hooks/token/useApproveOnPermit2.ts | 33 ++ .../nextjs/hooks/token/useApproveOnToken.ts | 30 ++ .../nextjs/hooks/token/useApproveTokens.ts | 88 ++++++ .../useTokens.ts => token/useReadTokens.ts} | 3 +- packages/nextjs/package.json | 4 +- packages/nextjs/tailwind.config.js | 6 +- yarn.lock | 213 ++----------- 60 files changed, 1452 insertions(+), 1781 deletions(-) rename packages/nextjs/app/hooks/{_components/HooksCards.tsx => HookDetails.tsx} (70%) create mode 100644 packages/nextjs/app/hooks/types.ts create mode 100644 packages/nextjs/app/pools/_components/PoolPageSkeleton.tsx delete mode 100644 packages/nextjs/app/pools/_components/UserLiquidity.tsx delete mode 100644 packages/nextjs/app/pools/_components/actions/AddLiquidityForm.tsx delete mode 100644 packages/nextjs/app/pools/_components/actions/Alerts.tsx delete mode 100644 packages/nextjs/app/pools/_components/actions/RemoveLiquidityForm.tsx delete mode 100644 packages/nextjs/app/pools/_components/actions/SwapForm.tsx rename packages/nextjs/app/pools/_components/{ => info}/HooksConfig.tsx (100%) rename packages/nextjs/app/pools/_components/{ => info}/PoolAttributes.tsx (100%) rename packages/nextjs/app/pools/_components/{ => info}/PoolComposition.tsx (53%) rename packages/nextjs/app/pools/_components/{ => info}/PoolConfig.tsx (100%) create mode 100644 packages/nextjs/app/pools/_components/info/UserLiquidity.tsx create mode 100644 packages/nextjs/app/pools/_components/info/index.tsx create mode 100644 packages/nextjs/app/pools/_components/operations/AddLiquidityForm.tsx rename packages/nextjs/app/pools/_components/{PoolActions.tsx => operations/PoolOperations.tsx} (53%) create mode 100644 packages/nextjs/app/pools/_components/operations/RemoveLiquidityForm.tsx create mode 100644 packages/nextjs/app/pools/_components/operations/ResultsDisplay.tsx create mode 100644 packages/nextjs/app/pools/_components/operations/SwapForm.tsx rename packages/nextjs/app/pools/_components/{actions => operations}/TokenField.tsx (68%) rename packages/nextjs/app/pools/_components/{actions/PoolActionButton.tsx => operations/TransactionButton.tsx} (89%) rename packages/nextjs/app/pools/_components/{actions => operations}/index.tsx (55%) delete mode 100644 packages/nextjs/app/router/layout.tsx delete mode 100644 packages/nextjs/app/router/page.tsx delete mode 100644 packages/nextjs/app/subgraph/layout.tsx delete mode 100644 packages/nextjs/app/subgraph/page.tsx create mode 100644 packages/nextjs/components/common/Alert.tsx create mode 100644 packages/nextjs/components/common/TokenAmountDisplay.tsx create mode 100644 packages/nextjs/hooks/balancer/addLiquidity/index.ts create mode 100644 packages/nextjs/hooks/balancer/addLiquidity/useAddLiquidity.ts create mode 100644 packages/nextjs/hooks/balancer/addLiquidity/useQueryAddLiquidity.ts create mode 100644 packages/nextjs/hooks/balancer/removeLiquidity/index.ts create mode 100644 packages/nextjs/hooks/balancer/removeLiquidity/useQueryRemoveLiquidity.ts create mode 100644 packages/nextjs/hooks/balancer/removeLiquidity/useRemoveLiquidity.ts create mode 100644 packages/nextjs/hooks/balancer/swap/index.ts create mode 100644 packages/nextjs/hooks/balancer/swap/useQuerySwap.ts create mode 100644 packages/nextjs/hooks/balancer/swap/useSwap.ts delete mode 100644 packages/nextjs/hooks/balancer/useAddLiquidity.ts delete mode 100644 packages/nextjs/hooks/balancer/useApprove.ts delete mode 100644 packages/nextjs/hooks/balancer/useRemoveLiquidity.ts delete mode 100644 packages/nextjs/hooks/balancer/useSwap.ts delete mode 100644 packages/nextjs/hooks/balancer/useToken.ts create mode 100644 packages/nextjs/hooks/token/index.ts create mode 100644 packages/nextjs/hooks/token/useAllowanceOnPermit2.ts create mode 100644 packages/nextjs/hooks/token/useAllowanceOnToken.ts create mode 100644 packages/nextjs/hooks/token/useApproveOnPermit2.ts create mode 100644 packages/nextjs/hooks/token/useApproveOnToken.ts create mode 100644 packages/nextjs/hooks/token/useApproveTokens.ts rename packages/nextjs/hooks/{balancer/useTokens.ts => token/useReadTokens.ts} (94%) diff --git a/packages/nextjs/app/hooks/_components/HooksCards.tsx b/packages/nextjs/app/hooks/HookDetails.tsx similarity index 70% rename from packages/nextjs/app/hooks/_components/HooksCards.tsx rename to packages/nextjs/app/hooks/HookDetails.tsx index 898bd39f..0a848771 100644 --- a/packages/nextjs/app/hooks/_components/HooksCards.tsx +++ b/packages/nextjs/app/hooks/HookDetails.tsx @@ -2,10 +2,11 @@ import { useState } from "react"; import Link from "next/link"; -import { type HookDetails } from "../page"; +import { type HookInfo } from "./page"; +import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; import Modal from "~~/components/common/Modal"; -export const HooksCards = ({ hooks }: { hooks: HookDetails[] }) => { +export const HooksDetails = ({ hooks }: { hooks: HookInfo[] }) => { const [activeModal, setActiveModal] = useState(null); const modalContent = (id: number) => { @@ -14,12 +15,16 @@ export const HooksCards = ({ hooks }: { hooks: HookDetails[] }) => { if (!hook) return null; const categories = hook.category.join(", "); return ( -
-

{hook.title}

-
Created By {hook.created_by}
-

{hook.description}

-
Audited: {hook.audited}
+
+
+

{hook.title}

+
Created By {hook.created_by}
+
+ +
{hook.description}
+
+
Audited: {hook.audited}
Categories: {categories}
{ {hooks.map(hook => (
setActiveModal(hook.id)} > -
{hook.title}
+
{hook.title}
-
+
- {hook.github.slice(0, 40)}... + github
-
{hook.category}
-
{hook.created_by}
+
{hook.category}
+
{hook.created_by}
))} diff --git a/packages/nextjs/app/hooks/page.tsx b/packages/nextjs/app/hooks/page.tsx index fae95863..15dbc20e 100644 --- a/packages/nextjs/app/hooks/page.tsx +++ b/packages/nextjs/app/hooks/page.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; -import { HooksCards } from "./_components/HooksCards"; +import { HooksDetails } from "./HookDetails"; import type { NextPage } from "next"; -export type HookDetails = { +export type HookInfo = { id: number; title: string; source: string; @@ -15,7 +15,7 @@ export type HookDetails = { }; const Hooks: NextPage = async () => { - let hooks: HookDetails[] | null = null; + let hooks: HookInfo[] | null = null; const response = await fetch("https://raw.githubusercontent.com/burns2854/balancer-hooks/main/hook-data.json"); if (response.ok) { hooks = await response.json(); @@ -40,13 +40,13 @@ const Hooks: NextPage = async () => {
-
-
Name
-
Repo URL
-
Category
-
Created By
+
+
Name
+
Repo URL
+
Category
+
Created By
- {hooks ? :
Error fetching hooks data!
} + {hooks ? :
Error fetching hooks data!
}
); diff --git a/packages/nextjs/app/hooks/types.ts b/packages/nextjs/app/hooks/types.ts new file mode 100644 index 00000000..7fca25af --- /dev/null +++ b/packages/nextjs/app/hooks/types.ts @@ -0,0 +1,11 @@ +export type HookDetails = { + id: number; + title: string; + source: string; + description: string; + github: string; + additional_link: string; + created_by: string; + audited: "Yes" | "No"; + category: string[]; +}; diff --git a/packages/nextjs/app/page.tsx b/packages/nextjs/app/page.tsx index c9a4812c..d7c5ce40 100644 --- a/packages/nextjs/app/page.tsx +++ b/packages/nextjs/app/page.tsx @@ -14,18 +14,6 @@ const TOOLS = [ href: "/hooks", description: "Extend liquidity pool functionality with hooks", }, - // { - // emoji: "🧭", - // title: "Smart Order Router", - // href: "/router", - // description: "Integrate pools with the smart order router", - // }, - // { - // emoji: "📡", - // title: "Subgraph", - // href: "/subgraph", - // description: "Integrate pools with the Balancer subgraph", - // }, ]; const Home: NextPage = () => { diff --git a/packages/nextjs/app/pools/_components/PoolPageSkeleton.tsx b/packages/nextjs/app/pools/_components/PoolPageSkeleton.tsx new file mode 100644 index 00000000..c12e36b9 --- /dev/null +++ b/packages/nextjs/app/pools/_components/PoolPageSkeleton.tsx @@ -0,0 +1,29 @@ +import { SkeletonLoader } from "~~/components/common"; + +export const PoolPageSkeleton = () => { + return ( +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ ); +}; diff --git a/packages/nextjs/app/pools/_components/UserLiquidity.tsx b/packages/nextjs/app/pools/_components/UserLiquidity.tsx deleted file mode 100644 index effe27fb..00000000 --- a/packages/nextjs/app/pools/_components/UserLiquidity.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect, useState } from "react"; -import { type TokenAmount } from "@balancer/sdk"; -import { useAccount } from "wagmi"; -import { useRemoveLiquidity } from "~~/hooks/balancer/"; -import { type Pool } from "~~/hooks/balancer/types"; -import { formatToHuman } from "~~/utils/"; - -/** - * If there is a connected user, display their liquidity within the pool - */ -export const UserLiquidity = ({ pool }: { pool: Pool }) => { - const [expectedAmountsOut, setExpectedAmountsOut] = useState(); - - const { isConnected } = useAccount(); - const { queryRemoveLiquidity } = useRemoveLiquidity(pool); - - useEffect(() => { - async function sendQuery() { - if (pool.userBalance > 0n) { - const { expectedAmountsOut } = await queryRemoveLiquidity(pool.userBalance); - setExpectedAmountsOut(expectedAmountsOut); - } else { - setExpectedAmountsOut(undefined); - } - } - sendQuery(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pool.userBalance]); // excluded queryRemoveLiquidity from deps array because it causes infinite re-renders - - // only render the component if the pool is initialized and the user is connected - if (!isConnected || !pool?.poolConfig?.isPoolInitialized) { - return null; - } - - return ( -
-
-
My Liquidity
- -
-
-
-
{pool.symbol}
-
{pool.name}
-
-
-
{formatToHuman(pool.userBalance ?? 0n, pool.decimals)}
-
{pool.userBalance?.toString()}
-
-
-
- {pool.poolTokens.map((token, index) => ( -
-
-
{token.symbol}
-
{token.name}
-
- -
-
- {expectedAmountsOut ? formatToHuman(expectedAmountsOut[index].amount, token.decimals) : "0.0000"} -
-
- {expectedAmountsOut ? expectedAmountsOut[index].amount.toString() : "0"} -
-
-
- ))} -
-
-
-
- ); -}; diff --git a/packages/nextjs/app/pools/_components/actions/AddLiquidityForm.tsx b/packages/nextjs/app/pools/_components/actions/AddLiquidityForm.tsx deleted file mode 100644 index 3bc1b917..00000000 --- a/packages/nextjs/app/pools/_components/actions/AddLiquidityForm.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { PoolActionButton, QueryErrorAlert, QueryResponseAlert, TokenField, TransactionReceiptAlert } from "."; -import { PoolActionsProps } from "../PoolActions"; -import { BALANCER_ROUTER, InputAmount, PERMIT2, erc20Abi, permit2Abi } from "@balancer/sdk"; -import { calculateProportionalAmounts } from "@balancer/sdk"; -import { formatUnits, parseUnits } from "viem"; -import { useContractEvent, usePublicClient, useWalletClient } from "wagmi"; -import abis from "~~/contracts/abis"; -import { useAddLiquidity, useTargetFork, useTokens } from "~~/hooks/balancer/"; -import { PoolActionReceipt, QueryAddLiquidityResponse, QueryPoolActionError, TokenInfo } from "~~/hooks/balancer/types"; -import { useTransactor } from "~~/hooks/scaffold-eth"; -import { MaxUint48, MaxUint160, MaxUint256 } from "~~/utils/constants"; - -/** - * 1. Query adding some amount of liquidity to the pool - * 2. Approve the Balancer vault to spend the tokens to be used in the transaction (if necessary) - * 3. Send transaction to add liquidity to the pool - * 4. Display the transaction results to the user - */ -export const AddLiquidityForm: React.FC = ({ - pool, - refetchPool, - tokenBalances, - refetchTokenBalances, -}) => { - const initialTokenInputs = pool.poolTokens.map(token => ({ - address: token.address as `0x${string}`, - decimals: token.decimals, - rawAmount: 0n, - })); - const [tokensToApprove, setTokensToApprove] = useState([]); - const [tokenInputs, setTokenInputs] = useState(initialTokenInputs); - const [queryResponse, setQueryResponse] = useState(null); - const [sufficientAllowances, setSufficientAllowances] = useState(false); - const [queryError, setQueryError] = useState(); - const [isApproving, setIsApproving] = useState(false); - const [isQuerying, setIsQuerying] = useState(false); - const [isAddingLiquidity, setIsAddingLiquidity] = useState(false); - const [addLiquidityReceipt, setAddLiquidityReceipt] = useState(null); - const [bptOut, setBptOut] = useState({ - rawAmount: 0n, - decimals: pool.decimals, - address: pool.address as `0x${string}`, - }); - - const { tokenAllowances, refetchTokenAllowances } = useTokens(tokenInputs); - const { queryAddLiquidity, addLiquidity } = useAddLiquidity(pool, tokenInputs); - const writeTx = useTransactor(); // scaffold hook for tx status toast notifications - const publicClient = usePublicClient(); - const { data: walletClient } = useWalletClient(); - const { chainId } = useTargetFork(); - - useEffect(() => { - // Determine which tokens need to be approved - async function determineTokensToApprove() { - if (tokenAllowances) { - const tokensNeedingApproval = tokenInputs.filter((token, index) => { - const allowance = tokenAllowances[index] || 0n; - return allowance < token.rawAmount; - }); - setTokensToApprove(tokensNeedingApproval); - // Check if all tokens have sufficient tokenAllowances - if (tokensNeedingApproval.length > 0) { - setSufficientAllowances(false); - } else { - setSufficientAllowances(true); - } - } - } - determineTokensToApprove(); - }, [tokenInputs, tokenAllowances]); - - const handleInputChange = (index: number, value: string) => { - setQueryError(null); - setQueryResponse(null); - setAddLiquidityReceipt(null); - - const updatedTokens = tokenInputs.map((token, idx) => { - if (idx === index) { - return { ...token, rawAmount: parseUnits(value, token.decimals) }; - } - return token; - }); - - if (pool.poolConfig?.liquidityManagement.disableUnbalancedLiquidity) { - // Read pool supply and token balances on-chain - const poolStateWithBalances = { - address: pool.address as `0x${string}`, - totalShares: formatUnits(pool.totalSupply, pool.decimals) as `${number}`, - tokens: pool.poolTokens.map(token => ({ - address: token.address as `0x${string}`, - decimals: token.decimals, - balance: formatUnits(token.balance, token.decimals) as `${number}`, - })), - }; - const referenceAmount = updatedTokens[index]; - const { bptAmount, tokenAmounts } = calculateProportionalAmounts(poolStateWithBalances, referenceAmount); - setBptOut(bptAmount); - setTokenInputs(tokenAmounts); - } else { - setTokenInputs(updatedTokens); - } - }; - - const handleQueryAddLiquidity = async () => { - setQueryResponse(null); - setAddLiquidityReceipt(null); - setIsQuerying(true); - const response = await queryAddLiquidity(bptOut); - if (response.error) { - setQueryError(response.error); - } else { - setQueryResponse(response); - } - setIsQuerying(false); - }; - - const handleApprove = async () => { - if (!walletClient) throw new Error("Wallet client not connected!"); - tokensToApprove.forEach(async token => { - try { - setIsApproving(true); - // Max approve canonical Permit2 address to spend account's tokens - const { request: approveSpenderOnToken } = await publicClient.simulateContract({ - address: token.address, - abi: erc20Abi, - functionName: "approve", - account: walletClient.account, - args: [PERMIT2[chainId], MaxUint256], - }); - await writeTx(() => walletClient.writeContract(approveSpenderOnToken), { - blockConfirmations: 1, - onBlockConfirmation: () => { - console.log("Approved permit2 contract to spend max amount of", token.address); - }, - }); - // Approve Router to spend account's tokens using Permit2.approve(token, spender, amount, deadline) - const { request: approveSpenderOnPermit2 } = await publicClient.simulateContract({ - address: PERMIT2[chainId], - abi: permit2Abi, - functionName: "approve", - account: walletClient.account, - args: [token.address, BALANCER_ROUTER[chainId], MaxUint160, MaxUint48], - }); - await writeTx(() => walletClient.writeContract(approveSpenderOnPermit2), { - blockConfirmations: 1, - onBlockConfirmation: () => { - console.log("Approved router to spend max amount of", token.address); - refetchTokenAllowances(); - setIsApproving(false); - }, - }); - } catch (error) { - console.error("Approval error", error); - setIsApproving(false); - } - }); - }; - - const handleAddLiquidity = async () => { - try { - setIsAddingLiquidity(true); - await addLiquidity(); - refetchTokenAllowances(); - refetchTokenBalances(); - refetchPool(); - } catch (e) { - console.error("error", e); - } finally { - setIsAddingLiquidity(false); - } - }; - - useContractEvent({ - address: pool.address, - abi: abis.balancer.Pool, - eventName: "Transfer", - listener(log: any[]) { - const data: TokenInfo = { - symbol: pool.symbol, - name: pool.name, - decimals: pool.decimals, - rawAmount: log[0].args.value, - }; - setAddLiquidityReceipt({ data: [data], transactionHash: log[0].transactionHash }); - }, - }); - - const { expectedBptOut, minBptOut } = queryResponse || {}; - - return ( -
-
- {tokenInputs.map((token, index) => { - const humanInputAmount = formatUnits(token.rawAmount, token.decimals); - return ( - handleInputChange(index, value)} - /> - ); - })} -
- - {!expectedBptOut || (expectedBptOut && addLiquidityReceipt) ? ( - token.rawAmount === 0n)} - /> - ) : !sufficientAllowances ? ( - - ) : ( - - )} - - {addLiquidityReceipt && ( - - )} - - {expectedBptOut && minBptOut && ( - - )} - - {queryError && } -
- ); -}; diff --git a/packages/nextjs/app/pools/_components/actions/Alerts.tsx b/packages/nextjs/app/pools/_components/actions/Alerts.tsx deleted file mode 100644 index 946dc4c0..00000000 --- a/packages/nextjs/app/pools/_components/actions/Alerts.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { usePublicClient } from "wagmi"; -import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; -import { type TokenInfo } from "~~/hooks/balancer/types"; -import { formatToHuman } from "~~/utils/"; -import { getBlockExplorerTxLink } from "~~/utils/scaffold-eth"; - -interface QueryResponseAlertProps { - title: string; - data: queryData[]; -} - -type queryData = { - type: string; - description?: string; - rawAmount: bigint; - decimals: number; -}; - -/** - * Displays results after successful query (just a style wrapper for query result info) - */ -export const QueryResponseAlert: React.FC = ({ title, data }) => { - return ( -
-
{title}
-
- {data && - data.map((item, idx) => ( -
-
-
{item.type}
-
{item.description}
-
-
-
{formatToHuman(item.rawAmount, item.decimals)}
-
{item.rawAmount.toString()}
-
-
- ))} -
-
- ); -}; - -/** - * Displays if query thdatas an error - */ -export const QueryErrorAlert: React.FC<{ message: string }> = ({ message }) => { - return ( -
-
Error
-
-
{message}
-
-
- ); -}; - -interface TransactionReceiptAlertProps { - transactionHash: string; - title: string; - data: TokenInfo[]; -} -/** - * Displays after successful pool operation transaction - */ -export const TransactionReceiptAlert: React.FC = ({ title, transactionHash, data }) => { - const publicClient = usePublicClient(); - const chainId = publicClient?.chain.id as number; - const transactionUrl = getBlockExplorerTxLink(chainId, transactionHash); - - return ( -
-
-
{title}
- {chainId !== 31337 && ( - - block explorer - - )} -
- -
- {data && - data.map((token, idx) => ( -
-
-
{token.symbol}
-
{token.name}
-
-
-
{formatToHuman(token.rawAmount, token.decimals)}
-
{token.rawAmount.toString()}
-
-
- ))} -
-
- ); -}; diff --git a/packages/nextjs/app/pools/_components/actions/RemoveLiquidityForm.tsx b/packages/nextjs/app/pools/_components/actions/RemoveLiquidityForm.tsx deleted file mode 100644 index 40ba2f6f..00000000 --- a/packages/nextjs/app/pools/_components/actions/RemoveLiquidityForm.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useState } from "react"; -import { PoolActionButton, QueryErrorAlert, QueryResponseAlert, TokenField, TransactionReceiptAlert } from "."; -import { PoolActionsProps } from "../PoolActions"; -import { BALANCER_ROUTER, VAULT_V3, vaultV3Abi } from "@balancer/sdk"; -import { parseUnits } from "viem"; -import { useContractEvent } from "wagmi"; -import { useApprove, useRemoveLiquidity, useTargetFork } from "~~/hooks/balancer/"; -import { - PoolActionReceipt, - QueryPoolActionError, - QueryRemoveLiquidityResponse, - TokenInfo, -} from "~~/hooks/balancer/types"; -import { formatToHuman } from "~~/utils/"; - -/** - * 1. Query removing some amount of liquidity from the pool - * 2. Send transaction to remove liquidity from the pool - * 3. Display the transaction results to the user - */ -export const RemoveLiquidityForm: React.FC = ({ pool, refetchPool, refetchTokenBalances }) => { - const [queryResponse, setQueryResponse] = useState(null); - const [queryError, setQueryError] = useState(null); - const [isQuerying, setIsQuerying] = useState(false); - const [isRemovingLiquidity, setIsRemovingLiquidity] = useState(false); - const [removeLiquidityReceipt, setRemoveLiquidityReceipt] = useState(null); - const [bptIn, setBptIn] = useState({ - rawAmount: 0n, - displayValue: "", - }); - - const { queryRemoveLiquidity, removeLiquidity } = useRemoveLiquidity(pool); - const { chainId } = useTargetFork(); - const { approveSpenderOnToken: approveRouterOnToken } = useApprove(pool.address, BALANCER_ROUTER[chainId]); - - const handleAmountChange = (amount: string) => { - setQueryError(null); - setRemoveLiquidityReceipt(null); - const rawAmount = parseUnits(amount, pool.decimals); - setBptIn({ rawAmount, displayValue: amount }); - setQueryResponse(null); - }; - - const handleQuery = async () => { - setQueryError(null); - setRemoveLiquidityReceipt(null); - setIsQuerying(true); - const response = await queryRemoveLiquidity(bptIn.rawAmount); - if (response.error) { - setQueryError(response.error); - } else { - const { expectedAmountsOut, minAmountsOut } = response; - setQueryResponse({ expectedAmountsOut, minAmountsOut }); - } - setIsQuerying(false); - }; - - const handleRemoveLiquidity = async () => { - try { - setIsRemovingLiquidity(true); - // Before removing liquidity, must approve Router to spend account's BPT - await approveRouterOnToken(); - await removeLiquidity(); - refetchPool(); - refetchTokenBalances(); - } catch (error) { - console.error("Error removing liquidity", error); - } finally { - setIsRemovingLiquidity(false); - } - }; - - const setMaxAmount = () => { - setBptIn({ - rawAmount: pool.userBalance, - displayValue: formatToHuman(pool.userBalance || 0n, pool.decimals), - }); - setQueryResponse(null); - }; - - useContractEvent({ - address: VAULT_V3[chainId], - abi: vaultV3Abi, - eventName: "PoolBalanceChanged", - listener(log: any[]) { - const data: TokenInfo[] = log[0].args.deltas.map((delta: bigint, idx: number) => ({ - symbol: pool.poolTokens[idx].symbol, - name: pool.poolTokens[idx].name, - rawAmount: -delta, - decimals: pool.poolTokens[idx].decimals, - })); - setRemoveLiquidityReceipt({ data, transactionHash: log[0].transactionHash }); - }, - }); - - const { expectedAmountsOut } = queryResponse ?? {}; - - return ( -
- - - {!expectedAmountsOut || (expectedAmountsOut && removeLiquidityReceipt) ? ( - - ) : ( - - )} - - {removeLiquidityReceipt && ( - - )} - - {expectedAmountsOut && ( - ({ - type: token.symbol, - description: token.name, - rawAmount: expectedAmountsOut[index].amount, - decimals: token.decimals, - }))} - /> - )} - - {queryError && } -
- ); -}; diff --git a/packages/nextjs/app/pools/_components/actions/SwapForm.tsx b/packages/nextjs/app/pools/_components/actions/SwapForm.tsx deleted file mode 100644 index 87f10230..00000000 --- a/packages/nextjs/app/pools/_components/actions/SwapForm.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { useMemo, useState } from "react"; -import { PoolActionButton, QueryErrorAlert, QueryResponseAlert, TokenField, TransactionReceiptAlert } from "."; -import { PoolActionsProps } from "../PoolActions"; -import { BALANCER_ROUTER, PERMIT2, SwapKind, VAULT_V3, vaultV3Abi } from "@balancer/sdk"; -import { parseUnits } from "viem"; -import { useContractEvent } from "wagmi"; -import { useApprove, useSwap, useTargetFork, useToken } from "~~/hooks/balancer/"; -import { - PoolActionReceipt, - QueryPoolActionError, - QuerySwapResponse, - SwapConfig, - TokenInfo, -} from "~~/hooks/balancer/types"; -import { useTransactor } from "~~/hooks/scaffold-eth"; -import { formatToHuman } from "~~/utils/"; - -const initialSwapConfig = { - tokenIn: { - poolTokensIndex: 0, - amount: "", - rawAmount: 0n, - }, - tokenOut: { - poolTokensIndex: 1, - amount: "", - rawAmount: 0n, - }, - swapKind: SwapKind.GivenOut, -}; - -/** - * 1. Choose tokenIn and tokenOut - * 2. Query swapping some amount of tokens in the pool - * 3. Approve the vault for the tokenIn used in the swap transaction (if necessary) - * 4. Send transaction to swap the tokens - */ -export const SwapForm: React.FC = ({ pool, refetchPool, tokenBalances, refetchTokenBalances }) => { - const [queryResponse, setQueryResponse] = useState(null); - const [queryError, setQueryError] = useState(null); - const [swapConfig, setSwapConfig] = useState(initialSwapConfig); - const [isTokenOutDropdownOpen, setTokenOutDropdownOpen] = useState(false); - const [isTokenInDropdownOpen, setTokenInDropdownOpen] = useState(false); - const [swapReceipt, setSwapReceipt] = useState(null); - const [isApproving, setIsApproving] = useState(false); - const [isSwapping, setIsSwapping] = useState(false); - const [isQuerying, setIsQuerying] = useState(false); - - const tokenIn = pool.poolTokens[swapConfig.tokenIn.poolTokensIndex]; - const tokenOut = pool.poolTokens[swapConfig.tokenOut.poolTokensIndex]; - - const writeTx = useTransactor(); - const { chainId } = useTargetFork(); - const { querySwap, swap } = useSwap(pool, swapConfig); - const { approveSpenderOnToken: approvePermit2OnToken } = useApprove(tokenIn.address, PERMIT2[chainId]); - const { approveSpenderOnPermit2: approveRouterOnPermit2 } = useApprove(tokenIn.address, BALANCER_ROUTER[chainId]); - const { tokenAllowance, refetchTokenAllowance } = useToken(tokenIn.address); - - const sufficientAllowance = useMemo(() => { - return tokenAllowance && tokenAllowance >= swapConfig.tokenIn.rawAmount; - }, [tokenAllowance, swapConfig.tokenIn.rawAmount]); - - const handleTokenAmountChange = (amount: string, swapConfigKey: "tokenIn" | "tokenOut") => { - // Clean up UI to prepare for new query - setQueryResponse(null); - setSwapReceipt(null); - setQueryError(null); - // Update the focused input amount with new value and reset the other input amount - setSwapConfig(prevConfig => ({ - tokenIn: { - ...prevConfig.tokenIn, - amount: swapConfigKey === "tokenIn" ? amount : "", - rawAmount: swapConfigKey === "tokenIn" ? parseUnits(amount, tokenIn.decimals) : 0n, - }, - tokenOut: { - ...prevConfig.tokenOut, - amount: swapConfigKey === "tokenOut" ? amount : "", - rawAmount: swapConfigKey === "tokenOut" ? parseUnits(amount, tokenOut.decimals) : 0n, - }, - swapKind: swapConfigKey === "tokenIn" ? SwapKind.GivenIn : SwapKind.GivenOut, - })); - }; - - const handleTokenSelection = (selectedSymbol: string, swapConfigKey: "tokenIn" | "tokenOut") => { - const selectedIndex = pool.poolTokens.findIndex(token => token.symbol === selectedSymbol); - const otherIndex = pool.poolTokens.length === 2 ? (selectedIndex === 0 ? 1 : 0) : -1; - - setSwapConfig(prevConfig => ({ - ...prevConfig, - [swapConfigKey]: { - // Update the selected token with the new index and reset the amount - poolTokensIndex: selectedIndex, - amount: "", - }, - // If there are only two tokens in pool, automatically set the other token - ...(pool.poolTokens.length === 2 && { - [swapConfigKey === "tokenIn" ? "tokenOut" : "tokenIn"]: { - poolTokensIndex: otherIndex, - amount: "", - }, - }), - })); - - setTokenInDropdownOpen(false); - setTokenOutDropdownOpen(false); - setQueryResponse(null); - }; - - const handleQuerySwap = async () => { - setQueryResponse(null); - setSwapReceipt(null); - setIsQuerying(true); - const response = await querySwap(); - if (response.error) { - setQueryError(response.error); - } else { - const { swapKind, expectedAmount, minOrMaxAmount } = response; - setQueryResponse({ - expectedAmount, - minOrMaxAmount, - swapKind, - }); - - // update the unfilled token input field appropriately - const rawExpectedAmount = expectedAmount?.amount ?? 0n; - if (swapKind === SwapKind.GivenIn) { - setSwapConfig(prevConfig => ({ - ...prevConfig, - tokenOut: { - ...prevConfig.tokenOut, - amount: formatToHuman(rawExpectedAmount, tokenOut.decimals), - rawAmount: rawExpectedAmount, - }, - })); - } else { - setSwapConfig(prevConfig => ({ - ...prevConfig, - tokenIn: { - ...prevConfig.tokenIn, - amount: formatToHuman(rawExpectedAmount, tokenIn.decimals), - rawAmount: rawExpectedAmount, - }, - })); - } - } - setIsQuerying(false); - }; - - const handleApprove = async () => { - try { - setIsApproving(true); - await writeTx(approvePermit2OnToken, { - blockConfirmations: 1, - onBlockConfirmation: () => { - refetchTokenAllowance(); - setIsApproving(false); - }, - }); - await writeTx(approveRouterOnPermit2, { - blockConfirmations: 1, - onBlockConfirmation: () => { - refetchTokenAllowance(); - setIsApproving(false); - }, - }); - } catch (err) { - console.error("error", err); - setIsApproving(false); - } - }; - - const handleSwap = async () => { - try { - const tokenBalance = tokenBalances[tokenIn.address]; - if (tokenBalance === null || tokenBalance === undefined || tokenBalance < swapConfig.tokenIn.rawAmount) { - throw new Error("Insufficient user balance"); - } - setIsSwapping(true); - await swap(); - refetchPool(); - refetchTokenAllowance(); - refetchTokenBalances(); - } catch (e) { - if (e instanceof Error) { - console.error("error", e); - setQueryError({ message: e.message }); - } else { - console.error("An unexpected error occurred", e); - setQueryError({ message: "An unexpected error occurred" }); - } - } finally { - setIsSwapping(false); - } - }; - - useContractEvent({ - address: VAULT_V3[chainId], - abi: vaultV3Abi, - eventName: "Swap", - listener(log: any[]) { - const data: TokenInfo[] = [ - { - decimals: tokenIn.decimals, - rawAmount: log[0].args.amountIn, - symbol: `${tokenIn.symbol} In`, - name: tokenIn.name, - }, - { - decimals: tokenOut.decimals, - rawAmount: log[0].args.amountOut, - symbol: `${tokenOut.symbol} Out`, - name: tokenOut.name, - }, - ]; - - setSwapReceipt({ data, transactionHash: log[0].transactionHash }); - }, - }); - - const { expectedAmount, minOrMaxAmount } = queryResponse ?? {}; - - return ( -
- handleTokenAmountChange(value, "tokenIn")} - onTokenSelect={symbol => handleTokenSelection(symbol, "tokenIn")} - tokenDropdownOpen={isTokenInDropdownOpen} - setTokenDropdownOpen={setTokenInDropdownOpen} - selectableTokens={pool.poolTokens.filter(token => token.symbol !== tokenIn.symbol)} - isHighlighted={queryResponse?.swapKind === SwapKind.GivenIn} - /> - handleTokenAmountChange(value, "tokenOut")} - onTokenSelect={symbol => handleTokenSelection(symbol, "tokenOut")} - tokenDropdownOpen={isTokenOutDropdownOpen} - setTokenDropdownOpen={setTokenOutDropdownOpen} - selectableTokens={pool.poolTokens.filter(token => token.symbol !== tokenOut.symbol)} - isHighlighted={queryResponse?.swapKind === SwapKind.GivenOut} - /> - - {!expectedAmount || (expectedAmount && swapReceipt) ? ( - - ) : !sufficientAllowance ? ( - - ) : ( - - )} - - {queryError && } - - {swapReceipt && ( - - )} - - {expectedAmount && minOrMaxAmount && ( - - )} -
- ); -}; diff --git a/packages/nextjs/app/pools/_components/index.tsx b/packages/nextjs/app/pools/_components/index.tsx index bb9c31ed..01f94246 100644 --- a/packages/nextjs/app/pools/_components/index.tsx +++ b/packages/nextjs/app/pools/_components/index.tsx @@ -1,7 +1,4 @@ -export * from "./PoolActions"; -export * from "./PoolComposition"; -export * from "./PoolAttributes"; -export * from "./UserLiquidity"; export * from "./PoolSelector"; -export * from "./PoolConfig"; -export * from "./HooksConfig"; +export * from "./info"; +export * from "./operations"; +export * from "./PoolPageSkeleton"; diff --git a/packages/nextjs/app/pools/_components/HooksConfig.tsx b/packages/nextjs/app/pools/_components/info/HooksConfig.tsx similarity index 100% rename from packages/nextjs/app/pools/_components/HooksConfig.tsx rename to packages/nextjs/app/pools/_components/info/HooksConfig.tsx diff --git a/packages/nextjs/app/pools/_components/PoolAttributes.tsx b/packages/nextjs/app/pools/_components/info/PoolAttributes.tsx similarity index 100% rename from packages/nextjs/app/pools/_components/PoolAttributes.tsx rename to packages/nextjs/app/pools/_components/info/PoolAttributes.tsx diff --git a/packages/nextjs/app/pools/_components/PoolComposition.tsx b/packages/nextjs/app/pools/_components/info/PoolComposition.tsx similarity index 53% rename from packages/nextjs/app/pools/_components/PoolComposition.tsx rename to packages/nextjs/app/pools/_components/info/PoolComposition.tsx index 6b0e4068..643911bf 100644 --- a/packages/nextjs/app/pools/_components/PoolComposition.tsx +++ b/packages/nextjs/app/pools/_components/info/PoolComposition.tsx @@ -1,5 +1,5 @@ +import { TokenAmountDisplay } from "~~/components/common"; import { type Pool } from "~~/hooks/balancer/types"; -import { formatToHuman } from "~~/utils/"; /** * Display a pool's token composition including the tokens' symbols, names, and balances @@ -16,17 +16,14 @@ export const PoolComposition = ({ pool }: { pool: Pool }) => {
- {pool.poolTokens.map(token => ( -
-
-
{token.symbol}
-
{token.name}
-
-
-
{formatToHuman(token.balance, token.decimals)}
-
{token.balance.toString()}
-
-
+ {pool.poolTokens.map((token, idx) => ( + ))}
diff --git a/packages/nextjs/app/pools/_components/PoolConfig.tsx b/packages/nextjs/app/pools/_components/info/PoolConfig.tsx similarity index 100% rename from packages/nextjs/app/pools/_components/PoolConfig.tsx rename to packages/nextjs/app/pools/_components/info/PoolConfig.tsx diff --git a/packages/nextjs/app/pools/_components/info/UserLiquidity.tsx b/packages/nextjs/app/pools/_components/info/UserLiquidity.tsx new file mode 100644 index 00000000..9e426ff2 --- /dev/null +++ b/packages/nextjs/app/pools/_components/info/UserLiquidity.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react"; +import { useAccount } from "wagmi"; +import { TokenAmountDisplay } from "~~/components/common/"; +import { type Pool, useQueryRemoveLiquidity } from "~~/hooks/balancer/"; + +/** + * If there is a connected user, display their liquidity within the pool + */ +export const UserLiquidity = ({ pool }: { pool: Pool }) => { + const { isConnected } = useAccount(); + const { data: queryResponse, refetch: refetchQueryRemove } = useQueryRemoveLiquidity( + "queryRemoveMax", + pool, + pool.userBalance, + ); + + // Hacky solution to display user's proportional token balances within the pool + useEffect(() => { + refetchQueryRemove(); + }, [pool.userBalance, refetchQueryRemove]); + + // only render the component if the pool is initialized and the user is connected + if (!isConnected || !pool?.poolConfig?.isPoolInitialized) { + return null; + } + + return ( +
+
+
My Liquidity
+ +
+
+ +
+
+ {pool.poolTokens.map((token, index) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/packages/nextjs/app/pools/_components/info/index.tsx b/packages/nextjs/app/pools/_components/info/index.tsx new file mode 100644 index 00000000..251e52bf --- /dev/null +++ b/packages/nextjs/app/pools/_components/info/index.tsx @@ -0,0 +1,5 @@ +export * from "./PoolComposition"; +export * from "./PoolAttributes"; +export * from "./UserLiquidity"; +export * from "./PoolConfig"; +export * from "./HooksConfig"; diff --git a/packages/nextjs/app/pools/_components/operations/AddLiquidityForm.tsx b/packages/nextjs/app/pools/_components/operations/AddLiquidityForm.tsx new file mode 100644 index 00000000..c02b296e --- /dev/null +++ b/packages/nextjs/app/pools/_components/operations/AddLiquidityForm.tsx @@ -0,0 +1,165 @@ +import React, { useState } from "react"; +import { ResultsDisplay, TokenField, TransactionButton } from "."; +import { InputAmount, calculateProportionalAmounts } from "@balancer/sdk"; +import { useQueryClient } from "@tanstack/react-query"; +import { formatUnits, parseUnits } from "viem"; +import { useContractEvent } from "wagmi"; +import { Alert } from "~~/components/common/"; +import abis from "~~/contracts/abis"; +import { useAddLiquidity, useQueryAddLiquidity } from "~~/hooks/balancer/"; +import { PoolActionsProps, PoolOperationReceipt, TokenAmountDetails } from "~~/hooks/balancer/types"; +import { useApproveTokens, useReadTokens } from "~~/hooks/token/"; + +/** + * 1. Query adding some amount of liquidity to the pool + * 2. Approve the Balancer vault to spend the tokens to be used in the transaction (if necessary) + * 3. Send transaction to add liquidity to the pool + * 4. Display the transaction results to the user + */ +export const AddLiquidityForm: React.FC = ({ + pool, + refetchPool, + tokenBalances, + refetchTokenBalances, +}) => { + const initialTokenInputs = pool.poolTokens.map(token => ({ + address: token.address as `0x${string}`, + decimals: token.decimals, + rawAmount: 0n, + })); + const [tokenInputs, setTokenInputs] = useState(initialTokenInputs); + const [addLiquidityReceipt, setAddLiquidityReceipt] = useState(null); + const [referenceAmount, setReferenceAmount] = useState(); // only for the proportional add liquidity case + + const { + data: queryResponse, + isFetching: isQueryFetching, + error: queryError, + refetch: refetchQueryAddLiquidity, + } = useQueryAddLiquidity(pool, tokenInputs, referenceAmount); + const { sufficientAllowances, isApproving, approveTokens } = useApproveTokens(tokenInputs); + const { mutate: addLiquidity, isPending: isAddLiquidityPending, error: addLiquidityError } = useAddLiquidity(); + const { refetchTokenAllowances } = useReadTokens(tokenInputs); + const queryClient = useQueryClient(); + + const handleInputChange = (index: number, value: string) => { + queryClient.removeQueries({ queryKey: ["queryAddLiquidity"] }); + setAddLiquidityReceipt(null); + const updatedTokens = tokenInputs.map((token, idx) => { + if (idx === index) { + return { ...token, rawAmount: parseUnits(value, token.decimals) }; + } + return token; + }); + + if (pool.poolConfig?.liquidityManagement.disableUnbalancedLiquidity) { + // Read pool supply and token balances on-chain + const poolStateWithBalances = { + address: pool.address as `0x${string}`, + totalShares: formatUnits(pool.totalSupply, pool.decimals) as `${number}`, + tokens: pool.poolTokens.map(token => ({ + address: token.address as `0x${string}`, + decimals: token.decimals, + balance: formatUnits(token.balance, token.decimals) as `${number}`, + })), + }; + + const referenceAmount = updatedTokens[index]; + const { bptAmount, tokenAmounts } = calculateProportionalAmounts(poolStateWithBalances, referenceAmount); + setReferenceAmount(bptAmount); + setTokenInputs(tokenAmounts); + } else { + setTokenInputs(updatedTokens); + } + }; + + const handleQueryAddLiquidity = () => { + queryClient.removeQueries({ queryKey: ["queryAddLiquidity"] }); + refetchQueryAddLiquidity(); + setAddLiquidityReceipt(null); + }; + + const handleAddLiquidity = () => { + addLiquidity(queryResponse, { + onSuccess: () => { + refetchTokenAllowances(); + refetchTokenBalances(); + refetchPool(); + }, + }); + }; + + // Listen for Transfer events to update the UI with the actual BPT out amount + useContractEvent({ + address: pool.address, + abi: abis.balancer.Pool, + eventName: "Transfer", + listener(log: any[]) { + const data: TokenAmountDetails = { + symbol: pool.symbol, + name: pool.name, + decimals: pool.decimals, + rawAmount: log[0].args.value, + }; + setAddLiquidityReceipt({ data: [data], transactionHash: log[0].transactionHash }); + }, + }); + + const error = queryError || addLiquidityError; + const isFormEmpty = tokenInputs.some(token => token.rawAmount === 0n); + + return ( +
+ {tokenInputs.map((token, index) => { + const humanInputAmount = formatUnits(token.rawAmount, token.decimals); + return ( + handleInputChange(index, value)} + /> + ); + })} + + {!queryResponse || addLiquidityReceipt || isFormEmpty ? ( + + ) : !sufficientAllowances ? ( + + ) : ( + + )} + + {queryResponse && ( + + )} + + {addLiquidityReceipt && ( + + )} + + {(error as Error) && {(error as Error).message}} +
+ ); +}; diff --git a/packages/nextjs/app/pools/_components/PoolActions.tsx b/packages/nextjs/app/pools/_components/operations/PoolOperations.tsx similarity index 53% rename from packages/nextjs/app/pools/_components/PoolActions.tsx rename to packages/nextjs/app/pools/_components/operations/PoolOperations.tsx index 2f32447c..24dc6f4d 100644 --- a/packages/nextjs/app/pools/_components/PoolActions.tsx +++ b/packages/nextjs/app/pools/_components/operations/PoolOperations.tsx @@ -1,39 +1,26 @@ import { useState } from "react"; -import { AddLiquidityForm, RemoveLiquidityForm, SwapForm } from "./actions"; +import { AddLiquidityForm, RemoveLiquidityForm, SwapForm } from "."; import { useAccount } from "wagmi"; -import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; -import { useTokens } from "~~/hooks/balancer"; -import { type Pool, type TokenBalances } from "~~/hooks/balancer/types"; -import { type RefetchPool } from "~~/hooks/balancer/useReadPool"; +import { Alert } from "~~/components/common"; +import { Pool, RefetchPool, TokenBalances } from "~~/hooks/balancer"; import { useAccountBalance, useScaffoldContractWrite } from "~~/hooks/scaffold-eth"; +import { useReadTokens } from "~~/hooks/token"; -type Action = "Swap" | "AddLiquidity" | "RemoveLiquidity"; - -export interface PoolActionsProps { - pool: Pool; - refetchPool: RefetchPool; - tokenBalances: TokenBalances; - refetchTokenBalances: () => void; -} +type Operation = "Swap" | "AddLiquidity" | "RemoveLiquidity"; /** * Allow user to swap, add liquidity, and remove liquidity from a pool */ -export const PoolActions: React.FC<{ pool: Pool; refetchPool: RefetchPool }> = ({ pool, refetchPool }) => { - const [activeTab, setActiveTab] = useState("Swap"); - - const { address } = useAccount(); - const { balance } = useAccountBalance(address); +export const PoolOperations: React.FC<{ pool: Pool; refetchPool: RefetchPool }> = ({ pool, refetchPool }) => { + const [activeTab, setActiveTab] = useState("Swap"); const tokens = pool.poolTokens.map(token => ({ address: token.address as `0x${string}`, decimals: token.decimals, - rawAmount: 0n, // Quirky solution cus useTokens expects type InputAmount[] cus originally built for AddLiquidityForm :D + rawAmount: 0n, // Quirky solution cus useReadTokens expects type InputAmount[] cus originally built for AddLiquidityForm :D })); - const { tokenBalances, refetchTokenBalances } = useTokens(tokens); - - const userHasNoTokens = Object.values(tokenBalances).every(balance => balance === 0n); + const { tokenBalances, refetchTokenBalances } = useReadTokens(tokens); const tabs = { Swap: ( @@ -65,22 +52,22 @@ export const PoolActions: React.FC<{ pool: Pool; refetchPool: RefetchPool }> = ( return (
-
-
Pool Actions
- {address && !balance ? ( - Click the faucet button in the top right corner! - ) : balance !== 0 && userHasNoTokens ? ( - - ) : pool.poolConfig?.liquidityManagement.disableUnbalancedLiquidity ? ( - This pool only allows adding liquidity proportionally - ) : null} +
+
Pool Operations
+
+ +
{Object.keys(tabs).map(tab => ( - {tokenDropdownOpen ? ( + {isDropdownOpen ? (
    {selectableTokens && - onTokenSelect && selectableTokens.map(token => (
  • onTokenSelect(token.symbol)} + onClick={() => handleTokenSelect(token.symbol)} className="hover:bg-neutral-400 hover:bg-opacity-40 rounded-xl text-lg" > {token.symbol} diff --git a/packages/nextjs/app/pools/_components/actions/PoolActionButton.tsx b/packages/nextjs/app/pools/_components/operations/TransactionButton.tsx similarity index 89% rename from packages/nextjs/app/pools/_components/actions/PoolActionButton.tsx rename to packages/nextjs/app/pools/_components/operations/TransactionButton.tsx index 10390bbd..9b11a047 100644 --- a/packages/nextjs/app/pools/_components/actions/PoolActionButton.tsx +++ b/packages/nextjs/app/pools/_components/operations/TransactionButton.tsx @@ -12,7 +12,7 @@ interface PoolActionButtonProps { * Approve button is outlined style * Swap, AddLiquidity, and RemoveLiquidity buttons are solid gradient style */ -export const PoolActionButton: React.FC = ({ onClick, label, isDisabled, isFormEmpty }) => { +export const TransactionButton: React.FC = ({ onClick, label, isDisabled, isFormEmpty }) => { const outlined = `border border-base-100 hover:bg-base-100`; const gradient = `shadow-md bg-gradient-to-r from-violet-400 via-orange-100 to-orange-300 hover:from-violet-300 hover:via-orange-100 hover:to-orange-400 text-neutral-700 `; diff --git a/packages/nextjs/app/pools/_components/actions/index.tsx b/packages/nextjs/app/pools/_components/operations/index.tsx similarity index 55% rename from packages/nextjs/app/pools/_components/actions/index.tsx rename to packages/nextjs/app/pools/_components/operations/index.tsx index 05fd243c..c6292140 100644 --- a/packages/nextjs/app/pools/_components/actions/index.tsx +++ b/packages/nextjs/app/pools/_components/operations/index.tsx @@ -2,5 +2,6 @@ export * from "./RemoveLiquidityForm"; export * from "./AddLiquidityForm"; export * from "./SwapForm"; export * from "./TokenField"; -export * from "./PoolActionButton"; -export * from "./Alerts"; +export * from "./TransactionButton"; +export * from "./ResultsDisplay"; +export * from "./PoolOperations"; diff --git a/packages/nextjs/app/pools/page.tsx b/packages/nextjs/app/pools/page.tsx index 21692b91..78b129b9 100644 --- a/packages/nextjs/app/pools/page.tsx +++ b/packages/nextjs/app/pools/page.tsx @@ -1,22 +1,13 @@ "use client"; -import { Fragment, Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; -import { - HooksConfig, - PoolActions, - PoolAttributes, - PoolComposition, - PoolConfig, - PoolSelector, - UserLiquidity, -} from "./_components/"; +import { PoolOperations, PoolPageSkeleton, PoolSelector } from "./_components/"; +import { HooksConfig, PoolAttributes, PoolComposition, PoolConfig, UserLiquidity } from "./_components/info"; import { type NextPage } from "next"; import { type Address } from "viem"; -import { SkeletonLoader } from "~~/components/common"; -import { useReadPool } from "~~/hooks/balancer"; -import { type Pool } from "~~/hooks/balancer/types"; -import { type RefetchPool } from "~~/hooks/balancer/useReadPool"; +import { Alert } from "~~/components/common"; +import { type Pool, type RefetchPool, useReadPool } from "~~/hooks/balancer/"; /** * 1. Search by pool address or select from dropdown @@ -57,25 +48,22 @@ const PoolPageContent = () => { }, [poolAddress]); return ( - + <> {isLoading ? ( ) : isError ? ( -
    -
    Error fetching pool data. The pool contract address was not valid
    -
    {selectedPoolAddress}
    -
    + Error attempting to fetch pool data for {selectedPoolAddress} ) : ( isSuccess && pool && )} -
    + ); }; const PoolDashboard = ({ pool, refetchPool }: { pool: Pool; refetchPool: RefetchPool }) => { return ( - + <>

    {pool.name}

    @@ -90,40 +78,12 @@ const PoolDashboard = ({ pool, refetchPool }: { pool: Pool; refetchPool: Refetch
{pool.poolConfig?.isPoolInitialized && ( - + )}
- - ); -}; - -const PoolPageSkeleton = () => { - return ( -
-
-
-
- -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
+ ); }; diff --git a/packages/nextjs/app/router/layout.tsx b/packages/nextjs/app/router/layout.tsx deleted file mode 100644 index 0fea47d3..00000000 --- a/packages/nextjs/app/router/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { getMetadata } from "~~/utils/scaffold-eth/getMetadata"; - -export const metadata = getMetadata({ - title: "Smart Order Router", - description: "Integrate pools with the Smart Order Router", -}); - -const RouterLayout = ({ children }: { children: React.ReactNode }) => { - return ( - <> -
-
{children}
-
- - ); -}; - -export default RouterLayout; diff --git a/packages/nextjs/app/router/page.tsx b/packages/nextjs/app/router/page.tsx deleted file mode 100644 index 750e9f21..00000000 --- a/packages/nextjs/app/router/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import Link from "next/link"; -import type { NextPage } from "next"; - -const Router: NextPage = () => { - return ( -
-
-

Smart Order Router

- - Guide coming soon™️ - -
-
- ); -}; - -export default Router; diff --git a/packages/nextjs/app/subgraph/layout.tsx b/packages/nextjs/app/subgraph/layout.tsx deleted file mode 100644 index 87793669..00000000 --- a/packages/nextjs/app/subgraph/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { getMetadata } from "~~/utils/scaffold-eth/getMetadata"; - -export const metadata = getMetadata({ - title: "Subgraph", - description: "Balancer subgraph integration guide", -}); - -const SubgraphLayout = ({ children }: { children: React.ReactNode }) => { - return ( - <> -
-
{children}
-
- - ); -}; - -export default SubgraphLayout; diff --git a/packages/nextjs/app/subgraph/page.tsx b/packages/nextjs/app/subgraph/page.tsx deleted file mode 100644 index d5e674f4..00000000 --- a/packages/nextjs/app/subgraph/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import Link from "next/link"; -import type { NextPage } from "next"; - -const Subgraph: NextPage = () => { - return ( -
-
-

Subgraph

-

- In order for end users to interact with a pool, the contract must first be indexed by Balancer's official - subgraph.{" "} - - Guide coming soon™️ - -

-
-
- ); -}; - -export default Subgraph; diff --git a/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx b/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx index bfd2f17e..fb81b318 100644 --- a/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx +++ b/packages/nextjs/components/ScaffoldEthAppWithProviders.tsx @@ -1,8 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client"; import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useTheme } from "next-themes"; import { Toaster } from "react-hot-toast"; import { WagmiConfig } from "wagmi"; @@ -26,6 +26,15 @@ const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => { ); }; +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + structuralSharing: false, + }, + }, +}); + export const ScaffoldEthAppWithProviders = ({ children }: { children: React.ReactNode }) => { const { resolvedTheme } = useTheme(); const isDarkMode = resolvedTheme === "dark"; @@ -35,14 +44,8 @@ export const ScaffoldEthAppWithProviders = ({ children }: { children: React.Reac setMounted(true); }, []); - const subgraphUri = "https://api.studio.thegraph.com/query/31386/balancer-v3-sepolia/version/latest"; - const apolloClient = new ApolloClient({ - uri: subgraphUri, - cache: new InMemoryCache(), - }); - return ( - + {children} - + ); }; diff --git a/packages/nextjs/components/common/Alert.tsx b/packages/nextjs/components/common/Alert.tsx new file mode 100644 index 00000000..6bd1bba5 --- /dev/null +++ b/packages/nextjs/components/common/Alert.tsx @@ -0,0 +1,29 @@ +import { + CheckCircleIcon, + ExclamationCircleIcon, + ExclamationTriangleIcon, + InformationCircleIcon, +} from "@heroicons/react/24/outline"; + +interface AlertProps { + type: "error" | "warning" | "success" | "info"; // `type` is required + showIcon?: boolean; + children?: React.ReactNode; // `children` can be optional +} + +const alertTypeMap = { + error: { styles: "bg-error-tint border-error ", icon: }, + warning: { styles: "bg-warning-tint border-warning ", icon: }, + success: { styles: "bg-success-tint border-success ", icon: }, + info: { styles: "bg-info-tint border-info ", icon: }, +}; + +export const Alert: React.FC = ({ children, type, showIcon = true }) => { + const { styles, icon } = alertTypeMap[type]; + return ( +
+ {showIcon &&
{icon}
} +
{children}
+
+ ); +}; diff --git a/packages/nextjs/components/common/TokenAmountDisplay.tsx b/packages/nextjs/components/common/TokenAmountDisplay.tsx new file mode 100644 index 00000000..3475ffff --- /dev/null +++ b/packages/nextjs/components/common/TokenAmountDisplay.tsx @@ -0,0 +1,18 @@ +import { type TokenAmountDetails } from "~~/hooks/balancer/types"; +import { formatToHuman } from "~~/utils/"; + +export const TokenAmountDisplay = (data: TokenAmountDetails) => { + const { symbol, name, decimals, rawAmount } = data; + return ( +
+
+
{symbol}
+
{name}
+
+
+
{formatToHuman(rawAmount, decimals)}
+
{rawAmount.toString()}
+
+
+ ); +}; diff --git a/packages/nextjs/components/common/index.tsx b/packages/nextjs/components/common/index.tsx index 9a0b1a6d..881cd238 100644 --- a/packages/nextjs/components/common/index.tsx +++ b/packages/nextjs/components/common/index.tsx @@ -1 +1,4 @@ export * from "./SkeletonLoader"; +export * from "./Alert"; +export * from "./Modal"; +export * from "./TokenAmountDisplay"; diff --git a/packages/nextjs/hooks/balancer/addLiquidity/index.ts b/packages/nextjs/hooks/balancer/addLiquidity/index.ts new file mode 100644 index 00000000..2a21ccae --- /dev/null +++ b/packages/nextjs/hooks/balancer/addLiquidity/index.ts @@ -0,0 +1,2 @@ +export * from "./useQueryAddLiquidity"; +export * from "./useAddLiquidity"; diff --git a/packages/nextjs/hooks/balancer/addLiquidity/useAddLiquidity.ts b/packages/nextjs/hooks/balancer/addLiquidity/useAddLiquidity.ts new file mode 100644 index 00000000..dbec554f --- /dev/null +++ b/packages/nextjs/hooks/balancer/addLiquidity/useAddLiquidity.ts @@ -0,0 +1,47 @@ +import { AddLiquidity, AddLiquidityQueryOutput, Slippage } from "@balancer/sdk"; +import { useMutation } from "@tanstack/react-query"; +import { useWalletClient } from "wagmi"; +import { useTargetFork } from "~~/hooks/balancer"; +import { useTransactor } from "~~/hooks/scaffold-eth"; +import { getBlockExplorerTxLink } from "~~/utils/scaffold-eth"; + +export const useAddLiquidity = () => { + const { chainId } = useTargetFork(); + const { data: walletClient } = useWalletClient(); + const writeTx = useTransactor(); + const addLiquidity = new AddLiquidity(); + + const doAddLiquidity = async (queryOutput: AddLiquidityQueryOutput | undefined) => { + if (!walletClient) throw new Error("Must connect a wallet to add liquidity"); + if (!queryOutput) throw new Error("Query output is required to add liquidity"); + + const slippage = Slippage.fromPercentage("1"); // 1% + + // Applies slippage to the BPT out amount and constructs the call + const call = addLiquidity.buildCall({ + ...queryOutput, + slippage, + chainId, + wethIsEth: false, + }); + + const txHash = await writeTx( + () => + walletClient.sendTransaction({ + account: walletClient.account, + data: call.callData, + to: call.to, + value: call.value, + }), + { blockConfirmations: 1 }, + ); + + if (!txHash) throw new Error("Transaction failed"); + const blockExplorerTxURL = getBlockExplorerTxLink(chainId, txHash); + return blockExplorerTxURL; + }; + + return useMutation({ + mutationFn: (queryOutput: AddLiquidityQueryOutput | undefined) => doAddLiquidity(queryOutput), + }); +}; diff --git a/packages/nextjs/hooks/balancer/addLiquidity/useQueryAddLiquidity.ts b/packages/nextjs/hooks/balancer/addLiquidity/useQueryAddLiquidity.ts new file mode 100644 index 00000000..377a42aa --- /dev/null +++ b/packages/nextjs/hooks/balancer/addLiquidity/useQueryAddLiquidity.ts @@ -0,0 +1,53 @@ +import { + AddLiquidity, + AddLiquidityInput, + AddLiquidityKind, + InputAmount, + OnChainProvider, + PoolState, +} from "@balancer/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { useTargetFork } from "~~/hooks/balancer"; +import { Pool } from "~~/hooks/balancer/types"; + +export const useQueryAddLiquidity = (pool: Pool, amountsIn: InputAmount[], referenceAmount?: InputAmount) => { + const { rpcUrl, chainId } = useTargetFork(); + + const queryAddLiquidity = async () => { + console.log("Fetching query..."); + const onchainProvider = new OnChainProvider(rpcUrl, chainId); + const poolId = pool.address as `0x${string}`; + const poolState: PoolState = await onchainProvider.pools.fetchPoolState(poolId, "CustomPool"); + + // Construct the addLiquidity input object based on if pool allows unbalanced liquidity operations + const addLiquidityInput: AddLiquidityInput = + pool.poolConfig?.liquidityManagement.disableUnbalancedLiquidity && referenceAmount + ? { + kind: AddLiquidityKind.Proportional, + referenceAmount, + chainId, + rpcUrl, + } + : { + kind: AddLiquidityKind.Unbalanced, + amountsIn, + chainId, + rpcUrl, + }; + + // Query addLiquidity to get the amount of BPT out + const addLiquidity = new AddLiquidity(); + const queryOutput = await addLiquidity.query(addLiquidityInput, poolState); + + return queryOutput; + }; + + // const serializedAmountsIn = amountsIn.map(amount => `${amount.address}-${amount.rawAmount}`); + // const serializedBptOut = bptOut ? `${bptOut.address}-${bptOut.rawAmount}` : ""; + + return useQuery({ + queryKey: ["queryAddLiquidity"], + queryFn: queryAddLiquidity, + enabled: false, + }); +}; diff --git a/packages/nextjs/hooks/balancer/index.ts b/packages/nextjs/hooks/balancer/index.ts index ef974a91..a47625e6 100644 --- a/packages/nextjs/hooks/balancer/index.ts +++ b/packages/nextjs/hooks/balancer/index.ts @@ -1,10 +1,7 @@ export * from "./useReadPool"; export * from "./types"; -export * from "./useSwap"; -export * from "./useAddLiquidity"; -export * from "./useRemoveLiquidity"; export * from "./useTargetFork"; -export * from "./useToken"; -export * from "./useTokens"; -export * from "./useApprove"; export * from "./useFactoryHistory"; +export * from "./swap"; +export * from "./addLiquidity"; +export * from "./removeLiquidity"; diff --git a/packages/nextjs/hooks/balancer/removeLiquidity/index.ts b/packages/nextjs/hooks/balancer/removeLiquidity/index.ts new file mode 100644 index 00000000..73f3672e --- /dev/null +++ b/packages/nextjs/hooks/balancer/removeLiquidity/index.ts @@ -0,0 +1,2 @@ +export * from "./useRemoveLiquidity"; +export * from "./useQueryRemoveLiquidity"; diff --git a/packages/nextjs/hooks/balancer/removeLiquidity/useQueryRemoveLiquidity.ts b/packages/nextjs/hooks/balancer/removeLiquidity/useQueryRemoveLiquidity.ts new file mode 100644 index 00000000..8f48c5ac --- /dev/null +++ b/packages/nextjs/hooks/balancer/removeLiquidity/useQueryRemoveLiquidity.ts @@ -0,0 +1,47 @@ +import { + InputAmount, + OnChainProvider, + PoolState, + RemoveLiquidity, + RemoveLiquidityInput, + RemoveLiquidityKind, +} from "@balancer/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { Pool, useTargetFork } from "~~/hooks/balancer"; + +export const useQueryRemoveLiquidity = (queryKey: string, pool: Pool, rawAmount: bigint) => { + const { rpcUrl, chainId } = useTargetFork(); + + const queryRemoveLiquidity = async () => { + const onchainProvider = new OnChainProvider(rpcUrl, chainId); + const poolId = pool.address as `0x${string}`; + const poolState: PoolState = await onchainProvider.pools.fetchPoolState(poolId, "CustomPool"); + + // Construct the RemoveLiquidityInput, in this case a RemoveLiquiditySingleTokenExactIn + const bptIn: InputAmount = { + rawAmount, + decimals: pool.decimals, + address: poolState.address, + }; + + // Construct the RemoveLiquidityInput, in this case an RemoveLiquidityProportional + const removeLiquidityInput: RemoveLiquidityInput = { + chainId, + rpcUrl, + bptIn, + kind: RemoveLiquidityKind.Proportional, + }; + + // Query removeLiquidity to get the token out amounts + const removeLiquidity = new RemoveLiquidity(); + const queryOutput = await removeLiquidity.query(removeLiquidityInput, poolState); + + return queryOutput; + }; + + return useQuery({ + queryKey: [queryKey, rawAmount.toString()], + queryFn: queryRemoveLiquidity, + enabled: false, + }); +}; diff --git a/packages/nextjs/hooks/balancer/removeLiquidity/useRemoveLiquidity.ts b/packages/nextjs/hooks/balancer/removeLiquidity/useRemoveLiquidity.ts new file mode 100644 index 00000000..802e10a1 --- /dev/null +++ b/packages/nextjs/hooks/balancer/removeLiquidity/useRemoveLiquidity.ts @@ -0,0 +1,46 @@ +import { RemoveLiquidity, RemoveLiquidityQueryOutput, Slippage } from "@balancer/sdk"; +import { useMutation } from "@tanstack/react-query"; +import { useWalletClient } from "wagmi"; +import { useTargetFork } from "~~/hooks/balancer"; +import { useTransactor } from "~~/hooks/scaffold-eth"; +import { getBlockExplorerTxLink } from "~~/utils/scaffold-eth"; + +export const useRemoveLiquidity = () => { + const { data: walletClient } = useWalletClient(); + const removeLiquidity = new RemoveLiquidity(); + const { chainId } = useTargetFork(); + const writeTx = useTransactor(); + + const doRemoveLiquidity = async (queryOutput: RemoveLiquidityQueryOutput | undefined) => { + if (!walletClient) throw new Error("Must connect a wallet to send a transaction"); + if (!queryOutput) throw new Error("Query output is required to remove liquidity"); + + const slippage = Slippage.fromPercentage("1"); // 1% + // Construct call object for transaction + const call = removeLiquidity.buildCall({ + ...queryOutput, + slippage, + chainId, + wethIsEth: false, + }); + + const txHash = await writeTx( + () => + walletClient.sendTransaction({ + account: walletClient.account, + data: call.callData, + to: call.to, + value: call.value, + }), + { blockConfirmations: 1 }, + ); + if (!txHash) throw new Error("Transaction failed"); + + const blockExplorerTxURL = getBlockExplorerTxLink(chainId, txHash); + return blockExplorerTxURL; + }; + + return useMutation({ + mutationFn: (queryOutput: RemoveLiquidityQueryOutput | undefined) => doRemoveLiquidity(queryOutput), + }); +}; diff --git a/packages/nextjs/hooks/balancer/swap/index.ts b/packages/nextjs/hooks/balancer/swap/index.ts new file mode 100644 index 00000000..cd6a5773 --- /dev/null +++ b/packages/nextjs/hooks/balancer/swap/index.ts @@ -0,0 +1,2 @@ +export * from "./useQuerySwap"; +export * from "./useSwap"; diff --git a/packages/nextjs/hooks/balancer/swap/useQuerySwap.ts b/packages/nextjs/hooks/balancer/swap/useQuerySwap.ts new file mode 100644 index 00000000..6fbaca20 --- /dev/null +++ b/packages/nextjs/hooks/balancer/swap/useQuerySwap.ts @@ -0,0 +1,47 @@ +import { Dispatch, SetStateAction } from "react"; +import { Swap, SwapInput, SwapKind } from "@balancer/sdk"; +import { useQuery } from "@tanstack/react-query"; +import { SwapConfig, useTargetFork } from "~~/hooks/balancer"; +import { formatToHuman } from "~~/utils"; + +export const useQuerySwap = (swapInput: SwapInput, setSwapConfig: Dispatch>) => { + const { rpcUrl } = useTargetFork(); + + const querySwap = async () => { + const swap = new Swap(swapInput); + const queryOutput = await swap.query(rpcUrl); + + // Update the swap inputs UI with the expected amount + if (queryOutput.swapKind === SwapKind.GivenIn) { + const rawAmountExpected = queryOutput.expectedAmountOut.amount; + const decimals = queryOutput.expectedAmountOut.token.decimals; + setSwapConfig(prevConfig => ({ + ...prevConfig, + tokenOut: { + ...prevConfig.tokenOut, + amount: formatToHuman(rawAmountExpected, decimals), + rawAmount: rawAmountExpected, + }, + })); + } else { + const rawAmountExpected = queryOutput.expectedAmountIn.amount; + const decimals = queryOutput.expectedAmountIn.token.decimals; + setSwapConfig(prevConfig => ({ + ...prevConfig, + tokenIn: { + ...prevConfig.tokenIn, + amount: formatToHuman(rawAmountExpected, decimals), + rawAmount: rawAmountExpected, + }, + })); + } + + return queryOutput; + }; + + return useQuery({ + queryKey: ["querySwap"], + queryFn: querySwap, + enabled: false, + }); +}; diff --git a/packages/nextjs/hooks/balancer/swap/useSwap.ts b/packages/nextjs/hooks/balancer/swap/useSwap.ts new file mode 100644 index 00000000..b8e2e07d --- /dev/null +++ b/packages/nextjs/hooks/balancer/swap/useSwap.ts @@ -0,0 +1,60 @@ +import { + ExactInQueryOutput, + ExactOutQueryOutput, // Permit2Helper, + Slippage, + Swap, + SwapInput, +} from "@balancer/sdk"; +import { useMutation } from "@tanstack/react-query"; +import { useWalletClient } from "wagmi"; +import { useTransactor } from "~~/hooks/scaffold-eth"; + +export const useSwap = (swapInput: SwapInput) => { + const { data: walletClient } = useWalletClient(); + const writeTx = useTransactor(); + + const swap = new Swap(swapInput); + + const doSwap = async (queryOutput: ExactInQueryOutput | ExactOutQueryOutput | undefined) => { + if (!walletClient) throw new Error("Must connect a wallet to send a transaction"); + if (!queryOutput) throw new Error("Query output is required to swap"); + + const deadline = 999999999999999999n; // Deadline for the swap, in this case infinite + const slippage = Slippage.fromPercentage("0.1"); // 0.1% + + const buildCallInput = { + slippage, + deadline, + queryOutput, + wethIsEth: false, + }; + + const call = swap.buildCall(buildCallInput); + + // buildCallWithPermit2 requires viem/wagmi v2 because Client types and methods change + + // const permit2 = await Permit2Helper.signSwapApproval({ + // ...buildCallInput, + // client: walletClient, + // owner: walletClient.account.address as `0x${string}`, + // }); + + // const call = swap.buildCallWithPermit2(buildCallInput, permit2); + + const txHashPromise = () => + walletClient.sendTransaction({ + account: walletClient.account, + data: call.callData, + to: call.to, + value: call.value, + }); + const txHash = await writeTx(txHashPromise, { blockConfirmations: 1 }); + if (!txHash) throw new Error("Transaction failed"); + + return txHash; + }; + + return useMutation({ + mutationFn: (queryOutput: ExactInQueryOutput | ExactOutQueryOutput | undefined) => doSwap(queryOutput), + }); +}; diff --git a/packages/nextjs/hooks/balancer/types.ts b/packages/nextjs/hooks/balancer/types.ts index 648db95c..e85bd43d 100644 --- a/packages/nextjs/hooks/balancer/types.ts +++ b/packages/nextjs/hooks/balancer/types.ts @@ -1,5 +1,5 @@ -import { SwapKind, TokenAmount } from "@balancer/sdk"; -import { InputAmount } from "@balancer/sdk"; +import { RefetchPool } from "./useReadPool"; +import { SwapKind } from "@balancer/sdk"; import { WriteContractResult } from "@wagmi/core"; import { type Address } from "viem"; @@ -64,27 +64,16 @@ export type HooksConfig = { }; /////////////////// -// Pool Hooks +// Pool Action Forms ////////////////// -export type UseSwap = { - querySwap: () => Promise; - swap: () => Promise; -}; - -export type UseAddLiquidity = { - queryAddLiquidity: (bptOut: InputAmount) => Promise; - addLiquidity: () => Promise; -}; - -export type UseRemoveLiquidity = { - queryRemoveLiquidity: (rawAmount: bigint) => Promise; - removeLiquidity: () => Promise; -}; +export interface PoolActionsProps { + pool: Pool; + refetchPool: RefetchPool; + tokenBalances: TokenBalances; + refetchTokenBalances: () => void; +} -/////////////////// -// Pool Action Forms -////////////////// export type SwapConfig = { tokenIn: { poolTokensIndex: number; @@ -99,36 +88,15 @@ export type SwapConfig = { swapKind: SwapKind; }; -export type QueryPoolActionError = { message: string } | null; - -export type QuerySwapResponse = { - swapKind?: SwapKind; - expectedAmount?: TokenAmount; - minOrMaxAmount?: TokenAmount; - error?: QueryPoolActionError; -}; - -export type QueryAddLiquidityResponse = { - expectedBptOut?: TokenAmount; - minBptOut?: TokenAmount; - error?: QueryPoolActionError; -}; - -export type QueryRemoveLiquidityResponse = { - expectedAmountsOut?: TokenAmount[]; - minAmountsOut?: TokenAmount[]; - error?: QueryPoolActionError; -}; - -export type TokenInfo = { +export type TokenAmountDetails = { symbol: string; name: string; rawAmount: bigint; decimals: number; }; -export type PoolActionReceipt = { - data: TokenInfo[]; +export type PoolOperationReceipt = { + data: TokenAmountDetails[]; transactionHash: string; } | null; @@ -138,13 +106,6 @@ export type TransactionHash = string | null; // Token Hooks ////////////////////// -export type UseToken = { - tokenAllowance: bigint; - tokenBalance: bigint; - refetchTokenAllowance: () => void; - refetchTokenBalance: () => void; -}; - export type TokenBalances = { [key: Address]: bigint }; export type UseTokens = { diff --git a/packages/nextjs/hooks/balancer/useAddLiquidity.ts b/packages/nextjs/hooks/balancer/useAddLiquidity.ts deleted file mode 100644 index af67448c..00000000 --- a/packages/nextjs/hooks/balancer/useAddLiquidity.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useState } from "react"; -import { - AddLiquidity, - AddLiquidityBuildCallOutput, - AddLiquidityInput, - AddLiquidityKind, - InputAmount, - OnChainProvider, - PoolState, - Slippage, -} from "@balancer/sdk"; -import { useWalletClient } from "wagmi"; -import { useTargetFork } from "~~/hooks/balancer"; -import { Pool, UseAddLiquidity } from "~~/hooks/balancer/types"; -import { useTransactor } from "~~/hooks/scaffold-eth"; -import { getBlockExplorerTxLink } from "~~/utils/scaffold-eth"; - -/** - * Custom hook for adding liquidity to a pool where `queryAddLiquidity()` sets state of - * the call object that is used to construct the transaction that is later sent by `addLiquidity()` - */ -export const useAddLiquidity = (pool: Pool, amountsIn: InputAmount[]): UseAddLiquidity => { - const [call, setCall] = useState(); - const { data: walletClient } = useWalletClient(); - const { rpcUrl, chainId } = useTargetFork(); - const writeTx = useTransactor(); - - const queryAddLiquidity = async (bptOut: InputAmount) => { - try { - const slippage = Slippage.fromPercentage("1"); // 1% - const onchainProvider = new OnChainProvider(rpcUrl, chainId); - const poolId = pool.address as `0x${string}`; - const poolState: PoolState = await onchainProvider.pools.fetchPoolState(poolId, "CustomPool"); - - // Construct the addLiquidity input object based on if pool allows unbalanced liquidity operations - const addLiquidityInput: AddLiquidityInput = pool.poolConfig?.liquidityManagement.disableUnbalancedLiquidity - ? { - kind: AddLiquidityKind.Proportional, - bptOut, - chainId, - rpcUrl, - } - : { - kind: AddLiquidityKind.Unbalanced, - amountsIn, - chainId, - rpcUrl, - }; - - // Query addLiquidity to get the amount of BPT out - const addLiquidity = new AddLiquidity(); - const queryOutput = await addLiquidity.query(addLiquidityInput, poolState); - - // Applies slippage to the BPT out amount and constructs the call - const call = addLiquidity.buildCall({ - ...queryOutput, - slippage, - chainId, - wethIsEth: false, - }); - - setCall(call); - - return { expectedBptOut: queryOutput.bptOut, minBptOut: call.minBptOut }; - } catch (error) { - console.error("error", error); - const message = (error as { shortMessage?: string }).shortMessage || "An unknown error occurred"; - return { error: { message } }; - } - }; - - const addLiquidity = async () => { - try { - if (!walletClient) { - throw new Error("Must connect a wallet to send a transaction"); - } - if (!call) { - throw new Error("tx call object is undefined"); - } - const txHashPromise = () => - walletClient.sendTransaction({ - account: walletClient.account, - data: call.callData, - to: call.to, - value: call.value, - }); - - const hash = await writeTx(txHashPromise, { blockConfirmations: 1 }); - - if (!hash) { - throw new Error("Transaction failed"); - } - - const chainId = await walletClient.getChainId(); - const blockExplorerTxURL = getBlockExplorerTxLink(chainId, hash); - return blockExplorerTxURL; - } catch (e) { - console.error("error", e); - return null; - } - }; - - return { queryAddLiquidity, addLiquidity }; -}; diff --git a/packages/nextjs/hooks/balancer/useApprove.ts b/packages/nextjs/hooks/balancer/useApprove.ts deleted file mode 100644 index 64a5aa45..00000000 --- a/packages/nextjs/hooks/balancer/useApprove.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { PERMIT2, erc20Abi, permit2Abi } from "@balancer/sdk"; -import { Address } from "viem"; -import { useContractWrite } from "wagmi"; -import { useTargetFork } from "~~/hooks/balancer"; -import { UseApprove } from "~~/hooks/balancer/types"; -import { MaxUint48, MaxUint160, MaxUint256 } from "~~/utils/constants"; - -/** - * Custom hook for approving spenders both erc20 and permit2 style - */ -export const useApprove = (token: Address, spender: Address): UseApprove => { - const { chainId } = useTargetFork(); - - // Max approve canonical Permit2 address to spend account's tokens - const { writeAsync: approveSpenderOnToken } = useContractWrite({ - address: token, - abi: erc20Abi, - functionName: "approve", - args: [spender, MaxUint256], // point this approval at permit2 contract - }); - - // Approve Router to spend account's tokens using Permit2 - const { writeAsync: approveSpenderOnPermit2 } = useContractWrite({ - address: PERMIT2[chainId], - abi: permit2Abi, - functionName: "approve", - args: [token, spender, MaxUint160, MaxUint48], - }); - - return { - approveSpenderOnToken, - approveSpenderOnPermit2, - }; -}; diff --git a/packages/nextjs/hooks/balancer/useRemoveLiquidity.ts b/packages/nextjs/hooks/balancer/useRemoveLiquidity.ts deleted file mode 100644 index b70a3f23..00000000 --- a/packages/nextjs/hooks/balancer/useRemoveLiquidity.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useState } from "react"; -import { - InputAmount, - OnChainProvider, - PoolState, - RemoveLiquidity, - RemoveLiquidityBuildCallOutput, - RemoveLiquidityInput, - RemoveLiquidityKind, - Slippage, -} from "@balancer/sdk"; -import { useWalletClient } from "wagmi"; -import { useTargetFork } from "~~/hooks/balancer"; -import { Pool, UseRemoveLiquidity } from "~~/hooks/balancer/types"; -import { useTransactor } from "~~/hooks/scaffold-eth"; -import { getBlockExplorerTxLink } from "~~/utils/scaffold-eth"; - -/** - * Custom hook for removing liquidity from a pool where `queryRemoveLiquidity()` sets state of - * the call object that is used to construct the transaction that is later sent by `removeLiquidity()` - */ -export const useRemoveLiquidity = (pool: Pool): UseRemoveLiquidity => { - const [call, setCall] = useState(); - const { data: walletClient } = useWalletClient(); - const { rpcUrl, chainId } = useTargetFork(); - const writeTx = useTransactor(); - - const queryRemoveLiquidity = async (rawAmount: bigint) => { - try { - const slippage = Slippage.fromPercentage("1"); // 1% - const onchainProvider = new OnChainProvider(rpcUrl, chainId); - const poolId = pool.address as `0x${string}`; - const poolState: PoolState = await onchainProvider.pools.fetchPoolState(poolId, "CustomPool"); - - // Construct the RemoveLiquidityInput, in this case a RemoveLiquiditySingleTokenExactIn - const bptIn: InputAmount = { - rawAmount, - decimals: pool.decimals, - address: poolState.address, - }; - - // Construct the RemoveLiquidityInput, in this case an RemoveLiquidityProportional - const removeLiquidityInput: RemoveLiquidityInput = { - chainId, - rpcUrl, - bptIn, - kind: RemoveLiquidityKind.Proportional, - }; - - // Query removeLiquidity to get the token out amounts - const removeLiquidity = new RemoveLiquidity(); - const queryOutput = await removeLiquidity.query(removeLiquidityInput, poolState); - - // Construct call object for transaction - const call = removeLiquidity.buildCall({ - ...queryOutput, - slippage, - chainId, - wethIsEth: false, - }); - setCall(call); // save to state for use in removeLiquidity() - - return { expectedAmountsOut: queryOutput.amountsOut, minAmountsOut: call.minAmountsOut }; - } catch (error) { - const message = (error as { shortMessage?: string }).shortMessage || "An unknown error occurred"; - return { error: { message } }; - } - }; - - const removeLiquidity = async () => { - try { - if (!walletClient) { - throw new Error("Must connect a wallet to send a transaction"); - } - if (!call) { - throw new Error("tx call object is undefined"); - } - const txHashPromise = () => - walletClient.sendTransaction({ - account: walletClient.account, - data: call.callData, - to: call.to, - value: call.value, - }); - - const hash = await writeTx(txHashPromise, { blockConfirmations: 1 }); - if (!hash) { - throw new Error("Transaction failed"); - } - - const chainId = await walletClient.getChainId(); - const blockExplorerTxURL = getBlockExplorerTxLink(chainId, hash); - return blockExplorerTxURL; - } catch (error) { - console.error("error", error); - return null; - } - }; - - return { queryRemoveLiquidity, removeLiquidity }; -}; diff --git a/packages/nextjs/hooks/balancer/useSwap.ts b/packages/nextjs/hooks/balancer/useSwap.ts deleted file mode 100644 index b0f132d5..00000000 --- a/packages/nextjs/hooks/balancer/useSwap.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useState } from "react"; -import { Slippage, Swap, SwapBuildOutputExactIn, SwapBuildOutputExactOut, SwapKind, TokenAmount } from "@balancer/sdk"; -import { useWalletClient } from "wagmi"; -import { useTargetFork } from "~~/hooks/balancer"; -import { Pool, SwapConfig, UseSwap } from "~~/hooks/balancer/types"; -import { useTransactor } from "~~/hooks/scaffold-eth"; - -/** - * Custom hook for swapping tokens in a pool where `querySwap()` sets state of - * the call object that is used to construct the transaction that is then sent by `swap()` - */ -export const useSwap = (pool: Pool, swapConfig: SwapConfig): UseSwap => { - const [call, setCall] = useState(); - const { data: walletClient } = useWalletClient(); - const { rpcUrl, chainId } = useTargetFork(); - const writeTx = useTransactor(); - - const tokenIn = pool.poolTokens[swapConfig.tokenIn.poolTokensIndex]; - const tokenOut = pool.poolTokens[swapConfig.tokenOut.poolTokensIndex]; - - const querySwap = async () => { - try { - const swapInput = { - chainId: chainId, - swapKind: swapConfig.swapKind, - paths: [ - { - pools: [pool.address as `0x${string}`], - tokens: [ - { - address: tokenIn.address as `0x${string}`, - decimals: tokenIn.decimals, - }, // tokenIn - { - address: tokenOut.address as `0x${string}`, - decimals: tokenOut.decimals, - }, // tokenOut - ], - protocolVersion: 3 as const, - inputAmountRaw: swapConfig.tokenIn.rawAmount, - outputAmountRaw: swapConfig.tokenOut.rawAmount, - }, - ], - }; - - const swap = new Swap(swapInput); - const updatedAmount = await swap.query(rpcUrl); - - const call = swap.buildCall({ - slippage: Slippage.fromPercentage("0.1"), - deadline: 999999999999999999n, // Deadline for the swap, in this case infinite - queryOutput: updatedAmount, - wethIsEth: false, - }); - - setCall(call); - - let expectedAmount: TokenAmount; - let minOrMaxAmount: TokenAmount; - const swapKind = updatedAmount.swapKind; - - if (swapKind === SwapKind.GivenIn && "minAmountOut" in call) { - expectedAmount = updatedAmount.expectedAmountOut; - minOrMaxAmount = call.minAmountOut; - } else if (updatedAmount.swapKind === SwapKind.GivenOut && "maxAmountIn" in call) { - expectedAmount = updatedAmount.expectedAmountIn; - minOrMaxAmount = call.maxAmountIn; - } else { - throw new Error("Invalid swapKind or call object"); - } - - return { - swapKind, - expectedAmount, - minOrMaxAmount, - }; - } catch (error) { - console.error("error", error); - const message = (error as { shortMessage?: string }).shortMessage || "An unknown error occurred"; - return { error: { message } }; - } - }; - - /** - * Execute the swap tx and return the block explorer URL - */ - const swap = async () => { - try { - if (!walletClient) { - throw new Error("Must connect a wallet to send a transaction"); - } - if (!call) { - throw new Error("tx call object is undefined"); - } - const txHashPromise = () => - walletClient.sendTransaction({ - account: walletClient.account, - data: call.callData, - to: call.to, - value: call.value, - }); - const txHash = await writeTx(txHashPromise, { blockConfirmations: 1 }); - if (!txHash) { - throw new Error("Transaction failed"); - } - - return txHash; - } catch (e) { - console.error("error", e); - return null; - } - }; - - return { - querySwap, - swap, - }; -}; diff --git a/packages/nextjs/hooks/balancer/useToken.ts b/packages/nextjs/hooks/balancer/useToken.ts deleted file mode 100644 index 479fe99c..00000000 --- a/packages/nextjs/hooks/balancer/useToken.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BALANCER_ROUTER, PERMIT2, erc20Abi, permit2Abi } from "@balancer/sdk"; -import { Address, zeroAddress } from "viem"; -import { useContractRead, useWalletClient } from "wagmi"; -import { useTargetFork } from "~~/hooks/balancer"; -import { UseToken } from "~~/hooks/balancer/types"; - -/** - * Custom hook for dealing with a single token - */ -export const useToken = (token: Address): UseToken => { - const { data: walletClient } = useWalletClient(); - const connectedAddress = walletClient?.account.address || zeroAddress; - const { chainId } = useTargetFork(); - - // Balance of token for the connected account - const { data: tokenBalance, refetch: refetchTokenBalance } = useContractRead({ - address: token, - abi: erc20Abi, - functionName: "balanceOf", - args: [connectedAddress], - }); - - // Allowance for Router to spend account's tokens from Permit2 - const { data: tokenAllowance, refetch: refetchTokenAllowance } = useContractRead({ - address: PERMIT2[chainId], - abi: permit2Abi, - functionName: "allowance", - args: [connectedAddress, token, BALANCER_ROUTER[chainId]], - }); - - return { - tokenAllowance: tokenAllowance ? tokenAllowance[0] : 0n, - refetchTokenAllowance, - tokenBalance: tokenBalance ? tokenBalance : 0n, - refetchTokenBalance, - }; -}; diff --git a/packages/nextjs/hooks/token/index.ts b/packages/nextjs/hooks/token/index.ts new file mode 100644 index 00000000..03254d4a --- /dev/null +++ b/packages/nextjs/hooks/token/index.ts @@ -0,0 +1,6 @@ +export * from "./useAllowanceOnPermit2"; +export * from "./useAllowanceOnToken"; +export * from "./useApproveOnPermit2"; +export * from "./useApproveOnToken"; +export * from "./useApproveTokens"; +export * from "./useReadTokens"; diff --git a/packages/nextjs/hooks/token/useAllowanceOnPermit2.ts b/packages/nextjs/hooks/token/useAllowanceOnPermit2.ts new file mode 100644 index 00000000..a3eeca37 --- /dev/null +++ b/packages/nextjs/hooks/token/useAllowanceOnPermit2.ts @@ -0,0 +1,18 @@ +import { BALANCER_ROUTER, PERMIT2, permit2Abi } from "@balancer/sdk"; +import { Address, zeroAddress } from "viem"; +import { useContractRead, useWalletClient } from "wagmi"; +import { useTargetFork } from "~~/hooks/balancer"; + +// Use Permit2 contract to check users's allowance for Balancer Router +export const useAllowanceOnPermit2 = (token: Address) => { + const { data: walletClient } = useWalletClient(); + const connectedAddress = walletClient?.account.address || zeroAddress; + const { chainId } = useTargetFork(); + + return useContractRead({ + address: PERMIT2[chainId], + abi: permit2Abi, + functionName: "allowance", + args: [connectedAddress, token, BALANCER_ROUTER[chainId]], + }); +}; diff --git a/packages/nextjs/hooks/token/useAllowanceOnToken.ts b/packages/nextjs/hooks/token/useAllowanceOnToken.ts new file mode 100644 index 00000000..96ab5587 --- /dev/null +++ b/packages/nextjs/hooks/token/useAllowanceOnToken.ts @@ -0,0 +1,15 @@ +import { erc20Abi } from "@balancer/sdk"; +import { Address, zeroAddress } from "viem"; +import { useContractRead, useWalletClient } from "wagmi"; + +export const useAllowanceOnToken = (token: Address, spender: Address) => { + const { data: walletClient } = useWalletClient(); + const connectedAddress = walletClient?.account.address || zeroAddress; + + return useContractRead({ + address: token, + abi: erc20Abi, + functionName: "allowance", + args: [connectedAddress, spender], + }); +}; diff --git a/packages/nextjs/hooks/token/useApproveOnPermit2.ts b/packages/nextjs/hooks/token/useApproveOnPermit2.ts new file mode 100644 index 00000000..742019a2 --- /dev/null +++ b/packages/nextjs/hooks/token/useApproveOnPermit2.ts @@ -0,0 +1,33 @@ +import { BALANCER_ROUTER, PERMIT2, permit2Abi } from "@balancer/sdk"; +import { useMutation } from "@tanstack/react-query"; +import { Address } from "viem"; +import { useContractWrite } from "wagmi"; +import { useTargetFork } from "~~/hooks/balancer"; +import { useTransactor } from "~~/hooks/scaffold-eth"; +import { MaxUint48, MaxUint160 } from "~~/utils/constants"; + +// Using permit2 contract, approve the Router to spend the connected account's account's tokens +export const useApproveOnPermit2 = (token: Address) => { + const { chainId } = useTargetFork(); + const writeTx = useTransactor(); + + const { writeAsync: approveRouter } = useContractWrite({ + address: PERMIT2[chainId], + abi: permit2Abi, + functionName: "approve", + args: [token, BALANCER_ROUTER[chainId], MaxUint160, MaxUint48], + }); + + const approve = async () => { + await writeTx(() => approveRouter(), { + blockConfirmations: 1, + onBlockConfirmation: () => { + console.log("Using permit2 contract, user approved Router to spend max amount of", token); + }, + }); + }; + + return useMutation({ + mutationFn: () => approve(), + }); +}; diff --git a/packages/nextjs/hooks/token/useApproveOnToken.ts b/packages/nextjs/hooks/token/useApproveOnToken.ts new file mode 100644 index 00000000..2fabcec8 --- /dev/null +++ b/packages/nextjs/hooks/token/useApproveOnToken.ts @@ -0,0 +1,30 @@ +import { erc20Abi } from "@balancer/sdk"; +import { useMutation } from "@tanstack/react-query"; +import { Address } from "viem"; +import { useContractWrite } from "wagmi"; +import { useTransactor } from "~~/hooks/scaffold-eth"; +import { MaxUint256 } from "~~/utils/constants"; + +export const useApproveOnToken = (token: Address, spender: Address) => { + const writeTx = useTransactor(); + + const { writeAsync: maxApprovePermit2 } = useContractWrite({ + address: token, + abi: erc20Abi, + functionName: "approve", + args: [spender, MaxUint256], // point this approval at permit2 contract + }); + + const approve = async () => { + await writeTx(() => maxApprovePermit2(), { + blockConfirmations: 1, + onBlockConfirmation: () => { + console.log("Using token contract, user approved permit2 contract to spend max amount of", token); + }, + }); + }; + + return useMutation({ + mutationFn: () => approve(), + }); +}; diff --git a/packages/nextjs/hooks/token/useApproveTokens.ts b/packages/nextjs/hooks/token/useApproveTokens.ts new file mode 100644 index 00000000..c3eecc0f --- /dev/null +++ b/packages/nextjs/hooks/token/useApproveTokens.ts @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { useReadTokens } from "."; +import { BALANCER_ROUTER, InputAmount, PERMIT2, erc20Abi, permit2Abi } from "@balancer/sdk"; +import { usePublicClient, useWalletClient } from "wagmi"; +import { useTargetFork } from "~~/hooks/balancer"; +import { useTransactor } from "~~/hooks/scaffold-eth"; +import { MaxUint48, MaxUint160, MaxUint256 } from "~~/utils/constants"; + +/** + * Only used for the AddLiquidityForm component + * Should refactor this mess one day :D + */ +export const useApproveTokens = (tokenInputs: InputAmount[]) => { + const [tokensToApprove, setTokensToApprove] = useState([]); + const [sufficientAllowances, setSufficientAllowances] = useState(false); + const [isApproving, setIsApproving] = useState(false); + const writeTx = useTransactor(); // scaffold hook for tx status toast notifications + const publicClient = usePublicClient(); + const { data: walletClient } = useWalletClient(); + + const { chainId } = useTargetFork(); + + const { tokenAllowances, refetchTokenAllowances } = useReadTokens(tokenInputs); + + const approveTokens = () => { + if (!walletClient) throw new Error("Wallet client not connected!"); + tokensToApprove.forEach(async token => { + try { + setIsApproving(true); + // Max approve canonical Permit2 address to spend account's tokens + const { request: approveSpenderOnToken } = await publicClient.simulateContract({ + address: token.address, + abi: erc20Abi, + functionName: "approve", + account: walletClient.account, + args: [PERMIT2[chainId], MaxUint256], + }); + await writeTx(() => walletClient.writeContract(approveSpenderOnToken), { + blockConfirmations: 1, + onBlockConfirmation: () => { + console.log("Approved permit2 contract to spend max amount of", token.address); + }, + }); + // Approve Router to spend account's tokens using Permit2.approve(token, spender, amount, deadline) + const { request: approveSpenderOnPermit2 } = await publicClient.simulateContract({ + address: PERMIT2[chainId], + abi: permit2Abi, + functionName: "approve", + account: walletClient.account, + args: [token.address, BALANCER_ROUTER[chainId], MaxUint160, MaxUint48], + }); + await writeTx(() => walletClient.writeContract(approveSpenderOnPermit2), { + blockConfirmations: 1, + onBlockConfirmation: () => { + console.log("Approved router to spend max amount of", token.address); + refetchTokenAllowances(); + setIsApproving(false); + }, + }); + } catch (error) { + console.error("Approval error", error); + setIsApproving(false); + } + }); + }; + + useEffect(() => { + // Determine which tokens need to be approved + async function determineTokensToApprove() { + if (tokenAllowances) { + const tokensNeedingApproval = tokenInputs.filter((token, index) => { + const allowance = tokenAllowances[index] || 0n; + return allowance < token.rawAmount; + }); + setTokensToApprove(tokensNeedingApproval); + // Check if all tokens have sufficient tokenAllowances + if (tokensNeedingApproval.length > 0) { + setSufficientAllowances(false); + } else { + setSufficientAllowances(true); + } + } + } + determineTokensToApprove(); + }, [tokenInputs, tokenAllowances]); + + return { approveTokens, sufficientAllowances, isApproving }; +}; diff --git a/packages/nextjs/hooks/balancer/useTokens.ts b/packages/nextjs/hooks/token/useReadTokens.ts similarity index 94% rename from packages/nextjs/hooks/balancer/useTokens.ts rename to packages/nextjs/hooks/token/useReadTokens.ts index d6b13652..8277b3f8 100644 --- a/packages/nextjs/hooks/balancer/useTokens.ts +++ b/packages/nextjs/hooks/token/useReadTokens.ts @@ -8,8 +8,9 @@ import { type TokenBalances } from "~~/hooks/balancer/types"; /** * Custom hook for dealing with multiple tokens + * Should refactor this mess one day :D */ -export const useTokens = (amountsIn: InputAmount[]): UseTokens => { +export const useReadTokens = (amountsIn: InputAmount[]): UseTokens => { const { data: walletClient } = useWalletClient(); const connectedAddress = walletClient?.account.address || zeroAddress; const { chainId } = useTargetFork(); diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 3d685064..cc9b1072 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -14,11 +14,11 @@ "vercel:yolo": "vercel --build-env NEXT_PUBLIC_IGNORE_BUILD_ERROR=true" }, "dependencies": { - "@apollo/client": "^3.9.7", - "@balancer/sdk": "^0.24.0", + "@balancer/sdk": "^0.25.0", "@ethersproject/providers": "^5.7.2", "@heroicons/react": "^2.0.11", "@rainbow-me/rainbowkit": "1.3.5", + "@tanstack/react-query": "^5.28.6", "@uniswap/sdk-core": "^4.0.1", "@uniswap/v2-sdk": "^3.0.1", "blo": "^1.0.1", diff --git a/packages/nextjs/tailwind.config.js b/packages/nextjs/tailwind.config.js index b7e792c3..03ab8bd6 100644 --- a/packages/nextjs/tailwind.config.js +++ b/packages/nextjs/tailwind.config.js @@ -20,7 +20,7 @@ module.exports = { "base-200": "rgb(245, 243, 239)", "base-300": "#EBE8E0", // bg color "base-content": "rgb(45, 55, 72)", - info: "#0e7490", + info: "#a78bfa", success: "#047857", warning: "#b45309", error: "#b91c1c", @@ -52,7 +52,7 @@ module.exports = { "base-200": "rgb(63, 70, 80)", "base-300": "rgb(56, 62, 71)", // bg color "base-content": "rgb(229, 211, 190)", - info: "#67e8f9", + info: "#a78bfa", success: "#34d399", warning: "#fcd34d", error: "#fca5a5", @@ -91,7 +91,7 @@ module.exports = { "error-tint": "#ef444433", "warning-tint": "#f59e0b33", "success-tint": "#10b98133", - "info-tint": "#06b6d433", + "info-tint": "#a78bfa33", }, }, }, diff --git a/yarn.lock b/yarn.lock index 5adff386..6cab96e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,43 +19,6 @@ __metadata: languageName: node linkType: hard -"@apollo/client@npm:^3.9.7": - version: 3.10.8 - resolution: "@apollo/client@npm:3.10.8" - dependencies: - "@graphql-typed-document-node/core": ^3.1.1 - "@wry/caches": ^1.0.0 - "@wry/equality": ^0.5.6 - "@wry/trie": ^0.5.0 - graphql-tag: ^2.12.6 - hoist-non-react-statics: ^3.3.2 - optimism: ^0.18.0 - prop-types: ^15.7.2 - rehackt: ^0.1.0 - response-iterator: ^0.2.6 - symbol-observable: ^4.0.0 - ts-invariant: ^0.10.3 - tslib: ^2.3.0 - zen-observable-ts: ^1.2.5 - peerDependencies: - graphql: ^15.0.0 || ^16.0.0 - graphql-ws: ^5.5.5 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 - subscriptions-transport-ws: ^0.9.0 || ^0.11.0 - peerDependenciesMeta: - graphql-ws: - optional: true - react: - optional: true - react-dom: - optional: true - subscriptions-transport-ws: - optional: true - checksum: 965e95389bdbde8aa5f542f11860fd930438f635560c766c716f186d5a9d6c5f0bfb31088cf28150c4f3969191030f1bbc0254418738a5737568108e96ffd815 - languageName: node - linkType: hard - "@babel/code-frame@npm:^7.22.13, @babel/code-frame@npm:^7.24.7": version: 7.24.7 resolution: "@babel/code-frame@npm:7.24.7" @@ -220,14 +183,14 @@ __metadata: languageName: node linkType: hard -"@balancer/sdk@npm:^0.24.0": - version: 0.24.0 - resolution: "@balancer/sdk@npm:0.24.0" +"@balancer/sdk@npm:^0.25.0": + version: 0.25.0 + resolution: "@balancer/sdk@npm:0.25.0" dependencies: decimal.js-light: ^2.5.1 lodash.clonedeep: ^4.5.0 viem: ^2.12.1 - checksum: badee64a06449679290678350c2a3a50378ea1a2628d7fde971a7c6609b3ac459a11f1b7474434a60d1254da4ba5347004c0029a75a5c20d4ba372d8b9b07a13 + checksum: 59ba476c48f5cd2a16219399ba78a17d8ad47205af067fe5eaa73a49077b9e80c7a386bd72282bd0864669f924179ed202fbcf1cb5e42055aa3af8e01ac2c537 languageName: node linkType: hard @@ -741,15 +704,6 @@ __metadata: languageName: node linkType: hard -"@graphql-typed-document-node/core@npm:^3.1.1": - version: 3.2.0 - resolution: "@graphql-typed-document-node/core@npm:3.2.0" - peerDependencies: - graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - checksum: fa44443accd28c8cf4cb96aaaf39d144a22e8b091b13366843f4e97d19c7bfeaf609ce3c7603a4aeffe385081eaf8ea245d078633a7324c11c5ec4b2011bb76d - languageName: node - linkType: hard - "@heroicons/react@npm:^2.0.11": version: 2.1.4 resolution: "@heroicons/react@npm:2.1.4" @@ -1503,11 +1457,11 @@ __metadata: version: 0.0.0-use.local resolution: "@se-2/nextjs@workspace:packages/nextjs" dependencies: - "@apollo/client": ^3.9.7 - "@balancer/sdk": ^0.24.0 + "@balancer/sdk": ^0.25.0 "@ethersproject/providers": ^5.7.2 "@heroicons/react": ^2.0.11 "@rainbow-me/rainbowkit": 1.3.5 + "@tanstack/react-query": ^5.28.6 "@trivago/prettier-plugin-sort-imports": ^4.1.1 "@types/node": ^17.0.35 "@types/nprogress": ^0 @@ -1746,6 +1700,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.53.2": + version: 5.53.2 + resolution: "@tanstack/query-core@npm:5.53.2" + checksum: cac7dce3c1b31101e6caf33f6d75cdfa1a580193a6fa130e5402d21647ca623a0608cea529b0695df73f8ff141ce0d430010c651704d5103db69bd9666fc25f4 + languageName: node + linkType: hard + "@tanstack/query-persist-client-core@npm:4.36.1": version: 4.36.1 resolution: "@tanstack/query-persist-client-core@npm:4.36.1" @@ -1794,6 +1755,17 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-query@npm:^5.28.6": + version: 5.53.2 + resolution: "@tanstack/react-query@npm:5.53.2" + dependencies: + "@tanstack/query-core": 5.53.2 + peerDependencies: + react: ^18 || ^19 + checksum: 8dab90fe514c4768f97a9a60cd0dacc671e0c4fda1b2a8f275a5200fc65b51ed80e10fe16d38b3dbb10659d80d21241c16a75b220a0fc2ee34cbfa11cd358081 + languageName: node + linkType: hard + "@trivago/prettier-plugin-sort-imports@npm:^4.1.1": version: 4.3.0 resolution: "@trivago/prettier-plugin-sort-imports@npm:4.3.0" @@ -2689,51 +2661,6 @@ __metadata: languageName: node linkType: hard -"@wry/caches@npm:^1.0.0": - version: 1.0.1 - resolution: "@wry/caches@npm:1.0.1" - dependencies: - tslib: ^2.3.0 - checksum: 9e89aa8e9e08577b2e4acbe805f406b141ae49c2ac4a2e22acf21fbee68339fa0550e0dee28cf2158799f35bb812326e80212e49e2afd169f39f02ad56ae4ef4 - languageName: node - linkType: hard - -"@wry/context@npm:^0.7.0": - version: 0.7.4 - resolution: "@wry/context@npm:0.7.4" - dependencies: - tslib: ^2.3.0 - checksum: 9bc8c30a31f9c7d36b616e89daa9280c03d196576a4f9fef800e9bd5de9434ba70216322faeeacc7ef1ab95f59185599d702538114045df729a5ceea50aef4e2 - languageName: node - linkType: hard - -"@wry/equality@npm:^0.5.6": - version: 0.5.7 - resolution: "@wry/equality@npm:0.5.7" - dependencies: - tslib: ^2.3.0 - checksum: 892f262fae362df80f199b12658ea6966949539d4a3a50c1acf00d94a367d673a38f8efa1abcb726ae9e5cc5e62fce50c540c70f797b7c8a2c4308b401dfd903 - languageName: node - linkType: hard - -"@wry/trie@npm:^0.4.3": - version: 0.4.3 - resolution: "@wry/trie@npm:0.4.3" - dependencies: - tslib: ^2.3.0 - checksum: 106e021125cfafd22250a6631a0438a6a3debae7bd73f6db87fe42aa0757fe67693db0dfbe200ae1f60ba608c3e09ddb8a4e2b3527d56ed0a7e02aa0ee4c94e1 - languageName: node - linkType: hard - -"@wry/trie@npm:^0.5.0": - version: 0.5.0 - resolution: "@wry/trie@npm:0.5.0" - dependencies: - tslib: ^2.3.0 - checksum: 92aeea34152bd8485184236fe328d3d05fc98ee3b431d82ee60cf3584dbf68155419c3d65d0ff3731b204ee79c149440a9b7672784a545afddc8d4342fbf21c9 - languageName: node - linkType: hard - "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -5065,17 +4992,6 @@ __metadata: languageName: node linkType: hard -"graphql-tag@npm:^2.12.6": - version: 2.12.6 - resolution: "graphql-tag@npm:2.12.6" - dependencies: - tslib: ^2.1.0 - peerDependencies: - graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - checksum: b15162a3d62f17b9b79302445b9ee330e041582f1c7faca74b9dec5daa74272c906ec1c34e1c50592bb6215e5c3eba80a309103f6ba9e4c1cddc350c46f010df - languageName: node - linkType: hard - "graphql@npm:^16.8.1": version: 16.9.0 resolution: "graphql@npm:16.9.0" @@ -5191,15 +5107,6 @@ __metadata: languageName: node linkType: hard -"hoist-non-react-statics@npm:^3.3.2": - version: 3.3.2 - resolution: "hoist-non-react-statics@npm:3.3.2" - dependencies: - react-is: ^16.7.0 - checksum: b1538270429b13901ee586aa44f4cc3ecd8831c061d06cb8322e50ea17b3f5ce4d0e2e66394761e6c8e152cd8c34fb3b4b690116c6ce2bd45b18c746516cb9e8 - languageName: node - linkType: hard - "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -6835,18 +6742,6 @@ __metadata: languageName: node linkType: hard -"optimism@npm:^0.18.0": - version: 0.18.0 - resolution: "optimism@npm:0.18.0" - dependencies: - "@wry/caches": ^1.0.0 - "@wry/context": ^0.7.0 - "@wry/trie": ^0.4.3 - tslib: ^2.3.0 - checksum: d6ed6a90b05ee886dadfe556c7a30227c66843f51278e51eb843977a6a9368b6c50297fcc63fa514f53d8a5a58f8ddc8049c2356bd4ffac32f8961bcb806254d - languageName: node - linkType: hard - "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -7284,7 +7179,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -7422,7 +7317,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1, react-is@npm:^16.7.0": +"react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f @@ -7560,21 +7455,6 @@ __metadata: languageName: node linkType: hard -"rehackt@npm:^0.1.0": - version: 0.1.0 - resolution: "rehackt@npm:0.1.0" - peerDependencies: - "@types/react": "*" - react: "*" - peerDependenciesMeta: - "@types/react": - optional: true - react: - optional: true - checksum: 2c3bcd72524bf47672640265e79cba785e0e6837b9b385ccb0a3ea7d00f55a439d9aed3e0ae71e991d88e0d4b2b3158457c92e75fff5ebf99cd46e280068ddeb - languageName: node - linkType: hard - "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -7655,13 +7535,6 @@ __metadata: languageName: node linkType: hard -"response-iterator@npm:^0.2.6": - version: 0.2.6 - resolution: "response-iterator@npm:0.2.6" - checksum: b0db3c0665a0d698d65512951de9623c086b9c84ce015a76076d4bd0bf733779601d0b41f0931d16ae38132fba29e1ce291c1f8e6550fc32daaa2dc3ab4f338d - languageName: node - linkType: hard - "restore-cursor@npm:^4.0.0": version: 4.0.0 resolution: "restore-cursor@npm:4.0.0" @@ -8236,13 +8109,6 @@ __metadata: languageName: node linkType: hard -"symbol-observable@npm:^4.0.0": - version: 4.0.0 - resolution: "symbol-observable@npm:4.0.0" - checksum: 212c7edce6186634d671336a88c0e0bbd626c2ab51ed57498dc90698cce541839a261b969c2a1e8dd43762133d47672e8b62e0b1ce9cf4157934ba45fd172ba8 - languageName: node - linkType: hard - "system-architecture@npm:^0.1.0": version: 0.1.0 resolution: "system-architecture@npm:0.1.0" @@ -8412,15 +8278,6 @@ __metadata: languageName: node linkType: hard -"ts-invariant@npm:^0.10.3": - version: 0.10.3 - resolution: "ts-invariant@npm:0.10.3" - dependencies: - tslib: ^2.1.0 - checksum: bb07d56fe4aae69d8860e0301dfdee2d375281159054bc24bf1e49e513fb0835bf7f70a11351344d213a79199c5e695f37ebbf5a447188a377ce0cd81d91ddb5 - languageName: node - linkType: hard - "tsconfig-paths@npm:^3.15.0": version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0" @@ -8440,7 +8297,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0": +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0": version: 2.6.3 resolution: "tslib@npm:2.6.3" checksum: 74fce0e100f1ebd95b8995fbbd0e6c91bdd8f4c35c00d4da62e285a3363aaa534de40a80db30ecfd388ed7c313c42d930ee0eaf108e8114214b180eec3dbe6f5 @@ -9222,22 +9079,6 @@ __metadata: languageName: node linkType: hard -"zen-observable-ts@npm:^1.2.5": - version: 1.2.5 - resolution: "zen-observable-ts@npm:1.2.5" - dependencies: - zen-observable: 0.8.15 - checksum: 3b707b7a0239a9bc40f73ba71b27733a689a957c1f364fabb9fa9cbd7d04b7c2faf0d517bf17004e3ed3f4330ac613e84c0d32313e450ddaa046f3350af44541 - languageName: node - linkType: hard - -"zen-observable@npm:0.8.15": - version: 0.8.15 - resolution: "zen-observable@npm:0.8.15" - checksum: b7289084bc1fc74a559b7259faa23d3214b14b538a8843d2b001a35e27147833f4107590b1b44bf5bc7f6dfe6f488660d3a3725f268e09b3925b3476153b7821 - languageName: node - linkType: hard - "zustand@npm:^4.1.2, zustand@npm:^4.3.1": version: 4.5.4 resolution: "zustand@npm:4.5.4"