swap-router at 2becf83 and the order engine at 94808f0.
Every measured figure on this page names the block it was taken at. Pool state
moves; the numbers are illustrations of shape, not quotes.
The question is inverted on purpose
A trader does not ask what the impact of $500k is. They ask how much they can trade, and the honest answer depends on what they are willing to pay. So/v1/sizing takes an acceptable cost and returns a size, not the other
way round.
That inversion is also what makes it affordable. Asking “what does size S cost”
needs a quote per size, and finding the largest size under a cost is then a
search. Asking “what size costs C” is one probe per level: the pool is given
a price limit, it stops itself when its marginal rate reaches that limit, and it
reports what it moved. The answer falls out of the probe instead of being
searched for.
The default ladder is geometric (5, 10, 25, 50, 100 bps) because the
interesting region is near zero, where a few bps doubles the size, and nobody
makes a different decision between 400 and 500 bps.
Measured on Base at block 50769467, USDC to WETH, 11 candidate pools:
The 5 bps rung is the volatile one. Across runs on the same fork it has come
back at 12,265, 24,498 and 58,360 USDC: the winning pool changes, and a rung
measured over one small pool sits next to one measured over nine. That is why
every level publishes
poolsAnswered alongside its size: two levels measured
over different pool counts are not comparable, and the sizes alone do not say so.
costBps is a marginal bound, not what you pay
This is the single most misread number in the API, and it is misread in the
direction that makes execution look twice as expensive as it is.
costBps is the price the pool is stopped at. It is where the last unit
trades, not the blended price of the whole fill. The average the trade receives
is better, by roughly half:
For a single constant-product pool this is exact: if a trade displaces the
marginal price by δ, the average price paid is displaced by δ/2. That is where
“roughly half” comes from.
It does not survive contact with a real book. Measured across the same ladder at
block 50769564, the ratio between the marginal bound and the average it actually
delivers is neither 2 nor constant:
The book is concentrated liquidity across eleven pools with a winner that
changes between levels, so the “half” heuristic is directional and nothing more.
The same block, priced the other way round through
/v1/quote:
The row that matters: the size the ladder labels “10 bps” costs 2.65 bps.
avgCostPips is null rather than 0 when the dust probe for that pool failed.
Unknown and free are different answers, and an earlier version reported both as
zero.
The bound, stated as a price
costBps is a price bound, but expressed as a distance from a reference you
had to reconstruct yourself, which is a large part of why it gets read as the
cost of the trade. Each level now states both prices outright:
price: what one wholetokenInbuys at this level’s executed averagemarginalPrice, the price the bound actually names:costBpsbelow this level’s own referencequoteDecimalson the response, so either can be rendered
marginalPrice is worse than price at every level, and it has to be: the pool
is stopped at the bound, and the average it delivered on the way there is
better. The gap between the two columns is exactly what avgCostPips measures.
Both are in the direction of the trade and never inverted. A price that
flips to the more human-looking side needs a buy/sell flag to say which way it
went, and a caller who reads that flag wrong is out by the square of the rate.
Ask for the other direction instead: tokenIn=WETH on the same pair reads
2412.991864 USDC per WETH with quoteDecimals: 6.
Cost excludes the LP fee, on purpose
The fee is unavoidable and identical at every size, so folding it into “cost” would report a floor nobody can trade below as though it were something the trader chose.costBps is measured against the best rate the pool could possibly
give; its spot rate already net of the fee.
Each level is measured against its own pool
avgCostPips is computed against a dust trade on that level’s winning pool,
and the pair is published as referenceAmountIn / referenceAmountOut. The
top-level reference is the best rate across every candidate, which is right for
pricing levels against each other and wrong for pricing a fill.
Measuring a fill that routed through a 3 bp pool against a reference taken from
a 1 bp pool folds 2 bps of pure fee difference into the answer and calls it
cost. A fill test read 1,567 pips of “cost” on an 80,000 USDC trade that way, of
which roughly 200 was fee difference and none of it was a charge anyone made.
Basis points do not add across fills
The second recurring error, and it points the same way. Four fills that each cost 5 bps of their own notional cost 5 bps blended, not 20. It is a rate on notional, not a counter. But the correction that matters more is the one underneath it: Splitting an order across time saves nothing unless the price recovers between fills. Four back-to-back swaps on one pool are mathematically identical to one swap of the total: the second starts wherever the first left the price. This is AMM path independence, and it is exact. Measured at block 50769511, USDC to WETH:1.86 bps is seconds of ordinary ETH movement. An order that waits hours for
rungs to refill is exposed to drift that dwarfs what patience earns. Splitting
across pools is free; splitting across time is a trade.
Splitting across pools: a sum, not a maximum
Routing was single-path, so at a given bound the service reported the best single pool’s depth and discarded every other pool’s depth at the same price. That is the wrong aggregation for a bounded order, and the reason is a one-line argument. For a bound P the constraint is per pool: no pool may execute past marginal price P. Pools share no state, so the feasible set is a product and the problem separates. Each pool’s maximum is the amount driving its marginal price to exactly P, and the total is the sum. There is nothing to search, and the code already computed every term, becausefills_at_limits probes every candidate at
a common absolute rate in one batched call. The max on the next line threw the
rest away.
An earlier experiment had measured splitting and concluded the prize was “3–10
bps, and only above ~$1M”. That was correct for the question it asked: at a
fixed size, does splitting get a better average price? A bounded order asks the
opposite question. The price is fixed by construction, so the average cannot
improve and there are no bps to win. What splitting buys is quantity.
Measured through the real quote path at block 50769511: 2,000,000 USDC, bound
25 bps below spot:
+111.5% fillable size, for 1.54 bps of rate. The nine leg weights sum to
exactly 10,000, and leg 3 is the same pool at the same 945,639 that the
single-pool answer returned: the split adds eight pools on top of it rather
than replacing it.
The uplift is a capacity number, not a benefit number. It is worth nothing to an
order the best single pool could already swallow whole. The decision-relevant
figure is how much of an order actually fills (recorded in the service’s own
split_value_by_size run, live Base, 9 routable pools):
= means the splitter changed nothing. Read as a product statement: the
splitter turns partial fills into complete fills, and it earns its keep
exactly where a trader asks for a good price. Below roughly $45k it does nothing
at any bound; at bounds of 50 bps or looser it does nothing until multi-million
sizes.
DeFiLoops sends it on every limit route. Not a plan parameter: at a fixed
rate, filling more of the owner’s own order is what they asked for, so it is the
default rather than a flag nobody sets. Measured on Base: an offer of 450,000
USDC at the rate a 150,000 fill fetches took 74,994 through the best single pool
and 145,572 across eight.
Three things had to be true first, and the flag is the client’s declaration that
they are:
Each of those failures is silent. That is why the service ships the flag off and
makes the client say otherwise.
Seeing the split before you commit to a size
allowSplit executes a split, but you have to have chosen a size first. The
ladder now answers the prior question: how much is there, across everything, at
this bound? Each level carries pools[]: every pool that answered, biggest
slice first, the winner included: plus splitAmountIn and splitAmountOut, the
sums across them.
This costs no extra work. fills_at_limits already probes every candidate at
every level in one batched round; the max that picks the single-path winner
used to discard the rest. Measured on Base at block 50769722:
Each slice carries its own
maxAmountIn, amountOut, saturated, zeroForOne
and reference pair: zeroForOne because it follows the pool’s own sorted token
order and a client recomputing it gets it backwards for half the pools.
That last clause is not a hedge. The same test, run against two forks on the
same day, landed on both sides of it:
When the best single path is multi-hop, a split assembled from direct pools
cannot reach the route the quote used, and loses to it. So
splitAmountIn
answers “how much depth sits in the direct pools at this bound”, which is the
right question for sizing a bounded order, and the wrong one for picking a
route. Compare against a real quote before assuming the split is better.
One claim that was corrected
An early version of this work claimed the blend “can never price worse than the best single pool”. That is true only at equal volume. A split that fills 112% more is necessarily deeper into the book: 1.54 bps in the run above, 0.10 bps in another. The guarantee the product makes is the bound, and every leg stops at it. Filling more costs rate, and that is the trade.Three constraints, routinely confused
They are not variations of one idea. They are a tolerance, a rate, and a cap.slippageBps and limitRate together are refused: with a limit, the floor
is the caller’s own rate, and a second floor derived from the market is a
different number pretending to be the same one. limitRate and maxImpactBps
may be sent together: the limit stops the fill at a price, the cap shrinks the
offer, so both can bite at once, and boundBy names which one did:
"limit", "impact", "both", or empty.
sizedDown is present when maxImpactBps made the trade smaller rather than
refusing it. The quote is complete and buildable either way; the field only
explains why it is not the size that was offered.
A plain floor with no limitRate is a fourth thing again; it is checked at
the end, so it is fill-or-kill. An order for $1m whose price supports 40% moves
nothing at all. That is why a plan’s limit_price reaches the router as a
limitRate only when the step is partially fillable, and takes the plain route
otherwise.
costBps and maxImpactBps are not the same knob
Both take a number in basis points and both hand back a size, which is exactly
why they get substituted for one another. They differ on basis and on
mechanism.
Both can be sent to /v1/quote. costBps there does what /v1/sizing does:
the same ladder, at one level, and then prices the size it arrived at, so the
minAmountOut you get back belongs to the amountIn you get back. Sizing on
one call and pricing on another does not have that property, and the gap is not
theoretical: a floor derived from the amount you asked for is one a smaller
trade can never clear.
That “cost to compute” row is not academic. Sizing gets its whole ladder from one
batched round of probes, because a price limit is something the pool enforces on
itself.
maxImpactBps has no such trick; it bisects the offer and takes a real
quote at every step, so a very large offer against a tight cap is slow enough to
time out. Measured here: an 8,000,000 USDC offer never returned; 1,000,000 did.
The consequence that costs people money: the same number is not the same
request. Because costBps is marginal and maxImpactBps is average,
costBps=25 admits a trade whose average footprint is about 10 bps, so
costBps=25 is roughly maxImpactBps=10, not maxImpactBps=25.
Read down the avgCostPips column of the ladder to convert between them. At
block 50769564 on USDC to WETH that mapping was:
maxImpactBps, against
the ladder’s own rungs on the same pair:
The last two rows are the same trade reached from opposite directions: one asked
for a 10 bps average cap, the other for a 25 bps marginal bound, and they
landed within 2% of each other on size and within 2 pips on cost. That is the
conversion, confirmed rather than argued.
Note also how tightly
maxImpactBps lands under its own cap: 194 against 200,
485 against 500, 995 against 1000. The bisection converges on just-under, which
is what makes it expensive.
There is one more asymmetry worth holding onto. maxImpactBps cannot make a
trade bigger; it only ever shrinks what you offered, so it needs you to have
picked a size first. costBps has no offer to shrink; it answers the sizing
question outright. If you already know your size and want it capped, use
maxImpactBps. If you are asking how much to trade at all, use /v1/sizing:
reaching for maxImpactBps there means guessing a size and then discovering the
guess was wrong.
The shape of the book, not just its depth
The ladder also reports where the curve bends, because “how much” and “through what” are one decision.segments: runs of consecutive levels served by the same pool. The curve is the lower envelope of the per-pool curves, so it kinks wherever the winner changes.routeChanges, a segment boundary. Informational, never a warning: a crossover is usually the router finding a better venue, so the envelope’s slope improves. Flagging it as a cliff would warn the trader at the exact point the routing got better. Both pools are carried, because a deep pair usually has several pools at different fee tiers and a real crossover otherwise renders as “univ3 to univ3” and reads as a bug.knee, where cost starts buying materially less size. Detected inside a segment and never across one, at a threshold of 60% of the size-per-bps the segment opened with.saturated: the pool absorbed everything offered and would have taken more. The number is then a floor, not a maximum. This is the one failure mode of the endpoint that looks entirely correct.
Partial fills, and the literal that shrinks with the bite
A resting order fills in bites. Each bite runs the whole downstream pipeline at a fraction of full size, so every amount in the plan has to shrink with it. Symbolic references shrink for free: a step spending “what the swap produced” is automatically a tenth when the swap was a tenth. Literals do not, and the sizing refuses the plan rather than running one step at the number the document wrote while the money around it moved. A bite of a tenth executing the whole trade is not an error anybody would catch by reading the plan. But symbolic is not always available. Repaying WETH bought with USDC and then taking back cbBTC collateral has no step producing cbBTC to point at, and pointing at the WETH would withdraw a WETH-shaped number of a token with different decimals and a different price. SoValueRef.ScalesWith names a step whose sizing factor a literal follows.
The author states the amount for the whole order and says what it scales with:
at full size it is itself, at a tenth it is a tenth.
ScalesWith: "buy" makes a
bite that filled a tenth withdraw a tenth.
ScalesWith is preserved through compilation rather than consumed by it. A plan
is stored and re-read for every bite, so a rewritten amount that lost its
ScalesWith would make the next bite scale from this bite’s number instead of
the order’s.
Saying it in a plan
Everything above is the router’s vocabulary. A plan reaches it through three parameters onswap.router, and picking between them is the whole of the skill.
limit_price is a rate written as two whole numbers, "out units/in units",
never a decimal. A rate as a float has to be reconciled with two token
decimalisations somewhere, and every place that happens is a place a limit can be
silently multiplied by a million.
Buy the dip at a price you named
You want cbBTC, but only at 80,000 or better. The price is the point; the size is whatever the market gives you there.partial true this becomes a
resting order that takes what clears and comes back for the rest; with it false
the trade is fill-or-kill at that rate.
Reach for this whenever a person said a number. It is the only one of the
three that the chain enforces: it becomes the signed MinOut.
Rebalance without moving the market
A treasury shifting 2,000,000 USDC into WETH does not care about a specific price. It cares that the trade does not print a candle.sizedDown on the answer says it did,
and boundBy says the cap is why.
Reach for this when somebody says “do not move the market”. No price is
named, so you take the market rate for whatever size fits.
Trade whatever fits cheaply, when nobody knows what fits
An agent told to convert “as much as is sensible” has no size to offer. Asking the book first is the honest answer.costBps: 5, which returns the largest trade that fills
within 5 bps and the floor for that trade, and both are signed. On one
measurement of Base the size was 58,360 USDC, and 12,264 twenty minutes later,
because trades in between had moved the pool.
Both numbers come from one call on purpose. The floor is derived from the amount
that is quoted, so a size chosen afterwards is always too late for it: sizing
with /v1/sizing and then signing a floor for the original amount produces a
bound the smaller trade cannot clear, and the swap reverts InsufficientOutput.
Reach for this when the size is the unknown. It is the only parameter that
answers how much, and the only one that asks the book rather than telling it.
They compose
Any combination is allowed exceptlimit_price with min_out, which is refused
because the two can disagree and picking a winner would trade at a price nobody
chose. A resting order that wants all three says: fill no worse than this rate,
take no more than the book offers at 5 bps, and do not move the pool more than 10
bps getting there.
Only one of them is a promise
limit_price becomes MinOut, which is in the signed intent and enforced on
chain: a fill below it reverts. The two size bounds are not signed and do not
need to be; they can only make a trade smaller, and spending less than the
signed amount is what a partial fill already is.
That asymmetry is why a plan cannot use a size bound to spend more than it asked
for, and why adding them moved the wire schema between signer and worker without
touching the EIP-712 type or any adapter address.
Zero is refused for both size bounds. Zero impact and zero cost each describe
a trade nothing fills, so a plan carrying one would look bounded while being
unfillable. Omit the parameter to mean “no bound”.
What is built, and what is not
The split proof is executed rather than modelled: a fork at the same block the
plan was computed at, attached to the live proxy rather than a fresh deploy,
funding a trader and running both routes for real. It filled 92% more at a
marginally better price, and the predicted total missed the executed total by
0.03%: entirely
portionBps rounding.