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.
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
- 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_ONLYincluded, 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
dexVenuePolicyhas no field on the Swap settings screen. Of the direct-pool family onlydexDirectPoolsEnabledanddexPoolRiskAckRequiredare 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 threedex*keys and none of these. -
The Pairs edit dialog shows
venuePolicyand does not save it.PUT /api/admin/dex/pair/{id}writespoolAddress,indexerId,pricePrecision,amountPrecision,defaultSlippageBps,isHot,isTrendingandliquidityUsd— 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.
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 unitsThe 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
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.
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:
dexDirectPoolDefaultSlippageBpsmay not exceed the effective ceiling, which is the lower ofdexMaxSlippageBpsanddexDirectPoolMaxSlippageBps.- That effective ceiling may not fall below
dexMinSlippageBps, or no value is pickable on a direct route. dexDirectPoolMaxImpactBpsmay not exceeddexMaxPriceImpactBps— 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.- 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
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
-
Leave the global policy alone unless you have a concrete reason. The default cannot re-route a pair any aggregator will quote.
-
Register and verify the pool before you change any policy — a
DIRECT_ONLYpair with no bound pool refuses every quote. See Direct pools. -
Switch
dexDirectPoolsEnabledon (Super Admin, Swap settings → Liquidity). Nothing routes until this is on. -
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. -
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_ONLYexists for, and the only one.