Skip to content

feat(swap): quotes from freighter-backend-v2 (C1) - #3074

Open
piyalbasu wants to merge 17 commits into
masterfrom
feat/swap-backend-quotes
Open

piyalbasu wants to merge 17 commits into
masterfrom
feat/swap-backend-quotes

Conversation

@piyalbasu

@piyalbasu piyalbasu commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What

Swap quotes now come from freighter-backend-v2 (POST /api/v1/swap/quotes) instead of Horizon path finding. This covers the live "You receive" amount, the quote the review builds from, and the XLM reserve sheet. The extension still builds and signs the classic path payment itself from the backend's classic route; moving the build to the backend is the next step (C2).

When the backend can't quote (an outage, a timeout, a rate limit) or the pair has no route, "You receive" shows 0, a "Quote unavailable" line opens an explanation sheet, and "Review swap" is disabled. Outages go to Sentry; a pair with no route does not. The button keeps its label in every state, as designed.

Custom networks keep Horizon path finding, since the backend can't reach them.

Ship order: this needs stellar/freighter-backend-v2#178 live in prd first. Released before that, every swap shows "Quote unavailable".

Why

Each client has its own Horizon swap code today, so every new liquidity source costs two client releases. With routing on the backend, a source is added once. Soroban tokens (through LI.FI) arrive this way, after C2.

Spec: stellar/wallet-eng-monorepo#131 (client item C1).

Implementation details (for agents)

What changed

  • getSwapQuotes calls /swap/quotes through the existing FETCH_BACKEND_V2 path, so requests are JWT-signed in the service worker. A 400 is bad input; every other non-200 is "unavailable". getSwapQuote picks the classic route (pickSwapRoute is the one place to switch to routes[0] once the backend builds) and falls back to Horizon on custom networks. The full response is kept in transactionData.swapQuotes for C2/C4.
  • Stored quotes carry the request they answered (account, network, assets, amount, mode). Review reuses the live quote only on an exact match and otherwise re-quotes, so the transaction is never built from a quote for an earlier amount or another destination. A quote that expires at submit drops its key.
  • Live quote: a monotonic request id drops out-of-order answers. Pressing Review cancels any pending or in-flight live quote, so it can't overwrite the quote the review builds. A quote landing after unmount is discarded. The live quote re-runs when the account or network changes.
  • Amounts are sent as plain decimals. The backend rejects 5., .5 and exponents. On this screen the percentage buttons and the fiat-to-crypto sync could produce 1e-7, which cleanAmount turned into 17.
  • Outages: an unreachable backend, a 502/503, and a 200 whose classic source reported error/timeout/rate_limited are reported to Sentry; no_route, insufficient_balance and 400 are not. Both show the same "Quote unavailable" line and sheet with "Review swap" disabled. At review time the review sheet's error notice still distinguishes them ("Swaps are temporarily unavailable" / "No quote available").
  • e2e: swap mocks move from page.route("**/paths**") to a context-level stubSwapQuotes, because the request leaves from the service worker. stubAllExternalApis answers /swap/quotes with no route by default.

Verification

  • Unit tests mock fetchBackendV2 instead of Horizon. Each fix has a test that was run against the code before the fix and failed.
  • Run in Chromium (Playwright, integration mode, a fresh Friendbot account) against feat(swap): swap routing across the classic DEX and LI.FI freighter-backend-v2#178 @ cec158e running locally. /swap/* went to the local backend and everything else to stg backend-v2. All quote requests were JWT-signed by the extension.
    • Testnet XLM→USDC (new trustline): the live quote showed 7.7813421 USDC, the same as Horizon's answer for that pair. Review reused it without a second request, showed the trustline banner, and the swap was submitted and succeeded on testnet.
    • Backend down: "You receive 0", the "Quote unavailable" line, and a disabled "Review swap".
    • No route (an illiquid testnet asset): the backend returned routes: [] with classic no_route; same screen as backend down, without the Sentry report.

Screenshots / video

pr3074-swap-quote-states-v3

Swap screen with quotes from freighter-backend-v2: quote loading, quote landed, quote unavailable (backend down or no route for the pair; the screen is the same), and the Quote unavailable sheet.

Checklist

  • I have read CONTRIBUTING.md and this PR meets its requirements.
  • This PR has narrow scope and does not mix refactoring with feature changes.
  • This PR adds tests for the new functionality or fixes.
  • These changes have been tested in both Chrome and Firefox.
  • This PR includes relevant before and after screenshots for UI changes.
  • I took the time to review my own PR.

🤖 Generated with Claude Code

piyalbasu and others added 5 commits November 4, 2026 18:41
getSwapQuotes calls POST /swap/quotes through the existing FETCH_BACKEND_V2
chokepoint (JWT-signed in the service worker). 400 maps to a bad-input
error, every other non-200 to "unavailable".

getSwapQuote uses it on standard networks and keeps Horizon path finding on
custom networks, which the backend can't reach. Until the backend builds the
transaction (C2), the classic route is used; pickSwapRoute is the one place
to switch that to routes[0].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The debounced live quote, the review-time fallback quote and the XLM
reserve sheet's receive quote now go through getSwapQuote. The full
response is kept in transactionData.swapQuotes for C2/C4. A 502/503
(or any non-400 failure) shows "Swaps are temporarily unavailable";
empty routes, no classic route, or a 400 show "No quote available".

Unit tests mock fetchBackendV2 instead of Horizon. e2e mocks move from
page.route("**/paths**") to a context-level stubSwapQuotes, since the
request leaves from the service worker; stubAllExternalApis now
answers /swap/quotes with no route by default, and the two swap tests
that relied on live Horizon now stub a quote.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…lable

Review reused the stored live quote whenever it was non-zero, so a quote
for an earlier amount, another destination, or one that expired at submit
could be built with the wrong destMin and path. The stored quote now
carries the request it answered (account, network, assets, amount, mode).
Review reuses it only on an exact match and otherwise re-quotes. A quote
that expires at submit drops its key. A live quote that lands after the
screen unmounts is discarded.

An unreachable backend (fetchBackendV2 rejecting) and a 200 whose classic
source reported error/timeout/rate_limited showed "No quote available"
and reached no Sentry. Both are now "Swaps are temporarily unavailable"
and are captured (spec §8). no_route/insufficient_balance and 400 keep
the no-quote state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e fixes

- Quote amounts are sent and keyed as plain decimals (BigNumber toFixed).
  The backend rejects "5.", ".5" and exponents. The percentage buttons and
  the fiat-to-crypto sync produced "1e-7" via toString, which cleanAmount
  then turned into "17".
- A 400 when review re-quotes shows "No quote available", as the live CTA
  does, instead of the generic error.
- Starting the review cancels any pending or in-flight live quote, so it
  can't overwrite the quote the review builds. This covers the reserve-
  sheet path too.
- The reserve prefill no longer re-reports SwapQuotesError to Sentry.
  getSwapQuotes already reports outages, and 400s stay out of Sentry.
- The live quote re-runs when the account or network changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The only conflict was swap.test.ts's import list: this branch's
stubSwapQuotes beside master's trending-route constants from #3065
(asset search through backend v2). Kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-311503013c1b9c016d26
Backend: sandbox (piyalbasu). SDF collaborators only — install instructions in the release description.

piyalbasu and others added 9 commits November 9, 2026 18:07
The Review swap CTA was enabled and "You receive" showed a stale value or 0
while a /swap/quotes request was pending (1.7-3.3 s via LI.FI), because
hasNoSwapPath was forced false while isLiveQuoteLoading, and for one render
after an amount change neither flag said a quote was pending.

A quote is now pending when the amount is positive, both tokens are picked,
and either a live quote is in flight or the stored quote's key does not match
the current inputs. While pending, the CTA keeps the "Review swap" label but
is disabled with the Button loading spinner, and the receive card shows the
Shimmer placeholder. Balance and XLM-fee blockers still take precedence. A
failed live quote now stores its request key so it settles to the existing
no-quote / unavailable states instead of staying pending (the review build
still re-fetches because the stored amount is 0).

Tests: unit and component tests that click Review now wait for the quote.
The two "starting the review with a live quote in flight" tests are replaced
by one asserting the review cannot start in that state (now unreachable).
e2e: swap and blockaidScan specs wait for the CTA to be enabled before
clicking; the blockaidScan.safe review assertion drops a .or(getByText
("Review")) that also matched the CTA and went strict-mode flaky once the
review opened faster. The quote-expiry recovery e2e now asserts the CTA
re-enables after the re-quote.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Pressing Review committed the cleaned amount ("1,000" -> "1000") to
Redux, which re-armed the live-quote effect after cancelLiveQuote had
run. The effect set isLiveQuoteLoading, the request's result landed
while the review sheet was open and was dropped as review-frozen, and
nothing reset the flag. Cancelling the review then left the CTA
disabled with a spinner and the receive amount shimmering until the
user edited an input.

The stored quote key already answers "is a quote pending": every
success and every failure stores the key it answered, and a dropped or
superseded response stores nothing. Drop the flag and derive pending
from the key alone, so no cancelled or frozen request can leave the
screen pending.

The CTA-gate test that reaches the review sheet now spreads the full
transactionSubmission initial state: the review reads hardwareWalletData
and crashed on the partial store, which the test only survived because
the sheet rendered after RTL cleanup. Awaiting the click exposed it on
the unfixed code too.

The redundant /swap/quotes request on each Review press remains; it is
harmless now that its result cannot leave the screen pending.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
getSwapQuotes and getSwapQuote captured to Sentry on every failure, and
the live quote re-fires once per debounced keystroke, so an outage cost
one event per 500ms of typing per user. Meanwhile a 400 was reported
nowhere: the helpers skipped it as "user input", and the reserve
prefill skipped every typed error on the claim that the helpers had
already reported it.

Move reporting to the two paths where the user acted: the review
re-quote in useSimulateTxData and the swap-for-reserve prefill. Both
report once per press and include badRequest, since the extension
builds every field of the request and a 400 is a client bug. The
helpers now only type the error; the live hook stays silent.

Tests: the helper and classic-outage tests flip to "not reported";
the live-quote tests assert no capture on 400, 5xx, unreachable and
classic outage; the review and prefill tests assert one capture each,
400 included. Both suites now clear the Sentry module mock between
tests, which the review 400 assertion needed to be discriminating.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Every quote now goes through getSwapQuote, which calls
horizonGetBestPath directly for custom networks; nothing dispatches
the thunk, and no test references it. Its fulfilled/rejected reducers
still wrote transactionData.path and destinationAmount, so a new
caller could have bypassed the backend silently. Remove the thunk, the
two reducers, and the two imports only they used.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…arming

The previous commit moved Sentry reporting to the review re-quote, but
a live-quote failure disables the CTA, so that path is unreachable
during an outage and nothing was reported at all. Report from the live
hook instead, once per mount until a quote succeeds, so an outage costs
one event per screen visit rather than one per keystroke. 400s stay
unreported there: on the live path they are the user's state (unfunded
account, unsupported token); the review and prefill paths still report
them.

Key the live-quote effect on the cleaned amount. Review commits
"1,000" as "1000", which re-armed a quote that could land behind the
review sheet and replace the path the XDR was built from, and cost a
redundant request per press.

Align the two 400 comments: a 400 is the request, not an outage; the
review comment no longer claims it is always a client bug.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Shown when the CTA state is noQuote or unavailable; tapping opens a bottom
sheet explaining that no quote could be fetched (Figma 3405-86973).

Moved here from the C4 branch, which also adds the See details link that
shares the SwapAsset__details-link style; the style now lands with this
commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Varial design (Transactions page, "Quote unavailble" frame) shows a
disabled "Review swap" with the reason on the "Quote unavailable" line
and sheet. The amount-screen CTA had carried the reason instead: "No
quote available" since Swap to New Token (#2872), and "Swaps are
temporarily unavailable" since bad1536, which predates the line and
sheet. Both CTA labels go, with their now-unused translation keys; the
CTA state reports isQuoteUnavailable for the line. The review sheet's
error notice still shows the two strings when a re-quote fails there.

The outage/no-route distinction only fed those labels, so the
isSwapUnavailable state in useSwapLiveQuote and its pass-through in
getSwapDerivedData are deleted. Sentry reporting is unchanged.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
@piyalbasu
piyalbasu marked this pull request as ready for review November 11, 2026 15:50
@piyalbasu
piyalbasu requested a lite review from Copilot November 11, 2026 15:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Four moderate active issues remain: incorrect no-quote handling, untranslated user-facing errors, over-reporting expected 400 responses, and broken parsing for token symbols containing colons.

4 open issues
What changed in this PR

This PR moves standard-network swap quote retrieval from Horizon path finding to freighter-backend-v2 while retaining Horizon for custom networks, and adds quote identity tracking, unavailable states, and updated test coverage.

Changes:

  • Adds typed backend quote requests, route conversion, error classification, and custom-network fallback.
  • Updates live quoting, review simulation, reserve preflight, quote expiry, and transaction state handling.
  • Adds loading and unavailable UI, translations, unit tests, and service-worker-compatible Playwright stubs.
File Description
extension/​src/​popup/​locales/​pt/​translation.json Updates Portuguese translations for quote-related swap states.
extension/​src/​popup/​locales/​en/​translation.json Updates English translations for quote-related swap states.
extension/​src/​popup/​locales/​__tests__/​translationParity.test.ts Adjusts translation parity coverage for the updated swap keys.
extension/​src/​popup/​helpers/​swapQuote.ts Implements backend and custom-network quote retrieval with route conversion.
extension/​src/​popup/​helpers/​__tests__/​swapQuote.test.ts Tests backend routing, asset conversion, error handling, and Horizon fallback.
extension/​src/​popup/​ducks/​transactionSubmission.ts Stores swap quote responses and request keys in transaction state.
extension/​src/​popup/​ducks/​__tests__/​transactionSubmission.test.ts Tests quote-key persistence and expiry invalidation.
extension/​src/​popup/​components/​swap/​SwapAmount/​styles.scss Styles quote-unavailable content and the explanation sheet.
extension/​src/​popup/​components/​swap/​SwapAmount/​index.tsx Integrates live quotes, reserve quoting, loading states, and unavailable-quote UI.
extension/​src/​popup/​components/​swap/​SwapAmount/​hooks/​useSwapQuoteExpiry.tsx Updates swap quote expiry behavior for explicit re-quoting.
extension/​src/​popup/​components/​swap/​SwapAmount/​hooks/​useSwapLiveQuote.ts Adds debounced backend live quotes and stale-response protection.
extension/​src/​popup/​components/​swap/​SwapAmount/​hooks/​useSimulateSwapData.tsx Reuses matching quotes or fetches new quotes during review simulation.
extension/​src/​popup/​components/​swap/​SwapAmount/​hooks/​__tests__/​useSwapLiveQuote.test.ts Tests live quote refreshes and stale-response behavior.
extension/​src/​popup/​components/​swap/​SwapAmount/​hooks/​__tests__/​useSimulateSwapData.quote.test.tsx Tests review-time quote reuse, re-quoting, errors, and transaction construction.
extension/​src/​popup/​components/​swap/​SwapAmount/​helpers/​swapCtaState.ts Adds pending and unavailable quote states to CTA logic.
extension/​src/​popup/​components/​swap/​SwapAmount/​helpers/​getSwapDerivedData.ts Derives quote matching and pending status for the swap UI.
extension/​src/​popup/​components/​swap/​SwapAmount/​helpers/​__tests__/​swapCtaState.test.ts Tests quote-related CTA precedence and states.
extension/​src/​popup/​components/​swap/​SwapAmount/​__tests__/​SwapAmount.telemetry.test.tsx Updates telemetry tests for asynchronous quote behavior.
extension/​src/​popup/​components/​swap/​SwapAmount/​__tests__/​SwapAmount.reserveQuote.test.tsx Tests backend receive quotes used for reserve prefilling.
extension/​src/​popup/​components/​swap/​SwapAmount/​__tests__/​SwapAmount.quoteUnavailable.test.tsx Tests unavailable-quote messaging, sheet behavior, and recovery.
extension/​src/​popup/​components/​swap/​SwapAmount/​__tests__/​SwapAmount.liveQuote.test.tsx Tests live quote loading, failures, recovery, and stale responses.
extension/​src/​popup/​components/​swap/​SwapAmount/​__tests__/​SwapAmount.fiatLabel.test.tsx Updates fiat-label tests for asynchronous quote loading.
extension/​src/​popup/​components/​swap/​SwapAmount/​__tests__/​SwapAmount.directionToggle.test.tsx Updates direction-toggle tests with backend quote mocks.
extension/​src/​popup/​components/​swap/​SwapAmount/​__tests__/​SwapAmount.ctaGate.test.tsx Updates CTA and reserve-gate tests for quote readiness.
extension/​src/​popup/​components/​amount/​AmountCard/​index.tsx Adds loading placeholders for pending receive amounts.
extension/​e2e-tests/​swap.test.ts Migrates swap end-to-end scenarios to backend quote stubs.
extension/​e2e-tests/​helpers/​stubs.ts Adds context-level backend quote stubbing and default no-route behavior.
extension/​e2e-tests/​blockaidScan.unable.test.ts Updates unable-to-scan swap setup to use backend quotes.
extension/​e2e-tests/​blockaidScan.suspicious.test.ts Updates suspicious-scan swap setup to use backend quotes.
extension/​e2e-tests/​blockaidScan.safe.test.ts Updates safe-scan swap setup to use backend quotes.
extension/​e2e-tests/​blockaidScan.malicious.test.ts Updates malicious-scan swap setup to use backend quotes.
@shared/​api/​types/​backend-api.ts Defines backend swap quote request and response types.
@shared/​api/​helpers/​getSwapQuotes.ts Adds the typed backend quote API helper and error classification.
@shared/​api/​helpers/​__tests__/​getSwapQuotes.test.ts Tests quote requests, response handling, validation, and HTTP error classification.

Review effort: Lite

piyalbasu and others added 3 commits November 11, 2026 12:21
…route

The live screen treats a response without a classic route as no quote;
the review re-quote showed "No path found for swap." for the same
answer. Both paths now use the no-quote message, and the unused
NO_PATH_FOUND constant goes. Mobile already maps no route and 400 to
one message at both points.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
…ntract ID

toBackendAsset split the canonical on the first colon, so a symbol like
"MY:TOKEN" sent "TOKEN" instead of the C… contract ID. splitCanonical
already splits on the last colon for exactly this reason.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
The receive quote behind "Swap for 0.5 XLM" can take seconds. If the
sell token, account or network changed before it landed, its amount
was written over the new inputs. The answer is now applied only when
the inputs it was asked for are still current, and never after the
screen unmounted. Mobile's hook gets the same identity check.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants