Repository navigation
Conversation
decodeSwapTx accepted only XLM and CODE:ISSUER and read every authorized transfer at 7 decimals. A `C…` wire asset is now its own contract, and the amount sold is read at the sold token's decimals, passed in from the source balance. A classic path payment still needs classic assets on both sides, and a trust() on a Soroban-only token bought is refused: it opens no trustline, so review must not show the reserve notice for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cimals findSwapPath refused any contract token before asking the backend, which now quotes them through LI.FI (and answers "no route" where no source can). A Soroban token goes on the wire as its contract ID, and the minOut sent to /swap/tx is rounded down to the bought token's decimals: the backend rejects a minOut with more fraction digits than the token has, so the old fixed 7 dp broke tokens with fewer decimals and truncated those with more. The destination shim carries a Soroban descriptor's contract ID and decimals for both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
parseDisplayNumber parsed through parseFloat, so an amount with more than ~15 significant digits (any 18-decimal Soroban token amount with a whole part) lost its tail: typing 1234.123456789012345678 stored 1234.1234567890124. It now parses the normalized string with BigNumber and falls back to parseFloat only for input BigNumber can't read. Callers checked: the token-fiat converter (reducer SET_TOKEN_DISPLAY_FROM_TEXT, SET_FIAT_DISPLAY_FROM_TEXT, SET_DISPLAY_AMOUNT_FROM_TEXT), the transaction settings sheet and the slippage/fee/timeout validators. For inputs that fit a double, BigNumber#toString matches Number#toString (checked against "", "1.", ".5", "0.0000001", "1e5", "-1", "007", "1.50", "-0", "0."); only inputs past double precision change, and they now keep their digits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
25/50/75%/Max formatted the amount at 7 dp. For an 18-decimal Soroban balance of 1.234567891234567891, Max set 1.2345679, above the balance; for a token with fewer than 7 decimals it sent more fraction digits than the backend accepts. They now format at the source balance's own decimals, as Send's percentage buttons already do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picker dropped Soroban contract tokens from every source. It now keeps them, from the same sources: - held Soroban balances, in "Your tokens" (both pickers) and in held search matches, with their decimals; a held token also matches its exact contract ID; - stellar.expert search and trending records whose asset is a contract ID, formatted with the contract ID as issuer, CUSTOM_TOKEN type and stellar.expert's decimals (trending still requires the verified lists, which key contract tokens by contract ID); - a pasted contract ID, now looked up through freighter-backend (handleContractLookup, as Add token does) instead of stellar.expert. Blockaid scans them through the existing bulk scan (SYMBOL-C… ids, which /scan-asset-bulk accepts). A Soroban destination never requires a trustline, so the reserve deduction and reserve sheet stay classic-only. The "Soroban tokens aren't supported" empty state is gone; a contract ID nothing resolves shows the normal no-results line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ubtitle Picker and trending rows showed the domain, then the name, for every non-held token. A Soroban-only token now shows its contract ID shortened to the first and last four characters (e.g. CBI7…IHRV), per the design doc's row-subtitle rule, so a token that copies another's symbol and domain still looks different. Classic rows keep the issuer's home domain. The row's context menu also gets the contract ID, so "View on stellar.expert" opens /contract/C… instead of an /asset/CODE-C… page that doesn't exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns up in the asset lists Icons follow the design doc's order: the user's asset lists, then the issuer's stellar.toml, with the first hit cached so the token looks the same across the wallet. - getIconUrl read a Soroban token's contract ID (which icon-store keys put in the issuer slot) as a G… issuer, so the asset-list lookup, which matches contract tokens by contract ID, never hit, and the toml lookup rejected the key. It now passes such an issuer as the contract ID. - A non-held picker or trending row now caches its icon if the store has none (the asset lists' icons are cached up front): stellar.expert's copy of the issuer's toml image when the record has one, else fetchIconUrl, which tries the lists and then the toml. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The no-route message still said "No path found between these tokens", a Horizon path-finding term. The design doc's wording is "no quote available", which also covers a Soroban pair that classic can't serve (unsupported) and LI.FI has no route for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ole tokens A SorobanBalance's total/available are in the token's smallest unit, but the swap compared them to whole-token amounts: spendable (so Max and the percentage buttons) was the raw integer, the insufficient-balance check compared a whole-token amount against raw units and never tripped, and validateSwapTransactionParams did the same. Spendable is now scaled by the token's decimals, the spendable check scales the amount to raw units, and the build-time check uses getBalanceInWholeUnits. Classic balances are unchanged. The Max test's fixture stored a whole-token total on a Soroban balance, which is why it passed; it now stores the raw integer. Sibling left alone: Send's validateTransactionParams compares amount with balance.total the same way; Send behaviour is out of scope here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the search-unavailable notice The picker's contract-ID lookup sent an empty pub_key to /token-details, so it always came back empty and the picker said "No tokens match". The hook now takes the active account's public key from SwapToScreen and passes it to handleContractLookup. The lookup branch also cleared stellarExpertDown, so a failed lookup hid the "search unavailable" notice. The lookup doesn't involve stellar.expert, so it no longer touches that flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
formatSearchRecord's Soroban branch copied useTokenLookup's Path 2. Both now call helpers/formatSorobanSearchRecord, which leaves the icon to the caller: Add token keeps the icon cache first and then tomlInfo.image, and swap keeps tomlInfo.image (plus price). No behaviour change; the existing useTokenLookup contract-record tests cover the Add token side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row subtitle built its own CBI7…IHRV; it now uses the existing truncateAddress helper (CBI7...IHRV), as the rest of the app does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adcb113 had non-held picker rows write their icon into the shared token icon store (cacheIconIfMissing). By decision, the picker shows a row's icon through the existing lookup (store first, then the record's stellar.toml image) but never writes to the shared cache. The row effect and the store action, used only by it, are removed; tokenIcons.ts is back to its state before C3. getIconUrl's contract-ID handling from that commit stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LI.FI quotes mainnet only, so off mainnet a Soroban token the account doesn't hold could only ever answer "No quote available". Search and trending now drop such records when the network isn't mainnet; held Soroban tokens still list everywhere. The filter joins the hidden-token filter applied to the hook's outputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ails, or refuse it A Soroban search or trending record can come without decimals (stellar.expert omits them sometimes), and descriptorFromSearchRecord then assumed 7, so minOut could be rounded at the wrong precision and a token with fewer decimals failed at build. Picking such a token from the picker or from trending now reads its decimals through /token-details for the active account first. If they stay unknown the pick is refused with a "Couldn't read this token's details" toast and a logger.error (Sentry). descriptorFromSearchRecord no longer falls back to 7 for a Soroban record: it throws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ch token Covers a build where neither side has 7 decimals: the request carries the amount sold at 6 dp and minOut rounded down at 18 dp, and the decoded amount sold is read at the sold token's 6 decimals. Mutation-checked: hardcoding 7 for the decoder's fromDecimals reads 0.1123456; hardcoding 7 for minOut sends 2.1022222. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…twice e5a722f made spendableAmount whole tokens for a Soroban source, but the Sell card's "available" line passes it to formatBalanceAmount, which scales a Soroban balance's amount from raw units again: 100 of a 6-decimal token read "0.0001 TKN available". buildSourceBalanceRight now hands formatBalanceAmount the raw-unit amount it expects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s decimals The message capped spendable at 7 decimal places, so a small 18-decimal balance (0.000000000001) read "Maximum spendable: 0". It now formats at the source balance's own decimals; classic sources are still 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adcb113 made getIconUrl read a C… issuer as a contract ID. getIconUrl is shared by the token-icon batch refresh and fetchIconUrl, so that changed icons outside the swap (Add token, balances). The picker doesn't need it: asset-list icons for contract tokens are cached up front under CODE:CONTRACT, which is the key the picker row reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… never stellar.expert withTokenDecimals skipped /token-details whenever a record already had decimals, and a stellar.expert record's decimals can come from the issuer's stellar.toml. An issuer could then set the precision minOut is rounded to: too many places and every build is rejected, too few and minOut is coarser than it should be. For a Soroban token the account doesn't hold the pick now always reads decimals through /token-details. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
handleRecordPress waits on /token-details for an unheld Soroban token before it dismisses the picker. Nothing stopped a second tap, a different pick or closing the picker in the meantime, so a late result could call goBack again (popping the amount screen) or overwrite a newer pick. A repeat tap on the pending row is now ignored, and the result applies only if no other row was picked and the picker is still open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The trending "Swap to" and "Confirm anyway" actions applied the /token-details result whenever it returned, with the source captured at the press. A pick made in the picker meanwhile was overwritten, and the same-token check ran against a stale source. The result (and its error toast) now applies only if the swap's source and destination are what they were at the press. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The picker matched records to held balances by CODE:ISSUER, but a Soroban balance id uses the contract's on-chain symbol and a stellar.expert record uses stellar.expert's code, which can differ. Such a held token then read as not held: hidden off mainnet, and on mainnet listed as a new token whose descriptor id (ABC:C…) matched no balance. Verified reachable before the fix: with the source SYM:C… held, picking the ABC:C… row set the destination to ABC:C… without clearing the source, and validateSwapTransactionParams' id check passed, so the token could be swapped into itself. canonicalId now keys a Soroban token by its contract ID (held set, hidden set, dedupe), and the picker and trending map a record to the held balance with the same contract ID, so the pick uses the balance id and the same-token checks apply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The details screen's Swap button was gated to classic assets, with a comment saying the swap flow didn't support Soroban tokens. Now that it does, the button shows for every token except liquidity-pool shares, matching the extension's user-approved decision (freighter 1f3685dbd). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not "No tokens match" getTokenDetails answers null both for a contract that isn't a token (400) and for a failed request (timeout, 5xx), so the picker showed a valid contract ID as not found when /token-details was down. getTokenDetails and handleContractLookup take an opt-in throwOnFailure (default off, so every other caller is unchanged); the swap picker passes it and turns a failure into the "search unavailable" notice. While that notice shows, the picker no longer also says "No tokens match", which applies to the stellar.expert outage path too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… both ways 90c6d76 only ever turned the notice on from the contract-ID branch, so a notice left by an earlier failed lookup, or by a stellar.expert or trending outage, stayed up after a lookup that answered. With "No tokens match" hidden while the notice shows, a non-token paste then read as "search unavailable", and a later successful paste kept the notice above its result. The branch now sets the notice from its own outcome. The earlier "keeps the notice when a lookup fails" test modelled a failure as a null answer, which since 90c6d76 means "not a token"; it now rejects, as a failed request does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-fe2dbee7871a0222db3b |
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-3bd4644370bbb47c66cd |
# Conflicts: # src/ducks/swap.ts
# Conflicts: # src/ducks/swap.ts # src/ducks/transactionBuilder.ts
The decoder refused a trust() whenever the bought token was given as a contract ID, on the reasoning that a Soroban-only token has no trustline. A SAC held by its contract ID does, and the backend adds the trust() for it: its toClassic (lifi.go) reads the contract instance to tell a SAC from any other contract before building. The extension accepts the same transaction; mobile blocked it with "didn't match your request". Drift review on #1048 / #3073. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A stellar.expert record with neither a code nor a toml code was named by its full 56-character contract ID in the swap picker, which overflowed the row, repeated the shortened-ID subtitle, and went out as the asset_code analytics property. The extension names the same token by the shortened ID; the two platforms now agree. Applied in the swap picker's formatter only: Add token's lookup shares formatSorobanSearchRecord and keeps the full ID as a custom token's code (pinned by its own test). Drift review on #1048 / #3073. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
An active async-selection issue can let a late token-details response select a token after the picker is dismissed or changed, while repeated taps may also trigger duplicate requests.
1 open issue
What changed in this PR
Adds SEP-41 Soroban token support to the swap picker and transaction flow, including contract-ID lookup, network-specific visibility, decimal-aware balances and transaction validation, and Soroban token details actions.
Changes:
- Supports Soroban tokens across search, trending, held balances, and pasted contract-ID lookup.
- Propagates token decimals through spendable amounts, balance validation, quote construction, transaction decoding, and minimum-output calculation.
- Refactors shared token formatting and updates swap UI, translations, and comprehensive tests.
| File | Description |
|---|---|
| src/services/transactionService.ts | Validates swap balances in whole-token units. |
| src/services/backend.ts | Adds optional failure propagation for token-detail lookups. |
| src/i18n/locales/pt/translations.json | Updates Portuguese swap messages. |
| src/i18n/locales/en/translations.json | Updates English swap messages. |
| src/hooks/useTokenLookup.ts | Reuses Soroban search-record formatting. |
| src/helpers/formatSorobanSearchRecord.ts | Adds shared Soroban search-record formatting. |
| src/helpers/formatAmount.ts | Preserves high-precision input and exposes token decimal helpers. |
| src/ducks/transactionBuilder.ts | Supplies source-token decimals while decoding swap transactions. |
| src/ducks/swap.ts | Supports Soroban wire assets and decimal-aware minimum output. |
| src/config/types.ts | Extends token-detail request parameters with failure behavior. |
| src/components/screens/TokenDetailsScreen/TokenDetailsScreen.tsx | Enables Swap for Soroban tokens. |
| src/components/screens/SwapScreen/screens/SwapToScreen.tsx | Handles asynchronous Soroban selection and decimal lookup. |
| src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx | Applies token-specific decimals to spendable amounts and Max. |
| src/components/screens/SwapScreen/hooks/useTrendingTokenDetail.ts | Resolves decimals for trending Soroban selections. |
| src/components/screens/SwapScreen/hooks/useSwapTransaction.ts | Builds swaps with destination-token precision. |
| src/components/screens/SwapScreen/hooks/useSwapTokenLookup.ts | Lists and filters Soroban tokens in swap search and trending results. |
| src/components/screens/SwapScreen/hooks/useSwapToEmptyStates.ts | Simplifies swap picker empty states. |
| src/components/screens/SwapScreen/hooks/useSwapAmountError.ts | Performs decimal-aware source balance checks. |
| src/components/screens/SwapScreen/helpers/types.ts | Documents Soroban destination descriptor fields. |
| src/components/screens/SwapScreen/helpers/tokenDecimals.ts | Resolves missing Soroban decimals through the backend. |
| src/components/screens/SwapScreen/helpers/swapDisplayStrings.ts | Formats Soroban spendable balances correctly. |
| src/components/screens/SwapScreen/helpers/recordPredicates.ts | Defines swappable classic and Soroban records. |
| src/components/screens/SwapScreen/helpers/index.ts | Exports the new swap helpers. |
| src/components/screens/SwapScreen/helpers/formatSearchRecord.ts | Formats both classic and Soroban search records. |
| src/components/screens/SwapScreen/helpers/formatClassicRecord.ts | Replaces the classic-only search-record formatter. |
| src/components/screens/SwapScreen/helpers/descriptors.ts | Builds Soroban descriptors without trustline requirements. |
| src/components/screens/SwapScreen/helpers/decodeSwapTx.ts | Decodes Soroban swaps using source-token decimals. |
| src/components/screens/SwapScreen/helpers/computeTrendingIntersection.ts | Includes verified Soroban records in trending results. |
| src/components/screens/SwapScreen/helpers/canonicalId.ts | Canonicalizes Soroban records by contract ID. |
| src/components/screens/SwapScreen/components/SwapTokenRow.tsx | Displays shortened Soroban contract identifiers. |
| tests/services/transactionService.test.ts | Tests decimal-aware swap balance validation. |
| tests/services/backend.test.ts | Tests propagated token lookup failures. |
| tests/hooks/useTokenFiatConverter.reducer.test.ts | Tests preservation of high-precision token input. |
| tests/helpers/formatAmount.test.ts | Tests high-precision display parsing. |
| tests/ducks/transactionBuilder.test.ts | Tests decimal-aware Soroban transaction decoding. |
| tests/ducks/swap.test.ts | Tests Soroban descriptor path projections. |
| tests/ducks/swap.swapMinOut.test.ts | Tests decimal-aware minimum output rounding. |
| tests/ducks/swap.findSwapPath.test.ts | Tests Soroban quote requests and unavailable routes. |
| tests/components/screens/TokenDetailsScreen/TokenDetailsScreen.test.tsx | Tests Swap availability for Soroban and LP tokens. |
| tests/components/screens/SwapScreen/useSwapTransaction.test.ts | Tests Soroban wire assets and precision. |
| tests/components/screens/SwapScreen/SwapToScreen.test.tsx | Tests lookup, selection races, and decimal failures. |
| tests/components/screens/SwapScreen/SwapAmountScreen.test.tsx | Tests Soroban Max amount handling. |
| tests/components/screens/SwapScreen/hooks/useTrendingTokenDetail.test.ts | Tests trending Soroban selection and stale results. |
| tests/components/screens/SwapScreen/hooks/useSwapTokenLookup.test.ts | Tests Soroban search, verification, filtering, and lookup states. |
| tests/components/screens/SwapScreen/hooks/useSwapAmountError.test.ts | Tests decimal-aware amount validation and messaging. |
| tests/components/screens/SwapScreen/helpers/tokenDecimals.test.ts | Tests backend decimal resolution and refusal behavior. |
| tests/components/screens/SwapScreen/helpers/swapDisplayStrings.test.ts | Tests whole-token spendable display formatting. |
| tests/components/screens/SwapScreen/helpers/descriptors.test.ts | Tests Soroban descriptor construction. |
| tests/components/screens/SwapScreen/helpers/decodeSwapTx.test.ts | Tests Soroban transaction decoding and mismatch handling. |
| tests/components/screens/SwapScreen/helpers/computeTrendingIntersection.test.ts | Tests verified Soroban trending intersections. |
| tests/components/screens/SwapScreen/helpers/canonicalId.test.ts | Tests contract-ID canonicalization. |
| tests/components/screens/SwapScreen/components/SwapTokenRow.test.tsx | Tests Soroban subtitles and context-menu data. |
Review effort: Lite
…he sheet closes The trending detail sheet guarded a late /token-details result only by comparing the swap store with a snapshot taken before the request. Closing the sheet, or confirming again, changes nothing in that snapshot, so a late result still selected the token after the user had dismissed the sheet, and repeated confirms issued duplicate reads. The hook now tracks the pending pick the way SwapToScreen does: each confirm takes a pick id, closing the sheet invalidates it, and a confirm for the token already pending is ignored. The store comparison stays, since a pick made through the regular picker doesn't go through this hook. Copilot review on #1048. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The no-code fallback used ??, so a record with code "" kept an empty name; the extension's fallback uses ||. The two now take the same branch. The shortened ID still renders with each platform's own separator (CCW6...MI75 here, CCW6…MI75 on the extension), from the shared truncate helpers each app uses everywhere. Medium review on the C3 fix round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

What
The swap token picker now offers Soroban contract tokens (SEP-41) alongside classic assets. You can find them through search, trending, a pasted contract ID, or your balances, and swap them through LI.FI on mainnet. The Swap button on a Soroban token's details screen now works too.
/token-details; if they can't be read, the pick is refused.trust()on a token bought by its contract ID, since the backend adds one for a SAC.Draft, for diff review. Stacked on C2 (
feat/swap-backend-tx, which is stacked on C1,feat/swap-backend-quotes): C2 is #1050, C1 is #1049. Needs stellar/freighter-backend-v2#178 live first.Why
Swap routing spec, client item C3: stellar/wallet-eng-monorepo#131.
Implementation details (for agents)
What changed
useSwapTokenLookup,SwapToScreen,useTrendingTokenDetail): Soroban records allowed. A held token is matched by contract ID (canonicalId), so the same-token checks work. A pasted contract ID is looked up for the active account. A failed lookup shows the "search unavailable" notice, and the notice follows each lookup's own outcome. A pick waiting on/token-detailsignores repeat taps and drops late results, in the picker and the trending sheet (including after the sheet closes). A no-code token is named by its shortened contract ID.formatSorobanSearchRecordis shared with Add token, and each caller keeps its own icon behaviour.throwOnFailure: opt-in, added togetTokenDetailsandhandleContractLookupso a failed request can be told apart from "not a token". It defaults off, so no other caller changes.getIconUrlis back to its pre-C3 behaviour.Verification
/code-reviewat high, then medium on each fix round.Known follow-ups
toFixed(7)./token-detailsanswers 400 for every failure, so a lookup outage reads as "not a token" and a pasted ID shows "No tokens match" instead of the unavailable notice (both platforms). Needs 404 for not-a-token and 5xx for upstream failures.Screenshots
iOS simulator, mainnet, a local freighter-backend-v2 (stellar/freighter-backend-v2#178) with LI.FI enabled. Pasting deJTRSY's contract ID lists it with its shortened contract ID; LI.FI quotes 10 XLM → 1.4189969 deJTRSY; the review is decoded from the built Soroban transaction (not submitted).
🤖 Generated with Claude Code