18 KiB
18 KiB
Function Micro-Analysis Example
This example demonstrates a complete micro-analysis following the Per-Function Microstructure Checklist.
Target: swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, uint256 deadline) in Router.sol
Purpose: Enables users to swap one token for another through a liquidity pool. Core trading operation in a DEX that:
- Calculates output amount using constant product formula (x * y = k)
- Deducts 0.3% protocol fee from input amount
- Enforces user-specified slippage protection
- Updates pool reserves to maintain AMM invariant
- Prevents stale transactions via deadline check
This is a critical financial primitive affecting pool solvency, user fund safety, and protocol fee collection.
Inputs & Assumptions:
Parameters:
tokenIn(address): Source token to swap from. Assumed untrusted (could be malicious ERC20).tokenOut(address): Destination token to receive. Assumed untrusted.amountIn(uint256): Amount of tokenIn to swap. User-specified, untrusted input.minAmountOut(uint256): Minimum acceptable output. User-specified slippage tolerance.deadline(uint256): Unix timestamp. Transaction must execute before this or revert.
Implicit Inputs:
msg.sender: Transaction initiator. Assumed to have approved Router to spend amountIn of tokenIn.pairs[tokenIn][tokenOut]: Storage mapping to pool address. Assumed populated during pool creation.reserves[pair]: Pool's current token reserves. Assumed synchronized with actual pool balances.block.timestamp: Current block time. Assumed honest (no validator manipulation considered here).
Preconditions:
- Pool exists for tokenIn/tokenOut pair (pairs[tokenIn][tokenOut] != address(0))
- msg.sender has approved Router for at least amountIn of tokenIn
- msg.sender balance of tokenIn >= amountIn
- Pool has sufficient liquidity to output at least minAmountOut
- block.timestamp <= deadline
Trust Assumptions:
- Pool contract correctly maintains reserves
- ERC20 tokens follow standard behavior (return true on success, revert on failure)
- No reentrancy from tokenIn/tokenOut during transfers (or handled by nonReentrant modifier)
Outputs & Effects:
Returns:
- Implicit: amountOut (not returned, but emitted in event)
State Writes:
reserves[pair].reserve0andreserves[pair].reserve1: Updated to reflect post-swap balances- Pool token balances: Physical token transfers change actual balances
External Interactions:
IERC20(tokenIn).transferFrom(msg.sender, pair, amountIn): Pulls tokenIn from user to poolIERC20(tokenOut).transfer(msg.sender, amountOut): Sends tokenOut from pool to user
Events Emitted:
Swap(msg.sender, tokenIn, tokenOut, amountIn, amountOut, block.timestamp)
Postconditions:
amountOut >= minAmountOut(slippage protection enforced)- Pool reserves updated:
reserve0 * reserve1 >= k_before(constant product maintained with fee) - User received exactly amountOut of tokenOut
- Pool received exactly amountIn of tokenIn
- Fee collected:
amountIn * 0.003remains in pool as liquidity
Block-by-Block Analysis:
// L90: Deadline validation (modifier: ensure(deadline))
modifier ensure(uint256 deadline) {
require(block.timestamp <= deadline, "Expired");
_;
}
- What: Checks transaction hasn't expired based on user-provided deadline
- Why here: First line of defense; fail fast before any state reads or computation
- Assumption:
block.timestampis sufficiently honest (no 900-second manipulation considered) - Depends on: User setting reasonable deadline (e.g., block.timestamp + 300 seconds)
- First Principles: Time-sensitive operations need expiration to prevent stale execution at unexpected prices
- 5 Whys:
- Why check deadline? → Prevent stale transactions
- Why are stale transactions bad? → Price may have moved significantly
- Why not just use slippage protection? → Slippage doesn't prevent execution hours later
- Why does timing matter? → Market conditions change, user intent expires
- Why user-provided vs fixed? → User decides their time tolerance based on urgency
// L92-94: Input validation
require(amountIn > 0, "Invalid input amount");
require(minAmountOut > 0, "Invalid minimum output");
require(tokenIn != tokenOut, "Identical tokens");
- What: Validates basic input sanity (non-zero amounts, different tokens)
- Why here: Second line of defense; cheap checks before expensive operations
- Assumption: Zero amounts indicate user error, not intentional probe
- Invariant established:
amountIn > 0 && minAmountOut > 0 && tokenIn != tokenOut - First Principles: Fail fast on invalid input before consuming gas on computation/storage
- 5 Hows:
- How to ensure valid swap? → Check inputs meet minimum requirements
- How to check minimum requirements? → Test amounts > 0 and tokens differ
- How to handle violations? → Revert with descriptive error
- How to order checks? → Cheapest first (inequality checks before storage reads)
- How to communicate failure? → Require statements with clear messages
// L98-99: Pool resolution
address pair = pairs[tokenIn][tokenOut];
require(pair != address(0), "Pool does not exist");
- What: Looks up liquidity pool address for token pair, validates existence
- Why here: Must identify pool before reading reserves or executing transfers
- Assumption:
pairsmapping is correctly populated during pool creation; no race conditions - Depends on: Factory having called createPair(tokenIn, tokenOut) previously
- Invariant established:
pair != 0x0(valid pool address exists) - Risk: If pairs mapping is corrupted or pool address is incorrect, funds could be sent to wrong address
// L102-103: Reserve reads
(uint112 reserveIn, uint112 reserveOut) = getReserves(pair, tokenIn, tokenOut);
require(reserveIn > 0 && reserveOut > 0, "Insufficient liquidity");
- What: Reads current pool reserves for tokenIn and tokenOut, validates pool has liquidity
- Why here: Need current reserves to calculate output amount; must confirm pool is operational
- Assumption:
reserves[pair]storage is synchronized with actual pool token balances - Invariant established:
reserveIn > 0 && reserveOut > 0(pool is liquid) - Depends on: Sync mechanism keeping reserves accurate (called after transfers/swaps)
- 5 Whys:
- Why read reserves? → Need current pool state for price calculation
- Why must reserves be > 0? → Division by zero in formula if empty
- Why check liquidity here? → Cheaper to fail now than after transferFrom
- Why not just try the swap? → Better UX with specific error message
- Why trust reserves storage? → Alternative is querying balances (expensive)
// L108-109: Fee application
uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
- What: Applies 0.3% protocol fee by multiplying amountIn by 997 (instead of deducting 3)
- Why here: Fee must be applied before price calculation to affect output amount
- Assumption: 997/1000 = 0.997 = (1 - 0.003) represents 0.3% fee deduction
- Invariant maintained:
amountInWithFee = amountIn * 0.997(3/1000 fee taken) - First Principles: Fees modify effective input, reducing output proportionally
- 5 Whys:
- Why multiply by 997? → Gas optimization: avoids separate subtraction step
- Why not amountIn * 0.997? → Solidity doesn't support floating point
- Why 0.3% fee? → Protocol parameter (Uniswap V2 standard, commonly copied)
- Why apply before calculation? → Fee reduces input amount, must affect price
- Why not apply after? → Would incorrectly calculate output at full amountIn
// L110-111: Output calculation (constant product formula)
uint256 denominator = (reserveIn * 1000) + amountInWithFee;
uint256 amountOut = numerator / denominator;
- What: Calculates output amount using AMM constant product formula:
Δy = (x * Δx_fee) / (y + Δx_fee) - Why here: After fee application; core pricing logic of the AMM
- Assumption:
k = reserveIn * reserveOutis the invariant to maintain (with fee adding to k) - Invariant formula:
(reserveIn + amountIn) * (reserveOut - amountOut) >= reserveIn * reserveOut - First Principles: Constant product AMM maintains
x * y = k(with fee slightly increasing k) - 5 Whys:
- Why this formula? → Constant product market maker (x * y = k)
- Why not linear pricing? → Would drain pool at constant price (exploitable)
- Why multiply reserveIn by 1000? → Match denominator scale with numerator (997 * 1000)
- Why divide? → Solving for Δy in: (x + Δx_fee) * (y - Δy) = k
- Why this maintains k? → New product = (reserveIn + amountIn*0.997) * (reserveOut - amountOut) ≈ k * 1.003
- Mathematical verification:
- Given:
k = reserveIn * reserveOut - New reserves:
reserveIn' = reserveIn + amountIn,reserveOut' = reserveOut - amountOut - With fee:
amountInWithFee = amountIn * 0.997 - Solving
(reserveIn + amountIn) * (reserveOut - amountOut) = k:reserveOut - amountOut = k / (reserveIn + amountIn)amountOut = reserveOut - k / (reserveIn + amountIn)- Substituting and simplifying yields the formula above
- Given:
// L115: Slippage protection enforcement
require(amountOut >= minAmountOut, "Slippage exceeded");
- What: Validates calculated output meets user's minimum acceptable amount
- Why here: After calculation, before any state changes or transfers (fail fast if insufficient)
- Assumption: User calculated minAmountOut correctly based on acceptable slippage tolerance
- Invariant enforced:
amountOut >= minAmountOut(user-defined slippage limit) - First Principles: User must explicitly consent to price via slippage tolerance; prevents sandwich attacks
- 5 Whys:
- Why check minAmountOut? → Protect user from excessive slippage
- Why is slippage protection critical? → Prevents sandwich attacks and MEV extraction
- Why user-specified? → Different users have different risk tolerances
- Why fail here vs warn? → Financial safety: user should not receive less than intended
- Why before transfers? → Cheaper to revert now than after expensive external calls
- Attack scenario prevented:
- Attacker front-runs with large buy → price increases
- Victim's swap would execute at worse price
- This check causes victim's transaction to revert instead
- Attacker cannot profit from sandwich
// L118: Input token transfer (pull pattern)
IERC20(tokenIn).transferFrom(msg.sender, pair, amountIn);
- What: Pulls tokenIn from user to liquidity pool
- Why here: After all validations pass; begins state-changing operations (point of no return)
- Assumption: User has approved Router for at least amountIn; tokenIn is standard ERC20
- Depends on: Prior approval:
tokenIn.approve(router, amountIn)called by user - Risk considerations:
- If tokenIn is malicious: could revert (DoS), consume excessive gas, or attempt reentrancy
- If tokenIn has transfer fee: actual amount received < amountIn (breaks invariant)
- If tokenIn is pausable: could revert if paused
- Reentrancy: If tokenIn has callback, attacker could call Router again (mitigated by nonReentrant modifier)
- First Principles: Pull pattern (transferFrom) is safer than users sending first (push) - Router controls timing
- 5 Hows:
- How to get tokenIn? → Pull from user via transferFrom
- How to ensure Router can pull? → User must have approved Router
- How to specify destination? → Send directly to pair (gas optimization: no router intermediate storage)
- How to handle failures? → transferFrom reverts on failure (ERC20 standard)
- How to prevent reentrancy? → nonReentrant modifier (assumed present)
// L122: Output token transfer (push pattern)
IERC20(tokenOut).transfer(msg.sender, amountOut);
- What: Sends calculated amountOut of tokenOut from pool to user
- Why here: After input transfer succeeds; completes the swap atomically
- Assumption: Pool has at least amountOut of tokenOut; tokenOut is standard ERC20
- Invariant maintained: User receives exact amountOut (no more, no less)
- Risk considerations:
- If tokenOut is malicious: could revert (DoS), but user selected this token pair
- If tokenOut has transfer hook: could attempt reentrancy (mitigated by nonReentrant)
- If transfer fails: entire transaction reverts (atomic swap)
- CEI pattern: Not strictly followed (Check-Effects-Interactions) - both transfers are interactions
- Typically Effects (reserve update) should precede Interactions (transfers)
- Here, transfers happen before reserve update (see next block)
- Justification: nonReentrant modifier prevents exploitation
- 5 Whys:
- Why transfer to msg.sender? → User initiated swap, they receive output
- Why not to an arbitrary recipient? → Simplicity; extensions can add recipient parameter
- Why this amount exactly? → amountOut calculated from constant product formula
- Why after input transfer? → Ensures atomicity: both succeed or both fail
- Why trust pool has balance? → Pool's job to maintain reserves; if insufficient, transfer reverts
// L125-126: Reserve synchronization
reserves[pair].reserve0 = uint112(reserveIn + amountIn);
reserves[pair].reserve1 = uint112(reserveOut - amountOut);
- What: Updates stored reserves to reflect post-swap balances
- Why here: After transfers complete; brings storage in sync with actual balances
- Assumption: No other operations have modified pool balances since reserves were read
- Invariant maintained:
reserve0 * reserve1 >= k_before * 1.003(constant product + fee) - Casting risk:
uint112casting could truncate if reserves exceed 2^112 - 1 (≈ 5.2e33)- For most tokens with 18 decimals: limit is ~5.2e15 tokens
- Overflow protection: require reserves fit in uint112, else revert
- 5 Whys:
- Why update reserves? → Storage must match actual balances for next swap
- Why after transfers? → Need to know final state before recording
- Why not query balances? → Gas optimization: storage update cheaper than CALL + BALANCE
- Why uint112? → Pack two reserves in one storage slot (256 bits = 2 * 112 + 32 for timestamp)
- Why this formula? → reserveIn increased by amountIn, reserveOut decreased by amountOut
- Invariant verification:
- Before:
k_before = reserveIn * reserveOut - After:
k_after = (reserveIn + amountIn) * (reserveOut - amountOut) - With 0.3% fee:
k_after ≈ k_before * 1.003(fee adds permanent liquidity)
- Before:
// L130: Event emission
emit Swap(msg.sender, tokenIn, tokenOut, amountIn, amountOut, block.timestamp);
- What: Emits event logging swap details for off-chain indexing
- Why here: After all state changes finalized; last operation before return
- Assumption: Event watchers (subgraphs, dex aggregators) rely on this for tracking trades
- Data included:
msg.sender: Who initiated swap (for user trade history)tokenIn/tokenOut: Which pair was tradedamountIn/amountOut: Exact amounts for price trackingblock.timestamp: When trade occurred (for TWAP calculations, analytics)
- First Principles: Events are write-only log for off-chain systems; don't affect on-chain state
- 5 Hows:
- How to notify off-chain? → Emit event (logs are cheaper than storage)
- How to structure event? → Include all relevant swap parameters
- How do indexers use this? → Build trade history, calculate volume, track prices
- How to ensure consistency? → Emit after state finalized (can't be front-run)
- How to query later? → Blockchain logs filtered by event signature + contract address
Cross-Function Dependencies:
Internal Calls:
getReserves(pair, tokenIn, tokenOut): Helper to read and order reserves based on token addresses- Depends on:
reserves[pair]storage being synchronized - Returns: (reserveIn, reserveOut) in correct order for tokenIn/tokenOut
- Depends on:
External Calls (Outbound):
IERC20(tokenIn).transferFrom(msg.sender, pair, amountIn): ERC20 standard call- Assumes: tokenIn implements ERC20, user has approved Router
- Reentrancy risk: If tokenIn is malicious, could callback
- Failure: Reverts entire transaction
IERC20(tokenOut).transfer(msg.sender, amountOut): ERC20 standard call- Assumes: Pool has sufficient tokenOut balance
- Reentrancy risk: If tokenOut has hooks
- Failure: Reverts entire transaction
Called By:
- Users directly (external call)
- Aggregators/routers (external call)
- Multi-hop swap functions (internal call from same contract)
Shares State With:
addLiquidity(): Modifies same reserves[pair], must maintain k invariantremoveLiquidity(): Modifies same reserves[pair]sync(): Emergency function to force reserves sync with balancesskim(): Removes excess tokens beyond reserves
Invariant Coupling:
- Global invariant:
sum(all reserves[pair].reserve0 for all pairs) <= sum(all token balances in pools) - Per-pool invariant:
reserves[pair].reserve0 * reserves[pair].reserve1 >= k_initial * (1.003^n)where n = number of swaps- Each swap increases k by 0.3% due to fee
- Reentrancy protection:
nonReentrantmodifier ensures no cross-function reentrancy- swap() cannot be re-entered while executing
- addLiquidity/removeLiquidity also cannot execute during swap
Assumptions Propagated to Callers:
- Caller must have approved Router to spend amountIn of tokenIn
- Caller must set reasonable deadline (e.g., block.timestamp + 300 seconds)
- Caller must calculate minAmountOut based on acceptable slippage (e.g., expectedOutput * 0.99 for 1%)
- Caller assumes pair exists (or will handle "Pool does not exist" revert)