Deep EVM #8: Building a Token Swap in Pure Yul
Engineering Team
From Theory to Practice
Over the past seven articles, we have built a comprehensive understanding of the EVM: opcodes and the stack machine, the memory model, gas mechanics, security primitives, Yul syntax, memory management, and loop optimization. Now we put it all together.
In this article, we build a complete token swap contract in pure Yul that executes a Uniswap V2 swap. Then we compare it line-by-line with the Solidity equivalent to quantify exactly where the gas savings come from.
The Contract: SimpleSwap
Our contract does one thing: swap an exact amount of token A for token B through a Uniswap V2 pair. It exposes two functions:
swap(address pair, address tokenIn, uint256 amountIn, uint256 amountOutMin, address to)— Execute the swapowner()— Return the contract owner (for access control)
The Solidity Version (Reference)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IUniswapV2Pair {
function getReserves() external view returns (
uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast
);
function token0() external view returns (address);
function swap(
uint256 amount0Out, uint256 amount1Out,
address to, bytes calldata data
) external;
}
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
}
contract SimpleSwapSolidity {
address public immutable owner;
constructor() {
owner = msg.sender;
}
function swap(
address pair,
address tokenIn,
uint256 amountIn,
uint256 amountOutMin,
address to
) external {
require(msg.sender == owner, "Not owner");
// Transfer tokens to the pair
IERC20(tokenIn).transfer(pair, amountIn);
// Get reserves
(uint112 reserve0, uint112 reserve1,) =
IUniswapV2Pair(pair).getReserves();
// Determine swap direction
address token0 = IUniswapV2Pair(pair).token0();
bool isToken0 = tokenIn == token0;
(uint256 reserveIn, uint256 reserveOut) = isToken0
? (uint256(reserve0), uint256(reserve1))
: (uint256(reserve1), uint256(reserve0));
// Calculate output amount (Uniswap V2 formula)
uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
uint256 amountOut = numerator / denominator;
require(amountOut >= amountOutMin, "Insufficient output");
// Execute swap
(uint256 amount0Out, uint256 amount1Out) = isToken0
? (uint256(0), amountOut)
: (amountOut, uint256(0));
IUniswapV2Pair(pair).swap(
amount0Out, amount1Out, to, ""
);
}
}
This is clean, readable Solidity. Now let us see the Yul version.
The Pure Yul Version
object "SimpleSwap" {
code {
// Constructor: store owner in slot 0
sstore(0, caller())
// Deploy runtime code
let size := datasize("runtime")
let offset := dataoffset("runtime")
datacopy(0, offset, size)
return(0, size)
}
object "runtime" {
code {
// No calldata = receive ETH (reject)
if iszero(calldatasize()) { revert(0, 0) }
// Extract function selector (first 4 bytes)
let selector := shr(224, calldataload(0))
switch selector
// owner() -> address
case 0x8da5cb5b {
mstore(0x00, sload(0))
return(0x00, 0x20)
}
// swap(address,address,uint256,uint256,address)
// Selector: 0x6d7e80b7
case 0x6d7e80b7 {
// Access control: only owner
if iszero(eq(caller(), sload(0))) {
mstore(0x00, 0x08c379a0) // Error(string) selector
mstore(0x04, 0x20)
mstore(0x24, 9)
mstore(0x44, "Not owner")
revert(0x00, 0x64)
}
// Parse calldata
let pair := and(calldataload(0x04), 0xffffffffffffffffffffffffffffffffffffffff)
let tokenIn := and(calldataload(0x24), 0xffffffffffffffffffffffffffffffffffffffff)
let amountIn := calldataload(0x44)
let amountOutMin := calldataload(0x64)
let to := and(calldataload(0x84), 0xffffffffffffffffffffffffffffffffffffffff)
// === Step 1: Transfer tokens to pair ===
// transfer(address,uint256) selector: 0xa9059cbb
mstore(0x00, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
mstore(0x04, pair)
mstore(0x24, amountIn)
let success := call(gas(), tokenIn, 0, 0x00, 0x44, 0x00, 0x20)
// Handle non-standard ERC20 (USDT returns nothing)
if iszero(and(
success,
or(
iszero(returndatasize()),
and(gt(returndatasize(), 31), mload(0x00))
)
)) {
revert(0, 0)
}
// === Step 2: Get reserves ===
// getReserves() selector: 0x0902f1ac
mstore(0x00, 0x0902f1ac00000000000000000000000000000000000000000000000000000000)
success := staticcall(gas(), pair, 0x00, 0x04, 0x00, 0x60)
if iszero(success) { revert(0, 0) }
let reserve0 := mload(0x00)
let reserve1 := mload(0x20)
// === Step 3: Get token0 for swap direction ===
// token0() selector: 0x0dfe1681
mstore(0x00, 0x0dfe168100000000000000000000000000000000000000000000000000000000)
success := staticcall(gas(), pair, 0x00, 0x04, 0x00, 0x20)
if iszero(success) { revert(0, 0) }
let token0 := and(mload(0x00), 0xffffffffffffffffffffffffffffffffffffffff)
let isToken0 := eq(tokenIn, token0)
// Determine reserveIn and reserveOut
let reserveIn := reserve1
let reserveOut := reserve0
if isToken0 {
reserveIn := reserve0
reserveOut := reserve1
}
// === Step 4: Calculate amountOut (Uniswap V2 formula) ===
// amountOut = (amountIn * 997 * reserveOut) /
// (reserveIn * 1000 + amountIn * 997)
let amountInWithFee := mul(amountIn, 997)
let numerator := mul(amountInWithFee, reserveOut)
let denominator := add(mul(reserveIn, 1000), amountInWithFee)
let amountOut := div(numerator, denominator)
// Slippage check
if lt(amountOut, amountOutMin) {
mstore(0x00, 0x08c379a0)
mstore(0x04, 0x20)
mstore(0x24, 19)
mstore(0x44, "Insufficient output")
revert(0x00, 0x64)
}
// === Step 5: Execute swap ===
// swap(uint256,uint256,address,bytes)
// Selector: 0x022c0d9f
let amount0Out := 0
let amount1Out := amountOut
if isToken0 {
amount0Out := 0
amount1Out := amountOut
}
if iszero(isToken0) {
amount0Out := amountOut
amount1Out := 0
}
mstore(0x00, 0x022c0d9f00000000000000000000000000000000000000000000000000000000)
mstore(0x04, amount0Out) // amount0Out
mstore(0x24, amount1Out) // amount1Out
mstore(0x44, to) // to address
mstore(0x64, 0x80) // offset to bytes data
mstore(0x84, 0x00) // bytes length = 0 (empty)
success := call(gas(), pair, 0, 0x00, 0xa4, 0x00, 0x00)
if iszero(success) {
// Forward revert data
returndatacopy(0x00, 0x00, returndatasize())
revert(0x00, returndatasize())
}
// Return amountOut
mstore(0x00, amountOut)
return(0x00, 0x20)
}
// Unknown function selector: revert
default { revert(0, 0) }
}
}
}
Line-by-Line Gas Comparison
Let us break down where the Yul version saves gas compared to Solidity:
1. Function Dispatch
| Component | Solidity | Yul | Savings |
|---|---|---|---|
| Selector extraction | ~30 gas (compiler overhead) | ~13 gas (shr + calldataload) | 17 gas |
| Function matching | ~44 gas (2 comparisons with binary search) | ~22 gas (direct switch) | 22 gas |
Solidity generates additional code for function dispatch: argument decoding, validation, and routing. The Yul version goes straight from selector to implementation.
2. Access Control Check
| Component | Solidity | Yul | Savings |
|---|---|---|---|
| require(msg.sender == owner) | ~2220 gas (SLOAD + comparison + revert string) | ~2115 gas (SLOAD + eq + iszero) | 105 gas |
The Solidity version ABI-encodes the revert reason string at runtime. The Yul version stores the raw bytes directly.
3. Token Transfer (ERC-20 call)
| Component | Solidity | Yul | Savings |
|---|---|---|---|
| ABI encoding | ~120 gas | ~30 gas (3x mstore) | 90 gas |
| Call + return check | ~200 gas | ~80 gas | 120 gas |
| Memory allocation | ~30 gas (free pointer) | 0 gas (scratch space) | 30 gas |
Solidity uses abi.encodeWithSelector, which involves free memory pointer reads/writes, bounds checking, and dynamic encoding. The Yul version writes directly to scratch space.
4. getReserves() Staticcall
| Component | Solidity | Yul | Savings |
|---|---|---|---|
| ABI encoding | ~80 gas | ~9 gas (1x mstore) | 71 gas |
| Return data decoding | ~100 gas | ~12 gas (2x mload) | 88 gas |
| Memory management | ~30 gas | 0 gas | 30 gas |
5. Uniswap V2 Math
| Component | Solidity | Yul | Savings |
|---|---|---|---|
| amountIn * 997 | ~40 gas (checked mul) | ~8 gas (mul) | 32 gas |
| numerator calculation | ~40 gas (checked mul) | ~8 gas (mul) | 32 gas |
| denominator calculation | ~80 gas (checked mul + add) | ~14 gas (mul + add) | 66 gas |
| Division | ~20 gas (checked div) | ~8 gas (div) | 12 gas |
Solidity 0.8+ overflow checks add ~30 gas per arithmetic operation. In this context, overflow is impossible (reserves are uint112, amounts are bounded by token supply), so the checks are pure overhead.
6. Swap Call
| Component | Solidity | Yul | Savings |
|---|---|---|---|
| ABI encoding (with dynamic bytes) | ~250 gas | ~45 gas (5x mstore) | 205 gas |
| Memory allocation | ~30 gas | 0 gas | 30 gas |
| Revert forwarding | ~60 gas | ~20 gas | 40 gas |
Total Gas Savings
| Category | Savings |
|---|---|
| Function dispatch | 39 gas |
| Access control | 105 gas |
| ERC-20 transfer | 240 gas |
| getReserves call | 189 gas |
| token0 call | ~120 gas |
| Arithmetic | 142 gas |
| Swap call | 275 gas |
| Deployment size | ~60% smaller bytecode |
| Total runtime savings | ~1,110 gas |
For a typical Uniswap V2 swap that costs ~110,000 gas total, saving 1,110 gas is approximately a 1% improvement. That might not sound like much, but for an MEV bot executing thousands of swaps per day, at an average gas price of 30 gwei and ETH at $3,000:
1,110 gas * 30 gwei * $3,000/ETH = $0.0999 per swap
$0.0999 * 1,000 swaps/day = $99.90/day
$99.90 * 365 = $36,463/year
And that is for a single swap function. An MEV bot with multiple strategies, each saving 1-5% on gas, can save hundreds of thousands of dollars annually.
Deployment Cost
The Yul version has a significantly smaller bytecode:
| Metric | Solidity | Yul |
|---|---|---|
| Runtime bytecode | ~1,200 bytes | ~480 bytes |
| Deployment gas | ~240,000 | ~96,000 |
| Savings | — | 60% smaller, 60% cheaper to deploy |
Smaller bytecode also means lower intrinsic gas for EXTCODESIZE and EXTCODECOPY operations when other contracts interact with yours.
Testing the Yul Contract
You can compile and test Yul contracts with Foundry:
# Compile Yul to bytecode
solc --strict-assembly --optimize --optimize-runs 200 SimpleSwap.yul --bin
# Or use Foundry's Yul support in tests
// test/SimpleSwap.t.sol
contract SimpleSwapTest is Test {
address swap;
function setUp() public {
// Deploy Yul contract from bytecode
bytes memory bytecode = hex"..."; // compiled bytecode
assembly {
swap := create(0, add(bytecode, 0x20), mload(bytecode))
}
}
function testOwner() public {
(bool s, bytes memory data) = swap.staticcall(
abi.encodeWithSignature("owner()")
);
assertTrue(s);
assertEq(abi.decode(data, (address)), address(this));
}
function testSwap() public {
// Fork mainnet, use real Uniswap V2 pair
// ...
}
}
Security Considerations for Pure Yul Contracts
Pure Yul contracts bypass all of Solidity’s safety features. Before deploying:
- Validate all calldata — Mask addresses to 20 bytes, check array bounds.
- Check all return values — Every CALL/STATICCALL return value must be checked.
- Handle non-standard tokens — USDT returns nothing on transfer; your code must handle both cases.
- Reentrancy — Without Solidity’s built-in modifiers, you must implement guards manually.
- Arithmetic overflow — Yul does not check. Verify that your input domain makes overflow impossible.
- Memory corruption — Track your memory layout carefully. Overlapping writes are silent bugs.
- Formal verification — Consider using tools like Certora or Halmos to verify critical invariants.
When Pure Yul Is Worth It
Pure Yul contracts make sense for:
- MEV bot execution contracts — Every gas unit is profit margin
- Minimal proxies — EIP-1167 is pure bytecode by design
- Precompile wrappers — Thin wrappers around ecrecover, modexp, etc.
- Gas-golfing competitions — Pushing the EVM to its limits
Pure Yul is NOT worth it for:
- Application contracts — The audit cost exceeds the gas savings
- Governance contracts — Readability and correctness matter more than gas
- Any contract handling user deposits — The risk of a subtle bug losing funds is too high
Beyond Yul: The Huff Frontier
If Yul gives you 80-90% of maximum gas efficiency, Huff gives you 95-100%. Huff removes even Yul’s abstractions — no variables, no functions, just raw stack manipulation and macros. The SimpleSwap contract in Huff would save an additional 200-500 gas by eliminating Yul’s variable management overhead.
But that is a topic for another series. For now, you have the complete toolkit: EVM fundamentals, memory management, gas optimization, security awareness, and practical Yul skills to build gas-efficient contracts.
Conclusion
This series has taken you from EVM basics to building production-grade Yul code. The key takeaways:
- The EVM is a stack machine with 256-bit words, ~140 opcodes, and gas metering.
- Storage is expensive — 20000 gas for new entries, 2100 for cold reads. Minimize state access.
- Gas optimization is architectural — Storage packing, calldata over memory, access lists.
- Security comes from understanding execution flow — CEI pattern, reentrancy guards, return value checks.
- Yul gives you opcode-level control with readable syntax.
- Memory management is manual — Free memory pointer, scratch space, expansion costs.
- Loops multiply everything — Optimize the loop body first, then the loop structure.
- Pure Yul saves 1-5% gas over optimized Solidity — worth millions for high-volume operations.
The EVM is a simple machine. Master its rules, and you master on-chain computation.