From 40c4cbe270cdc91371235ec321a0cb6eaba6f066 Mon Sep 17 00:00:00 2001 From: Jean Neiverth Date: Tue, 22 Apr 2025 17:23:02 -0300 Subject: [PATCH 1/2] feat: wip add new transfer hook --- .../hooksStore/dapps/TransferHook/index.tsx | 147 ++++++++++++++++++ .../dapps/TransferHook/useCowShedSignature.ts | 68 ++++++++ .../TransferHook/useHandleTokenAllowance.ts | 112 +++++++++++++ .../TransferHook/useHandleTokenApprove.ts | 64 ++++++++ .../src/modules/hooksStore/hookRegistry.tsx | 2 + libs/hook-dapp-lib/src/hookDappsRegistry.json | 13 ++ 6 files changed, 406 insertions(+) create mode 100644 apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx create mode 100644 apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useCowShedSignature.ts create mode 100644 apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenAllowance.ts create mode 100644 apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenApprove.ts diff --git a/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx new file mode 100644 index 00000000000..102085090ab --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Wrapper } from '../styled' +import { ButtonPrimary } from '@cowprotocol/ui' +import { HookDappProps } from 'modules/hooksStore/types/hooks' +import { Address, createPublicClient, encodeFunctionData, http } from 'viem' +import { Erc20Abi } from '@cowprotocol/abis' + +import { mainnet } from 'viem/chains' + +import { CowShedHooks } from '@cowprotocol/cow-sdk' +import { BaseTransaction, useCowShedSignature } from './useCowShedSignature' +import { CoWHookDappActions, HookDappContext, initCoWHookDapp } from '@cowprotocol/hook-dapp-lib' +import { BigNumber, type Signer } from 'ethers' + +import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers' +import { useHandleTokenAllowance } from './useHandleTokenAllowance' + +export function TransferHookApp({ context }: HookDappProps) { + const hookToEdit = context.hookToEdit + const isPreHook = context.isPreHook + const account = context?.account + const tokenAddress = context?.orderParams?.buyTokenAddress as Address | undefined + const amount = context?.orderParams?.buyAmount ? BigInt(context.orderParams.buyAmount) / BigInt(2) : undefined + + const toAddress = '0x21F3d1B62f6F23fC8b1B6920a3b62915790A85D5' + + const target = tokenAddress + + const [_context, _setContext] = useState() + const [web3Provider, setWeb3Provider] = useState() + const [actions, setActions] = useState() + const [signer, setSigner] = useState() + + useEffect(() => { + const { actions, provider } = initCoWHookDapp({ + onContext: _setContext as (args: HookDappContext) => void, + }) + + setActions(actions) + + const web3Provider = new Web3Provider(provider) + setWeb3Provider(web3Provider) + setSigner(web3Provider.getSigner()) + }, []) + + const publicClient = useMemo(() => { + if (!context?.chainId) return + return createPublicClient({ + chain: mainnet, + transport: http('https://eth.llamarpc.com'), + }) + }, [context?.chainId]) + + const jsonRpcProvider = useMemo(() => { + if (!context?.chainId) return + return new JsonRpcProvider('https://eth.llamarpc.com') + }, [context?.chainId]) + + const callData = useMemo(() => { + if (!tokenAddress || !amount || !signer) return + return encodeFunctionData({ + abi: Erc20Abi, + functionName: 'transferFrom', + args: [account, toAddress, amount], + }) + }, [tokenAddress, amount, account]) + + const gasLimit = '100000' + + const cowShed = useMemo(() => { + if (!context?.chainId) return + return new CowShedHooks(context.chainId) + }, [context?.chainId]) + + const cowShedProxy = useMemo(() => { + if (!context?.account || !cowShed) return + return cowShed.proxyOf(context.account) + }, [context?.account, cowShed]) as Address | undefined + + const cowShedSignature = useCowShedSignature({ + cowShed, + signer, + context, + }) + + const handleTokenAllowance = useHandleTokenAllowance({ + spender: cowShedProxy, + context, + web3Provider, + publicClient, + jsonRpcProvider, + signer, + }) + + const onButtonClick = useCallback(async () => { + if (!cowShed || !target || !callData || !gasLimit || !amount) return + + const hookTx: BaseTransaction = { + to: target, + value: BigInt(0), + callData, + } + + const permitTx = await handleTokenAllowance(BigNumber.from(amount), tokenAddress) + + if (!permitTx) return + + const permitTxAdjusted = { + to: permitTx.target, + value: BigInt(0), + callData: permitTx.callData, + } + + const txs = [permitTxAdjusted, hookTx] + + const cowShedCall = await cowShedSignature(txs) + if (!cowShedCall) throw new Error('Error signing hooks') + + const hook = { + target: cowShed.getFactoryAddress(), + callData: cowShedCall, + gasLimit, + } + + if (hookToEdit) { + context.editHook({ hook, uuid: hookToEdit.uuid }) + return + } + + context.addHook({ hook }) + }, [target, callData, gasLimit, context]) + + const buttonProps = useMemo(() => { + if (!context.account) return { message: 'Connect wallet', disabled: true } + if (!context.orderParams) return { message: 'Missing order params', disabled: true } + return { message: 'Add Post-hook', disabled: false } + }, [hookToEdit, context.account, isPreHook]) + + return ( + + This hook will transfer half of the buyAmount to {toAddress}. + + {buttonProps.message} + + + ) +} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useCowShedSignature.ts b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useCowShedSignature.ts new file mode 100644 index 00000000000..a12cda8fb05 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useCowShedSignature.ts @@ -0,0 +1,68 @@ +import { useCallback, useMemo } from 'react' + +import { SigningScheme } from '@cowprotocol/contracts' +import type { CowShedHooks, ICoWShedCall } from '@cowprotocol/cow-sdk' +import { HookDappContext } from '@cowprotocol/hook-dapp-lib' + +import { stringToHex } from 'viem' + +import type { Signer } from 'ethers' + +export interface BaseTransaction { + to: string + value: bigint + callData: string + isDelegateCall?: boolean +} + +export function useHookDeadline({ context }: { context: HookDappContext | undefined }) { + return useMemo(() => { + const now = new Date() + const validToOnTimezone = context?.orderParams?.validTo || 0 + const validToTimestamp = validToOnTimezone + now.getTimezoneOffset() * 60 + const currentTimestamp = new Date().getTime() / 1000 + const oneHourAfter = Number(currentTimestamp.toFixed()) + 60 * 60 + + if (validToTimestamp < oneHourAfter) return BigInt(oneHourAfter) + return BigInt(validToTimestamp) + }, [context?.orderParams?.validTo]) +} + +export function getCowShedNonce() { + return stringToHex(Date.now().toString(), { size: 32 }) +} + +export function useCowShedSignature({ + cowShed, + signer, + context, +}: { + cowShed: CowShedHooks | undefined + signer: Signer | undefined + context: HookDappContext | undefined +}) { + const hookDeadline = useHookDeadline({ context }) + + return useCallback( + async (txs: BaseTransaction[]) => { + if (!cowShed || !signer || !context?.account) return + const cowShedCalls: ICoWShedCall[] = txs.map((tx) => { + return { + target: tx.to, + value: BigInt(tx.value), + callData: tx.callData, + allowFailure: false, + isDelegateCall: !!tx.isDelegateCall, + } + }) + const nonce = getCowShedNonce() + const signature = await cowShed + .signCalls(cowShedCalls, nonce, hookDeadline, signer, SigningScheme.EIP712) + .catch(() => { + throw new Error('User rejected signature') + }) + return cowShed.encodeExecuteHooksForFactory(cowShedCalls, nonce, hookDeadline, context.account, signature) + }, + [hookDeadline, cowShed, signer, context], + ) +} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenAllowance.ts b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenAllowance.ts new file mode 100644 index 00000000000..e7480e8757c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenAllowance.ts @@ -0,0 +1,112 @@ +import { useCallback } from 'react' + +import { Erc20Abi } from '@cowprotocol/abis' +import { HookDappContext } from '@cowprotocol/hook-dapp-lib' +import { + type GetTokenPermitIntoResult, + type PermitInfo, + generatePermitHook, + getPermitUtilsInstance, + getTokenPermitInfo, +} from '@cowprotocol/permit-utils' +import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers' + +import { BigNumber, type Signer } from 'ethers' +import { Abi, type Address, maxUint256, PublicClient } from 'viem' + +import { handleTokenApprove } from './useHandleTokenApprove' + +export function useHandleTokenAllowance({ + spender, + context, + web3Provider, + publicClient, + jsonRpcProvider, + signer, +}: { + spender: Address | undefined + context: HookDappContext + web3Provider: Web3Provider | undefined + publicClient: PublicClient | undefined + jsonRpcProvider: JsonRpcProvider | undefined + signer: Signer | undefined +}) { + return useCallback( + async (amount: BigNumber, tokenAddress: Address) => { + if (!publicClient || !jsonRpcProvider || !context?.account || !spender || !web3Provider) + throw new Error('Missing context') + + const tokenContract = { + address: tokenAddress, + abi: Erc20Abi as Abi, + } + + const [{ result: currentAllowance }, { result: tokenName }] = await publicClient.multicall({ + contracts: [ + { + ...tokenContract, + functionName: 'allowance', + args: [context.account, spender], + }, + { + ...tokenContract, + functionName: 'name', + }, + ], + }) + if (currentAllowance === undefined || !tokenName) { + throw new Error('Token allowance not available') + } + + if (amount.lte(BigNumber.from(currentAllowance))) { + // amount is less than or equal to current allowance so no need to approve + return + } + + const { chainId, account } = context + + const eip2162Utils = getPermitUtilsInstance(chainId, web3Provider, account) + + const [permitInfo, nonce] = await Promise.all([ + getTokenPermitInfo({ + spender, + tokenAddress, + chainId, + provider: jsonRpcProvider, + }), + eip2162Utils.getTokenNonce(tokenAddress, account), + ]).catch(() => [undefined, undefined]) + + if (!permitInfo || !checkIsPermitInfo(permitInfo)) { + await handleTokenApprove({ + signer, + spender, + tokenAddress, + amount: maxUint256, + }) + return + } + + const hook = await generatePermitHook({ + chainId, + inputToken: { + address: tokenAddress, + name: tokenName as string, + }, + spender, + provider: jsonRpcProvider, + permitInfo, + eip2162Utils: eip2162Utils, + account, + nonce, + }) + if (!hook) throw new Error('User rejected permit') + return hook + }, + [jsonRpcProvider, context, publicClient, spender, signer, web3Provider], + ) +} + +export function checkIsPermitInfo(permitInfo: GetTokenPermitIntoResult): permitInfo is PermitInfo { + return 'type' in permitInfo && permitInfo.type !== 'unsupported' +} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenApprove.ts b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenApprove.ts new file mode 100644 index 00000000000..7c7bea8a26b --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useHandleTokenApprove.ts @@ -0,0 +1,64 @@ +import { useCallback } from 'react' + +import { Erc20Abi } from '@cowprotocol/abis' + +import { encodeFunctionData, maxUint256, type Address } from 'viem' + +import type { Signer } from 'ethers' + +export function useHandleTokenMaxApprove({ + signer, + spender, +}: { + signer: Signer | undefined + spender: Address | undefined +}) { + return useCallback( + async (tokenAddress: Address) => { + handleTokenApprove({ + signer, + spender, + tokenAddress, + amount: maxUint256, + }) + }, + [signer, spender], + ) +} + +export async function handleTokenApprove({ + signer, + spender, + tokenAddress, + amount, +}: { + signer: Signer | undefined + spender: Address | undefined + tokenAddress: Address + amount: bigint +}) { + if (!signer || !spender) { + throw new Error('Missing context') + } + + // add encodeFunctionData + const data = encodeFunctionData({ + abi: Erc20Abi, + functionName: 'approve', + args: [spender, amount], + }) + + const transaction = await signer + .sendTransaction({ + to: tokenAddress, + value: '0', + data, + }) + .catch(() => { + throw new Error('User rejected transaction') + }) + + const receipt = await transaction.wait() + + return receipt +} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/hookRegistry.tsx b/apps/cowswap-frontend/src/modules/hooksStore/hookRegistry.tsx index d2623fda64e..f9973fd02ec 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/hookRegistry.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/hookRegistry.tsx @@ -4,6 +4,7 @@ import { AirdropHookApp } from './dapps/AirdropHookApp' import { BuildHookApp } from './dapps/BuildHookApp' import { ClaimGnoHookApp } from './dapps/ClaimGnoHookApp' import { PermitHookApp } from './dapps/PermitHookApp' +import { TransferHookApp } from './dapps/TransferHook' import { HookDapp, HookDappInternal } from './types/hooks' const HOOK_DAPPS_OVERRIDES: Record> = { @@ -11,6 +12,7 @@ const HOOK_DAPPS_OVERRIDES: Record> = { CLAIM_GNO_FROM_VALIDATORS: { component: (props) => }, PERMIT_TOKEN: { component: (props) => }, CLAIM_COW_AIRDROP: { component: (props) => }, + TRANSFER: { component: (props) => }, } export const ALL_HOOK_DAPPS = Object.keys(hookDappsRegistry).map((id) => { diff --git a/libs/hook-dapp-lib/src/hookDappsRegistry.json b/libs/hook-dapp-lib/src/hookDappsRegistry.json index e3c1f854fef..8f024f630a0 100644 --- a/libs/hook-dapp-lib/src/hookDappsRegistry.json +++ b/libs/hook-dapp-lib/src/hookDappsRegistry.json @@ -137,5 +137,18 @@ "walletCompatibility": ["EOA"], "supportedNetworks": [1, 42161, 11155111, 8453] } + }, + "TRANSFER": { + "name": "Transfer", + "type": "INTERNAL", + "descriptionShort": "Transfer some buyAmount to another address.", + "description": "Transfer some buyAmount to another address.", + "version": "0.0.1", + "website": "https://dev.swap.cow.fi/", + "image": "https://cdn-icons-png.flaticon.com/512/2879/2879440.png", + "conditions": { + "position": "post", + "walletCompatibility": ["EOA"] + } } } From a379f04877af054142dd7209ab0573ea43649232 Mon Sep 17 00:00:00 2001 From: Jean Neiverth Date: Tue, 29 Apr 2025 10:23:38 -0300 Subject: [PATCH 2/2] feat: functional transfer hook --- .../hooksStore/dapps/TransferHook/index.tsx | 97 ++++++++------- .../TransferHook/useReadTokenContract.ts | 116 ++++++++++++++++++ 2 files changed, 170 insertions(+), 43 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useReadTokenContract.ts diff --git a/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx index 102085090ab..cd64775dd2a 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx @@ -2,52 +2,38 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Wrapper } from '../styled' import { ButtonPrimary } from '@cowprotocol/ui' import { HookDappProps } from 'modules/hooksStore/types/hooks' -import { Address, createPublicClient, encodeFunctionData, http } from 'viem' +import { Address, Chain, createPublicClient, encodeFunctionData, formatUnits, http, isAddress, parseUnits } from 'viem' import { Erc20Abi } from '@cowprotocol/abis' -import { mainnet } from 'viem/chains' +import { mainnet, base } from 'viem/chains' import { CowShedHooks } from '@cowprotocol/cow-sdk' import { BaseTransaction, useCowShedSignature } from './useCowShedSignature' -import { CoWHookDappActions, HookDappContext, initCoWHookDapp } from '@cowprotocol/hook-dapp-lib' -import { BigNumber, type Signer } from 'ethers' +import { BigNumber } from 'ethers' import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers' import { useHandleTokenAllowance } from './useHandleTokenAllowance' +import { useReadTokenContract } from './useReadTokenContract' +import { useWalletProvider } from '@cowprotocol/common-hooks' export function TransferHookApp({ context }: HookDappProps) { const hookToEdit = context.hookToEdit const isPreHook = context.isPreHook const account = context?.account const tokenAddress = context?.orderParams?.buyTokenAddress as Address | undefined - const amount = context?.orderParams?.buyAmount ? BigInt(context.orderParams.buyAmount) / BigInt(2) : undefined - - const toAddress = '0x21F3d1B62f6F23fC8b1B6920a3b62915790A85D5' const target = tokenAddress - const [_context, _setContext] = useState() - const [web3Provider, setWeb3Provider] = useState() - const [actions, setActions] = useState() - const [signer, setSigner] = useState() - - useEffect(() => { - const { actions, provider } = initCoWHookDapp({ - onContext: _setContext as (args: HookDappContext) => void, - }) - - setActions(actions) + const web3Provider = useWalletProvider() + const signer = useMemo(() => web3Provider && web3Provider.getSigner(), [web3Provider]) - const web3Provider = new Web3Provider(provider) - setWeb3Provider(web3Provider) - setSigner(web3Provider.getSigner()) - }, []) + const chain = ([mainnet, base].find((chain) => chain.id === context?.chainId) ?? mainnet) as Chain const publicClient = useMemo(() => { if (!context?.chainId) return return createPublicClient({ - chain: mainnet, - transport: http('https://eth.llamarpc.com'), + chain, + transport: http(context?.chainId === 1 ? 'https://eth.llamarpc.com' : 'https://base.llamarpc.com'), }) }, [context?.chainId]) @@ -56,14 +42,14 @@ export function TransferHookApp({ context }: HookDappProps) { return new JsonRpcProvider('https://eth.llamarpc.com') }, [context?.chainId]) - const callData = useMemo(() => { - if (!tokenAddress || !amount || !signer) return - return encodeFunctionData({ - abi: Erc20Abi, - functionName: 'transferFrom', - args: [account, toAddress, amount], - }) - }, [tokenAddress, amount, account]) + const { tokenDecimals, tokenSymbol, userBalance } = useReadTokenContract({ + tokenAddress: context?.orderParams?.buyTokenAddress as Address | undefined, + context, + publicClient, + }) + + const formattedBalance = + tokenDecimals && userBalance !== undefined ? formatUnits(userBalance, tokenDecimals) : undefined const gasLimit = '100000' @@ -93,7 +79,22 @@ export function TransferHookApp({ context }: HookDappProps) { }) const onButtonClick = useCallback(async () => { - if (!cowShed || !target || !callData || !gasLimit || !amount) return + if (!cowShed || !target || !gasLimit) return + + //@ts-ignore + const amount = String(document?.getElementById('amount')?.value) + //@ts-ignore + const toAddress = String(document?.getElementById('address')?.value) + + if (!amount || !toAddress || !isAddress(toAddress) || Number(amount) <= 0) return + + const amountBigint = parseUnits(amount, tokenDecimals) + + const callData = encodeFunctionData({ + abi: Erc20Abi, + functionName: 'transferFrom', + args: [account, toAddress, amountBigint], + }) const hookTx: BaseTransaction = { to: target, @@ -103,15 +104,15 @@ export function TransferHookApp({ context }: HookDappProps) { const permitTx = await handleTokenAllowance(BigNumber.from(amount), tokenAddress) - if (!permitTx) return - - const permitTxAdjusted = { - to: permitTx.target, - value: BigInt(0), - callData: permitTx.callData, - } + const permitTxAdjusted = permitTx + ? { + to: permitTx.target, + value: BigInt(0), + callData: permitTx.callData, + } + : undefined - const txs = [permitTxAdjusted, hookTx] + const txs = permitTxAdjusted ? [permitTxAdjusted, hookTx] : [hookTx] const cowShedCall = await cowShedSignature(txs) if (!cowShedCall) throw new Error('Error signing hooks') @@ -128,7 +129,7 @@ export function TransferHookApp({ context }: HookDappProps) { } context.addHook({ hook }) - }, [target, callData, gasLimit, context]) + }, [target, gasLimit, context]) const buttonProps = useMemo(() => { if (!context.account) return { message: 'Connect wallet', disabled: true } @@ -138,7 +139,17 @@ export function TransferHookApp({ context }: HookDappProps) { return ( - This hook will transfer half of the buyAmount to {toAddress}. + Amount of {tokenSymbol} to transfer + + Your balance: {formattedBalance} + Address to transfer + {buttonProps.message} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useReadTokenContract.ts b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useReadTokenContract.ts new file mode 100644 index 00000000000..2f19a92db80 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/useReadTokenContract.ts @@ -0,0 +1,116 @@ +import { useCallback } from 'react' + +import { Erc20Abi } from '@cowprotocol/abis' +import type { HookDappContext } from '@cowprotocol/hook-dapp-lib' + +import { BigNumber } from 'ethers' +import useSWR from 'swr' +import { Abi, Address, PublicClient, zeroAddress } from 'viem' + +export const useReadTokenContract = ({ + tokenAddress, + context, + publicClient, +}: { + tokenAddress: Address | undefined + context: HookDappContext + publicClient: PublicClient | undefined +}) => { + const tokenAddressLowerCase = tokenAddress?.toLowerCase() + + const _readTokenContract = useCallback( + async (address: Address) => { + if (!publicClient || !context?.account) return + return readTokenContract(address, publicClient, context?.account as Address, context?.balancesDiff) + }, + [publicClient, context?.account, context?.balancesDiff], + ) + + const { + data: tokenData, + isLoading: isLoadingToken, + error: errorToken, + } = useSWR(tokenAddressLowerCase, _readTokenContract, { + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + refreshWhenHidden: false, + refreshInterval: 0, + }) + + const tokenSymbol = tokenData?.symbol?.result ? String(tokenData.symbol.result) : '' + const tokenDecimals = Number(tokenData?.decimals?.result) + const userBalance = tokenData?.balance?.result + + return { + tokenSymbol, + tokenDecimals, + userBalance, + isLoadingToken, + errorToken, + } +} + +export const readTokenContract = async ( + address: Address, + publicClient: PublicClient, + account: Address, + balancesDiff?: HookDappContext['balancesDiff'], +) => { + const tokenAddressLowerCase = address.toLowerCase() as Address + const tokenBalanceDiff = balancesDiff?.account?.[tokenAddressLowerCase] || '0' + + const tokenContract = { + address: tokenAddressLowerCase, + abi: Erc20Abi as Abi, + } + + const tokenResults = + publicClient && + (await publicClient.multicall({ + contracts: [ + { + ...tokenContract, + functionName: 'symbol', + }, + { + ...tokenContract, + functionName: 'decimals', + }, + { + ...tokenContract, + functionName: 'balanceOf', + args: [account ?? zeroAddress], + }, + ], + })) + + for (const result of tokenResults ?? []) { + // Unexpected errors with token + if (result.status === 'failure') throw new Error('Unexpected error') + } + + if (!account) { + return { + symbol: tokenResults?.[0], + decimals: tokenResults?.[1], + balance: undefined, + } + } + const contractBalance = tokenResults?.[2]?.result + + const balanceWithContextDiff = BigNumber.from(contractBalance) + .add(BigNumber.from(tokenBalanceDiff ?? 0)) + .toBigInt() + + const balanceResultWithContextDiff = { + ...tokenResults?.[2], + result: balanceWithContextDiff, + } + + return { + symbol: tokenResults?.[0], + decimals: tokenResults?.[1], + balance: balanceResultWithContextDiff, + } +}