diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml index e1c386fc4..b615ca78b 100644 --- a/.github/workflows/android-e2e.yml +++ b/.github/workflows/android-e2e.yml @@ -232,6 +232,7 @@ jobs: # - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM) # - SwapClassicTokenMainnet: Swap tokens on mainnet (small amount: 0.000001 XLM) # - ForgotPasswordWarning: Lock screen forgot password confirmation modal + # - SendFederatedAddress: Send XLM and USDC to a federated address on mainnet (small amount: 0.000001 each) # - SignMessageMockDapp: WalletConnect sign message (requires mock server) # - SignAuthEntryMockDapp: WalletConnect sign auth entry (requires mock server) # Note: mock-dapp tests use shard-index:0, to run sequentially in isolation, @@ -239,43 +240,47 @@ jobs: matrix: include: - shard-index: 0 - shard-total: 8 + shard-total: 9 flow-name: LaunchAndInspect requires-mock-server: false - shard-index: 1 - shard-total: 8 + shard-total: 9 flow-name: CreateWallet requires-mock-server: false - shard-index: 2 - shard-total: 8 + shard-total: 9 flow-name: ImportWallet requires-mock-server: false - shard-index: 3 - shard-total: 8 + shard-total: 9 flow-name: ImportFundedWallet requires-mock-server: false - shard-index: 4 - shard-total: 8 + shard-total: 9 flow-name: SwitchToTestnet requires-mock-server: false - shard-index: 5 - shard-total: 8 + shard-total: 9 flow-name: SendClassicTokenMainnet requires-mock-server: false - shard-index: 6 - shard-total: 8 + shard-total: 9 flow-name: SwapClassicTokenMainnet requires-mock-server: false - shard-index: 7 - shard-total: 8 + shard-total: 9 flow-name: ForgotPasswordWarning requires-mock-server: false + - shard-index: 8 + shard-total: 9 + flow-name: SendFederatedAddress + requires-mock-server: false - shard-index: 0 - shard-total: 8 + shard-total: 9 flow-name: SignMessageMockDapp requires-mock-server: true - shard-index: 0 - shard-total: 8 + shard-total: 9 flow-name: SignAuthEntryMockDapp requires-mock-server: true diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml index 270c2d658..c3f78307b 100644 --- a/.github/workflows/ios-e2e.yml +++ b/.github/workflows/ios-e2e.yml @@ -229,6 +229,7 @@ jobs: # - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM) # - SwapClassicTokenMainnet: Swap tokens on mainnet (small amount: 0.000001 XLM) # - ForgotPasswordWarning: Lock screen forgot password confirmation modal + # - SendFederatedAddress: Send XLM and USDC to a federated address on mainnet (small amount: 0.000001 each) # - SignMessageMockDapp: WalletConnect sign message (requires mock server) # - SignAuthEntryMockDapp: WalletConnect sign auth entry (requires mock server) # Note: mock-dapp tests use shard-index:0, to run sequentially in isolation, @@ -236,43 +237,47 @@ jobs: matrix: include: - shard-index: 0 - shard-total: 8 + shard-total: 9 flow-name: LaunchAndInspect requires-mock-server: false - shard-index: 1 - shard-total: 8 + shard-total: 9 flow-name: CreateWallet requires-mock-server: false - shard-index: 2 - shard-total: 8 + shard-total: 9 flow-name: ImportWallet requires-mock-server: false - shard-index: 3 - shard-total: 8 + shard-total: 9 flow-name: ImportFundedWallet requires-mock-server: false - shard-index: 4 - shard-total: 8 + shard-total: 9 flow-name: SwitchToTestnet requires-mock-server: false - shard-index: 5 - shard-total: 8 + shard-total: 9 flow-name: SendClassicTokenMainnet requires-mock-server: false - shard-index: 6 - shard-total: 8 + shard-total: 9 flow-name: SwapClassicTokenMainnet requires-mock-server: false - shard-index: 7 - shard-total: 8 + shard-total: 9 flow-name: ForgotPasswordWarning requires-mock-server: false + - shard-index: 8 + shard-total: 9 + flow-name: SendFederatedAddress + requires-mock-server: false - shard-index: 0 - shard-total: 8 + shard-total: 9 flow-name: SignMessageMockDapp requires-mock-server: true - shard-index: 0 - shard-total: 8 + shard-total: 9 flow-name: SignAuthEntryMockDapp requires-mock-server: true diff --git a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx index 9a410b4be..469d76e87 100644 --- a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx +++ b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx @@ -31,6 +31,7 @@ jest.mock("@gorhom/bottom-sheet", () => ({ // Mock stellar helpers jest.mock("helpers/stellar", () => ({ isValidStellarAddress: jest.fn().mockReturnValue(true), + isFederationAddress: jest.fn().mockReturnValue(false), truncateAddress: jest.fn( (address) => `${address.slice(0, 4)}...${address.slice(-4)}`, ), @@ -74,10 +75,18 @@ const getSendStoreMock = (overrides = {}) => recentAddresses: [], searchResults: [], searchError: null, + isSearching: false, + isValidDestination: false, + isDestinationFunded: null, + destinationAddress: "", + federationAddress: "", + federationMemo: "", + federationMemoType: "", loadRecentAddresses: mockLoadRecentAddresses, searchAddress: mockSearchAddress, addRecentAddress: mockAddRecentAddress, setDestinationAddress: mockSetDestinationAddress, + prepareForSearch: jest.fn(), resetSendRecipient: mockReset, ...overrides, }); @@ -88,6 +97,9 @@ jest.mock("ducks/sendRecipient", () => ({ const getTransactionSettingsStoreMock = (overrides = {}) => ({ saveRecipientAddress: jest.fn(), + saveFederationAddress: jest.fn(), + saveMemo: jest.fn(), + saveMemoType: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), selectedCollectibleDetails: { collectionAddress: "", tokenId: "" }, selectedTokenId: "", @@ -97,6 +109,9 @@ const getTransactionSettingsStoreMock = (overrides = {}) => ({ jest.mock("ducks/transactionSettings", () => ({ useTransactionSettingsStore: jest.fn().mockReturnValue({ saveRecipientAddress: jest.fn(), + saveFederationAddress: jest.fn(), + saveMemo: jest.fn(), + saveMemoType: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), selectedCollectibleDetails: { collectionAddress: "", tokenId: "" }, selectedTokenId: "", diff --git a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx index 02375e408..e7a7bc197 100644 --- a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx +++ b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx @@ -338,12 +338,15 @@ describe("TransactionAmountScreen - Memo Update Flow", () => { transactionFee: "0.00001", transactionTimeout: 30, recipientAddress: mockRecipientAddress, + federationAddress: "", selectedTokenId: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", saveMemo: jest.fn(), + saveMemoType: jest.fn(), saveTransactionFee: jest.fn(), saveTransactionTimeout: jest.fn(), saveRecipientAddress: jest.fn(), + saveFederationAddress: jest.fn(), saveSelectedTokenId: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), resetSettings: jest.fn(), @@ -944,12 +947,15 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => { transactionFee: "0.00001", transactionTimeout: 30, recipientAddress: mockNonMemoRequiredAddress, + federationAddress: "", selectedTokenId: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", saveMemo: jest.fn(), + saveMemoType: jest.fn(), saveTransactionFee: jest.fn(), saveTransactionTimeout: jest.fn(), saveRecipientAddress: jest.fn(), + saveFederationAddress: jest.fn(), saveSelectedTokenId: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), resetSettings: jest.fn(), diff --git a/__tests__/ducks/sendRecipient.test.ts b/__tests__/ducks/sendRecipient.test.ts index 93ad17581..ce8928148 100644 --- a/__tests__/ducks/sendRecipient.test.ts +++ b/__tests__/ducks/sendRecipient.test.ts @@ -1,4 +1,4 @@ -import { Federation } from "@stellar/stellar-sdk"; +import { Federation, StrKey } from "@stellar/stellar-sdk"; import { act } from "@testing-library/react-hooks"; import { STORAGE_KEYS } from "config/constants"; import { getActiveAccountPublicKey } from "ducks/auth"; @@ -44,6 +44,9 @@ jest.mock("@stellar/stellar-sdk", () => ({ resolve: jest.fn(), }, }, + StrKey: { + isValidEd25519PublicKey: jest.fn(), + }, Networks: jest.requireActual("@stellar/stellar-sdk").Networks, })); @@ -82,6 +85,8 @@ describe("sendRecipient Duck", () => { searchResults: [], destinationAddress: "", federationAddress: "", + federationMemo: "", + federationMemoType: "", isSearching: false, searchError: null, isValidDestination: false, @@ -97,6 +102,7 @@ describe("sendRecipient Duck", () => { (stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(false); (getAccount as jest.Mock).mockResolvedValue({ id: mockPublicKey }); (getActiveAccountPublicKey as jest.Mock).mockResolvedValue("DIFFERENT_KEY"); + (StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(true); }); it("should have correct initial state", () => { @@ -139,14 +145,20 @@ describe("sendRecipient Duck", () => { expect(dataStorage.setItem).toHaveBeenCalled(); }); - it("should not add duplicate address", async () => { + it("should not write to storage when address already exists with the same name", async () => { act(() => { store.setState({ - recentAddresses: [{ id: "recent-1", address: "existingAddress" }], + recentAddresses: [ + { + id: "recent-1", + address: "existingAddress", + name: "alice*fed.com", + }, + ], }); }); - await store.getState().addRecentAddress("existingAddress"); + await store.getState().addRecentAddress("existingAddress", "alice*fed.com"); expect(dataStorage.setItem).not.toHaveBeenCalled(); }); @@ -170,6 +182,109 @@ describe("sendRecipient Duck", () => { expect(store.getState().destinationAddress).toBe(mockPublicKey); expect(store.getState().federationAddress).toBe(mockFederationAddress); + expect(store.getState().federationMemo).toBe(""); + expect(store.getState().federationMemoType).toBe(""); + }); + + it("should capture federation memo and memo_type when server returns them", async () => { + (stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true); + (Federation.Server.resolve as jest.Mock).mockResolvedValue({ + account_id: mockPublicKey, + memo: "12345", + memo_type: "id", + }); + + await store.getState().searchAddress(mockFederationAddress); + + expect(store.getState().destinationAddress).toBe(mockPublicKey); + expect(store.getState().federationMemo).toBe("12345"); + expect(store.getState().federationMemoType).toBe("id"); + }); + + it("should error when federation server returns a malformed account_id", async () => { + (stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true); + (Federation.Server.resolve as jest.Mock).mockResolvedValue({ + account_id: "not-a-valid-key", + }); + (StrKey.isValidEd25519PublicKey as jest.Mock).mockReturnValue(false); + + await store.getState().searchAddress(mockFederationAddress); + + expect(store.getState().searchError).toBe( + "sendRecipient.error.federationNotFound", + ); + expect(store.getState().isValidDestination).toBe(false); + expect(store.getState().destinationAddress).toBe(""); + }); + + it("should error when Federation.Server.resolve rejects", async () => { + (stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true); + (Federation.Server.resolve as jest.Mock).mockRejectedValue( + new Error("DNS failure"), + ); + + await store.getState().searchAddress(mockFederationAddress); + + expect(store.getState().searchError).toBe( + "sendRecipient.error.federationNotFound", + ); + expect(store.getState().isSearching).toBe(false); + expect(store.getState().isValidDestination).toBe(false); + }); + + it("should error when resolved federation address is the user's own account", async () => { + (stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true); + (Federation.Server.resolve as jest.Mock).mockResolvedValue({ + account_id: mockPublicKey, + }); + (getActiveAccountPublicKey as jest.Mock).mockResolvedValue(mockPublicKey); + // First call: federation string vs own key → false (different format, no match) + // Second call: resolved G... key vs own key → true (it is the same account) + (stellarHelpers.isSameAccount as jest.Mock) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + await store.getState().searchAddress(mockFederationAddress); + + expect(store.getState().searchError).toBe( + "sendRecipient.error.sendToSelfFederation", + ); + expect(store.getState().isValidDestination).toBe(false); + }); + + it("should call Federation.Server.resolve with a 10 second timeout", async () => { + (stellarHelpers.isFederationAddress as jest.Mock).mockReturnValue(true); + (Federation.Server.resolve as jest.Mock).mockResolvedValue({ + account_id: mockPublicKey, + }); + + await store.getState().searchAddress(mockFederationAddress); + + expect(Federation.Server.resolve).toHaveBeenCalledWith( + mockFederationAddress, + { timeout: 10000 }, + ); + }); + + it("should update federation name on existing recent address", async () => { + act(() => { + store.setState({ + recentAddresses: [ + { + id: "recent-1", + address: "existingAddress", + }, + ], + }); + }); + + await store.getState().addRecentAddress("existingAddress", "alice*fed.com"); + + const updated = store + .getState() + .recentAddresses.find((c) => c.address === "existingAddress"); + expect(updated?.name).toBe("alice*fed.com"); + expect(dataStorage.setItem).toHaveBeenCalled(); }); it("should handle invalid address format", async () => { @@ -212,7 +327,9 @@ describe("sendRecipient Duck", () => { it("should reset all search-related state", () => { act(() => { store.setState({ - searchResults: [{ id: "1", address: "address" }], + searchResults: [ + { id: "1", address: "address" }, + ], destinationAddress: "address", federationAddress: "fed*address", isSearching: true, diff --git a/__tests__/helpers/stellar.test.ts b/__tests__/helpers/stellar.test.ts index da6d693bc..804920940 100644 --- a/__tests__/helpers/stellar.test.ts +++ b/__tests__/helpers/stellar.test.ts @@ -22,6 +22,7 @@ import { signAuthEntry, signMessage, truncateAddress, + truncateFedAddress, } from "helpers/stellar"; jest.mock("@stellar/stellar-sdk", () => { @@ -131,9 +132,20 @@ describe("Stellar helpers", () => { expect(isFederationAddress("user*domain")).toBe(false); expect(isFederationAddress("userdomain.com")).toBe(false); expect(isFederationAddress("user*domain*com")).toBe(false); + expect(isFederationAddress("user*domain..com")).toBe(false); + expect(isFederationAddress("user*domain .com")).toBe(false); expect(isFederationAddress("")).toBe(false); expect(isFederationAddress(validEd25519)).toBe(false); }); + + it("should reject non-ASCII characters to prevent homoglyph spoofing", () => { + // Cyrillic lookalike for 'a' (U+0430) + expect(isFederationAddress("userа*domain.com")).toBe(false); + // Ellipsis character (U+2026) + expect(isFederationAddress("user…*domain.com")).toBe(false); + // Unicode asterisk lookalike (U+204E) + expect(isFederationAddress("user⁎domain.com")).toBe(false); + }); }); describe("isMuxedAccount", () => { @@ -293,6 +305,44 @@ describe("Stellar helpers", () => { }); }); + describe("truncateFedAddress", () => { + it("should return the address unchanged if short enough", () => { + expect(truncateFedAddress("user*domain.com")).toBe("user*domain.com"); + }); + + it("should truncate a long local part", () => { + expect(truncateFedAddress("averylongusername*domain.com")).toBe( + "averylongu…*domain.com", + ); + }); + + it("should truncate a long domain part", () => { + expect(truncateFedAddress("user*averylongdomainname.com")).toBe( + "user*averylongdom…", + ); + }); + + it("should truncate both parts when both are long", () => { + expect( + truncateFedAddress("averylongusername*averylongdomainname.com"), + ).toBe("averylongu…*averylongdom…"); + }); + + it("should respect custom maxLocal and maxDomain params", () => { + expect(truncateFedAddress("abcdefgh*domain.com", 4, 6)).toBe( + "abcd…*domain…", + ); + }); + + it("should return the input unchanged if it has no star", () => { + expect(truncateFedAddress("nodomain")).toBe("nodomain"); + }); + + it("should return the input unchanged for empty string", () => { + expect(truncateFedAddress("")).toBe(""); + }); + }); + describe("isSameAccount", () => { it("should return true for identical valid Ed25519 addresses", () => { expect(isSameAccount(validEd25519, validEd25519)).toBe(true); diff --git a/e2e/flows/transactions/SendFederatedAddress.yaml b/e2e/flows/transactions/SendFederatedAddress.yaml new file mode 100644 index 000000000..79de540d7 --- /dev/null +++ b/e2e/flows/transactions/SendFederatedAddress.yaml @@ -0,0 +1,232 @@ +appId: org.stellar.freighterdev +tags: + - transactions + - send + - federation + - mainnet +--- +# Send 0.000001 XLM and 0.000001 USDC on mainnet to a federated address +# (freighter.pb*lobstr.co). Verifies that federation address resolution works +# end-to-end for both native and classic tokens: +# search → resolve → review → confirm → sent. +# +# Uses a small amount to avoid wasting mainnet funds. + +# Import funded wallet +- runFlow: + file: ../onboarding/ImportFundedWallet.yaml + +# Wait for either home screen or banner to appear +- extendedWaitUntil: + visible: + id: home-screen|welcome-banner-dismiss-button + timeout: 30000 + +# Dismiss welcome banner if visible +- runFlow: + when: + visible: + id: welcome-banner-dismiss-button + commands: + - tapOn: + id: welcome-banner-dismiss-button + - extendedWaitUntil: + visible: + id: home-screen + timeout: 30000 + +# Stay on mainnet (no network switch) + +# Verify we're on home screen after import +- assertVisible: + id: home-screen + +# Ensure we're on the Home tab (funded wallets open on History) +- tapOn: + id: tab-home + +# Wait for accounts and balances to load +- extendedWaitUntil: + visible: + id: home-account-switcher + timeout: 15000 +- extendedWaitUntil: + visible: + id: icon-button-send + timeout: 15000 + +# Navigate to send flow +- tapOn: + id: icon-button-send +# Tap recipient row to open search +- tapOn: + id: send-recipient-row +# Wait for recipient search screen +- extendedWaitUntil: + visible: + id: send-search-contacts-screen + timeout: 15000 + +# Type a federation address (LOBSTR federation) +- tapOn: + id: send-recipient-input +- inputText: freighter.pb*lobstr.co + +# Wait for federation resolution and search suggestion to appear +- extendedWaitUntil: + visible: + id: search-suggestion-0 + timeout: 30000 +- tapOn: + id: search-suggestion-0 + +# Verify we're on amount screen (federation resolved successfully) +- extendedWaitUntil: + visible: + id: send-amount-screen + timeout: 15000 + +# Enter amount: 0.000001 XLM (minimal to avoid wasting mainnet funds) +- tapOn: + id: numeric-key-0 +- tapOn: + id: numeric-key-decimal +- repeat: + times: 5 + commands: + - tapOn: + id: numeric-key-0 +- tapOn: + id: numeric-key-1 + +# Tap Review button - this is where the bug manifested (federation address +# was passed to buildPaymentTransaction instead of the resolved G... key) +- tapOn: + id: send-continue-button + +# Verify review sheet is visible (confirms federation address was correctly +# resolved to a G... public key for transaction building) +- extendedWaitUntil: + visible: + id: send-review-sheet + timeout: 30000 +# Wait for the confirm button to be enabled (transaction built + memo validated) +- extendedWaitUntil: + visible: + id: send-review-confirm-button + enabled: true + timeout: 30000 +- tapOn: + id: send-review-confirm-button +# Verify processing screen +- extendedWaitUntil: + visible: + id: send-processing-screen + timeout: 30000 +# Wait for completion and tap Done +- extendedWaitUntil: + visible: + id: send-processing-done-button + timeout: 30000 +- tapOn: + id: send-processing-done-button +# Verify we're back on history screen with transaction visible +# Note: optional because the transaction might not be immediately rendered in the view +- assertVisible: + text: "XLM" + optional: true + +# ── USDC send ────────────────────────────────────────────────────────────── +# Start a second send to the same federation address using USDC. + +# Navigate back to Home tab (processing screen returns to History) +- tapOn: + id: tab-home +- extendedWaitUntil: + visible: + id: icon-button-send + timeout: 15000 + +# Navigate to send flow +- tapOn: + id: icon-button-send +# On TransactionAmountScreen, tap token row to change asset +- extendedWaitUntil: + visible: + id: send-amount-screen + timeout: 15000 +- tapOn: + id: send-token-row +# Wait for token selection screen +- extendedWaitUntil: + visible: + id: token-option-USDC + timeout: 15000 +# Select USDC +- tapOn: + id: token-option-USDC +# Verify we're on the amount screen with USDC selected +- extendedWaitUntil: + visible: + id: send-amount-screen + timeout: 15000 +# Open recipient search and type the same federation address +- tapOn: + id: send-recipient-row +- extendedWaitUntil: + visible: + id: send-search-contacts-screen + timeout: 15000 +- tapOn: + id: send-recipient-input +- inputText: freighter.pb*lobstr.co +# Wait for federation resolution +- extendedWaitUntil: + visible: + id: search-suggestion-0 + timeout: 30000 +- tapOn: + id: search-suggestion-0 +# Back on amount screen — enter 0.000001 USDC +- extendedWaitUntil: + visible: + id: send-amount-screen + timeout: 15000 +- tapOn: + id: numeric-key-0 +- tapOn: + id: numeric-key-decimal +- repeat: + times: 5 + commands: + - tapOn: + id: numeric-key-0 +- tapOn: + id: numeric-key-1 +# Review and confirm +- tapOn: + id: send-continue-button +- extendedWaitUntil: + visible: + id: send-review-sheet + timeout: 30000 +- extendedWaitUntil: + visible: + id: send-review-confirm-button + enabled: true + timeout: 30000 +- tapOn: + id: send-review-confirm-button +- extendedWaitUntil: + visible: + id: send-processing-screen + timeout: 30000 +- extendedWaitUntil: + visible: + id: send-processing-done-button + timeout: 30000 +- tapOn: + id: send-processing-done-button +- assertVisible: + text: "USDC" + optional: true diff --git a/src/components/TokensCollectiblesTabs.tsx b/src/components/TokensCollectiblesTabs.tsx index 8bea8c003..ff1f7e1df 100644 --- a/src/components/TokensCollectiblesTabs.tsx +++ b/src/components/TokensCollectiblesTabs.tsx @@ -64,6 +64,8 @@ interface Props { feeContext?: TransactionContext; /** Whether to disable inner scrolling for both the tokens and collectibles grids */ disableInnerScrolling?: boolean; + /** Optional testID prefix forwarded to each balance row (e.g. "token-option" → "token-option-XLM") */ + balanceRowTestIDPrefix?: string; } /** @@ -98,6 +100,7 @@ export const TokensCollectiblesTabs: React.FC = React.memo( showSpendableAmount = false, feeContext = TransactionContext.Send, disableInnerScrolling = false, + balanceRowTestIDPrefix, }) => { const navigation = useNavigation>(); const { t } = useAppTranslation(); @@ -199,6 +202,7 @@ export const TokensCollectiblesTabs: React.FC = React.memo( disableInnerScrolling={disableInnerScrolling} showSpendableAmount={showSpendableAmount} feeContext={feeContext} + balanceRowTestIDPrefix={balanceRowTestIDPrefix} /> ), [ @@ -208,6 +212,7 @@ export const TokensCollectiblesTabs: React.FC = React.memo( showSpendableAmount, feeContext, disableInnerScrolling, + balanceRowTestIDPrefix, ], ); diff --git a/src/components/screens/SendScreen/SendSearchContacts.tsx b/src/components/screens/SendScreen/SendSearchContacts.tsx index 6eaaa89f2..a3f8e258f 100644 --- a/src/components/screens/SendScreen/SendSearchContacts.tsx +++ b/src/components/screens/SendScreen/SendSearchContacts.tsx @@ -23,13 +23,14 @@ import { useSendRecipientStore } from "ducks/sendRecipient"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { getTokenType } from "helpers/balances"; import { isContractId } from "helpers/soroban"; +import { isFederationAddress } from "helpers/stellar"; import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; import useColors from "hooks/useColors"; import { useInAppBrowser } from "hooks/useInAppBrowser"; import { useRightHeaderButton } from "hooks/useRightHeader"; -import React, { useCallback, useEffect, useState } from "react"; -import { View } from "react-native"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { ActivityIndicator, View } from "react-native"; import { analytics } from "services/analytics"; type SendSearchContactsProps = NativeStackScreenProps< @@ -56,6 +57,9 @@ const SendSearchContacts: React.FC = ({ const [address, setAddress] = useState(""); const { saveRecipientAddress, + saveFederationAddress, + saveMemo, + saveMemoType, selectedCollectibleDetails, saveSelectedCollectibleDetails, selectedTokenId, @@ -67,8 +71,10 @@ const SendSearchContacts: React.FC = ({ recentAddresses, searchResults, searchError, + isSearching, loadRecentAddresses, searchAddress, + prepareForSearch, setDestinationAddress, resetSendRecipient, isValidDestination, @@ -110,35 +116,93 @@ const SendSearchContacts: React.FC = ({ [clearQRData, saveSelectedCollectibleDetails], ); + const searchDebounceRef = useRef | null>(null); + const SEARCH_DEBOUNCE_MS = 300; + /** - * Handles search input changes and updates suggestions + * Handles search input changes with debounce to prevent flickering + * messages and unnecessary intermediate requests while typing. * * @param {string} text - The search text entered by user */ const handleSearch = useCallback( (text: string) => { setAddress(text); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - searchAddress(text); + + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + } + + // Clear stale results/errors immediately so they don't linger during debounce + prepareForSearch(); + + searchDebounceRef.current = setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + searchAddress(text); + }, SEARCH_DEBOUNCE_MS); + }, + [searchAddress, prepareForSearch], + ); + + // Clean up debounce timer on unmount + useEffect( + () => () => { + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current); + } }, - [searchAddress], + [], ); /** * Handles when a contact or address is selected * - * @param {string} contactAddress - The selected contact address + * @param {string} contactAddress - The selected contact address (G... public key) + * @param {string} [contactName] - The federation address if applicable */ const handleContactPress = useCallback( - (contactAddress: string) => { + async (contactAddress: string, contactName?: string) => { if (recentAddresses.some((c) => c.address === contactAddress)) { analytics.track(AnalyticsEvent.SEND_PAYMENT_RECENT_ADDRESS); } + + const isFederation = !!contactName && isFederationAddress(contactName); + + let resolvedAddress = contactAddress; + + // Re-resolve recent federation contacts to pick up any address remapping (H3) + const isRecentContact = recentAddresses.some( + (c) => c.address === contactAddress, + ); + if (isFederation && isRecentContact) { + setAddress(contactName); + await searchAddress(contactName); + const state = useSendRecipientStore.getState(); + if (state.searchError || state.searchResults.length === 0) { + // searchError is shown in the UI; abort navigation + return; + } + resolvedAddress = state.searchResults[0].address; + } + + // Read federation memo/type from store — always fresh after any resolution above + const { + federationMemo: resolvedMemo, + federationMemoType: resolvedMemoType, + } = useSendRecipientStore.getState(); + // Save to both stores for different purposes // Send store is for contact management - setDestinationAddress(contactAddress); + setDestinationAddress( + resolvedAddress, + isFederation ? contactName : undefined, + ); // Transaction settings store is for the transaction flow - saveRecipientAddress(contactAddress); + saveRecipientAddress(resolvedAddress); + saveFederationAddress(isFederation ? contactName : ""); + // Apply federation memo and type; clear both for non-federation contacts (H2) + saveMemo(isFederation && resolvedMemo ? resolvedMemo : ""); + saveMemoType(isFederation && resolvedMemoType ? resolvedMemoType : ""); if (selectedCollectibleDetails.tokenId) { // Use popTo for collectible flow @@ -156,6 +220,10 @@ const SendSearchContacts: React.FC = ({ recentAddresses, setDestinationAddress, saveRecipientAddress, + saveFederationAddress, + saveMemo, + saveMemoType, + searchAddress, navigation, selectedCollectibleDetails, ], @@ -201,7 +269,15 @@ const SendSearchContacts: React.FC = ({ value={address} /> - {searchError && ( + {isSearching && address.length > 0 && ( + + + + )} + {searchError && !isSearching && ( {searchError} @@ -209,6 +285,7 @@ const SendSearchContacts: React.FC = ({ )} {!searchError && + !isSearching && isValidDestination && isDestinationFunded === false && shouldShowUnfundedNotice && ( @@ -238,13 +315,17 @@ const SendSearchContacts: React.FC = ({ {searchResults.length > 0 ? ( { + handleContactPress(contactAddress, name); + }} /> ) : ( recentAddresses.length > 0 && ( { + handleContactPress(contactAddress, name); + }} /> ) )} diff --git a/src/components/screens/SendScreen/components/RecentContactsList.tsx b/src/components/screens/SendScreen/components/RecentContactsList.tsx index dc5d3a1b7..ec9fb0001 100644 --- a/src/components/screens/SendScreen/components/RecentContactsList.tsx +++ b/src/components/screens/SendScreen/components/RecentContactsList.tsx @@ -15,7 +15,7 @@ interface RecentContact { interface RecentContactsListProps { transactions: RecentContact[]; - onContactPress: (address: string) => void; + onContactPress: (address: string, name?: string) => void; testID?: string; } @@ -70,7 +70,7 @@ export const RecentContactsList: React.FC = ({ onContactPress(item.address)} + onPress={() => onContactPress(item.address, item.name)} className="mb-[24px]" testID={`recent-contact-${index}`} /> diff --git a/src/components/screens/SendScreen/components/SearchSuggestionsList.tsx b/src/components/screens/SendScreen/components/SearchSuggestionsList.tsx index d5030c77b..2b4c90053 100644 --- a/src/components/screens/SendScreen/components/SearchSuggestionsList.tsx +++ b/src/components/screens/SendScreen/components/SearchSuggestionsList.tsx @@ -15,7 +15,7 @@ interface SearchSuggestion { interface SearchSuggestionsListProps { suggestions: SearchSuggestion[]; - onContactPress: (address: string) => void; + onContactPress: (address: string, name?: string) => void; testID?: string; } @@ -54,7 +54,7 @@ export const SearchSuggestionsList: React.FC = ({ onContactPress(item.address)} + onPress={() => onContactPress(item.address, item.name)} testID={`search-suggestion-${index}`} /> )} diff --git a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx index db790a0f4..0018ec965 100644 --- a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx +++ b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx @@ -23,7 +23,11 @@ import { isLiquidityPool } from "helpers/balances"; import { pxValue } from "helpers/dimensions"; import { formatTokenForDisplay, formatFiatAmount } from "helpers/formatAmount"; import { computeTotalFeeXlm, isSorobanTransaction } from "helpers/soroban"; -import { truncateAddress, isMuxedAccount } from "helpers/stellar"; +import { + truncateAddress, + truncateFedAddress, + isMuxedAccount, +} from "helpers/stellar"; import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; import useColors from "hooks/useColors"; @@ -94,8 +98,12 @@ const SendReviewBottomSheet: React.FC = ({ }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); - const { recipientAddress, transactionMemo, transactionFee } = - useTransactionSettingsStore(); + const { + recipientAddress, + federationAddress, + transactionMemo, + transactionFee, + } = useTransactionSettingsStore(); const { account } = useGetActiveAccount(); const { copyToClipboard } = useClipboard(); const slicedAddress = truncateAddress(recipientAddress, 4, 4); @@ -388,9 +396,20 @@ const SendReviewBottomSheet: React.FC = ({ hasDarkBackground /> - - {slicedAddress} - + {federationAddress ? ( + <> + + {truncateFedAddress(federationAddress)} + + + {slicedAddress} + + + ) : ( + + {slicedAddress} + + )} diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index bbe5a07b3..66e447519 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -57,7 +57,7 @@ import { } from "helpers/formatAmount"; import { checkContractMuxedSupport } from "helpers/muxedAddress"; import { isSorobanTransaction } from "helpers/soroban"; -import { isMuxedAccount } from "helpers/stellar"; +import { isMuxedAccount, truncateFedAddress } from "helpers/stellar"; import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; @@ -118,10 +118,13 @@ const TransactionAmountScreen: React.FC = ({ const { transactionFee, recipientAddress, + federationAddress, selectedTokenId, transactionMemo, saveSelectedTokenId, saveRecipientAddress, + saveFederationAddress, + saveMemoType, saveSelectedCollectibleDetails, saveMemo, resetSettings, @@ -134,8 +137,10 @@ const TransactionAmountScreen: React.FC = ({ useEffect(() => { // Clear collectible details when entering token flow to prevent cross-flow contamination saveSelectedCollectibleDetails({ collectionAddress: "", tokenId: "" }); - // Clear recipient address when entering the screen + // Clear recipient, federation, and memo state when entering the screen saveRecipientAddress(""); + saveFederationAddress(""); + saveMemoType(""); if (tokenId) { saveSelectedTokenId(tokenId); @@ -147,6 +152,8 @@ const TransactionAmountScreen: React.FC = ({ saveSelectedTokenId, saveSelectedCollectibleDetails, saveRecipientAddress, + saveFederationAddress, + saveMemoType, ]); useEffect(() => { @@ -572,6 +579,7 @@ const TransactionAmountScreen: React.FC = ({ // Get fresh settings values each time the function is called const { transactionMemo: freshTransactionMemo, + transactionMemoType: freshTransactionMemoType, transactionFee: freshTransactionFee, transactionTimeout: freshTransactionTimeout, recipientAddress: storeRecipientAddress, @@ -582,6 +590,7 @@ const TransactionAmountScreen: React.FC = ({ selectedBalance, recipientAddress: storeRecipientAddress, transactionMemo: freshTransactionMemo, + transactionMemoType: freshTransactionMemoType, transactionFee: freshTransactionFee, transactionTimeout: freshTransactionTimeout, network, @@ -964,6 +973,11 @@ const TransactionAmountScreen: React.FC = ({ isSingleRow onPress={navigateToSelectContactScreen} address={recipientAddress} + name={ + federationAddress + ? truncateFedAddress(federationAddress) + : undefined + } testID="send-recipient-row" rightElement={ = ({ onCollectiblePress={handleCollectiblePress} showSpendableAmount feeContext={TransactionContext.Send} + balanceRowTestIDPrefix="token-option" /> diff --git a/src/ducks/sendRecipient.ts b/src/ducks/sendRecipient.ts index 6eb5936f6..2bc9b2bb8 100644 --- a/src/ducks/sendRecipient.ts +++ b/src/ducks/sendRecipient.ts @@ -1,4 +1,4 @@ -import { Federation } from "@stellar/stellar-sdk"; +import { Federation as StellarFederation, StrKey } from "@stellar/stellar-sdk"; import { STORAGE_KEYS } from "config/constants"; import { logger } from "config/logger"; import { getActiveAccountPublicKey, useAuthenticationStore } from "ducks/auth"; @@ -23,6 +23,8 @@ interface SendStore { searchResults: Contact[]; destinationAddress: string; federationAddress: string; + federationMemo: string; + federationMemoType: string; isSearching: boolean; searchError: string | null; isValidDestination: boolean; @@ -32,6 +34,7 @@ interface SendStore { addRecentAddress: (address: string, name?: string) => Promise; searchAddress: (searchTerm: string) => Promise; setDestinationAddress: (address: string, fedAddress?: string) => void; + prepareForSearch: () => void; resetSendRecipient: () => void; } @@ -41,12 +44,15 @@ const initialState: Omit< | "addRecentAddress" | "searchAddress" | "setDestinationAddress" + | "prepareForSearch" | "resetSendRecipient" > = { recentAddresses: [], searchResults: [], destinationAddress: "", federationAddress: "", + federationMemo: "", + federationMemoType: "", isSearching: false, searchError: null, isValidDestination: false, @@ -61,23 +67,29 @@ export const useSendRecipientStore = create((set, get) => ({ const storedAddresses = await dataStorage.getItem( STORAGE_KEYS.RECENT_ADDRESSES, ); - const parsedAddresses: string[] = storedAddresses - ? JSON.parse(storedAddresses) - : []; + const parsedData: (string | { address: string; name?: string })[] = + storedAddresses ? JSON.parse(storedAddresses) : []; // Get current active account public key const activePublicKey = await getActiveAccountPublicKey(); // Transform to the Contact format, filtering out the current account - const contactList: Contact[] = parsedAddresses + // Supports both old format (string[]) and new format ({address, name?}[]) + const contactList: Contact[] = parsedData + .map((entry, index: number) => { + const addr = typeof entry === "string" ? entry : entry.address; + const name = typeof entry === "string" ? undefined : entry.name; + return { + id: `recent-${index}`, + address: addr, + name, + } as Contact; + }) .filter( - (address) => - !activePublicKey || !isSameAccount(address, activePublicKey), - ) - .map((address: string, index: number) => ({ - id: `recent-${index}`, - address, - })); + (contact) => + !activePublicKey || + !isSameAccount(contact.address, activePublicKey), + ); set({ recentAddresses: contactList }); } catch (error) { @@ -95,24 +107,45 @@ export const useSendRecipientStore = create((set, get) => ({ try { const { recentAddresses } = get(); - const exists = recentAddresses.some( + // Normalize federation address to lowercase (SEP-0002: case-insensitive) + const normalizedName = name?.toLowerCase(); + + const existingIndex = recentAddresses.findIndex( (contact) => contact.address === address, ); - if (!exists) { - const newContact = { id: `recent-${Date.now()}`, address, name }; - const updatedAddresses = [newContact, ...recentAddresses]; + let updatedAddresses: Contact[]; + + if (existingIndex === -1) { + updatedAddresses = [ + { + id: `recent-${Date.now()}`, + address, + name: normalizedName, + }, + ...recentAddresses, + ]; + } else { + const existing = recentAddresses[existingIndex]; + if (existing.name === normalizedName) { + return; + } + updatedAddresses = [ + { ...existing, name: normalizedName }, + ...recentAddresses.filter((_, i) => i !== existingIndex), + ]; + } - set({ recentAddresses: updatedAddresses }); + set({ recentAddresses: updatedAddresses }); - const addressesOnly = updatedAddresses.map( - (contact) => contact.address, - ); - await dataStorage.setItem( - STORAGE_KEYS.RECENT_ADDRESSES, - JSON.stringify(addressesOnly), - ); - } + const addressData = updatedAddresses.map((contact) => ({ + address: contact.address, + ...(contact.name ? { name: contact.name } : {}), + })); + await dataStorage.setItem( + STORAGE_KEYS.RECENT_ADDRESSES, + JSON.stringify(addressData), + ); } catch (error) { logger.error("[sendRecipient]", "Failed to add recent address:", error); } @@ -125,6 +158,10 @@ export const useSendRecipientStore = create((set, get) => ({ isValidDestination: false, isDestinationFunded: null, searchResults: [], + destinationAddress: "", + federationAddress: "", + federationMemo: "", + federationMemoType: "", }); try { @@ -159,13 +196,29 @@ export const useSendRecipientStore = create((set, get) => ({ let resolvedAddress = searchTerm; let fedAddress = ""; + let fedMemo = ""; + let fedMemoType = ""; let isFunded: boolean | null = null; if (isFederationAddress(searchTerm)) { try { - const fedRecord = await Federation.Server.resolve(searchTerm); + const fedRecord = await StellarFederation.Server.resolve(searchTerm, { + timeout: 10000, + }); + + if (!StrKey.isValidEd25519PublicKey(fedRecord.account_id)) { + set({ + isSearching: false, + searchError: t("sendRecipient.error.federationNotFound"), + }); + return; + } + resolvedAddress = fedRecord.account_id; - fedAddress = searchTerm; + // Normalize to lowercase per SEP-0002 (federation addresses are case-insensitive) + fedAddress = searchTerm.toLowerCase(); + fedMemo = fedRecord.memo ?? ""; + fedMemoType = fedRecord.memo_type ?? ""; // Re-check if resolved address is the user's own account if ( @@ -229,7 +282,8 @@ export const useSendRecipientStore = create((set, get) => ({ const result: Contact = { id: `search-${Date.now()}`, - address: searchTerm, + address: resolvedAddress, + name: fedAddress || undefined, }; set({ @@ -238,6 +292,8 @@ export const useSendRecipientStore = create((set, get) => ({ isDestinationFunded: isFunded, destinationAddress: resolvedAddress, federationAddress: fedAddress, + federationMemo: fedMemo, + federationMemoType: fedMemoType, isSearching: false, searchError: null, }); @@ -293,6 +349,20 @@ export const useSendRecipientStore = create((set, get) => ({ })(); }, + prepareForSearch: () => { + set({ + searchResults: [], + searchError: null, + isValidDestination: false, + isDestinationFunded: null, + isSearching: true, + destinationAddress: "", + federationAddress: "", + federationMemo: "", + federationMemoType: "", + }); + }, + resetSendRecipient: () => { set({ ...initialState, diff --git a/src/ducks/transactionBuilder.ts b/src/ducks/transactionBuilder.ts index bf74127ae..a355bff7d 100644 --- a/src/ducks/transactionBuilder.ts +++ b/src/ducks/transactionBuilder.ts @@ -63,6 +63,7 @@ interface TransactionBuilderState { selectedBalance?: PricedBalance; recipientAddress?: string; transactionMemo?: string; + transactionMemoType?: string; transactionFee?: string; transactionTimeout?: number; network?: NETWORKS; @@ -172,6 +173,7 @@ export const useTransactionBuilderStore = create( selectedBalance: params.selectedBalance, recipientAddress: params.recipientAddress, transactionMemo: params.transactionMemo, + transactionMemoType: params.transactionMemoType, transactionFee: params.transactionFee, transactionTimeout: params.transactionTimeout, network: params.network, diff --git a/src/ducks/transactionSettings.ts b/src/ducks/transactionSettings.ts index 62acf28e7..0a4acffad 100644 --- a/src/ducks/transactionSettings.ts +++ b/src/ducks/transactionSettings.ts @@ -6,9 +6,11 @@ import { create } from "zustand"; const INITIAL_TRANSACTION_SETTINGS_STATE = { transactionMemo: "", + transactionMemoType: "", transactionFee: MIN_TRANSACTION_FEE, transactionTimeout: DEFAULT_TRANSACTION_TIMEOUT, recipientAddress: "", + federationAddress: "", selectedTokenId: "", selectedCollectibleDetails: { collectionAddress: "", @@ -25,24 +27,30 @@ const INITIAL_TRANSACTION_SETTINGS_STATE = { * * @interface TransactionSettingsState * @property {string} transactionMemo - Memo text to include with the transaction + * @property {string} transactionMemoType - Memo type: "text" | "id" | "hash" | "" (from federation record) * @property {string} transactionFee - Fee amount for the transaction (in XLM) * @property {number} transactionTimeout - Timeout in seconds for the transaction - * @property {string} recipientAddress - Recipient address for the transaction + * @property {string} recipientAddress - Recipient address for the transaction (resolved G... public key) + * @property {string} federationAddress - Original federation address (user*domain) if applicable * @property {string} selectedTokenId - ID of the token selected for the transaction * @property {string} selectedCollectibleDetails - collection ID and token ID of the collectible selected for the transaction * @property {Function} saveMemo - Function to save the memo value + * @property {Function} saveMemoType - Function to save the memo type * @property {Function} saveTransactionFee - Function to save the transaction fee value * @property {Function} saveTransactionTimeout - Function to save the transaction timeout value * @property {Function} saveRecipientAddress - Function to save the recipient address + * @property {Function} saveFederationAddress - Function to save the federation address * @property {Function} saveSelectedTokenId - Function to save the selected token ID - * @property {Function} saveSelectedCollectibleDetails - Function to save the selected collectilbe details + * @property {Function} saveSelectedCollectibleDetails - Function to save the selected collectible details * @property {Function} resetSettings - Function to reset all settings to default values */ interface TransactionSettingsState { transactionMemo: string; + transactionMemoType: string; transactionFee: string; transactionTimeout: number; recipientAddress: string; + federationAddress: string; selectedTokenId: string; selectedCollectibleDetails: { collectionAddress: string; @@ -51,9 +59,11 @@ interface TransactionSettingsState { feeManuallyChanged: boolean; saveMemo: (memo: string) => void; + saveMemoType: (memoType: string) => void; saveTransactionFee: (fee: string) => void; saveTransactionTimeout: (timeout: number) => void; saveRecipientAddress: (address: string) => void; + saveFederationAddress: (address: string) => void; saveSelectedTokenId: (tokenId: string) => void; saveSelectedCollectibleDetails: (collectibleDetails: { collectionAddress: string; @@ -78,6 +88,12 @@ export const useTransactionSettingsStore = create( */ saveMemo: (transactionMemo) => set({ transactionMemo }), + /** + * Saves the memo type for a transaction (from federation record or user selection) + * @param {string} memoType - "text" | "id" | "hash" | "" + */ + saveMemoType: (memoType) => set({ transactionMemoType: memoType }), + /** * Saves the transaction fee amount * @param {string} fee - The fee amount to save (in XLM) @@ -93,10 +109,16 @@ export const useTransactionSettingsStore = create( /** * Saves the recipient address for the transaction - * @param {string} address - The recipient address + * @param {string} address - The recipient address (resolved G... public key) */ saveRecipientAddress: (address) => set({ recipientAddress: address }), + /** + * Saves the original federation address for display purposes + * @param {string} address - The federation address (user*domain) + */ + saveFederationAddress: (address) => set({ federationAddress: address }), + /** * Saves the selected token ID for the transaction * @param {string} tokenId - The token ID diff --git a/src/helpers/stellar.ts b/src/helpers/stellar.ts index b78c790e2..f96a1e48e 100644 --- a/src/helpers/stellar.ts +++ b/src/helpers/stellar.ts @@ -15,9 +15,69 @@ import { isContractId } from "helpers/soroban"; * @param address The address to check * @returns True if the address is a federation address */ +const hasInvalidFederationChars = (value: string): boolean => + value.split("").some((char) => char === "@" || char === "*" || char <= " "); + export const isFederationAddress = (address: string): boolean => { - const federationAddressRegex = /^[^*@]+\*[^*@]+(\.[^*@]+)+$/; - return federationAddressRegex.test(address); + if (!address) { + return false; + } + + // Reject non-ASCII to prevent homoglyph spoofing (SEP-0002 addresses are ASCII-only) + if (address.split("").some((char) => char.charCodeAt(0) > 127)) { + return false; + } + + const separatorIndex = address.indexOf("*"); + + if ( + separatorIndex <= 0 || + separatorIndex !== address.lastIndexOf("*") || + separatorIndex === address.length - 1 + ) { + return false; + } + + const localPart = address.slice(0, separatorIndex); + const domainPart = address.slice(separatorIndex + 1); + + if ( + hasInvalidFederationChars(localPart) || + hasInvalidFederationChars(domainPart) + ) { + return false; + } + + const domainLabels = domainPart.split("."); + + return ( + domainLabels.length > 1 && + domainLabels.every( + (label) => label.length > 0 && !hasInvalidFederationChars(label), + ) + ); +}; + +/** + * Truncates a long federation address for display. + * Keeps the local part up to maxLocal chars and the domain up to maxDomain chars. + * e.g. "averylongusername*averylongdomain.com" → "averylon…*averylon…" + */ +export const truncateFedAddress = ( + address: string, + maxLocal = 10, + maxDomain = 12, +): string => { + if (!address) return address; + const starIdx = address.indexOf("*"); + if (starIdx === -1) return address; + const local = address.slice(0, starIdx); + const domain = address.slice(starIdx + 1); + const truncLocal = + local.length > maxLocal ? `${local.slice(0, maxLocal)}…` : local; + const truncDomain = + domain.length > maxDomain ? `${domain.slice(0, maxDomain)}…` : domain; + return `${truncLocal}*${truncDomain}`; }; /** diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 7a5aa7572..25075c429 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -1099,7 +1099,8 @@ "transactionTimeoutRequired": "Transaction timeout is required", "networkRequired": "Network is required", "minimumXlmForNewAccount": "Minimum of 1 XLM required to create a new account", - "invalidDecimals": "Invalid or missing decimals for custom token" + "invalidDecimals": "Invalid or missing decimals for custom token", + "invalidFederationMemo": "The federation server returned an invalid memo. Please verify the address and try again." } }, "welcomeBanner": { diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index dc60bc174..d7edbc4a3 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -1100,7 +1100,8 @@ "transactionTimeoutRequired": "Tempo limite da transação é obrigatório", "networkRequired": "Rede é obrigatória", "minimumXlmForNewAccount": "Mínimo de 1 XLM necessário para criar uma nova conta", - "invalidDecimals": "Decimais inválidos ou ausentes para token personalizado" + "invalidDecimals": "Decimais inválidos ou ausentes para token personalizado", + "invalidFederationMemo": "O servidor de federação retornou um memo inválido. Por favor, verifique o endereço e tente novamente." } }, "welcomeBanner": { diff --git a/src/navigators/TabNavigator.tsx b/src/navigators/TabNavigator.tsx index 803355663..ea0f21160 100644 --- a/src/navigators/TabNavigator.tsx +++ b/src/navigators/TabNavigator.tsx @@ -141,8 +141,13 @@ export const TabNavigator = () => { + - {discoverEnabled && (