انتقل إلى المحتوى الرئيسي
BlockchainMar 28, 2026

Deep EVM #8: Building a Token Swap in Pure Yul

OS
Open Soft Team

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 swap
  • owner() — 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

ComponentSolidityYulSavings
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

ComponentSolidityYulSavings
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)

ComponentSolidityYulSavings
ABI encoding~120 gas~30 gas (3x mstore)90 gas
Call + return check~200 gas~80 gas120 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

ComponentSolidityYulSavings
ABI encoding~80 gas~9 gas (1x mstore)71 gas
Return data decoding~100 gas~12 gas (2x mload)88 gas
Memory management~30 gas0 gas30 gas

5. Uniswap V2 Math

ComponentSolidityYulSavings
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

ComponentSolidityYulSavings
ABI encoding (with dynamic bytes)~250 gas~45 gas (5x mstore)205 gas
Memory allocation~30 gas0 gas30 gas
Revert forwarding~60 gas~20 gas40 gas

Total Gas Savings

CategorySavings
Function dispatch39 gas
Access control105 gas
ERC-20 transfer240 gas
getReserves call189 gas
token0 call~120 gas
Arithmetic142 gas
Swap call275 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:

MetricSolidityYul
Runtime bytecode~1,200 bytes~480 bytes
Deployment gas~240,000~96,000
Savings60% 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:

  1. Validate all calldata — Mask addresses to 20 bytes, check array bounds.
  2. Check all return values — Every CALL/STATICCALL return value must be checked.
  3. Handle non-standard tokens — USDT returns nothing on transfer; your code must handle both cases.
  4. Reentrancy — Without Solidity’s built-in modifiers, you must implement guards manually.
  5. Arithmetic overflow — Yul does not check. Verify that your input domain makes overflow impossible.
  6. Memory corruption — Track your memory layout carefully. Overlapping writes are silent bugs.
  7. 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:

  1. The EVM is a stack machine with 256-bit words, ~140 opcodes, and gas metering.
  2. Storage is expensive — 20000 gas for new entries, 2100 for cold reads. Minimize state access.
  3. Gas optimization is architectural — Storage packing, calldata over memory, access lists.
  4. Security comes from understanding execution flow — CEI pattern, reentrancy guards, return value checks.
  5. Yul gives you opcode-level control with readable syntax.
  6. Memory management is manual — Free memory pointer, scratch space, expansion costs.
  7. Loops multiply everything — Optimize the loop body first, then the loop structure.
  8. 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.