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
- Creator
- 0xeCC4faa529…8BC500d958
- 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