Core Fundamentals
The Solidity foundations specific to a DEX surface — what every senior contract engineer is expected to know cold before they walk into a design or code round at an AMM team.
Calldata-heavy contracts
DEX peripheries are dominated by calldata cost, not storage cost. A router takes a path, deadline, recipient, amount specs, and signature blobs — every one of those bytes is paid for by the user.
Three rules:
- Prefer
calldataovermemoryfor function parameters that aren't mutated. Memory copies cost gas; calldata reads don't. - Pack inputs tightly. If your router takes
address tokenIn, address tokenOut, uint24 fee, uint160 sqrtPriceLimit, that's two 32-byte slots if packed correctly versus four if naively encoded. - Multicall the boring stuff. Permit, swap, refund — combine into one calldata payload via
Multicall.
// Bad — every external call pays calldata + ABI decoding cost separately
function swapAndRefund(address t, uint a) external {
swap(t, a);
refund();
}
// Better — single calldata payload, one external call
function multicall(bytes[] calldata data) external returns (bytes[] memory results) {
results = new bytes[](data.length);
for (uint256 i; i < data.length; ++i) {
(bool ok, bytes memory ret) = address(this).delegatecall(data[i]);
require(ok, "multicall");
results[i] = ret;
}
}Production DEX peripheries (Uniswap's Multicall, Uniswap v3 SwapRouter) lean on this pattern heavily. Be ready to write it from memory.
Single-storage-slot designs
The most-read state in a DEX pool is often packed into a single 32-byte slot. Uniswap v3's slot0 is the canonical example:
// Uniswap v3 slot0 — single SLOAD reads everything the swap needs to start
struct Slot0 {
uint160 sqrtPriceX96; // current price
int24 tick; // current tick
uint16 observationIndex;
uint16 observationCardinality;
uint16 observationCardinalityNext;
uint8 feeProtocol;
bool unlocked;
}
// 160 + 24 + 16 + 16 + 16 + 8 + 8 = 248 bits → fits in 256 bitsWhy this matters at the bar: when you propose a design, a senior interviewer will mentally lay out the storage. If your design needs three separate SLOADs on the hot path, they will push back. Plan your slots before you write the contract.
Auxiliary techniques:
immutable— set in constructor, embedded in bytecode. Zero SLOAD cost.constant— compile-time. Same property.- Bit-packing — pack two
uint128s into one slot, or auint160address with auint96counter. - SSTORE2 / SSTORE3 — store large blobs as runtime bytecode in a dummy contract. Useful for static configs.
Transient storage (EIP-1153) — Cancun
Post-Cancun, TSTORE and TLOAD give you storage that exists only for the lifetime of the transaction. Two killer use cases for DEX code:
- Reentrancy locks. Previously: write 1 / write 2 to a storage slot, costing ~20k + ~5k gas. With transient storage: ~100 + ~100. Massive savings on every swap.
- Intermediate state in a multi-hook lifecycle. v4's hooks need to pass data between
beforeSwapandafterSwapwithout persisting it. Transient storage is purpose-built for this.
// Pre-Cancun reentrancy guard — expensive
uint256 private _locked = 1;
modifier nonReentrant() {
require(_locked == 1, "reentrant");
_locked = 2; // ~20k gas (cold) or ~5k (warm SSTORE)
_;
_locked = 1; // ~5k or refund
}
// Cancun-era transient lock — ~200 gas total
modifier tnonReentrant() {
assembly {
if tload(0) { revert(0,0) }
tstore(0, 1)
}
_;
assembly { tstore(0, 0) }
}If you can name the opcodes (TSTORE = 0x5D, TLOAD = 0x5C) and explain that values reset at end-of-transaction not end-of-call, you've shown post-Cancun fluency.
Function-pointer dispatch tricks
Solidity's default function dispatch is a linear search through selectors. Solady popularized a sorted-selector dispatch trick — pre-sort by 4-byte selector and binary-search-style branch — that shaves measurable gas on hot routers.
You aren't usually expected to write this in an interview, but you should know:
- Solidity emits a series of
EQ + JUMPIper selector. Cost grows linearly with selector count. - Frequently-called selectors should be ordered first (Solidity 0.8.x supports this via the
via-irpipeline and function ordering heuristics — and you can hint it). - Solady / advanced repos drop into Yul and write their own dispatcher. The gain is real on routers.
Yul / inline-assembly for hot paths
You will not write a whole pool in Yul. You will see and write small inline-assembly blocks for:
- Packed loads and stores when Solidity's struct semantics force extra SLOADs.
- Bit manipulation for tick bitmap operations (Uniswap v3 stores 256 ticks per uint256).
mulDivand 512-bit math via Yul to avoid intermediate overflow.- Memory-safe scratch space when computing hashes (
0x00..0x40is fair game). - Custom errors with payloads via
mstore + revert.
// Solady-style sqrt — Newton's method in inline assembly
function sqrt(uint256 x) internal pure returns (uint256 z) {
assembly {
z := 181 // initial guess
// ... bit shifts to bring `x` into a normalized range
// Newton iterations:
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
z := shr(1, add(z, div(x, z)))
if lt(div(x, z), z) { z := div(x, z) }
}
}Every assembly block is a place the compiler can no longer help you. Memory safety annotations ("memory-safe") help. Comments matter more here than anywhere else in Solidity. And it must be tested with the same fuzz rigor as the rest.
ERC-20 quirks that bite DEX code
The single richest source of DEX bugs. Real tokens violate the spec in surprising ways. Periphery code has to defend against all of them.
| Quirk | Example | What breaks |
|---|---|---|
| Non-standard return | USDT, BNB, OMG — don't return bool from transfer | Naive require(token.transfer(...)) reverts on these |
| Fee-on-transfer | STA, PAXG (sometimes), various memecoins | Balance after transfer < expected; AMM math breaks if you trust the input amount |
| Rebasing | AMPL, OHM, stETH (semi) | Balances change without transfers; LP accounting drifts |
| Double entrypoint | TUSD (historic), SNX (proxy) | Two addresses control one balance; allowlists with one address miss the other |
| Blacklist / pausable | USDC, USDT | Transfers can revert at runtime; idempotency-sensitive flows break |
| Decimals != 18 | USDC (6), WBTC (8) | Naive 18-decimal math drops or gains precision |
| Approval-frontrun | "USDT approve-zero-first" pattern | Need increaseAllowance/decreaseAllowance or permit2 instead |
| Hooks / callbacks (ERC-777, ERC-1363) | imBTC, some wrappers | Reentrancy via transfer; classic attack surface |
// SafeERC20 — the canonical defense against non-standard returns
function safeTransfer(IERC20 token, address to, uint256 value) internal {
(bool ok, bytes memory ret) = address(token).call(
abi.encodeWithSelector(IERC20.transfer.selector, to, value)
);
require(ok && (ret.length == 0 || abi.decode(ret, (bool))), "ST");
}
// Fee-on-transfer-aware deposit pattern
function _depositMeasured(IERC20 token, address from, uint256 amount) internal returns (uint256 received) {
uint256 before = token.balanceOf(address(this));
token.safeTransferFrom(from, address(this), amount);
received = token.balanceOf(address(this)) - before; // trust the diff, not the input
}ERC-721 / ERC-1155 position NFTs
Modern AMMs ship liquidity positions as NFTs. Uniswap v3's NonfungiblePositionManager is ERC-721. Other systems use ERC-1155 for fungible tranches.
What you must know:
- The NFT is a periphery contract holding a pointer to the core position. The pool tracks
(owner, tickLower, tickUpper)internally; the NFT translates that totokenId. - Transferring the NFT transfers control of the underlying liquidity. The pool itself never sees the transfer.
- This separation enables composability — money market protocols can use position NFTs as collateral.
- It also enables read-only reentrancy attacks if a price-reading function fires during an NFT callback. Watch for that.
One wrong byte costs millions
The thing that makes DEX engineering different from most software engineering: the cost function is non-continuous. Most software ships a bug and you patch it. AMM core code ships a bug and one of the following happens:
- It's exploited within hours and TVL is gone.
- It can never be fixed because the contract is immutable.
- You ship a new version (v3 → v4) and migrate, which takes months and isn't free.
This shapes everything: code review depth, the audit budget, the test count, the use of formal methods, the deliberate choice to make core contracts immutable so that the worst-case outcome is bounded.
Treat every line of core code as if it will be deployed forever and run with $1B+ at stake. Because it will be.