Reference
Smart contracts
Four contracts, Solidity 0.8.30, cancun EVM (transient storage), compiled with via_ir. Sources in contracts/src.
| Contract | Deployed? | Holds funds? | Owner powers |
|---|---|---|---|
Executor | Yes | Never between transactions | Fee (≤ 1%), WHYPE allow-list, pause, ownership |
OrderBook | Yes | Escrow of open orders | Keeper allow-list, ownership |
FeeCollector | Yes | Router fees until converted | Keeper, treasury, target token, sweep, ownership |
TokenProbe | No: injected into eth_call by state override | No | None |
The canonical Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3) is used, not deployed.
Executor
Executes and quotes routes by calling pools directly. One Executor serves every V2/V3/V4 factory on the chain.
Entry points
function swap(
address tokenIn, // ERC20, or address(0) for native HYPE (sent as msg.value)
address tokenOut, // ERC20, or address(0) to receive HYPE
uint256 amountIn,
uint256 minOut, // checked on what `recipient` actually receives
address recipient,
address wrappedNative, // allow-listed WHYPE for wrap/unwrap (ignored if no native side)
uint256 deadline,
Split[] calldata splits
) external payable returns (uint256 amountOut);
function swapWithPermit2(..., same 8 args ..., uint256 permitNonce, uint256 permitDeadline, bytes calldata signature)
external returns (uint256 amountOut); // ERC20 input pulled from msg.sender via Permit2
function quote(Split[] calldata splits) external returns (uint256 amountOut); // call with eth_call onlyRoute encoding
struct Split { uint256 amountIn; Hop[] hops; }
struct Hop {
address pool; // the pool; KIND_REWRAP: the WHYPE to wrap into; KIND_V4: the PoolManager
bool zeroForOne; // token0 -> token1
uint8 kind; // see below
uint16 v2FeeBps; // KIND_V2 only: the pair's fee (30 = 0.3%)
bytes extra; // KIND_V4 only: abi.encode(PoolKey, wrapper)
}kind | Name | Venues | How the hop runs |
|---|---|---|---|
| 0 | KIND_V3 | Uniswap V3 and forks; also Slipstream and Algebra Integral (same swap ABI) | pool.swap(this, zeroForOne, +amountIn, priceLimit, ""); the pool calls uniswapV3SwapCallback (or algebraSwapCallback) and the Executor pays exactly what is owed |
| 1 | KIND_V2 | Uniswap V2 and forks | Transfer the input to the pair, compute the output from reserves and the amount actually received (balanceOf(pair) − reserve) with v2FeeBps, call pair.swap |
| 2 | KIND_REWRAP | WHYPE ↔ WHYPE | withdraw the current WHYPE, deposit into hop.pool. Both must be allow-listed |
| 3 | KIND_V4 | Uniswap v4 (with hooks) | PoolManager.unlock → in unlockCallback: swap, then sync/settle the input and take the output. Native sides are unwrapped/wrapped around the hop with wrapper |
| 4 | KIND_SOLIDLY | Solidly family: Velodrome v2, Aerodrome, Nest classic, Ramses legacy (volatile and stable) | Transfer the input to the pair, price the output with pair.getAmountOut(actualIn, tokenIn), call pair.swap |
Kind 4 and the Algebra/Slipstream support are the Nest and Aero adapters; see Supported venues.
Rules checked on-chain:
Σ splits[i].amountIn == amountIn, each leg's first hop starts attokenIn(or its WHYPE) and its last hop ends attokenOut(or its WHYPE). OtherwiseInvalidRoute().- Within a leg, hop i's output is hop i+1's input; amounts are measured by balance deltas, so fee-on-transfer tokens work at every step. A taxed input is scaled across the legs.
- Legs run one after another; legs that share a pool see each other's impact (the API only builds pool-disjoint legs).
- The price limit is the extreme tick, so a V3 hop is limited only by liquidity. If a pool can't take the whole input:
PartialFill().
Execution steps
deadline, pause and reentrancy checks.- Pull the input (
transferFromor Permit2), or wrapmsg.value. Record what actually arrived. - Run every leg.
fee = floor(totalOut × feeBps / 10,000)→feeRecipient.- Send
totalOut − feetorecipient(unwrapping iftokenOutis native). Measure the recipient's balance before and after; if it grew by less thanminOut:InsufficientOutput(received, minOut). emit Swapped(sender, recipient, tokenIn, tokenOut, amountIn, received, fee).
Quoting
quote(splits) runs each leg's real swaps. In the V3 callback (and in v4's unlockCallback in quote mode) the Executor reverts with the amounts instead of paying, the QuoterV2 technique, and catches the revert to continue to the next hop. V2/Solidly hops are computed from reserves / getAmountOut. quote returns the total output after the router fee. It needs no balance, approval or ownership of anything, and is meant for eth_call only. The API batches 20 of them in one Multicall3 call.
Callback security
The Executor pays pools from inside callbacks, so callbacks are the attack surface. All checks use transient storage (EIP-1153), cleared at the end of the transaction:
| Slot | Set before calling a pool | Checked in the callback |
|---|---|---|
activePool | The pool (or PoolManager) about to be called | msg.sender == activePool, otherwise Unauthorized() |
expectedPayToken | The hop's input token | The token paid is this one, not what the pool's data says |
expectedPayMax | The hop's input amount | The amount paid never exceeds it |
expectedV4Key | keccak256(PoolKey, zeroForOne) | The v4 swap is the one we asked for |
locked | Reentrancy guard |
So a malicious pool (or a contract posing as one) can't get paid in another token, get paid more than the hop's input, or call back outside its own swap. unlockCallback treats every non-quote mode as a payment path and requires the active PoolManager. receive() accepts HYPE only from allow-listed WHYPEs or the active pool.
Admin
| Function | Effect | Guard |
|---|---|---|
setFee(feeBps, feeRecipient) | Router fee and where it goes | feeBps ≤ 100; recipient ≠ 0 and ≠ Executor |
setWrappedNative(token, allowed) | WHYPE allow-list | |
setPaused(bool) | Stops swap/swapWithPermit2 (and so order fills). quote keeps working | |
transferOwnership(newOwner) / acceptOwnership() | Two-step ownership transfer | New owner must accept |
The owner cannot move users' funds: the Executor holds none, and there is no sweep or arbitrary-call function.
Events and errors
event Swapped(address indexed sender, address indexed recipient, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint256 fee);
event FeeConfigUpdated(uint16 feeBps, address feeRecipient);
event WrappedNativeUpdated(address token, bool allowed);
event PausedSet(bool paused);
event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
error Expired(); error InvalidRoute(); error InvalidNativeConfig(); error PartialFill();
error InsufficientOutput(uint256 amountOut, uint256 minOut); error Unauthorized(); error TransferFailed();
error FeeTooHigh(); error IsPaused();OrderBook
Escrow for limit orders and DCA. Full behavior: Limit orders and DCA.
constructor(Executor executor, address owner);
function create(address tokenIn, address tokenOut, uint256 total, uint256 slices, uint256 minOutPerSlice,
uint64 interval, uint64 expiry) external payable returns (uint256 id);
function fill(uint256 id, address wrappedNative, Executor.Split[] calldata splits, uint256 fillerMinOut)
external returns (uint256 amountOut); // allow-listed keepers only
function cancel(uint256 id) external; // owner any time; anyone after expiry; refund to owner
function cancelTo(uint256 id, address to) external; // owner only; refund to `to`
function setKeeper(address keeper, bool allowed) external; // owner
function transferOwnership(address) external; function acceptOwnership() external;| Check | Error |
|---|---|
tokenIn ≠ tokenOut, total ≥ slices > 0, expiry in the future, minOutPerSlice > 0, interval > 0 if slices > 1 | InvalidOrder() |
Native: msg.value == total; ERC20: msg.value == 0 and the full total arrives (no fee-on-transfer) | InvalidOrder() |
fill by a non-keeper | Unauthorized() |
fill before nextFillAt, after expiry, or on an empty order | NotFillable() |
cancel by a stranger before expiry | Unauthorized() |
A fill sends exactly the slice (the last slice takes the remainder), approves the Executor for that exact amount, and calls Executor.swap(..., minOut = max(slice floor, fillerMinOut), recipient = owner, deadline = now). State is updated before the external call.
Events: OrderCreated, OrderFilled, OrderCancelled, KeeperSet.
FeeCollector
The Executor's feeRecipient. Converts collected fees into one target token for the treasury.
constructor(Executor executor, address owner, address keeper, address treasury, address target); // target 0 = native HYPE
function convert(address token, uint256 amount, uint256 minOut, address wrappedNative, Executor.Split[] calldata splits)
external; // keeper or owner: swap `amount` of `token` into `target`, output to treasury
function sweep(address token, uint256 amount) external; // owner: send as-is to the treasury
function configure(address keeper, address treasury, address target) external; // owner
function transferOwnership(address) external; function acceptOwnership() external;Output always goes to the treasury, never to the caller, so a compromised keeper key can at worst convert at a bad price within minOut; the API's keeper guards minOut with a price EMA (see Fees).
Events: Converted(token, amountIn, amountOut), ConfigUpdated(keeper, treasury, target).
TokenProbe
Never deployed. The API injects its bytecode at a fixed address with an eth_call state override, plus 1 HYPE of balance, and calls:
function probe(address ex, address token, address wrappedNative, Executor.Split[] calldata buy, Executor.Hop[] calldata sellHops)
external returns (Result memory);
struct Result { uint256 quotedBuy; uint256 received; uint256 transferSent; uint256 transferReceived;
uint256 quotedSell; uint256 sold; bool sellOk; }It buys through the Executor, transfers to a second address, and sells back, measuring each step. It uses low-level calls so tokens that don't return a bool (USDT-style) work. See Token safety.
Deployment
contracts/script/Deploy.s.sol deploys all three and wires them:
| Env | Required | Default | Used for |
|---|---|---|---|
WRAPPED_NATIVES | ✅ | Comma-separated WHYPEs to allow-list | |
FEE_BPS | 5 | Router fee | |
FEE_RECIPIENT | the FeeCollector | Set it to bypass the FeeCollector | |
KEEPER | deployer | OrderBook keeper and FeeCollector keeper | |
TREASURY | deployer | FeeCollector output | |
DEPLOYER_KEY | --private-key |
Then contracts/script/verify.sh verifies them on the explorer. Full runbook: Running and deploying.
Tests
cd contracts && forge test:
- Fork tests against Elysium testnet pinned at block 24,094,443 (
FORK_BLOCKto override): Executor, OrderBook, fee-on-transfer, FeeCollector, V4 with hooks, Permit2, launchpad. - Adapter fork tests (
test/Adapters.fork.t.sol): Aerodrome volatile, stable and 2-hop, and Slipstream on a Base fork; Algebra (Camelot) on an Arbitrum fork. Quote equals the executed amount. - Mainnet launchpad fork tests (
test/Launchpads.mainnet.fork.t.sol, skipped withoutELYSIUM_MAINNET_RPC_URL;MAINNET_FORK_BLOCKto pin): buy and sell through the Kleos tax hook, ElysPump and Ascend (dynamic fee, ERC20 WHYPE) with exact quotes and an empty Executor. - Liquidity replay (
test/LiquidityV3.fork.t.sol): the V3 add transactions the API built (fixture) replayed byte for byte on a fork; position minted with exactly the computed liquidity and range. - Liquidity on Slipstream and Algebra (
test/LiquidityL2.fork.t.sol): mints built by the API's encoders replayed on Aero (Base) and Camelot (Arbitrum) forks. - Fork-free fuzz and invariant tests (
test/Invariants.t.sol) with mocks of honest and hostile venues (greedy pool, forged callback, partial fill, 5% tax token). Invariants: the Executor ends every call with zero balance; each swap equals its quote; the fee is exact; the OrderBook's escrow equals the sum of open orders.