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..cd64775dd2a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/hooksStore/dapps/TransferHook/index.tsx @@ -0,0 +1,158 @@ +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, Chain, createPublicClient, encodeFunctionData, formatUnits, http, isAddress, parseUnits } from 'viem' +import { Erc20Abi } from '@cowprotocol/abis' + +import { mainnet, base } from 'viem/chains' + +import { CowShedHooks } from '@cowprotocol/cow-sdk' +import { BaseTransaction, useCowShedSignature } from './useCowShedSignature' +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 target = tokenAddress + + const web3Provider = useWalletProvider() + const signer = useMemo(() => web3Provider && web3Provider.getSigner(), [web3Provider]) + + const chain = ([mainnet, base].find((chain) => chain.id === context?.chainId) ?? mainnet) as Chain + + const publicClient = useMemo(() => { + if (!context?.chainId) return + return createPublicClient({ + chain, + transport: http(context?.chainId === 1 ? 'https://eth.llamarpc.com' : 'https://base.llamarpc.com'), + }) + }, [context?.chainId]) + + const jsonRpcProvider = useMemo(() => { + if (!context?.chainId) return + return new JsonRpcProvider('https://eth.llamarpc.com') + }, [context?.chainId]) + + 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' + + 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 || !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, + value: BigInt(0), + callData, + } + + const permitTx = await handleTokenAllowance(BigNumber.from(amount), tokenAddress) + + const permitTxAdjusted = permitTx + ? { + to: permitTx.target, + value: BigInt(0), + callData: permitTx.callData, + } + : undefined + + const txs = permitTxAdjusted ? [permitTxAdjusted, hookTx] : [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, 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 ( + + Amount of {tokenSymbol} to transfer + + Your balance: {formattedBalance} + Address to transfer + + + {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/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, + } +} 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"] + } } }