Two Uniswap v4 pools can trade the same currencies and behave differently. One charges a fixed LP fee. Another uses a hook to change the fee for each swap. A third checks the caller, modifies accounting or rejects the operation under conditions your reserve calculation does not model.
For a bot, a pool’s price is therefore not enough to describe the trade. You need the pool key, hook address, execution path and state used for simulation. A successful quote through one caller does not prove the same swap succeeds through your executor.
The pool discovery guide covers how to obtain v4 pool keys from Initialize events. This article starts with that key and follows the swap through fee selection, hooks and settlement. The code is read-only: it executes eth_call, not a transaction submission.
A pool key identifies more than a currency pair
The key contains currency0, currency1, fee, tickSpacing and hooks. Its ABI-encoded hash is the PoolId. Changing the hook address or tick spacing changes the pool, even if the two currencies are unchanged.
The zero address is a native-ETH currency in v4. It is not an ERC-20 contract to query for decimals() or approve. Your route builder must also distinguish native ETH from WETH and include wrapping or unwrapping where the selected route requires it.
Store the complete key. A map indexed only by currency pair will merge pools that have different fees, liquidity distributions and callback logic.
Two ways a dynamic LP fee changes
LPFeeLibrary defines the dynamic-fee flag as 0x800000. A pool created with that flag does not use the key’s fee field as an ordinary fixed percentage.
The pool’s hook can change the fee in two ways:
- Update the stored fee. The hook calls
PoolManager.updateDynamicLPFee(key, fee). This is a PoolManager call, not a value returned by a callback. The stored setting remains until it is changed again. - Override one swap. A permitted
beforeSwapcallback returns a fee withOVERRIDE_FEE_FLAG,0x400000, set. That override applies to the current swap without replacing the stored fee.
The flags are different. Treating 0x800000 as a number of fee units would display a nonsensical percentage. Ordinary LP fee values use hundredths of a basis point: 500 means 0.05%, and 3000 means 0.30%.
For a current-swap decision inside beforeSwap, use the override semantics specified by the deployed implementation. Do not infer that changing stored state during a callback necessarily changes a fee already read by the calling path. Consult Hooks.sol and the matching PoolManager version rather than treating the two mechanisms as interchangeable.
Dynamic LP fees are not the only cost. Protocol fees and permitted hook accounting can also matter. Compute the route’s net result, not just an LP fee percentage extracted from pool metadata.
What the hook address tells you
The low 14 bits of the hook address encode callback permissions. The constants are in Hooks.sol.
| Permission | Mask | Why a bot cares |
|---|---|---|
beforeInitialize |
0x2000 |
Initialization can execute custom logic |
afterInitialize |
0x1000 |
Initialization can trigger follow-up logic |
beforeSwap |
0x0080 |
The hook can inspect or reject the swap and choose an override |
afterSwap |
0x0040 |
More logic can execute after the pool swap |
beforeSwapReturnsDelta |
0x0008 |
The hook can participate in swap accounting |
afterSwapReturnsDelta |
0x0004 |
The hook can return a post-swap accounting delta |
For example, afterInitialize plus beforeSwap is 0x1080. That says where the PoolManager may invoke the hook. It does not say what the code does at those callbacks.
A deployment may use CREATE2 address mining to obtain the desired bits. The bits are fixed by the address, but that does not prove behavior is immutable: the code may depend on mutable configuration, external contracts or an upgrade mechanism. Read the deployed source and its authority model before granting allowances or admitting the pool to an automated route set.
The hook’s sender is usually not your EOA
For the swap callback, sender is the immediate caller of PoolManager.swap. In a routed trade that is generally the router or executor operating inside the unlock flow. The callback’s own msg.sender is the PoolManager. These are different identities.
hookData is an opaque byte string passed through the swap call. A hook may interpret it as a signature, a user identifier or other application data. Decoding an address from it does not authenticate that address. An untrusted caller can encode someone else’s address.
If a hook offers a user-specific fee or permission, inspect how it authenticates that claim. A trusted router can pass an identity it has actually verified; another design may check a signature with replay protection. Merely checking that the callback came from the PoolManager does not authenticate the original user.
This affects simulation directly. Swapping through a Quoter can expose a different sender to the hook than swapping through your production router. Supplying the same currencies and amount does not eliminate that difference.
What the V4 Quoter can tell you
Uniswap provides a V4Quoter. Use the official deployment list for the address on the network and version you intend to use.
Like the v3 quoting approach, it simulates the swap rather than relying on a simple pool view function. The implementation uses a revert internally to unwind state and carry the quote result, then catches and decodes that result. Invoke the Quoter through eth_call or your library’s equivalent simulation method, not as a paid on-chain transaction.
eth_call is not the EVM’s STATICCALL opcode. The node can execute state-changing operations and discard their effects after simulation. That is why a quote can exercise swap logic without committing the resulting state.
A quote is useful for route selection, but it is not a rehearsal of every production step. It may use a different caller, skip the user’s actual Permit2 or allowance path, and omit the executor’s final balance checks. A hook may also behave differently with different hookData. Test the complete transaction before treating the quote as executable.
Simulate the transaction you will actually sign
For the Universal Router, the outer execute call contains commands and their encoded inputs. The current command definitions assign V4_SWAP the byte 0x10; 0x13 is V4_INITIALIZE_POOL, not a swap.
A valid v4 input also contains the required action plan and settlement operations. Passing a raw PoolKey and amount as the command’s entire input is not a complete Universal Router swap. Use the SDK or encoder matching your deployed router, including its settle/take actions and output bounds.
Save the exact RPC transaction object produced by that route builder as swap-tx.json. Include the real from, to, data, value, intended gas cap and fee fields. RPC uses gas, not ethers’ application-level gasLimit property. Existing balances, allowances and Permit2 state must support the call, unless those changes are performed earlier in the same transaction.
The following Node.js 22 script accepts that object without replacing the caller, fees or gas budget. It pins execution to a canonical block hash using EIP-1898. No wallet or private key is needed.
Save it as simulate-v4-swap.mjs:
import { readFile } from "node:fs/promises";
const file = process.argv[2];
const url = process.env.RPC_URL;
if (!file || !url || !["http:", "https:"].includes(new URL(url).protocol)) {
throw new Error(
"Usage: set HTTP(S) RPC_URL, then node simulate-v4-swap.mjs swap-tx.json"
);
}
const tx = JSON.parse(await readFile(file, "utf8"));
for (const key of ["from", "to"]) {
if (!/^0x[0-9a-fA-F]{40}$/.test(tx[key])) {
throw new Error(`Invalid ${key}`);
}
}
if (!/^0x(?:[0-9a-fA-F]{2})+$/.test(tx.data)) {
throw new Error("Expected encoded calldata");
}
// RPC uses "gas", not ethers' application-level "gasLimit".
if (!/^0x[1-9a-fA-F][0-9a-fA-F]*$/.test(tx.gas)) {
throw new Error("Supply the intended RPC gas cap");
}
if ("gasLimit" in tx) {
throw new Error("Use RPC gas, not gasLimit");
}
let id = 0;
async function rpc(method, params) {
const requestId = ++id;
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params }),
signal: AbortSignal.timeout(15000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`HTTP ${response.status} from RPC`);
}
const message = await response.json();
if (message.id !== requestId) {
throw new Error("Mismatched RPC response");
}
if (message.error) {
console.error(JSON.stringify({ method, rpcError: message.error }));
throw new Error(`${method} failed; inspect the RPC error above`);
}
if (!("result" in message)) {
throw new Error("Missing RPC result");
}
return message.result;
}
try {
if (await rpc("eth_chainId", []) !== "0x1") {
throw new Error("Expected Ethereum mainnet");
}
let hash = process.env.BLOCK_HASH;
if (hash && !/^0x[0-9a-fA-F]{64}$/.test(hash)) {
throw new Error("Invalid BLOCK_HASH");
}
const block = hash
? await rpc("eth_getBlockByHash", [hash, false])
: await rpc("eth_getBlockByNumber", ["latest", false]);
if (!block?.hash) {
throw new Error("Simulation block unavailable");
}
hash = block.hash;
// EIP-1898 pins the call to this exact block and requires it be canonical.
// A node that does not support the selector will fail visibly.
const result = await rpc("eth_call", [
tx,
{ blockHash: hash, requireCanonical: true },
]);
if (!/^0x(?:[0-9a-fA-F]{2})*$/.test(result)) {
throw new Error("Invalid call returndata");
}
console.log(
JSON.stringify(
{
blockHash: hash,
blockNumber: Number(BigInt(block.number)),
returndata: result,
},
null,
2
)
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
With RPC_URL set to your HTTP(S) Ethereum endpoint, run:
node simulate-v4-swap.mjs swap-tx.json
BLOCK_HASH is optional. Without it, the script obtains the latest block once and pins the call to that hash. With it, the script uses the supplied block and requires it to be canonical. An endpoint that does not support this selector should fail visibly; falling back silently to latest would change the experiment.
This diagnostic uses HTTP fetch. BLAZED.sh’s injected endpoint is WebSocket; in an existing ethers application, the same raw eth_call params can be sent through its WebSocket provider. The transport is not part of the swap ABI.
A successful call is not a profit report
eth_call returns the called function’s returndata, not a receipt, emitted logs or arbitrary balance deltas. Universal Router execute has no return value, so a successful call normally returns 0x. That is not “zero tokens received,” and it is not a decoded quote.
To enforce an acceptable outcome, put the relevant output bound into the actual swap plan. An arbitrage executor should additionally check the final asset balance or minimum-profit condition it needs. If the executor deliberately allows a command to revert, an overall successful call may include a failed swap; inspect that command configuration too.
For detailed analysis, use the client’s supported call-tracing facilities or a controlled local fork to inspect balances and execution. Do not assume that every hook emits a standard profit event. There is no universal BalancesChanged event that makes an arbitrary routed trade’s net profit available from plain eth_call.
Gas estimation is a separate RPC operation. Keep the same transaction fields, distinguish a gas estimate from a worst-case budget, and include gas plus any builder payment when assessing net profit. A contract’s token-balance assertion alone may not cover ETH fees paid by the transaction sender.
State and environment can still change
The pinned call uses the selected block’s post-state and execution environment. It does not predict the next block’s timestamp, base fee, transaction order or preceding swaps. Hooks can depend on those inputs.
After a new head, recompute stale state-dependent decisions. For a private bundle, simulate the exact ordered transaction sequence against the target environment rather than treating separate eth_call successes as equivalent. The bundle troubleshooting guide explains why those simulations can disagree with eventual inclusion.
Review unfamiliar hooks as untrusted external code. Permission bits and one successful simulation are not an audit. Source review and formal analysis can establish specific properties under stated assumptions, but neither a badge nor a spot check proves every future trade is safe.
The practical sequence is pool discovery, route construction, quoting, full-path simulation and a final acceptance check. Co-location can shorten the application-to-node network path; it does not cap the hook’s execution time or guarantee inclusion. Measure the stages with the RPC benchmarking guide instead of assigning every simulation a fixed latency.