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"]
+ }
}
}