Conversation
🦋 Changeset detectedLatest commit: be97fd4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
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 |
Contributor
✅ E2E Dev Smoke — passing
4 passed · 0 failed · 0 skipped · 29s |
Contributor
E2E Playground resultsDetails
📥 Download full HTML report (open the run → Artifacts → |
chybisov
had a problem deploying
to
widget-test-pr-874
September 11, 2026 08:02 — with
GitHub Actions
Failure
chybisov
had a problem deploying
to
widget-test-pr-874
September 11, 2026 08:28 — with
GitHub Actions
Failure
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —
useRoutesreceivedunavailableRoutesand 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
useRoutesqueryFnerrorspayload never enters the query cacheutils/routeIssues/useRouteIssueCardRouteIssueCardmemo, zero hooksWhy classify inside
queryFn: one measured no-route response carried ~590 KB oferrors. Caching that per query key across the 60s refetch is not acceptable. The classifier is pure andqueryFnalready holds everything it needs, so only the compact result is cached.Query data shape.
queryFnnow returns{ routes, issues }instead ofRoute[]. 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_INPUTis contractually stable; everything else is backend prose that can be reworded at any time.classifyRouteIssuesreturns[]— 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 exhaustiveswitch, and each entry is classified inside its owntryso one bad reason cannot discard the rest. Every matcher fragment is pinned by a test, and an unmatched payload warns in development.Alternatives considered
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 ononClick),priceToTokenAmount,formatTokenPrice,formatSlippage,wrapLongWords,t('format.currency'), and the existingt(key as any)idiom for computed keys. A newuseApplyAmounthook removes the duplicated limit-mode write thatPercentageChipshad inline.Things worth a reviewer's attention
stringifyPathjoins swaps with~, bridges with-). Otherwise the card states the bucket with no number.NO_POSSIBLE_ROUTEis emitted per tool, so beside any other reason it is both noise and untrue — it is dropped unless it is the only signal.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)
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
Testing
259 tests (110 new),
check:typesclean on@lifi/widgetand@lifi/widget-checkout,knip:checkclean. 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
slippageTooTightcard is covered by unit tests but has never been reproduced in a browser — I could not provoke a path-level slippage rejection on demand.