From 435bfdfa3e68cea1652bc00dcf5908bbc991d7b1 Mon Sep 17 00:00:00 2001 From: Pedro Yves Fracari <55461956+yvesfracari@users.noreply.github.com> Date: Wed, 23 Oct 2024 08:03:39 -0300 Subject: [PATCH] feat(hook-store): create bundle hooks tenderly simulation (#4943) * chore: init tenderly module * feat: enable bundle simulations * refactor: change bundle simulation to use SWR * chore: consider custom recipient * chore: remove goldrush sdk * fix: error on post hooks get simulation * refactor: use bff on bundle simulation feature * chore: remove console.logs * chore: fix leandro comments * chore: remove unused tenderly consts * refactor: rename tenderly simulation hook * chore: refactor top token holder swr to jotai with cache * chore: rename hook to match file name * refactor: use seconds for cache time in toptokenholder state --- apps/cowswap-frontend/.env | 2 +- apps/cowswap-frontend/package.json | 2 +- .../hooks/useSetupHooksStoreOrderParams.ts | 1 + .../hooksStore/pure/AppliedHookItem/index.tsx | 45 +++---- .../pure/AppliedHookItem/styled.tsx | 16 +++ .../tenderly/hooks/useGetTopTokenHolders.ts | 20 +++ .../hooks/useTenderlyBundleSimulation.ts | 106 ++++++++++++++++ .../modules/tenderly/state/topTokenHolders.ts | 55 ++++++++ .../src/modules/tenderly/types.ts | 26 ++++ .../tenderly/utils/bundleSimulation.ts | 117 ++++++++++++++++++ .../tenderly/utils/generateSimulationData.ts | 36 ++++++ .../tenderly/utils/getTokenTransferInfo.ts | 45 +++++++ libs/hook-dapp-lib/src/types.ts | 1 + 13 files changed, 448 insertions(+), 24 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tenderly/hooks/useGetTopTokenHolders.ts create mode 100644 apps/cowswap-frontend/src/modules/tenderly/hooks/useTenderlyBundleSimulation.ts create mode 100644 apps/cowswap-frontend/src/modules/tenderly/state/topTokenHolders.ts create mode 100644 apps/cowswap-frontend/src/modules/tenderly/types.ts create mode 100644 apps/cowswap-frontend/src/modules/tenderly/utils/bundleSimulation.ts create mode 100644 apps/cowswap-frontend/src/modules/tenderly/utils/generateSimulationData.ts create mode 100644 apps/cowswap-frontend/src/modules/tenderly/utils/getTokenTransferInfo.ts diff --git a/apps/cowswap-frontend/.env b/apps/cowswap-frontend/.env index 9461bc47d5..2ea99eeddf 100644 --- a/apps/cowswap-frontend/.env +++ b/apps/cowswap-frontend/.env @@ -135,4 +135,4 @@ REACT_APP_MOCK=true # REACT_APP_DOMAIN_REGEX_ENS="(:?^cowswap\.eth|ipfs)" # Path regex (to detect environment) -# REACT_APP_PATH_REGEX_ENS="/ipfs" +# REACT_APP_PATH_REGEX_ENS="/ipfs" \ No newline at end of file diff --git a/apps/cowswap-frontend/package.json b/apps/cowswap-frontend/package.json index 0b9af36fdc..457d76151a 100644 --- a/apps/cowswap-frontend/package.json +++ b/apps/cowswap-frontend/package.json @@ -30,4 +30,4 @@ "dependencies": {}, "devDependencies": {}, "nx": {} -} \ No newline at end of file +} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/hooks/useSetupHooksStoreOrderParams.ts b/apps/cowswap-frontend/src/modules/hooksStore/hooks/useSetupHooksStoreOrderParams.ts index d77e7e1907..10c9422649 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/hooks/useSetupHooksStoreOrderParams.ts +++ b/apps/cowswap-frontend/src/modules/hooksStore/hooks/useSetupHooksStoreOrderParams.ts @@ -21,6 +21,7 @@ export function useSetupHooksStoreOrderParams() { buyAmount: orderParams.outputAmount.quotient.toString(), sellTokenAddress: getCurrencyAddress(orderParams.inputAmount.currency), buyTokenAddress: getCurrencyAddress(orderParams.outputAmount.currency), + receiver: orderParams.recipient, }) } }, [orderParams, setOrderParams]) diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx index 09b25cd8cf..d30afa2a05 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/index.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react' + import ICON_CHECK_ICON from '@cowprotocol/assets/cow-swap/check-singular.svg' import ICON_GRID from '@cowprotocol/assets/cow-swap/grid.svg' import TenderlyLogo from '@cowprotocol/assets/cow-swap/tenderly-logo.svg' @@ -8,6 +10,8 @@ import { InfoTooltip } from '@cowprotocol/ui' import { Edit2, Trash2, ExternalLink as ExternalLinkIcon } from 'react-feather' import SVG from 'react-inlinesvg' +import { useTenderlyBundleSimulation } from 'modules/tenderly/hooks/useTenderlyBundleSimulation' + import * as styledEl from './styled' import { TenderlySimulate } from '../../containers/TenderlySimulate' @@ -23,25 +27,21 @@ interface HookItemProp { index: number } -// TODO: remove once a tenderly bundle simulation is ready -const isBundleSimulationReady = false +// TODO: refactor tu use single simulation as fallback +const isBundleSimulationReady = true export function AppliedHookItem({ account, hookDetails, dapp, isPreHook, editHook, removeHook, index }: HookItemProp) { - // TODO: Determine the simulation status based on actual simulation results - // For demonstration, using a placeholder. Replace with actual logic. - const simulationPassed = true // TODO: Replace with actual condition - const simulationStatus = simulationPassed ? 'Simulation successful' : 'Simulation failed' - const simulationTooltip = simulationPassed - ? 'The Tenderly simulation was successful. Your transaction is expected to succeed.' - : 'The Tenderly simulation failed. Please review your transaction.' + const { isValidating, data } = useTenderlyBundleSimulation() - // TODO: Placeholder for Tenderly simulation URL; replace with actual logic when available - const tenderlySimulationUrl = '' // e.g., 'https://tenderly.co/simulation/12345' + const simulationData = useMemo(() => { + if (!data) return + return data[hookDetails.uuid] + }, [data, hookDetails.uuid]) - // TODO: Determine if simulation passed or failed - const isSimulationSuccessful = simulationPassed - - if (!dapp) return null + const simulationStatus = simulationData?.status ? 'Simulation successful' : 'Simulation failed' + const simulationTooltip = simulationData?.status + ? 'The Tenderly simulation was successful. Your transaction is expected to succeed.' + : 'The Tenderly simulation failed. Please review your transaction.' return ( @@ -51,8 +51,9 @@ export function AppliedHookItem({ account, hookDetails, dapp, isPreHook, editHoo {index + 1} - {dapp.name} - {dapp.name} + {dapp?.name} + {dapp?.name} + {isValidating && } editHook(hookDetails.uuid)}> @@ -64,15 +65,15 @@ export function AppliedHookItem({ account, hookDetails, dapp, isPreHook, editHoo - {account && isBundleSimulationReady && ( - - {isSimulationSuccessful ? ( + {account && isBundleSimulationReady && simulationData && ( + + {simulationData.status ? ( ) : ( )} - {tenderlySimulationUrl ? ( - + {simulationData.link ? ( + {simulationStatus} diff --git a/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/styled.tsx b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/styled.tsx index 54a937e93f..10bf26cc22 100644 --- a/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/styled.tsx +++ b/apps/cowswap-frontend/src/modules/hooksStore/pure/AppliedHookItem/styled.tsx @@ -211,3 +211,19 @@ export const SimulateFooter = styled.div` padding: 2px; } ` + +export const Spinner = styled.div` + border: 5px solid transparent; + border-top-color: ${`var(${UI.COLOR_PRIMARY_LIGHTER})`}; + border-radius: 50%; + animation: spin 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite; + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +` diff --git a/apps/cowswap-frontend/src/modules/tenderly/hooks/useGetTopTokenHolders.ts b/apps/cowswap-frontend/src/modules/tenderly/hooks/useGetTopTokenHolders.ts new file mode 100644 index 0000000000..194a086a11 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tenderly/hooks/useGetTopTokenHolders.ts @@ -0,0 +1,20 @@ +import { useAtom } from 'jotai' +import { useCallback } from 'react' + +import { topTokenHoldersAtom } from '../state/topTokenHolders' +import { GetTopTokenHoldersParams } from '../types' + +export function useGetTopTokenHolders() { + const [cachedData, fetchTopTokenHolders] = useAtom(topTokenHoldersAtom) + + return useCallback( + async (params: GetTopTokenHoldersParams) => { + const key = `${params.chainId}-${params.tokenAddress}` + if (cachedData[key]?.value) { + return cachedData[key].value + } + return fetchTopTokenHolders(params) + }, + [cachedData, fetchTopTokenHolders], + ) +} diff --git a/apps/cowswap-frontend/src/modules/tenderly/hooks/useTenderlyBundleSimulation.ts b/apps/cowswap-frontend/src/modules/tenderly/hooks/useTenderlyBundleSimulation.ts new file mode 100644 index 0000000000..7ab1478887 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tenderly/hooks/useTenderlyBundleSimulation.ts @@ -0,0 +1,106 @@ +import { useCallback } from 'react' + +import { useWalletInfo } from '@cowprotocol/wallet' + +import useSWR from 'swr' + +import { useHooks } from 'modules/hooksStore' +import { useOrderParams } from 'modules/hooksStore/hooks/useOrderParams' + +import { useTokenContract } from 'common/hooks/useContract' + +import { useGetTopTokenHolders } from './useGetTopTokenHolders' + +import { completeBundleSimulation, preHooksBundleSimulation } from '../utils/bundleSimulation' +import { generateNewSimulationData, generateSimulationDataToError } from '../utils/generateSimulationData' +import { getTokenTransferInfo } from '../utils/getTokenTransferInfo' + +export function useTenderlyBundleSimulation() { + const { account, chainId } = useWalletInfo() + const { preHooks, postHooks } = useHooks() + const orderParams = useOrderParams() + const tokenSell = useTokenContract(orderParams?.sellTokenAddress) + const tokenBuy = useTokenContract(orderParams?.buyTokenAddress) + const buyAmount = orderParams?.buyAmount + const sellAmount = orderParams?.sellAmount + const orderReceiver = orderParams?.receiver || account + + const getTopTokenHolder = useGetTopTokenHolders() + + const simulateBundle = useCallback(async () => { + if (postHooks.length === 0 && preHooks.length === 0) return + + if (!postHooks.length) + return preHooksBundleSimulation({ + chainId, + preHooks, + }) + + if (!account || !tokenBuy || !tokenSell || !buyAmount || !sellAmount || !orderReceiver) { + return + } + + const buyTokenTopHolders = await getTopTokenHolder({ + tokenAddress: tokenBuy.address, + chainId, + }) + + if (!buyTokenTopHolders) return + + const tokenBuyTransferInfo = getTokenTransferInfo({ + tokenHolders: buyTokenTopHolders, + amountToTransfer: buyAmount, + }) + + const paramsComplete = { + postHooks, + preHooks, + tokenBuy, + tokenBuyTransferInfo, + sellAmount, + orderReceiver, + tokenSell, + account, + chainId, + } + + return completeBundleSimulation(paramsComplete) + }, [ + account, + chainId, + getTopTokenHolder, + tokenBuy, + postHooks, + preHooks, + buyAmount, + sellAmount, + orderReceiver, + tokenSell, + ]) + + const getNewSimulationData = useCallback(async () => { + try { + const simulationData = await simulateBundle() + + if (!simulationData) { + return {} + } + + return generateNewSimulationData(simulationData, { preHooks, postHooks }) + } catch { + return generateSimulationDataToError({ preHooks, postHooks }) + } + }, [preHooks, postHooks, simulateBundle]) + + const { data, isValidating: isBundleSimulationLoading } = useSWR( + ['tenderly-bundle-simulation', postHooks, preHooks, orderParams?.sellTokenAddress, orderParams?.buyTokenAddress], + getNewSimulationData, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + }, + ) + + return { data, isValidating: isBundleSimulationLoading } +} diff --git a/apps/cowswap-frontend/src/modules/tenderly/state/topTokenHolders.ts b/apps/cowswap-frontend/src/modules/tenderly/state/topTokenHolders.ts new file mode 100644 index 0000000000..64910555ca --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tenderly/state/topTokenHolders.ts @@ -0,0 +1,55 @@ +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' + +import { BFF_BASE_URL } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +export interface GetTopTokenHoldersParams { + tokenAddress?: string + chainId: SupportedChainId +} + +export interface TokenHolder { + address: string + balance: string +} + +export async function getTopTokenHolder({ tokenAddress, chainId }: GetTopTokenHoldersParams) { + if (!tokenAddress) return + + return (await fetch(`${BFF_BASE_URL}/${chainId}/tokens/${tokenAddress}/topHolders`, { + method: 'GET', + }).then((res) => res.json())) as TokenHolder[] +} + +interface CachedValue { + value: T + timestamp: number +} + +const baseTopTokenHolderAtom = atomWithStorage>>( + 'topTokenHolders:v1', + {}, +) + +export const topTokenHoldersAtom = atom( + (get) => get(baseTopTokenHolderAtom), + async (get, set, params: GetTopTokenHoldersParams) => { + const key = `${params.chainId}:${params.tokenAddress?.toLowerCase()}` + const cachedData = get(baseTopTokenHolderAtom) + const currentTime = Date.now() / 1000 + + // 1 hour in seconds + if (cachedData[key] && currentTime - cachedData[key].timestamp <= 3600) { + return cachedData[key].value + } + + const newValue = await getTopTokenHolder(params) + set(baseTopTokenHolderAtom, { + ...cachedData, + [key]: { value: newValue, timestamp: currentTime }, + }) + + return newValue + }, +) diff --git a/apps/cowswap-frontend/src/modules/tenderly/types.ts b/apps/cowswap-frontend/src/modules/tenderly/types.ts new file mode 100644 index 0000000000..9ef8eb6b56 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tenderly/types.ts @@ -0,0 +1,26 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +export interface SimulationInput { + input: string + from: string + to: string + value?: string + gas?: number + gas_price?: string +} + +export interface SimulationData { + link: string + status: boolean + id: string +} + +export interface GetTopTokenHoldersParams { + tokenAddress?: string + chainId: SupportedChainId +} + +export interface TokenHolder { + address: string + balance: string +} diff --git a/apps/cowswap-frontend/src/modules/tenderly/utils/bundleSimulation.ts b/apps/cowswap-frontend/src/modules/tenderly/utils/bundleSimulation.ts new file mode 100644 index 0000000000..63d7c8dd0a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tenderly/utils/bundleSimulation.ts @@ -0,0 +1,117 @@ +import { Erc20 } from '@cowprotocol/abis' +import { BFF_BASE_URL } from '@cowprotocol/common-const' +import { COW_PROTOCOL_SETTLEMENT_CONTRACT_ADDRESS, SupportedChainId } from '@cowprotocol/cow-sdk' +import { CowHookDetails } from '@cowprotocol/hook-dapp-lib' + +import { CowHook } from 'modules/hooksStore/types/hooks' + +import { SimulationData, SimulationInput } from '../types' + +export interface GetTransferTenderlySimulationInput { + currencyAmount: string + from: string + receiver: string + token: Erc20 +} + +export type TokenBuyTransferInfo = { + sender: string + amount: string +}[] +export interface PostBundleSimulationParams { + account: string + chainId: SupportedChainId + tokenSell: Erc20 + tokenBuy: Erc20 + preHooks: CowHookDetails[] + postHooks: CowHookDetails[] + sellAmount: string + orderReceiver: string + tokenBuyTransferInfo: TokenBuyTransferInfo +} + +export const completeBundleSimulation = async (params: PostBundleSimulationParams): Promise => { + const input = getBundleTenderlySimulationInput(params) + return simulateBundle(input, params.chainId) +} + +export const preHooksBundleSimulation = async ( + params: Pick, +): Promise => { + const input = params.preHooks.map((hook) => + getCoWHookTenderlySimulationInput(COW_PROTOCOL_SETTLEMENT_CONTRACT_ADDRESS[params.chainId], hook.hook), + ) + return simulateBundle(input, params.chainId) +} + +const simulateBundle = async (input: SimulationInput[], chainId: SupportedChainId): Promise => { + const response = await fetch(`${BFF_BASE_URL}/${chainId}/simulation/simulateBundle`, { + method: 'POST', + body: JSON.stringify(input), + headers: { + 'Content-Type': 'application/json', + }, + }).then((res) => res.json()) + + return response as SimulationData[] +} + +export function getCoWHookTenderlySimulationInput(from: string, params: CowHook): SimulationInput { + return { + input: params.callData, + to: params.target, + from, + } +} + +export function getTransferTenderlySimulationInput({ + currencyAmount, + from, + receiver, + token, +}: GetTransferTenderlySimulationInput): SimulationInput { + const callData = token.interface.encodeFunctionData('transfer', [receiver, currencyAmount]) + + return { + input: callData, + to: token.address, + from, + } +} + +export function getBundleTenderlySimulationInput({ + account, + chainId, + tokenSell, + tokenBuy, + preHooks, + postHooks, + sellAmount, + orderReceiver, + tokenBuyTransferInfo, +}: PostBundleSimulationParams): SimulationInput[] { + const settlementAddress = COW_PROTOCOL_SETTLEMENT_CONTRACT_ADDRESS[chainId] + const preHooksSimulations = preHooks.map((hook) => getCoWHookTenderlySimulationInput(settlementAddress, hook.hook)) + const postHooksSimulations = postHooks.map((hook) => getCoWHookTenderlySimulationInput(settlementAddress, hook.hook)) + + // If there are no post hooks, we don't need to simulate the transfer + if (postHooks.length === 0) return preHooksSimulations + + const sellTokenTransfer = getTransferTenderlySimulationInput({ + currencyAmount: sellAmount, + from: account, + receiver: COW_PROTOCOL_SETTLEMENT_CONTRACT_ADDRESS[chainId], + token: tokenSell, + }) + + const buyTokenTransfers = tokenBuyTransferInfo.map((transferInfo) => + getTransferTenderlySimulationInput({ + currencyAmount: transferInfo.amount, + from: transferInfo.sender, + receiver: postHooks[0].recipientOverride || orderReceiver, + token: tokenBuy, + }), + ) + + return [...preHooksSimulations, sellTokenTransfer, ...buyTokenTransfers, ...postHooksSimulations] +} diff --git a/apps/cowswap-frontend/src/modules/tenderly/utils/generateSimulationData.ts b/apps/cowswap-frontend/src/modules/tenderly/utils/generateSimulationData.ts new file mode 100644 index 0000000000..5d3416b736 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tenderly/utils/generateSimulationData.ts @@ -0,0 +1,36 @@ +import { PostBundleSimulationParams } from './bundleSimulation' + +import { SimulationData } from '../types' + +export function generateSimulationDataToError( + postParams: Pick, +): Record { + const preHooksKeys = postParams.preHooks.map((hookDetails) => hookDetails.uuid) + const postHooksKeys = postParams.postHooks.map((hookDetails) => hookDetails.uuid) + const hooksKeys = [...preHooksKeys, ...postHooksKeys] + + return hooksKeys.reduce( + (acc, key) => ({ + ...acc, + [key]: { link: '', status: false, id: key }, + }), + {}, + ) +} + +export function generateNewSimulationData( + simulationData: SimulationData[], + postParams: Pick, +): Record { + const preHooksKeys = postParams.preHooks.map((hookDetails) => hookDetails.uuid) + const postHooksKeys = postParams.postHooks.map((hookDetails) => hookDetails.uuid) + + const preHooksData = simulationData.slice(0, preHooksKeys.length) + + const postHooksData = simulationData.slice(-postHooksKeys.length) + + return { + ...preHooksKeys.reduce((acc, key, index) => ({ ...acc, [key]: preHooksData[index] }), {}), + ...postHooksKeys.reduce((acc, key, index) => ({ ...acc, [key]: postHooksData[index] }), {}), + } +} diff --git a/apps/cowswap-frontend/src/modules/tenderly/utils/getTokenTransferInfo.ts b/apps/cowswap-frontend/src/modules/tenderly/utils/getTokenTransferInfo.ts new file mode 100644 index 0000000000..1b3a232e4e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tenderly/utils/getTokenTransferInfo.ts @@ -0,0 +1,45 @@ +import { BigNumber } from 'ethers' + +import { TokenBuyTransferInfo } from './bundleSimulation' + +import { TokenHolder } from '../types' + +export function getTokenTransferInfo({ + tokenHolders, + amountToTransfer, +}: { + tokenHolders: TokenHolder[] + amountToTransfer: string +}): TokenBuyTransferInfo { + const amountToTransferBigNumber = BigNumber.from(amountToTransfer) + let sum = BigNumber.from('0') + const result: TokenBuyTransferInfo = [] + + if (!tokenHolders) { + return result + } + + for (const tokenHolder of tokenHolders) { + // skip token holders with no address or balance + if (!tokenHolder.address || !tokenHolder.balance) continue + + const tokenHolderAmount = BigNumber.from(tokenHolder.balance) + const sumWithTokenHolder = sum.add(tokenHolderAmount) + + if (sumWithTokenHolder.gte(amountToTransferBigNumber)) { + const remainingAmount = amountToTransferBigNumber.sub(sum) + result.push({ + sender: tokenHolder.address, + amount: remainingAmount.toString(), + }) + break + } + sum = sum.add(tokenHolderAmount) + result.push({ + sender: tokenHolder.address, + amount: tokenHolderAmount.toString(), + }) + } + + return result +} diff --git a/libs/hook-dapp-lib/src/types.ts b/libs/hook-dapp-lib/src/types.ts index 9adea48b49..57c2c2d65d 100644 --- a/libs/hook-dapp-lib/src/types.ts +++ b/libs/hook-dapp-lib/src/types.ts @@ -44,6 +44,7 @@ export interface HookDappOrderParams { validTo: number sellTokenAddress: string buyTokenAddress: string + receiver: string sellAmount: string buyAmount: string }