Path encoding
A route is bytes: tokenA | poolType | tokenB | poolType | tokenC | … — addresses of 20 bytes joined by a single byte of pool type where Uniswap V3 has a three-byte fee tier. The route names the curve, not the price: fees here are governance state a plugin can override per swap, so a fee-bearing route would name a pool that stops existing the moment a fee moved.
Pool type values: CL = 0, CP = 1, STABLE = 2 — an open set.
Exact-output paths are encoded in reverse — token bought first, token sold last — exactly as in Uniswap V3. Encoding them forwards silently prices the wrong trade.
The router
SwapRouter exposes the four V3-shaped entries; the difference from V3 is the poolType field where fee was:
Behavior worth knowing:
- Deadline:
Expired() past it. Slippage: TooLittleReceived() / TooMuchRequested().
- Price limit:
0 means “the widest the pool accepts” (MIN_SQRT_RATIO+1 / MAX_SQRT_RATIO−1) — a real bound, not “no limit”. A limit at or beyond the current price reverts InvalidSqrtPriceLimit(). Multi-hop entries always pass the widest per hop; there is no per-hop limit.
- A binding limit under-delivers instead of failing — and unlike upstream, this router checks the shortfall itself: an exact-output swap that cannot fill reverts
TooLittleReceived() rather than silently delivering less.
- Exact-output pulls only what the swap cost — no ERC-20 change is left behind. Native over-send must be reclaimed by you (below).
- Amounts above
int256.max revert AmountOutOfRange() — beyond it the cast flips exact-input into a monstrous exact-output.
- Gasless approvals:
selfPermit / selfPermitIfNecessary on the router.
Native coin, multicall, and the one hard requirement
The router batches via multicall and settles native through its own balance. Three consequences:
msg.value is not a budget: every batched call sees the full amount. Payment logic spends the contract’s actual balance, so batching stays safe — but do not meter by msg.value.
- The router must hold nothing between transactions. The sweep helpers (
refundNative, unwrapNative, sweepToken) send the whole balance to anyone who calls. Therefore: every entry point that can leave a balance must be called inside one atomic multicall that ends with the matching sweep. This is a hard requirement of the API, not a recommendation — a payable swap sent bare, stopped at its price limit, leaves coin the next caller takes.
- On a chain with no native wrapper (
wrappedNative() == 0 — the Arc case, where the gas token is the quote ERC-20 seen at 18 decimals): refundNative and unwrapNative revert NoNativeWrapper(); use sweepToken. The router’s receive() rejects unsolicited native.
The quoter
- Not a
view — it prices by starting the swap and aborting it from the callback (revert-and-catch). Call it with eth_call; on-chain it burns a swap’s gas.
amountOutReceived on the exact-output quoters reports what would actually arrive when a price limit cuts the swap short — compare routes on it, or you will pick one that cannot fill. V3’s quoter has no equivalent.
- A route may not visit the same pool twice (
PoolVisitedTwice()): each simulated hop rolls back before the next, so a second visit would price against pre-trade reserves. Split such routes or price them yourself.
- Quotes are bids, not promises: dynamic fees price on measured volatility (and may price on the swap’s own size), so a quote ages faster than on a static-fee venue.
The pool-level swap
For integrators who settle themselves:
The pool sends the output, then calls back for payment — pay the positive delta before returning. Verify the caller is a pool from the canonical factory (getPool, not an init-code-hash recomputation). amountSpecified of 0 or int256.min reverts on every pool type. The signature — including the price limit — is uniform across CL, CP and STABLE; a STABLE swap simply resolves in one step instead of a tick loop.
Sibling callbacks for liquidity and flash: lunyaMintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes) and lunyaFlashCallback(uint256 fee0, uint256 fee1, bytes).