SpotRouter
The SpotRouter is a multi-stage swap router that walks an ordered list of SpotPool instances as taker orders in a single transaction. It is the contract that powers the Simple Swap frontend and is the recommended on-chain entry point for any aggregator, indexer, or third-party integration that needs multi-hop swaps against the dreamDEX order books.
The router is a pure orchestrator — it holds no user funds across legs. Each leg invokes pool.placeOrderFor(...); the pool auto-pulls input from the caller's wallet and auto-delivers output back to the caller's wallet directly. The router itself never custodies an intermediate balance.
Addresses
The router stack is three contracts: SpotRouter, OperatorPermissionsRegistry, and SpotPoolRegistry.
Mainnet (Somnia, chain ID 5031)
| Component | Address |
|---|---|
SpotRouter | 0x780672aDA90Ed7cf2C3E8B70DBa87A19d584c8B0 |
SpotPoolRegistry | 0xB601bc1099B040E4882089D94690F7C38AF4CCD2 |
OperatorPermissionsRegistry | 0xE7a190736B6024a4DbafadC04E283075877005ce |
Testnet (Somnia Shannon, chain ID 50312)
| Component | Address |
|---|---|
SpotRouter | 0x0aA7c584074d2EA5B623772F97928baD23915ba8 |
SpotPoolRegistry | 0x07A29A0A086Bc8262a9320db93E603eE13D57962 |
OperatorPermissionsRegistry | 0x15C7e8CE38F021c5b45d098AaD788f63090bF20A |
The router stack consumes the SpotPools listed under Contract Specifications.
Architecture
┌────────────────────────────────────────────┐
│ SpotRouter (proxy) │
│ swapExactIn / swapExactOut │
│ quoteMarketExactIn / quoteExactIn │
│ quoteExactOut │
└─────────────┬─────────────────┬────────────┘
│ isRegistered │ placeOrderFor (msg.sender = user)
▼ ▼
┌─────────────────────────┐ ┌──────────────────────────┐
│ SpotPoolRegistry │ │ SpotPool (per pair) │
│ isRegistered(pool) │ │ auto-pull from wallet │
│ registerPool (admin) │ │ auto-deliver to wallet │
└──────────┬──────────────┘ └─────────────┬────────────┘
│ │ isApproved(user, router, ...)
▼ ▼
┌────────────────────────────────────────────────────┐
│ OperatorPermissionsRegistry │
│ setOperatorApprovalGlobal(operator, selectors, ✓) │
│ isApproved(owner, operator, selector, pool) │
└────────────────────────────────────────────────────┘
Three on-chain checks gate every router call:
- Pool registration —
SpotPoolRegistry.isRegistered(pool)per leg. Curated by the registry owner viaregisterPool/unregisterPool. - Operator approval —
OperatorPermissionsRegistry.isApproved(user, router, placeOrderFor.selector, pool). Granted once globally per user; covers every pool the registry will ever list. - Per-pool ERC-20 allowance — the pool's auto-pull does
IERC20(input).transferFrom(caller, pool, amount)on each leg. Granted once per pool per token. Native input forwardsmsg.valuedirectly to leg 0; no allowance needed.
Caller prerequisites
Before invoking the router for the first time, a caller MUST set up:
- Operator approval on the
OperatorPermissionsRegistry. A single call covers every official pool. See Granting operator approval below. - Per-token ERC-20 allowance on each leg's pool. The pool auto-pulls input via
transferFrom. Oneapprove(pool, MAX_UINT256)per pool per token is enough. - (Native input only) Exact-equal
msg.value. ForswapExactInwith native input,msg.value == params.inputAmount. ForswapExactOut,msg.value == params.maxInputAmount.
If the operator approval is missing, the router fails fast in its pre-walk with RouterNotApprovedAsOperator(legIndex, pool) rather than a deep pool-side revert — so the UI can pinpoint the offending leg.
Functions
Every entry below is callable by anyone unless noted. Admin / owner-only entries (registry rotation, leg-cap tuning, recovery helpers) are out of scope for this reference.
swapExactIn
Spends an exact input amount and receives at least minOutputAmount of the output token.
function swapExactIn(SwapExactInParams calldata params)
external payable
returns (uint256 amountOut, uint256 amountInUsed);
The struct:
struct SwapExactInParams {
address inputToken;
uint256 inputAmount;
address outputToken;
uint256 minOutputAmount;
SwapLeg[] route;
uint64 deadlineNs;
}
struct SwapLeg {
address pool;
uint256 priceLimit;
uint256 quantity; // MUST be 0 for swapExactIn
}
| Parameter | Type | Description |
|---|---|---|
inputToken | address | ERC-20 address or the NATIVE_TOKEN sentinel for SOMI. |
inputAmount | uint256 | Exact amount of inputToken to spend (raw units, value × 10^decimals). |
outputToken | address | Final leg's output token; must equal the last leg's output. |
minOutputAmount | uint256 | Minimum acceptable outputToken received. Reverts with InsufficientOutput if not met. |
route | SwapLeg[] | Ordered legs. quantity must be 0. priceLimit is the worst-acceptable price per leg, tick-aligned to the pool's tickSize. |
deadlineNs | uint64 | Latest acceptable timestamp in nanoseconds. Reverts with DeadlineExpired if exceeded. |
Returns: amountOut — the outputToken delta on the caller's wallet. amountInUsed — the inputToken delta (for native input, exactly msg.value).
payable. For ERC-20 input,msg.valuemust be0(InvalidMsgValue). For native input,msg.valuemust equalinputAmountexactly — the router forwards it to leg 0's pool, which enforces strict equality on the auto-pull.
swapExactOut
Receives an exact output amount and spends at most maxInputAmount of the input token.
function swapExactOut(SwapExactOutParams calldata params)
external payable
returns (uint256 amountIn, uint256 amountOutReceived);
The struct:
struct SwapExactOutParams {
address inputToken;
uint256 maxInputAmount;
address outputToken;
uint256 outputAmount;
SwapLeg[] route;
uint64 deadlineNs;
}
| Parameter | Type | Description |
|---|---|---|
inputToken | address | ERC-20 address or NATIVE_TOKEN sentinel. |
maxInputAmount | uint256 | Maximum acceptable inputToken spend. Reverts with ExcessiveInput if exceeded. |
outputToken | address | Final leg's output token. |
outputAmount | uint256 | Exact amount of outputToken to receive. |
route | SwapLeg[] | Ordered legs. Each quantity MUST be lot-aligned and non-zero; priceLimit tick-aligned. |
deadlineNs | uint64 | Latest acceptable timestamp in nanoseconds. |
Exact-out legs execute as Fill-or-Kill (FOK). If any leg cannot fill its required quantity at its priceLimit, the router reverts with LegFillFailed(legIndex) and the entire swap unwinds.
quoteMarketExactIn
Pure view function. Walks the opposite-side book naturally to project what swapExactIn would deliver right now.
function quoteMarketExactIn(SwapLeg[] calldata route, address inputToken, uint256 inputAmount)
external view
returns (QuoteResult memory result);
The result:
struct QuoteResult {
bool ok;
uint256 amountIn;
uint256 amountOut;
LegQuote[] legs;
}
struct LegQuote {
uint256 baseQuantity;
uint256 amountIn;
uint256 amountOut;
uint256 worstFillPrice;
bool fullyFilled;
}
| Parameter | Type | Description |
|---|---|---|
route | SwapLeg[] | Each leg's priceLimit and quantity MUST be 0 — the router derives the leg-internal price via a natural walk. Non-zero values revert with RouterMarketQuoteInvalidPriceLimit. |
inputToken | address | Input token (ERC-20 address or NATIVE_TOKEN). |
inputAmount | uint256 | Budget to consume on leg 0. |
Result semantics:
amountIn— what would actually leave the caller's wallet (includes the auto-pull fee envelope, sized bymax(takerFee, makerFee)).amountOut— projected output token received.legs[i].worstFillPrice— the deepest price the natural walk would touch on legi. Use this to size the live swap'spriceLimit(see Quote → swap recipe).ok—trueif every leg fully filled. Whenok == false && amountOut > 0, the visible 64-level book ran out before the budget consumed; the live swap still executes for the partially-filled amount. Whenok == false && amountOut == 0, no fill is possible — gate the CTA accordingly.
The quote walks at most 64 levels per leg of the opposite-side book. If a real swap would need deeper liquidity, the quote returns ok = false and the caller should adjust the input amount.
Why not
quoteExactInfor market-style UIs?quoteExactInis apriceLimit-pinned IOC preview. A permissivepriceLimit(the obvious recipe — pick something large) collapses bid-leg quotes to zero because the closed-form derivation overestimates the leg's qty cap relative to the actual depth.quoteMarketExactInwalks the book naturally first, then re-runs the closed-form path pinned at the natural walk's deepest price, so bid-leg quotes are accurate. UsequoteMarketExactInfor any "what would I get right now?" UI.
quoteExactIn
Pure view function. Limit-style preview — caller supplies an explicit priceLimit per leg.
function quoteExactIn(SwapLeg[] calldata route, address inputToken, uint256 inputAmount)
external view
returns (QuoteResult memory result);
Identical return shape to quoteMarketExactIn, but each route[i].priceLimit must be non-zero and tick-aligned. Use this when the caller has a specific worst-acceptable price in mind (e.g., a future "limit swap" surface). For market-style "what would I get right now?" UIs, prefer quoteMarketExactIn.
quoteExactOut
Pure view function. Walks back-to-front from the requested output to project the input that swapExactOut would consume.
function quoteExactOut(SwapLeg[] calldata route, address inputToken, address outputToken, uint256 outputAmount)
external view
returns (QuoteResult memory result);
| Parameter | Type | Description |
|---|---|---|
route | SwapLeg[] | Each leg's quantity MUST be the exact output base/quote required on that leg, lot-aligned. priceLimit is the per-leg worst-acceptable price, tick-aligned. |
inputToken | address | Input token. |
outputToken | address | Output token. |
outputAmount | uint256 | Exact output amount the caller wants to receive. |
result.ok is false when any leg's book + priceLimit cannot deliver the required output. The CTA must be gated on ok == true because the live swapExactOut is FOK and reverts on any leg short-fill.
maxLegs
function maxLegs() external view returns (uint256);
Returns the cap on route.length. Default is 8.
getSpotPoolRegistry
function getSpotPoolRegistry() external view returns (ISpotPoolRegistry);
Returns the current SpotPoolRegistry pointer the router consults for the per-leg isRegistered check.
Events
SwapExecuted
Emitted once per successful swap, after all legs have settled.
event SwapExecuted(
address indexed caller,
address indexed inputToken,
address indexed outputToken,
uint256 amountIn,
uint256 amountOut
);
| Parameter | Type | Description |
|---|---|---|
caller | address | Order owner (msg.sender to the router, the auto-deliver target). |
inputToken | address | Input token (ERC-20 address or NATIVE_TOKEN). |
outputToken | address | Final leg's output token. |
amountIn | uint256 | Caller's wallet input delta. For native input, exactly msg.value. |
amountOut | uint256 | Caller's wallet output delta. |
Errors
The router decodes a fine-grained error surface so UIs can map specific reverts to user-actionable copy.
| Error | When |
|---|---|
RouteEmpty() | route.length == 0. |
RouteTooLong(maxLegs) | route.length > maxLegs(). |
DeadlineExpired(deadline, currentTs) | block.timestamp * 1e9 > deadlineNs. |
TokenZero() | Input or output token is the zero address. |
InputEqualsOutput() | inputToken == outputToken. |
InvalidMsgValue(expected, actual) | msg.value mismatch on entry. |
RouteTokenMismatch(legIndex, expected, base, quote) | Running token not in the leg's (base, quote). |
FinalTokenMismatch(expected, actual) | Last leg's output ≠ outputToken. |
RouterInvalidLegPrice(legIndex, priceLimit, tickSize) | priceLimit zero or unaligned (on swap entries — not market quotes). |
PoolNotRegistered(legIndex, pool) | Pool not in the linked SpotPoolRegistry. |
InvalidLegQuantity(legIndex) | quantity != 0 on swapExactIn / quoteMarketExactIn / quoteExactIn; or quantity == 0 on swapExactOut. |
RouterQuantityBelowMinimum(legIndex, qty, minQty) | Leg quantity below pool's minQuantity. |
RouterQuantityNotLotAligned(legIndex, qty, lotSize) | Exact-out leg quantity not lot-aligned. |
InsufficientLegInput(legIndex, available, required) | Running input < leg deposit. |
LegFillFailed(legIndex) | Inner placeOrderFor returned success = false (no liquidity / self-match cancelled / book moved past priceLimit). |
InsufficientOutput(received, minRequired) | Total output delta < minOutputAmount. |
ExcessiveInput(spent, maxAllowed) | Exact-out: total input spend > maxInputAmount. |
NativeIntermediateUnsupported(legIndex) | A non-final leg outputs NATIVE_TOKEN. |
RouterNotApprovedAsOperator(legIndex, pool) | Caller has not granted the router operator approval. |
RouterQuoteInputZero() | Quote called with inputAmount == 0. |
RouterQuoteOutputZero() | Quote called with outputAmount == 0. |
RouterMarketQuoteInvalidPriceLimit(legIndex, providedPriceLimit) | quoteMarketExactIn called with priceLimit != 0 on any leg. |
NativeTransferFailed() / NativeRefundFailed() | Native push to the caller failed. |
InsufficientGasForPayout(gasLeft) | Native-base BUY broadcast with too low a gas limit (the inner SpotPool's payout-path guard tripped). Set ≥ 5,000,000. |
Recommended UX copy mapping
| Solidity error | UX copy |
|---|---|
LegFillFailed | "No liquidity at your price — try a smaller amount or widen slippage." |
InsufficientOutput | "Price moved more than your slippage tolerance. Try again or widen tolerance." |
ExcessiveInput | "Input usage exceeded your max. Try again." |
DeadlineExpired | "Order expired before submission. Try again." |
RouterNotApprovedAsOperator | "Router approval missing — click Approve Router and try again." |
NativeIntermediateUnsupported | "Native (SOMI) cannot be used as an intermediate token." |
PoolNotRegistered | "Pool is not registered with the router. Contact admin." |
FinalTokenMismatch / RouteTokenMismatch | "Route doesn't link up — internal." |
RouterQuantityBelowMinimum | "Amount below the pool's minimum trade size." |
Quote → swap recipe
The router's quote returns the worst-case fill price the trade would touch. The frontend sizes the live priceLimit from that value, inflated by the slippage cushion, so the executed swap delivers the quoted output modulo wei-level rounding.
// 1. Build the quote route — zeros everywhere.
ISpotRouter.SwapLeg[] memory route = new ISpotRouter.SwapLeg[](1);
route[0] = ISpotRouter.SwapLeg({
pool: WETH_USDSO_POOL,
priceLimit: 0,
quantity: 0
});
// 2. Quote.
ISpotRouter.QuoteResult memory q = router.quoteMarketExactIn(route, WETH, 1e18);
require(q.ok, "Insufficient liquidity at this depth");
// 3. Build the swap route — use the quote's worstFillPrice inflated by slippage.
uint256 slippageBps = 50; // 0.5%
ISpotRouter.SwapLeg[] memory liveRoute = new ISpotRouter.SwapLeg[](route.length);
for (uint256 i = 0; i < route.length; i++) {
liveRoute[i] = ISpotRouter.SwapLeg({
pool: route[i].pool,
priceLimit: tickAlignAwayFromUser(q.legs[i].worstFillPrice, slippageBps, isBid[i], tick[i]),
quantity: 0
});
}
// 4. Submit.
ISpotRouter.SwapExactInParams memory params = ISpotRouter.SwapExactInParams({
inputToken: WETH,
inputAmount: 1e18,
outputToken: USDso,
minOutputAmount: q.amountOut * (10_000 - slippageBps) / 10_000,
route: liveRoute,
deadlineNs: uint64((block.timestamp + 30 minutes) * 1e9)
});
(uint256 amountOut, uint256 amountInUsed) = router.swapExactIn(params);
tickAlignAwayFromUser rounds the inflated price away from the user's interest — up for a bid leg (user accepts paying more) and down for an ask leg (user accepts receiving less) — then snaps to the pool's tickSize.
For native input, set inputToken = NATIVE_TOKEN and attach value = inputAmount on the call.
Granting operator approval
A one-time per-wallet call on the OperatorPermissionsRegistry. Covers every official pool the linked SpotPoolRegistry will ever list.
Using cast:
export ROUTER=0x780672aDA90Ed7cf2C3E8B70DBa87A19d584c8B0 # mainnet
export OP_REGISTRY=0xE7a190736B6024a4DbafadC04E283075877005ce # mainnet
export PLACE_ORDER_FOR_SELECTOR=0x80054449 # bytes4(keccak256("placeOrderFor(address,bool,uint64,uint256,uint256,uint64,uint8,uint8,address,uint96)"))
cast send $OP_REGISTRY \
"setOperatorApprovalGlobal(address,bytes4[],bool)" \
$ROUTER "[$PLACE_ORDER_FOR_SELECTOR]" true \
--rpc-url https://api.infra.mainnet.somnia.network/ \
--private-key $PRIVATE_KEY
Verify the grant landed:
cast call $OP_REGISTRY \
"isGloballyApproved(address,address,bytes4)(bool)" \
$YOUR_WALLET $ROUTER $PLACE_ORDER_FOR_SELECTOR \
--rpc-url https://api.infra.mainnet.somnia.network/
For pools that are NOT in the registry (rare — every official pool is registered), use setOperatorApprovalForPool(pool, operator, selectors, approved) instead.
SpotPoolRegistry
The router's per-leg isRegistered(pool) check delegates to the SpotPoolRegistry (mainnet 0xB601bc1099B040E4882089D94690F7C38AF4CCD2, testnet 0x07A29A0A086Bc8262a9320db93E603eE13D57962). The registry is the canonical allowlist of router-routable pools — the same registry is consulted by OperatorPermissionsRegistry when resolving global operator approvals, so a single registration covers both the router-side legitimacy check AND the user's setOperatorApprovalGlobal grant.
Read functions integrators use:
function isRegistered(address pool) external view returns (bool);
function areRegistered(address[] calldata pools) external view returns (bool[] memory);
event PoolRegistered(address indexed pool);
event PoolUnregistered(address indexed pool);
Owner-curated. Pools are added / removed via registerPool / unregisterPool.
Tooling
dex-spot-router-interactionClaude skill — self-contained reference (ABIs, interfaces, error-to-UX map) that any agent can load to integrate with the router without cloning the protocol repo.- Simple Swap — the consumer-facing one-click swap UI built on the router; see Simple Swap for the quote → swap flow.
Native token sentinel
NATIVE_TOKEN = 0x28f34DeFd2b4CB48d9eE6d89f2Be4Bc601694c00. Use this address in SwapLeg.pool-adjacent fields (inputToken, outputToken, SwapExactInParams.inputToken, etc.) when the side is native SOMI. Calling IERC20 on the sentinel reverts — it is a marker, not a deployed contract.
Native may only appear as leg-0 input or final-leg output. The router rejects native as the output of an intermediate leg with NativeIntermediateUnsupported(legIndex).