Skip to content

solana: forward native prices from the autopilot to the orderbook - #5068

Merged
tilacog merged 33 commits into
mainfrom
tiago/be-327-forward-native-prices-from-the-autopilot-to-the-orderbook
Oct 19, 2026
Merged

tilacog merged 33 commits into
mainfrom
tiago/be-327-forward-native-prices-from-the-autopilot-to-the-orderbook

Conversation

@tilacog

@tilacog tilacog commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Description

The Solana autopilot serves its native prices over HTTP. The Solana orderbook reads them and refuses to quote a token nobody prices, and the autopilot drops orders on such tokens from the auction.

Motivation

The orderbook had no native prices, so /quote answered any pair with the best raw driver amount. An order on a token no estimator prices was accepted and then sat in every auction until it expired, because its solutions score nothing.

This is the EVM setup: the orderbook fetches both native prices from the autopilot through the Forwarder estimator and answers NoLiquidity when one is missing, and the autopilot filters orders without both prices out of each auction. The forwarder is the orderbook's only price source for now.

Builds on #5054 (merged), which forwards the prices to drivers.

Changes

autopilot-svm

  • An axum server on api-address (default 0.0.0.0:12088, the EVM autopilot's port) with GET /native_price/{mint}. 200 carries {"price": "<lamports per 10^9 atoms>"} from the NativePrices cache, the unit the autopilot already sends drivers. 404 for an unpriced token or a failed lookup, 429 when every source failed and one of them was rate limited, 400 for a mint that does not parse. An optional timeout_ms bounds the lookup, with a 250ms floor. Past it the route answers 404. Spec in openapi.yml.
  • NativePrices::price(mint) looks one token up without touching the set the refresher maintains, so a request cannot shrink that set to one token. Concurrent lookups of one token share one fetch. A CoinGecko 429 fails the lookup as RateLimited and leaves the token uncached, so the next lookup asks again.
  • The auction cut drops orders missing the sell or buy price, with a Filtered event under missing_native_price.

solana-orderbook

  • A forwarder in infra/prices.rs, pointed at the new required [quoting] autopilot URL. 404 maps to NoLiquidity, 429 to RateLimited, anything else to Internal. It forwards X-REQUEST-ID and sends quoting.timeout as timeout_ms.
  • /quote fetches both prices alongside the driver quotes. A missing or rate limited price answers 404 NoLiquidity. A transport error answers 500. The prices themselves stay unused until BE-366 nets costs out of quotes. openapi.yml describes the new 404 case.

Deployment configuration: the orderbook's [quoting] autopilot points at the autopilot's base URL, and the autopilot's api-address port is exposed to it.

How to test

cargo nextest run -p autopilot-svm -p solana-orderbook. The route tests in autopilot-svm/src/infra/api.rs cover 200, 400, 404, 429 and the timeout_ms cut against a seeded cache and mock sources. quote_without_a_native_price_is_no_liquidity in solana-orderbook/tests/api.rs runs the quote route with drivers answering and the autopilot returning 404.

Rollout: the orderbook refuses to start without [quoting] autopilot, so set it before upgrading. The autopilot change is additive, but its port must be reachable from the orderbook.

Related Issues

Linear: BE-327

tilacog added 12 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.
@linear-code

linear-code Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

BE-327

BE-430

@github-actions

github-actions Bot commented Oct 8, 2026 •

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:

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.
Base automatically changed from tiago/sc-468-forward-native-reference-prices-to-drivers-and-solvers to main October 9, 2026 19:29
@tilacog
tilacog marked this pull request as ready for review October 13, 2026 16:02
@tilacog
tilacog requested a review from a team as a code owner October 13, 2026 16:02
…native-prices-from-the-autopilot-to-the-orderbook
@tilacog
tilacog marked this pull request as draft October 15, 2026 08:57
@tilacog
tilacog requested a review from squadgazzz October 15, 2026 09:32
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.
@tilacog

tilacog commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @tilacog's task in 3m 42s —— View job


PR Review

Reviewed the autopilot-svm native-price API, the orderbook forwarder, and the auction-cut filtering. The change is well-structured, well-documented, and well-tested. One issue worth addressing, posted inline:

  • solana-orderbook/src/infra/prices.rs — the forwarder sends timeout_ms = self.timeout as the server budget and sets the reqwest transport timeout to the same self.timeout. With no margin, the client times out before the autopilot's budget-exhaustion 404 can arrive, so a slow lookup surfaces as 500 instead of the intended 404 NoLiquidity. The sibling Quoter already solves this with RESPONSE_RESERVE (server deadline = timeout - 500ms, transport = timeout). See inline comment.

Everything else looks good — the negative-cache / RateLimited handling, request-sharing for concurrent single lookups, the maintain/lookup split that keeps single-token quotes from shrinking the refresher's set, and the priced_orders auction filter all read cleanly and are covered by tests.
· tiago/be-327-forward-native-prices-from-the-autopilot-to-the-orderbook

Comment thread crates/solana-orderbook/src/infra/prices.rs
@tilacog
tilacog marked this pull request as ready for review October 15, 2026 15:57
@claude

claude Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @tilacog's task in 2m 51s —— View job


PR Review

  • Read the diff against origin/main
  • Review autopilot-svm native price API
  • Review solana-orderbook forwarder + quote route
  • Post findings

I re-reviewed the full diff (autopilot-svm native-price API, the orderbook forwarder, the /quote gating, and the auction-cut filter). This is a clean, well-structured, well-tested change, and I found no new issues to flag.

What I checked and liked

  • price() / maintain/lookup split in autopilot-svm/prices.rs — a single-token quote no longer shrinks the refresher's maintained set; RequestSharing collapses a burst of lookups on one token into one source fetch. Both covered by tests.
  • RateLimited plumbing — a CoinGecko 429 fails the lookup without negatively caching the token (so the next lookup retries), propagates only when every source fails, and surfaces as a clean 429. The negative-cache gate (failures == 0) still correctly avoids poisoning tokens a failed source might know.
  • priced_orders in provider.rs — correctly keeps only orders whose both tokens landed a real price. Native SOL / wSOL resolve to the denominator via is_sol and so stay in the prices map, so SOL-leg orders aren't dropped. A single unpriceable token still filters per-order rather than skipping the whole cut.
  • /quote gating — try_join! runs the driver quote and both price lookups concurrently; unpriced/rate-limited → 404 NoLiquidity, transport error → 500, matching the EVM mapping. timeout with the no-timeout_ms Duration::MAX path is safe (tokio's timeout uses checked_add → far-future, no panic).
  • openapi specs and example configs match the implemented behavior; the required [quoting] autopilot with no default enforces the documented rollout order.

On the earlier finding (client/server timeout race): the previous review noted that timeout_ms and the reqwest transport timeout are both self.timeout with no margin, so a budget-exhaustion 404 can't arrive before the client gives up (→ 500). @tilacog's response is well-reasoned — this intentionally mirrors the EVM forwarder for parity and the fix belongs to BE-425 across both orderbooks, not here. I agree with deferring it and won't re-raise.

LGTM. 👍
· tiago/be-327-forward-native-prices-from-the-autopilot-to-the-orderbook

…-327-forward-native-prices-from-the-autopilot-to-the-orderbook

# Conflicts:
#	crates/solana-orderbook/src/infra/api/routes/quote/mod.rs
Comment on lines +65 to +78
match tokio::time::timeout(budget, prices.price(mint))
.await
.unwrap_or(Ok(None))
{
Ok(Some(price)) => Json(NativePrice { price }).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "No liquidity").into_response(),
Err(err) if err.is::<RateLimited>() => {
(StatusCode::TOO_MANY_REQUESTS, "Rate limited").into_response()
}
Err(err) => {
tracing::warn!(?err, %mint, "native price lookup failed");
(StatusCode::NOT_FOUND, "No liquidity").into_response()
}
}

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.

A budget timeout answers 404 without a log line, and since the lookup is dropped, the source warn never fires either. The EVM route logs every failure with elapsed. Maybe a warn! here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in ab4bacc

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.

CI validates four specs (pull-request.yaml:321-328) and this one is not among them. It passes swagger-cli validate locally, so adding it to the job should be enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The solana-orderbook spec has been in the job since 6941977.

The one missing was crates/autopilot-svm/openapi.yml, now added in 8cdca6c.

Comment on lines +252 to +264
inner
.single_lookups
.shared_or_else(token, move |&token| {
async move {
fetcher
.lookup(HashSet::from([token]))
.await
.map(|mut prices| prices.remove(&token))
.map_err(Arc::new)
}
.boxed()
})
.await

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.

This PR helped me to realize we have a gap in the price estimation logic. #5112 rewrites fetch_into_cache to ask the sources by stage, like the EVM competition.

What stays is the tail. A token CoinGecko does not list costs its miss plus the driver stage, and on timeout, it drops the lookup with nothing cached, so every quote on that token pays it again. EVM caches the timeout. The spawn from 66d85fd would let the second quote hit the cache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Spawn re-applied in c097e66, with the test.

A lookup past `timeout_ms` answered 404 silently: the handler swallowed
the timeout, and dropping the lookup cancelled the source request before
its own warning could fire. The EVM route warns on every failed lookup
with the elapsed time. Treat the timeout as a failed lookup so the same
warning covers it, and log the budget and elapsed time beside the error.
The spec joined the repo without joining the openapi job, which
validates and lints the other four. It passes both locally.
A quote whose `timeout_ms` ran out dropped the shared lookup future, and
with it the in-flight source request: nothing was cached, so the next
quote on the token asked the sources again and ran out of time the same
way. A token CoinGecko does not list always costs its miss plus a driver
probe, more than the orderbook's budget covers, so such tokens never
entered the cache. The EVM estimator bounds each source request by the
budget instead, so its fetch finishes and its verdict is cached. Spawn
the lookup so a waiter giving up leaves it running and its verdict lands
in the cache for the next quote.
@tilacog
tilacog requested a review from a team as a code owner October 19, 2026 12:43
@tilacog
tilacog requested a review from squadgazzz October 19, 2026 12:46

@squadgazzz squadgazzz 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

…native-prices-from-the-autopilot-to-the-orderbook
@tilacog
tilacog enabled auto-merge October 19, 2026 14:36
@tilacog
tilacog added this pull request to the merge queue Oct 19, 2026
Merged via the queue into main with commit f61f2b3 Oct 19, 2026
43 of 46 checks passed
@tilacog
tilacog deleted the tiago/be-327-forward-native-prices-from-the-autopilot-to-the-orderbook branch October 19, 2026 16:46
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 19, 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