Skip to content

feat(widget): explain why a quote returned no routes (JUMEMB-98) - #874

Open
chybisov wants to merge 56 commits into
mainfrom
feature/jumemb-98-expose-failed-or-filtered-out-routes-reason-when-no-routes
Open

chybisov wants to merge 56 commits into
mainfrom
feature/jumemb-98-expose-failed-or-filtered-out-routes-reason-when-no-routes

Conversation

@chybisov

Copy link
Copy Markdown
Member

Which Linear task is linked to this PR?

JUMEMB-98

Why was it implemented this way?

When a quote returns no routes the widget showed one generic sentence, so a user could not tell "your amount is too small" from "this pair is not supported" from "try again in a minute". The API already reports why every candidate was dropped and the SDK already surfaces it — useRoutes received unavailableRoutes and discarded it.

This maps those reasons onto ten buckets, shows the single most useful one as a card, and offers a one-click fix where the widget can apply one.

Four layers, so each can be reasoned about alone

Layer Responsibility
useRoutes queryFn Classifies in place — the ~590 KB errors payload never enters the query cache
utils/routeIssues/ Pure: no React, no i18n. Flatten → match → extract → dedupe → rank → suppress
useRouteIssueCard Turns a bucket into copy and, where possible, an action
RouteIssueCard Presentational, memo, zero hooks

Why classify inside queryFn: one measured no-route response carried ~590 KB of errors. Caching that per query key across the 60s refetch is not acceptable. The classifier is pure and queryFn already holds everything it needs, so only the compact result is cached.

Query data shape. queryFn now returns { routes, issues } instead of Route[]. Direct cache access was limited to two call sites. Keeping the issues inside the cached response makes them expire atomically with the routes — no second lifecycle to keep in sync.

An unrecognised reason is the default path, not an edge case

Only GASLESS_FEE_EXCEEDS_INPUT is contractually stable; everything else is backend prose that can be reworded at any time. classifyRouteIssues returns [] — and the card falls back to today's byte-identical generic copy — for a new reason string, a new tool-error code, a reworded reason, an all-suppressed payload, a malformed payload, or any throw. The rule table is a lookup, never an exhaustive switch, and each entry is classified inside its own try so one bad reason cannot discard the rest. Every matcher fragment is pinned by a test, and an unmatched payload warns in development.

Alternatives considered

  • Classifying in a hook over the cached payload — rejected; the payload would sit in the cache.
  • A side store keyed by query key — rejected; two lifecycles to keep in sync.
  • Several stacked cards with an expander — built, then removed. The reasons are alternatives across tools, not a conjunction of blockers, and a stack read as a list of things the user must all clear.
  • Reusing useMessageQueue / WarningMessages — rejected. That queue explains why an existing route cannot execute, from client-side conditions, rendered in the form as severity alerts. This explains why no route exists, from a backend payload, rendered in the Receive panel.

Reuse

Card (its per-variant hover is already keyed on onClick), priceToTokenAmount, formatTokenPrice, formatSlippage, wrapLongWords, t('format.currency'), and the existing t(key as any) idiom for computed keys. A new useApplyAmount hook removes the duplicated limit-mode write that PercentageChips had inline.

Things worth a reviewer's attention

  • A figure is only shown when it is trustworthy. The backend reports amounts in the bridge leg's own token, which it never names. One captured payload held ratios from 1.74× to 10¹³× for a single request. A figure is derived only when the route path proves the leg still holds the user's token (stringifyPath joins swaps with ~, bridges with -). Otherwise the card states the bucket with no number.
  • Contradictions are resolved. Bridges disagree on range, so "too low" and "too high" can both be reported; whichever carries a real figure wins. NO_POSSIBLE_ROUTE is emitted per tool, so beside any other reason it is both noise and untrue — it is dropped unless it is the only signal.
  • The receiver card needs a receiver. It is suppressed unless the user set one and it differs from the sender, since its fix — send to your own address — is otherwise already the state of the form.
  • Behaviour change: in mode: 'custom', a contract-call quote that 404s with diagnostics now renders the no-routes screen instead of the error state. Without diagnostics it still throws, so the retry affordance is kept.

Visual showcase (Screenshots or Videos)

Receive panel, ETH → USDC (Polygon), 1 wei:

No routes available
Here's what got in the way, and what might help.

Amount is too low
Routes for this pair need at least $1.20 to work with. Around 0.00049 ETH should get you there.
[ Apply suggestion ]

Verified in the browser across Ethereum, Polygon, Arbitrum, Base and Solana; in all four themes plus dark mode; and with a connected wallet whose balance is below the suggestion — clicking applies the amount and the widget's own insufficient-funds warning takes over.

Checklist before requesting a review

  • I have performed a self-review and testing of my code.
  • This pull request is focused and addresses a single problem.
  • If this PR modifies the Widget API or adds new features that require documentation, I have updated the documentation in the public-docs repository.

Testing

259 tests (110 new), check:types clean on @lifi/widget and @lifi/widget-checkout, knip:check clean. Rules are pinned against a captured real API payload; a live sweep over 12 requests across 7 chains and 6 tokens (~1,600 raw reasons) produced zero unrecognised reasons.

Not done

  • The slippageTooTight card is covered by unit tests but has never been reproduced in a browser — I could not provoke a path-level slippage rejection on demand.
  • Copy is English-only; the other 16 locales carry empty placeholders for Crowdin.

@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: be97fd4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
@lifi/widget Minor
@lifi/widget-checkout Minor
connectkit Patch
deposit-flow Patch
dynamic Patch
nextjs Patch
nextjs15 Patch
nft-checkout Patch
nuxt-app Patch
privy-ethers-example Patch
privy Patch
rainbowkit Patch
react-router Patch
remix Patch
reown Patch
svelte Patch
tanstack-router-example Patch
vite-project Patch
vue Patch
zustand-widget-config Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

✅ E2E Dev Smoke — passing

Check Result
Dev server start (pnpm dev) ✅ started
Smoke tests ✅ passed

4 passed · 0 failed · 0 skipped · 29s

View run

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

E2E Playground results

passed  158 passed

Details

stats  158 tests across 10 suites
duration  1 minute, 55 seconds
commit  be97fd4

📥 Download full HTML report (open the run → Artifacts → playwright-report)

…lippage

Both slippage cards described the value as the user's own setting, which is
wrong on auto slippage — the widget resolved that figure, the user did not.
"Currently applied" is true either way, and stays true after the card's own
button writes the value.
`per-intent canary cap of 100000 sats` carries a figure in a unit the message
never names. On a BTC send the token guard is what keeps it out of the
evidence — reading it as BTC would be off by 1e8 — so the guard is worth a
test of its own rather than resting on the fixture sweep, which runs with a
USDC send and would pass for the wrong reason.

The changeset also now says slippage moves either way.
The reason fixture sweeps `filteredOut`, but nothing swept the `code` field on
`failed[].subpaths`. All thirteen documented codes, plus the internal
TOOL_NOT_ALLOWED, turn out to be mapped or deliberately suppressed already —
this pins that, so a code added upstream shows up as a failing test rather than
as the generic no-routes sentence.

Confirmed the test bites by adding an unmapped code and watching it fail.
`buildCard` was private to the hook, so the logic that decides every suggestion
— which figure to offer, and whether to offer a button at all — had no test at
all. It is a pure function of an issue and a handful of values; only the
gathering of those values needs React.

Moved it to `utils/routeIssues/card.ts` as `buildRouteIssueCard`, leaving the
hook to assemble the dependencies. No behaviour change.

The new test covers what the reports in this PR were about: a suggestion that
would not move the amount offers no button, a slippage cap withdraws its button
once applied, a cap offers nothing on a resolved auto value, and a contract-call
quote with no send amount invents no figure.
A sweep of all 36 bridges against the live API, restricted to one bridge at a
time, measured what each suggestion does when it is applied.

Where the backend named a maximum the suggestion held: celercircle,
celercirclefast and polymerStandard all returned routes at the suggested
amount. Where it named none, the card halved what the user had sent — and the
amount was refused again, with no figure again, so the card offered another
halving. That is the "apply one suggestion and another appears" case, and it
was the only one the sweep found.

The halving fallback is gone. Without a reported maximum the card now says the
amount is above what these routes accept and offers no button, which is all the
widget actually knows. Every remaining button comes from a figure the backend
gave: the four too-low suggestions all returned routes, and the one too-high
suggestion that still failed (polymer) landed on "pair not supported" with no
button rather than on a second figure.

Also classifies `Cross-chain swaps not supported`, which the sweep caught
reaching users unbucketed from seven bridges. It is emitted by the service
behind the deployed API rather than by lifi-backend, so the reason fixture
could not have predicted it.
Measured on the deployed preview with every tool enabled, sending 100,000,000
USDC from Arbitrum to Base: the card suggested 53,000,000, that amount was
still refused, and the next card suggested 9,800,000, which worked. Starting
from 60,000,000 took the same two steps; 20,000,000 took one; 2,000,000 needed
none.

The widget folds every maximum the backend reports and offers the largest, so
the user is asked to give up as little as possible. The largest is also the one
derived from current liquidity rather than a fixed cap, so it moves between the
two requests. The smaller, round caps held every time.

Keeping the largest is still the right default — preferring the smallest would
have suggested 980,000 where 9,800,000 works. What was wrong was the wording:
"should fit" promises an outcome the widget cannot know, and the card said it
immediately before the amount was refused again. It now states what the widget
actually knows, which is the largest amount it can see a route for.

The too-low wording keeps its promise: all four of those suggestions returned
routes when applied.
Reported: sending 0.000001 USDC — one unit, the smallest that exists — to SOL
showed "Not enough liquidity … A smaller amount may work better." There is no
smaller amount, and thin liquidity was not the problem.

Price impact is a ratio, so a send worth almost nothing reads as nearly 100%
however deep the pool is: the fixed costs are the whole trade. The sweep in
this PR recorded over 99.99% on a 100,000,000 USDC swap as well, so the figure
alone cannot separate the two.

The classifier now takes the send's USD value and treats a price impact under
the same one-dollar floor the rest of the feature uses as "amount is too low",
which offers the larger amount that actually fixes it. Above the floor the
liquidity card is unchanged, and a token with no usable price keeps the
liquidity card rather than having every impact called dust.

Verified by unit test in both directions; the pair in the report no longer
returns a price-impact reason from the API, so the live path could not be
re-run.
Swept 85 real quotes across 20 routes and four amount bands: EVM pairs,
same-chain swaps, and both directions for Solana, Bitcoin, Sui, Tron and two
long-tail chains. Every request returned HTTP 200. The 231 filter reasons
collapse to 20 distinct templates, and eight of them had no rule at all, so
they reached users as the generic no-routes sentence.

- A range limit in scientific notation ("max: 1.8921633406219466e+23") failed
  to parse. The range rule needs every limit, so it dropped the reason whole
  and an amount the backend had refused was never explained. `toBigInt` now
  expands scientific notation exactly, without going through a float.
- "USD value difference exceeds 40% ($x in vs $y out)" says what a price impact
  says, so it shares the same rule shape, dust handling included.
- "Chain 10 not supported" now reads as the pair not being supported, and
  "Upstream timeout" as a temporary failure.
- Four internal router notes are suppressed rather than shown: a tool's HTTP
  error, an unknown cause, invalid USD amounts, and a failed route split. None
  name anything the user can change.

The fixture now carries these live templates beside the ones lifted from
lifi-backend. The deployed API is a different service, so source alone was
never going to cover it — the comment in the coverage test now says so.
A second sweep added five long-tail chains and the request shapes the first one
never exercised: a receiver different from the sender, a contract as receiver,
and slippage at 0.01% and 30%. 102 cases, every request HTTP 200, four new
reason templates.

Three already classified — the receiver rules generalise across tools, so
celercircle and celercirclefast matched the same rule stargate does. The fourth
was "…skipping 1inch Fusion route generation", which describes what the router
declined to build rather than anything the user can change, so it is suppressed
and no longer crowds the card.
…roblem

The reported card — "Not enough liquidity … A smaller amount may work better"
on 0.000001 USDC — came from the INSUFFICIENT_LIQUIDITY *code*, with the
message "No liquidity available from OKX". The earlier fix hung the dust test
off the price-impact prose rules, so it never ran on that path, and the card
still read the same on the preview.

The test now sits in `collect`, where every rule's bucket is decided. Any route
to the liquidity bucket — prose or code, today's rules or tomorrow's — becomes
"amount is too low" when the send is worth less than the one-dollar floor. The
prose rules lose their own copy of the logic, so there is one place to read.

Captured from the deployed API by intercepting the widget's own request, not a
probe of my own: the widget's request returned that code and no price-impact
reason at all, which is why the first attempt missed it.
Driving Chrome to find classifier gaps was slow and unreliable: the browser
truncated large responses, the widget's own request differs from a hand-rolled
one, and nothing was repeatable. The widget only ever sees `unavailableRoutes`,
so replaying a captured payload through `classifyRouteIssues` and
`buildRouteIssueCard` reproduces a card exactly, offline.

`scripts/collect-route-issues.js` captures the payloads. It reads the API's
`ratelimit-remaining` header and stops with budget to spare, throws on any
non-200 rather than recording an empty payload — that silent failure is what
made an earlier browser sweep produce pages of nonsense — and keeps only the
fields the rules read, which takes the fixture from 2.8 MB to 244 KB. Cases can
pin a bridge or exchange, which is how an empty quote is produced on demand.

The test renders each card with the real English strings and asserts what a
user would actually read:

- no placeholder is left unresolved
- a raise suggestion moves the amount up, a lower one moves it down
- the liquidity card, which advises a smaller amount, never appears on a send
  worth less than a dollar
- reasons are never dropped: a payload carrying reasons must produce a bucket,
  and only an empty payload may fall back to the generic sentence

That last one is the invariant this whole PR turns on, and it now fails a test
instead of reaching a user. Cases where the quote still returned routes are
skipped, since the widget shows no card at all then — asserting on those was
measuring something nobody sees.
Running the new harness over 25 real payloads found one reason being dropped:
symbiosis returns "Amount too high, max available is $2921.44" under
TOOL_SPECIFIC_ERROR, which is suppressed wholesale, so a ceiling the user could
act on reached them as the generic sentence. Evidence now carries `maxUsd`
beside `minUsd`, and the card converts it the way it already converts a
minimum. That case now reads "the most we can see a route for is around 2900
USDC" and offers the button.

The harness gained the checks that make "does this make sense" an assertion
rather than a reading exercise:

- the button and the sentence must name the same figure, which is what the
  "quote says $11, suggestion says less" report was about
- a suggestion may not fall under a minimum the backend stated
- a slippage suggestion stays inside what the widget itself calls usual
- "this pair is not supported" never survives beside a reason about the
  request, and a busy tool never leads over one

It also tells a dropped reason from a suppressed one. A payload whose only
reason is "Oops! Try again" is meant to fall through to the generic sentence;
one carrying a reason nothing bucketed is the bug, and only that fails.

The collector now generates its matrix and tops the fixture up across rate
limit windows, since the routes endpoint allows about 75 an hour and the
matrix is 84 cases.
The routes endpoint allows 75 requests per ~80 minutes, in a fixed window that
releases all at once rather than trickling back. Measured across fifteen
minutes: remaining stayed at 1 and then went to 0, while reset counted down in
step with the clock. Spacing requests out therefore buys nothing — the whole
matrix needs several windows.

`LIFI_API_KEY` is now sent as `x-lifi-api-key`, and the run says which mode it
is in so a half-collected fixture is never mistaken for a complete one. Biome
owns the fixture's formatting, which JSON.stringify does not match, so the
script says to run `pnpm check:write` after collecting.
Every card the fixture produces now passes the invariants: suggestions move the
amount the way the sentence says, the button and the text name the same figure,
no suggestion falls under a stated minimum, the liquidity card never lands on a
dust send, and no reason is dropped.

Worth knowing from the spread: for a single-exchange restriction the API very
often returns an empty payload, so roughly a fifth of the cases have nothing to
explain and the generic sentence is the only honest answer. Both slippage
variants returned routes, so the slippage buckets are still not exercised by
live data — they rest on unit tests alone.
Added a `--verify` mode that re-requests at each suggested amount, which is the
one thing no offline assertion can check. Of fourteen suggestions measured
against the live API, nine found routes and five did not:

  celercirclefast  53,000,000  a maximum derived from current liquidity
  symbiosis             2,900  the dollar ceiling the tool itself stated
  squid                   1.1  the minimum the tool itself stated
  mayanMCTP            0.0076  the minimum the tool itself stated
  garden                    1  the invented one-dollar floor

So a bound the backend names is a good guess and not a guarantee: it moves
between the two requests, or another constraint binds at the new amount. The
too-high wording already said only what the widget knows. The too-low wording
still said "bumping it up to around X should do it", which is a promise that
fails about a third of the time, so it now names the smallest amount we can see
a route for and leaves it there.

Nothing about the figures changes. They are still the best available, and a
suggestion that does not resolve leaves the user with a fresh card rather than
a dead end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant