diff --git a/.env.development b/.env.development index f0a0b4f66f..1f7793a354 100644 --- a/.env.development +++ b/.env.development @@ -11,3 +11,4 @@ NEXT_PUBLIC_API_BASEURL=https://aave-api-v2.aave.com NEXT_PUBLIC_SUBGRAPH_API_KEY= FAMILY_API_KEY= FAMILY_API_URL= +NEXT_PUBLIC_COW_SIMULATION_ONLY=true diff --git a/.env.example b/.env.example index 2d602969cf..43826036e5 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,7 @@ NEXT_PUBLIC_SUBGRAPH_API_KEY= NEXT_PUBLIC_IS_CYPRESS_ENABLED=false NEXT_PUBLIC_AMPLITUDE_API_KEY=6b28cb736c53d59f0951a50f59597aae NEXT_PUBLIC_PRIVATE_RPC_ENABLED=false +NEXT_PUBLIC_COW_SIMULATION_ONLY=true diff --git a/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaCoWAdapters.tsx b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaCoWAdapters.tsx index 9fe42097da..afac19476e 100644 --- a/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaCoWAdapters.tsx +++ b/src/components/transactions/Swap/actions/CollateralSwap/CollateralSwapActionsViaCoWAdapters.tsx @@ -21,6 +21,7 @@ import { getCowTradingSdkByChainIdAndAppCode, } from '../../helpers/cow'; import { calculateInstanceAddress } from '../../helpers/cow/adapters.helpers'; +import { simulateCollateralSwapPreHook } from '../../helpers/cow/simulation.helpers'; import { useSwapGasEstimation } from '../../hooks/useSwapGasEstimation'; import { areActionsBlocked, @@ -32,6 +33,14 @@ import { } from '../../types'; import { useSwapTokenApproval } from '../approval/useSwapTokenApproval'; +const cowSimulationOnlyFlag = process.env.NEXT_PUBLIC_COW_SIMULATION_ONLY; +const COW_SIMULATION_ONLY = + cowSimulationOnlyFlag === 'true' + ? true + : cowSimulationOnlyFlag === 'false' + ? false + : process.env.NODE_ENV !== 'production'; + /** * Collateral swap via CoW Protocol Flashloan Adapters. * @@ -230,6 +239,43 @@ export const CollateralSwapActionsViaCowAdapters = ({ orderPostParams.swapSettings.appData ); + const hooks = orderPostParams.swapSettings.appData?.metadata?.hooks; + console.log('[CoW][CollateralSwap] Hooks before order submission', { + preHooks: hooks?.pre, + postHooks: hooks?.post, + }); + if (COW_SIMULATION_ONLY) { + console.info('[CoW][CollateralSwap] Simulation-only mode is active'); + const simulationOk = await simulateCollateralSwapPreHook({ + chainId: state.chainId, + from: user as `0x${string}`, + preHook: hooks?.pre?.[0], + flashloan: orderPostParams.swapSettings.appData?.metadata?.flashloan, + postHook: hooks?.post?.[0], + settlementContext: { + receiver: orderPostParams.instanceAddress, + buyToken: state.buyAmountToken?.underlyingAddress, + buyAmount: state.buyAmountBigInt, + }, + }); + console.info('[CoW][CollateralSwap] Simulation result', simulationOk); + if (!simulationOk) { + console.info( + '[CoW][CollateralSwap] Simulation failed; skipping CoW order submission in simulation-only mode' + ); + setMainTxState({ + txHash: undefined, + loading: false, + success: false, + }); + setState({ + actionsLoading: false, + }); + return; + } + console.info('[CoW][CollateralSwap] Simulation passed; proceeding to post order'); + } + // Safe-check in case any param changed between approval and order posting const instanceAddress = orderPostParams.instanceAddress; if (instanceAddress !== approvedAddress) { diff --git a/src/components/transactions/Swap/helpers/cow/simulation.helpers.ts b/src/components/transactions/Swap/helpers/cow/simulation.helpers.ts new file mode 100644 index 0000000000..5744b55cbe --- /dev/null +++ b/src/components/transactions/Swap/helpers/cow/simulation.helpers.ts @@ -0,0 +1,229 @@ +import { + encodeAbiParameters, + erc20Abi, + encodeFunctionData, + keccak256, + toHex, +} from 'viem'; +import { getPublicClient } from 'wagmi/actions'; + +import { wagmiConfig } from 'src/ui-config/wagmiConfig'; + +const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11'; +const MULTICALL3_AGGREGATE3_ABI = [ + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'target', type: 'address' }, + { internalType: 'bool', name: 'allowFailure', type: 'bool' }, + { internalType: 'bytes', name: 'callData', type: 'bytes' }, + ], + internalType: 'struct IMulticall3.Call3[]', + name: 'calls', + type: 'tuple[]', + }, + ], + name: 'aggregate3', + outputs: [ + { + components: [ + { internalType: 'bool', name: 'success', type: 'bool' }, + { internalType: 'bytes', name: 'returnData', type: 'bytes' }, + ], + internalType: 'struct IMulticall3.Result[]', + name: 'returnData', + type: 'tuple[]', + }, + ], + stateMutability: 'payable', + type: 'function', + }, +] as const; + +type HookDefinition = { + target?: string; + callData?: string; + gasLimit?: string | number; +}; + +type FlashloanMetadata = { + amount?: string; + token?: string; + protocolAdapter?: string; + receiver?: string; +}; + +type OrderSettlementContext = { + receiver?: string; + buyToken?: string; + buyAmount?: bigint; +}; + +const DEFAULT_BALANCE_SLOT_CANDIDATES = [0n, 1n, 2n, 3n, 4n, 5n, 6n, 7n, 8n, 9n, 10n, 11n, 12n]; + +const getBalanceSlotKeys = (owner: string, slotCandidates = DEFAULT_BALANCE_SLOT_CANDIDATES) => { + return slotCandidates.map((slot) => { + const encoded = encodeAbiParameters( + [{ type: 'address' }, { type: 'uint256' }], + [owner as `0x${string}`, slot] + ); + return keccak256(encoded); + }); +}; + +const buildBalanceOverride = async ({ + chainId, + token, + recipient, + amount, +}: { + chainId: number; + token?: string; + recipient?: string; + amount?: bigint; +}) => { + const normalizedToken = token as `0x${string}` | undefined; + const normalizedRecipient = recipient as `0x${string}` | undefined; + if (!normalizedToken || !normalizedRecipient || amount === undefined) return undefined; + + const publicClient = getPublicClient(wagmiConfig, { chainId }); + if (!publicClient) return undefined; + + let currentBalance = 0n; + try { + currentBalance = await publicClient.readContract({ + address: normalizedToken, + functionName: 'balanceOf', + args: [normalizedRecipient], + abi: erc20Abi, + }); + } catch (error) { + console.warn('[CoW][CollateralSwap] Could not read current balance for state override', error); + } + + const updatedBalance = currentBalance + amount; + const balanceSlots = getBalanceSlotKeys(normalizedRecipient); + + const stateDiff: Record = {}; + balanceSlots.forEach((slot) => { + stateDiff[slot] = toHex(updatedBalance, { size: 32 }); + }); + + return { [normalizedToken]: { stateDiff } }; +}; + +const mergeOverrides = (...overrides: (Record }> | undefined)[]) => { + const merged: Record }> = {}; + overrides.forEach((override) => { + if (!override) return; + Object.entries(override).forEach(([addr, overrideVal]) => { + if (!merged[addr]) { + merged[addr] = { stateDiff: {} }; + } + merged[addr].stateDiff = { ...merged[addr].stateDiff, ...overrideVal.stateDiff }; + }); + }); + return Object.keys(merged).length ? merged : undefined; +}; + +export const simulateCollateralSwapPreHook = async ({ + chainId, + from, + preHook, + flashloan, + postHook, + settlementContext, +}: { + chainId: number; + from?: `0x${string}`; + preHook?: HookDefinition; + flashloan?: FlashloanMetadata; + postHook?: HookDefinition; + settlementContext?: OrderSettlementContext; +}) => { + const caller = from; + + if (!caller || !preHook?.target || !preHook?.callData) { + console.log('[CoW][CollateralSwap] Skipping preHook simulation, missing data'); + return false; + } + + const publicClient = getPublicClient(wagmiConfig, { chainId }); + if (!publicClient) { + console.warn('[CoW][CollateralSwap] No public client available for simulation'); + return; + } + + const flashloanOverride = await buildBalanceOverride({ + chainId, + token: flashloan?.token, + recipient: flashloan?.protocolAdapter ?? flashloan?.receiver, + amount: flashloan?.amount ? BigInt(flashloan.amount) : undefined, + }); + + const settlementOverride = await buildBalanceOverride({ + chainId, + token: settlementContext?.buyToken, + recipient: settlementContext?.receiver, + amount: settlementContext?.buyAmount, + }); + + const encodedPreHook = { + from: caller, + to: preHook.target as `0x${string}`, + input: preHook.callData as `0x${string}`, + gas: preHook.gasLimit ? toHex(BigInt(preHook.gasLimit)) : undefined, + }; + + const encodedPostHook = + postHook?.target && postHook?.callData + ? { + from: caller, + to: postHook.target as `0x${string}`, + input: postHook.callData as `0x${string}`, + gas: postHook?.gasLimit !== undefined ? toHex(BigInt(postHook.gasLimit)) : undefined, + } + : undefined; + + const callsSequence = [{ ...encodedPreHook, label: 'preHook' }, ...(encodedPostHook ? [{ ...encodedPostHook, label: 'postHook' }] : [])]; + + const aggregateData = encodeFunctionData({ + abi: MULTICALL3_AGGREGATE3_ABI, + functionName: 'aggregate3', + args: [ + callsSequence.map((c) => ({ + target: c.to as `0x${string}`, + allowFailure: true, + callData: (c as any).input ?? c.data, + })), + ], + }); + + const stateOverrides = mergeOverrides(flashloanOverride, settlementOverride); + + const blockStateCall = { + calls: [{ from, to: MULTICALL3_ADDRESS as `0x${string}`, input: aggregateData, label: 'multicall' }], + transactions: [{ from, to: MULTICALL3_ADDRESS as `0x${string}`, data: aggregateData }], + ...(stateOverrides ? { stateOverrides } : {}), + }; + + const simulationPayload = { + parentBlock: 'latest', + blockStateCalls: [blockStateCall], + }; + + console.log('[CoW][CollateralSwap] PreHook simulation payload', simulationPayload); + + try { + const result = await publicClient.request({ + method: 'eth_simulateV1', + params: [simulationPayload] as unknown[], + }); + console.log('[CoW][CollateralSwap] PreHook simulation result', result); + return true; + } catch (error) { + console.error('[CoW][CollateralSwap] PreHook simulation failed', error); + return false; + } +};