Section C · Coding

Coding Problems Worked

Ten Solidity problems at senior protocol-engineer difficulty. Drill them: read the problem, try it on paper, then expand the answer.

How to use this page

Read each problem, attempt the solution mentally or on paper before expanding the answer. Each answer often includes a second, more idiomatic approach to compare.

P1 — Constant-product AMM swap

Implement swap for a Uniswap v2-style constant-product pool with reserves (r0, r1). Inputs: amountIn, zeroForOne, fee in bps. Return the amount out. Maintain the invariant (r0 + dx) × (r1 - dy) ≥ r0 × r1 after fee.

Reveal answer
function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut, uint256 feeBps)
    public pure returns (uint256 amountOut)
{
    if (amountIn == 0) revert ZeroInput();
    if (reserveIn == 0 || reserveOut == 0) revert NoLiquidity();
    uint256 amountInWithFee = amountIn * (10_000 - feeBps);
    uint256 numerator   = amountInWithFee * reserveOut;
    uint256 denominator = reserveIn * 10_000 + amountInWithFee;
    amountOut = numerator / denominator;
}

function swap(uint256 amountIn, bool zeroForOne, uint256 minOut, address to)
    external returns (uint256 amountOut)
{
    (uint256 rIn, uint256 rOut) = zeroForOne ? (reserve0, reserve1) : (reserve1, reserve0);
    amountOut = getAmountOut(amountIn, rIn, rOut, FEE_BPS);
    if (amountOut < minOut) revert SlippageExceeded();

    (IERC20 tokenIn, IERC20 tokenOut) = zeroForOne ? (token0, token1) : (token1, token0);
    tokenIn.safeTransferFrom(msg.sender, address(this), amountIn);
    tokenOut.safeTransfer(to, amountOut);

    // Effects last (or pre-compute and snapshot — many designs are CEI here)
    if (zeroForOne) { reserve0 += uint128(amountIn); reserve1 -= uint128(amountOut); }
    else            { reserve1 += uint128(amountIn); reserve0 -= uint128(amountOut); }
}

Discussion points: Fee is applied to amountIn before constant-product math. Slippage param protects users against MEV / price-impact. For a "real" UniV2, the swap is callback-based (flash swaps) and reserves are updated last with a K-check at the end.

P2 — Fixed-point pow(x, n)

Implement rpow(uint256 x, uint256 n, uint256 base) that returns x^n in fixed-point with given base (e.g., 1e18). Must handle large n efficiently and overflow safely.

Reveal answer
function rpow(uint256 x, uint256 n, uint256 base) internal pure returns (uint256 z) {
    if (x == 0) return n == 0 ? base : 0;
    z = n % 2 == 0 ? base : x;
    uint256 half = base / 2;
    for (n /= 2; n > 0; n /= 2) {
        uint256 xx = x * x;
        require(x == 0 || xx / x == x, "OVERFLOW");
        uint256 xxRound = xx + half;
        require(xxRound >= xx, "OVERFLOW");
        x = xxRound / base;
        if (n % 2 != 0) {
            uint256 zx = z * x;
            require(x == 0 || zx / x == z, "OVERFLOW");
            uint256 zxRound = zx + half;
            require(zxRound >= zx, "OVERFLOW");
            z = zxRound / base;
        }
    }
}

Discussion points: Exponentiation by squaring is O(log n). Without overflow checks the result is garbage at high x or n. half = base / 2 is the round-half-up bias. MakerDAO's rpow is the canonical reference; this is the same shape in Solidity rather than Yul.

P3 — ERC-4626 deposit/withdraw without dust loss

Implement deposit(assets) → shares and redeem(shares) → assets for a vault where round-tripping is monotonic: depositing X then redeeming all shares never returns more than X.

Reveal answer
function _convertToShares(uint256 assets, Math.Rounding r) internal view returns (uint256) {
    uint256 ts = totalSupply();
    return assets.mulDiv(ts + VIRTUAL_SHARES, totalAssets() + 1, r);
}

function _convertToAssets(uint256 shares, Math.Rounding r) internal view returns (uint256) {
    return shares.mulDiv(totalAssets() + 1, totalSupply() + VIRTUAL_SHARES, r);
}

function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
    shares = _convertToShares(assets, Math.Rounding.Down);   // round shares down
    if (shares == 0) revert ZeroShares();
    asset.safeTransferFrom(msg.sender, address(this), assets);
    _mint(receiver, shares);
    emit Deposit(msg.sender, receiver, assets, shares);
}

function redeem(uint256 shares, address receiver, address owner) public returns (uint256 assets) {
    if (msg.sender != owner) _spendAllowance(owner, msg.sender, shares);
    assets = _convertToAssets(shares, Math.Rounding.Down);   // round assets down
    _burn(owner, shares);
    asset.safeTransfer(receiver, assets);
    emit Withdraw(msg.sender, receiver, owner, assets, shares);
}

Discussion points: Virtual shares (VIRTUAL_SHARES = 1e6, OpenZeppelin v5 pattern) defeat the donation attack. Rounding shares down on deposit and assets down on redeem guarantees the protocol never gives out more than was put in. If you want symmetric "mint shares for assets" semantics, the rounding direction flips.

P4 — Spot the reentrancy in 30 lines

Find the bug:

contract Bank {
    mapping(address => uint256) public balance;

    function deposit() external payable {
        balance[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balance[msg.sender] >= amount, "INSUF");
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "TX_FAILED");
        balance[msg.sender] -= amount;
    }

    function transfer(address to, uint256 amount) external {
        require(balance[msg.sender] >= amount, "INSUF");
        balance[msg.sender] -= amount;
        balance[to] += amount;
    }
}
Reveal answer

Bug: withdraw performs the external call before updating balance. A malicious contract's receive can re-enter withdraw and drain the bank — the balance check still passes because the deduction hasn't happened yet.

Fix (CEI):

function withdraw(uint256 amount) external {
    require(balance[msg.sender] >= amount, "INSUF");
    balance[msg.sender] -= amount;                        // Effects first
    (bool ok,) = msg.sender.call{value: amount}("");      // Interactions last
    require(ok, "TX_FAILED");
}

Additional belt-and-braces: add a nonReentrant modifier (transient-storage version in 0.8.24+).

P5 — Spot the share-inflation bug

contract NaiveVault {
    IERC20 public immutable asset;
    uint256 public totalShares;
    mapping(address => uint256) public shareOf;

    function deposit(uint256 assets) external returns (uint256 shares) {
        uint256 ta = asset.balanceOf(address(this));
        shares = totalShares == 0 ? assets : assets * totalShares / ta;
        asset.transferFrom(msg.sender, address(this), assets);
        totalShares += shares;
        shareOf[msg.sender] += shares;
    }

    function redeem(uint256 shares) external returns (uint256 assets) {
        uint256 ta = asset.balanceOf(address(this));
        assets = shares * ta / totalShares;
        totalShares -= shares;
        shareOf[msg.sender] -= shares;
        asset.transfer(msg.sender, assets);
    }
}
Reveal answer

Bug: the donation attack on first deposit.

  1. Attacker deposits 1 wei. totalShares = 0, branch taken; mints 1 share.
  2. Attacker sends 1e18 of the underlying directly to the contract (does not call deposit).
  3. Victim deposits 2e18. shares = 2e18 * 1 / (1 + 1e18) = 1 (rounds down).
  4. Vault now holds ~3e18, distributed as 1 share to attacker, 1 share to victim.
  5. Attacker redeems 1 share, receives ~1.5e18 — half the victim's deposit.

Fixes:

  • Use virtual shares: shares = assets * (totalShares + 1e6) / (ta + 1).
  • Or mint and burn a chunk of "dead shares" on first deposit so the ratio cannot be inflated.
  • Or use a SHARES_OFFSET (e.g., 1e6) so the first deposit mints 1e6× shares.

Bonus bug: totalShares == 0 with ta != 0 (donation before first deposit) would compute shares = assets * 0 / ta = 0, locking the first depositor out. The virtual-shares pattern fixes this too.

P6 — Dutch auction primitive

Implement a Dutch auction over a fixed quantity of token: start price P0 at time t0, decays linearly to P1 ≤ P0 by time t1. Anyone can claim by paying the current price.

Reveal answer
contract DutchAuction {
    IERC20 public immutable token;
    IERC20 public immutable paymentToken;
    uint256 public immutable quantity;
    uint256 public immutable p0;
    uint256 public immutable p1;
    uint64  public immutable t0;
    uint64  public immutable t1;
    bool    public sold;

    constructor(IERC20 _t, IERC20 _p, uint256 q, uint256 _p0, uint256 _p1, uint64 _t0, uint64 _t1) {
        require(_t0 < _t1 && _p1 <= _p0, "BAD_PARAMS");
        token = _t; paymentToken = _p; quantity = q;
        p0 = _p0; p1 = _p1; t0 = _t0; t1 = _t1;
    }

    function currentPrice() public view returns (uint256) {
        if (block.timestamp <= t0) return p0;
        if (block.timestamp >= t1) return p1;
        uint256 elapsed = block.timestamp - t0;
        uint256 span    = t1 - t0;
        // Linear decay from p0 to p1
        return p0 - (p0 - p1) * elapsed / span;
    }

    function buy(uint256 maxPayment) external {
        require(!sold, "SOLD");
        uint256 price = currentPrice();
        require(price <= maxPayment, "PRICE_TOO_HIGH");
        sold = true;
        paymentToken.safeTransferFrom(msg.sender, address(this), price);
        token.safeTransfer(msg.sender, quantity);
        emit Sold(msg.sender, price);
    }
}

Discussion points: Linear decay is the simplest; production auctions (e.g., Liquity v2 redemptions, Aave's MEV-resistant liquidations) often use exponential decay or piecewise curves. maxPayment protects against price spikes between block submission and inclusion. For liquidations, the auction is parameterized by the position's debt/collateral, not a fixed quantity.

P7 — Compute a TWAP from a Uniswap v3 pool

Given a Uniswap v3 pool address and a window in seconds, return the TWAP price of token1/token0 as a Q64.96 sqrt price.

Reveal answer
function getTwapSqrtPrice(address pool, uint32 secondsAgo) public view returns (uint160 sqrtPriceX96) {
    require(secondsAgo > 0, "ZERO");
    uint32[] memory secondsAgos = new uint32[](2);
    secondsAgos[0] = secondsAgo;
    secondsAgos[1] = 0;
    (int56[] memory tickCumulatives,) = IUniswapV3Pool(pool).observe(secondsAgos);

    int56 delta = tickCumulatives[1] - tickCumulatives[0];
    int24 avgTick = int24(delta / int56(uint56(secondsAgo)));
    if (delta < 0 && (delta % int56(uint56(secondsAgo)) != 0)) avgTick--;   // round toward -inf

    sqrtPriceX96 = TickMath.getSqrtRatioAtTick(avgTick);
}

Discussion points: tickCumulative values are running sums; their difference divided by the window is the TWAP tick. Tick is log-price; converting back via TickMath gives geometric mean price. Important: round toward negative infinity (Solidity's / rounds toward zero), or your TWAP biases up for negative ticks.

You must also ensure the pool's observation cardinality covers the window — otherwise observe reverts.

P8 — Simple liquidation function

Implement liquidate(borrower, seizeCollateral) for a single-collateral market. Liquidator repays debt = seizeCollateral × price / (1 + LI). Reverts if position is healthy or if liquidator over-repays.

Reveal answer
function liquidate(address borrower, uint256 seizeCollateral) external returns (uint256 repaid) {
    _accrue();
    Position storage p = position[borrower];
    uint256 price = IOracle(ORACLE).price();
    if (_isHealthy(p, price)) revert HealthyPosition();
    if (seizeCollateral > p.collateral) revert OverSeize();

    // debt = seize * price / (1 + LI), scaled
    uint256 collateralValue = seizeCollateral * price / ORACLE_SCALE;
    repaid = collateralValue * WAD / (WAD + LIQUIDATION_INCENTIVE);

    if (repaid > p.borrowAssets) {
        // Cannot repay more than outstanding debt; clamp.
        repaid = p.borrowAssets;
        // and recompute corresponding collateral
        seizeCollateral = repaid * (WAD + LIQUIDATION_INCENTIVE) * ORACLE_SCALE / WAD / price;
    }

    p.collateral    -= uint128(seizeCollateral);
    p.borrowAssets  -= uint128(repaid);
    totalBorrowAssets -= uint128(repaid);

    LOAN.safeTransferFrom(msg.sender, address(this), repaid);
    COLLATERAL.safeTransfer(msg.sender, seizeCollateral);

    // Socialize remaining debt if collateral was fully seized
    if (p.collateral == 0 && p.borrowAssets > 0) {
        totalSupplyAssets -= p.borrowAssets;
        totalBorrowAssets -= p.borrowAssets;
        emit BadDebtSocialized(borrower, p.borrowAssets);
        p.borrowAssets = 0;
    }

    emit Liquidate(msg.sender, borrower, seizeCollateral, repaid);
}

Discussion points: Round repaid amount up (favor protocol) in production; the over-repay clamp can leave collateral on the table. Health check before; post-condition check after. Socialization keeps the bookkeeping invariant.

P9 — Binary search for the price at which a position becomes unhealthy

Given a position with collateral C, borrow B, LLTV L, find the largest oracle price p at which the position is still healthy. (Useful for off-chain liquidator simulation.)

Reveal answer

Closed form: C × p × L = B, so p* = B / (C × L) (with WAD scaling). No binary search needed.

function liquidationPrice(uint256 C, uint256 B, uint256 lltv) pure returns (uint256) {
    // health: C * p * lltv / WAD >= B  ==>  p >= B * WAD / (C * lltv)
    // For collateral PRICED in loan-token units; sign convention may differ.
    return B * WAD / (C * lltv / WAD);
}

Discussion points: The instinct to binary search is good; the instinct to step back and find a closed form is better. In an interview, demonstrate both: "I could binary search this, but actually the function is linear in price so I'll just solve for it." That earns the round.

Where binary search does help: complex non-linear health functions (e.g., concentrated-liquidity positions, structured collateral) where there's no closed form.

P10 — Flash-loan-based liquidator

Sketch a contract that liquidates an unhealthy position using a flash loan for the repay capital. The liquidator should require zero capital in: borrow the loan token, repay debt, receive collateral, swap collateral → loan token to repay the flash loan, keep the spread.

Reveal answer
contract FlashLiquidator is IFlashLoanReceiver {
    IFlashLoanProvider public immutable lender;
    ILendingCore       public immutable core;
    ISwapRouter        public immutable router;

    function liquidate(address borrower, uint256 seize, bytes calldata swapData) external {
        // 1. Flash-borrow the loan token from a provider (Balancer, Aave, Uni v3 flash)
        bytes memory data = abi.encode(borrower, seize, swapData);
        lender.flashLoan(address(this), LOAN, MAX_BORROW, data);
        // 4. After this returns, residual LOAN balance is profit -> sweep
        uint256 profit = LOAN.balanceOf(address(this));
        LOAN.safeTransfer(msg.sender, profit);
    }

    // 2. Called back by the lender with the funds
    function onFlashLoan(address /*initiator*/, address /*token*/, uint256 amount, uint256 fee, bytes calldata data)
        external override returns (bytes32)
    {
        require(msg.sender == address(lender), "AUTH");
        (address borrower, uint256 seize, bytes memory swapData) = abi.decode(data, (address, uint256, bytes));

        // Approve core to pull loan token
        LOAN.forceApprove(address(core), amount);
        uint256 repaid = core.liquidate(borrower, seize);
        require(repaid <= amount, "MORE_THAN_BORROWED");

        // 3. Swap seized collateral -> loan token to repay flash loan + fee
        uint256 needed = amount + fee;
        router.exactOutput(swapData, needed);

        LOAN.forceApprove(address(lender), needed);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

Discussion points:

  • The flow is callback-driven: lender → onFlashLoan → core.liquidate → router.swap → lender.
  • Real searchers will pre-compute the optimal seize off-chain (binary search over the slippage curve) and pass it in.
  • Use forceApprove (OZ v5) for USDT compatibility.
  • If the swap fails (illiquid collateral), the entire tx reverts — searcher loses gas but no principal.
  • Production liquidator bots use MEV-Boost / private mempools to avoid frontrunning.