Advanced

MEV Mitigation — Project — MEV-aware swap intent forwarder

Avoid getting exploited at the mempool layer.

Developer Advanced
7/8 — MEV Mitigation — Project — MEV-aware swap intent forwarder

What to writeA signed intent forwarder that enforces slippage/deadlines, prevents replays, and only lets a trusted relayer submit swaps.

Cryptography is about how to maintain control over information.
— Shafi Goldwasser
  1. State & domain
    Router, relayer, nonces, and EIP-712 domain for intents.
  2. Intent hash
    Hash user intent (tokens, amounts, minOut, deadline, nonce, relayer).
  3. Execute
    Only relayer calls; verify signature, nonce, deadlines; perform swap and enforce minOut.
  4. Admin
    Rotate relayer; expose events for monitoring.

Start with state, domain separator, and nonce tracking.

contract MEVProtectedSwap {
    using ECDSA for bytes32;

    IRouter public immutable router;
    address public relayer;
    mapping(address => uint256) public nonces;

    bytes32 public constant INTENT_TYPEHASH = keccak256(
        "SwapIntent(address user,address tokenIn,address tokenOut,uint256 amountIn,uint256 minOut,uint256 deadline,uint256 nonce,address relayer)"
    );
    bytes32 public immutable DOMAIN_SEPARATOR;

    event IntentExecuted(address indexed user, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut, uint256 nonce, bytes32 digest);
    event RelayerUpdated(address indexed oldRelayer, address indexed newRelayer);

    constructor(address _router, address _relayer) {
        router = IRouter(_router);
        relayer = _relayer;
        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes("MEVProtectedSwap")),
                keccak256(bytes("1")),
                block.chainid,
                address(this)
            )
        );
    }

Hash and verify intents (user signs; relayer submits).

struct SwapIntent {
    address user;
    address tokenIn;
    address tokenOut;
    uint256 amountIn;
    uint256 minOut;
    uint256 deadline;
    uint256 nonce;
    address relayer;
}

function _hashIntent(SwapIntent memory i) internal view returns (bytes32) {
    return keccak256(
        abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            keccak256(
                abi.encode(
                    INTENT_TYPEHASH,
                    i.user,
                    i.tokenIn,
                    i.tokenOut,
                    i.amountIn,
                    i.minOut,
                    i.deadline,
                    i.nonce,
                    i.relayer
                )
            )
        )
    );
}

Execute swap: only relayer submits; signature, nonce, deadlines, and minOut enforced.

function executeIntent(SwapIntent calldata i, bytes calldata sig, address[] calldata path) external returns (uint256 out) {
    require(msg.sender == relayer && i.relayer == relayer, "relayer");
    require(block.timestamp <= i.deadline, "expired");
    require(i.nonce == nonces[i.user], "bad nonce");

    bytes32 digest = _hashIntent(i);
    address signer = digest.recover(sig);
    require(signer == i.user, "bad sig");

    nonces[i.user] = i.nonce + 1;
    IERC20(i.tokenIn).transferFrom(i.user, address(this), i.amountIn);
    IERC20(i.tokenIn).approve(address(router), i.amountIn);

    uint256[] memory amounts = router.swapExactTokensForTokens(i.amountIn, i.minOut, path, i.user, i.deadline);
    out = amounts[amounts.length - 1];
    require(out >= i.minOut, "slippage");

    emit IntentExecuted(i.user, i.tokenIn, i.tokenOut, i.amountIn, out, i.nonce, digest);
}

Keep rotation simple so you can swap relayers if they fail or get censored.

function setRelayer(address r) external {
    require(msg.sender == relayer, "only relayer");
    emit RelayerUpdated(relayer, r);
    relayer = r;
}

Full reference (interfaces + contract) for copy/paste:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

interface IRouter {
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}

contract MEVProtectedSwap {
    using ECDSA for bytes32;

    IRouter public immutable router;
    address public relayer;
    mapping(address => uint256) public nonces;

    bytes32 public constant INTENT_TYPEHASH = keccak256(
        "SwapIntent(address user,address tokenIn,address tokenOut,uint256 amountIn,uint256 minOut,uint256 deadline,uint256 nonce,address relayer)"
    );
    bytes32 public immutable DOMAIN_SEPARATOR;

    event IntentExecuted(address indexed user, address indexed tokenIn, address indexed tokenOut, uint256 amountIn, uint256 amountOut, uint256 nonce, bytes32 digest);
    event RelayerUpdated(address indexed oldRelayer, address indexed newRelayer);

    constructor(address _router, address _relayer) {
        router = IRouter(_router);
        relayer = _relayer;
        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes("MEVProtectedSwap")),
                keccak256(bytes("1")),
                block.chainid,
                address(this)
            )
        );
    }

    struct SwapIntent {
        address user;
        address tokenIn;
        address tokenOut;
        uint256 amountIn;
        uint256 minOut;
        uint256 deadline;
        uint256 nonce;
        address relayer;
    }

    function _hashIntent(SwapIntent memory i) internal view returns (bytes32) {
        return keccak256(
            abi.encodePacked(
                "\x19\x01",
                DOMAIN_SEPARATOR,
                keccak256(
                    abi.encode(
                        INTENT_TYPEHASH,
                        i.user,
                        i.tokenIn,
                        i.tokenOut,
                        i.amountIn,
                        i.minOut,
                        i.deadline,
                        i.nonce,
                        i.relayer
                    )
                )
            )
        );
    }

    function executeIntent(SwapIntent calldata i, bytes calldata sig, address[] calldata path) external returns (uint256 out) {
        require(msg.sender == relayer && i.relayer == relayer, "relayer");
        require(block.timestamp <= i.deadline, "expired");
        require(i.nonce == nonces[i.user], "bad nonce");

        bytes32 digest = _hashIntent(i);
        address signer = digest.recover(sig);
        require(signer == i.user, "bad sig");

        nonces[i.user] = i.nonce + 1;
        IERC20(i.tokenIn).transferFrom(i.user, address(this), i.amountIn);
        IERC20(i.tokenIn).approve(address(router), i.amountIn);

        uint256[] memory amounts = router.swapExactTokensForTokens(i.amountIn, i.minOut, path, i.user, i.deadline);
        out = amounts[amounts.length - 1];
        require(out >= i.minOut, "slippage");

        emit IntentExecuted(i.user, i.tokenIn, i.tokenOut, i.amountIn, out, i.nonce, digest);
    }

    function setRelayer(address r) external {
        require(msg.sender == relayer, "only relayer");
        emit RelayerUpdated(relayer, r);
        relayer = r;
    }
}

Try itTests: bad sig/nonce/relayer reverts; expired deadline reverts; minOut enforced; relayer rotation works; private submission hides tx from mempool.

← Previous section
Next section →