Coding Problems
Ten problems that map directly to live-coding rounds for senior smart-contract roles at AMMs and DEXes. Each has a recommended approach, the gotchas, and an alternate framing.
P1 · Implement constant-product swap
Prompt: "Given reserves and amountIn (with a 30bps fee), compute amountOut for a CPMM."
Approach A — direct formula:
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;
}Gotchas:
- Reserves at
type(uint112).maxtimesamountIncan overflow if you're not careful. Use FullMath.mulDiv if your reserves can be large. - Floor division is correct here — favors the protocol.
- The "fee" of 0.3% is encoded as
997/1000. Don't be fancy with floats.
Approach B — using FullMath:
uint256 amountInWithFee = amountIn * 997;
amountOut = FullMath.mulDiv(amountInWithFee, reserveOut, reserveIn * 1000 + amountInWithFee);P2 · Implement StableSwap getY given x (Newton's method)
Prompt: "Given the StableSwap invariant and reserves, compute the new y reserve after a deposit of dx."
Approach: Find D first, then iterate Newton on the polynomial in y.
function getY(uint256 x, uint256 D, uint256 A) internal pure returns (uint256 y) {
uint256 n = 2;
uint256 Ann = A * 4; // n^n = 4 for n=2
uint256 c = D * D / (x * n);
c = c * D / (Ann * n);
uint256 b = x + D / Ann;
y = D;
for (uint256 i; i < 255; ++i) {
uint256 yPrev = y;
y = (y * y + c) / (2 * y + b - D);
if (y > yPrev) { if (y - yPrev <= 1) return y; }
else { if (yPrev - y <= 1) return y; }
}
revert("NO_CONVERGE");
}Gotchas:
- If you forgot to compute D first, you'll loop forever. State the precondition.
- For n=2,
Ann = A · n^n = 4A. Hardcode the n=2 case if the spec restricts you. - The break is on "delta ≤ 1," not equality.
P3 · Implement sqrt in Solidity
Prompt: "Implement integer sqrt for uint256."
Approach A — Babylonian, simple:
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;
}Approach B — Solady-style (faster): Bit-shift to a normalized range to get a great initial guess, then 7 Newton iterations.
Gotchas:
- Babylonian converges but isn't gas-optimal. Solady is ~2× faster.
- Test
sqrt(0),sqrt(1),sqrt(2²⁵⁶ − 1). The last one stresses the largest input. - Property:
sqrt(x)² ≤ x < (sqrt(x)+1)². Use this as your invariant test.
P4 · v3 tick → price conversion
Prompt: "Given a tick i, return sqrtPriceX96 = (√1.0001)^i · 2^96."
Approach: Bit-decomposition of the tick. Precompute magic constants for each bit.
function getSqrtRatioAtTick(int24 tick) internal pure returns (uint160 sqrtPriceX96) {
uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick));
require(absTick <= 887272, "T");
uint256 ratio = (absTick & 0x1) != 0
? 0xfffcb933bd6fad37aa2d162d1a594001
: 0x100000000000000000000000000000000;
if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128;
if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128;
// ... up to absTick & 0x80000
if (tick > 0) ratio = type(uint256).max / ratio;
sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1));
}Note: In a real interview, cite the algorithm by name (Uniswap v3 TickMath) and explain the bit-decomposition. Implementing every constant from memory is unreasonable.
P5 · Detect read-only reentrancy in a 30-line slot0 read
Prompt: Here is a price-reading function from an integrator. What's wrong?
// Integrator's price reader — buggy
function getPriceFromPool(IPool pool) external view returns (uint256 price) {
(uint160 sqrtP, , , , , , ) = pool.slot0();
price = (uint256(sqrtP) * uint256(sqrtP)) >> (96 * 2);
}The bug: If this is called during a callback that has temporarily put the pool in an inconsistent state, sqrtP is stale or mid-update. The integrator gets a price that doesn't reflect a real market state.
Fix:
- Verify the pool's reentrancy lock is not held (some pools expose this — call a function the pool guards with its lock).
- Or use a TWAP —
pool.observe([10])over a window — which gives a price that's resilient to single-block manipulation. - Or use a snapshot-based oracle that updates only outside of swap context.
P6 · Build a minimal v4 hook that takes a fee on swaps
Prompt: "Write a v4 hook that takes 5 bps of every swap and sends it to a recipient."
contract FeeHook is BaseHook {
address public immutable recipient;
uint128 public constant HOOK_FEE_BPS = 5;
constructor(IPoolManager _pm, address _recipient) BaseHook(_pm) {
recipient = _recipient;
}
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeSwap: true,
afterSwap: false,
// ...all others false
beforeSwapReturnDelta: true
});
}
function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata)
external override returns (bytes4, BeforeSwapDelta, uint24)
{
// skim 5 bps from the input
Currency input = params.zeroForOne ? key.currency0 : key.currency1;
uint256 amount = uint256(int256(params.amountSpecified < 0 ? -params.amountSpecified : params.amountSpecified));
uint128 fee = uint128(amount * HOOK_FEE_BPS / 10_000);
poolManager.take(input, recipient, fee);
return (BaseHook.beforeSwap.selector, toBeforeSwapDelta(int128(fee), 0), 0);
}
}Gotchas:
- Hook contract must be deployed to an address whose low bits match its permissions (v4 encodes permissions into the address). Use CREATE2 with a precomputed salt.
- Returning the right
BeforeSwapDeltarequires understanding how the PoolManager nets the deltas. - Test that the user's effective swap output is reduced by exactly the fee.
P7 · Single-tx flash loan from an AMM pool
Prompt: "Use a v2-style flash swap to borrow a token, do something with it, repay in the same transaction."
interface IV2Pair { function swap(uint256, uint256, address, bytes calldata) external; }
contract FlashBorrower {
address immutable pair;
address immutable token0;
address immutable token1;
function borrow0(uint256 amount0) external {
IV2Pair(pair).swap(amount0, 0, address(this), abi.encode(amount0)); // non-empty data triggers callback
}
function uniswapV2Call(address sender, uint256 a0, uint256 a1, bytes calldata data) external {
require(msg.sender == pair, "BAD_SENDER");
require(sender == address(this), "BAD_INITIATOR");
// ...do something with a0 tokens that arrived in this contract...
_doStuff(a0);
// Repay: borrow has implicit fee — for v2 it's ~30 bps
uint256 fee = (a0 * 3) / 997 + 1;
uint256 owe = a0 + fee;
IERC20(token0).transfer(pair, owe);
}
}Gotchas:
- Caller must be the pair contract — verify
msg.sender. - The fee math for repayment is asymmetric — pay enough to keep
Kfrom decreasing. - Use a callback-data payload to pass state across the boundary.
P8 · Fees-owed for a position
Prompt: "Given a v3-style position and the pool's current feeGrowth globals, compute the fees owed to the position since last collection."
function feesOwed(
Position memory pos,
uint256 feeGrowthGlobal0X128,
uint256 feeGrowthGlobal1X128,
uint256 feeGrowthOutsideLower0X128,
uint256 feeGrowthOutsideUpper0X128,
int24 tickCurrent
) internal pure returns (uint256 owed0, uint256 owed1) {
// 1. Compute feeGrowthInside since position's lastSnapshot
uint256 inside0;
if (tickCurrent >= pos.tickUpper) {
inside0 = feeGrowthOutsideLower0X128 - feeGrowthOutsideUpper0X128;
} else if (tickCurrent < pos.tickLower) {
inside0 = feeGrowthOutsideUpper0X128 - feeGrowthOutsideLower0X128;
} else {
inside0 = feeGrowthGlobal0X128 - feeGrowthOutsideLower0X128 - feeGrowthOutsideUpper0X128;
}
// 2. Delta against position's stored snapshot
uint256 delta = inside0 - pos.feeGrowthInside0LastX128; // unchecked wraparound is intentional
// 3. Multiply by liquidity, downshift Q128
owed0 = FullMath.mulDiv(delta, pos.liquidity, 1 << 128);
}Gotchas:
- The subtraction wraps intentionally — feeGrowth values can overflow over time, but the delta is meaningful modulo 2²⁵⁶.
- The three cases (above/below/inside range) are easy to get wrong. Draw the diagram.
- Q128 fixed-point — downshift by 128 at the end, not earlier.
P9 · TWAP from observation cardinality
Prompt: "Given a circular buffer of (timestamp, tickCumulative) observations, compute a TWAP over the last N seconds."
function twap(Observation[] storage obs, uint16 idx, uint16 card, uint32 secondsAgo)
internal view returns (int24 tickTwap)
{
int56 tickCumNow = _observeAt(obs, idx, card, 0);
int56 tickCumThen = _observeAt(obs, idx, card, secondsAgo);
int56 tickDelta = tickCumNow - tickCumThen;
tickTwap = int24(tickDelta / int56(uint56(secondsAgo)));
}
function _observeAt(Observation[] storage obs, uint16 idx, uint16 card, uint32 secondsAgo)
private view returns (int56 cum)
{
uint32 target = uint32(block.timestamp) - secondsAgo;
// Binary search the circular buffer for the observation pair surrounding `target`,
// then interpolate linearly.
(Observation memory before_, Observation memory after_) = _binarySearch(obs, idx, card, target);
if (target == before_.timestamp) cum = before_.tickCumulative;
else if (target == after_.timestamp) cum = after_.tickCumulative;
else {
uint32 dt = after_.timestamp - before_.timestamp;
uint32 dtTarget = target - before_.timestamp;
int56 perSecond = (after_.tickCumulative - before_.tickCumulative) / int56(uint56(dt));
cum = before_.tickCumulative + perSecond * int56(uint56(dtTarget));
}
}Gotchas:
- The buffer is circular. Indexing is modular.
- Linear interpolation between observations is correct only because
tickCumulativeis linear-in-time at constant tick. - Watch the signed types —
int56can hold tickCumulative deltas for ~years, but cast carefully.
P10 · CREATE2 vanity solver
Prompt: "Find a salt that gives a deployed contract an address starting with eight zero nibbles."
// On-chain prediction
function predict(address deployer, bytes32 salt, bytes32 initCodeHash) pure returns (address) {
return address(uint160(uint256(keccak256(
abi.encodePacked(bytes1(0xff), deployer, salt, initCodeHash)
))));
}The off-chain solver is the interesting part — try random salts and check the prefix. Reasoning on the math: each random salt gives a ~16⁻⁸ ≈ 2.3 × 10⁻¹⁰ chance of an 8-nibble-zero prefix. ~4B trials expected. A modern GPU does this in seconds.
Why this matters: v4 hook permissions are encoded in the low bits of the hook's contract address. You must CREATE2-mine your hook to a valid permission-encoding address. This isn't optional.