Conversation
🦋 Changeset detectedLatest commit: 446c142 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 · 30s |
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
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.
chybisov
had a problem deploying
to
widget-test-pr-874
September 18, 2026 09:05 — with
GitHub Actions
Failure
"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.
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.