Module 1: Solidity 0.8.x β What Changed
Difficulty: Beginner
Estimated reading time: ~55 minutes | Exercises: ~3-4 hours
π Table of Contents
Language-Level Changes
- Checked Arithmetic (0.8.0)
- Custom Errors (0.8.4+)
- User-Defined Value Types (0.8.8+)
- abi.encodeCall (0.8.11+)
- Other Notable Changes
- Build Exercise: ShareMath
The Bleeding Edge
- Transient Storage (0.8.24+)
- Pectra/Prague EVM (0.8.30+)
- Solidity 0.9.0 Deprecations
- Build Exercise: TransientGuard
π‘ Language-Level Changes That Matter for DeFi
π‘ Concept: Checked Arithmetic (0.8.0)
Why this matters: You know the history β pre-0.8 overflow was silent, SafeMath was everywhere. Since 0.8.0, arithmetic reverts on overflow by default. The real question for DeFi work is: when do you turn it off, and how do you prove itβs safe?
Introduced in Solidity 0.8.0 (December 2020)
Legacy context: Youβll still encounter SafeMath in Uniswap V2, Compound V2, and original MakerDAO. Recognize it, never use it in new code.
The unchecked {} escape hatch:
When you can mathematically prove an operation wonβt overflow, use unchecked to skip the safety check and save gas:
// β
CORRECT: Loop counter that can't realistically overflow
for (uint256 i = 0; i < length;) {
// ... loop body
unchecked { ++i; } // Saves ~20 gas per iteration
}
// β
CORRECT: AMM math where inputs are already validated
// Safety proof: reserveIn and amountInWithFee are bounded by token supply
// (max ~10^30 for 18-decimal tokens), so their product can't overflow uint256 (~10^77)
unchecked {
uint256 denominator = reserveIn * 1000 + amountInWithFee;
}
When to use unchecked:
Only when you can mathematically prove the operation wonβt overflow. In DeFi, this usually means:
- Loop counters with bounded iteration counts
- Formulas where the mathematical structure guarantees safety
- Values already validated through require checks
β‘ Common pitfall: Donβt use
uncheckedjust because βit probably wonβt overflow.β The gas savings (5-20 gas per operation) arenβt worth the risk if your proof is wrong.
π» Quick Try:
Before moving on, open Remix and test this:
// See the difference yourself
function testChecked() external pure returns (uint256) {
return type(uint256).max + 1; // Reverts!
}
function testUnchecked() external pure returns (uint256) {
unchecked {
return type(uint256).max + 1; // Wraps to 0
}
}
Deploy, call both. One reverts, one returns 0. Feel the difference.
ποΈ Real usage:
Uniswap V4βs FullMath.sol (originally from V3) is a masterclass in unchecked usage. Every operation is proven safe through the structure of 512-bit intermediate calculations. Study the mulDiv function to see how production DeFi handles complex fixed-point math safely.
π Deep Dive: Understanding mulDiv β Safe Precision Math
The problem:
In DeFi, the formula (a * b) / c appears everywhere β vault shares, AMM pricing, interest rates. But in Solidity, the intermediate product a * b can overflow uint256 before the division brings it back down. This is called phantom overflow: the final result fits in 256 bits, but the intermediate step doesnβt.
Concrete example with real numbers:
// A vault with massive TVL
uint256 depositAmount = 500_000e18; // 500k tokens (18 decimals)
uint256 totalShares = 1_000_000e18; // 1M shares
uint256 totalAssets = 2_000_000e18; // 2M assets
// Expected: (500k * 1M) / 2M = 250k shares β (fits in uint256)
// But the intermediate product:
// 500_000e18 * 1_000_000e18 = 5 * 10^41
// That's fine here. But scale up to real DeFi TVLs...
uint256 totalShares2 = 10**60; // large share supply after years of compounding
uint256 totalAssets2 = 10**61;
// Now: 500_000e18 * 10^60 = 5 * 10^83 β EXCEEDS uint256.max (β 1.15 * 10^77)
// The multiplication reverts even though the final answer (5 * 10^22) is tiny
The naive approach β broken at scale:
// β Phantom overflow: a * b exceeds uint256 even though result fits
function shareBroken(uint256 assets, uint256 supply, uint256 total) pure returns (uint256) {
return (assets * supply) / total; // Reverts on large values
}
The fix β mulDiv:
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
// β
Safe at any scale β computes in 512-bit intermediate space
function shareFixed(uint256 assets, uint256 supply, uint256 total) pure returns (uint256) {
return Math.mulDiv(assets, supply, total); // Never overflows
}
Thatβs it. Same formula, same result, safe at any scale. mulDiv(a, b, c) computes (a * b) / c using 512-bit intermediate math.
How it works under the hood:
Step 1: Multiply a * b into a 512-bit result (two uint256 slots)
βββββββββββββββ¬ββββββββββββββ
β high β low β β a * b stored across 512 bits
βββββββββββββββ΄ββββββββββββββ
Step 2: Divide the full 512-bit value by c β result fits back in uint256
uint256max β 10^77 (thatβs 1 followed by 77 zeros β an astronomically large number)- Two
uint256slots hold up to 10^154 (10 to the power of 154) β more than enough for any real DeFi scenario - The final result is exact (no precision loss from splitting the operation)
Rounding direction matters:
In vault math, rounding isnβt neutral β it determines who eats the dust:
// Deposits: round DOWN β depositor gets slightly fewer shares (vault keeps dust)
shares = Math.mulDiv(assets, totalSupply, totalAssets); // default: round down
// Withdrawals: round UP β withdrawer gets slightly fewer assets (vault keeps dust)
assets = Math.mulDiv(shares, totalAssets, totalSupply, Math.Rounding.Ceil);
The rule: always round against the user, in favor of the vault. This prevents a roundtrip (deposit β withdraw) from being profitable, which would let attackers drain the vault 1 wei at a time.
When youβll see this in DeFi:
- ERC-4626 vault share calculations (
convertToShares,convertToAssets) - AMM price calculations with large reserves
- Fixed-point math libraries (Ray/Wad math in Aave, DSMath in MakerDAO)
Available libraries:
| Library | Style | When to use |
|---|---|---|
| OpenZeppelin Math.sol | Clean Solidity | Default choice β readable, audited, supports rounding modes |
| Solady FixedPointMathLib | Assembly-optimized | Gas-critical paths (saves ~200 gas vs OZ) |
| Uniswap FullMath | Assembly, unchecked | Uniswap-specific β study for learning, use OZ/Solady in practice |
The actual assembly β from Uniswap V4βs FullMath.sol:
Hereβs the core of the 512-bit multiplication (simplified from the full function):
// From Uniswap V4 FullMath.sol β the 512-bit multiply step
assembly {
// mul(a, b) β EVM multiply opcode, keeps only the LOW 256 bits.
// If a * b > 2^256, the overflow is silently discarded (no revert in assembly).
let prod0 := mul(a, b)
// mulmod(a, b, not(0)) β a single EVM opcode that computes (a * b) mod (2^256 - 1)
// without intermediate overflow. Gives a different "view" of the same product.
let mm := mulmod(a, b, not(0))
// The difference between mm and prod0, adjusted for borrow (the lt check),
// gives us the HIGH 256 bits of the full product.
// If prod1 == 0, no overflow occurred and simple a * b / c suffices.
let prod1 := sub(sub(mm, prod0), lt(mm, prod0))
}
// Full 512-bit product = prod1 * 2^256 + prod0
// The rest of the function divides this 512-bit value by the denominator.
Reading this code β every symbol explained:
mul(a, b)β the EVM MUL opcode (3 gas). In assembly, overflow wraps β no revertmulmod(a, b, m)β the EVM MULMOD opcode (8 gas). Computes(a * b) % mwithout intermediate overflownot(0)β bitwise NOT of zero, flips all bits: gives0xFFFF...FFFF= 2^256 - 1 (the largest uint256)lt(mm, prod0)β βless thanβ comparison, returns 1 ifmm < prod0, 0 otherwise. Acts as a borrow flag for the subtractionsub(a, b)β subtraction. The nestedsub(sub(mm, prod0), lt(...))subtracts with borrow to extract the high bits:=β Yulβs assignment operator (like=in Solidity, but for assembly variables)
You donβt need to prove WHY the extraction math works. The key insight: two views of the same product (mod 2^256 vs mod 2^256 - 1), combined, recover the full 512-bit value. Trust the library, understand the concept.
How to read the code:
- Start with OpenZeppelinβs
mulDivβ clean, well-commented Solidity - The core insight: multiply first (in 512 bits), divide second (back to 256)
- Then compare with Uniswapβs FullMath to see the assembly optimizations above in full context
- Donβt get stuck on the bit manipulation β understand the concept first, internals later
π Deep dive: Consensys Smart Contract Best Practices covers integer overflow/underflow security patterns. Trail of Bits - Building Secure Contracts provides development guidelines including arithmetic safety.
π DeFi Pattern Connection
Where checked arithmetic changed everything:
-
Vault Share Math (ERC-4626)
- Pre-0.8: Every vault needed SafeMath for
shares = (assets * totalSupply) / totalAssets - Post-0.8: Built-in safety, cleaner code
- Youβll implement this in the ShareMath exercise below
- Pre-0.8: Every vault needed SafeMath for
-
AMM Pricing (Uniswap, Curve, Balancer)
- Constant product formula:
x * y = k - Reserve updates must never overflow
- Modern AMMs use
uncheckedonly where math proves safety (like in UniswapβsFullMath)
- Constant product formula:
-
Rebasing Tokens (Aave aTokens, Lido stETH)
- Balance =
shares * rebaseIndex / 1e18 - Overflow protection is critical when rebaseIndex grows over years
- Checked arithmetic prevents silent corruption
- Balance =
The pattern: If youβre doing (a * b) / c with large numbers in DeFi, you need mulDiv. Every major protocol has its own version or uses a library.
πΌ Job Market Context
What DeFi teams expect you to know:
-
βWhen would you use
uncheckedin a vault contract?β- Good answer: Loop counters, intermediate calculations where inputs are validated, formulas with mathematical guarantees
-
βWhy canβt we just divide first:
(a / c) * binstead of(a * b) / c?β- Good answer: Lose precision. If
a < c, you get 0, then 0 * b = 0 (wrong!)
- Good answer: Lose precision. If
-
βHow do you handle multiplication overflow in share price calculations?β
- Good answer: Use a
mulDivlibrary (OpenZeppelin, Solady, or custom) for precise 512-bit intermediate math
- Good answer: Use a
Interview Red Flags:
- π© Importing SafeMath in new Solidity 0.8+ code
- π© Not knowing when to use
unchecked - π© Canβt explain why
uncheckedis safe in a specific case
Pro tip: In interviews, mention that you understand the tradeoff: checked arithmetic costs gas (~20-30 gas per operation) but prevents exploits. Show you think about both security AND efficiency.
β οΈ Common Mistakes
// β WRONG: Using unchecked without mathematical proof
unchecked {
uint256 result = userInput - fee; // If fee > userInput β wraps to ~2^256!
}
// β
CORRECT: Validate first, then use unchecked
require(userInput >= fee, InsufficientBalance());
unchecked {
uint256 result = userInput - fee; // Safe: validated above
}
// β WRONG: Importing SafeMath in 0.8+ code
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
using SafeMath for uint256;
uint256 total = a.add(b); // Redundant! Already checked by default
// β
CORRECT: Just use native operators
uint256 total = a + b; // Reverts on overflow automatically
// β WRONG: Wrapping entire function in unchecked to "save gas"
function processDeposit(uint256 amount) external {
unchecked {
totalDeposits += amount; // Could overflow with enough deposits!
userBalances[msg.sender] += amount; // Same problem
}
}
// β
CORRECT: Only unchecked for provably safe operations
function processDeposit(uint256 amount) external {
totalDeposits += amount; // Keep checked β can't prove safety
userBalances[msg.sender] += amount;
unchecked { ++depositCount; } // Safe: would need 2^256 deposits to overflow
}
π‘ Concept: Custom Errors (0.8.4+)
Why this matters: Every revert in DeFi costs gas. Thousands of transactions revert daily (failed slippage checks, insufficient balances, etc.). String-based require messages waste ~24 gas per revert and bloat your contract bytecode. Custom errors fix both problems.
Introduced in Solidity 0.8.4 (April 2021)
The old way:
// β OLD: Stores the string in bytecode, costs gas on every revert
require(amount > 0, "Amount must be positive");
require(balance >= amount, "Insufficient balance");
The modern way:
// β
MODERN: ~24 gas cheaper per revert, no string storage
error InvalidAmount();
error InsufficientBalance(uint256 available, uint256 required);
if (amount == 0) revert InvalidAmount();
if (balance < amount) revert InsufficientBalance(balance, amount);
require with custom errors (0.8.26+) β the recommended pattern:
As of Solidity 0.8.26, you can use custom errors directly in require. This is now the recommended way to write input validation β it combines the readability of require with the gas efficiency and tooling benefits of custom errors:
// β
RECOMMENDED (0.8.26+): Best of both worlds
error InvalidAmount();
error InsufficientBalance(uint256 available, uint256 required);
require(amount > 0, InvalidAmount());
require(balance >= amount, InsufficientBalance(balance, amount));
This replaces both the old require("string") pattern AND the verbose if (...) revert pattern for simple validations. Use if (...) revert when you need complex branching logic; use require(condition, CustomError()) for straightforward precondition checks.
β‘ Production note: As of early 2026, not all codebases have adopted this yet β youβll see both
if/revertandrequirewith custom errors in modern protocols. Both are correct;requireis more readable for simple checks.
Beyond gas savings:
Custom errors are better for off-chain tooling too β you can decode them by selector without needing string parsing or ABIs.
π» Quick Try:
Test error selectors in Remix:
error Unauthorized();
error InsufficientBalance(uint256 available, uint256 needed);
function testErrors() external pure {
// Copy the selector from the revert - it's 0x82b42900
revert Unauthorized();
// With parameters: notice how the data includes encoded values
revert InsufficientBalance(100, 200);
}
Call this, check the revert data in the console. See the 4-byte selector + ABI-encoded parameters.
ποΈ Real usage:
Two common patterns in production:
- Centralized: Aave V3βs
Errors.soldefines 60+ revert reasons in one library usingstring public constant(the pre-custom-error pattern). The principle β single source of truth for all revert reasons β carries forward to custom errors. - Decentralized: Uniswap V4 defines errors per-contract. More modular, less coupling.
Both work β choose based on your protocol size and organization.
β‘ Common pitfall: Changing an error signature (e.g., adding a parameter) changes its selector. Update your frontend/indexer decoding logic when you do this, or reverts will decode as βunknown error.β
π DeFi Pattern Connection
Why custom errors matter in DeFi composability:
-
Cross-Contract Error Propagation
// Your aggregator calls Uniswap try IUniswapV3Pool(pool).swap(...) { // Success path } catch (bytes memory reason) { // Uniswap's custom error bubbles up in 'reason' // Decode the selector to handle specific errors: // - InsufficientLiquidity β try another pool // - InvalidTick β recalculate parameters // - Generic revert β fail the whole transaction } -
Error-Based Control Flow
- Flash loan callbacks check for specific errors
- Aggregators route differently based on pool errors
- Multisig wallets decode errors for transaction preview
-
Frontend Error Handling
- Instead of showing βTransaction revertedβ
- Decode
InsufficientBalance(100, 200)β βNeed 200 tokens, you have 100β - Better UX = more users = more TVL
Production example: Aaveβs frontend decodes 60+ custom errors to show specific messages like βHealth factor too lowβ instead of cryptic hex data.
πΌ Job Market Context
What DeFi teams expect you to know:
-
βHow do you handle errors when calling external protocols?β
- Good answer: Use try/catch, decode custom error selectors, implement fallback logic based on error type
- Better answer: Show code example of catching Uniswap errors and routing to Curve as fallback
-
βWhy use custom errors over require strings in production?β
- Okay answer: Gas savings
- Good answer: Gas savings + better off-chain tooling + smaller bytecode
- Great answer: Plus explain the tradeoff (error handling complexity in try/catch)
-
βHow would you design error handling for a cross-protocol aggregator?β
- Show understanding of: error propagation, selector decoding, graceful degradation
Interview Red Flags:
- π© Still using
require(condition, "String message")everywhere in new code - π© Not knowing how to decode error selectors
- π© Canβt explain how errors bubble up in cross-contract calls
Pro tip: When building aggregators or routers, design your error types as a hierarchy β base errors for the protocol, specific errors per module. Teams that do this well (like 1inch, Paraswap) can provide users with actionable revert reasons instead of opaque failures.
π Deep dive: Cyfrin Updraft - Custom Errors provides a tutorial with practical examples. Solidity Docs - Error Handling covers how custom errors work with try/catch.
π Deep Dive: try/catch for Cross-Contract Error Handling
Custom errors shine when combined with try/catch β the pattern youβll use constantly in DeFi aggregators, routers, and any protocol that calls external contracts.
The problem: External calls can fail, and you need to handle failures gracefully β not just let them propagate up and kill the entire transaction.
Basic try/catch:
// Catch specific custom errors from external calls
try pool.swap(amountIn, minAmountOut) returns (uint256 amountOut) {
// Success β use amountOut
} catch Error(string memory reason) {
// Catches require(false, "reason") or revert("reason")
} catch Panic(uint256 code) {
// Catches assert failures, division by zero, overflow (codes: 0x01, 0x11, 0x12, etc.)
} catch (bytes memory lowLevelData) {
// Catches custom errors and anything else
// Decode: bytes4 selector = bytes4(lowLevelData);
}
DeFi pattern β aggregator with fallback routing:
function swapWithFallback(
address primaryPool,
address fallbackPool,
uint256 amountIn,
uint256 minOut
) external returns (uint256) {
// Try primary pool first
try IPool(primaryPool).swap(amountIn, minOut) returns (uint256 out) {
return out;
} catch (bytes memory reason) {
bytes4 selector = bytes4(reason);
if (selector == IPool.InsufficientLiquidity.selector) {
// Known error β fall through to backup pool
} else {
// Unknown error β re-throw (don't swallow unexpected failures)
assembly { revert(add(reason, 32), mload(reason)) }
}
}
// Fallback to secondary pool
return IPool(fallbackPool).swap(amountIn, minOut);
}
Key rules:
tryonly works on external function calls and contract creation (new)- The
returnsclause captures success values - Always handle the catch-all
catch (bytes memory)β custom errors land here - Never silently swallow errors (
catch {}) unless you genuinely intend to ignore failures
Understanding what each catch branch receives:
When an external call fails, what your catch block receives depends on HOW it failed:
| Failure type | catch Error(string) | catch Panic(uint256) | catch (bytes memory) |
|---|---|---|---|
revert("message") / require(false, "msg") | β Caught | β | β Also caught (ABI-encoded) |
revert CustomError(params) | β | β | β Caught (4-byte selector + params) |
assert(false) / overflow / div-by-zero | β | β Caught (panic code) | β Also caught |
| Out of gas in the sub-call | β | β | β
Caught, but reason is empty |
revert() with no argument | β | β | β
Caught, reason is empty |
The critical edge case: empty returndata. When a call runs out of gas or uses bare revert(), catch receives zero-length bytes. If you try to read bytes4(reason) on empty data, you get a panic. Always check length first:
catch (bytes memory reason) {
if (reason.length >= 4) {
bytes4 selector = bytes4(reason);
if (selector == IPool.InsufficientLiquidity.selector) {
// Decode the error parameters β skip the 4-byte selector
(uint256 available, uint256 required) = abi.decode(
// reason[4:] is a bytes slice β everything after the selector
reason[4:],
(uint256, uint256)
);
// Now you have the actual values from the error
emit SwapFailedWithDetails(available, required);
} else if (selector == IPool.Expired.selector) {
// Handle differently
} else {
// Unknown error β re-throw it (explained below)
assembly { revert(add(reason, 32), mload(reason)) }
}
} else {
// Empty or very short reason β could be:
// - Out of gas in the sub-call
// - Bare revert() with no data
// - Very old contract without error messages
// Don't try to decode β propagate or handle generically
revert SwapFailed();
}
}
The re-throw pattern β explained line by line:
Youβll see this assembly line everywhere in production DeFi code. Hereβs what each piece does:
assembly { revert(add(reason, 32), mload(reason)) }
reasonβ abytes memoryvariable. In memory, itβs laid out as: [32 bytes: length][actual bytes dataβ¦]mload(reason)β reads the first 32 bytes at that memory address, which is the length of the bytes arrayadd(reason, 32)β skips past the length prefix, pointing to where the actual data startsrevert(offset, size)β the EVM REVERT opcode: stops execution and returns the specified memory range as returndata
In plain English: βtake the raw error bytes exactly as received from the sub-call and re-throw them.β This preserves the original error selector and parameters through each call layer, no matter how deep.
Multi-hop error propagation:
In DeFi, calls are often 3-4 levels deep: User β Router β Pool β Callback. Understanding how errors flow through this chain is critical:
User β Router.swap()
β
ββ try Pool.swap()
β
ββ Callback.uniswapV3SwapCallback()
β
ββ reverts: InsufficientBalance(100, 200)
β
βββββββββββββββββ
β Pool doesn't catch β error propagates UP automatically
β (Solidity's default: uncaught reverts bubble up)
βββββββββββ
β Router's catch receives: reason = 0xf4d678b8...0064...00c8
β (that's InsufficientBalance.selector + abi.encode(100, 200))
β
β Router can now:
β 1. Decode it β know exactly what went wrong
β 2. Re-throw it β user sees the original error
β 3. Try fallback pool β graceful degradation
β 4. Wrap it β revert RouterSwapFailed(primaryPool, reason)
Without the re-throw pattern, each layer wraps or loses the original error. The caller sees βSwap failedβ instead of βInsufficientBalance(100, 200).β For debugging, for frontends, and for MEV searchers β the original error data is invaluable.
Where this appears in DeFi:
- Aggregators (1inch, Paraswap): try Pool A, catch β decode error β try Pool B with adjusted parameters
- Liquidation bots: try to liquidate, catch β check if itβs βhealthy positionβ (skip) vs βinsufficient gasβ (retry)
- Keepers (Gelato, Chainlink Automation): try execution, catch β log specific error for monitoring dashboards
- Flash loans: decode callback errors β was it the userβs callback that failed, or the repayment?
- Routers (Uniswap Universal Router): multi-hop swaps where each hop can fail independently
Forward reference: Youβll implement cross-contract error handling in Part 2 Module 5 (Flash Loans) where callback errors must be decoded and handled.
β οΈ Common Mistakes
// β WRONG: Mixing old and new error styles in the same contract
error InsufficientBalance(uint256 available, uint256 required);
function withdraw(uint256 amount) external {
require(amount > 0, "Amount must be positive"); // Old style string
if (balance < amount) revert InsufficientBalance(balance, amount); // New style
}
// β
CORRECT: Consistent error style throughout
error ZeroAmount();
error InsufficientBalance(uint256 available, uint256 required);
function withdraw(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
if (balance < amount) revert InsufficientBalance(balance, amount);
}
// β WRONG: Losing error context in cross-contract calls
try pool.swap(amount) {} catch {
revert("Swap failed"); // Lost the original error β debugging nightmare
}
// β
CORRECT: Decode and handle specific errors
try pool.swap(amount) {} catch (bytes memory reason) {
if (bytes4(reason) == IPool.InsufficientLiquidity.selector) {
// Try alternate pool
} else {
// Re-throw original error with full context
assembly { revert(add(reason, 32), mload(reason)) }
}
}
// β WRONG: Errors without useful parameters
error TransferFailed(); // Which transfer? Which token? How much?
// β
CORRECT: Include debugging context in error parameters
error TransferFailed(address token, address to, uint256 amount);
π‘ Concept: User-Defined Value Types (0.8.8+)
Why this matters: Type safety catches bugs at compile time, not runtime. In DeFi, mixing up similar values (token addresses vs. pool addresses, amounts vs. shares, prices vs. quantities) causes expensive bugs. UDVTs prevent these with zero gas cost.
Introduced in Solidity 0.8.8 (September 2021)
The problem UDVTs solve:
Without type safety, this compiles but is wrong:
// β WRONG: Compiles but has a logic bug
function execute(uint128 price, uint128 quantity) external {
uint128 total = quantity + price; // BUG: should be price * quantity
// Compiler can't help you β both are uint128
}
The solution β wrap primitives in types:
// β
CORRECT: Type safety catches the bug at compile time
type Price is uint128;
type Quantity is uint128;
function execute(Price price, Quantity qty) external {
// Price + Quantity won't compile β type mismatch caught immediately β¨
uint128 rawPrice = Price.unwrap(price);
uint128 rawQty = Quantity.unwrap(qty);
uint128 total = rawPrice * rawQty; // Must unwrap to do math
}
Custom operators (0.8.19+):
Since Solidity 0.8.19, you can define operators on UDVTs to avoid manual unwrap/wrap:
type Fixed18 is uint256;
using { add as +, sub as - } for Fixed18 global;
function add(Fixed18 a, Fixed18 b) pure returns (Fixed18) {
return Fixed18.wrap(Fixed18.unwrap(a) + Fixed18.unwrap(b));
}
function sub(Fixed18 a, Fixed18 b) pure returns (Fixed18) {
return Fixed18.wrap(Fixed18.unwrap(a) - Fixed18.unwrap(b));
}
// Now you can use: result = a + b - c
π» Quick Try:
Build a simple UDVT with an operator in Remix:
type TokenId is uint256;
using { equals as == } for TokenId global;
function equals(TokenId a, TokenId b) pure returns (bool) {
return TokenId.unwrap(a) == TokenId.unwrap(b);
}
function test() external pure returns (bool) {
TokenId id1 = TokenId.wrap(42);
TokenId id2 = TokenId.wrap(42);
return id1 == id2; // Uses your custom operator!
}
π Intermediate Example: Building a Practical UDVT
Before diving into Uniswap V4, letβs build a realistic DeFi example - a vault with type-safe shares:
// Type-safe vault shares
type Shares is uint256;
type Assets is uint256;
// Global operators
using { addShares as +, subShares as - } for Shares global;
using { addAssets as +, subAssets as - } for Assets global;
function addShares(Shares a, Shares b) pure returns (Shares) {
return Shares.wrap(Shares.unwrap(a) + Shares.unwrap(b));
}
function subShares(Shares a, Shares b) pure returns (Shares) {
return Shares.wrap(Shares.unwrap(a) - Shares.unwrap(b));
}
// Similar for Assets...
// Now your vault logic is type-safe:
function deposit(Assets assets) external returns (Shares) {
Shares shares = convertToShares(assets);
_totalAssets = _totalAssets + assets; // Can't mix with shares!
_totalShares = _totalShares + shares; // Type enforced β¨
return shares;
}
Why this matters: Try mixing Shares and Assets - it wonβt compile. This prevents the classic bug: shares + assets (meaningless operation).
ποΈ Real usage β Uniswap V4:
Understanding UDVTs is essential for reading V4 code. They use them extensively:
PoolId.solβtype PoolId is bytes32, computed viakeccak256(abi.encode(poolKey))Currency.solβtype Currency is address, unifies native ETH and ERC-20 handling with custom comparison operatorsBalanceDelta.solβtype BalanceDelta is int256, packs twoint128values using bit manipulation with custom+,-,==,!=operators
π Deep Dive: Understanding BalanceDelta Bit-Packing
This is the advanced pattern youβll see in production DeFi. Letβs break it down step-by-step.
The problem: Uniswap V4 needs to track balance changes for two tokens in a pool. Storing them separately costs 2 storage slots (40,000 gas). Packing them into one slot saves 20,000 gas per swap.
The solution - pack two int128 values into one int256:
Visual memory layout:
βββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββ
β amount0 (128 bits) β amount1 (128 bits) β
βββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββ
int256 (256 bits total)
Step-by-step packing:
// Input: two separate int128 values
int128 amount0 = -100; // Token 0 balance change
int128 amount1 = 200; // Token 1 balance change
// Step 1: Cast amount0 to int256 and shift left 128 bits
int256 packed = int256(amount0) << 128;
// After shift (binary):
// [amount0 in high 128 bits][empty 128 bits with zeros]
// Step 2: OR with amount1 (fills the low 128 bits)
// β οΈ Must mask to 128 bits via the triple-cast chain:
// int128 β uint128: reinterprets sign bit as data (e.g., -1 β 0xFF..FF)
// uint128 β uint256: zero-extends (fills high bits with 0, not sign)
// uint256 β int256: safe reinterpret (value fits, high bits are 0)
// Without this: int256(negative_int128) sign-extends to 256 bits,
// corrupting the high 128 bits (amount0) when ORed.
packed = packed | int256(uint256(uint128(amount1)));
// Final result (binary):
// [amount0 in bits 128-255][amount1 in bits 0-127]
// Wrap it in the UDVT
BalanceDelta delta = BalanceDelta.wrap(packed);
The actual Uniswap V4 code β assembly version:
In production, Uniswap V4 packs with assembly for gas savings. Hereβs their toBalanceDelta:
// From Uniswap V4 β src/types/BalanceDelta.sol
function toBalanceDelta(int128 _amount0, int128 _amount1)
pure returns (BalanceDelta balanceDelta)
{
assembly {
// shl(128, _amount0) β shift amount0 left by 128 bits (same as << 128)
// and(_amount1, 0x00..00ffffffffffffffffffffffffffffffff) β mask to 128 bits
// (the mask is 16 bytes of 0xFF = the low 128 bits)
// This does the same job as the triple-cast chain: prevents sign-extension
// or(..., ...) β combine both halves into one 256-bit value
balanceDelta := or(
shl(128, _amount0),
and(_amount1, 0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff)
)
}
}
Same concept as the Solidity version above β shift left, mask, OR. The assembly version saves gas by avoiding the intermediate casts and doing the masking with a single and opcode. Functionally identical.
Step-by-step unpacking:
// Extract amount0 (high 128 bits)
int256 unwrapped = BalanceDelta.unwrap(delta);
int128 amount0 = int128(unwrapped >> 128); // Shift right 128 bits
// Extract amount1 (low 128 bits)
int128 amount1 = int128(unwrapped); // Just truncate (keeps low 128)
Why the casts work:
int256 >> 128: Arithmetic right shift preserves sign (negative stays negative)int128(int256 value): Truncates to low 128 bits- The sign bit of each int128 is preserved in its respective half
Testing your understanding:
// What does this pack?
int128 a = -50;
int128 b = 100;
int256 packed = (int256(a) << 128) | int256(uint256(uint128(b)));
// Visual representation:
// High 128 bits: -50 (sign-extended, then shifted β safe)
// Low 128 bits: 100 (masked to 128 bits before OR β safe)
// Total: one int256 storing both values
Custom operators on packed data:
// Add two BalanceDelta values
function add(BalanceDelta a, BalanceDelta b) pure returns (BalanceDelta) {
// Extract both amounts from 'a'
int256 aUnwrapped = BalanceDelta.unwrap(a);
int128 a0 = int128(aUnwrapped >> 128);
int128 a1 = int128(aUnwrapped);
// Extract both amounts from 'b'
int256 bUnwrapped = BalanceDelta.unwrap(b);
int128 b0 = int128(bUnwrapped >> 128);
int128 b1 = int128(bUnwrapped);
// Add them
int128 sum0 = a0 + b0;
int128 sum1 = a1 + b1;
// Pack the result (mask sum1 to prevent sign-extension corruption)
int256 packed = (int256(sum0) << 128) | int256(uint256(uint128(sum1)));
return BalanceDelta.wrap(packed);
}
using { add as + } for BalanceDelta global;
// Now you can: result = deltaA + deltaB (both amounts add component-wise)
When youβll see this pattern:
- AMMs tracking token pair balances (Uniswap V4)
- Packing timestamp + value in one slot
- Any time you need two related values accessed together
π How to Study BalanceDelta.sol:
- Start with tests - See how itβs constructed and used
- Draw the bit layout - Literally draw boxes showing which bits are what
- Trace one operation - Pick
+, trace through pack/unpack/repack - Verify with examples - Test with small numbers in Remix to see the bits
- Read comments - Uniswapβs code comments explain the βwhyβ
Donβt get stuck on: Assembly optimizations in the Uniswap code. Understand the concept first (pure Solidity), then see how they optimize it.
π DeFi Pattern Connection
Where UDVTs prevent real bugs:
-
βWrong Tokenβ Bug Class
// Without UDVTs - this compiles but is wrong: function swap(address tokenA, address tokenB, uint256 amount) { // Oops - swapped tokenA and tokenB IERC20(tokenB).transferFrom(msg.sender, pool, amount); IERC20(tokenA).transfer(msg.sender, output); } // With UDVTs - won't compile: type TokenA is address; type TokenB is address; function swap(TokenA a, TokenB b, uint256 amount) { IERC20(TokenB.unwrap(a)).transfer... // TYPE ERROR! β¨ } -
AMM Pool Identification
- Uniswap V4 uses
type PoolId is bytes32 - Canβt accidentally use a random bytes32 as a PoolId
- Type system prevents:
pools[someRandomHash](compile error)
- Uniswap V4 uses
-
Vault Shares vs Assets
type Shares is uint256vstype Assets is uint256- Prevents:
shares + assets(meaningless operation caught at compile time) - Youβll implement this in the ShareMath exercise below
The pattern: Use UDVTs for domain-specific identifiers (PoolId, TokenId, OrderId) and values that shouldnβt be mixed (Shares vs Assets, Price vs Quantity).
πΌ Job Market Context
What DeFi teams expect you to know:
-
βWhy does Uniswap V4 use PoolId instead of bytes32?β
- Good answer: Type safety - prevents using random hashes as pool identifiers
- Great answer: Plus explain the zero-cost abstraction (no runtime overhead)
-
βHow would you design a type-safe vault?β
- Show understanding of:
type Shares is uint256, custom operators, preventing shares/assets confusion
- Show understanding of:
-
βExplain bit-packing in BalanceDelta.β
- This is a common interview question for Uniswap-related roles
- Expected: Explain the memory layout, how packing/unpacking works, why it saves gas
- Bonus: Mention the tradeoff (complexity vs gas savings)
Interview Red Flags:
- π© Never heard of UDVTs
- π© Canβt explain when youβd use them
- π© Donβt know about Uniswap V4βs usage (if applying to DEX roles)
Pro tip: Mentioning youβve studied BalanceDelta.sol and understand bit-packing shows you can handle complex production code. Itβs a signal that youβre beyond beginner tutorials.
π Deep dive: Uniswap V4 Design Blog explains their architectural reasoning for using UDVTs. Solidity Blog - User-Defined Operators provides an official deep dive on custom operators.
β οΈ Common Mistakes
// β WRONG: Unwrapping too early, losing type safety
function deposit(Assets assets) external {
uint256 raw = Assets.unwrap(assets); // Unwrapped immediately
// ... 50 lines of math with raw uint256 ...
// Type safety lost β could mix with shares again
}
// β
CORRECT: Keep wrapped as long as possible
function deposit(Assets assets) external {
Shares shares = convertToShares(assets); // Types maintained throughout
_totalAssets = _totalAssets + assets; // Can't accidentally mix with shares
_totalShares = _totalShares + shares; // Type-enforced β¨
}
// β WRONG: Wrapping arbitrary values β defeats the purpose
type PoolId is bytes32;
function getPool(bytes32 data) external view returns (Pool memory) {
return pools[PoolId.wrap(data)]; // Wrapping unvalidated data β no safety!
}
// β
CORRECT: Only create PoolId from validated sources
function computePoolId(PoolKey memory key) internal pure returns (PoolId) {
return PoolId.wrap(keccak256(abi.encode(key))); // Only valid path
}
// β WRONG: Forgetting to define operators, leading to verbose code
type Shares is uint256;
// Without operators, every operation is painful:
Shares total = Shares.wrap(Shares.unwrap(a) + Shares.unwrap(b));
// β
CORRECT: Define operators with `using for ... global`
using { addShares as + } for Shares global;
function addShares(Shares a, Shares b) pure returns (Shares) {
return Shares.wrap(Shares.unwrap(a) + Shares.unwrap(b));
}
// Now clean and readable:
Shares total = a + b;
π‘ Concept: abi.encodeCall (0.8.11+)
Why this matters: Low-level calls are everywhere in DeFi β delegate calls in proxies, calls through routers, flash loan callbacks. Type-safe encoding prevents silent bugs where you pass the wrong argument types.
Introduced in Solidity 0.8.11 (December 2021)
The old way:
// β OLD: No compile-time type checking β easy to swap arguments
bytes memory data = abi.encodeWithSelector(
IERC20.transfer.selector,
amount, // BUG: swapped with recipient
recipient
);
The modern way:
// β
MODERN: Compiler verifies argument types match the function signature
bytes memory data = abi.encodeCall(
IERC20.transfer,
(recipient, amount) // Compile error if these are swapped β¨
);
The compiler knows IERC20.transfer expects (address, uint256) and will reject mismatches at compile time.
When to use this:
- Encoding calls for
delegatecall,call,staticcall - Building multicall batches
- Encoding data for cross-chain messages
- Anywhere you previously used
abi.encodeWithSelector
π» Quick Try:
Test the type-safety difference in Remix or Foundry:
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
function testEncoding() external pure returns (bytes memory safe, bytes memory unsafe) {
address recipient = address(0xBEEF);
uint256 amount = 100e18;
// β
Type-safe: compiler verifies (address, uint256) match
safe = abi.encodeCall(IERC20.transfer, (recipient, amount));
// β No type checking: swapping args compiles fine β silent bug!
unsafe = abi.encodeWithSelector(IERC20.transfer.selector, amount, recipient);
// Both produce 4-byte selector + args, but only encodeCall catches the swap
}
Try swapping (recipient, amount) to (amount, recipient) in the encodeCall line β the compiler rejects it immediately. The encodeWithSelector version silently produces wrong calldata.
π DeFi Pattern Connection
Where abi.encodeCall matters in DeFi:
-
Multicall Routers (1inch, Paraswap aggregators)
// Building a batch of swaps bytes[] memory calls = new bytes[](3); calls[0] = abi.encodeCall( IUniswap.swap, (tokenA, tokenB, amount, minOut) // Type-checked! ); calls[1] = abi.encodeCall( ICurve.exchange, (i, j, dx, min_dy) // Compiler ensures correct types ); // Execute batch multicall(calls); -
Flash Loan Callbacks
// Encoding callback data for Aave flash loan bytes memory params = abi.encodeCall( this.executeArbitrage, (token, amount, profitTarget) ); lendingPool.flashLoan(address(this), assets, amounts, modes, params); -
Cross-Chain Messages (LayerZero, Axelar)
// Encoding a message to execute on destination chain bytes memory payload = abi.encodeCall( IDestination.mint, (recipient, amount, tokenId) // Type safety prevents costly errors ); bridge.send(destChainId, destAddress, payload);
Why this matters: In cross-chain/cross-protocol calls, debugging is expensive (canβt just revert and retry). Type safety catches bugs before deployment.
πΌ Job Market Context
What DeFi teams expect you to know:
-
βHow would you build a multicall router?β
- Good answer: Batch multiple calls, use
abi.encodeCallfor type safety - Great answer: Plus mention gas optimization (batch vs individual), error handling, and security (reentrancy)
- Good answer: Batch multiple calls, use
-
βWhatβs the difference between abi.encodeCall and abi.encodeWithSelector?β
abi.encodeCall: Type-checked at compile timeabi.encodeWithSelector: No type checking, easy to make mistakes- Show you know when to use each (prefer encodeCall in new code)
Interview Red Flags:
- π© Still using
abi.encodeWithSelectororabi.encodeWithSignaturein new code - π© Not aware of the type safety benefits
Pro tip: In multicall/batch architectures, abi.encodeCall shines because a single typo in a selector can drain funds. Show interviewers you default to the type-safe option and only drop to encodeWithSelector when dealing with dynamic interfaces (e.g., proxy patterns where the target ABI isnβt known at compile time).
β οΈ Common Mistakes
// β WRONG: Using abi.encodeWithSignature β typo-prone, no type checking
bytes memory data = abi.encodeWithSignature(
"tranfer(address,uint256)", // Typo! Missing 's' β silent failure
recipient, amount
);
// β ALSO WRONG: Using abi.encodeWithSelector β no argument type checking
bytes memory data = abi.encodeWithSelector(
IERC20.transfer.selector,
amount, recipient // Swapped args β compiles fine, fails at runtime!
);
// β
CORRECT: abi.encodeCall catches both issues at compile time
bytes memory data = abi.encodeCall(
IERC20.transfer,
(recipient, amount) // Compiler verifies types match signature
);
// β WRONG: Forgetting the tuple syntax for arguments
bytes memory data = abi.encodeCall(IERC20.transfer, recipient, amount);
// Compile error! Arguments must be wrapped in a tuple
// β
CORRECT: Arguments in parentheses as a tuple
bytes memory data = abi.encodeCall(IERC20.transfer, (recipient, amount));
π‘ Concept: Other Notable Changes
Named parameters in mapping types (0.8.18+):
Self-documenting code, especially useful for nested mappings:
// β BEFORE: Hard to understand
mapping(address => mapping(address => uint256)) public balances;
// β
AFTER: Self-explanatory
mapping(address user => mapping(address token => uint256 balance)) public balances;
Introduced in Solidity 0.8.18
OpenZeppelin v5 β The _update() hook pattern:
OpenZeppelin v5 (aligned with Solidity 0.8.20+) replaced the dual _beforeTokenTransfer / _afterTokenTransfer hooks with a single _update() function. When reading protocol code, check which OZ version theyβre using.
Learn more: Introducing OpenZeppelin Contracts 5.0
bytes.concat and string.concat (0.8.4+ / 0.8.12+):
Cleaner alternatives to abi.encodePacked for non-hashing concatenation:
// β BEFORE: abi.encodePacked for everything
bytes memory data = abi.encodePacked(prefix, payload);
// β
AFTER: Purpose-specific concatenation
bytes memory data = bytes.concat(prefix, payload); // For bytes
string memory name = string.concat(first, " ", last); // For strings
Use bytes.concat / string.concat for building data, abi.encodePacked only for hash inputs.
immutable improvements (0.8.8+ / 0.8.21+):
Immutable variables became more flexible over time:
- 0.8.8+: Immutables can be read in the constructor
- 0.8.21+: Immutables can be non-value types (bytes, strings) β previously only value types (uint256, address, etc.) were supported
// Since 0.8.21: immutable string and bytes
string public immutable name; // Stored in code, not storage β cheaper to read
bytes32 public immutable merkleRoot;
constructor(string memory _name, bytes32 _root) {
name = _name;
merkleRoot = _root;
}
Free functions and using for at file level (0.8.0+):
Functions can exist outside contracts, and using LibraryX for TypeY global makes library functions available everywhere:
// Free function (not in a contract)
function toWad(uint256 value) pure returns (uint256) {
return value * 1e18;
}
// Make it available globally
using { toWad } for uint256 global;
// Now usable anywhere in the file:
uint256 wad = 100.toWad();
This pattern dominates Uniswap V4βs codebase β nearly all their utilities are free functions with global using for declarations.
π― Build Exercise: ShareMath
Workspace: workspace/src/part1/module1/exercise1-share-math/ β starter file: ShareMath.sol, tests: ShareMath.t.sol
Build a vault share calculator β the exact math that underpins every ERC-4626 vault, lending pool, and LP token in DeFi:
-
Define UDVTs for
AssetsandShares(both wrappinguint256) with custom+and-operators- Implement the operators as free functions with
using { add as +, sub as - } for Assets global - This exercises the free function +
using for globalpattern
- Implement the operators as free functions with
-
Implement conversion functions:
toShares(Assets assets, Assets totalAssets, Shares totalSupply)toAssets(Shares shares, Assets totalAssets, Shares totalSupply)- Use
uncheckedwhere the math is provably safe - Use custom errors:
ZeroAssets(),ZeroShares(),ZeroTotalSupply()
-
Create a wrapper contract
ShareCalculatorthat wraps these functions- In your Foundry tests, call it via
abi.encodeCallfor at least one test case - Verify the type-safe encoding catches what
abi.encodeWithSelectorwouldnβt
- In your Foundry tests, call it via
-
Test the math:
- Deposit 1000 assets when totalAssets=5000, totalSupply=3000 β verify you get 600 shares
- Test the roundtrip: convert assetsβsharesβassets
- Verify the result is within 1 wei of the original (rounding always favors the vault)
π― Goal: Get hands-on with the syntax in a DeFi context. This exact shares/assets math shows up in every vault and lending protocol in Part 2 β youβre building the intuition now.
π Summary: Language-Level Changes
β Covered:
- Checked arithmetic by default (0.8.0) β no more SafeMath needed
- Custom errors (0.8.4+) β gas savings and better tooling
- User-Defined Value Types (0.8.8+) β type safety for domain concepts
abi.encodeCall(0.8.11+) β type-safe low-level calls- Named mapping parameters (0.8.18+) β self-documenting code
- OpenZeppelin v5 patterns β
_update()hook - Free functions and global
using forβ Uniswap V4 style
Next: Transient storage, bleeding edge features, and whatβs coming in 0.9.0
π‘ Solidity 0.8.24+ β The Bleeding Edge
π‘ Concept: Transient Storage Support (0.8.24+)
Why this matters: Reentrancy guards cost 5,000-20,000 gas to write to storage. Transient storage costs ~100 gas for the same protection. Thatβs a 50-200x gas savings. Beyond guards, transient storage enables new patterns like Uniswap V4βs flash accounting system.
Based on EIP-1153, supported since Solidity 0.8.24
Assembly-first (0.8.24-0.8.27):
Initially, transient storage required inline assembly:
// β οΈ OLD SYNTAX: Assembly required (0.8.24-0.8.27)
modifier nonreentrant() {
assembly {
if tload(0) { revert(0, 0) }
tstore(0, 1)
}
_;
assembly {
tstore(0, 0)
}
}
The transient keyword (0.8.28+):
Since Solidity 0.8.28, you can declare state variables with transient, and the compiler handles the opcodes:
// β
MODERN SYNTAX: transient keyword (0.8.28+)
contract ReentrancyGuard {
bool transient locked;
modifier nonreentrant() {
require(!locked);
locked = true;
_;
locked = false;
}
}
The transient keyword makes the variable live in transient storage β same slot-based addressing as regular storage, but discarded at the end of every transaction.
π» Quick Try:
Test transient vs regular storage gas costs in Remix:
contract GasTest {
// Regular storage
uint256 regularValue;
// Transient storage
uint256 transient transientValue;
function testRegular() external {
regularValue = 1; // Check gas cost
}
function testTransient() external {
transientValue = 1; // Check gas cost
}
}
Deploy and compare execution costs. Youβll see ~20,000 vs ~100 gas difference.
π Gas comparison:
| Storage Type | First Write | Warm Write | Savings |
|---|---|---|---|
| Regular storage (cold) | ~20,000 gas | ~5,000 gas | Baseline |
| Transient storage | ~100 gas | ~100 gas | 50-200x cheaper β¨ |
β‘ Note: Exact gas costs vary by compiler version, optimizer settings, and EVM upgrades. The relative difference (transient is dramatically cheaper) is what matters, not the precise numbers.
π Understanding Transient Storage at EVM Level
How it works:
Regular Storage (SSTORE/SLOAD):
βββββββββββββββββββββββββββββββββββ
β Persists across transactions β
β Written to blockchain state β
β Expensive (disk I/O) β
β Refunds available β
βββββββββββββββββββββββββββββββββββ
Transient Storage (TSTORE/TLOAD):
βββββββββββββββββββββββββββββββββββ
β Lives only during transaction β
β In-memory (no disk writes) β
β Cheap (~100 gas) β
β Auto-cleared after transaction β
βββββββββββββββββββββββββββββββββββ
Key properties:
- Transaction-scoped: Set in call A, read in call B (same transaction) β
- Auto-reset: Cleared when transaction ends (no manual cleanup needed)
- No refunds: Unlike SSTORE, no refund mechanism needed (simpler gas accounting)
- Same slot addressing: Uses storage slots like regular storage
When to use assembly vs keyword:
// Use the keyword (0.8.28+) for simple cases:
bool transient locked; // Clear, readable
// Use assembly for dynamic slot calculation:
assembly {
let slot := keccak256(add(key, someOffset))
tstore(slot, value) // Dynamic slot access
}
ποΈ Real usage:
OpenZeppelinβs ReentrancyGuardTransient.sol β their production implementation using the transient keyword. Compare it to the classic storage-based ReentrancyGuard.sol to see the difference.
π Deep Dive: Uniswap V4 Flash Accounting
The traditional model (V1, V2, V3):
// Transfer tokens IN
token.transferFrom(user, pool, amountIn);
// Do swap logic
uint256 amountOut = calculateSwap(amountIn);
// Transfer tokens OUT
token.transfer(user, amountOut);
Every swap = 2 token transfers = expensive!
V4βs flash accounting (using transient storage):
// Record debt in transient storage
int256 transient delta0; // How much pool owes/is owed for token0
int256 transient delta1; // How much pool owes/is owed for token1
// During swap: just update deltas (cheap!)
delta0 -= int256(amountIn); // Pool gains token0
delta1 += int256(amountOut); // Pool owes token1
// At END of transaction: settle all debts at once
function settle() external {
if (delta0 < 0) token0.transferFrom(msg.sender, pool, uint256(-delta0));
if (delta1 > 0) token1.transfer(msg.sender, uint256(delta1));
}
The breakthrough:
- Multiple swaps in one transaction? Update deltas multiple times (cheap)
- Settle debts ONCE at the end (one transfer per token)
- Net result: Massive gas savings for multi-hop swaps
Visualization:
Old model (V3):
Swap A: Transfer IN β Swap β Transfer OUT
Swap B: Transfer IN β Swap β Transfer OUT
Swap C: Transfer IN β Swap β Transfer OUT
Total: 6 transfers
New model (V4):
Swap A: Update delta (100 gas)
Swap B: Update delta (100 gas)
Swap C: Update delta (100 gas)
Settle: Transfer IN + Transfer OUT (2 transfers total)
Savings: 4 transfers eliminated!
Why transient storage is essential:
- Deltas must persist across internal calls within the transaction
- But must be cleared before next transaction (no state pollution)
- Perfect fit for transient storage
π DeFi Pattern Connection
Where transient storage changes DeFi:
-
Reentrancy Guards (everywhere)
- Before: 20,000 gas per protected function
- After: 100 gas per protected function
- Every protocol with external calls benefits
-
Flash Loan State (Aave, Balancer)
- Track βin flash loanβ state across callback
- Verify repayment before transaction ends
- No permanent storage pollution
-
Multi-Protocol Routing (aggregators like 1inch)
- Track token balances across multiple DEX calls
- Settle once at the end
- Massive savings for complex routes
-
Temporary Access Control
- Grant permission for duration of transaction
- Auto-revoke when transaction ends
- Useful for complex DeFi operations
The pattern: Whenever you need state that:
- Lives across multiple calls in ONE transaction
- Must be cleared before next transaction
- Is accessed frequently (gas-sensitive)
β Use transient storage
πΌ Job Market Context
This is hot right now - Uniswap V4 just launched with this, every DeFi team is watching.
What DeFi teams expect you to know:
-
βExplain Uniswap V4βs flash accounting.β
- This is THE interview question for DEX roles in 2025-2026
- Expected: Explain delta tracking, settlement, why transient storage
- Bonus: Explain the gas savings quantitatively
-
βWhen would you use transient storage?β
- Good answer: Reentrancy guards, temporary state within transaction
- Great answer: Plus mention flash accounting pattern, multi-step operations, the tradeoff (only works within one transaction)
-
βHow would you migrate a reentrancy guard to transient storage?β
- Show understanding of: drop-in replacement, gas savings, when itβs worth it
Interview Red Flags:
- π© Never heard of transient storage (major red flag for modern DeFi roles)
- π© Canβt explain EIP-1153 basics
- π© Donβt know about Uniswap V4βs usage
Pro tip: If interviewing for a DEX/AMM role, deeply study Uniswap V4βs implementation. Mentioning you understand flash accounting puts you ahead of 90% of candidates.
π Deep dive: EIP-1153 includes detailed security considerations. Uniswap V4 Flash Accounting Docs shows production usage. Cyfrin - Uniswap V4 Swap Deep Dive provides a technical walkthrough of flash accounting with transient storage.
β οΈ Common Mistakes
// β WRONG: Assuming transient storage persists across transactions
contract TokenCache {
address transient lastSender;
function recordSender() external {
lastSender = msg.sender; // Gone after this transaction!
}
function getLastSender() external view returns (address) {
return lastSender; // Always address(0) in a new transaction
}
}
// β
CORRECT: Use regular storage for cross-transaction state
contract TokenCache {
address public lastSender; // Regular storage β persists
bool transient _processing; // Transient β only for intra-tx flags
}
// β WRONG: Using transient storage for data that must survive upgrades
contract VaultV1 {
uint256 transient totalDeposits; // Lost after every transaction!
}
// β
CORRECT: Only transient for ephemeral intra-transaction state
contract VaultV1 {
uint256 public totalDeposits; // Persistent β survives across txs
bool transient _reentrancyLocked; // Ephemeral β only during tx
}
// β WRONG: Forgetting to reset transient state in multi-step transactions
modifier withCallback() {
_callbackExpected = true;
_;
// Forgot to reset! If tx continues after this call, stale flag remains
}
// β
CORRECT: Explicitly reset even though tx-end auto-clears
modifier withCallback() {
_callbackExpected = true;
_;
_callbackExpected = false; // Clean up β don't rely only on auto-clear
}
π‘ Concept: Pectra/Prague EVM Target (0.8.30+)
What changed: Solidity 0.8.30 changed the default EVM target from Cancun to Prague (the Pectra upgrade, May 2025). New opcodes are available and the compilerβs code generation assumes the newer EVM.
What Pectra brought:
- EIP-7702: Set EOA code (delegate transactions) β enables account abstraction patterns without deploying a new wallet contract. Covered in depth in Module 4.
- EIP-7685: General purpose execution layer requests
- EIP-2537: BLS12-381 precompile β efficient BLS signature verification (important for consensus layer interactions)
What this means for you:
- β Deploying to Ethereum mainnet: use default (Prague/Pectra)
- β οΈ Deploying to L2s or chains that havenβt upgraded: specify
--evm-version cancunin your compiler settings - β οΈ Compiling with Prague target produces bytecode that may fail on pre-Pectra chains
Check your target chainβs EVM version in your Foundry config (foundry.toml):
[profile.default]
evm_version = "cancun" # For L2s that haven't adopted Pectra yet
π‘ Concept: Whatβs Coming β Solidity 0.9.0 Deprecations
Solidity 0.8.31 started emitting deprecation warnings for features being removed in 0.9.0:
| Feature | Status | What to Use Instead |
|---|---|---|
| ABI coder v1 | β οΈ Deprecated | ABI coder v2 (default since 0.8.0) |
| Virtual modifiers | β οΈ Deprecated | Virtual functions |
transfer() / send() | β οΈ Deprecated | .call{value: amount}("") |
| Contract type comparisons | β οΈ Deprecated | Address comparisons |
You should already be avoiding all of these in new code, but youβll encounter them when reading older DeFi protocols.
β οΈ Critical:
.transfer()and.send()have a fixed 2300 gas stipend, which breaks with some smart contract wallets and modern opcodes. Always use.call{value: amount}("")instead.
π― Build Exercise: TransientGuard
Workspace: workspace/src/part1/module1/exercise2-transient-guard/ β starter file: TransientGuard.sol, tests: TransientGuard.t.sol
- Implement
TransientReentrancyGuardusing thetransientkeyword (0.8.28+ syntax) - Implement the same guard using raw
tstore/tloadassembly (0.8.24+ syntax) - Write a Foundry test that demonstrates the reentrancy protection works:
- Create an attacker contract that attempts reentrant calls
- Verify the guard blocks the attack
- Compare gas costs between:
- Your transient guard
- OpenZeppelinβs storage-based
ReentrancyGuard - A raw storage implementation
π― Goal: Understand both the high-level transient syntax and the underlying opcodes. The gas comparison gives you a concrete sense of why this matters.
π Summary: Bleeding Edge Features
β Covered:
- Transient storage (0.8.24+) β 50-200x cheaper than regular storage
transientkeyword (0.8.28+) β high-level syntax for transient storage- Pectra/Prague EVM target (0.8.30+) β new default compiler target
- Solidity 0.9.0 deprecations β what to avoid in new code
Key takeaway: Transient storage is the biggest gas optimization since EIP-2929. Understanding it is essential for reading modern DeFi code (especially Uniswap V4) and building gas-efficient protocols.
π Cross-Module Concept Links
β Forward to Part 1 (where these concepts appear next):
- Module 2 (EVM Changes): TSTORE/TLOAD opcodes underpin the
transientkeyword; EVM target versioning affects available opcodes - Module 3 (Token Approvals): Permit/Permit2 build on the approve model covered here; EIP-712 signatures introduced
- Module 4 (Account Abstraction): EIP-7702 delegate transactions use
abi.encodeCallfor type-safe calldata encoding - Module 5 (Foundry): All exercises use Foundry; fork testing and gas snapshots for the transient storage comparison
- Module 6 (Proxy Patterns):
delegatecallencoding usesabi.encodeCall; storage layout awareness connects to UDVTs and bit-packing - Module 7 (Deployment): Compiler
--evm-versionsetting connects to Pectra/Prague target discussion
β Forward to Part 2 (where these patterns become foundational):
| Concept from Module 1 | Where it appears in Part 2 | How itβs used |
|---|---|---|
unchecked + mulDiv | M2 (AMMs) β Uniswap FullMath | 512-bit math for constant product calculations, LP share minting |
| UDVTs + BalanceDelta | M2 (AMMs) β Uniswap V4 | PoolId, Currency, BalanceDelta throughout the V4 codebase |
| Transient storage / flash accounting | M2 (AMMs) β Uniswap V4 | Delta tracking across multi-hop swaps, settled at end of tx |
| ERC-4626 share math | M7 (Vaults & Yield) | convertToShares / convertToAssets uses mulDiv rounding |
| Custom errors | M1 (Token Mechanics) β SafeERC20 | Error propagation in cross-protocol token interactions |
abi.encodeCall | M5 (Flash Loans) | Flash loan callback encoding, multicall batch construction |
π Production Study Order
Read these in order to build understanding progressively:
| Order | File | What to study | Difficulty | Lines |
|---|---|---|---|---|
| 1 | OZ Math.sol β mulDiv | Clean mulDiv implementation β understand the concept without assembly optimizations | ββ | ~50 lines |
| 2 | Uniswap V4 FullMath.sol | Assembly-optimized mulDiv β compare with OZ version, note the unchecked blocks | βββ | ~120 lines |
| 3 | Uniswap V4 PoolId.sol | Simplest UDVT β type PoolId is bytes32, one function | β | ~10 lines |
| 4 | Uniswap V4 Currency.sol | UDVT with custom operators β type Currency is address, native ETH handling | ββ | ~40 lines |
| 5 | Uniswap V4 BalanceDelta.sol | Advanced UDVT β bit-packed int128 pair with custom +, -, == operators | βββ | ~60 lines |
| 6 | OZ ReentrancyGuardTransient.sol | Production transient storage β compare with classic ReentrancyGuard.sol | β | ~30 lines |
| 7 | Aave V3 Errors.sol | Centralized error library β 60+ string constant revert reasons (pre-custom-error pattern), study the organizational principle | β | ~100 lines |
Donβt get stuck on: Assembly optimizations in FullMath β understand the mulDiv concept from OZ first, then see how Uniswap optimizes it.
π Resources
Core Solidity Documentation
- 0.8.0 Breaking Changes β complete list of all changes from 0.7
- Solidity Blog - Release Announcements β every version explained
- Solidity Changelog β detailed version history
Checked Arithmetic & Unchecked
- Solidity docs β Checked or Unchecked Arithmetic
- Uniswap V4 FullMath.sol β production
uncheckedusage for 512-bit math
Custom Errors
- Solidity docs β Errors
- Solidity blog β βCustom Errors in Solidityβ β introduction, gas savings, ABI encoding
- Aave V3 Errors.sol β centralized error library pattern
User-Defined Value Types
- Solidity docs β UDVTs
- Uniswap V4 PoolId.sol β
type PoolId is bytes32 - Uniswap V4 Currency.sol β
type Currency is addresswith custom operators - Uniswap V4 BalanceDelta.sol β
type BalanceDelta is int256with bit-packed int128 pair
ABI Encoding
Transient Storage
- Solidity blog β βTransient Storage Opcodes in Solidity 0.8.24β β EIP-1153, use cases, risks
- Solidity blog β 0.8.28 Release β full
transientkeyword support - EIP-1153: Transient Storage Opcodes β the EIP specification
- OpenZeppelin ReentrancyGuardTransient.sol β production implementation
OpenZeppelin v5
- Introducing OpenZeppelin Contracts 5.0 β all breaking changes, migration from v4
- OpenZeppelin Contracts 5.x docs
- Changelog with migration guide
Solidity 0.9.0 Deprecations
- Solidity blog β 0.8.31 Release β first deprecation warnings for 0.9.0
Security & Analysis Tools
- Slither Detector Documentation β automated security checks for modern Solidity features
Navigation: Start of Part 1 | Next: Module 2 - EVM Changes β