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 registrationSpotPoolRegistry.isRegistered(pool) per leg. Curated by the registry owner via registerPool / unregisterPool.
  2. Operator approvalOperatorPermissionsRegistry.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).
  • oktrue 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.


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.

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.
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.
Solidity errorUX 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.

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