From bc5c3c0a5e8c1df9594f4d03ddcd3223e363637f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:51:53 +0000 Subject: [PATCH 1/2] chore: bump app version to v1.14.25 --- android/app/build.gradle | 2 +- ios/freighter-mobile.xcodeproj/project.pbxproj | 8 ++++---- ios/freighter-mobile/Info-Dev.plist | 2 +- ios/freighter-mobile/Info.plist | 2 +- package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index b302df00..e170cb4d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -141,7 +141,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1234567890 - versionName "1.13.25" + versionName "1.14.25" } buildTypes { diff --git a/ios/freighter-mobile.xcodeproj/project.pbxproj b/ios/freighter-mobile.xcodeproj/project.pbxproj index 851a422d..40f4092d 100644 --- a/ios/freighter-mobile.xcodeproj/project.pbxproj +++ b/ios/freighter-mobile.xcodeproj/project.pbxproj @@ -505,7 +505,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.13.25; + MARKETING_VERSION = 1.14.25; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -542,7 +542,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.13.25; + MARKETING_VERSION = 1.14.25; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -740,7 +740,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.13.25; + MARKETING_VERSION = 1.14.25; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -775,7 +775,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.13.25; + MARKETING_VERSION = 1.14.25; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/freighter-mobile/Info-Dev.plist b/ios/freighter-mobile/Info-Dev.plist index 76cbcfca..1daf2ce8 100644 --- a/ios/freighter-mobile/Info-Dev.plist +++ b/ios/freighter-mobile/Info-Dev.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.13.25 + 1.14.25 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/freighter-mobile/Info.plist b/ios/freighter-mobile/Info.plist index 3b4fbdfe..8023e0e4 100644 --- a/ios/freighter-mobile/Info.plist +++ b/ios/freighter-mobile/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.13.25 + 1.14.25 CFBundleSignature ???? CFBundleURLTypes diff --git a/package.json b/package.json index 26de648b..3c682686 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freighter-mobile", - "version": "1.13.25", + "version": "1.14.25", "license": "Apache-2.0", "scripts": { "android": "yarn android-dev", From 94cfe7848fc12d494f631e430106b64544046382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Marcos=20Goulart?= <3228151+CassioMG@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:28:18 -0700 Subject: [PATCH 2/2] Fix crash when adding contract tokens from stellar.expert search (#799) (#801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix crash when adding contract tokens from stellar.expert search (#799) stellar.expert now returns Soroban contract tokens alongside classic assets. The `asset` field for these is a raw contract ID instead of the "CODE-ISSUER-TYPE" format, which caused `new Asset()` to throw when the 56-char contract ID was used as an asset code. Handle contract tokens in `formatTokensFromSearchResults` by detecting contract IDs and reading metadata from the new top-level fields (`code`, `decimals`, `token_name`) with `tomlInfo` as fallback. Co-Authored-By: Claude Opus 4.6 (1M context) * Cap CI Xcode version at 26.3 to fix iOS build Xcode 26.4 ships a stricter Clang that breaks the `fmt` CocoaPod with consteval errors. Cap the auto-selected Xcode at 26.3 until the Pod is updated. The cap is a single variable (MAX_XCODE_VERSION) at the top of the script for easy bumping. Co-Authored-By: Claude Opus 4.6 (1M context) * Add tests for contract token parsing from stellar.expert Test that search results containing Soroban contract tokens (where `asset` is a raw contract ID) are correctly parsed using the new top-level fields, and that missing metadata falls back gracefully. Co-Authored-By: Claude Opus 4.6 (1M context) * Prevent crash when swapping custom/Soroban tokens Custom tokens cannot be swapped via Horizon's classic DEX path finder. Detect them early in findSwapPath and show a user-friendly error instead of letting getTokenForPayment throw an unhandled exception. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix infinite useEffect loop in SwapAmountScreen Remove activeError from the dependency array of the pathError useEffect. activeError was both set by and a dependency of this effect, creating an infinite loop: setActiveError creates a new object → triggers the effect → setActiveError again → forever. The effect only needs to react to pathError, sourceAmount, and destinationTokenId changes. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix tokenContractId selection to prefer explicit contract field Use explicit isContractId checks instead of || fallback to avoid picking a non-empty classic issuer (G...) when tokenCode holds the contract ID. Co-Authored-By: Claude Opus 4.6 (1M context) * add comment --------- Co-authored-by: Claude Opus 4.6 (1M context) --- __tests__/hooks/useTokenLookup.test.tsx | 110 ++++++++++++++++++ scripts/setup-xcode-latest-stable | 28 +++-- .../AddTokenBottomSheetContent.tsx | 22 +++- .../SwapScreen/screens/SwapAmountScreen.tsx | 2 +- src/config/types.ts | 9 +- src/ducks/swap.ts | 18 +++ src/hooks/useTokenLookup.ts | 75 +++++++++--- src/i18n/locales/en/translations.json | 3 +- src/i18n/locales/pt/translations.json | 3 +- 9 files changed, 239 insertions(+), 31 deletions(-) diff --git a/__tests__/hooks/useTokenLookup.test.tsx b/__tests__/hooks/useTokenLookup.test.tsx index b09bc17f..1529517a 100644 --- a/__tests__/hooks/useTokenLookup.test.tsx +++ b/__tests__/hooks/useTokenLookup.test.tsx @@ -109,6 +109,46 @@ jest.mock("services/stellarExpert", () => ({ }, }); } + if (searchTerm === "dejaaa") { + return Promise.resolve({ + _embedded: { + records: [ + { + asset: "CC64WBDGS6QQP22QTTIACYIXT3WF7BBQEYOQPLTP7GTKYY7PZ74QYGSL", + domain: "centrifuge.io", + code: "deJAAA", + token_name: "deJAAA", + decimals: 18, + tomlInfo: { + code: "deJAAA", + issuer: + "CBSZNRNQIFHKHBHPZEHJFWGTJMPLYN4NJYGTLEJBHUVQTTOQ2WSQ6OMV", + image: + "https://stellar.myfilebase.com/ipfs/QmXu7RUtm2zhGDhk1ScDSWH587xccMm8ewprrFr4rYX6F3", + name: "deJAAA", + decimals: 18, + }, + }, + { + asset: + "DEJAAA-GASIX3XBHMFJGHOMC2FEELMO2E6JYE5LL2V2QBW3GX23GJI2EHWM4R5E-2", + }, + ], + }, + }); + } + if (searchTerm === "contract-only") { + return Promise.resolve({ + _embedded: { + records: [ + { + asset: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + domain: "example.com", + }, + ], + }, + }); + } if (searchTerm === "error") { return Promise.reject(new Error("Search failed")); } @@ -294,6 +334,76 @@ describe("useTokenLookup", () => { expect(tokens).not.toContain("FOO"); }); + it("should parse contract tokens from stellar.expert search results", async () => { + const { result } = renderHook(() => + useTokenLookup({ + network: mockNetwork, + publicKey: mockPublicKey, + balanceItems: mockBalanceItems, + }), + ); + + await act(async () => { + result.current.handleSearch("dejaaa"); + await flushPromises(); + }); + + expect(result.current.status).toBe(HookStatus.SUCCESS); + expect(result.current.searchResults).toHaveLength(2); + + // First result: contract token — should use top-level `code`, not the raw contract ID + const contractToken = result.current.searchResults[0]; + expect(contractToken.tokenCode).toBe("deJAAA"); + expect(contractToken.issuer).toBe( + "CC64WBDGS6QQP22QTTIACYIXT3WF7BBQEYOQPLTP7GTKYY7PZ74QYGSL", + ); + expect(contractToken.domain).toBe("centrifuge.io"); + expect(contractToken.tokenType).toBe(TokenTypeWithCustomToken.CUSTOM_TOKEN); + expect(contractToken.decimals).toBe(18); + expect(contractToken.name).toBe("deJAAA"); + expect(contractToken.iconUrl).toBe( + "https://stellar.myfilebase.com/ipfs/QmXu7RUtm2zhGDhk1ScDSWH587xccMm8ewprrFr4rYX6F3", + ); + + // Second result: classic asset — should be parsed with split("-") + const classicToken = result.current.searchResults[1]; + expect(classicToken.tokenCode).toBe("DEJAAA"); + expect(classicToken.issuer).toBe( + "GASIX3XBHMFJGHOMC2FEELMO2E6JYE5LL2V2QBW3GX23GJI2EHWM4R5E", + ); + }); + + it("should fall back to contract ID as tokenCode when metadata is missing", async () => { + const { result } = renderHook(() => + useTokenLookup({ + network: mockNetwork, + publicKey: mockPublicKey, + balanceItems: mockBalanceItems, + }), + ); + + await act(async () => { + result.current.handleSearch("contract-only"); + await flushPromises(); + }); + + expect(result.current.status).toBe(HookStatus.SUCCESS); + expect(result.current.searchResults).toHaveLength(1); + + const token = result.current.searchResults[0]; + // No `code` or `tomlInfo.code` — falls back to contract ID + expect(token.tokenCode).toBe( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + expect(token.issuer).toBe( + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + ); + expect(token.domain).toBe("example.com"); + expect(token.tokenType).toBe(TokenTypeWithCustomToken.CUSTOM_TOKEN); + expect(token.decimals).toBeUndefined(); + expect(token.iconUrl).toBeUndefined(); + }); + it("should handle contract ID search", async () => { const { result } = renderHook(() => useTokenLookup({ diff --git a/scripts/setup-xcode-latest-stable b/scripts/setup-xcode-latest-stable index 6ac268fc..3cf64f0e 100644 --- a/scripts/setup-xcode-latest-stable +++ b/scripts/setup-xcode-latest-stable @@ -4,6 +4,10 @@ set -e +# Maximum allowed Xcode major.minor version. +# Bump this once all Pods (especially `fmt`) compile cleanly on the next Xcode. +MAX_XCODE_VERSION="26.3" + # Find all beta/RC Xcode versions to identify base versions to exclude BETA_XCODE_PATHS=$(ls -1d /Applications/Xcode*.app 2>/dev/null | grep -E "(Release_Candidate|RC|beta|Beta)" || true) @@ -44,15 +48,23 @@ for XCODE_PATH in $XCODE_PATHS; do # Extract major.minor version for comparison BASE_VERSION=$(echo "$VERSION" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/') - # Check if this base version should be excluded + # Check if this version exceeds the maximum allowed version SHOULD_EXCLUDE=false - for EXCLUDED_BASE in $EXCLUDED_BASE_VERSIONS; do - if [ "$BASE_VERSION" = "$EXCLUDED_BASE" ]; then - echo "Excluding $XCODE_PATH (Version: $VERSION, Base: $BASE_VERSION matches excluded base version)" - SHOULD_EXCLUDE=true - break - fi - done + if [ -n "$MAX_XCODE_VERSION" ] && [ "$(printf '%s\n%s' "$MAX_XCODE_VERSION" "$BASE_VERSION" | sort -V | head -n1)" != "$BASE_VERSION" ]; then + echo "Excluding $XCODE_PATH (Version: $VERSION exceeds max allowed $MAX_XCODE_VERSION)" + SHOULD_EXCLUDE=true + fi + + # Check if this base version should be excluded (beta/RC) + if [ "$SHOULD_EXCLUDE" = "false" ]; then + for EXCLUDED_BASE in $EXCLUDED_BASE_VERSIONS; do + if [ "$BASE_VERSION" = "$EXCLUDED_BASE" ]; then + echo "Excluding $XCODE_PATH (Version: $VERSION, Base: $BASE_VERSION matches excluded base version)" + SHOULD_EXCLUDE=true + break + fi + done + fi if [ "$SHOULD_EXCLUDE" = "false" ]; then printf "%s\t%s\n" "$VERSION" "$XCODE_PATH" >> "$TEMP_FILE" diff --git a/src/components/screens/AddTokenScreen/AddTokenBottomSheetContent.tsx b/src/components/screens/AddTokenScreen/AddTokenBottomSheetContent.tsx index abb4c269..8f3bdcfd 100644 --- a/src/components/screens/AddTokenScreen/AddTokenBottomSheetContent.tsx +++ b/src/components/screens/AddTokenScreen/AddTokenBottomSheetContent.tsx @@ -157,11 +157,23 @@ const AddTokenBottomSheetContent: React.FC = ({ const listItems = useMemo(() => { if (!token) return []; - // If issuer is already a contract ID, use it directly - // Otherwise, create an Asset and get its contract ID - const tokenContractId = isContractId(token.issuer) - ? token.issuer - : new Asset(token.tokenCode, token.issuer).contractId(networkPassphrase); + // For contract tokens (Soroban), the contract ID is typically stored in + // `issuer`. As a safety fallback, stellar.expert can return contract + // tokens where `asset` is the raw contract ID, so `tokenCode` may hold + // the contract ID instead. Select the contract-bearing field explicitly: + // a non-empty classic issuer must not override a contract ID found in + // `tokenCode`. + const getTokenContractId = () => { + if (isContractId(token.issuer)) return token.issuer; + if (isContractId(token.tokenCode)) return token.tokenCode; + + // without the above guard, `new Asset()` would + // throw because contract IDs exceed the 12-char asset code limit + return new Asset(token.tokenCode, token.issuer).contractId( + networkPassphrase, + ); + }; + const tokenContractId = getTokenContractId(); const items = [ { diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 2d824d57..0dc87de0 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -280,7 +280,7 @@ const SwapAmountScreen: React.FC = ({ duration: 0, }); } - }, [pathError, sourceAmount, destinationTokenId, activeError]); + }, [pathError, sourceAmount, destinationTokenId]); const handleSettingsPress = useCallback(() => { transactionSettingsBottomSheetModalRef.current?.present(); diff --git a/src/config/types.ts b/src/config/types.ts index 4bf07dfa..65c384c6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -271,11 +271,16 @@ export interface SearchTokenResponse { }; score: number; paging_token: number; - tomlInfo: { + tomlInfo?: { code: string; - image: string; + image?: string; issuer: string; + decimals?: number; + name?: string; }; + code?: string; + token_name?: string; + decimals?: number; }[]; }; } diff --git a/src/ducks/swap.ts b/src/ducks/swap.ts index 6a4c79ee..c590fca8 100644 --- a/src/ducks/swap.ts +++ b/src/ducks/swap.ts @@ -9,6 +9,7 @@ import { logger } from "config/logger"; import { PricedBalance } from "config/types"; import { useDebugStore } from "ducks/debug"; import { formatBigNumberForDisplay } from "helpers/formatAmount"; +import { isContractId } from "helpers/soroban"; import { t } from "i18next"; import { getTokenForPayment } from "services/transactionService"; import { create } from "zustand"; @@ -184,6 +185,23 @@ export const useSwapStore = create((set) => ({ throw new Error(t("debug.debugMessages.swapPathFailure")); } + // Custom/Soroban tokens cannot be swapped via Horizon's classic DEX + const isSourceCustom = + "contractId" in sourceBalance || + ("id" in sourceBalance && isContractId(sourceBalance.id ?? "")); + const isDestCustom = + "contractId" in destinationBalance || + ("id" in destinationBalance && + isContractId(destinationBalance.id ?? "")); + + if (isSourceCustom || isDestCustom) { + set({ + isLoadingPath: false, + pathError: t("swapScreen.errors.customTokenSwapNotSupported"), + }); + return; + } + // For now, we only support classic path payments // TODO: Add Soroswap support for Testnet in future iteration const pathResult = await findClassicSwapPath({ diff --git a/src/hooks/useTokenLookup.ts b/src/hooks/useTokenLookup.ts index 1a6c6ef5..2ef97434 100644 --- a/src/hooks/useTokenLookup.ts +++ b/src/hooks/useTokenLookup.ts @@ -135,7 +135,21 @@ export const useTokenLookup = ({ return !!matchingBalance; }; - // Format tokens from different sources while preserving original order + // Format tokens from different sources while preserving original order. + // + // Three paths depending on the result origin: + // + // 1. freighter-backend (contract ID search via handleContractLookup) + // → Already a FormattedSearchTokenRecord, just enrich with icon and trustline info. + // + // 2. stellar.expert — Soroban contract token + // → `asset` is a raw contract ID (e.g. "CC64WB...QYGSL"). + // Token metadata comes from top-level fields (`code`, `decimals`, `token_name`) + // with `tomlInfo` as fallback. + // + // 3. stellar.expert — Classic asset + // → `asset` is "CODE-ISSUER-TYPE" (e.g. "USDC-GA5ZSE...R5E-1"). + // Split by "-" to extract code and issuer. const formatTokensFromSearchResults = ( rawSearchResults: | SearchTokenResponse["_embedded"]["records"] @@ -144,8 +158,8 @@ export const useTokenLookup = ({ icons: Record = {}, ): FormattedSearchTokenRecord[] => rawSearchResults.map((result) => { + // Path 1: Already formatted by freighter-backend (contract ID search) if ("tokenCode" in result) { - // came from freighter-backend const tokenIdentifier = getTokenIdentifier({ type: TokenTypeWithCustomToken.CUSTOM_TOKEN, code: result.tokenCode, @@ -165,7 +179,43 @@ export const useTokenLookup = ({ }; } - // came from stellar.expert + // Path 2: stellar.expert — Soroban contract token + // Contract tokens have no "-" in the asset field (it's just the contract ID). + // Metadata (symbol, decimals, name) comes from top-level fields with + // tomlInfo as fallback. + const isContract = isContractId(result.asset); + + if (isContract) { + const contractId = result.asset; + const tokenCode = result.code ?? result.tomlInfo?.code ?? contractId; + const tokenIdentifier = getTokenIdentifier({ + type: TokenTypeWithCustomToken.CUSTOM_TOKEN, + code: tokenCode, + issuer: { + key: contractId, + }, + }); + const iconUrl = + icons[tokenIdentifier]?.imageUrl ?? result.tomlInfo?.image; + + return { + tokenCode, + domain: result.domain ?? "", + hasTrustline: hasExistingTrustline( + userBalances, + tokenCode, + contractId, + ), + iconUrl, + issuer: contractId, + isNative: false, + tokenType: TokenTypeWithCustomToken.CUSTOM_TOKEN, + decimals: result.decimals ?? result.tomlInfo?.decimals, + name: result.token_name ?? result.tomlInfo?.name, + }; + } + + // Path 3: stellar.expert — Classic asset (asset format: "CODE-ISSUER-TYPE") const [tokenCode, issuer] = result.asset.split("-"); const tokenIdentifier = getTokenIdentifier({ type: TokenTypeWithCustomToken.CUSTOM_TOKEN, @@ -243,31 +293,30 @@ export const useTokenLookup = ({ icons = resJson?.reduce( (prev, curr) => { + if (!curr.tomlInfo) return prev; + const tokenIdentifier = getTokenIdentifier({ type: TokenTypeWithCustomToken.CREDIT_ALPHANUM4, - code: curr.tomlInfo?.code, + code: curr.tomlInfo.code, issuer: { - key: curr.tomlInfo?.issuer, + key: curr.tomlInfo.issuer, }, }); // Apply USDC special case logic inline - let imageUrl = curr.tomlInfo?.image; + let imageUrl = curr.tomlInfo.image; if ( network === NETWORKS.PUBLIC && - curr.tomlInfo?.code === USDC_CODE && - curr.tomlInfo?.issuer === CIRCLE_USDC_ISSUER + curr.tomlInfo.code === USDC_CODE && + curr.tomlInfo.issuer === CIRCLE_USDC_ISSUER ) { imageUrl = logos.usdc as unknown as string; } - const icon = { - imageUrl, - network, - }; + if (!imageUrl) return prev; // eslint-disable-next-line no-param-reassign - prev[tokenIdentifier] = icon; + prev[tokenIdentifier] = { imageUrl, network }; return prev; }, {} as Record, diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 1cc6e8fa..8f050613 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -671,7 +671,8 @@ "insufficientBalance": "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", "insufficientXlmForFees": "Insufficient XLM for transaction fees. You need at least {{fee}} XLM to pay the transaction fee.", "noPathFound": "No path found between these tokens", - "pathFindFailed": "Couldn't fetch swap path. Please try again." + "pathFindFailed": "Couldn't fetch swap path. Please try again.", + "customTokenSwapNotSupported": "Swapping custom tokens is not supported yet" }, "proceedAnyway": "Proceed anyway" }, diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 6ae79f3b..6ae372a8 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -635,7 +635,8 @@ "insufficientBalance": "Saldo insuficiente. Máximo disponível: {{amount}} {{symbol}}", "insufficientXlmForFees": "XLM insuficiente para taxas de transação. Você precisa de pelo menos {{fee}} XLM para pagar a taxa de transação.", "noPathFound": "Nenhum caminho encontrado entre estes tokens", - "pathFindFailed": "Não foi possível buscar o caminho de troca. Tente novamente." + "pathFindFailed": "Não foi possível buscar o caminho de troca. Tente novamente.", + "customTokenSwapNotSupported": "Troca de tokens customizados ainda não é suportada" }, "proceedAnyway": "Prosseguir mesmo assim" },