ZippSwapRouterV2
0x2e0dec8c4d6624d5315c9b0a57cb1cfdd1b108b8
Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
19,362 bytes
ABI entries
93
17 read · 24 write
License
none
Contract information
- Address
- 0x2e0dec8c4d6624d5315c9b0a57cb1cfdd1b108b8
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.24+commit.e11b9ed9
- Optimization
- Enabled
- Creator
- 0xf054413454…F24F1b4435
- Creation tx
- 0x90428c91ba…25764c8e73
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (17)
BPS_DENOMINATOR() → uint256
MAX_FEE_BPS() → uint256
WETH() → address
approvedRouters(address) → bool
feeBps() → uint256
feeExempt(address) → bool
feeVault() → address
owner() → address
paused() → bool
pendingOwner() → address
permit2() → address
poolManager() → address
universalRouter() → address
v4UseMinHopPrice() → bool
venueAdapter(uint8) → address
venueManager() → address
wiringSealed() → bool
Events (21)
ETHRescuedFeeBpsUpdatedFeeCollectedFeeExemptSetFeeVaultUpdatedOwnershipTransferStartedOwnershipTransferredPausedPermit2SetPoolManagerSetRouterApprovedRouterRevokedSwapTokensRescuedUniversalRouterSetUnpausedV4ParamsVersionSetVenueManagerSetVenueRegisteredVenueRemovedWiringSealed
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "_weth",
"type": "address"
},
{
"internalType": "address",
"name": "_feeVault",
"type": "address"
},
{
"internalType": "uint256",
"name": "_feeBps",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [
{
"internalType": "uint8",
"name": "venueId",
"type": "uint8"
}
],
"name": "AdapterMismatch",
"type": "error"
},
{
"inputs": [],
"name": "AlreadyPaused",
"type": "error"
},
{
"inputs": [],
"name": "AlreadyUnpaused",
"type": "error"
},
{
"inputs": [],
"name": "AmountMismatch",
"type": "error"
},
{
"inputs": [],
"name": "AmountTooLarge",
"type": "error"
},
{
"inputs": [],
"name": "ContractPaused",
"type": "error"
},
{
"inputs": [],
"name": "ETHTransferFailed",
"type": "error"
},
{
"inputs": [],
"name": "EmptyRoutes",
"type": "error"
},
{
"inputs": [],
"name": "Expired",
"type": "error"
},
{
"inputs": [],
"name": "FeeTooHigh",
"type": "error"
},
{
"inputs": [],
"name": "IncorrectETHAmount",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amountOutMin",
"type": "uint256"
}
],
"name": "InsufficientOutput",
"type": "error"
},
{
"inputs": [],
"name": "InvalidPath",
"type": "error"
},
{
"inputs": [],
"name": "InvalidPoolKey",
"type": "error"
},
{
"inputs": [],
"name": "InvalidRouter",
"type": "error"
},
{
"inputs": [],
"name": "NotOwner",
"type": "error"
},
{
"inputs": [],
"name": "NotPendingOwner",
"type": "error"
},
{
"inputs": [],
"name": "NotVenueManager",
"type": "error"
},
{
"inputs": [],
"name": "NothingToRescue",
"type": "error"
},
{
"inputs": [],
"name": "ReentrantCall",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "router",
"type": "address"
}
],
"name": "RouterNotApproved",
"type": "error"
},
{
"inputs": [],
"name": "SameToken",
"type": "error"
},
{
"inputs": [],
"name": "SwapCallFailed",
"type": "error"
},
{
"inputs": [],
"name": "UnexpectedETH",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint8",
"name": "venueId",
"type": "uint8"
}
],
"name": "VenueNotRegistered",
"type": "error"
},
{
"inputs": [],
"name": "WiringIsSealed",
"type": "error"
},
{
"inputs": [],
"name": "WiringNotSet",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAddress",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAmount",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "ETHRescued",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "oldBps",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "newBps",
"type": "uint256"
}
],
"name": "FeeBpsUpdated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "FeeCollected",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "exempt",
"type": "bool"
}
],
"name": "FeeExemptSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "oldVault",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newVault",
"type": "address"
}
],
"name": "FeeVaultUpdated",
"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"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "Paused",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "permit2",
"type": "address"
}
],
"name": "Permit2Set",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "poolManager",
"type": "address"
}
],
"name": "PoolManagerSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "router",
"type": "address"
}
],
"name": "RouterApproved",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "router",
"type": "address"
}
],
"name": "RouterRevoked",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"indexed": false,
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "fee",
"type": "uint256"
}
],
"name": "Swap",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "TokensRescued",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "universalRouter",
"type": "address"
}
],
"name": "UniversalRouterSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "Unpaused",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "bool",
"name": "useMinHopPrice",
"type": "bool"
}
],
"name": "V4ParamsVersionSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "venueManager",
"type": "address"
}
],
"name": "VenueManagerSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint8",
"name": "venueId",
"type": "uint8"
},
{
"indexed": true,
"internalType": "address",
"name": "adapter",
"type": "address"
}
],
"name": "VenueRegistered",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint8",
"name": "venueId",
"type": "uint8"
}
],
"name": "VenueRemoved",
"type": "event"
},
{
"anonymous": false,
"inputs": [],
"name": "WiringSealed",
"type": "event"
},
{
"inputs": [],
"name": "BPS_DENOMINATOR",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_FEE_BPS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "WETH",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "acceptOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "router",
"type": "address"
}
],
"name": "approveRouter",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "approvedRouters",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "feeBps",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "feeExempt",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "feeVault",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pause",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "paused",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pendingOwner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "permit2",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolManager",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint8",
"name": "venueId",
"type": "uint8"
}
],
"name": "removeVenueAdapter",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
}
],
"name": "rescueETH",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
}
],
"name": "rescueTokens",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "router",
"type": "address"
}
],
"name": "revokeRouter",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "sealWiring",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "_feeBps",
"type": "uint256"
}
],
"name": "setFeeBps",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "bool",
"name": "exempt",
"type": "bool"
}
],
"name": "setFeeExempt",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_vault",
"type": "address"
}
],
"name": "setFeeVault",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_permit2",
"type": "address"
}
],
"name": "setPermit2",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_poolManager",
"type": "address"
}
],
"name": "setPoolManager",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_universalRouter",
"type": "address"
}
],
"name": "setUniversalRouter",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bool",
"name": "on",
"type": "bool"
}
],
"name": "setV4UseMinHopPrice",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint8",
"name": "venueId",
"type": "uint8"
},
{
"internalType": "address",
"name": "adapter",
"type": "address"
}
],
"name": "setVenueAdapter",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_venueManager",
"type": "address"
}
],
"name": "setVenueManager",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "router",
"type": "address"
},
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amountOutMin",
"type": "uint256"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "routerData",
"type": "bytes"
}
],
"name": "swap",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"components": [
{
"internalType": "uint8",
"name": "venueId",
"type": "uint8"
},
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "bytes",
"name": "poolParams",
"type": "bytes"
},
{
"internalType": "address",
"name": "expectedAdapter",
"type": "address"
}
],
"internalType": "struct IZippSwapRouterV2.Leg[]",
"name": "legs",
"type": "tuple[]"
},
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amountOutMin",
"type": "uint256"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
}
],
"name": "swapModular",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "router",
"type": "address"
},
{
"internalType": "address[]",
"name": "path",
"type": "address[]"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amountOutMin",
"type": "uint256"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "routerData",
"type": "bytes"
}
],
"name": "swapMultiHop",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"components": [
{
"internalType": "address",
"name": "router",
"type": "address"
},
{
"internalType": "address[]",
"name": "path",
"type": "address[]"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "minOut",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"internalType": "struct IZippSwapRouterV2.SwapRoute[]",
"name": "routes",
"type": "tuple[]"
},
{
"internalType": "address",
"name": "tokenIn",
"type": "address"
},
{
"internalType": "address",
"name": "tokenOut",
"type": "address"
},
{
"internalType": "uint256",
"name": "totalAmountIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "totalAmountOutMin",
"type": "uint256"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
}
],
"name": "swapSplit",
"outputs": [
{
"internalType": "uint256",
"name": "totalAmountOut",
"type": "uint256"
}
],
"stateMutability": "payable",
"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 ZippSwapRouterV2.PoolKey",
"name": "key",
"type": "tuple"
},
{
"internalType": "bool",
"name": "zeroForOne",
"type": "bool"
},
{
"internalType": "uint256",
"name": "amountIn",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amountOutMin",
"type": "uint256"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "deadline",
"type": "uint256"
}
],
"name": "swapV4Single",
"outputs": [
{
"internalType": "uint256",
"name": "amountOut",
"type": "uint256"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "universalRouter",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "unpause",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "v4UseMinHopPrice",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint8",
"name": "",
"type": "uint8"
}
],
"name": "venueAdapter",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "venueManager",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "wiringSealed",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IERC20} from "./interfaces/IERC20.sol";
import {IWETH} from "./interfaces/IWETH.sol";
import {IZippSwapRouterV2} from "./interfaces/IZippSwapRouterV2.sol";
import {IVenueAdapter} from "./interfaces/IVenueAdapter.sol";
import {IUniversalRouter} from "./interfaces/IUniversalRouter.sol";
import {IAllowanceTransfer} from "./interfaces/IPermit2.sol";
import {Ownable2Step} from "./lib/Ownable2Step.sol";
import {ReentrancyGuard} from "./lib/ReentrancyGuard.sol";
import {SafeERC20} from "./lib/SafeERC20.sol";
/// @title ZippSwapRouterV2
/// @notice Modular, venue-adapter DEX aggregator router for ZippSwap on Robinhood Chain. Evolves
/// ZippSwapRouter: keeps the proven money-safety core (swap / swapMultiHop / swapSplit /
/// swapV4Single, balance-diff accounting, aggregate min-out, protocol fee, feeExempt,
/// pause, rescue, Ownable2Step, ReentrancyGuard, SafeERC20, MAX_FEE_BPS cap) VERBATIM, and
/// replaces the hardcoded-venue `swapMultiRouter` with `swapModular`.
///
/// `swapModular` dispatches each leg to a registered VENUE ADAPTER (IVenueAdapter). New
/// DEX venues are added POST-DEPLOY by registering an adapter — one tx, no core redeploy,
/// no proxy, no delegatecall. The money-touching core is immutable and its safety rules
/// never change; only the venue registry is mutable (there is intentionally NO seal on it,
/// mirroring the already-unsealed `approvedRouters` allowlist trust surface).
///
/// @dev SAFETY SPINE (identical discipline to the kept paths):
/// - No delegatecall anywhere. Adapters are plain external CALLS.
/// - Every output is measured by balance-diff; the core NEVER trusts an adapter/router return.
/// - Per leg, the core approves the adapter for EXACTLY that leg's input, then resets to 0
/// after the call. A malicious adapter can waste at most one leg's input; the swap then
/// reverts on the aggregate amountOutMin (or on a zero leg output). It can never drain the
/// core or a user's standing approval, and never touches the user directly.
/// - Fee-on-transfer safe on the input (balance-diff pull in _pullTokenIn).
///
/// NOTE ON via_ir: as with ZippSwapRouter, this contract is deliberately structured with
/// small internal helpers + memory structs to stay under the 16-slot stack limit WITHOUT
/// enabling via_ir (a known footgun for balance-diff/timestamp-sensitive contracts).
contract ZippSwapRouterV2 is IZippSwapRouterV2, Ownable2Step, ReentrancyGuard {
using SafeERC20 for IERC20;
/// @dev Sentinel used in tokenIn/tokenOut positions to mean "native ETH".
address internal constant NATIVE_ETH = address(0);
uint256 public constant MAX_FEE_BPS = 100; // 1.00% hard cap
uint256 internal constant MAX_LEGS = 10; // swapModular hop cap (gas + sandwich-surface bound)
uint256 public constant BPS_DENOMINATOR = 10_000;
address public immutable WETH;
address public feeVault;
uint256 public feeBps = 30; // 0.30% default, overwritten by constructor arg
/// @notice Output tokens exempt from the protocol fee (keyed on the user-facing output token;
/// NATIVE_ETH sentinel for native out). Owner-settable, not frozen.
mapping(address => bool) public feeExempt;
mapping(address => bool) public approvedRouters;
bool public paused;
// --- modular venue-adapter registry -----------------------------------------
// venueId -> adapter address. Reserve stable ids: 2 = UniV2, 3 = UniV3, 4 = UniV4; new venues
// get new ids. There is NO seal: `venueManager` (or owner) can register/replace/remove adapters
// forever. Adapter isolation (scoped approve + reset, no trusted return values) means the
// registry can NEVER drain principal, touch fees, pause, ownership, or rescue — those stay
// onlyOwner.
//
// HONEST BLAST RADIUS: a rogue/compromised venueManager CAN, however, skim a modular swap DOWN
// TO each user's own aggregate slippage tolerance (amountOutMin) — by registering a hostile
// adapter and letting the swap complete just above the floor. It cannot exceed that: the
// aggregate min-out is enforced on the recipient's real balance-diff, so anything below the
// user's tolerance reverts atomically. Per-leg adapter pinning (Leg.expectedAdapter, see
// _validateModularChain) hardens this further: a SILENT mid-flight substitution (front-running
// setVenueAdapter after a user's tx is in the mempool) now REVERTS with AdapterMismatch rather
// than skimming — a user pins the exact adapter they quoted against.
mapping(uint8 => address) public venueAdapter;
/// @notice Hot dev wallet allowed to register/replace/remove venue adapters ONLY. Cannot move
/// funds, change fee, pause, transfer ownership, or approve legacy routers — those stay
/// onlyOwner. Owner can change or revoke the venueManager at any time. See the registry
/// comment above for the honest skim-to-slippage blast radius and the expectedAdapter pin.
address public venueManager;
// --- v4 wiring (owner-settable until sealed) --------------------------------
address public universalRouter; // v4 execution via UniversalRouter + Permit2
address public permit2; // canonical Permit2 singleton (input approvals)
address public poolManager; // v4 PoolManager (reference / monitoring)
bool public wiringSealed;
/// @dev false = 5-field v4 ExactInputSingleParams; true = 6-field with minHopPriceX36.
bool public v4UseMinHopPrice;
// --- v4 UniversalRouter command + action bytes (verified live on chain 4663) ---
uint8 internal constant V4_SWAP = 0x10;
uint8 internal constant SWAP_EXACT_IN_SINGLE = 0x06;
uint8 internal constant SETTLE_ALL = 0x0c;
uint8 internal constant TAKE_ALL = 0x0f;
uint48 internal constant PERMIT2_APPROVAL_TTL = 300;
/// @dev v4 PoolKey. currency0 < currency1; native ETH = address(0) sorts lowest.
struct PoolKey {
address currency0;
address currency1;
uint24 fee;
int24 tickSpacing;
address hooks;
}
/// @dev v4 IV4Router.ExactInputSingleParams — 5-field layout (RH mainnet).
struct ExactInputSingleParams {
PoolKey poolKey;
bool zeroForOne;
uint128 amountIn;
uint128 amountOutMinimum;
bytes hookData;
}
/// @dev Newer 6-field v4-periphery layout (RH testnet 46630) with minHopPriceX36 before hookData.
struct ExactInputSingleParamsV6 {
PoolKey poolKey;
bool zeroForOne;
uint128 amountIn;
uint128 amountOutMinimum;
uint256 minHopPriceX36;
bytes hookData;
}
/// @dev Common single-route swap params bundled to keep entrypoints under the stack limit.
struct SwapParams {
address router;
address tokenIn;
address tokenOut;
uint256 amountIn;
uint256 amountOutMin;
address to;
}
error Expired();
error ContractPaused();
error AlreadyPaused();
error AlreadyUnpaused();
error RouterNotApproved(address router);
error InsufficientOutput(uint256 amountOut, uint256 amountOutMin);
error AmountMismatch();
error SwapCallFailed();
error FeeTooHigh();
error InvalidPath();
error ZeroAmount();
error UnexpectedETH();
error IncorrectETHAmount();
error ETHTransferFailed();
error EmptyRoutes();
error NothingToRescue();
error SameToken();
error InvalidRouter();
error WiringNotSet();
error WiringIsSealed();
error AmountTooLarge();
error InvalidPoolKey();
error NotVenueManager();
error VenueNotRegistered(uint8 venueId);
error AdapterMismatch(uint8 venueId);
event UniversalRouterSet(address indexed universalRouter);
event Permit2Set(address indexed permit2);
event PoolManagerSet(address indexed poolManager);
event V4ParamsVersionSet(bool useMinHopPrice);
event FeeExemptSet(address indexed token, bool exempt);
event WiringSealed();
modifier whenNotPaused() {
if (paused) revert ContractPaused();
_;
}
modifier notSealed() {
if (wiringSealed) revert WiringIsSealed();
_;
}
modifier checkDeadline(uint256 deadline) {
if (block.timestamp > deadline) revert Expired();
_;
}
/// @dev venueManager OR owner may register/replace/remove venue adapters. Owner is always
/// permitted so the cold key can operate the registry without a hot venueManager set.
modifier onlyVenueManager() {
if (msg.sender != venueManager && msg.sender != owner) revert NotVenueManager();
_;
}
constructor(address _weth, address _feeVault, uint256 _feeBps) Ownable2Step(msg.sender) {
if (_weth == address(0)) revert ZeroAddress();
if (_feeVault == address(0)) revert ZeroAddress();
if (_feeBps > MAX_FEE_BPS) revert FeeTooHigh();
WETH = _weth;
feeVault = _feeVault;
feeBps = _feeBps;
}
/// @dev Required so this contract can receive ETH unwrapped from WETH.withdraw() and native
/// output forwarded back by the v4 adapter.
receive() external payable {}
// ---------------------------------------------------------------------
// Swaps (external entrypoints)
// ---------------------------------------------------------------------
/// @inheritdoc IZippSwapRouterV2
function swap(
address router,
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 amountOutMin,
address to,
uint256 deadline,
bytes calldata routerData
) external payable nonReentrant whenNotPaused checkDeadline(deadline) returns (uint256 amountOut) {
if (to == address(0)) revert ZeroAddress();
if (amountIn == 0) revert ZeroAmount();
if (tokenIn == tokenOut) revert SameToken();
if (!approvedRouters[router]) revert RouterNotApproved(router);
amountOut = _executeSwap(
SwapParams({
router: router,
tokenIn: tokenIn,
tokenOut: tokenOut,
amountIn: amountIn,
amountOutMin: amountOutMin,
to: to
}),
routerData
);
}
/// @inheritdoc IZippSwapRouterV2
function swapMultiHop(
address router,
address[] calldata path,
uint256 amountIn,
uint256 amountOutMin,
address to,
uint256 deadline,
bytes calldata routerData
) external payable nonReentrant whenNotPaused checkDeadline(deadline) returns (uint256 amountOut) {
if (to == address(0)) revert ZeroAddress();
if (amountIn == 0) revert ZeroAmount();
if (path.length < 2) revert InvalidPath();
if (!approvedRouters[router]) revert RouterNotApproved(router);
address tokenIn = path[0];
address tokenOut = path[path.length - 1];
if (tokenIn == tokenOut) revert SameToken();
amountOut = _executeSwap(
SwapParams({
router: router,
tokenIn: tokenIn,
tokenOut: tokenOut,
amountIn: amountIn,
amountOutMin: amountOutMin,
to: to
}),
routerData
);
}
/// @inheritdoc IZippSwapRouterV2
/// @dev Balance-diff accounting is measured ONCE around the whole batch of legs (a single
/// balanceOf before the loop, a single balanceOf after), not per-leg. Only the aggregate
/// `totalAmountOutMin` is enforced on-chain.
function swapSplit(
SwapRoute[] calldata routes,
address tokenIn,
address tokenOut,
uint256 totalAmountIn,
uint256 totalAmountOutMin,
address to,
uint256 deadline
) external payable nonReentrant whenNotPaused checkDeadline(deadline) returns (uint256 totalAmountOut) {
if (to == address(0)) revert ZeroAddress();
if (totalAmountIn == 0) revert ZeroAmount();
if (routes.length == 0) revert EmptyRoutes();
if (tokenIn == tokenOut) revert SameToken();
totalAmountOut = _executeSplit(routes, tokenIn, tokenOut, totalAmountIn, totalAmountOutMin, to);
}
/// @inheritdoc IZippSwapRouterV2
/// @notice Sequential modular multi-venue swap: each leg runs through the ADAPTER registered
/// for its venueId, the output of leg N feeding leg N+1. Adds/routes any venue
/// (including v4) post-deploy without a core redeploy.
/// @dev SECURITY: (1) The leg chain is validated CEI-first (connected resolvedTokenIn -> ... ->
/// actualTokenOut, every venueId registered) BEFORE any funds move. (2) Input is pulled by
/// balance-diff (FOT-safe). (3) Per-leg adapter approvals are scoped to the exact running
/// amount and reset to 0 after each call. (4) Output is balance-diffed per leg (adapter
/// return values are never trusted); a zero leg output reverts. (5) The ONLY value
/// guarantee is the aggregate `amountOutMin`, enforced on the recipient's REAL balance-diff
/// in _settleOutput — a misrouted/hostile adapter can only make the final output fall short,
/// reverting the whole tx atomically. No funds are custodied between transactions.
function swapModular(
Leg[] calldata legs,
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 amountOutMin,
address to,
uint256 deadline
) external payable nonReentrant whenNotPaused checkDeadline(deadline) returns (uint256 amountOut) {
if (to == address(0)) revert ZeroAddress();
if (amountIn == 0) revert ZeroAmount();
if (legs.length == 0) revert EmptyRoutes();
if (legs.length > MAX_LEGS) revert InvalidPath();
if (tokenIn == tokenOut) revert SameToken();
amountOut = _executeModular(legs, tokenIn, tokenOut, amountIn, amountOutMin, to, deadline);
}
// ---------------------------------------------------------------------
// Modular orchestration (kept small & single-purpose to avoid stack-too-deep)
// ---------------------------------------------------------------------
function _executeModular(
Leg[] calldata legs,
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 amountOutMin,
address to,
uint256 deadline
) internal returns (uint256 amountOut) {
address actualTokenOut = tokenOut == NATIVE_ETH ? WETH : tokenOut;
address resolvedTokenIn = tokenIn == NATIVE_ETH ? WETH : tokenIn;
if (resolvedTokenIn == actualTokenOut) revert SameToken();
// CEI: fully validate the leg chain (connectivity + every venueId registered) BEFORE
// pulling funds or making any external call.
_validateModularChain(legs, resolvedTokenIn, actualTokenOut);
(address actualTokenIn, uint256 receivedIn) = _pullTokenIn(tokenIn, amountIn);
uint256 grossOut = _runModularLegs(legs, actualTokenIn, receivedIn, deadline);
uint256 fee;
(amountOut, fee) = _settleOutput(tokenOut, actualTokenOut, grossOut, amountOutMin, to);
emit Swap(msg.sender, to, tokenIn, tokenOut, amountIn, amountOut, fee);
}
/// @dev Validate the legs form a single connected path resolvedTokenIn -> ... -> actualTokenOut
/// and that every leg's venueId has a registered adapter. View + pre-funds (CEI): a bad
/// chain or unregistered venue reverts before anything is pulled or approved.
function _validateModularChain(Leg[] calldata legs, address resolvedTokenIn, address actualTokenOut)
internal
view
{
if (legs[0].tokenIn != resolvedTokenIn) revert InvalidPath();
if (legs[legs.length - 1].tokenOut != actualTokenOut) revert InvalidPath();
for (uint256 i = 0; i < legs.length; ++i) {
address registered = venueAdapter[legs[i].venueId];
// Keep the registered check first: an unregistered/removed venue reverts VenueNotRegistered
// (even if the caller pinned address(0) as expectedAdapter).
if (registered == address(0)) revert VenueNotRegistered(legs[i].venueId);
// FIX 2 (anti-substitution): pin the adapter per leg. A hot venueManager that front-runs
// setVenueAdapter to reroute this leg makes the swap REVERT here (pre-funds) instead of
// silently skimming through a substituted adapter.
if (registered != legs[i].expectedAdapter) revert AdapterMismatch(legs[i].venueId);
if (legs[i].tokenIn == legs[i].tokenOut) revert SameToken();
if (i + 1 < legs.length && legs[i].tokenOut != legs[i + 1].tokenIn) revert InvalidPath();
}
}
/// @dev Run the legs in order via their registered adapters (execution model B — pull). Each leg:
/// measure tokenOut balance before -> scope-approve the adapter for the REAL running amount
/// (leg N-1's measured output) -> call adapter.swap (recipient forced to this contract) ->
/// reset the approval to 0 -> balance-diff the output token to get the amount feeding the
/// next leg. NEVER trusts an adapter return value; per-leg minOut is 0 (only the aggregate
/// is enforced). A zero leg output dooms the chain (SwapCallFailed).
function _runModularLegs(Leg[] calldata legs, address actualTokenIn, uint256 amountIn, uint256 deadline)
internal
returns (uint256 grossOut)
{
address currentToken = actualTokenIn;
uint256 currentAmount = amountIn;
for (uint256 i = 0; i < legs.length; ++i) {
Leg calldata leg = legs[i];
// leg.tokenIn == currentToken is guaranteed by _validateModularChain.
address adapter = venueAdapter[leg.venueId]; // non-zero (validated pre-funds)
uint256 outBefore = IERC20(leg.tokenOut).balanceOf(address(this));
// Scope-approve exactly this leg's input to the adapter, call it (adapter pulls via
// transferFrom and sends output back here), then hard-reset the approval to 0. This
// bounds a hostile/buggy adapter to at most one leg's input.
IERC20(currentToken).forceApprove(adapter, currentAmount);
IVenueAdapter(adapter).swap(currentToken, leg.tokenOut, currentAmount, address(this), leg.poolParams, deadline);
IERC20(currentToken).forceApprove(adapter, 0);
// Checked math: reverts if the adapter somehow decreased our balance of the output token.
uint256 legOut = IERC20(leg.tokenOut).balanceOf(address(this)) - outBefore;
if (legOut == 0) revert SwapCallFailed(); // a leg producing nothing dooms the chain
currentToken = leg.tokenOut;
currentAmount = legOut;
}
grossOut = currentAmount;
}
// ---------------------------------------------------------------------
// Internal swap orchestration (legacy paths — ported verbatim)
// ---------------------------------------------------------------------
function _executeSwap(SwapParams memory p, bytes calldata routerData) internal returns (uint256 amountOut) {
// M1 fix: compare the RESOLVED tokens (native sentinel -> WETH) before touching any funds.
address actualTokenOut = p.tokenOut == NATIVE_ETH ? WETH : p.tokenOut;
address resolvedTokenIn = p.tokenIn == NATIVE_ETH ? WETH : p.tokenIn;
if (resolvedTokenIn == actualTokenOut) revert SameToken();
(address actualTokenIn, uint256 receivedIn) = _pullTokenIn(p.tokenIn, p.amountIn);
// Approve/execute only what we actually received (F1: FOT input safe-reverts, never drains).
uint256 grossOut = _executeRouterSwap(p.router, actualTokenIn, actualTokenOut, receivedIn, routerData);
uint256 fee;
(amountOut, fee) = _settleOutput(p.tokenOut, actualTokenOut, grossOut, p.amountOutMin, p.to);
emit Swap(msg.sender, p.to, p.tokenIn, p.tokenOut, p.amountIn, amountOut, fee);
}
function _executeSplit(
SwapRoute[] calldata routes,
address tokenIn,
address tokenOut,
uint256 totalAmountIn,
uint256 totalAmountOutMin,
address to
) internal returns (uint256 totalAmountOut) {
uint256 sumIn = _validateRoutes(routes);
if (sumIn != totalAmountIn) revert AmountMismatch();
// M1 fix: same resolved-token guard as _executeSwap.
address actualTokenOut = tokenOut == NATIVE_ETH ? WETH : tokenOut;
address resolvedTokenIn = tokenIn == NATIVE_ETH ? WETH : tokenIn;
if (resolvedTokenIn == actualTokenOut) revert SameToken();
(address actualTokenIn, uint256 receivedIn) = _pullTokenIn(tokenIn, totalAmountIn);
// Split routes commit fixed per-branch amounts summing to totalAmountIn; reject FOT input. F1.
if (receivedIn != totalAmountIn) revert AmountMismatch();
uint256 grossOut = _executeRouterLegs(routes, actualTokenIn, actualTokenOut);
uint256 fee;
(totalAmountOut, fee) = _settleOutput(tokenOut, actualTokenOut, grossOut, totalAmountOutMin, to);
emit Swap(msg.sender, to, tokenIn, tokenOut, totalAmountIn, totalAmountOut, fee);
}
/// @dev First pass over routes: validate each router is approved and amounts non-zero, sum
/// amountIn. Done before any external calls (CEI).
function _validateRoutes(SwapRoute[] calldata routes) internal view returns (uint256 sumIn) {
for (uint256 i = 0; i < routes.length; ++i) {
if (!approvedRouters[routes[i].router]) revert RouterNotApproved(routes[i].router);
if (routes[i].amountIn == 0) revert ZeroAmount();
sumIn += routes[i].amountIn;
}
}
/// @dev Second pass over routes: execute each leg's router call. Output is measured once, by
/// the caller, around this whole function (balance-diff over the batch).
function _executeRouterLegs(SwapRoute[] calldata routes, address actualTokenIn, address actualTokenOut)
internal
returns (uint256 grossOut)
{
uint256 balanceBefore = IERC20(actualTokenOut).balanceOf(address(this));
for (uint256 i = 0; i < routes.length; ++i) {
SwapRoute calldata route = routes[i];
IERC20(actualTokenIn).forceApprove(route.router, route.amountIn);
(bool success,) = route.router.call(route.data);
if (!success) revert SwapCallFailed();
IERC20(actualTokenIn).forceApprove(route.router, 0);
}
uint256 balanceAfter = IERC20(actualTokenOut).balanceOf(address(this));
grossOut = balanceAfter - balanceBefore;
}
/// @dev Pulls `amountIn` of `tokenIn` into this contract. Native sentinel wraps msg.value to
/// WETH (exact match). Returns the actual ERC20 held AND the REAL amount received (F1:
/// balance-diffed, so a FOT input never bleeds resting balance).
function _pullTokenIn(address tokenIn, uint256 amountIn)
internal
returns (address actualTokenIn, uint256 receivedIn)
{
if (tokenIn == NATIVE_ETH) {
if (msg.value != amountIn) revert IncorrectETHAmount();
IWETH(WETH).deposit{value: amountIn}();
return (WETH, amountIn); // WETH deposit is exact 1:1
}
if (msg.value != 0) revert UnexpectedETH();
uint256 balBefore = IERC20(tokenIn).balanceOf(address(this));
IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn);
receivedIn = IERC20(tokenIn).balanceOf(address(this)) - balBefore;
return (tokenIn, receivedIn);
}
/// @dev Approves `router` for exactly `amountIn` of `actualTokenIn`, forwards `routerData`
/// verbatim, resets approval to 0, then balance-diffs `actualTokenOut`. NEVER trusts a
/// return value from the router call.
function _executeRouterSwap(
address router,
address actualTokenIn,
address actualTokenOut,
uint256 amountIn,
bytes calldata routerData
) internal returns (uint256 grossOut) {
uint256 balanceBefore = IERC20(actualTokenOut).balanceOf(address(this));
IERC20(actualTokenIn).forceApprove(router, amountIn);
(bool success,) = router.call(routerData);
if (!success) revert SwapCallFailed();
IERC20(actualTokenIn).forceApprove(router, 0);
uint256 balanceAfter = IERC20(actualTokenOut).balanceOf(address(this));
grossOut = balanceAfter - balanceBefore;
}
/// @dev Deducts the protocol fee from `grossOut`, enforces `amountOutMin` against the recipient's
/// REAL received amount (M2 discipline), sends the fee to feeVault, and delivers the net to
/// `to` — unwrapping WETH to ETH if the caller requested NATIVE_ETH. Fee is always paid in
/// `actualTokenOut` (WETH, not ETH, for ETH-out swaps).
function _settleOutput(
address tokenOutRequested,
address actualTokenOut,
uint256 grossOut,
uint256 amountOutMin,
address to
) internal returns (uint256 netOut, uint256 fee) {
fee = feeExempt[tokenOutRequested] ? 0 : (grossOut * feeBps) / BPS_DENOMINATOR;
netOut = grossOut - fee;
if (tokenOutRequested == NATIVE_ETH) {
// WETH is never FOT, so unwrapping `netOut` 1:1 to ETH means the recipient's received
// amount equals netOut — checking netOut against amountOutMin is exact here.
if (netOut < amountOutMin) revert InsufficientOutput(netOut, amountOutMin);
if (fee > 0) {
IERC20(actualTokenOut).safeTransfer(feeVault, fee);
emit FeeCollected(actualTokenOut, fee);
}
IWETH(WETH).withdraw(netOut);
(bool ok,) = to.call{value: netOut}("");
if (!ok) revert ETHTransferFailed();
} else {
if (fee > 0) {
IERC20(actualTokenOut).safeTransfer(feeVault, fee);
emit FeeCollected(actualTokenOut, fee);
}
// M2 fix: never trust `netOut` as what `to` receives — actualTokenOut may be FOT.
// Measure the recipient's real balance-diff and enforce amountOutMin against that.
uint256 toBalanceBefore = IERC20(actualTokenOut).balanceOf(to);
IERC20(actualTokenOut).safeTransfer(to, netOut);
uint256 received = IERC20(actualTokenOut).balanceOf(to) - toBalanceBefore;
if (received < amountOutMin) revert InsufficientOutput(received, amountOutMin);
}
}
// ---------------------------------------------------------------------
// Uniswap v4 (hardened UniversalRouter + Permit2 adapter) — ported verbatim
// ---------------------------------------------------------------------
/// @notice Swap through a Uniswap v4 pool (exact-in, single hop) via the UniversalRouter.
/// @dev SECURITY MODEL: builds the UniversalRouter command/action bytes itself from the TYPED
/// PoolKey params (never forwards arbitrary caller calldata). Funds are pulled via a
/// per-swap-scoped Permit2 approval, output lands here via TAKE_ALL, and the real received
/// amount is balance-diffed, fee'd, and forwarded to `to`.
function swapV4Single(
PoolKey calldata key,
bool zeroForOne,
uint256 amountIn,
uint256 amountOutMin,
address to,
uint256 deadline
) external payable nonReentrant whenNotPaused checkDeadline(deadline) returns (uint256 amountOut) {
if (to == address(0)) revert ZeroAddress();
if (amountIn == 0) revert ZeroAmount();
if (universalRouter == address(0) || permit2 == address(0)) revert WiringNotSet();
if (amountIn > type(uint128).max || amountOutMin > type(uint128).max) revert AmountTooLarge();
if (uint160(key.currency0) >= uint160(key.currency1)) revert InvalidPoolKey();
address outputCurrency = zeroForOne ? key.currency1 : key.currency0;
uint256 grossOut = _pullExecuteV4(key, zeroForOne, amountIn, amountOutMin, deadline);
uint256 fee;
(amountOut, fee) = _settleV4Output(outputCurrency, grossOut, amountOutMin, to);
emit Swap(
msg.sender, to, zeroForOne ? key.currency0 : key.currency1, outputCurrency, amountIn, amountOut, fee
);
}
function _pullExecuteV4(
PoolKey calldata key,
bool zeroForOne,
uint256 amountIn,
uint256 amountOutMin,
uint256 deadline
) internal returns (uint256 grossOut) {
address inputCurrency = zeroForOne ? key.currency0 : key.currency1;
address outputCurrency = zeroForOne ? key.currency1 : key.currency0;
if (inputCurrency == outputCurrency) revert SameToken();
(bool inputIsNative, uint256 actualIn) = _pullV4Input(inputCurrency, amountIn);
// FIX 4: re-check the uint128 bound on the RECEIVED amount (balance-diffed), not just the
// requested amountIn, before the uint128 cast in _executeV4.
if (actualIn > type(uint128).max) revert AmountTooLarge();
bool outputIsNative = outputCurrency == NATIVE_ETH;
uint256 balanceBefore =
outputIsNative ? address(this).balance : IERC20(outputCurrency).balanceOf(address(this));
_executeV4(key, zeroForOne, uint128(actualIn), uint128(amountOutMin), inputIsNative ? actualIn : 0, deadline);
uint256 balanceAfter =
outputIsNative ? address(this).balance : IERC20(outputCurrency).balanceOf(address(this));
grossOut = balanceAfter - balanceBefore;
if (!inputIsNative) {
IAllowanceTransfer(permit2).approve(inputCurrency, universalRouter, 0, 0);
IERC20(inputCurrency).forceApprove(permit2, 0);
}
}
function _pullV4Input(address inputCurrency, uint256 amountIn)
internal
returns (bool inputIsNative, uint256 actualIn)
{
if (inputCurrency == NATIVE_ETH) {
if (msg.value != amountIn) revert IncorrectETHAmount();
return (true, amountIn);
}
if (msg.value != 0) revert UnexpectedETH();
uint256 balBefore = IERC20(inputCurrency).balanceOf(address(this));
IERC20(inputCurrency).safeTransferFrom(msg.sender, address(this), amountIn);
actualIn = IERC20(inputCurrency).balanceOf(address(this)) - balBefore;
IERC20(inputCurrency).forceApprove(permit2, actualIn);
IAllowanceTransfer(permit2).approve(
inputCurrency, universalRouter, uint160(actualIn), uint48(block.timestamp + PERMIT2_APPROVAL_TTL)
);
return (false, actualIn);
}
function _executeV4(
PoolKey calldata key,
bool zeroForOne,
uint128 amountIn,
uint128 amountOutMin,
uint256 nativeValue,
uint256 deadline
) internal {
address inputCurrency = zeroForOne ? key.currency0 : key.currency1;
address outputCurrency = zeroForOne ? key.currency1 : key.currency0;
bytes memory actions = abi.encodePacked(SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL);
bytes[] memory params = new bytes[](3);
params[0] = v4UseMinHopPrice
? abi.encode(
ExactInputSingleParamsV6({
poolKey: key,
zeroForOne: zeroForOne,
amountIn: amountIn,
amountOutMinimum: amountOutMin,
minHopPriceX36: 0,
hookData: bytes("")
})
)
: abi.encode(
ExactInputSingleParams({
poolKey: key,
zeroForOne: zeroForOne,
amountIn: amountIn,
amountOutMinimum: amountOutMin,
hookData: bytes("")
})
);
params[1] = abi.encode(inputCurrency, uint256(amountIn)); // SETTLE_ALL(currency, maxAmount)
params[2] = abi.encode(outputCurrency, uint256(amountOutMin)); // TAKE_ALL(currency, minAmount)
bytes memory commands = abi.encodePacked(V4_SWAP);
bytes[] memory inputs = new bytes[](1);
inputs[0] = abi.encode(actions, params);
IUniversalRouter(universalRouter).execute{value: nativeValue}(commands, inputs, deadline);
}
function _settleV4Output(address outputCurrency, uint256 grossOut, uint256 amountOutMin, address to)
internal
returns (uint256 netOut, uint256 fee)
{
fee = feeExempt[outputCurrency] ? 0 : (grossOut * feeBps) / BPS_DENOMINATOR;
netOut = grossOut - fee;
if (outputCurrency == NATIVE_ETH) {
if (netOut < amountOutMin) revert InsufficientOutput(netOut, amountOutMin);
if (fee > 0) {
// L-2: pay native-out fee in WETH so feeVault never needs a payable receive().
IWETH(WETH).deposit{value: fee}();
IERC20(WETH).safeTransfer(feeVault, fee);
emit FeeCollected(WETH, fee);
}
(bool ok,) = to.call{value: netOut}("");
if (!ok) revert ETHTransferFailed();
} else {
if (fee > 0) {
IERC20(outputCurrency).safeTransfer(feeVault, fee);
emit FeeCollected(outputCurrency, fee);
}
uint256 toBalanceBefore = IERC20(outputCurrency).balanceOf(to);
IERC20(outputCurrency).safeTransfer(to, netOut);
uint256 received = IERC20(outputCurrency).balanceOf(to) - toBalanceBefore;
if (received < amountOutMin) revert InsufficientOutput(received, amountOutMin);
}
}
// ---------------------------------------------------------------------
// Modular venue registry admin
// ---------------------------------------------------------------------
/// @notice Set the hot venueManager allowed to register/replace/remove venue adapters. Owner-
/// only. Pass address(0) to disable delegation (only owner can then operate the registry).
function setVenueManager(address _venueManager) external onlyOwner {
venueManager = _venueManager;
emit VenueManagerSet(_venueManager);
}
/// @notice Register (or replace) the adapter for `venueId`. venueManager or owner. NO seal.
/// @dev Drain-safe by construction: the core hands an adapter at most one leg's input (scoped
/// approve + reset), trusts no return value (balance-diff), and enforces aggregate min-out.
function setVenueAdapter(uint8 venueId, address adapter) external onlyVenueManager {
if (adapter == address(0)) revert ZeroAddress();
// FIX 5: mirror approveRouter's guard — the core must never point a venue at itself (would
// let the registry re-enter money-touching paths) or at WETH (a value-bearing contract).
if (adapter == address(this) || adapter == WETH) revert InvalidRouter();
venueAdapter[venueId] = adapter;
emit VenueRegistered(venueId, adapter);
}
/// @notice Remove the adapter for `venueId`. venueManager or owner. Subsequent legs on that
/// venueId revert VenueNotRegistered.
function removeVenueAdapter(uint8 venueId) external onlyVenueManager {
delete venueAdapter[venueId];
emit VenueRemoved(venueId);
}
// ---------------------------------------------------------------------
// v4 wiring admin (owner-only, sealable) — ported verbatim
// ---------------------------------------------------------------------
function setUniversalRouter(address _universalRouter) external onlyOwner notSealed {
if (_universalRouter == address(0)) revert ZeroAddress();
universalRouter = _universalRouter;
emit UniversalRouterSet(_universalRouter);
}
function setPermit2(address _permit2) external onlyOwner notSealed {
if (_permit2 == address(0)) revert ZeroAddress();
permit2 = _permit2;
emit Permit2Set(_permit2);
}
function setPoolManager(address _poolManager) external onlyOwner notSealed {
if (_poolManager == address(0)) revert ZeroAddress();
poolManager = _poolManager;
emit PoolManagerSet(_poolManager);
}
function setV4UseMinHopPrice(bool on) external onlyOwner notSealed {
v4UseMinHopPrice = on;
emit V4ParamsVersionSet(on);
}
/// @notice One-way lock: permanently freezes universalRouter/permit2/poolManager. Does NOT
/// touch the venue registry (which has no seal by design).
function sealWiring() external onlyOwner {
if (universalRouter == address(0) || permit2 == address(0)) revert WiringNotSet();
wiringSealed = true;
emit WiringSealed();
}
// ---------------------------------------------------------------------
// Fee / router / pause / rescue admin (owner-only) — ported verbatim
// ---------------------------------------------------------------------
function setFeeBps(uint256 _feeBps) external onlyOwner {
if (_feeBps > MAX_FEE_BPS) revert FeeTooHigh();
emit FeeBpsUpdated(feeBps, _feeBps);
feeBps = _feeBps;
}
/// @notice Exempt (or un-exempt) an OUTPUT token from the protocol fee. Swaps into `token` pay
/// 0 fee. Use the NATIVE_ETH sentinel (address(0)) to exempt native-ETH-out swaps.
function setFeeExempt(address token, bool exempt) external onlyOwner {
feeExempt[token] = exempt;
emit FeeExemptSet(token, exempt);
}
function setFeeVault(address _vault) external onlyOwner {
if (_vault == address(0)) revert ZeroAddress();
emit FeeVaultUpdated(feeVault, _vault);
feeVault = _vault;
}
/// @notice Approve a legacy swap router that arbitrary caller `routerData` may be forwarded to
/// (used by swap / swapMultiHop / swapSplit). NEVER approve UniversalRouter / Permit2 /
/// Multicall or any "execute arbitrary commands" contract (audit H1).
function approveRouter(address router) external onlyOwner {
if (router == address(0)) revert ZeroAddress();
if (router == address(this) || router == WETH) revert InvalidRouter();
approvedRouters[router] = true;
emit RouterApproved(router);
}
function revokeRouter(address router) external onlyOwner {
approvedRouters[router] = false;
emit RouterRevoked(router);
}
function pause() external onlyOwner {
if (paused) revert AlreadyPaused();
paused = true;
emit Paused(msg.sender);
}
function unpause() external onlyOwner {
if (!paused) revert AlreadyUnpaused();
paused = false;
emit Unpaused(msg.sender);
}
/// @notice Emergency ERC20 recovery. Explicit destination, onlyOwner. Works even while paused.
function rescueTokens(address token, address to) external onlyOwner nonReentrant {
if (to == address(0)) revert ZeroAddress();
uint256 balance = IERC20(token).balanceOf(address(this));
if (balance == 0) revert NothingToRescue();
IERC20(token).safeTransfer(to, balance);
emit TokensRescued(token, to, balance);
}
/// @notice Emergency native ETH recovery. Explicit destination, onlyOwner.
function rescueETH(address to) external onlyOwner nonReentrant {
if (to == address(0)) revert ZeroAddress();
uint256 balance = address(this).balance;
if (balance == 0) revert NothingToRescue();
(bool ok,) = to.call{value: balance}("");
if (!ok) revert ETHTransferFailed();
emit ETHRescued(to, balance);
}
}
Chain explorer5206msChain node82ms