diff --git a/__tests__/hooks/useTokenLookup.test.tsx b/__tests__/hooks/useTokenLookup.test.tsx index b09bc17f2..1529517a9 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/android/app/build.gradle b/android/app/build.gradle index b302df007..e170cb4d3 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 851a422da..40f4092d7 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 76cbcfcaa..1daf2ce85 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 3b4fbdfe5..8023e0e42 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 26de648bb..3c6826865 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", diff --git a/scripts/setup-xcode-latest-stable b/scripts/setup-xcode-latest-stable index 6ac268fc7..3cf64f0e9 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 abb4c269e..8f3bdcfdf 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 2d824d57c..0dc87de02 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 4bf07dfab..65c384c69 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 6a4c79ee3..c590fca80 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 1a6c6ef5c..2ef974343 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 1cc6e8fa8..8f0506138 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 6ae79f3bb..6ae372a84 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" },