Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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



Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
*
Expand Down Expand Up @@ -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) {
Expand Down
229 changes: 229 additions & 0 deletions src/components/transactions/Swap/helpers/cow/simulation.helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};
balanceSlots.forEach((slot) => {
stateDiff[slot] = toHex(updatedBalance, { size: 32 });
});

return { [normalizedToken]: { stateDiff } };
};

const mergeOverrides = (...overrides: (Record<string, { stateDiff: Record<string, string> }> | undefined)[]) => {
const merged: Record<string, { stateDiff: Record<string, string> }> = {};
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;
Comment on lines +220 to +224

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Simulation always passes even when hooks would revert

The new eth_simulateV1 check never surfaces failures: the multicall is constructed with allowFailure: true (line 197), and in the try block the result is only logged before unconditionally returning true (lines 220-224). Because aggregate3 reports per-call failures via its success flag instead of throwing when allowFailure is true, any reverting pre/post hook will still return success here and the code proceeds to post the order even in simulation-only mode. This defeats the safety gate the simulation was meant to provide and lets invalid hook sequences be sent downstream.

Useful? React with 👍 / 👎.

} catch (error) {
console.error('[CoW][CollateralSwap] PreHook simulation failed', error);
return false;
}
};