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)

ComponentAddress
SpotRouter0x780672aDA90Ed7cf2C3E8B70DBa87A19d584c8B0
SpotPoolRegistry0xB601bc1099B040E4882089D94690F7C38AF4CCD2
OperatorPermissionsRegistry0xE7a190736B6024a4DbafadC04E283075877005ce

Testnet (Somnia Shannon, chain ID 50312)

ComponentAddress
SpotRouter0x0aA7c584074d2EA5B623772F97928baD23915ba8
SpotPoolRegistry0x07A29A0A086Bc8262a9320db93E603eE13D57962
OperatorPermissionsRegistry0x15C7e8CE38F021c5b45d098AaD788f63090bF20A

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:

  1. Pool registration — SpotPoolRegistry.isRegistered(pool) per leg. Curated by the registry owner via registerPool / unregisterPool.
  2. Operator approval — OperatorPermissionsRegistry.isApproved(user, router, placeOrderFor.selector, pool). Granted once globally per user; covers every pool the registry will ever list.
  3. 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 forwards msg.value directly to leg 0; no allowance needed.

Caller prerequisites

Before invoking the router for the first time, a caller MUST set up:

  1. Operator approval on the OperatorPermissionsRegistry. A single call covers every official pool. See Granting operator approval below.
  2. Per-token ERC-20 allowance on each leg's pool. The pool auto-pulls input via transferFrom. One approve(pool, MAX_UINT256) per pool per token is enough.
  3. (Native input only) Exact-equal msg.value. For swapExactIn with native input, msg.value == params.inputAmount. For swapExactOut, 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.

solidity
function swapExactIn(SwapExactInParams calldata params)
    external payable
    returns (uint256 amountOut, uint256 amountInUsed);

The struct:

solidity
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
}
ParameterTypeDescription
inputTokenaddressERC-20 address or the NATIVE_TOKEN sentinel for SOMI.
inputAmountuint256Exact amount of inputToken to spend (raw units, value × 10^decimals).
outputTokenaddressFinal leg's output token; must equal the last leg's output.
minOutputAmountuint256Minimum acceptable outputToken received. Reverts with InsufficientOutput if not met.
routeSwapLeg[]Ordered legs. quantity must be 0. priceLimit is the worst-acceptable price per leg, tick-aligned to the pool's tickSize.
deadlineNsuint64Latest 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.value must be 0 (InvalidMsgValue). For native input, msg.value must equal inputAmount exactly — 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.

solidity
function swapExactOut(SwapExactOutParams calldata params)
    external payable
    returns (uint256 amountIn, uint256 amountOutReceived);

The struct:

solidity
struct SwapExactOutParams {
    address inputToken;
    uint256 maxInputAmount;
    address outputToken;
    uint256 outputAmount;
    SwapLeg[] route;
    uint64 deadlineNs;
}
ParameterTypeDescription
inputTokenaddressERC-20 address or NATIVE_TOKEN sentinel.
maxInputAmountuint256Maximum acceptable inputToken spend. Reverts with ExcessiveInput if exceeded.
outputTokenaddressFinal leg's output token.
outputAmountuint256Exact amount of outputToken to receive.
routeSwapLeg[]Ordered legs. Each quantity MUST be lot-aligned and non-zero; priceLimit tick-aligned.
deadlineNsuint64Latest 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.

solidity
function quoteMarketExactIn(SwapLeg[] calldata route, address inputToken, uint256 inputAmount)
    external view
    returns (QuoteResult memory result);

The result:

solidity
struct QuoteResult {
    bool ok;
    uint256 amountIn;
    uint256 amountOut;
    LegQuote[] legs;
}

struct LegQuote {
    uint256 baseQuantity;
    uint256 amountIn;
    uint256 amountOut;
    uint256 worstFillPrice;
    bool fullyFilled;
}
ParameterTypeDescription
routeSwapLeg[]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.
inputTokenaddressInput token (ERC-20 address or NATIVE_TOKEN).
inputAmountuint256Budget to consume on leg 0.

Result semantics:

  • amountIn — what would actually leave the caller's wallet (includes the auto-pull fee envelope, sized by max(takerFee, makerFee)).
  • amountOut — projected output token received.
  • legs[i].worstFillPrice — the deepest price the natural walk would touch on leg i. Use this to size the live swap's priceLimit (see Quote → swap recipe).
  • ok — true if every leg fully filled. When ok == false && amountOut > 0, the visible 64-level book ran out before the budget consumed; the live swap still executes for the partially-filled amount. When ok == 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 quoteExactIn for market-style UIs? quoteExactIn is a priceLimit-pinned IOC preview. A permissive priceLimit (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. quoteMarketExactIn walks 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. Use quoteMarketExactIn for any "what would I get right now?" UI.


quoteExactIn

Pure view function. Limit-style preview — caller supplies an explicit priceLimit per leg.

solidity
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.

solidity
function quoteExactOut(SwapLeg[] calldata route, address inputToken, address outputToken, uint256 outputAmount)
    external view
    returns (QuoteResult memory result);
ParameterTypeDescription
routeSwapLeg[]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.
inputTokenaddressInput token.
outputTokenaddressOutput token.
outputAmountuint256Exact 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.


Builder attribution

Every swap and quote entrypoint has a …WithBuilder twin that tags the swap with a builder address and charges that builder's fee. The plain entrypoints above are unchanged and remain the right choice when you are not attributing.

The fee is charged once, on leg 0 only — not per leg. A three-leg route pays the same builder fee as a one-leg route. It is collected inside leg 0's placeOrderFor, so it lands in the builder's SpotPool vault balance for that leg's input token, withdrawable exactly like any other builder fee (see Builder Fees). Attribution itself is swap-level: one SwapAttributed event covers the whole route.

solidity
function swapExactInWithBuilder(SwapExactInParams calldata params, address builder, uint96 builderFeeBpsTimes1k)
    external payable
    returns (uint256 amountOut, uint256 amountInUsed);

function swapExactOutWithBuilder(SwapExactOutParams calldata params, address builder, uint96 builderFeeBpsTimes1k)
    external payable
    returns (uint256 amountIn, uint256 amountOutReceived);

function quoteExactInWithBuilder(SwapLeg[] calldata route, address inputToken, uint256 inputAmount, uint96 builderFeeBpsTimes1k)
    external view returns (QuoteResult memory result);

function quoteExactOutWithBuilder(SwapLeg[] calldata route, address inputToken, address outputToken, uint256 outputAmount, uint96 builderFeeBpsTimes1k)
    external view returns (QuoteResult memory result);

function quoteMarketExactInWithBuilder(SwapLeg[] calldata route, address inputToken, uint256 inputAmount, uint96 builderFeeBpsTimes1k)
    external view returns (QuoteResult memory result);

Quote with the same rate you intend to swap with. The builder fee comes out of leg 0's principal budget, so a quote taken at rate 0 overstates what a swap at rate r will deliver. Passing builderFeeBpsTimes1k = 0 to any …WithBuilder quote reproduces the plain quote exactly.

Prerequisites, all enforced at swap time against the live pool state:

  • The user must have called approveBuilder on the leg-0 pool for this builder, for at least builderFeeBpsTimes1k. Read the effective ceiling with getEffectiveBuilderApproval, which already folds in the pool cap.
  • The leg-0 pool's getMaxBuilderFeeBpsTimes1k() must be ≥ the rate. Every mainnet and testnet pool currently runs a 100000 (100 BPS) cap.
  • The builder may not be the router itself, nor any pool on the route.

quoteExactOutRequiredInput

Returns the quote and the exact maxInputAmount to pass to the matching swapExactOut. Use this rather than padding a quote with headroom.

solidity
function quoteExactOutRequiredInput(
    SwapLeg[] calldata route,
    address inputToken,
    address outputToken,
    uint256 outputAmount,
    uint96 builderFeeBpsTimes1k
) external view returns (QuoteResult memory result, uint256 requiredMaxInputAmount);

requiredMaxInputAmount is both necessary and sufficient: passing it verbatim can neither revert InsufficientLegInput at leg 0 nor ExcessiveInput at the end.

For native input this is the only figure that works — not merely a safe upper bound. Exact-out with native input requires maxInputAmount == msg.value, and msg.value to equal leg 0's native requirement exactly, because the pool's auto-pull enforces strict equality. Padded headroom reverts where this value succeeds.

requiredMaxInputAmount is 0 whenever result.ok == false — there is no executable swap to size, and you must not submit one. Pass builderFeeBpsTimes1k = 0 to size a plain swapExactOut.


maxLegs

solidity
function maxLegs() external view returns (uint256);

Returns the cap on route.length. Default is 8.


getSpotPoolRegistry

solidity
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.

solidity
event SwapExecuted(
    address indexed caller,
    address indexed inputToken,
    address indexed outputToken,
    uint256 amountIn,
    uint256 amountOut
);
ParameterTypeDescription
calleraddressOrder owner (msg.sender to the router, the auto-deliver target).
inputTokenaddressInput token (ERC-20 address or NATIVE_TOKEN).
outputTokenaddressFinal leg's output token.
amountInuint256Caller's wallet input delta. For native input, exactly msg.value.
amountOutuint256Caller's wallet output delta.

SwapAttributed

Emitted once per builder-attributed swap, alongside SwapExecuted. Attribution is swap-level, not leg-level — one event covers the whole route regardless of how many legs it had. Plain (unattributed) swaps do not emit it, and SwapExecuted's own signature is unchanged, so existing indexers are unaffected.

solidity
event SwapAttributed(address indexed caller, address indexed builder, uint96 builderFeeBpsTimes1k);

topic0: 0x2abe99d2513ecc9757b8244d99f4d831157b1b03dea4e046500a77cefbc21eff

ParameterTypeDescription
calleraddressThe swapping user (indexed).
builderaddressBuilder credited with the swap (indexed).
builderFeeBpsTimes1kuint96Rate applied to leg 0, in BPS_TIMES_1K units.

The fee amount is not in this event — it is in the leg-0 pool's BuilderFeeCharged, which names the token and the exact figure.

Errors

The router decodes a fine-grained error surface so UIs can map specific reverts to user-actionable copy.

ErrorWhen
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.
LegPlacementRejected(legIndex, reason)The pool refused leg legIndex. reason is the pool's raw revert data — decode its first 4 bytes against SpotPool errors. In practice: ImmediateOrCancelNoFill (exact-in leg filled nothing), FillOrKillNotFillable (exact-out leg unfillable), or SelfMatchCancelTaker (you are the resting maker on the other side).
LegPlacementRevertedWithoutReason(legIndex)Leg legIndex reverted with empty returndata — a child-frame out-of-gas, not a decision by the pool. Raise the gas limit; do not treat it as "no liquidity".
LegFillFailed(legIndex)Legacy path: inner placeOrderFor returned success = false. Only reachable against a pre-upgrade pool; an upgraded pool reverts and surfaces as LegPlacementRejected instead.
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.
RouterBuilderFeeWithoutBuilder(rate)Non-zero builderFeeBpsTimes1k with builder == address(0).
RouterBuilderCodesNotSupportedOnPool(legIndex, pool)The leg-0 pool's builder cap is 0, so it cannot charge a builder fee.
RouterBuilderFeeExceedsPoolCap(...)Rate exceeds the leg-0 pool's getMaxBuilderFeeBpsTimes1k().
RouterBuilderNotApproved(legIndex, pool, rate, approved)User has not approveBuilder'd this builder on the leg-0 pool for at least rate.
RouterBuilderIsPool(legIndex, pool)The builder address is one of the route's pools.
RouterBuilderIsRouter()The builder address is the router itself.
Solidity errorUX copy
LegPlacementRejected / LegFillFailed"No liquidity at your price — try a smaller amount or widen slippage."
↳ inner SelfMatchCancelTaker"You're already resting an order on the other side of this market. Cancel it first."
LegPlacementRevertedWithoutReason"The swap ran out of gas. Try again with a higher gas limit."
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.

solidity
// 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:

bash
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:

bash
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:

solidity
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-interaction Claude 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).