Repository navigation
Conversation
🦋 Changeset detectedLatest commit: ef3da3f The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 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 · 24s |
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
Contributor
E2E Examples — all passedAll examples passed in the latest run. |
2 of 3 tasks
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
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 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
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 a reason about the request itself it is untrue and dropped; beside a tool's own trouble it stays, as the more complete answer.temporaryranks last (an eco-only quote said "Try again" on every retry), andTool … not applied.plus a note-less "is currently disabled" are suppressed (in every capture they areTOOL_NOT_ALLOWEDmessages, which §2 suppresses).slippageTooLooseis a bucket the ticket does not list.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)
Browser checks:
Checklist before requesting a review
Testing
check:typespasses on@lifi/widgetand@lifi/widget-checkout.knip:checkpasses.useRoutes,useDebouncedWatch,useRouteIssueCardandRouteNotFoundCard. I checked each hook test by breaking the code under test. Each break fails its test.scripts/route-issues/report.jsprints the card for every payload. It is not part of the unit suite.Not done
gaslessNotAvailablehas no reproduction through the public routes endpoint, so it rests on the backend's templates and unit tests.blockedBySettingsis captured from a real payload but not clicked through: the playground has noallowSwitchChaincontrol.