Skip to content

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

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

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

Conversation

@chybisov

@chybisov chybisov commented Sep 11, 2026 •

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 eleven 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 a reason about the request itself it is untrue and dropped; beside a tool's own trouble it stays, as the more complete answer.
  • Two departures from the ticket's §3/§4, pending the owner's sign-off on JUMEMB-98: temporary ranks last (an eco-only quote said "Try again" on every retry), and Tool … not applied. plus a note-less "is currently disabled" are suppressed (in every capture they are TOOL_NOT_ALLOWED messages, which §2 suppresses). slippageTooLoose is a bucket the ticket does not list.
  • 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, USDC (Base) → SOL, 0.000001 USDC:

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

Amount is too low
This is under the smallest amount these routes will take. Try about 0.64 USDC — a little over the smallest minimum we saw.
[ Apply suggestion ]

Browser checks:

  • I tested the card on Ethereum, Polygon, Arbitrum, Base, Solana and Stellar, in all four themes and in dark mode.
  • The card shows once in the wide variant and once in the compact variant.
  • Apply works for amount too low, amount too high, slippage too loose and slippage too tight. Where I measured it, the new quote started 102–227 ms after the click.
  • If the relayer quote is slow, the card still shows at once.
  • With a stubbed routes response, these cards render correctly: receiver not supported, temporarily unavailable, gasless not available, blocked by settings, not enough liquidity and pair not supported. An unknown reason shows the generic sentence.
  • "Send to my own address" clears the receiver and starts a new quote. "Try again" starts one new quote, also when pressed twice quickly.
  • If the wallet holds less than the suggestion, or none of the token, the card shows the figure without the Apply button. If the balance is not known, the button shows.

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

  • 834 widget tests and 202 checkout tests pass. CI runs them in the Test workflow.
  • check:types passes on @lifi/widget and @lifi/widget-checkout. knip:check passes.
  • Tests cover the classifier, the card, useRoutes, useDebouncedWatch, useRouteIssueCard and RouteNotFoundCard. I checked each hook test by breaking the code under test. Each break fails its test.
  • 87 captured API payloads pin the rules. Real data shows 8 of 11 buckets as a card, and real text pins 10 of 11 buckets. A coverage test names the gaps.
  • scripts/route-issues/report.js prints the card for every payload. It is not part of the unit suite.

Not done

  • gaslessNotAvailable has no reproduction through the public routes endpoint, so it rests on the backend's templates and unit tests.
  • blockedBySettings is captured from a real payload but not clicked through: the playground has no allowSwitchChain control.
  • 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: ef3da3f

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

This PR includes changesets to release 4 packages
Name Type
@lifi/widget Minor
@lifi/widget-checkout Minor
nft-checkout Patch
tanstack-router-example 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 · 24s

View run

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

E2E Playground results

passed  164 passed

Details

stats  164 tests across 11 suites
duration  8 minutes, 58 seconds
commit  ef3da3f

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

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

E2E Examples — all passed

All examples passed in the latest run.

Two JSDoc blocks had stacked up on the field, and TypeScript keeps only the
last — so the sentence explaining what the counter is for never reached a
reader. One block now says both things.
Every other one-click fix on the card hides itself when it cannot help — the
two slippage buttons check what is applied before offering anything. The amount
buttons did not, so a wallet holding 0.4 USDC was told to try 1.1 and the click
traded "no routes" for "insufficient funds". The card knows the spendable
balance now and keeps the figure while dropping the button. Zero still means
unknown — no wallet, or balances still loading — and offers it.
A reachability sweep over the 71 fragment rules found exactly one that neither
the collected backend reasons nor any test exercised: the gasless rule for a
tool charging a native-token fee on top of the transfer. Its siblings are all
pinned in the same table.
The card chooses its copy from the bucket, the evidence and the applied
slippage, so a bucket missing one of its variants only surfaces on the
combination that asks for it — as the raw key, in front of a user. The live
payloads cover what the API has produced so far; this covers what it has not:
all eleven buckets against nine evidence shapes, three slippage states and both
send amounts, asserting no absent key and no unfilled placeholder.

Removing a single variant from the copy fails it, naming the combination.
An engine without Intl.NumberFormat v3 ignores roundingMode and rounds
half-way, so a buffered floor of 1.0404 came out as 1.0 — under the bar —
and a slippage cap could round up past itself. Round on the decimal
digits instead.
The suggestions dump wrote a file and asserted nothing, and the card
printout had no expect, yet both ran with every unit test. They are now
one opt-in report under scripts/route-issues with its own vitest config,
so an ordinary test run neither prints nor writes.
Slippage floor, slippage cap, a blocked destination signature and a
receiver with no Stellar trustline now each come from a real request
pinned down to the tools that refuse it. The receiver prose is captured
beside a route, and no public request produces the gasless reasons, so
a coverage test names those gaps instead of leaving them implicit.

The slippage check assumed every suggestion raised the setting; a cap
is met by lowering it.
It was demoted as noise from tools that dislike any receiver, but the
bucket now survives only for a receiver the user chose, so it is never
noise. Beside a price-impact entry it hid the one fix that works behind
advice to send less. The replay of every captured payload changes no
card.
Main now keys the routes query on the resolved storageScopeKey, which
wins over the deprecated keyPrefix. The reason card's retry still built
its key from keyPrefix, so with both set it invalidated nothing and the
button did nothing.
Each press invalidated the routes query, and an invalidation cancels a
refetch in flight to start another, so two presses 80 ms apart sent two
requests. A press during a running refetch now leaves it alone.
Zero stood for an unknown balance, so a wallet holding none of the token
kept the button that a wallet holding a little less than the suggestion
lost. An unknown balance is now undefined, and a loaded zero withdraws
the button like any balance below the suggestion.
Several sentences claimed every route, any route or no provider, while
the reasons cover only the routes the request reached — one reason was
enough to say "every route", and a rate-limited tool beside a refusal
was enough to say no provider connects the pair. Each sentence now speaks
of the routes and providers it saw.
On the private tab Houdini answers "Amount 5 is out of Houdini bounds
(min 26.25, max 594000)", which matched no rule and left the generic
sentence. The three numbers share a unit, so the text settles the
direction; the bound becomes a figure only when the stated amount is the
user's own, which proves the unit is the user's token.
With no main route and a relayer quote in flight, the no-route answer
waited on the relayer: 2.8 s live, and as long as the relayer hung — 30 s
in QA. It now goes out at once. A route the relayer brings later takes
its place, unless a newer request is running or a newer answer already
has a route.
A relayer route that arrived after the user changed the amount was still
written to the old query and announced through AvailableRoutes, so an
integrator heard of a route for an amount no longer in the form. It now
lands only while the query still has an observer.
Partner prose arrives under TOOL_SPECIFIC_ERROR, which is suppressed, or
under NO_POSSIBLE_ROUTE, the catch-all, so a reason a partner names
outright was lost. On the captured payloads a 100M USDC send through
mayanMCTP said "This pair is not supported" beside "cctp does not have
enough capacity for this amount", and eco and mayan said the same beside
Bitget's value deviation. symbiosis's percent slippage cap matched
nothing, though the cap rule claimed it.

Rules now read the amount floors and ceilings, the slippage floor and
cap, and the value deviation those partners state. A capacity cap is in
the leg's own token, which the message does not name, so it gives the
direction only. A test now requires every collected partner text to be
read by a rule or named as noise, so the next one cannot go unnoticed.

Comments no longer cite the ticket, whose order this now is.
Across refuses its own handler contract with "does not send ETH to
contracts (receiver: ReceiverAcrossV4)", seen 17 times in the captures.
With a receiver of their own set, the user was told the recipient is not
supported and offered "Send to my own address", which changes nothing.
That text is now suppressed, and a refusal naming any other address than
the user's receiver is dropped.

Two rules required wording the backend does not promise: the gasless
floor now needs only "below the gasless minimum of", and a slippage
requirement may be stated in percent. Every gasless and Stellar reserve
fragment now has a pinned test, and the code fixture lists
PRICE_IMPACT_TOO_HIGH.
"Min destination amount too low for integrator" bars the received
amount, and fees come off before it is checked. The send's own value
refuted it, so the amount applied to clear it could arrive under the
floor and the next quote showed the generic sentence. The bar now
stands while the send is under twice the floor, allowing fees as large
as the floor itself; a send far past it still refutes it.

A tool that judged the amount routes the pair, even when its bar was
refuted or contradicted, so "This pair is not supported" no longer shows
beside one.

An operator's note on a disabled tool is shown only from a filter reason
or TOOL_NOT_ALLOWED, the two carriers operators publish through. A
partner's own error text never becomes copy, even in that shape.
The card asked for changes the user could not make: a larger amount the
app had locked, another address where the receiver is hidden, required
or in another ecosystem. Each now states the reason without the request.

- Destination reasons name the step: open a Stellar trustline, fund the
  account, link the SEI address, and so on.
- Expensive gas no longer reads as a busy provider.
- Price impact no longer advises a smaller amount. On a small send fees
  cause it, and a larger one helps, so the copy names no direction.
- "slightly larger" said too little where a minimum is many times the
  send.
- Buttons name what they set: "Use 27 USDC", "Set slippage to 0.6%".
  The slippage one replaces Auto in the saved settings.
- A figure the wallet cannot send keeps its sentence and says why it has
  no button.
- "Try again" holds while the quote runs, so a second press is not lost.
- A dollar bar gets the same 2% margin as a token figure.
- The copy uses one term, "these routes", and no idioms. Blocked steps
  belong to the app, not to the widget's settings.

Each new key is in en.json and blank in the other locale files.
The page dropped the no-route answer whenever a quote was in flight, so
every refresh and every "Try again" left an empty list until it
returned. It now keeps the answer until the new one arrives, as the
route card on the main page already does.
The rebase drops the merge commits that carried these: the routes test
mocks main's useAddressForChain and keys on storageScopeKey, and
shared.ts keeps main's resolveStorageScopeKey and url exports beside the
route issue types.
The card's 49 keys were blank in every locale file, so each locale showed
the English card under a translated title, and the locale test failed.
Each locale was translated by one agent that read only TRANSLATING.md and
its own glossary in docs/i18n, with placeholders, brand and chain names
kept as in English.

This branch was successfully deployed

1 active deployment
widget-test-pr-874 — ef3da3f4 Deployed Oct 12, 2026 by chybisov via Publish to Cloudflare Pages #3294
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant