Skip to main content
The swap router is ours. Trades here are not handed to an aggregator: the quoting, the routing across pools and the on-chain settlement are all DeFiLoops code, which is why a plan can ask questions an aggregator’s API does not answer: what size the book will take at a cost you name, what a bound would leave of an order, and how much of a resting order fills right now. This page is how it answers “how much can I trade”, what the number it returns actually means, and the three different things people mean by “don’t fill me at a bad price”. Two neighbouring questions have their own pages: Slippage is not impact for what the two bps bounds each protect against, and Does splitting cost less? for whether breaking a trade into slices saves anything. The splitting on THIS page is across pools at one moment; that one is across time. Written against 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:
Reading costBps as the cost of the trade reads a number about twice too large. A level labelled 5 bps holds a fill that /v1/quote prices at 2. avgCostPips is the field to compare against a quote’s priceImpactPips: same basis, same arithmetic.
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.
Do not estimate the average by halving costBps. On this pair it would have been wrong by 25% to 100% at every level. Read avgCostPips, which is measured rather than derived.
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 whole tokenIn buys at this level’s executed average
  • marginalPrice, the price the bound actually names: costBps below this level’s own reference
  • quoteDecimals on the response, so either can be rendered
Measured on Base at block 50769843, USDC to WETH: 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.
price and marginalPrice are display only. The (maxAmountIn, amountOut) pair is the contract; these are rounded decimal renderings of it. Size an order from the pair: round-tripping through the decimal loses wei.

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:
So the whole prize for splitting 200kintofourrestedbitesis200k into four rested bites is **37.25**, or 1.86 bps, and it is collected only if arbitrage pushes the pool back toward the external mid between bites. Back to back, it is exactly zero.
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, because fills_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.
allowSplit defaults to false in the service, and that is deliberate. A split quote handed to a client that reads only the scalar execution fields would execute one leg and fill a fraction of what it was quoted. Opting in is the client declaring that it reads fill.execution.legs. It also requires limitRate: without a bound there is nothing for the legs to stop at, and the request is refused.
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.
splitAmountIn is not executable from this endpoint. /v1/sizing builds no routes: size an order to it, send a plain /v1/quote, and you get the single-pool fill and a silent partial. Reaching it requires limitRate together with allowSplit. It is also a lower bound: direct concentrated pools only, so it ignores the multi-hop routes a quote can reach.
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:
Measured independently: a 1,000,000 USDC offer shrunk by 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. So ValueRef.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.
0.2 cbBTC is the take-back at full order size. 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 on swap.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.
1,000 USDC must buy at least 0.0125 cbBTC. With 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.
Offer the two million; the router shrinks it until the impact it reports is at most 0.05% and quotes that smaller trade. 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.
The plan is quoted with 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 except limit_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.