Skip to content

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

Open
piyalbasu wants to merge 16 commits into
mainfrom
feat/swap-backend-quotes
Open

piyalbasu wants to merge 16 commits into
mainfrom
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 quote shown while typing and the XLM reserve sheet's 0.5 XLM estimate. No Horizon path-finding call remains in the swap flow. The app 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, a "Quote unavailable" line under the receive card 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, as designed.

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).

Known limitations

Quotes now take longer (LI.FI answered in 1.7–3.3 s when measured in 2026-10, against about 0.35 s for Horizon), so the amount takes longer to appear while typing.

Implementation details (for agents)

What changed

  • Quotes go through the JWT-signed freighterBackendV2 client. Until C2, the app builds from the response's classic route (selectSwapRoute) and treats its absence as "no path". The full response is kept in the swap store as swapQuotes for C2/C4.
  • Quotes are tied to the inputs they answered. pathResult carries a quoteKey (account, network, mode, assets, amount). While it differs from the current inputs, for example when the amount was edited inside the 500 ms debounce, the CTA stays in its loading state, and setupSwapTransaction refuses to build with the existing "quote expired" error. findSwapPath drops answers superseded by a later request, clearPath or resetSwap.
  • The minimum received is computed at build time from the quote's amount and the current slippage, because slippage isn't part of the quote key and can change in the settings sheet. A settings change while the matching quote is still pending skips the rebuild instead of throwing.
  • Amounts are sent as plain decimals. The backend rejects 5. and 1e-7.
  • Outages: transport failure, 401, any 5xx, a 429 (the ingress rate limit), and a 200 whose classic source reported error/timeout/rate_limited set the "Swaps are temporarily unavailable" path error and are logged to Sentry once per outage; no_route, insufficient_balance, no classic entry and 400 set "No path found" and are not. Both render as the "Quote unavailable" line and sheet with "Review swap" disabled; the strings are no longer shown. The reserve sheet uses the same classifier and skips its quote when the sell side falls back to another token.

Verification

  • Unit tests cover the stale-quote check, out-of-order answers and each status mapping. Each fix has a test that was run against the code before the fix and failed.
  • Run on the iOS simulator (iPhone 17 Pro, iOS 26.5) 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, with quotes JWT-signed by the app.
    • Testnet XLM→USDC, 10 XLM: the app showed 7.7789019 USDC, the same as Horizon's strict-send answer at that moment. The swap, which added a USDC trustline, was submitted and succeeded (tx a5d99f9d…905d).
    • No route (an illiquid testnet asset) and a backend returning 503: the "Quote unavailable" line and sheet with "Review swap" disabled; no retries on the 503.
  • Android emulator (API 35), mainnet, same sandbox: the quote, the review sheet (not submitted), and the "Quote unavailable" line and sheet with the backend unreachable.

Screenshots / video

pr1049-swap-quote-states

iOS simulator, mainnet, quotes from a freighter-backend-v2 sandbox running stellar/freighter-backend-v2#178 as merged: quote landed, review sheet (not submitted), backend unreachable ("Quote unavailable" line, Review swap disabled), and the explanation sheet.

pr1049-swap-quote-states-android

Android emulator (API 35), same setup and states.

Checklist

PR structure

  • This PR does not mix refactoring changes with feature changes (break it down into smaller PRs if not).
  • This PR has reasonably narrow scope (break it down into smaller PRs if not).
  • This PR includes relevant before and after screenshots/videos highlighting these changes.
  • I took the time to review my own PR.

Testing

  • These changes have been tested and confirmed to work as intended on Android.
  • These changes have been tested and confirmed to work as intended on iOS.
  • These changes have been tested and confirmed to work as intended on small iOS screens.
  • These changes have been tested and confirmed to work as intended on small Android screens.
  • I have tried to break these changes while extensively testing them.
  • This PR adds tests for the new functionality or fixes.

Release

  • This is not a breaking change.
  • This PR updates existing JSDocs when applicable.
  • This PR adds JSDocs to new functionalities.
  • I've checked with the product team if we should add metrics to these changes.
  • I've shared relevant before and after screenshots/videos highlighting these changes with the design team and they've approved the changes.

🤖 Generated with Claude Code

piyalbasu and others added 3 commits November 4, 2026 18:31
The main swap flow and the XLM reserve sheet now quote through
POST /api/v1/swap/quotes on the JWT-signed freighterBackendV2 client
(send quotes while typing, a receive quote for the 0.5 XLM estimate).
No Horizon path-finding call remains in the swap flow.

Until C2 moves building to /swap/tx, the client still builds a classic
path payment, so it uses the response's classic route (selectSwapRoute)
and treats its absence as "no path". The full response is kept in the
swap store as swapQuotes for C2/C4.

Status handling: 200 with no usable route and 400 show the existing
"no path found" error; 502/503 show the new "Swaps are temporarily
unavailable" (spec section 8). Other failures keep the existing
generic error.

Quotes now take seconds (LI.FI 1.7-3.3 s measured 2026-10 vs Horizon
~0.35 s, per the spec), so a stale response could overwrite a newer
quote or a cleared amount; findSwapPath now drops responses superseded
by a later request, clearPath or resetSwap.

Also corrects two comments that still said the amount came from Horizon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es as unavailable

Stale quote (pre-pr-review D1): pathResult now carries quoteKey, the
account, network, mode, assets and amount of the request it answered.
SwapAmountScreen computes swapQuoteKey for the current inputs. The CTA
stays in its loading state while they differ (amount edited inside the
500 ms debounce, destination/account/network changed), and
setupSwapTransaction refuses to build from a mismatched quote with the
existing "quote expired" error.

Outage vs no route (D2, D3; ruling from spec section 8): transport
failure (status 0), 401 and any 5xx show "Swaps are temporarily
unavailable"; so does a 200 without a classic route when classic is
listed unavailable with error, timeout or rate_limited, which is now
logged to Sentry. no_route, insufficient_balance, no classic entry and
400 keep "No path found". The reserve sheet uses the same classifier and
warns on a classic outage, as it already did for failed quotes.

Adds the missing tests for resetSwap and the error-path stale check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e slippage

- B1: the quote request and quote key send the amount as a plain decimal
  (BigNumber.toFixed()), so "5." and "1e-7" no longer 400 against the
  backend's ^[0-9]+(\.[0-9]+)?$ check.
- B2: an HTTP 429 from the backend (Traefik rate limit) shows "Swaps are
  temporarily unavailable", like a source-level rate_limited.
- M1: setupSwapTransaction computes the minimum received from the quote's
  destinationAmount and the current slippage setting, since slippage is
  not in the quote key and can change in the settings sheet after quoting.
- M4: the reserve sheet no longer requests a receive quote when the sell
  side falls back to another token and the result would be discarded.
- M7: a settings change while the matching quote is still pending skips
  the rebuild instead of throwing "quote expired" (Review is disabled
  until that quote lands); the throw stays as the build-to-sign backstop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-31b3df4debed144d1bf0
Backend: sandbox (piyalbasu). SDF collaborators only — install instructions in the release description.

@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-ada6fc47fd68e03680b4
Backend: sandbox (piyalbasu). SDF collaborators only — install instructions in the release description.

piyalbasu and others added 10 commits November 10, 2026 22:25
…t screen

Shown after a no-route or swaps-unavailable quote result; tapping opens an
information sheet (Figma 3405-86973). The test sheet mock now records each
sheet's customContent so a test can find a sheet by its title.

Moved here from the C4 branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store now records which inputs a quote error is for, so the line no
longer shows an error from the previous amount or token during the
re-quote debounce. It also hides behind the insufficient-balance and
amount-error states, and the sheet dismisses when the line goes away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The path-finding effect re-fired on every slippage change and
findSwapPath still computed destinationAmountMin from it, but nothing
read that field: useSwapTransaction recomputes the minimum from the
stored quote at build time. Changing slippage from the review sheet
rebuilt the XDR from the current quote, then 500ms later the debounce
nulled the quote and fetched a new one that nothing rebuilt from, so
the sheet showed the new amount while the signed transaction carried
the old path and minimum. The quote-expired retry path passed slippage
for the same reason.

Drop slippage from the hook's inputs and effect deps, from the
findSwapPath params, and the unused minimum from SwapPathResult.
Slippage stays a build-time input only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
handleSwapForXlmFromSheet awaited the backend receive quote, which can
take seconds where Horizon answered in well under one, and then wrote
source, destination and amount unconditionally. Backing out of the
swap screen mid-flight let the result re-fill the store that
resetSwap had just cleared, so the next swap opened with XLM as the
receive token and a stale amount. findSwapPath got a request-id guard
in this branch; this call site did not.

Guard the writes on the hook still being mounted and on no newer tap
having started.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
swapQuoteErrorReason mapped 400 to no-route and only 0, 401, 429 and
5xx to unavailable; anything else fell through to the generic
"Couldn't fetch swap path" with no Quote unavailable sheet. Until
freighter-backend-v2#178 is live in prd the route 404s, so the window
both PR descriptions call out showed the generic message instead of
"Swaps are temporarily unavailable". The extension already treats
every non-400 failure as unavailable; match it.

A 200 whose body isn't a quotes envelope threw a plain Error and
landed in the same generic bucket. Give it a named error class and
classify it as unavailable too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
findSwapPath logged every failure at error level, which reaches
Sentry in production, and it runs once per debounced keystroke. A
backend outage produced one event per amount edit per user, and a 400
for bad input or an unfunded account was reported as a fault.

Report an outage once until a quote succeeds or the screen is left,
whether it arrives as a thrown non-400 or as a 200 with the classic
source down. Log 400s at warn. Anything the classifier doesn't
recognise still reports every time. Matches the extension.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The three-second path-error toast still fired for no-route and
swaps-unavailable, on top of the persistent "Quote unavailable" row
and sheet the amount screen now renders for exactly those two, so the
same failure surfaced twice and the toast re-fired over the row on
every re-quote. Skip the toast for the two inline-handled errors; any
other path error still toasts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
swapRouteIntermediateAssets turned an empty hop chain into an empty
path, and findSwapPath still built a path result from the route's
amounts, so a malformed classic route was offered as a quote and
submitted as a direct pathPaymentStrictSend that can fail on-chain
with the user paying the fee. Select only classic routes with hops;
the rest reads as no route, as the extension already does, and the
duck reports the malformed route to Sentry since it is a backend
fault rather than a pair without liquidity.

A real route always has at least one hop: freighter-backend-v2 builds
classic hops from [from, ...path, to] in classicRoute
(internal/services/swap/classic.go), so this guards against a
regression there, not a normal reply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Varial design (Transactions page, "Quote unavailble" frame) shows a
disabled "Review swap" with the reason on the "Quote unavailable" line
and sheet. A failed quote fell through to the "enter" state, so the
disabled button read "Enter an amount" with an amount already typed.
The pathError gate already disables it; only the label changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
# Conflicts:
#	src/components/screens/SwapScreen/hooks/useSwapTransaction.ts
@piyalbasu
piyalbasu marked this pull request as ready for review November 11, 2026 16:35
@piyalbasu
piyalbasu requested a lite review from Copilot November 11, 2026 16:35

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

Active issues remain, including a critical stale-request race that can apply obsolete swap data and moderate defects that preserve stale amounts or mishandle malformed quote responses.

3 open issues
What changed in this PR

Moves swap quote retrieval from Horizon path-finding to the authenticated freighter-backend-v2 quote endpoint while retaining client-side classic path-payment construction for C1.

Changes:

  • Adds backend-v2 quote handling, route selection, error classification, and stale-quote protection.
  • Updates swap UI, transaction building, slippage handling, and XLM reserve estimates for backend quotes.
  • Adds localized quote-unavailable messaging and comprehensive unit tests.
File Description
src/​services/​backend.ts Adds the backend-v2 swap quote API client.
src/​i18n/​locales/​pt/​translations.json Adds Portuguese quote-availability translations.
src/​i18n/​locales/​en/​translations.json Adds English quote-availability translations.
src/​hooks/​useTokenFiatConverter/​reducer.ts Updates amount serialization documentation for quote requests.
src/​ducks/​swap.ts Replaces Horizon path-finding with backend quotes and manages quote freshness and errors.
src/​config/​types.ts Defines backend swap quote response and route types.
src/​components/​screens/​SwapScreen/​screens/​SwapAmountScreen.tsx Displays quote-unavailable feedback and coordinates quote-aware transaction flow.
src/​components/​screens/​SwapScreen/​hooks/​useSwapTransaction.ts Validates quote freshness and derives minimum received using current slippage.
src/​components/​screens/​SwapScreen/​hooks/​useSwapPathFinding.ts Removes slippage from quote-triggering inputs and retains debounced quote fetching.
src/​components/​screens/​SwapScreen/​hooks/​useSwapForXlmReserve.ts Uses backend receive quotes for the XLM reserve estimate.
src/​components/​screens/​SwapScreen/​hooks/​useSwapCtaState.ts Controls CTA state for pending, stale, and failed quotes.
src/​components/​screens/​SwapScreen/​hooks/​useSwapAmountError.ts Prevents duplicate toasts for inline quote failures.
src/​components/​screens/​SwapScreen/​helpers/​swapDisplayStrings.ts Updates quote amount documentation.
src/​components/​screens/​SwapScreen/​helpers/​selectSwapRoute.ts Selects classic routes and classifies quote failures.
src/​components/​screens/​SwapScreen/​helpers/​index.ts Exports swap route and quote-classification helpers.
tests/​services/​backend.swapQuotes.test.ts Tests backend quote requests and response validation.
tests/​ducks/​swap.findSwapPath.test.ts Tests quote handling, route selection, stale responses, error mapping, and outage logging.
tests/​components/​screens/​SwapScreen/​useSwapTransaction.test.ts Tests slippage recalculation and stale-quote rejection.
tests/​components/​screens/​SwapScreen/​SwapAmountScreen.test.tsx Tests quote-unavailable UI, CTA behavior, settings changes, and sheet handling.
tests/​components/​screens/​SwapScreen/​hooks/​useSwapPathFinding.test.ts Updates path-finding hook tests for backend quote inputs.
tests/​components/​screens/​SwapScreen/​hooks/​useSwapForXlmReserve.test.ts Tests backend receive quotes, fallback behavior, and reserve-flow races.
tests/​components/​screens/​SwapScreen/​hooks/​useSwapCtaState.test.ts Tests CTA behavior for matching, stale, and failed quotes.
tests/​components/​screens/​SwapScreen/​hooks/​useSwapAmountError.test.ts Tests suppression of duplicate quote-error toasts.
tests/​components/​screens/​SwapScreen/​helpers/​selectSwapRoute.test.ts Tests classic route selection and quote error classification.

Review effort: Lite

Comment thread src/ducks/swap.ts Resolved
Comment thread src/services/backend.ts Resolved
piyalbasu and others added 3 commits November 11, 2026 12:21
The unmount and newer-tap guards did not cover the user dismissing the
sheet and changing the sell token, account or network while the
receive quote was in flight: the old answer then set XLM as the
destination and prefilled an amount for the previous inputs. The
answer is now applied only when the inputs it was asked for are still
current. The extension's prefill gets the same identity check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The extension reports every failed "Swap for 0.5 XLM" quote to Sentry,
400 included, because the request is built from the wallet's own
balances; this hook only warned, so the same failure was invisible on
mobile. A plain no-route answer is still not reported on either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A failed quote cleared pathResult but left destinationAmount, which
the receive card renders directly, so the previous quote's amount sat
next to "Quote unavailable". Every failure branch now resets it. The
amount stays on the card while the next quote is in flight, which is
what the design shows; the extension hides it behind a shimmer there
and resets it on failure the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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