Repository navigation
Conversation
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>
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-1cc750d570dd6dac512a |
|
Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-c1037a26543ee29aeccd |
There was a problem hiding this comment.
🟡 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
…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>

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.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)
/swap/txclient.routes[0]and review the decoded transaction (swapReviewstored next totransactionXDR).swapMinOut, rounded down to 7 dp, which avoids a 409 loop against LI.FI's truncated minimum.{ superseded: true }; the scan result is kept with the XDR it scanned./swap/txreported, 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
/code-reviewat high, then medium on each fix round.Known follow-ups:
Screenshots
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