Skip to content

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

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

chybisov wants to merge 90 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: 446c142

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 · 30s

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  2 minutes, 25 seconds
commit  446c142

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

Where a tool reports its cap in dollars and no token figure, the card converted
that cap at the current price and then read "the highest limit we saw was about
X TOKEN" — claiming a figure nobody stated, which is the over-claim the too-low
copy already avoids. The bar now travels with the suggestion, so the card quotes
the dollars the tool named and gives the token figure as the conversion it is.
"Try sending a little more" reads as an instruction about the send field, which
exact-output renders read-only — its lever is the receive amount. The advice is
the same either way, so the copy now talks about the amount rather than about
which box to type it in.
Gating which bar the card may quote also changed which amount it suggests. A
tool asking for less than the one-dollar floor is still a tool the user can
reach, so its bar should keep deciding that the floor amount beats another
tool's larger reported minimum — the card simply must not name the raised
figure as one anybody stated. The two questions are now answered separately.

Also: the limit routes block rendered an empty Stack, and its bottom margin, in
the wide layout where the panel carries the card; and the ranking comment named
two departures from the ticket while a third entry, liquidity, had moved too.
The card reads "the most any of them reported" while the fold kept the smallest
of two ceilings, so two tools capping a pair at $40 and $100 printed $40 under
that sentence. The token fold beside it already keeps the highest limit for a
too-high amount, and the reasoning is the same in both: the user has to clear
one tool's bar, not every tool's, so the gentlest of each kind is what to aim
for — the lowest floor and the highest ceiling.
The rule ran before every actionable one with an unanchored, greedy pattern, so
it claimed any prose that merely ended in "not applied." — dropping an amount
reason stated after it, and pre-empting `toolDisabled` on a disabled tool whose
published note happens to end that way. It is anchored to the whole reason now
and sits after `toolDisabled`, which is where its sibling already sat for the
same reason. Two tests pin both failures.

The quote branches also emitted an empty AvailableRoutes on a classified 404
while their success paths emitted nothing, so an integrator driving its UI from
that event saw the list empty and never refill. Both paths report now.

The invariant comment on RouteNotFoundCard sent the next reader to the
components rather than to the files holding the gates. It names the gates.
A contract-call quote is driven by the receive amount, and this branch added a
catch that feeds its 404 diagnostics to the classifier — so the sell field, which
config and the URL can fill even where the form hides it, started deciding the
bucket. A stale figure there rewrites a liquidity reason as "amount is too low"
and offers a button that writes a field `getContractCallsQuote` never reads. The
guard added for exact output covers both branches now.

The card also reported a scaled requirement as an observed one. The gasless rule
derives its figure by multiplying the user's own amount by a USD ratio and marks
it `estimated`; the card read only its own flag, so that figure was announced as
"the lowest minimum we saw". Both flags now mean the same thing to the copy.
The guard added last round omitted `toAmount`, so it was broader than the
contract-call branch it exists for. A custom deposit flow with contract calls
and no `toAmount` quotes through getRoutes and does send the sell amount, yet
its reasons were classified as though it did not — dropping every reported
figure and leaving the no-amount copy. It mirrors the branch exactly now.

The too-high card also ignored a stated ceiling that allowed more than the token
figure beside it: one tool capping at 50 tokens and another at $100 fold into
one issue, and the card advised 49 while calling it the highest limit seen.
Clearing one tool's cap is enough, so it aims for the higher of the two — the
mirror of the gentler-minimum rule for a low amount.

The anchored not-applied suppressor was also whitespace-sensitive, and nothing
trims a reason before matching it. Both are pinned by tests.

Also removes the wide-layout branch inside the limit and scheduled route blocks,
which the early return above had made unreachable.
Preferring the higher of the two too-high figures ignored whether the winner
still moves the amount. A reported cap always does, because a tool refused what
the user sent; a dollar cap is converted at the widget's own price and can land
at or above it. Taking that one then left no suggestion at all, where the
reported cap would have worked. The card aims for the highest figure that is
still below the sent amount.

The quote-error parser also cut the message at its first brace and parsed to the
end, so a brace in the leading prose or any text after the payload lost the
diagnostics. It scans the candidates instead.
The scan fixed the leading prose but still took the last closing brace in the
whole string as the end of the payload, so trailing text closing a brace of its
own — a trace id, say — lost the diagnostics and left the generic sentence. It
tries each opening brace against each closing one after it. Reasons are short
and hold few braces, so the scan stays small.

Also records why the debounce covers Max and the balance display and not only
the percentage chips: all three keep the delay they had before this feature, and
only the reason card's button asks to skip it.
The scan added last round paired each opening brace with each closing one after
it, on the stated grounds that reasons hold few braces. The payload it is
looking for is itself JSON, so a message carrying one holds hundreds, and a
malformed message meant that many parses over slices of it. The common case hid
this: the first pairing tried is the right one.

It walks out from each opening brace to the brace that closes it, skipping
string bodies so a reason may contain braces and quotes of its own, and stops
after a handful of candidates — prose before a payload carries a brace or two,
never a dozen. One parse per candidate.
…e ran first

"The amount is too low or too high. The minimum is X and the maximum is Y" was
split across two rules, one per direction. The first always matched, so a send
above the maximum was reported as "Amount is too low" and the card told the user
to send more. The mirror failed too: decimal bounds did not match the integer
pattern, so the ceiling rule claimed them and a send below the minimum was
reported as too high.

The backend's own wording does not say which bound was crossed, so only the
amount can settle it — and only while the leg still holds the user's token,
since the bounds are stated in the leg's. One rule compares and derives the
direction, as the transferRange rule beside it already did, and drops the entry
where it cannot tell rather than guessing.

The matcher also filtered candidate rules on their declared bucket, which
discarded exactly the rules that derive one. A rule with `bucketFrom` now
survives a code that disagrees; its `extract` already returns null when the
amount is inside the range, so it cannot claim an entry it does not explain.

Also replaces the agent session's scratchpad path, committed by mistake in the
collector and its dump test, with the system temporary directory and a
SUGGESTIONS_FILE override. `--verify` failed with ENOENT for anyone else.
Four findings from the fifteenth round, all in slippage and suggestion code the
earlier rounds never reached.

A "too tight" card with no stated figure capped its suggestion to the band the
widget recommends. The widget only warns about a looser setting rather than
blocking it, so a user at 3% read "Loosening it to 1% should help" — smaller
than what they already had, on a card titled too tight, and the Apply guard
refused to act on it anyway. It offers nothing there instead.

Slippage figures also rounded up whichever bar they described. That is right for
a floor and wrong for a cap: a bridge capping at 1.2345% was reported as 1.24%,
and applying it was refused for the same reason, so the card never settled. Each
bar now rounds away from itself, and a cap too small to express offers nothing.

In limit mode the card's Apply flushed the send amount at once while the receive
amount, derived from it by an effect, kept the full debounce — so for half a
second the widget quoted a resting order against the previous limit price. Both
halves settle together again.

The too-low sentence also presented a figure carrying a deliberate 2% buffer as
the minimum a tool stated. It says the suggestion sits a little over it.
Two fixes of mine met badly. Receive-driven quotes classify with `fromAmount`
zeroed, and the range rule added last round compares against it — so zero sat
below every stated minimum, every range read as "amount too low" with a figure,
and resolveAmountConflict then deleted the genuine "too high" from the same
payload. An exact-output user was told to raise an amount that was too high. The
rule needs a real send amount before it can say which bound was crossed.

The recipient card also offered to clear an address the widget requires. Its
gate checked only that both sides share an ecosystem, so with requiredUI
toAddress, or a contract wallet on a cross-chain pair, pressing it destroyed the
receiver and left the next quote unexecutable.

Also: the stream's done payload names each source the way the routes event does,
`provider` or `source`, and reading only `id` would have matched nothing and
silently suppressed every reason; and the message parser advanced one character
past a candidate, so braces nested in a leading object spent the whole attempt
budget before a payload further along was reached.
…ollide

The tie-break between "too low" and "too high" asked whether either side
carried a figure, and looked only for token amounts. A ceiling stated only in
dollars — which is how the toolMaxUsd rule reports one — therefore never had
one, so it always lost to a figureless low and the card told a user whose amount
was too high to send more. The mirror case dropped a low carrying only a
dollar floor. Both bars produce a suggestion, so both now count.

The evidence fold had the same blind spot in slippage. It kept the smaller of
two figures in both buckets, but requiredSlippage is a floor to clear in one and
a cap to stay under in the other: two caps of 0.5% and 2% folded to 0.5%, so the
card demanded a value five times tighter than any tool asked for. Clearing one
tool's cap is enough, as maxUsd beside it already knows.

The stream's done payload also reached `in` on each element to read its source
name, which throws on a null or a string — and the array is cast from the
backend unvalidated, so one malformed entry would have dropped the whole stream
to the batch fallback and re-run the request.
Letting bucket-deriving rules past the code filter, two rounds ago, meant the
range rules are now tried against every entry whatever its code. When one of
them rejects an entry — a range the send amount sits inside, or a receive-driven
quote with no send amount at all — `collect` dropped the entry outright, and the
code's own bucket died with it. A tool error reporting AMOUNT_TOO_HIGH,
INSUFFICIENT_LIQUIDITY, TOOL_TIMEOUT or CANNOT_GUARANTEE_MIN_AMOUNT alongside
that prose produced no card at all.

Exact-output and contract calls made it worse: they classify with no send
amount, so the range rule rejects on its first line and every entry carrying
that prose was deleted, code included — the generic sentence, on two of the
paths this feature exists to explain.

Rejecting the prose is a refusal to refine, not a verdict on the entry. The
match now carries what the code named, and falls back to it.

Also guards the stream's source read: the previous round let a non-object
element through the parser on purpose, which moved the throw into the helper
that destructures it — after the stream had finished, so the branch's catch read
it as a stream failure and re-ran the whole request.
When a payload carried both amount buckets and both had a figure, the tie-break
kept the low unconditionally. A send of 10,000 USDC answered with a $10 floor
from one tool and a $5,000 ceiling from another therefore read "Amount is too
low — try a slightly larger amount", and the stated ceiling and its button were
both discarded.

The discriminator was already in the evidence: a floor the send has cleared, or
a ceiling it already sits under, cannot be the reason. Whichever bar the send is
still on the wrong side of now wins, in token units or in dollars, and the
figure tie-break only settles the case where both stand.

Also removes a stale note telling the operator to reformat the fixture, which
Biome no longer owns.
The too-high card read "caps slippage at X%, which is less than is currently
applied" without ever reading the applied value. On a resolved auto slippage
there is nothing to compare against and nothing to lower in one click, so the
sentence asserted something the card could not know. It states the cap and the
action instead, which is true either way.

The too-tight comment also promised the opposite of the code beside it: the
button is capped to the band the widget calls reasonable, so a bridge asking for
more than that is named in the copy but never applied in one click. Agreeing to
lose several percent belongs in settings, not on a card. The comment says so.
React Query keeps the last successful payload through a failed refetch, so a
settled-empty quote followed sixty seconds later by a 500 left the card still
explaining the older one — "Amount is too low", with a live Apply button, beside
a "quote failed" message saying the request never landed. The panel had a guard
for this; the four other surfaces did not, so it belongs in the hook that feeds
all of them. A confirmed 404 is a real answer and still stands.

Suppressed prose also took the bucket a code had named down with it. For the
catch-all bucket the prose runs unfiltered, which is the only place a suppressed
rule can match, and a match dropped the entry whole — so a NO_POSSIBLE_ROUTE
tool error whose message happened to be "Unknown error" produced no card at all.
Saying nothing worth showing is no reason to discard what the code said.
…emoves

"Send to my own address" emptied `toAddress` and left `selectedBookmark` behind,
which the three other clear sites drop together. The receiver card then kept the
removed recipient's name on screen above an empty address, and the stale
bookmark's chainType could satisfy the guard in useToAddressReset, so a later
destination-chain change skipped the reset it exists for.
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