Venue routing: who fills a swap and who wins when both can

dexVenuePolicy and the direct-pool settings family — the five policy values, why DIRECT_ONLY has no global form, and how a pool has to beat an aggregator on net output before it fills a trade.

12 min readUpdated 6 August 2026routing, venue, direct-pools, aggregators, settings

Every quote can be answered by two kinds of venue: an aggregator — 0x, 1inch, KyberSwap, LI.FI, Odos, Jupiter, SunSwap, STON.fi — or a direct pool, which on this platform means a pool you registered and, usually, seeded with your own money.

dexVenuePolicy decides which of the two is allowed to answer, and what happens when both do. It is the one setting in this addon where the operator's interest and the user's interest can diverge, so it is worth reading before you touch it.

dexDirectPoolsEnabled ships off and is Super-Admin-only. It is checked first, before any model query or RPC call, so while it is off every quote goes to an aggregator and the whole of this page is inert. Turning it on is the decision; the settings below are how it behaves afterwards.

The five values

Value Behaviour
AGGREGATOR_ONLY Direct venues are never candidates. The adapter is not even constructed
AGGREGATOR_PREFERRED Both race. The aggregator wins any tie and any sub-threshold margin
BEST_EXECUTION Both race. The net-plus-margin rule decides
DIRECT_ONLY_WHEN_UNQUOTED The default. A direct pool is used only when no aggregator quoted at all
DIRECT_ONLY Per pair only. Aggregators are not consulted

The default is the most conservative policy that still delivers the feature. An aggregator that answered wins outright — no margin, no comparison — so switching direct pools on cannot re-route a major pair. The direct venue is reached only where the requirement actually lives: a pair no aggregator will quote. Fantom Opera is the clearest case in the registry; it has no aggregator coverage at all, so it is quotable only this way.

AGGREGATOR_PREFERRED and BEST_EXECUTION behave identically today — the strict comparison below already gives the aggregator every tie. They are kept apart because choosing one is a statement about what you want, and collapsing them would silently change that statement if the rule ever diverges.

DIRECT_ONLY is refused as a global value, by name

Save DIRECT_ONLY globally and the settings route refuses the whole patch with:

DIRECT_ONLY is a per-pair setting and has no global form. Globally it would route every pair — including the majors — through whatever pool is bound, at a worse fill no user could see the cause of. Set it on the pair instead.

The refusal is at the coercion layer, not in a handler, and it is deliberate that it refuses rather than degrades. Per pair, DIRECT_ONLY is a legitimate statement: this is my token, my pool, do not bother asking 0x. Globally it would send WETH/USDC through whatever pool happened to be bound. An operator who set it and saw the form accept it would believe something the server was not doing, so the enum member does not exist in the global set at all — and a value that reaches the reader anyway degrades to the safe default rather than being honoured.

Per pair: dexPair.venuePolicy

  1. The default — use the global policy

The column accepts INHERIT plus all five values, defaults to INHERIT, and is resolved per quote:

  • INHERIT, blank, or anything unrecognised → the global policy. Falling back to the operator's platform-wide choice is a better guess at their intent than any hardcoded default.
  • Anything else → that value, DIRECT_ONLY included, because this is the per-pair resolution point.

The model refuses to save DIRECT_ONLY on a pair with no bound poolId — otherwise every quote on that pair would refuse with NO_ROUTE. On the Pairs console the column sits at priority 1 and DIRECT_ONLY is the only value toned as a warning. The three that keep an aggregator in the race — AGGREGATOR_ONLY, AGGREGATOR_PREFERRED and BEST_EXECUTION — are toned primary, and INHERIT and DIRECT_ONLY_WHEN_UNQUOTED are neutral. So the one tone that stands out is the one market an operator has opted out of best execution on.

Two gaps, both verified against the code, and both mean the effective policy on a fresh install is the default everywhere:

  • The global dexVenuePolicy has no field on the Swap settings screen. Of the direct-pool family only dexDirectPoolsEnabled and dexPoolRiskAckRequired are rendered (the Liquidity tab). The rest are mirrored as defaults so the console can state them, but there is no control. It is also not on Admin → System → Settings, which carries three dex* keys and none of these.

  • The Pairs edit dialog shows venuePolicy and does not save it. PUT /api/admin/dex/pair/{id} writes poolAddress, indexerId, pricePrecision, amountPrecision, defaultSlippageBps, isHot, isTrending and liquidityUsd — and nothing else. A change to the venue field is accepted by the form and dropped by the handler, with no error.

The two gaps do not have the same workaround. The global key is writable without a field: dexVenuePolicy is in the DEX settings list, so the settings endpoint below sets it. A pair's venuePolicy has no writer anywhere — the settings endpoint ignores any key that is not a DEX settings key and can never reach a dexPair row, and neither the pair PUT, the create POST nor the status switch sets the column. In this build a per-pair policy is a direct database edit. Read a pair's venuePolicy column back after any attempt to change it from the console; if it still says INHERIT, it was not saved.

PUT/api/admin/dex/settingspermission: edit.dex.settings
Writes any key in the DEX settings list, including dexVenuePolicy and every direct-pool threshold. Changed keys only; an out-of-range value is refused with the number it disagreed with.

Priority orders the race; it does not pick the winner

dexProvider.priority is the field operators most often mistake for a preference ranking. It is not one.

Adapters are asked in priority ASC, name ASC order, and that ordering exists so the same request always races the same sequence and a dedup key computed over the result keeps matching itself. 0x sits at priority 10 and the four EVM aggregators added alongside it sit at 20 — not because 0x is better, but because it is the one venue whose buy-side fee accrual has a fork test behind it, so on an exact tie it is the one whose accrual is already proven.

The winner is chosen on net output, in a separate, pure module:

net = buyAmount
    − the fee, when the vendor takes it on the BUY side
    − gas, converted into buy-token units

The obvious implementation — highest buyAmount wins — is wrong in the direction that looks right on a dashboard. Gas is paid by the user out of the same trade, so a route quoting 0.3% more output at four times the gas leaves the user with less and wins every gross comparison, consistently.

When gas cannot be converted — that needs a USD price for both the native token and the buy token, and on a thin token there may be neither — the whole comparison drops to gross rather than pretending gas is free, and the fallback is recorded on the quote row. Mixing a net score against a gross one would systematically favour the quote we know least about.

dexDirectPoolWinMarginBps — how much better a pool must be

dexDirectPoolWinMarginBpstype: numberdefault: 50
How much better a direct pool's net output must be before it displaces an aggregator, in basis points.

This number exists because of a conflict of interest, and it is worth stating plainly rather than hiding.

An aggregator's net output is net of your own integrator fee. A direct pool's net output is net of nothing, because a raw AMM router has no integrator-fee hook to take one. So at a zero margin a direct pool wins any time it is within roughly your fee of the aggregator — and the platform routes away from its own revenue on a rounding difference, invisibly, on every trade.

There were two ways to fix that:

  • Rejected — compare gross output. That protects the fee by routing users to a worse fill. It is the opposite of best execution.
  • Chosen — compare net output, which is what the user actually receives, and require a material margin before displacing a route that has third-party competition, MEV protection and a vendor SLA behind it.

Three mechanics follow:

  • The comparison is strict. Exactly at the margin resolves to the aggregator. The burden of proof is on the venue displacing the incumbent.
  • The margin doubles under the gross fallback. A gross comparison systematically flatters the venue with no fee, which is always the direct one, so when either side fell back to gross the requirement becomes dexDirectPoolWinMarginBps × 2.
  • Zero is allowed. The floor on this key is 0, not 1, because "best net output wins outright" is a coherent operator choice. It is not one we recommend, and the reason is the conflict of interest above rather than an arithmetic error.

The margin, the two net figures and the required margin are all written to the quote's route summary, so "why that venue" is answerable after the fact from the quote log at /admin/dex/quote.

The four gates a direct pool passes before it can race

Each names both its threshold and the observed value — naming only the threshold tells you nothing you can act on, naming only the observation tells you nothing about what would be enough.

Gate Setting Default Refusal
Pool is ACTIVE and its verification is fresh dexPoolVerifyMaxAgeHours 48 POOL_NOT_ALLOWLISTED
Reserves are worth at least this much dexDirectPoolMinLiquidityUsd $250,000 POOL_TOO_SHALLOW
This trade as a share of reserves dexDirectPoolMaxTradeToLiquidityBps 100 (1%) POOL_TOO_SHALLOW
Price impact of this trade dexDirectPoolMaxImpactBps 100 (1%) DIRECT_POOL_IMPACT_CEILING

Two behaviours in that table are counter-intuitive and both are deliberate:

  • A pool whose reserves cannot be priced is refused, not passed. That is exactly the pool whose depth cannot be vouched for — which is to say exactly the pool the floor exists for. Letting a null through would make the floor a no-op on precisely the tokens it was written to catch.
  • On a direct route an uncomputable price impact refuses (DIRECT_POOL_IMPACT_UNCOMPUTABLE), where on an aggregator route it only warns. A vendor that does not report impact is not evidence of anything; here we hold the reserves ourselves, so a null means the pool state is degenerate.

The ratio gate is not a duplicate of the impact gate. On constant product, impact is roughly trade size over reserves for small trades, so the 1% ratio cap is about 100 bps of impact by construction — a structural bound that still holds when no impact figure can be computed at all. A swap at 2% of reserves has roughly 200 bps of impact, which is under a 300 bps warn threshold and over the ratio cap, and is refused there and nowhere else.

Under the racing policies a gated pool does not take the quote down: the aggregator wins and the gate's refusal is recorded as the reason the pool did not enter the race. Under DIRECT_ONLY and DIRECT_ONLY_WHEN_UNQUOTED there is no fallback, so the refusal is what the user sees.

The rest of the direct-pool settings family

All of these are real keys with real defaults and no field on any admin screen. They are listed here with what the server does with each value, not with what the name suggests.

dexDirectPoolsEnabledtype: booleandefault: false
Master switch for the whole venue class. Super Admin only. Checked before any query or RPC call.
dexDirectPoolV3Enabledtype: booleandefault: true
Whether V3 pools may be quoted. On, because both standards ship — the key exists so an operator can drop the QuoterV2 dependency, a gas-hungry eth_call some public RPCs refuse, without disabling the venue class.
dexDirectPoolMaxSlippageBpstype: numberdefault: 100
Ceiling on the slippage written into direct-pool calldata. TIGHTER than the global 500 on purpose.
dexDirectPoolDefaultSlippageBpstype: numberdefault: 100
Default slippage on a direct route. WIDER than the global 50 — a thin pool genuinely does move.
dexDirectPoolOperatorShareWarnBpstype: numberdefault: 5000
Above this share of a pool's liquidity, the operator IS the market. Drives a permanent warning on the position, never a refusal.
dexDirectPoolQuoteTimeoutMstype: numberdefault: 2500
Ceiling on one QuoterV2 eth_call before the direct candidate is abandoned for that quote.
dexPoolRiskAckRequiredtype: booleandefault: true
Whether seeding requires the six-clause market-risk notice. Super Admin only.

Four cross-field rules are enforced on save, and each exists because the value would otherwise be silently clamped on read — the console echoing your number while the server used a different one:

  1. dexDirectPoolDefaultSlippageBps may not exceed the effective ceiling, which is the lower of dexMaxSlippageBps and dexDirectPoolMaxSlippageBps.
  2. That effective ceiling may not fall below dexMinSlippageBps, or no value is pickable on a direct route.
  3. dexDirectPoolMaxImpactBps may not exceed dexMaxPriceImpactBps — above it the direct ceiling can never fire, because the global one refuses first, so a thin pool would end up governed by the loose threshold.
  4. Every numeric key must be a whole number and non-blank. A cleared field coerces to zero through Number(), which is how a slippage cap becomes 0.

The tolerance in the calldata is public. An attacker reads amountOutMin straight out of the mempool, so a wide tolerance on a thin pool is a standing, publicly visible invitation to sandwich for exactly that amount. That is why the direct ceiling is tighter than the global one rather than a re-tune of it, and why the refusal rules above exist instead of a clamp.

dexDirectPoolPrivateRpcRequired

dexDirectPoolPrivateRpcRequiredtype: booleandefault: false
Warn when a direct-pool swap would be broadcast to a public mempool rather than a protected endpoint.

The reason this key exists is a real asymmetry between the two venues. An aggregator route is built by a settlement contract that competes for order flow and generally has MEV protection behind it. A direct-pool swap is a plain router call whose amountOutMin is visible in the mempool, against a pool the operator sized — so it is sandwichable in a way an aggregator route is not, and the operator is on both sides of that: they own the pool the sandwich is executed against and they chose the venue the user was routed to.

The key is declared, defaulted, coerced and mirrored on the console, and no code path consumes it. Setting it to true produces no warning, no refusal and no log. It documents an intention rather than enforcing one.

Treat it as unimplemented and get the protection from the endpoint instead: put a private or protected RPC in the chain row's Server RPC field on the Chains console, which is the field that is never serialised to a browser.

A direct fill earns you nothing

feeRecipient and feeBps are an aggregator settlement-contract feature. A Uniswap V2 router's swapExactTokensForTokens has nowhere to put a fee, and neither does exactInputSingle on a V3 router. So on a direct route the fee resolver short-circuits before it even reads the chain's fee recipient and reports VENUE_NO_FEE_HOOK.

That is a property of the contract, not a misconfiguration — and it is the other half of why the win margin is set the way it is. Every trade you route to your own pool is a trade that pays no integrator fee, and what you earn instead is the pool's own LP fee, which is not platform revenue until it is withdrawn and recorded.

The whole argument, including the four checkable conditions under which the decision gets revisited, is in Why a direct pool earns no swap fee. What it means for your books is in Liquidity positions.

A working order of operations

  1. Leave the global policy alone unless you have a concrete reason. The default cannot re-route a pair any aggregator will quote.

  2. Register and verify the pool before you change any policy — a DIRECT_ONLY pair with no bound pool refuses every quote. See Direct pools.

  3. Switch dexDirectPoolsEnabled on (Super Admin, Swap settings → Liquidity). Nothing routes until this is on.

  4. Watch the quote log for race: reasons before narrowing anything. It records both net figures and the margin, so you can see whether your pool would have won before you make it the only option.

  5. Set a per-pair policy only for your own token, and only on a pair whose pool you seeded and monitor. That is the case DIRECT_ONLY exists for, and the only one.