Skip to content

feat(router): true exact-out (in-given-out) routing + regression tests#693

Draft
JohnnyWyles wants to merge 18 commits into
v28.xfrom
jw-exactout
Draft

feat(router): true exact-out (in-given-out) routing + regression tests#693
JohnnyWyles wants to merge 18 commits into
v28.xfrom
jw-exactout

Conversation

@JohnnyWyles

@JohnnyWyles JohnnyWyles commented Jun 25, 2026

Copy link
Copy Markdown

Summary

Replaces the inversion-based GetOptimalQuoteInGivenOut with a direct in-given-out routing path, and adds the regression coverage for it.

SQS previously implemented exact-out quotes by inverting an out-given-in quote. That caused three confirmed production issues:

  1. Understated input cost. amount_in excluded the taker fee and spread on the input side.
  2. Wrong route selection. Candidates were found in the wrong direction (confirmed ~$1.30/SOL worse on SOL to USDC, routing 80% through a 0.5% spread pool vs 0.05% for exact-in).
  3. Corrupted fee metadata. Pool taker fees differed between directions for the same pool (pool 1925 reported taker_fee=0.000 in exact-in and taker_fee=0.001 in exact-out).

What changed

Wiring (add90084, already on the branch):

  • GetOptimalQuoteInGivenOut now calls computeAndRankRoutesByDirectQuoteInGivenOut directly instead of inverting an out-given-in quote.
  • quoteExactAmountOut.PrepareResult computes amount_in, effective fee, price impact, and spot price from the true exact-out route data (PrepareResultPoolsInGivenOut) rather than the embedded inverted quote. Price impact is (effectiveOutPerIn / spotOutPerIn) - 1, negative when adverse. The legacy inversion branch is retained as a fallback while quoteExactAmountIn is non-nil.

Split-route optimization for exact-out:

  • getSplitQuoteInGivenOut is the input-minimising dual of the exact-in getSplitQuote. Where exact-in splits the input across routes to maximise output, this splits the desired output across routes to minimise the total input required, via a dynamic program with inverted objective (unreachable states seeded with a nil sentinel rather than zero; selection keeps the smaller input; per-increment cost uses CalculateTokenInByTokenOut and charges exact-out taker fees).
  • The truncation remainder from per-route output proportions is assigned to the last selected route, and each route's input is computed from its exact (remainder-adjusted) output, so the split outputs sum to exactly the requested tokenOut.
  • GetOptimalQuoteInGivenOut compares the single best route against the split quote and returns whichever requires less input, mirroring the out-given-in single-vs-split selection.

Tests (this commit):
The wired path only had coverage for the legacy inversion fallback (where the embedded quoteExactAmountIn is set). The true path (nil quoteExactAmountIn) was untested. Added:

  • NewExactAmountOutTrueQuote test helper that builds a quote on the true path.
  • TestPrepareResult_ExactOut_TruePath_EffectiveFee: per-route compounded taker fee weighted by output share.
  • TestPrepareResult_ExactOut_TruePath_PriceImpact: re-derives price impact from the quote's own reported spot price and asserts the adverse-trade negative sign convention.
  • TestPrepareResult_ExactOut_TruePath_TakerFeeMetadata: zero-taker-fee pool reports zero effective fee; a non-zero pool reports exactly its fee (pool 1925 metadata regression).
  • TestGetOptimalQuoteInGivenOut_Mainnet_FeeCorrectness: end to end against mainnet state, asserts positive fee-inclusive amount_in, in-given-out route orientation, and an effective fee matching the compounding of the chosen pools' taker fees.

Verification

CI is the verification gate for the test run: the suite cannot compile on native Windows (wasmd needs the cgo NewKeeper, and wasmvm ships no Windows library), so these were authored and gofmt-checked locally but executed only in CI (Linux, CGO enabled).

Pass criteria for the wiring, to confirm on a preview/live node:

  • amount_in for exact-out is close to the equivalent exact-in input (was understated by the inversion).
  • price_impact for exact-out is negative and similar magnitude to exact-in.
  • Route split for exact-out picks the low-spread pool, consistent with exact-in.
  • Pool taker fees are consistent between exact-in and exact-out responses.

Scope and behaviour notes

  • Exact-out picks the lower-input of single route vs split. The in-given-out path now runs split-route optimisation and returns whichever of the single best route or the best split requires less input for the desired output. A pair resolves to one route when splitting does not reduce the required input, and to several when it does. The new split optimiser is guarded by a test asserting the split never requires more input than the best single route.
  • Updated TestGetOptimalQuoteExactAmounOut_Mainnet expected route counts. The previous expectations reflected the old inversion implementation (which borrowed exact-in's split routing in the wrong trade direction). They are set to the counts produced by true exact-out routing.
  • Empty input denom on exact-out result quotes. On the true exact-out path, GetAmountIn().Denom and route.GetTokenInDenom() come back empty (the result struct carries amounts and pools but not the input denom string). The new mainnet test asserts on amounts and per-pool taker fees rather than the input denom. Worth a look during review to decide whether the input denom should be threaded into the result.
  • CI observability. This branch also tweaks .github/workflows/build.yml to tee go test output to the console (with set -o pipefail) and upload the coverage artifact if: always(), so a failing test surfaces its assertion in the run log instead of being swallowed into an unuploaded report.json.

deividaspetraitis and others added 18 commits April 24, 2026 18:12
Implements `handleCandidateRoutesInGivenOut` API for computing exact
amount out quotes.

This is smaller chunk of bigger PR-607 ( #607 ) implementing computing exact
amount out quotes.
Implements `GetCustomDirectQuoteInGivenOut` API for computing exact
amount out quotes.

This is smaller chunk of bigger PR-607 ( #607 ) implementing computing exact
amount out quotes.
Implements `rankRoutesByDirectQuoteInGivenOut` API for computing exact
amount out quotes.

This is smaller chunk of bigger PR-607 ( #607 ) implementing computing exact
amount out quotes.
Implements `rankRoutesByDirectQuoteInGivenOut` API for computing exact
amount out quotes.

This is smaller chunk of bigger PR-607 ( #607 ) implementing computing exact
amount out quotes.
Implements `PrepareResultPoolsInGivenOut` API for computing exact
amount out quotes.

This is smaller chunk of bigger PR-607 ( #607 ) implementing computing exact
amount out quotes.
…iven-out

Replace the inversion-based GetOptimalQuoteInGivenOut (which called
GetOptimalQuoteOutGivenIn with swapped args and inverted the result)
with a direct call to computeAndRankRoutesByDirectQuoteInGivenOut.

This fixes three confirmed production bugs:
- amount_in was understated (fees excluded from inversion)
- route selection was wrong (candidates found in wrong direction)
- pool taker fee metadata was corrupted (taker_fee flipped between
  exact-in and exact-out responses for the same pool)

Also fix PrepareResult in quoteExactAmountOut to use the true
exact-out route data from PrepareResultPoolsInGivenOut, computing
price impact as (effectiveOutPerIn / spotOutPerIn) - 1 (negative
when adverse).

Fix NewExactAmountOutRoutableResultPool call missing liquidityCap
arg (added to v28.x after BE-695 branched).

Remove nolint:unused from computeAndRankRoutesByDirectQuoteInGivenOut,
rankRoutesByDirectQuoteInGivenOut, handleCandidateRoutesInGivenOut,
and estimateAndRankSingleRouteQuoteInGivenOut.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The exact-out wiring (in-given-out) only had coverage for the legacy
inversion fallback, where the embedded quoteExactAmountIn is set. The
true path (quoteExactAmountIn nil), built by
estimateAndRankSingleRouteQuoteInGivenOut, was untested.

Add a routertesting helper (NewExactAmountOutTrueQuote) and regression
tests:

- EffectiveFee: per-route compounded taker fee weighted by output share
- PriceImpact: re-derived from the quote's reported spot price; asserts
  the adverse-trade negative sign convention
- TakerFeeMetadata: zero-taker-fee pool reports zero effective fee, a
  non-zero pool reports exactly its fee (pool 1925 metadata regression)
- Mainnet integration: GetOptimalQuoteInGivenOut yields positive,
  fee-inclusive amount_in with in-given-out route orientation, and an
  effective fee matching the compounding of the chosen pools' taker fees
The cherry-picked exact-out test commits were written against an older
v28.x API and no longer compiled, blocking the whole package's test
build (so none of the exact-out tests could run):

- NewExactAmountOutRoutableResultPool gained a liquidityCap parameter
  (between takerFee and codeID); three callers in route_test.go were
  missing it. ValidateRoutePools does not assert liquidity cap, so the
  values mirror the exact-in siblings for readability only.
- ingesttypes.PoolWrapper was refactored to unexported atomic fields;
  the ChainModel/SQSModel struct literal in router_usecase_test.go is
  replaced with the ingesttypes.NewPool(chainModel, sqsModel)
  constructor.
The price-impact re-derivation in the exact-out true-path test used a
slightly different Quo/Mul/Sub chain than PrepareResult, so exact string
equality was fragile to osmomath rounding in the last decimal places.
Assert closeness within 1e-7 instead; the adverse-trade negative sign
convention (the regression-critical property) is still asserted exactly.
The Test step piped JSON to report.json and uploaded it only on success,
so a failing test left no visible output in the run log (just exit
code 1). Tee the JSON to stdout so failures are greppable in the console,
add set -o pipefail so the test exit code still fails the job through the
pipe, and upload the coverage artifact with if: always() so report.json
survives a failure.
The unit tests built a multi-hop route (ETH->USDT->USDC) but declared
AmountOut in ETH, so PrepareResultPoolsInGivenOut walked the route from
a denom the pools don't contain and errored. Rebuild them as two
single-hop ETH->USDC routes with caller-supplied taker fees, output
denom USDC, matching the passing TakerFeeMetadata pattern. Effective
fee is now 0.02*0.6 + 0.003*0.4 = 0.0132.

The mainnet FeeCorrectness test asserted the input denom (GetAmountIn().
Denom and route.GetTokenInDenom()), which come back empty on the true
exact-out path; drop those assertions and keep the positive-amount and
fee-recomputation cross-checks. The empty input denom on exact-out
results is flagged in a comment for review.
GetInBaseOutQuoteSpotPrice() returns spotOutPerIn (1/totalSpotPriceOut-
BaseInQuote), the same value PrepareResult divides by, not its inverse.
The re-derivation multiplied by it instead of dividing, producing an
expected 15.0 against an actual 0.0 for a trade priced at spot. Divide
instead: PriceImpact = effectiveOutPerIn / spotOutPerIn - 1.
True exact-out (in-given-out) returns the single best route; split-route
optimization is deferred to a later phase. The prior expectations of 3
and 2 exact-out routes (uosmo->uion, *->uosmo) reflected the old
inversion implementation, which borrowed exact-in's split routing in the
wrong trade direction. Correct them to 1 and document why on the test
case struct.
Adds getSplitQuoteInGivenOut, the input-minimizing dual of getSplitQuote.
Where the exact-in splitter divides the input across routes to maximize
output, this divides the desired output across routes to minimize the
total input required, via a dynamic program with inverted objective:

- dp[x][j] = minimum input to produce x output-increments using the
  first j routes (unreachable states seeded with a nil sentinel, not
  zero, since min input for positive output with no routes is undefined)
- selection keeps the smaller input (choice.LT(best))
- per-increment cost uses CalculateTokenInByTokenOut (charging exact-out
  taker fees), not CalculateTokenOutByTokenIn
- returns a true exact-out quote (quoteExactAmountIn nil)

The truncation remainder from per-route output proportions is assigned
to the last selected route, and each route's input is computed from its
exact (remainder-adjusted) output, so the split outputs sum to exactly
the requested tokenOut (the user receives precisely what they asked for).

GetOptimalQuoteInGivenOut now compares the single best route against the
split quote and returns whichever requires LESS input, mirroring the
out-given-in path's single-vs-split selection.

Tests (exact-out split): mainnet sanity + output-sum invariant, error-
route exclusion, and a not-worse-than-single-route check (split input
must be <= the best single-route input for the same output).
…ation

The comment on optimalQuoteTestCases said exact-out returns a single
route (split deferred). That is no longer true: exact-out now runs split
optimization and returns the lower-input of single vs split, so a pair
resolves to one route only when splitting does not reduce input. Reword
to describe the actual behaviour.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants