EthZapRouter

0x8776ce66cb1d56b973a118a58b1d333362359418

Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
6,338 bytes
ABI entries
29
7 read · 9 write
License
none

Contract information

Address
0x8776ce66cb1d56b973a118a58b1d333362359418
Chain
Robinhood Chain (4663)
Compiler
v0.8.24+commit.e11b9ed9
Optimization
Enabled
Creation tx
0x26d7fa5670…8f18ac977b

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (7)

bonding()address
ethUsdgKey()address, address, uint24, int24, address
owner()address
pendingOwner()address
poolManager()address
usdg()address
zap()address

Events (3)

EthUsdgPoolSetOwnershipTransferStartedOwnershipTransferred

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "zap_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "usdg_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "poolManager_",
        "type": "address"
      },
      {
        "components": [
          {
            "internalType": "address",
            "name": "currency0",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "currency1",
            "type": "address"
          },
          {
            "internalType": "uint24",
            "name": "fee",
            "type": "uint24"
          },
          {
            "internalType": "int24",
            "name": "tickSpacing",
            "type": "int24"
          },
          {
            "internalType": "address",
            "name": "hooks",
            "type": "address"
          }
        ],
        "internalType": "struct PoolKey",
        "name": "ethUsdgKey_",
        "type": "tuple"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "EthTransferFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NoPendingSwap",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotPoolManager",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "SlippageExceeded",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "components": [
          {
            "internalType": "address",
            "name": "currency0",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "currency1",
            "type": "address"
          },
          {
            "internalType": "uint24",
            "name": "fee",
            "type": "uint24"
          },
          {
            "internalType": "int24",
            "name": "tickSpacing",
            "type": "int24"
          },
          {
            "internalType": "address",
            "name": "hooks",
            "type": "address"
          }
        ],
        "indexed": false,
        "internalType": "struct PoolKey",
        "name": "key",
        "type": "tuple"
      }
    ],
    "name": "EthUsdgPoolSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferStarted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "acceptOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "bonding",
    "outputs": [
      {
        "internalType": "contract Bonding",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "coin",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "minTokensOut",
        "type": "uint256"
      }
    ],
    "name": "buyWithEth",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "tokensOut",
        "type": "uint256"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "string",
            "name": "name",
            "type": "string"
          },
          {
            "internalType": "string",
            "name": "symbol",
            "type": "string"
          },
          {
            "internalType": "string",
            "name": "metadataURI",
            "type": "string"
          },
          {
            "internalType": "address",
            "name": "asset",
            "type": "address"
          }
        ],
        "internalType": "struct Zap.CreateSpotParams",
        "name": "p",
        "type": "tuple"
      },
      {
        "internalType": "uint256",
        "name": "minTokensOut",
        "type": "uint256"
      }
    ],
    "name": "createTokenSpotWithEth",
    "outputs": [
      {
        "internalType": "address",
        "name": "coin",
        "type": "address"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "string",
            "name": "name",
            "type": "string"
          },
          {
            "internalType": "string",
            "name": "symbol",
            "type": "string"
          },
          {
            "internalType": "string",
            "name": "metadataURI",
            "type": "string"
          },
          {
            "internalType": "address",
            "name": "vault",
            "type": "address"
          },
          {
            "internalType": "uint256",
            "name": "seriesId",
            "type": "uint256"
          }
        ],
        "internalType": "struct Zap.CreateParams",
        "name": "p",
        "type": "tuple"
      },
      {
        "internalType": "uint256",
        "name": "minTokensOut",
        "type": "uint256"
      }
    ],
    "name": "createTokenWithEth",
    "outputs": [
      {
        "internalType": "address",
        "name": "coin",
        "type": "address"
      }
    ],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "ethUsdgKey",
    "outputs": [
      {
        "internalType": "address",
        "name": "currency0",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "currency1",
        "type": "address"
      },
      {
        "internalType": "uint24",
        "name": "fee",
        "type": "uint24"
      },
      {
        "internalType": "int24",
        "name": "tickSpacing",
        "type": "int24"
      },
      {
        "internalType": "address",
        "name": "hooks",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "pendingOwner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolManager",
    "outputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "coin",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokensIn",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "minEthOut",
        "type": "uint256"
      }
    ],
    "name": "sellForEth",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "ethOut",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "address",
            "name": "currency0",
            "type": "address"
          },
          {
            "internalType": "address",
            "name": "currency1",
            "type": "address"
          },
          {
            "internalType": "uint24",
            "name": "fee",
            "type": "uint24"
          },
          {
            "internalType": "int24",
            "name": "tickSpacing",
            "type": "int24"
          },
          {
            "internalType": "address",
            "name": "hooks",
            "type": "address"
          }
        ],
        "internalType": "struct PoolKey",
        "name": "key",
        "type": "tuple"
      }
    ],
    "name": "setEthUsdgPool",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "unlockCallback",
    "outputs": [
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "usdg",
    "outputs": [
      {
        "internalType": "contract IERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "zap",
    "outputs": [
      {
        "internalType": "contract Zap",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

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

import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IPoolManager, PoolKey, SwapParams, IUnlockCallback} from "../interfaces/IUniswapV4.sol";
import {V4Lib} from "./V4Lib.sol";
import {Zap} from "./Zap.sol";
import {Bonding} from "./Bonding.sol";

/// @notice Periphery router: buy/sell/create EXPO coins with native ETH by wrapping a single-hop
///         ETH<->USDG V4 swap around the USDG-denominated Zap. Holds no funds between calls. The
///         Zap/Bonding core is untouched — this only relays. See docs/superpowers/specs/
///         2026-07-22-expo-eth-zap-router-design.md.
contract EthZapRouter is Ownable2Step, ReentrancyGuard, IUnlockCallback {
    using SafeERC20 for IERC20;

    error NotPoolManager();
    error NoPendingSwap();
    error SlippageExceeded();
    error EthTransferFailed();

    Zap public immutable zap;
    Bonding public immutable bonding;
    IERC20 public immutable usdg;
    IPoolManager public immutable poolManager;
    PoolKey public ethUsdgKey; // currency0 = address(0) (native ETH), currency1 = USDG

    bool private _swapPending;
    struct SwapReq { PoolKey key; address tokenIn; uint256 amountIn; }

    event EthUsdgPoolSet(PoolKey key);

    constructor(address zap_, address usdg_, address poolManager_, PoolKey memory ethUsdgKey_)
        Ownable(msg.sender)
    {
        zap = Zap(zap_);
        bonding = Zap(zap_).bonding();
        usdg = IERC20(usdg_);
        poolManager = IPoolManager(poolManager_);
        ethUsdgKey = ethUsdgKey_;
    }

    /// @notice Repoint the ETH/USDG routing pool (e.g. to a deeper fee tier) if liquidity moves.
    function setEthUsdgPool(PoolKey calldata key) external onlyOwner {
        ethUsdgKey = key;
        emit EthUsdgPoolSet(key);
    }

    /// @notice Native ETH in -> curve/vault coins out. `minTokensOut` covers BOTH legs
    ///         (ETH->USDG swap + Zap.buy), so no per-hop slippage bound is needed.
    function buyWithEth(address coin, uint256 minTokensOut)
        external
        payable
        nonReentrant
        returns (uint256 tokensOut)
    {
        uint256 usdgOut = _swapExactIn(ethUsdgKey, address(0), msg.value);
        usdg.forceApprove(address(zap), usdgOut);
        uint256 before = IERC20(coin).balanceOf(address(this));
        zap.buy(coin, usdgOut, minTokensOut); // coins land on this router (it is Zap's msg.sender)
        tokensOut = IERC20(coin).balanceOf(address(this)) - before;
        // I-2 fix (final review): Zap._buyCurve skips its own minTokensOut check on the zero-fill
        // graduation-trigger edge (xUsed==0, Zap.sol:307-308) and just refunds the USDG instead of
        // reverting. Without this guard a below-min (including zero-fill) buy would silently hand
        // the user 0 coins + a USDG refund instead of their ETH back, with no slippage enforcement
        // at all. Enforce the single end-to-end bound here so that case reverts cleanly instead.
        if (tokensOut < minTokensOut) revert SlippageExceeded();
        if (tokensOut > 0) IERC20(coin).safeTransfer(msg.sender, tokensOut);
        _refundUsdgDust(); // capped buys refund surplus USDG to this router — pass it back
    }

    /// @notice Coins in -> native ETH out. `minEthOut` is the single end-to-end slippage bound.
    ///         User must approve this router for `tokensIn` of `coin` first.
    function sellForEth(address coin, uint256 tokensIn, uint256 minEthOut)
        external
        nonReentrant
        returns (uint256 ethOut)
    {
        IERC20(coin).safeTransferFrom(msg.sender, address(this), tokensIn);
        IERC20(coin).forceApprove(address(zap), tokensIn);
        uint256 before = usdg.balanceOf(address(this));
        zap.sell(coin, tokensIn, 0); // USDG lands on this router; minUsdgOut=0, minEthOut guards the whole path
        uint256 usdgOut = usdg.balanceOf(address(this)) - before;
        ethOut = _swapExactIn(ethUsdgKey, address(usdg), usdgOut);
        if (ethOut < minEthOut) revert SlippageExceeded();
        (bool ok, ) = msg.sender.call{value: ethOut}("");
        if (!ok) revert EthTransferFailed();
    }

    /// @notice Create a LEVERAGE coin, seeding the creator's initial buy with native ETH. The
    ///         router is briefly the on-chain creator (Zap hardcodes msg.sender), then reassigns
    ///         creator to the real user via Bonding.transferCreator.
    function createTokenWithEth(Zap.CreateParams calldata p, uint256 minTokensOut)
        external
        payable
        nonReentrant
        returns (address coin)
    {
        uint256 seedUsdg = _swapExactIn(ethUsdgKey, address(0), msg.value);
        usdg.forceApprove(address(zap), seedUsdg);
        coin = zap.createToken(p, seedUsdg, minTokensOut);
        _finishCreate(coin);
    }

    /// @notice Same as createTokenWithEth for a SPOT coin.
    function createTokenSpotWithEth(Zap.CreateSpotParams calldata p, uint256 minTokensOut)
        external
        payable
        nonReentrant
        returns (address coin)
    {
        uint256 seedUsdg = _swapExactIn(ethUsdgKey, address(0), msg.value);
        usdg.forceApprove(address(zap), seedUsdg);
        coin = zap.createTokenSpot(p, seedUsdg, minTokensOut);
        _finishCreate(coin);
    }

    function _finishCreate(address coin) internal {
        bonding.transferCreator(coin, msg.sender);
        uint256 bal = IERC20(coin).balanceOf(address(this));
        if (bal > 0) IERC20(coin).safeTransfer(msg.sender, bal);
        _refundUsdgDust();
    }

    /// @dev Single-hop exact-input swap. Native ETH (tokenIn == address(0)) is paid with value;
    ///      an ERC-20 tokenIn is synced+transferred. Mirrors Zap._v4SwapExactIn (Zap.sol:240-276)
    ///      with the native settle branch added.
    function _swapExactIn(PoolKey memory key, address tokenIn, uint256 amountIn)
        internal
        returns (uint256 out)
    {
        if (amountIn == 0) return 0;
        _swapPending = true;
        bytes memory res = poolManager.unlock(abi.encode(SwapReq(key, tokenIn, amountIn)));
        out = abi.decode(res, (uint256));
    }

    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
        if (!_swapPending) revert NoPendingSwap();
        _swapPending = false;

        SwapReq memory r = abi.decode(data, (SwapReq));
        bool zeroForOne = r.key.currency0 == r.tokenIn;
        int256 delta = poolManager.swap(
            r.key,
            SwapParams(
                zeroForOne,
                -int256(r.amountIn),
                zeroForOne ? V4Lib.MIN_SQRT_PRICE + 1 : V4Lib.MAX_SQRT_PRICE - 1
            ),
            ""
        );
        address tokenOut = zeroForOne ? r.key.currency1 : r.key.currency0;
        uint256 out = uint256(uint128(zeroForOne ? V4Lib.amount1(delta) : V4Lib.amount0(delta)));

        if (r.tokenIn == address(0)) {
            poolManager.settle{value: r.amountIn}(); // native ETH: pay with value
        } else {
            poolManager.sync(r.tokenIn);
            IERC20(r.tokenIn).safeTransfer(address(poolManager), r.amountIn);
            poolManager.settle();
        }
        poolManager.take(tokenOut, address(this), out); // tokenOut == address(0) => native ETH to this router
        return abi.encode(out);
    }

    function _refundUsdgDust() internal {
        uint256 bal = usdg.balanceOf(address(this));
        if (bal > 0) usdg.safeTransfer(msg.sender, bal);
    }

    receive() external payable {} // accept native ETH taken from the PoolManager on sells
}
Chain explorer5655msChain node79ms