Interview Questions
Twenty-eight questions across eight sections. Drill these out loud. Answers are hidden by default — say your answer first, then expand to check.
Solidity & EVM
Q1 — What's the gas cost difference between a cold and warm SLOAD, and when does each apply?
Cold SLOAD = 2,100 gas; warm SLOAD = 100 gas. The first time a slot is read in a transaction it's cold; subsequent reads in the same tx are warm. EIP-2929 introduced this; it's the basis for the "slot0" pattern — pack all hot state into one slot so the rest of the function pays only warm reads.
Q2 — Walk me through what TSTORE and TLOAD do and why they matter for AMM design.
Transient storage (EIP-1153, Cancun). Same shape as storage but values are cleared at end-of-transaction. Cost is ~100 gas regardless of warm/cold. Two killer DEX use cases: (1) reentrancy locks become ~200 gas total instead of ~25k+5k via SSTORE, (2) flash accounting in v4 — pass per-call net deltas between hooks and the PoolManager without persisting them.
Q3 — How does the EVM dispatch a function call, and how can you optimize for a hot function?
The compiler generates a series of EQ + JUMPI on the first 4 bytes of calldata. Each unmatched selector costs ~22 gas. So a function listed 10th pays ~200 gas more per call than the 1st. Optimization: name your hot function so its selector sorts first (Solady pattern), or write a custom Yul dispatcher with binary search.
Q4 — What's the difference between calldata, memory, and storage?
Calldata: read-only, externally provided, near-free to read. Memory: linear, mutable, allocated per-call, costs grow quadratically with expansion. Storage: 256-bit keyed slots, persistent across calls, cost is dominated by SLOAD/SSTORE. For external function parameters that don't need mutation, prefer calldata.
AMM math
Q5 — Derive the constant-product swap formula with a fee.
Reserves x, y. Invariant x · y = k. With fee f (e.g. 0.003): the effective input becomes Δx · (1−f). New reserves: (x + Δx·(1−f)) · (y − Δy) = k = x·y. Solve: Δy = y · Δx · (1−f) / (x + Δx · (1−f)). In v2 implementation: Δy = (Δx · 997 · y) / (x · 1000 + Δx · 997). Floor division — rounds in protocol favor.
Q6 — Why does Uniswap v3 use sqrtPriceX96 instead of price?
Because the math collapses. With √P = √(y/x), real reserves are x = L/√P − L/√P_b and y = L·√P − L·√P_a. A swap changing √P by Δ produces Δy = L · Δ — a linear relationship. Computing Δx and Δy from Δ√P avoids square roots in the hot path. The X96 is the fixed-point format: Q64.96.
Q7 — Explain the StableSwap D invariant. Why Newton's method?
StableSwap blends constant-sum (flat near peg) with constant-product (curved at extremes). The invariant: A·n·Σx + D = A·D·n + D^(n+1)/(n^n·Πx). There's no closed-form solution for D given reserves, or for y given x and D — both polynomials, both need iteration. Newton converges quickly because the function is well-behaved near the peg. Use a delta-≤-1 stop condition, not equality, to avoid integer-math oscillation.
Q8 — In which direction must getAmountOut round, and why?
Always down. The user receives at most the formula's value; the protocol retains any rounding leftover. Rounding up would let a sequence of small swaps drain the pool by one wei per swap. The lemma generalizes: "always round in the protocol's favor."
Gas optimization
Q9 — Walk through your standard "shave gas off this function" playbook.
In order: cache repeated storage reads as locals; mark unchanging vars immutable or constant; use unchecked where overflow is impossible; pack SSTOREs into single slot writes; replace require(..., "string") with custom errors; use calldata for struct parameters; short-circuit checks early; replace external calls with staticcall where applicable; inline single-call helpers. Profile with forge snapshot --diff.
Q10 — How does the SSTORE refund work post-London, and when is it worth claiming?
SSTORE-to-zero earns a refund (currently 4,800 gas) but the total refund is capped at gas-used / 5. Worth claiming when you have writes-then-clears in the same tx — common in flash-accounting patterns. Don't structure code to maximize refunds at the cost of clarity; the cap usually limits the benefit.
Q11 — What's FullMath.mulDiv and why does it exist?
It computes a * b / c even when a * b overflows 256 bits. The intermediate is 512-bit, computed via two MULMOD-style tricks. Critical for AMM math where you multiply a Q96-fixed-point price by a uint128 liquidity and divide by 2^96 — naive Solidity would overflow. Remco Bloemen's original derivation; cited in nearly every DEX codebase.
Q12 — When should you reach for inline assembly vs trust the Solidity compiler?
When the compiler reliably misses an optimization that matters on the hot path. Examples: custom selector dispatch, packed loads spanning two slots, bit-counting in tick bitmaps, 512-bit mulDiv. Never for "I think this could be faster" — profile first. Always with "memory-safe" annotations and detailed comments. The downside is that the compiler can no longer reason about correctness.
Design rounds
Q13 — Design a v4 hook that resists JIT liquidity while preserving normal LP UX.
JIT defense without harming long-term LPs: in beforeAddLiquidity, record the block number for each new position. In beforeRemoveLiquidity, if the position was added within the last N blocks, apply a fee surcharge (e.g. 25% of fees earned during that window, redirected to the pool's other LPs). After N blocks, the surcharge expires. JIT searchers face a structural unprofitability; passive LPs are unaffected. Tune N based on observed JIT activity.
Q14 — How would you design an oracle that's resistant to single-block manipulation?
A TWAP over a window long enough that manipulating it costs more than it earns. Components: (1) on-chain observation buffer storing (timestamp, tickCumulative); (2) observe() view that interpolates between buffer points; (3) lending-protocol-side query that demands a minimum window (e.g. 30 min) and a minimum pool depth; (4) optional combination with Chainlink/Pyth via a deviation check. The pool deposit must be deep enough that a 30-min TWAP push costs >$X for an attack with at most $Y profit.
Q15 — Design a router that supports Permit2, multi-hop, ETH wrapping, and refund of unspent native ETH.
Surface: multicall(bytes[] calls) — internal delegatecall to typed entry points (permitTokens, swapExactIn, swapExactOut, unwrapWETH9, refundETH). Each entry point reads msg.value internally to find user-supplied ETH; refund logic at end of multicall sends the remainder back. Permit2 entry point pulls user signatures and forwards approvals to Permit2. The core router is stateless across calls — each multicall is self-contained.
Q16 — How would you architect a singleton DEX from scratch?
A single PoolManager contract; per-pool state lives in mappings keyed by PoolKey = (token0, token1, fee, hooks). Liquidity, ticks, fee growth — all sub-state hanging off the pool key. The contract exposes unlock() which calls a user-supplied callback; inside the callback, the user performs swaps/LP actions that accumulate net deltas in transient storage. At unlock completion, all deltas must be zero. Tokens transfer only on settle() and take(). Hooks are user contracts whose permissions are encoded in their address; the PoolManager calls the right hook functions at lifecycle moments.
Attack vectors
Q17 — Walk through a sandwich attack and three defenses.
Attack: victim sends large swap to mempool. MEV bot sees it. Bot front-runs with same-direction swap (pushing price), lets victim's tx execute (worse price), then back-runs with reverse swap (capturing the spread). Defenses: (1) Slippage limits — defensive minimum but doesn't fully prevent. (2) Batch auctions (CoW) — settle multiple orders at one clearing price per block, sandwich is structurally impossible. (3) Private flow / encrypted mempools — the attacker never sees the victim's tx (Flashbots Protect, MEV-Share, Shutter).
Q18 — Explain read-only reentrancy. Give a real example.
Reentrancy via a view function. During a state-mutating call's mid-flight (typically a callback), an external view function returns inconsistent state. Integrators relying on that view make wrong decisions. The Curve 2022 incident: get_virtual_price called during remove_liquidity returned inflated values. Lending integrators using Curve LP tokens as collateral mispriced and allowed undercollateralized borrows. Defense: pool's view functions check the reentrancy lock and revert if held.
Q19 — Why is fee-on-transfer dangerous for naive AMM routers?
The router calls transferFrom(user, pool, 100) assuming the pool receives 100. The pool actually receives 95 (5% fee on transfer). Subsequent K-check uses 100 → false invariant violation, or fee math is wrong → arbitrage drain. Defense: measure the actual delta on the pool's balance after the transfer; never trust the requested amount. Router exposes a separate supportingFeeOnTransferTokens path so non-FoT users don't pay the gas overhead.
Q20 — A new fork of Uniswap v3 reuses TickMath but truncates one of the magic constants. Where will the bug manifest?
At extreme ticks. The TickMath constants are precomputed approximations of 1.0001^(±2^k/2) for each bit k. Truncating one constant introduces a systematic error that compounds as ticks get further from zero. Manifests: (1) v3 swaps near MIN_TICK / MAX_TICK return wrong amounts, (2) LP positions at extreme ranges accrue inconsistent fees, (3) tick crossing math diverges from the canonical implementation. Tests at the boundary tick values catch it immediately; fuzz at moderate prices does not.
Coding
Q21 — Write getAmountOut for v2 with a 30bps fee, including input validation.
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut)
internal pure returns (uint256 amountOut)
{
if (amountIn == 0) revert ZeroIn();
if (reserveIn == 0 || reserveOut == 0) revert NoLiq();
uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator; // floor — protocol-favorable
}Q22 — Implement integer sqrt using the Babylonian method.
function sqrt(uint256 x) internal pure returns (uint256 z) {
if (x == 0) return 0;
z = (x + 1) / 2;
uint256 y = x;
while (z < y) {
y = z;
z = (x / z + z) / 2;
}
return y;
}
// Invariant: y*y <= x < (y+1)*(y+1)Q23 — Implement a Permit2-aware swap entry point.
function swapWithPermit2(
PermitTransferFrom calldata permit,
bytes calldata signature,
address pool,
uint256 minOut,
address to
) external returns (uint256 out) {
// Pull tokens via Permit2 — single signed permit, no standing allowance
PERMIT2.permitTransferFrom(
permit,
SignatureTransferDetails({ to: address(this), requestedAmount: permit.permitted.amount }),
msg.sender,
signature
);
IERC20(permit.permitted.token).approve(pool, permit.permitted.amount);
out = IPool(pool).swap(permit.permitted.amount, minOut, to);
if (out < minOut) revert Slippage(out, minOut);
}Q24 — Write a Foundry invariant test asserting "K never decreases."
contract PoolInvariants is Test {
Pool pool;
PoolHandler handler;
uint256 lastK;
function setUp() public {
pool = new Pool(token0, token1);
handler = new PoolHandler(pool);
targetContract(address(handler));
lastK = pool.reserve0() * pool.reserve1();
}
function invariant_K_monotone() public {
uint256 k = pool.reserve0() * pool.reserve1();
assertGe(k, lastK, "K decreased");
lastK = k;
}
}Ecosystem
Q25 — Name three DEXes you'd cite as architectural references and one thing distinctive about each.
Uniswap v4 — singleton + hooks + flash accounting; the cleanest current architecture for composable pool extensions. Curve v1 — StableSwap invariant; canonical reference for pegged-pair efficiency. Balancer v2/v3 — vault architecture predating v4 singleton; multi-token weighted pools enable index-fund-like primitives. Bonus mentions: Maverick (directional LP), Trader Joe v2.1 (bin-based liquidity, avoids tick overflow), CoW (batch auctions).
Q26 — How is deploying to zkSync Era different from deploying to Arbitrum?
Arbitrum is bytecode-equivalent (Nitro = same EVM with calldata pricing tweaks). Standard Foundry deploys work. zkSync Era is NOT bytecode-equivalent — contracts must be recompiled with zksolc producing different bytecode. SELFDESTRUCT is unsupported. CREATE2 address derivation differs. Account abstraction is native (every account is a smart contract). Practical implications: you cannot share the same CREATE2 address across Arbitrum and zkSync; deployments are essentially per-chain projects.
Behavioral
Q27 — Tell me about a production smart-contract bug you shipped or nearly shipped.
The senior-tier answer: name the bug specifically (rounding direction, decimal mismatch, fee-on-transfer assumption). Explain how you found it (audit, mainnet fork test, post-deploy invariant check). Describe what you did about it (patched in the periphery, shipped a v2, coordinated with auditors, communicated with affected users). End with what you changed in your process — added a specific test, added an invariant, raised the bar for that class of bug. The interviewer is checking for ownership and durability of the lesson.
Q28 — How do you work with audit firms?
Treat them as collaborators. Provide architecture docs, threat model, known-issues list, comprehensive tests on day one. Run a kickoff call walking through the system. Have a single point of contact who can answer questions within the day. Don't argue findings — treat low-severity findings as feedback, not "wrong." Diff each fix against the original finding before requesting re-review. Schedule the audit window to overlap a fix iteration (typically 1 week mid-audit). After the audit, publish the report and the response.