Skip to content

feat(swap): build through /swap/tx and review the decoded transaction (C2) - #1050

Open
piyalbasu wants to merge 18 commits into
feat/swap-backend-quotesfrom
feat/swap-backend-tx
Open

piyalbasu wants to merge 18 commits into
feat/swap-backend-quotesfrom
feat/swap-backend-tx

Conversation

@piyalbasu

@piyalbasu piyalbasu commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What

Swaps are now built by freighter-backend-v2 (POST /api/v1/swap/tx) from the best quoted route, LI.FI or classic, instead of the app building a path payment itself. The review screen shows values decoded from the transaction that will be signed. Before signing, the app checks that the built transaction does only what the user asked.

  • Classic: at most one changeTrust for the bought asset at the maximum limit, then one path payment to the user, selling exactly the requested amount and paying at least the requested minimum.
  • Soroban: one contract call from the user. The amount sold is read from the sold token's transfers in the authorization tree and must equal the amount requested; transfers of any other token, and any other token call the account would authorize (approve, burn, transfer_from, burn_from), are refused. These checks are the same for every source.
  • Review in step with its build: an edit during the build, or a settings rebuild, can no longer open review on, or scan, a transaction the store no longer holds. "Confirm anyway" follows Confirm's disabled state.

Draft, for diff review. Stacked on C1 (feat/swap-backend-quotes). Needs stellar/freighter-backend-v2#178 live first.

Why

Swap routing spec, client item C2: stellar/wallet-eng-monorepo#131.

Implementation details (for agents)

Commits (read in order)

  1. /swap/tx client.
  2. Decoder.
  3. Build from routes[0] and review the decoded transaction (swapReview stored next to transactionXDR).
  4. Checks before signing: classic op shape, destMin ≥ minOut and the changeTrust limit; Soroban single invoke, the auth-tree transfer sum, refusal of other tokens, and trust() on the bought token only. minOut comes from swapMinOut, rounded down to 7 dp, which avoids a 409 loop against LI.FI's truncated minimum.
  5. Review in step with its build: superseded builds return { superseded: true }; the scan result is kept with the XDR it scanned.
  6. Review round: amount equality, refusal of other token calls, per-route fee capped at the frozen total, a 400 from /swap/tx reported, a malformed build body treated as an outage, the decoded minimum shown in the details sheet.

Not checked on the client, by design: the LI.FI contract ID, an auth-tree allowlist, timeBounds, the Soroban minimum, the receiver and contract args.

Verification

  • Each fix has a test that was run against the code before the fix and failed.
  • /code-review at high, then medium on each fix round.

Known follow-ups:

  • A 409 during a settings rebuild leaves a dead review.
  • A superseded Review tap stops silently.
  • Slippage editing can produce NaN.
  • Any settings Save counts the fee as user-set, so a LI.FI route pays the classic two-op total after Settings is opened (shared sheet; same on the extension).

Screenshots

pr1050-review-and-mismatch

iOS simulator, mainnet, built by a freighter-backend-v2 sandbox running stellar/freighter-backend-v2#178 as merged. Left: the review sheet, decoded from the built transaction (not submitted). Right: the same request with the built transaction's minimum received lowered in transit; the app refuses it before review. The bar at the bottom is the dev build's error log.

🤖 Generated with Claude Code

piyalbasu and others added 5 commits November 9, 2026 11:50
buildSwapTx posts a quoted route to POST /api/v1/swap/tx on the
JWT-signed freighterBackendV2 client and returns the unsigned XDR and
addsTrustline. HTTP failures reject with the normalized ApiError, so
callers can tell a 409 (quote_moved) from an outage by status.

Request/response types mirror SwapBuildRequest/SwapBuildResponse in
freighter-backend-v2 internal/types/swap.go (#178).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decodeSwapTx reads the amount sold, minimum received, network fee and
any trustline out of the backend's unsigned transaction, for both
sources:
- classic: pathPaymentStrictSend after an optional changeTrust.
- LI.FI: swap(params, receiver). The amount sold is the sum of the sold
  token's transfers out of the account in the authorization tree (what
  the signature authorizes); the minimum is params.min_amount_out; a
  toAsset.trust(account) call is the trustline.

It refuses a transaction that does not swap the requested assets for
and to the account: another source account, a fee bump, another
recipient or asset, a changeTrust for another asset, or an authorized
transfer of a token other than the one sold. Contract tokens are
refused until the picker allows them (C3); classic assets and their
contracts use 7 decimals.

Every refusal is a SwapTxMismatchError, including a transaction it
cannot read: unparseable XDR, malformed call arguments or an unexpected
address type would otherwise surface the SDK's own error (XdrError,
TypeError) as an untranslated message.

LI.FI fixtures are the transactionRequest.data of the recorded mainnet
quotes in freighter-backend-v2 internal/services/swap/testdata.

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

The swap now uses routes[0], the backend's ranked best, whichever
source quoted it, and the client no longer builds a path payment.
setupSwapTransaction posts the route to /swap/tx with the user's
slippage, timeout and fee; minOut is the quoted toAmount minus the
current slippage, and the route's paths go along for classic builds.
The transaction builder store decodes the returned XDR (decodeSwapTx)
and keeps the decoded values as swapReview next to transactionXDR,
under the same requestId guard. swapReview changes only together with
transactionXDR (replaced on success, cleared on failure), so a settings
rebuild while review is open never shows a confirmable transaction
without its decoded values. A transaction that does not match the
request is refused before scan and review.

Review reads the decoded transaction instead of the quote, the settings
and the picker:
- Sell row: the amount the transaction spends.
- Fee row: the transaction's fee, which for LI.FI includes the Soroban
  resource fee. It was the inclusion fee setting.
- Trustline banner: shown when the transaction adds the trustline, not
  when the picker expected one.
The receive row stays the quoted estimate, which no transaction field
carries. There is no Minimum row, for parity with the extension (which
removed its own in #2872 to match mobile); the processing screen's
details sheet keeps its slippage estimate of the minimum.

The trustline is decided by the transaction: the backend adds
changeTrust (classic) or trust() (LI.FI) when the account lacks it, and
swap.trustline_added fires from the decoded addsTrustline instead of
the picker's requiresTrustline.

Failures:
- 409 quote_moved: fires swap.quote_expired (result_code quote_moved),
  re-quotes, and shows the existing "quote expired" message.
- 400: the existing swapScreen.errors.failedToSetupTransaction.
- 0/401/429/5xx: "Swaps are temporarily unavailable" (spec section 8).
- Decoder refusal: the new translated
  swapScreen.errors.swapTransactionMismatch (en, pt).
- Anything else keeps the generic setup error.
A 400 or 409 is an answer, not a fault: neither the store nor
SwapAmountScreen reports it to Sentry (the hook throws them as
SwapSetupAnswerError). Decoder refusals and other failures are still
reported.

The pre-build checks (amount, balance, fee, timeout, same token) still
run, from validateSwapTransactionParams. The local
buildSwapTransaction in transactionService and its tests are removed;
Send does not use it.

Signing and submission are unchanged: mobile submits through Horizon
(services/stellar submitTx), which already submits Soroban transactions
for collectible sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
decodeSwapTx now also takes the minOut the client sent to /swap/tx, and
the Soroban path no longer reads LI.FI's call, so a new backend source
does not need a client release.

Classic: an optional changeTrust for the bought asset, then exactly one
pathPaymentStrictSend to the account; tests pin that an extra payment
or setOptions, a third operation or another operation source is
refused, and a transaction with no operations now gets the mismatch
error instead of a TypeError. A destMin below the requested minOut is
refused (exact decimals). A changeTrust with a limit below the default
edits an existing trustline while review says it adds one, so only the
default limit (Int64 max stroops, what the backend builds with
txnbuild.MaxTrustlineLimit) is accepted.

Soroban: exactly one invokeHostFunction, read only through what every
source's transaction carries, the authorization tree:
- amount sold: the sold token's transfer(account, _, amount) entries. A
  negative amount would lower the total review shows and is refused; a
  zero is allowed, as the backend's check (lifi_checks.go) allows a
  zero-fee transfer. A transfer of any other token out of the account
  is refused.
- trustline: trust(account) on the bought token's contract; a trust()
  for any other contract is refused.
- fee: from the envelope.
No swap function name, argument count, params, receiver or minimum is
read; minReceived is undefined for Soroban swaps and nothing displays
it. The backend still checks the source-specific call (lifi_checks.go).
The non-positive transfer and changeTrust-limit gaps were found in the
extension's review, which mobile shared.

minOut rounding: computeDestMinWithSlippage multiplies by a JS float and
rounds half-up, so the minOut could exceed LI.FI's truncated
min_amount_out by one stroop (1.0000001 at 1% gave 0.9900001, LI.FI
0.9900000). The backend then answers 409 quote_moved, the client
re-quotes and gets the same 409. The minOut sent to /swap/tx now comes
from swapMinOut: toAmount x (100 - slippage) / 100 in BigNumber,
rounded down to 7 dp. computeDestMinWithSlippage is unchanged for its
other user (pathResult.destinationAmountMin).

The store's refusal test now starts from a built transaction and checks
the stored error, so it fails if a refusal leaves the previous
transaction in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
setupSwapTransaction awaits the backend build and then the Blockaid
scan. An amount, token or settings edit during the build, or a newer
build replacing the stored transaction, could still open review on the
old transaction, or set the scan result of an XDR the store no longer
holds. After each await it now checks that the quote key, fee, timeout
and slippage are the ones it built with and that the store still holds
its XDR; otherwise it returns { superseded: true }, sets no scan
result, and SwapAmountScreen opens no sheet.

After a settings rebuild the new XDR is stored before its scan
resolves, so Confirm re-enabled while review still showed the previous
transaction's verdict. useSwapTransaction now keeps the scan result
together with the XDR it scanned (scannedTransactionXDR), and
SwapAmountScreen passes the footer, and the security sheet's proceed
action, a transaction only once its own scan is in.

Confirm anyway (shown for a suspicious or malicious transaction) stayed
enabled while a rebuild ran or after a failed build. It now shares
Confirm's disabled state, and the security sheet's proceed action,
which confirms directly when the scan is not "unable to scan", does
nothing while building or without a scanned transaction.

The screen test's BottomSheet stub keeps each sheet's customContent so
a test can call the security sheet's onProceedAnyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@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-1cc750d570dd6dac512a
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

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

@piyalbasu
piyalbasu marked this pull request as ready for review November 11, 2026 20:53
@piyalbasu
piyalbasu requested a lite review from Copilot November 11, 2026 21:10

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

Critical validation gaps allow unauthorized transaction amounts and operations involving other accounts or assets to pass client checks.

2 open issues
What changed in this PR

This pull request moves swap transaction construction to freighter-backend-v2, decodes the returned unsigned transaction for review, and validates backend-built classic and Soroban transactions before signing.\n\nChanges:\n- Added typed /swap/tx client support and backend-ranked route selection.\n- Added transaction decoding, review-state storage, security checks, and build/scan synchronization.\n- Updated swap UI, localized errors, and comprehensive test coverage with LI.FI fixtures.

File Description
src/​services/​transactionService.ts Removes the former client-side swap transaction builder.
src/​services/​backend.ts Adds the backend /swap/tx request and response handling.
src/​i18n/​locales/​pt/​translations.json Adds the Portuguese transaction-mismatch error translation.
src/​i18n/​locales/​en/​translations.json Adds the English transaction-mismatch error translation.
src/​ducks/​transactionBuilder.ts Builds backend swap transactions, decodes them, and stores review data.
src/​ducks/​swap.ts Stores the selected route and calculates the rounded minimum output.
src/​config/​types.ts Defines types for backend swap transaction build responses.
src/​components/​screens/​SwapScreen/​screens/​SwapAmountScreen.tsx Synchronizes review opening and confirmation with build and scan state.
src/​components/​screens/​SwapScreen/​hooks/​useSwapTransaction.ts Builds, scans, validates, and submits backend-built swap transactions.
src/​components/​screens/​SwapScreen/​helpers/​selectSwapRoute.ts Selects the backend-ranked swap route.
src/​components/​screens/​SwapScreen/​helpers/​quoteErrors.ts Adds typed error classification for swap setup responses.
src/​components/​screens/​SwapScreen/​helpers/​index.ts Exports the swap decoder and setup-error helpers.
src/​components/​screens/​SwapScreen/​helpers/​decodeSwapTx.ts Decodes and validates classic and Soroban swap transaction XDR.
src/​components/​screens/​SwapScreen/​components/​SwapReviewBottomSheet.tsx Displays decoded transaction values and controls confirmation state.
tests/​services/​transactionService.test.ts Removes tests for the retired client-side swap builder.
tests/​services/​backend.swapTx.test.ts Tests backend swap transaction requests and responses.
tests/​ducks/​transactionBuilder.test.ts Tests backend builds, decoded review state, stale builds, and failures.
tests/​ducks/​swap.swapMinOut.test.ts Tests minimum-output calculation and rounding.
tests/​ducks/​swap.findSwapPath.test.ts Updates route-selection and path-result expectations.
tests/​components/​screens/​SwapScreen/​useSwapTransaction.test.ts Tests backend build requests, scans, superseded operations, and errors.
tests/​components/​screens/​SwapScreen/​SwapReviewBottomSheet.test.tsx Tests decoded review values and disabled confirmation states.
tests/​components/​screens/​SwapScreen/​SwapAmountScreen.test.tsx Tests review synchronization, confirmation gating, and setup errors.
tests/​components/​screens/​SwapScreen/​hooks/​useSwapCtaState.test.ts Updates mocked route data for route-based swaps.
tests/​components/​screens/​SwapScreen/​hooks/​useSwapAmountError.test.ts Updates swap path-result fixtures.
tests/​components/​screens/​SwapScreen/​helpers/​selectSwapRoute.test.ts Tests selection of the backend-ranked route.
tests/​components/​screens/​SwapScreen/​helpers/​fixtures/​lifiSwapTx.json Adds a LI.FI Soroban transaction fixture.
tests/​components/​screens/​SwapScreen/​helpers/​decodeSwapTx.test.ts Tests classic and Soroban transaction decoding and mismatch handling.

Review effort: Lite

piyalbasu and others added 9 commits November 11, 2026 16:30
…s own

The Soroban decoder summed the sold token's transfers and noted the
trustline, but skipped every other call in the account's authorization
tree. A transaction carrying an extra approve(account, spender, max) on
some token decoded as a normal swap, and the signature granted the
allowance.

The account only ever authorizes SEP-41 / SAC calls with itself at arg 0:
transfer, approve, burn, transfer_from, burn_from (plus the SAC's trust).
Those are now refused unless they are the swap's own transfer on the sold
token or trust on the bought one. Other names stay allowed: the aggregator's
own calls also carry the account at arg 0 and are the source-specific part
the backend checks, so a new source still needs no client release.

Found by /code-review on #1050; also answers the Copilot thread on the
arg-0 filter (the filter is right, the gap was the unchecked names).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ested

The decoder checked the assets, the destination and the minimum, but
never the amount sold: a path payment spending 1000 for a request of 100
decoded cleanly and the review showed 1000 beside the fiat label of the
typed 100. Balance validation had run against 100.

decodeSwapTx now takes the `amount` sent to /swap/tx. A classic
sendAmount must equal it; a Soroban swap's authorized transfers must sum
to it (the backend already requires fee + swap input to equal the request
for LI.FI, so a real build always passes).

Found by /code-review and a Copilot thread on #1050.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The decoder split CODE:ISSUER itself; helpers/balances already does it
for the change-trust builder. The SDK's Asset constructor rejects a bad
code or issuer, which keeps a contract id refused until C3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The build request body comes entirely from the client's own state, so a
400 is a client bug, not the backend's answer. It was skipped alongside
the 409 and never reached Sentry: a malformed minOut or timeout showed
the user a generic toast with nothing in telemetry. Same decision as the
quotes path, and as the extension.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The stored default fee is recommended × op count, sized for a classic
swap-to-new-token (changeTrust + path payment). The backend splits that
total across the classic ops, but a LI.FI build is a single op and uses
the whole `fee` as its inclusion fee, so a new-trustline swap on a LI.FI
route paid double the recommendation.

The route is only known at build time, so useSwapTransaction now takes
the per-op recommendation and sends it for a non-classic route when the
fee is still the default. A user-set fee stays the total as typed, and
the review shows the fee decoded from the transaction either way.
Pre-review displays keep the classic assumption.

Backend reference: classic.go divides req.Fee by len(ops); lifi.go
passes req.Fee as the inclusion fee. Found by /code-review on #1050;
the extension gets the same change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The details sheet recomputed "Minimum received" from the quote and the
slippage setting, while the classic decoder already holds the destMin the
signed transaction enforces. The two can differ in the last digit
(backend rounds down to the 7-decimal minOut; the estimate rounds for
display). Soroban swaps have no client-readable minimum and keep the
estimate.

Found by /code-review on #1050.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The per-op recommendation sent for a LI.FI route was the live value,
while the stored fee is frozen once an amount is entered and spendable
(Max) is sized from it. A recommendation that rose before Review could
charge more XLM than the account kept. The request now takes the lower
of the two.

Medium review on the fix round.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
calculateMinimumReceived returned a supplied minimum as-is while the
estimate went through formatTokenForDisplay, so a classic swap's details
row showed 18191.30443 next to amounts grouped as 18,191.30443.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A 200 without an xdr threw a plain Error, which no mapping in
useSwapTransaction recognised: the user got the raw "Unexpected swap tx
response shape" in a toast. The quotes route already classifies the same
case as an outage; the build route now throws the same error class, so
the screen shows "Swaps are temporarily unavailable" and the store still
logs it. Matches the extension.

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