Skip to content

solana-orderbook: net the execution cost out of quotes - #5121

Merged
squadgazzz merged 31 commits into
mainfrom
solana-services/be-366-net-quote-costs
Oct 20, 2026
Merged

squadgazzz merged 31 commits into
mainfrom
solana-services/be-366-net-quote-costs

Conversation

@squadgazzz

Copy link
Copy Markdown
Contributor

Description

A quote is net of the settlement's execution cost, the way the fill is: the driver takes the cost out of the fill (#5115), so a quote that ignores it promises more than the fill delivers.

The cost is the buy token account's rent, when the settlement must create it, plus the networkFee the driver reports for the quoted route (#5101). A driver that reports no fee is charged rent only. The cost comes off the side the trader does not fix: a sell order delivers less buy token, a buy order pulls more sell token, converted at the autopilot's native price (#5068) and rounded up against the trader. Drivers are ranked on the amounts after the cut, so a cheaper route can beat a larger raw answer. A quote the cost swallows answers 400 SellAmountDoesNotCoverFee.

Stacked on #5068.

Changes

  • The buy token account rent and the driver's network fee come out of the quoted amounts
  • Drivers are ranked on the netted amounts
  • networkFees reports the winning driver's fee instead of a fixed placeholder
  • A quote the cost swallows answers SellAmountDoesNotCoverFee

How to test

New and updated unit and integration tests.

Related issues

BE-366

tilacog and others added 30 commits October 6, 2026 20:43
The autopilot already prices every auction token in lamports per 10^9
atoms for scoring, but never sent the prices on. The driver's `/solve`
request now carries them, and the driver passes them to engines as
`tokens[mint].referencePrice`, keyed under wSOL for native SOL buys.
Both inbound sides default the field, so payloads without it parse.
Without price estimators the autopilot prices every token at the
denominator as a scoring stand-in. Those placeholders now stay with the
autopilot instead of reaching engines that would convert lamport costs
into token atoms with them. The baseline solver engine also drops its
`tokens` field: it does not use reference prices.
…native-reference-prices-to-drivers-and-solvers
"Lamports one atom is worth, scaled by 10^9" left readers working out
what the number is. It is a rate, and naming it directly reads as one.
The Solana orderbook has no native prices of its own, so it will read
them from the autopilot like the EVM orderbook does. The autopilot now
binds an HTTP API (default 0.0.0.0:12088, the EVM autopilot's port)
serving GET /native_price/{mint} from the price cache: 200 with the
lamports per 10^9 atoms as a decimal string, 404 for a mint no estimator
prices, 429 while every source is rate limited.

A single-mint lookup fetches inline like a cut's but leaves the
refresher's maintained set alone: a quote for one token must not shrink
the set to that token and let the auction tokens expire.
The Solana orderbook had no native prices: /quote answered with the
best raw driver amount for any pair, and an order on a token nobody
prices was accepted and then sat unscored in every auction until it
expired.

The orderbook now reads prices from the autopilot's native price route,
mirroring the EVM forwarder, and /quote fetches the sell and buy token
prices concurrently with the driver quotes, failing with NoLiquidity
when either is missing. Native SOL is forwarded under the System
Program key the request carries, which the autopilot prices at the
denominator like wSOL. The autopilot URL is a new required quoting
config, since the forwarder is the orderbook's only price source.
An order on a token no estimator prices went out in every auction until
it expired, since its solutions score nothing. The cut now keeps only
orders with both native prices and records the rest with a filtered
event under `missing_native_price`, mirroring the EVM autopilot.
The EVM autopilot ships an openapi.yml for /native_price/{token}, the
route the orderbook forwards to. Mirror it for the Solana autopilot so
the wire is reviewable as a spec: a per-token object with price, the
same route and status codes, priced in lamports per 10^9 atoms like the
/solve tokens[mint].price.
The EVM forwarder sends its quote timeout as timeout_ms and the EVM
autopilot bounds the estimate by it, raised to a 250ms floor. Do the
same here: the orderbook sends its quote timeout, and a lookup that
outlives it answers 404, as EVM answers an estimator that ran out of
time. Without the parameter the sources' own timeouts bound the lookup,
so there is no ceiling to clamp to.
The EVM autopilot answers 404 when an estimator fails (CoinGecko's
EstimatorInternal included) and keeps 429 for a spent quota. The Solana
autopilot answered 500, so a CoinGecko outage failed every quote on an
uncached token with 500 and an error log in the orderbook, where EVM
answers NoLiquidity. Answer 404 like EVM; the route has no other 500.
Quotes now reach the price sources through the autopilot, and each
concurrent quote on an uncached token fetched it on its own: N quotes
spent N CoinGecko requests and N driver probes. A spent quota then
fails the cut's own lookup, which skips the whole cycle. The EVM native
price cache shares in-flight requests per token through request-sharing;
do the same for the single-token lookups the orderbook drives. The
shared future needs a cloneable error, so the lookup returns
Arc<anyhow::Error>.
The quote gate's comments said an unpriced order would sit unscored in
every auction, but the autopilot's cut now drops such orders, so they
never enter one. Also drop the orderbook price error variant docs that
repeated their #[error] strings.
A quote whose `timeout_ms` ran out dropped the shared lookup future, and
with it the in-flight source request: its answer was discarded, nothing
was cached, and the next quote on the token asked the source again. The
EVM autopilot lets the estimator finish and caches what it returns.
Spawn the lookup so a waiter giving up leaves it running and its verdict
lands in the cache.
The orderbook built the native price URL by hand to survive a base
configured without a trailing slash. The same config already appends
the slash to the driver URLs at load time and serde-ext has the
single-URL helper, so apply it to the autopilot URL and resolve the
route with `Url::join` like the Quoter does.
…native-prices-from-the-autopilot-to-the-orderbook
A 429 cached nothing, so every later quote on an uncached token asked
CoinGecko again while its quota was already spent. The shared lookup
future dies as soon as its waiter answers, so sequential quotes did not
coalesce either. The EVM autopilot wraps CoinGecko in a rate limiter
whose back-off refuses requests locally after a 429. Record when the
back-off ends on a 429 and fail every lookup as rate limited until then
without asking a source, so the cut keeps its skip-the-cycle semantics
and the orderbook keeps seeing 429.
Eight comments justified a floor, a port, a probe size, or an error
mapping with "the EVM side does this". A reader of the Solana code
should get the reason itself, so each comment now states it, and the
ones whose code already says it are gone.
Test docs that repeated the test name, fn docs that repeated the
signature, and a handler doc that repeated the openapi spec are gone.
The `RateLimited` doc claimed the orderbook tells a spent quota from an
unpriced token, but its quote route answers both as `NoLiquidity`; it
now names what the type is for, the back-off. The orderbook client's
module and struct docs said the same thing, so the struct doc is gone.
`rate_limited_sources_are_left_alone_during_the_back_off` asserts the
first lookup's `RateLimited` verdict before asserting the back-off, so
`rate_limited_sources_fail_the_lookup_as_rate_limited` pinned nothing
on its own.
The two prices were discarded with `_`, which reads as if they were
fetched only to be ignored. Nothing consumes them until the execution
cost is netted out of the quote, which needs the driver's network fee
and the account rent first. Name them and leave the TODO where that
work starts.
…native-prices-from-the-autopilot-to-the-orderbook
The single lookup was spawned so a quote whose budget ran out could not
cancel the source request. The EVM autopilot does not do that: its
request sharing holds a weak handle, so a dropped waiter drops the
fetch, and the budget is passed into the estimator instead. Match it;
the shared in-flight future still keeps concurrent waiters on one
request.
A 429 paused every source for ten seconds, which also stalled the
refresher, so the auction's prices stopped refreshing until it lifted.
The EVM autopilot has no such pause on its native sources: a 429 is
answered as rate limited, the verdict is not cached, and the next lookup
asks again. Do the same; concurrent lookups of one token still share
one request, and the route still answers 429.
…-327-forward-native-prices-from-the-autopilot-to-the-orderbook

# Conflicts:
#	crates/solana-orderbook/src/infra/api/routes/quote/mod.rs
@linear-code

linear-code Bot commented Oct 19, 2026

Copy link
Copy Markdown

BE-366

@github-actions

Copy link
Copy Markdown

Reminder: Please consider backward compatibility when modifying the API specification.
If breaking changes are unavoidable, ensure:

  • You explicitly pointed out breaking changes.
  • You communicate the changes to affected teams (at least Frontend team and SAFE team).
  • You provide proper versioning and migration mechanisms.

Caused by:

@squadgazzz
squadgazzz marked this pull request as ready for review October 19, 2026 11:44
@squadgazzz
squadgazzz requested a review from a team as a code owner October 19, 2026 11:44
@claude

claude Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @squadgazzz's task in 3m 36s —— View job


Review: net the execution cost out of quotes

  • Read PR diff and changed files
  • Analyze quote netting logic
  • Check math, rounding, overflow, ranking
  • Post review

I read quote/mod.rs, quoter.rs, dto.rs, the openapi doc, and the tests, and traced the netting/ranking math end to end. This is a clean, well-tested change — I found no blocking issues. No inline comments to post.

What I verified

  • Side & price selection (mod.rs:96-101): sell orders net off buyAmount at the buy token's native price, buy orders net onto sellAmount at the sell token's price. The cost falls on the side the trader doesn't fix. Correct.
  • Conversion & rounding (net, mod.rs:192-193): ceil(lamports · 10⁹ / price) matches the "lamports per 10⁹ atoms" unit from prices.rs:50, and div_ceil rounds against the trader on both sides. lamports ≤ ~2⁶⁵ times PRICE_UNIT (~2³⁰) stays within u128; the u64::try_from(...).ok()? guards the down-cast.
  • Dropping unfillable quotes: a sell netting to ≤ 0 is dropped via checked_sub(...).filter(> 0), a buy whose sell leg overflows via checked_add; all-dropped ⇒ 400 SellAmountDoesNotCoverFee. Covered by a_quote_that_cannot_absorb_its_cost_is_dropped.
  • Ranking (best, mod.rs:176-179): ranks on netted amounts, so a larger raw answer can lose to its own reported fee — exactly the intent, and verified by quote_picks_the_best_driver_answer (raw 5,001,000 w/ fee loses to raw 5,000,000 fee-free) and by the integration tests' arithmetic (e.g. EVM-shape: 3,461,207 − (2,220,240 rent + 6,400 fee) = 1,234,567).
  • Reporting: networkFees now carries the winning driver's raw fee while the amounts are already netted; Option<DisplayFromStr> + #[serde(default)] correctly defaults an absent fee to 0. Only one caller of the old quote(); rename to quotes() is complete.

Minor, non-blocking observation (no change expected)

In practice a buy order is only rejected with SellAmountDoesNotCoverFee on u64 overflow of the sell leg — economically a buy can always pull more sell token, so the "cost swallows the fill" rejection is effectively sell-order-only. That matches the EVM error semantics and the PR's stated design, so I'd leave it as is; flagging only so it's a conscious choice.
· solana-services/be-366-net-quote-costs

Base automatically changed from tiago/be-327-forward-native-prices-from-the-autopilot-to-the-orderbook to main October 19, 2026 16:46
…6-net-quote-costs

# Conflicts:
#	crates/autopilot-svm/src/infra/api.rs
#	crates/autopilot-svm/src/infra/prices.rs
#	crates/solana-orderbook/src/infra/api/routes/quote/mod.rs

@tilacog tilacog left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!
This was way simpler than I could imagine!

@squadgazzz
squadgazzz added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit e48749c Oct 20, 2026
25 checks passed
@squadgazzz
squadgazzz deleted the solana-services/be-366-net-quote-costs branch October 20, 2026 07:51
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants