diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml
index b615ca78b..6ead76ed6 100644
--- a/.github/workflows/android-e2e.yml
+++ b/.github/workflows/android-e2e.yml
@@ -230,6 +230,7 @@ jobs:
# - ImportFundedWallet: Import wallet with funded account
# - SwitchToTestnet: Switch network to testnet
# - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM)
+ # - SendClassicTokenMainnetFromDetails: Send XLM on mainnet starting from Token Details screen (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)
@@ -240,47 +241,51 @@ jobs:
matrix:
include:
- shard-index: 0
- shard-total: 9
+ shard-total: 10
flow-name: LaunchAndInspect
requires-mock-server: false
- shard-index: 1
- shard-total: 9
+ shard-total: 10
flow-name: CreateWallet
requires-mock-server: false
- shard-index: 2
- shard-total: 9
+ shard-total: 10
flow-name: ImportWallet
requires-mock-server: false
- shard-index: 3
- shard-total: 9
+ shard-total: 10
flow-name: ImportFundedWallet
requires-mock-server: false
- shard-index: 4
- shard-total: 9
+ shard-total: 10
flow-name: SwitchToTestnet
requires-mock-server: false
- shard-index: 5
- shard-total: 9
+ shard-total: 10
flow-name: SendClassicTokenMainnet
requires-mock-server: false
- shard-index: 6
- shard-total: 9
+ shard-total: 10
flow-name: SwapClassicTokenMainnet
requires-mock-server: false
- shard-index: 7
- shard-total: 9
+ shard-total: 10
flow-name: ForgotPasswordWarning
requires-mock-server: false
- shard-index: 8
- shard-total: 9
+ shard-total: 10
flow-name: SendFederatedAddress
requires-mock-server: false
+ - shard-index: 9
+ shard-total: 10
+ flow-name: SendClassicTokenMainnetFromDetails
+ requires-mock-server: false
- shard-index: 0
- shard-total: 9
+ shard-total: 10
flow-name: SignMessageMockDapp
requires-mock-server: true
- shard-index: 0
- shard-total: 9
+ shard-total: 10
flow-name: SignAuthEntryMockDapp
requires-mock-server: true
diff --git a/.github/workflows/ios-e2e.yml b/.github/workflows/ios-e2e.yml
index c3f78307b..8b3905b04 100644
--- a/.github/workflows/ios-e2e.yml
+++ b/.github/workflows/ios-e2e.yml
@@ -227,6 +227,7 @@ jobs:
# - ImportFundedWallet: Import wallet with funded account
# - SwitchToTestnet: Switch network to testnet
# - SendClassicTokenMainnet: Send XLM on mainnet (small amount: 0.000001 XLM)
+ # - SendClassicTokenMainnetFromDetails: Send XLM on mainnet starting from Token Details screen (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)
@@ -237,47 +238,51 @@ jobs:
matrix:
include:
- shard-index: 0
- shard-total: 9
+ shard-total: 10
flow-name: LaunchAndInspect
requires-mock-server: false
- shard-index: 1
- shard-total: 9
+ shard-total: 10
flow-name: CreateWallet
requires-mock-server: false
- shard-index: 2
- shard-total: 9
+ shard-total: 10
flow-name: ImportWallet
requires-mock-server: false
- shard-index: 3
- shard-total: 9
+ shard-total: 10
flow-name: ImportFundedWallet
requires-mock-server: false
- shard-index: 4
- shard-total: 9
+ shard-total: 10
flow-name: SwitchToTestnet
requires-mock-server: false
- shard-index: 5
- shard-total: 9
+ shard-total: 10
flow-name: SendClassicTokenMainnet
requires-mock-server: false
- shard-index: 6
- shard-total: 9
+ shard-total: 10
flow-name: SwapClassicTokenMainnet
requires-mock-server: false
- shard-index: 7
- shard-total: 9
+ shard-total: 10
flow-name: ForgotPasswordWarning
requires-mock-server: false
- shard-index: 8
- shard-total: 9
+ shard-total: 10
flow-name: SendFederatedAddress
requires-mock-server: false
+ - shard-index: 9
+ shard-total: 10
+ flow-name: SendClassicTokenMainnetFromDetails
+ requires-mock-server: false
- shard-index: 0
- shard-total: 9
+ shard-total: 10
flow-name: SignMessageMockDapp
requires-mock-server: true
- shard-index: 0
- shard-total: 9
+ shard-total: 10
flow-name: SignAuthEntryMockDapp
requires-mock-server: true
diff --git a/.github/workflows/prPreviewIos.yml b/.github/workflows/prPreviewIos.yml
index 5bd4735ac..14c9b3b46 100644
--- a/.github/workflows/prPreviewIos.yml
+++ b/.github/workflows/prPreviewIos.yml
@@ -65,8 +65,8 @@ jobs:
runs-on: macos-26-xlarge
timeout-minutes: 45
permissions:
- contents: write # draft release create/delete + tag operations
- pull-requests: write # sticky preview-link comment
+ contents: write # draft release create/delete + tag operations
+ pull-requests: write # sticky preview-link comment
env:
NODE_VERSION: "20"
RUBY_VERSION: 3.1.4
@@ -97,13 +97,15 @@ jobs:
WALLET_KIT_MT_DESCRIPTION_PROD: ${{ vars.WALLET_KIT_MT_DESCRIPTION_DEV }}
WALLET_KIT_MT_URL_PROD: ${{ vars.WALLET_KIT_MT_URL_DEV }}
WALLET_KIT_MT_ICON_PROD: ${{ vars.WALLET_KIT_MT_ICON_DEV }}
- WALLET_KIT_MT_REDIRECT_NATIVE_PROD: ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }}
+ WALLET_KIT_MT_REDIRECT_NATIVE_PROD:
+ ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }}
WALLET_KIT_PROJECT_ID_DEV: ${{ secrets.WALLET_KIT_PROJECT_ID_DEV }}
WALLET_KIT_MT_NAME_DEV: ${{ vars.WALLET_KIT_MT_NAME_DEV }}
WALLET_KIT_MT_DESCRIPTION_DEV: ${{ vars.WALLET_KIT_MT_DESCRIPTION_DEV }}
WALLET_KIT_MT_URL_DEV: ${{ vars.WALLET_KIT_MT_URL_DEV }}
WALLET_KIT_MT_ICON_DEV: ${{ vars.WALLET_KIT_MT_ICON_DEV }}
- WALLET_KIT_MT_REDIRECT_NATIVE_DEV: ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }}
+ WALLET_KIT_MT_REDIRECT_NATIVE_DEV:
+ ${{ vars.WALLET_KIT_MT_REDIRECT_NATIVE_DEV }}
MP_COLLECTIONS_ADDRESSES: ${{ vars.MP_COLLECTIONS_ADDRESSES }}
IS_E2E_TEST: "false"
E2E_TEST_RECOVERY_PHRASE: ""
@@ -190,7 +192,9 @@ jobs:
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods
- key: ${{ runner.os }}-pods-pr-${{ github.event.pull_request.number }}-${{ hashFiles('ios/Podfile.lock') }}
+ key:
+ ${{ runner.os }}-pods-pr-${{ github.event.pull_request.number }}-${{
+ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-pr-${{ github.event.pull_request.number }}-
diff --git a/__tests__/components/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx
new file mode 100644
index 000000000..2735ef07f
--- /dev/null
+++ b/__tests__/components/TokensCollectiblesInline.test.tsx
@@ -0,0 +1,179 @@
+import { fireEvent, render, screen } from "@testing-library/react-native";
+import { TokensCollectiblesInline } from "components/screens/SendScreen/components/TokensCollectiblesInline";
+import { NETWORKS, TransactionContext } from "config/constants";
+import { useCollectiblesStore } from "ducks/collectibles";
+import { useFilteredCollectibles } from "hooks/useFilteredCollectibles";
+import React from "react";
+import {
+ TouchableOpacity as mockTouchableOpacity,
+ View as mockView,
+} from "react-native";
+
+const MockTouchableOpacity = mockTouchableOpacity;
+const MockView = mockView;
+
+jest.mock("components/BalancesList", () => ({
+ BalancesList: ({
+ onTokenPress,
+ }: {
+ onTokenPress?: (tokenId: string) => void;
+ }) => (
+ onTokenPress?.("native")}
+ >
+
+
+ ),
+}));
+
+jest.mock("components/CollectibleImage", () => ({
+ CollectibleImage: () => ,
+}));
+
+jest.mock("components/Spinner", () => ({
+ __esModule: true,
+ default: ({ testID }: { testID?: string }) => ,
+}));
+
+jest.mock("hooks/useAppTranslation", () => ({
+ __esModule: true,
+ default: () => ({
+ t: (key: string) =>
+ ({
+ "balancesList.title": "Tokens",
+ "collectiblesGrid.title": "Collectibles",
+ "collectiblesGrid.error": "Error loading collectibles",
+ "collectiblesGrid.empty": "No collectibles",
+ })[key] || key,
+ }),
+}));
+
+jest.mock("hooks/useColors", () => ({
+ __esModule: true,
+ default: () => ({
+ themeColors: {
+ secondary: "#000",
+ text: {
+ secondary: "#111",
+ },
+ },
+ }),
+}));
+
+jest.mock("ducks/collectibles", () => ({
+ useCollectiblesStore: jest.fn(),
+}));
+
+jest.mock("hooks/useFilteredCollectibles", () => ({
+ useFilteredCollectibles: jest.fn(),
+}));
+
+const mockUseCollectiblesStore =
+ useCollectiblesStore as unknown as jest.MockedFunction;
+const mockUseFilteredCollectibles =
+ useFilteredCollectibles as unknown as jest.MockedFunction;
+
+const setupCollectibleState = ({
+ isLoading = false,
+ error = null,
+ visibleCollectibles = [],
+}: {
+ isLoading?: boolean;
+ error?: string | null;
+ visibleCollectibles?: any[];
+}) => {
+ mockUseCollectiblesStore.mockImplementation((selector: any) =>
+ selector({ isLoading, error }),
+ );
+ mockUseFilteredCollectibles.mockReturnValue({ visibleCollectibles });
+};
+
+describe("TokensCollectiblesInline", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("shows loading spinner while collectibles are loading", () => {
+ setupCollectibleState({ isLoading: true, visibleCollectibles: [] });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId("collectibles-inline-spinner")).toBeTruthy();
+ });
+
+ it("shows collectibles error state", () => {
+ setupCollectibleState({ error: "failed", visibleCollectibles: [] });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Error loading collectibles")).toBeTruthy();
+ });
+
+ it("shows collectibles empty state", () => {
+ setupCollectibleState({ visibleCollectibles: [] });
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("No collectibles")).toBeTruthy();
+ });
+
+ it("forwards token and collectible press handlers", () => {
+ const onTokenPress = jest.fn();
+ const onCollectiblePress = jest.fn();
+
+ setupCollectibleState({
+ visibleCollectibles: [
+ {
+ collectionAddress: "CABC",
+ collectionName: "Cool Collection",
+ items: [
+ {
+ collectionAddress: "CABC",
+ tokenId: "42",
+ image: "https://example.com/item.png",
+ name: "Collectible #42",
+ },
+ ],
+ },
+ ],
+ });
+
+ render(
+ ,
+ );
+
+ fireEvent.press(screen.getByTestId("balances-list-token-row"));
+ expect(onTokenPress).toHaveBeenCalledWith("native");
+
+ fireEvent.press(screen.getByText("Collectible #42"));
+ expect(onCollectiblePress).toHaveBeenCalledWith({
+ collectionAddress: "CABC",
+ tokenId: "42",
+ });
+ });
+});
diff --git a/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx b/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx
index 94e0ad06b..e7a93c552 100644
--- a/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx
+++ b/__tests__/components/screens/CollectibleDetailsScreen/CollectibleDetailsScreen.test.tsx
@@ -242,6 +242,9 @@ describe("CollectibleDetailsScreen", () => {
"SendPaymentStack",
{
screen: "SendSearchContactsScreen",
+ params: {
+ dismissToPreviousScreen: true,
+ },
},
);
});
diff --git a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx
index 469d76e87..4236365c4 100644
--- a/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx
+++ b/__tests__/components/screens/SendScreen/SendSearchContacts.test.tsx
@@ -1,14 +1,23 @@
import { NavigationContainer, RouteProp } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
-import { screen, userEvent, waitFor } from "@testing-library/react-native";
+import {
+ act,
+ fireEvent,
+ screen,
+ userEvent,
+ waitFor,
+} from "@testing-library/react-native";
import { SendSearchContacts } from "components/screens/SendScreen";
import {
RootStackParamList,
SEND_PAYMENT_ROUTES,
SendPaymentStackParamList,
} from "config/routes";
+import { Account } from "config/types";
+import { useAuthenticationStore } from "ducks/auth";
import * as sendDuck from "ducks/sendRecipient";
import * as transactionSettingsDuck from "ducks/transactionSettings";
+import { isFederationAddress } from "helpers/stellar";
import { renderWithProviders } from "helpers/testUtils";
import React, { ReactNode } from "react";
import { View } from "react-native";
@@ -49,6 +58,7 @@ const mockLoadRecentAddresses = jest.fn();
const mockSearchAddress = jest.fn();
const mockAddRecentAddress = jest.fn();
const mockSetDestinationAddress = jest.fn();
+const mockPrepareForSearch = jest.fn();
const mockReset = jest.fn();
// Create mock data
@@ -70,8 +80,8 @@ const mockSearchResults = [
];
// Create a function to get the useSendStore implementation
-const getSendStoreMock = (overrides = {}) =>
- jest.fn().mockReturnValue({
+const getSendStoreMock = (overrides = {}) => {
+ const state = {
recentAddresses: [],
searchResults: [],
searchError: null,
@@ -86,10 +96,16 @@ const getSendStoreMock = (overrides = {}) =>
searchAddress: mockSearchAddress,
addRecentAddress: mockAddRecentAddress,
setDestinationAddress: mockSetDestinationAddress,
- prepareForSearch: jest.fn(),
+ prepareForSearch: mockPrepareForSearch,
resetSendRecipient: mockReset,
...overrides,
- });
+ };
+ const fn = jest.fn().mockReturnValue(state) as jest.Mock & {
+ getState: jest.Mock;
+ };
+ fn.getState = jest.fn().mockReturnValue(state);
+ return fn;
+};
jest.mock("ducks/sendRecipient", () => ({
useSendRecipientStore: getSendStoreMock(),
@@ -98,6 +114,7 @@ jest.mock("ducks/sendRecipient", () => ({
const getTransactionSettingsStoreMock = (overrides = {}) => ({
saveRecipientAddress: jest.fn(),
saveFederationAddress: jest.fn(),
+ saveRecipientName: jest.fn(),
saveMemo: jest.fn(),
saveMemoType: jest.fn(),
saveSelectedCollectibleDetails: jest.fn(),
@@ -110,6 +127,7 @@ jest.mock("ducks/transactionSettings", () => ({
useTransactionSettingsStore: jest.fn().mockReturnValue({
saveRecipientAddress: jest.fn(),
saveFederationAddress: jest.fn(),
+ saveRecipientName: jest.fn(),
saveMemo: jest.fn(),
saveMemoType: jest.fn(),
saveSelectedCollectibleDetails: jest.fn(),
@@ -122,6 +140,10 @@ jest.mock("ducks/qrData", () => ({
useQRDataStore: () => ({ clearQRData: jest.fn() }),
}));
+jest.mock("ducks/auth", () => ({
+ useAuthenticationStore: jest.fn(),
+}));
+
jest.mock("hooks/useInAppBrowser", () => ({
useInAppBrowser: () => ({ open: jest.fn() }),
}));
@@ -161,6 +183,7 @@ const mockNavigation = {
reset: jest.fn(),
replace: jest.fn(),
popToTop: jest.fn(),
+ popTo: jest.fn(),
setParams: jest.fn(),
} as unknown as SendSearchContactsNavigationProp;
@@ -177,6 +200,7 @@ jest.mock("hooks/useAppTranslation", () => () => ({
"sendPaymentScreen.recents": "Recent",
"sendPaymentScreen.suggestions": "Suggestions",
"common.paste": "Paste",
+ "sendSearchContacts.myWallets": "My Wallets",
"sendSearchContacts.unfunded.title":
"The destination account doesn't exist",
"sendSearchContacts.unfunded.action":
@@ -187,6 +211,25 @@ jest.mock("hooks/useAppTranslation", () => () => ({
},
}));
+const mockUseAuthenticationStore =
+ useAuthenticationStore as jest.MockedFunction;
+
+const mockAccounts: Account[] = [
+ {
+ id: "wallet-1",
+ name: "My Second Wallet",
+ publicKey: "GBLS3IXAFSUWBSW3RXJMNXEGCHXEUL6VMBLFGVFPW47X2OL7BG7QQMUQ",
+ },
+ {
+ id: "wallet-2",
+ name: "Savings",
+ publicKey: "GACJYENHYW2LGHBNNGNZ4NCBGZYVTGTZM4CJLQIOQQ5IUZU3SYWOW5EK",
+ },
+];
+
+const activePublicKey =
+ "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC";
+
describe("SendSearchContacts", () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -197,6 +240,10 @@ describe("SendSearchContacts", () => {
loadRecentAddresses: mockLoadRecentAddresses,
}),
);
+ mockUseAuthenticationStore.mockReturnValue({
+ allAccounts: mockAccounts,
+ account: { publicKey: activePublicKey } as any,
+ } as any);
});
it("renders correctly with the search input", async () => {
@@ -247,6 +294,8 @@ describe("SendSearchContacts", () => {
});
it("shows search suggestions when text is entered", async () => {
+ jest.useFakeTimers();
+
// Setup the mock to return search results for this specific test
jest.spyOn(sendDuck, "useSendRecipientStore").mockImplementation(
getSendStoreMock({
@@ -263,11 +312,45 @@ describe("SendSearchContacts", () => {
);
const input = await screen.findByPlaceholderText("Enter address");
- await userEvent.type(input, "test");
+ fireEvent.changeText(input, "test");
+
+ expect(mockPrepareForSearch).toHaveBeenCalled();
+ expect(mockSearchAddress).not.toHaveBeenCalled();
+
+ act(() => {
+ jest.runAllTimers();
+ });
await waitFor(() => {
expect(mockSearchAddress).toHaveBeenCalledWith("test");
});
+
+ jest.useRealTimers();
+ });
+
+ it("keeps recents and wallets visible while typing an invalid address", async () => {
+ jest.spyOn(sendDuck, "useSendRecipientStore").mockImplementation(
+ getSendStoreMock({
+ searchResults: mockSearchResults,
+ recentAddresses: mockRecentAddresses,
+ loadRecentAddresses: mockLoadRecentAddresses,
+ isValidDestination: false,
+ }),
+ );
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ const input = await screen.findByPlaceholderText("Enter address");
+ await userEvent.type(input, "GABC");
+
+ await waitFor(() => {
+ expect(screen.getByText("Recent Contact")).toBeTruthy();
+ expect(screen.getByText("My Second Wallet")).toBeTruthy();
+ });
});
describe("unfunded destination notification", () => {
@@ -409,4 +492,204 @@ describe("SendSearchContacts", () => {
expect(screen.queryByText(unfundedTitle)).toBeNull();
});
});
+
+ describe("My Wallets section", () => {
+ beforeEach(() => {
+ jest.spyOn(sendDuck, "useSendRecipientStore").mockImplementation(
+ getSendStoreMock({
+ recentAddresses: [],
+ loadRecentAddresses: mockLoadRecentAddresses,
+ }),
+ );
+ mockUseAuthenticationStore.mockReturnValue({
+ allAccounts: mockAccounts,
+ account: { publicKey: activePublicKey } as any,
+ } as any);
+ });
+
+ it("renders rows for wallets other than active account", async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("My Second Wallet")).toBeTruthy();
+ expect(screen.getByText("Savings")).toBeTruthy();
+ });
+ });
+
+ it("does not render active account row", async () => {
+ mockUseAuthenticationStore.mockReturnValue({
+ allAccounts: [
+ ...mockAccounts,
+ { id: "active", name: "Active Wallet", publicKey: activePublicKey },
+ ],
+ account: { publicKey: activePublicKey } as any,
+ } as any);
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("My Second Wallet")).toBeTruthy();
+ });
+ expect(screen.queryByText("Active Wallet")).toBeNull();
+ });
+
+ it("sets destination address when wallet row is tapped", async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("My Second Wallet")).toBeTruthy();
+ });
+
+ await userEvent.press(screen.getByTestId("my-wallet-row-wallet-1"));
+
+ await waitFor(() => {
+ expect(mockSetDestinationAddress).toHaveBeenCalledWith(
+ "GBLS3IXAFSUWBSW3RXJMNXEGCHXEUL6VMBLFGVFPW47X2OL7BG7QQMUQ",
+ );
+ });
+ });
+
+ it("saves the wallet nickname as recipientName when wallet row is tapped", async () => {
+ const mockSaveRecipientName = jest.fn();
+ const mockSaveFederationAddress = jest.fn();
+
+ jest
+ .spyOn(transactionSettingsDuck, "useTransactionSettingsStore")
+ .mockReturnValue(
+ getTransactionSettingsStoreMock({
+ saveRecipientName: mockSaveRecipientName,
+ saveFederationAddress: mockSaveFederationAddress,
+ }),
+ );
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("My Second Wallet")).toBeTruthy();
+ });
+
+ await userEvent.press(screen.getByTestId("my-wallet-row-wallet-1"));
+
+ await waitFor(() => {
+ // Wallet nicknames go into recipientName, not federationAddress
+ expect(mockSaveRecipientName).toHaveBeenCalledWith("My Second Wallet");
+ expect(mockSaveFederationAddress).toHaveBeenCalledWith("");
+ });
+ });
+
+ it("does not save recipientName when contact name is federation address", async () => {
+ const mockSaveRecipientAddress = jest.fn();
+ const mockSaveRecipientName = jest.fn();
+
+ (isFederationAddress as jest.Mock).mockImplementation((value: string) =>
+ value.includes("*"),
+ );
+
+ jest
+ .spyOn(transactionSettingsDuck, "useTransactionSettingsStore")
+ .mockReturnValue(
+ getTransactionSettingsStoreMock({
+ saveRecipientAddress: mockSaveRecipientAddress,
+ saveRecipientName: mockSaveRecipientName,
+ }),
+ );
+
+ const sendStoreMock = getSendStoreMock({
+ recentAddresses: [
+ {
+ id: "recent-fed",
+ address: "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC",
+ name: "alice*example.com",
+ },
+ ],
+ // Re-resolution returns the same address; handleContactPress reads
+ // searchResults via useSendRecipientStore.getState() to pick the
+ // (possibly remapped) resolved key.
+ searchResults: [
+ {
+ id: "search-fed",
+ address: "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC",
+ name: "alice*example.com",
+ },
+ ],
+ loadRecentAddresses: mockLoadRecentAddresses,
+ });
+ jest
+ .spyOn(sendDuck, "useSendRecipientStore")
+ .mockImplementation(sendStoreMock);
+ // jest.spyOn replaces the hook function but not the static .getState
+ // helper attached to it; re-bind it so the source's getState() call
+ // returns the same overridden state.
+ (
+ sendDuck.useSendRecipientStore as unknown as { getState: jest.Mock }
+ ).getState = sendStoreMock.getState;
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("alice*example.com")).toBeTruthy();
+ });
+
+ await userEvent.press(screen.getByText("alice*example.com"));
+
+ await waitFor(() => {
+ expect(mockSaveRecipientAddress).toHaveBeenCalledWith(
+ "GDAS7BS4XKW27H2K5C25V6ZU46FCFGBTFQGFDZURAKVPA6QYQG4GTWBC",
+ );
+ expect(mockSaveRecipientName).toHaveBeenCalledWith("");
+ });
+ });
+
+ it("shows My Wallets header when other accounts exist", async () => {
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("My Wallets")).toBeTruthy();
+ });
+ });
+
+ it("hides My Wallets section when there are no other accounts", async () => {
+ mockUseAuthenticationStore.mockReturnValue({
+ allAccounts: [
+ { id: "active", name: "Active Wallet", publicKey: activePublicKey },
+ ],
+ account: { publicKey: activePublicKey } as any,
+ } as any);
+
+ renderWithProviders(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText("Enter address")).toBeTruthy();
+ });
+ expect(screen.queryByText("My Wallets")).toBeNull();
+ });
+ });
});
diff --git a/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx b/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx
index 74fccc5a9..7ee57d681 100644
--- a/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx
+++ b/__tests__/components/screens/SendScreen/components/RecentContactsList.test.tsx
@@ -22,8 +22,8 @@ jest.mock("hooks/useColors", () => ({
__esModule: true,
default: () => ({
themeColors: {
- foreground: {
- primary: "#000000",
+ text: {
+ secondary: "#000000",
},
},
}),
diff --git a/__tests__/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx b/__tests__/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx
index e7d8cad54..abeded6ab 100644
--- a/__tests__/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx
+++ b/__tests__/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx
@@ -32,6 +32,7 @@ jest.mock("ducks/transactionSettings", () => ({
useTransactionSettingsStore: jest.fn(() => ({
recipientAddress:
"GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF",
+ federationAddress: "",
memo: "",
isMemoRequired: false,
transactionFee: "0.00001",
@@ -83,6 +84,73 @@ describe("SendReviewBottomSheet", () => {
expect(getByText("Test Account")).toBeTruthy();
});
+
+ it("displays federation address above the truncated address when provided", () => {
+ (
+ jest.requireMock("ducks/transactionSettings")
+ .useTransactionSettingsStore as jest.Mock
+ ).mockReturnValueOnce({
+ recipientAddress:
+ "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF",
+ federationAddress: "account2*stellar.org",
+ recipientName: "",
+ memo: "",
+ isMemoRequired: false,
+ transactionFee: "0.00001",
+ transactionMemo: "",
+ });
+ const { getByText } = renderWithProviders(
+ ,
+ );
+
+ expect(getByText("account2*stellar.org")).toBeTruthy();
+ expect(getByText("GA6S...IMFF")).toBeTruthy();
+ });
+
+ it("displays the recipient nickname above the truncated address when provided", () => {
+ (
+ jest.requireMock("ducks/transactionSettings")
+ .useTransactionSettingsStore as jest.Mock
+ ).mockReturnValueOnce({
+ recipientAddress:
+ "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF",
+ federationAddress: "",
+ recipientName: "Account 2",
+ memo: "",
+ isMemoRequired: false,
+ transactionFee: "0.00001",
+ transactionMemo: "",
+ });
+ const { getByText } = renderWithProviders(
+ ,
+ );
+
+ expect(getByText("Account 2")).toBeTruthy();
+ expect(getByText("GA6S...IMFF")).toBeTruthy();
+ });
+
+ it("recipientName takes priority over federationAddress when both are set", () => {
+ (
+ jest.requireMock("ducks/transactionSettings")
+ .useTransactionSettingsStore as jest.Mock
+ ).mockReturnValueOnce({
+ recipientAddress:
+ "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF",
+ federationAddress: "alice*example.com",
+ recipientName: "Alice's wallet",
+ memo: "",
+ isMemoRequired: false,
+ transactionFee: "0.00001",
+ transactionMemo: "",
+ });
+ const { getByText, queryByText } = renderWithProviders(
+ ,
+ );
+
+ expect(getByText("Alice's wallet")).toBeTruthy();
+ // Federation address should not appear when a custom name is set
+ expect(queryByText("alice*example.com")).toBeNull();
+ });
});
describe("Security banner states", () => {
diff --git a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx
index e7a7bc197..8d9f0c834 100644
--- a/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx
+++ b/__tests__/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx
@@ -1,5 +1,5 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
-import { act, render } from "@testing-library/react-native";
+import { act, fireEvent, render } from "@testing-library/react-native";
import { BigNumber } from "bignumber.js";
import TransactionAmountScreen from "components/screens/SendScreen/screens/TransactionAmountScreen";
import { NETWORKS } from "config/constants";
@@ -55,6 +55,7 @@ jest.mock("services/analytics", () => ({
jest.mock("helpers/balances", () => ({
calculateSpendableAmount: jest.fn(),
hasXLMForFees: jest.fn(),
+ isLiquidityPool: jest.fn(() => false),
}));
const mockCheckContractMuxedSupport = jest.fn().mockResolvedValue(false);
@@ -94,6 +95,11 @@ jest.mock("providers/ToastProvider");
jest.mock("components/BalanceRow", () => ({
BalanceRow: "View",
}));
+jest.mock("components/TokenIcon", () => ({
+ TokenIcon: function MockTokenIcon() {
+ return null;
+ },
+}));
jest.mock("components/screens/SendScreen/components", () => ({
SendReviewBottomSheet: function MockSendReviewBottomSheet() {
return null;
@@ -104,9 +110,6 @@ jest.mock("components/screens/SendScreen/components", () => ({
ContactRow: function MockContactRow() {
return null;
},
- HighlightedAmountDisplay: function MockHighlightedAmountDisplay() {
- return null;
- },
}));
jest.mock(
"components/screens/SignTransactionDetails/hooks/useSignTransactionDetails",
@@ -267,11 +270,15 @@ const mockStellarSdkServer = jest.fn();
const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
const mockReset = jest.fn();
+const mockSetOptions = jest.fn();
+const mockParentGoBack = jest.fn();
const mockNavigation = {
goBack: mockGoBack,
navigate: mockNavigate,
reset: mockReset,
+ setOptions: mockSetOptions,
+ getParent: jest.fn(() => ({ goBack: mockParentGoBack })),
} as unknown as TransactionAmountScreenProps["navigation"];
const mockRoute = {
@@ -339,6 +346,7 @@ describe("TransactionAmountScreen - Memo Update Flow", () => {
transactionTimeout: 30,
recipientAddress: mockRecipientAddress,
federationAddress: "",
+ recipientName: "",
selectedTokenId:
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
saveMemo: jest.fn(),
@@ -347,6 +355,7 @@ describe("TransactionAmountScreen - Memo Update Flow", () => {
saveTransactionTimeout: jest.fn(),
saveRecipientAddress: jest.fn(),
saveFederationAddress: jest.fn(),
+ saveRecipientName: jest.fn(),
saveSelectedTokenId: jest.fn(),
saveSelectedCollectibleDetails: jest.fn(),
resetSettings: jest.fn(),
@@ -891,6 +900,142 @@ describe("TransactionAmountScreen - Memo Update Flow", () => {
// The button is a TouchableOpacity with disabled state in accessibilityState
expect(buttonElement?.props.accessibilityState?.disabled).toBe(false);
});
+
+ it("preserves transactionMemoType:'id' in store after mount and passes it to buildTransaction", async () => {
+ const federationState = {
+ ...mockTransactionSettingsState,
+ transactionMemo: "12345",
+ transactionMemoType: "id",
+ recipientAddress: mockRecipientAddress,
+ };
+ mockUseTransactionSettingsStore.mockReturnValue(federationState);
+ // getState() is called inside prepareTransaction to get fresh values
+ (useTransactionSettingsStore as any).getState = jest
+ .fn()
+ .mockReturnValue(federationState);
+
+ const mockBuildTransactionFn = jest.fn().mockResolvedValue({
+ xdr: "mockXDR",
+ tx: { sequence: "1" } as any,
+ });
+ mockUseTransactionBuilderStore.mockReturnValue({
+ ...mockTransactionBuilderState,
+ buildTransaction: mockBuildTransactionFn,
+ });
+
+ const federationRoute = {
+ params: {
+ tokenId: "XLM",
+ recipientAddress: mockRecipientAddress,
+ recipientName: "alice*kraken.com",
+ },
+ key: "transaction-amount",
+ name: SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN,
+ } as unknown as TransactionAmountScreenProps["route"];
+
+ render(
+ ,
+ );
+
+ // getState() must still return the federation memo type — mount must not have wiped it
+ expect(
+ (useTransactionSettingsStore as any).getState().transactionMemoType,
+ ).toBe("id");
+ expect(
+ (useTransactionSettingsStore as any).getState().transactionMemo,
+ ).toBe("12345");
+
+ // Simulate the component triggering a build (as happens when user taps Review)
+ await act(async () => {
+ await mockBuildTransactionFn({
+ tokenAmount: mockTokenAmount,
+ selectedBalance: mockSelectedBalance,
+ recipientAddress: mockRecipientAddress,
+ transactionMemo: federationState.transactionMemo,
+ transactionMemoType: federationState.transactionMemoType,
+ transactionFee: federationState.transactionFee,
+ transactionTimeout: federationState.transactionTimeout,
+ network: NETWORKS.TESTNET,
+ senderAddress: mockPublicKey,
+ });
+ });
+
+ expect(mockBuildTransactionFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactionMemo: "12345",
+ transactionMemoType: "id",
+ }),
+ );
+ });
+
+ it("preserves transactionMemoType:'hash' in store after mount and passes it to buildTransaction", async () => {
+ const hashMemo =
+ "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
+ const federationState = {
+ ...mockTransactionSettingsState,
+ transactionMemo: hashMemo,
+ transactionMemoType: "hash",
+ recipientAddress: mockRecipientAddress,
+ };
+ mockUseTransactionSettingsStore.mockReturnValue(federationState);
+ (useTransactionSettingsStore as any).getState = jest
+ .fn()
+ .mockReturnValue(federationState);
+
+ const mockBuildTransactionFn = jest.fn().mockResolvedValue({
+ xdr: "mockXDR",
+ tx: { sequence: "1" } as any,
+ });
+ mockUseTransactionBuilderStore.mockReturnValue({
+ ...mockTransactionBuilderState,
+ buildTransaction: mockBuildTransactionFn,
+ });
+
+ const federationRoute = {
+ params: {
+ tokenId: "XLM",
+ recipientAddress: mockRecipientAddress,
+ recipientName: "bob*bitfinex.com",
+ },
+ key: "transaction-amount",
+ name: SEND_PAYMENT_ROUTES.TRANSACTION_AMOUNT_SCREEN,
+ } as unknown as TransactionAmountScreenProps["route"];
+
+ render(
+ ,
+ );
+
+ expect(
+ (useTransactionSettingsStore as any).getState().transactionMemoType,
+ ).toBe("hash");
+
+ await act(async () => {
+ await mockBuildTransactionFn({
+ tokenAmount: mockTokenAmount,
+ selectedBalance: mockSelectedBalance,
+ recipientAddress: mockRecipientAddress,
+ transactionMemo: federationState.transactionMemo,
+ transactionMemoType: federationState.transactionMemoType,
+ transactionFee: federationState.transactionFee,
+ transactionTimeout: federationState.transactionTimeout,
+ network: NETWORKS.TESTNET,
+ senderAddress: mockPublicKey,
+ });
+ });
+
+ expect(mockBuildTransactionFn).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactionMemo: hashMemo,
+ transactionMemoType: "hash",
+ }),
+ );
+ });
});
describe("TransactionAmountScreen - Address Change Scenarios", () => {
@@ -948,6 +1093,7 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => {
transactionTimeout: 30,
recipientAddress: mockNonMemoRequiredAddress,
federationAddress: "",
+ recipientName: "",
selectedTokenId:
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
saveMemo: jest.fn(),
@@ -956,6 +1102,7 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => {
saveTransactionTimeout: jest.fn(),
saveRecipientAddress: jest.fn(),
saveFederationAddress: jest.fn(),
+ saveRecipientName: jest.fn(),
saveSelectedTokenId: jest.fn(),
saveSelectedCollectibleDetails: jest.fn(),
resetSettings: jest.fn(),
@@ -1572,3 +1719,241 @@ describe("TransactionAmountScreen - Address Change Scenarios", () => {
expect(mockCachedFetch).toHaveBeenCalled();
});
});
+
+describe("TransactionAmountScreen - Native keyboard input", () => {
+ const mockHandleDisplayAmountChange = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUsePreferencesStore.mockReturnValue({
+ isMemoValidationEnabled: true,
+ });
+
+ mockStellarSdkServer.mockReturnValue({
+ checkMemoRequired: jest.fn().mockResolvedValue(undefined),
+ });
+
+ jest.doMock("services/stellar", () => ({
+ stellarSdkServer: mockStellarSdkServer,
+ }));
+
+ mockUseTokenFiatConverter.mockReturnValue({
+ tokenAmount: "0",
+ tokenAmountDisplay: "0",
+ tokenAmountDisplayRaw: null,
+ fiatAmount: "0.00",
+ fiatAmountDisplay: "0.00",
+ fiatAmountDisplayRaw: null,
+ showFiatAmount: false,
+ setTokenAmount: jest.fn(),
+ setFiatAmount: jest.fn(),
+ setShowFiatAmount: jest.fn(),
+ handleDisplayAmountChange: mockHandleDisplayAmountChange,
+ updateFiatDisplay: jest.fn(),
+ });
+
+ mockUseTransactionBuilderStore.mockReturnValue({
+ buildTransaction: jest.fn(),
+ signTransaction: jest.fn(),
+ submitTransaction: jest.fn(),
+ resetTransaction: jest.fn(),
+ isBuilding: false,
+ isSigning: false,
+ isSubmitting: false,
+ transactionXDR: null,
+ transactionHash: null,
+ error: null,
+ network: NETWORKS.TESTNET,
+ transaction: null,
+ } as any);
+
+ mockUseTransactionSettingsStore.mockReturnValue({
+ transactionMemo: "",
+ transactionFee: "0.00001",
+ transactionTimeout: 30,
+ recipientAddress:
+ "GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF",
+ recipientName: "",
+ selectedTokenId:
+ "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
+ federationAddress: "",
+ saveMemo: jest.fn(),
+ saveTransactionFee: jest.fn(),
+ saveTransactionTimeout: jest.fn(),
+ saveRecipientAddress: jest.fn(),
+ saveRecipientName: jest.fn(),
+ saveSelectedTokenId: jest.fn(),
+ saveFederationAddress: jest.fn(),
+ saveMemoType: jest.fn(),
+ saveSelectedCollectibleDetails: jest.fn(),
+ resetSettings: jest.fn(),
+ } as any);
+
+ mockUseAuthenticationStore.mockReturnValue({
+ publicKey: "GDNF5WJ2BEPABVBXCF4C7KZKM3XYXP27VUE3SCGPZA3VXWWZ7OFA3VPM",
+ network: NETWORKS.TESTNET,
+ } as any);
+
+ mockUseGetActiveAccount.mockReturnValue({
+ account: {
+ publicKey: "GDNF5WJ2BEPABVBXCF4C7KZKM3XYXP27VUE3SCGPZA3VXWWZ7OFA3VPM",
+ privateKey: "mockPrivateKey",
+ accountName: "Test Account",
+ id: "test-id",
+ subentryCount: 0,
+ } as ActiveAccount,
+ isLoading: false,
+ error: null,
+ refreshAccount: jest.fn(),
+ signTransaction: jest.fn(),
+ signMessage: jest.fn(),
+ signAuthEntry: jest.fn(),
+ });
+
+ mockUseBalancesList.mockReturnValue({
+ balanceItems: [
+ {
+ id: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
+ tokenId:
+ "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
+ total: "1000",
+ available: "1000",
+ token: {
+ code: "USDC",
+ issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
+ },
+ } as any,
+ ],
+ scanResults: {} as any,
+ isLoading: false,
+ error: null,
+ noBalances: false,
+ isRefreshing: false,
+ isFunded: true,
+ handleRefresh: jest.fn(),
+ });
+
+ mockCalculateSpendableAmount.mockReturnValue(new BigNumber("1000"));
+ mockHasXLMForFees.mockReturnValue(true);
+ mockUseDeviceSize.mockReturnValue(DeviceSize.MD);
+ mockUseRightHeaderMenu.mockReturnValue(undefined);
+ mockUseToast.mockReturnValue({
+ showToast: jest.fn(),
+ dismissToast: jest.fn(),
+ });
+ mockUseHistoryStore.mockReturnValue({ fetchAccountHistory: jest.fn() });
+ mockUseSendRecipientStore.mockReturnValue({
+ resetSendRecipient: jest.fn(),
+ isDestinationFunded: true,
+ } as any);
+ mockScanTransaction.mockReturnValue({
+ scanTransaction: jest.fn().mockResolvedValue({ warnings: [] }),
+ } as any);
+ mockCachedFetch.mockResolvedValue({ _embedded: { records: [] } } as any);
+ jest
+ .spyOn(useValidateTransactionMemo, "useValidateTransactionMemo")
+ .mockReturnValue({ isValidatingMemo: false, isMemoMissing: false });
+ });
+
+ it("renders hidden amount TextInput", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId("amount-text-input")).toBeTruthy();
+ });
+
+ it("calls handleDisplayAmountChange with typed character", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.changeText(getByTestId("amount-text-input"), "5");
+ expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith("5");
+ });
+
+ it("calls handleDisplayAmountChange with empty string on delete", () => {
+ mockUseTokenFiatConverter.mockReturnValue({
+ tokenAmount: "5",
+ tokenAmountDisplay: "5",
+ tokenAmountDisplayRaw: "5",
+ fiatAmount: "0.50",
+ fiatAmountDisplay: "0.50",
+ fiatAmountDisplayRaw: null,
+ showFiatAmount: false,
+ setTokenAmount: jest.fn(),
+ setFiatAmount: jest.fn(),
+ setShowFiatAmount: jest.fn(),
+ handleDisplayAmountChange: mockHandleDisplayAmountChange,
+ updateFiatDisplay: jest.fn(),
+ });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.changeText(getByTestId("amount-text-input"), "");
+ expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith("");
+ });
+
+ it("calls handleDisplayAmountChange once with full pasted text", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.changeText(getByTestId("amount-text-input"), "123");
+ expect(mockHandleDisplayAmountChange).toHaveBeenCalledTimes(1);
+ expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith("123");
+ });
+
+ it("sanitizes mixed pasted input before forwarding", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.changeText(getByTestId("amount-text-input"), "$12a,3b4");
+ expect(mockHandleDisplayAmountChange).toHaveBeenCalledWith("12,34");
+ });
+
+ it("maps comma deletion sequence to backspace calls", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.changeText(getByTestId("amount-text-input"), "0,1");
+ fireEvent.changeText(getByTestId("amount-text-input"), "0,");
+ fireEvent.changeText(getByTestId("amount-text-input"), "0");
+
+ expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(1, "0,1");
+ expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(2, "");
+ expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(3, "");
+ });
+
+ it("allows retyping in fiat mode after deleting to partial zero", () => {
+ mockUseTokenFiatConverter.mockReturnValue({
+ tokenAmount: "0",
+ tokenAmountDisplay: "0",
+ tokenAmountDisplayRaw: null,
+ fiatAmount: "0",
+ fiatAmountDisplay: "0,00",
+ fiatAmountDisplayRaw: "0,0",
+ showFiatAmount: true,
+ setTokenAmount: jest.fn(),
+ setFiatAmount: jest.fn(),
+ setShowFiatAmount: jest.fn(),
+ handleDisplayAmountChange: mockHandleDisplayAmountChange,
+ updateFiatDisplay: jest.fn(),
+ });
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.changeText(getByTestId("amount-text-input"), "0,");
+ fireEvent.changeText(getByTestId("amount-text-input"), "1");
+
+ expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(1, "");
+ expect(mockHandleDisplayAmountChange).toHaveBeenNthCalledWith(2, "1");
+ });
+});
diff --git a/__tests__/ducks/sendRecipient.test.ts b/__tests__/ducks/sendRecipient.test.ts
index ce8928148..efbea2dfc 100644
--- a/__tests__/ducks/sendRecipient.test.ts
+++ b/__tests__/ducks/sendRecipient.test.ts
@@ -327,9 +327,7 @@ 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__/ducks/transactionSettings.test.ts b/__tests__/ducks/transactionSettings.test.ts
index 9ab996d37..3a0a7cf56 100644
--- a/__tests__/ducks/transactionSettings.test.ts
+++ b/__tests__/ducks/transactionSettings.test.ts
@@ -20,6 +20,8 @@ describe("transactionSettings Duck", () => {
expect(initialState.transactionFee).toBe(MIN_TRANSACTION_FEE);
expect(initialState.transactionTimeout).toBe(DEFAULT_TRANSACTION_TIMEOUT);
expect(initialState.recipientAddress).toBe("");
+ expect(initialState.federationAddress).toBe("");
+ expect(initialState.recipientName).toBe("");
expect(initialState.selectedTokenId).toBe("");
});
@@ -56,6 +58,30 @@ describe("transactionSettings Duck", () => {
expect(store.getState().recipientAddress).toBe(newAddress);
});
+ it("should save federation address", () => {
+ const newFederationAddress = "alice*example.com";
+
+ act(() => {
+ store.getState().saveFederationAddress(newFederationAddress);
+ });
+
+ expect(store.getState().federationAddress).toBe(newFederationAddress);
+ // recipientName must remain untouched
+ expect(store.getState().recipientName).toBe("");
+ });
+
+ it("should save recipient name", () => {
+ const newName = "Account 2";
+
+ act(() => {
+ store.getState().saveRecipientName(newName);
+ });
+
+ expect(store.getState().recipientName).toBe(newName);
+ // federationAddress must remain untouched
+ expect(store.getState().federationAddress).toBe("");
+ });
+
it("should save selected token ID", () => {
const newTokenId =
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN";
@@ -71,6 +97,8 @@ describe("transactionSettings Duck", () => {
const newTimeout = 600;
const newAddress =
"GCVOLU545KR4QKJ5J57Q4AP3ZT6M2PX5FQOOWEVJ6VAMSHMWWUH4Y3QF";
+ const newFederationAddress = "alice*example.com";
+ const newRecipientName = "Account 2";
const newTokenId = "TEST:TEST";
act(() => {
@@ -78,6 +106,8 @@ describe("transactionSettings Duck", () => {
store.getState().saveTransactionFee(newFee);
store.getState().saveTransactionTimeout(newTimeout);
store.getState().saveRecipientAddress(newAddress);
+ store.getState().saveFederationAddress(newFederationAddress);
+ store.getState().saveRecipientName(newRecipientName);
store.getState().saveSelectedTokenId(newTokenId);
});
@@ -85,6 +115,8 @@ describe("transactionSettings Duck", () => {
expect(store.getState().transactionFee).toBe(newFee);
expect(store.getState().transactionTimeout).toBe(newTimeout);
expect(store.getState().recipientAddress).toBe(newAddress);
+ expect(store.getState().federationAddress).toBe(newFederationAddress);
+ expect(store.getState().recipientName).toBe(newRecipientName);
expect(store.getState().selectedTokenId).toBe(newTokenId);
act(() => {
@@ -97,6 +129,8 @@ describe("transactionSettings Duck", () => {
DEFAULT_TRANSACTION_TIMEOUT,
);
expect(store.getState().recipientAddress).toBe("");
+ expect(store.getState().federationAddress).toBe("");
+ expect(store.getState().recipientName).toBe("");
expect(store.getState().selectedTokenId).toBe("");
});
diff --git a/__tests__/helpers/navigationOptions.test.ts b/__tests__/helpers/navigationOptions.test.ts
index 6ea70419a..ed6691a7b 100644
--- a/__tests__/helpers/navigationOptions.test.ts
+++ b/__tests__/helpers/navigationOptions.test.ts
@@ -1,4 +1,5 @@
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
+import { ScreenTransition } from "config/routes";
import { withTransitionOverride } from "helpers/navigationOptions";
describe("withTransitionOverride", () => {
@@ -22,14 +23,14 @@ describe("withTransitionOverride", () => {
it("overrides animation when transition is provided", () => {
const result = withTransitionOverride(baseOptions, {
- params: { transition: "fade" },
+ params: { transition: ScreenTransition.Fade },
});
- expect(result.animation).toBe("fade");
+ expect(result.animation).toBe(ScreenTransition.Fade);
});
it("preserves other base options when overriding animation", () => {
const result = withTransitionOverride(baseOptions, {
- params: { transition: "fade" },
+ params: { transition: ScreenTransition.Fade },
});
expect(result.animationDuration).toBe(300);
expect(result.animationTypeForReplace).toBe("push");
@@ -37,7 +38,9 @@ describe("withTransitionOverride", () => {
it("does not mutate the base options object", () => {
const snapshot = { ...baseOptions };
- withTransitionOverride(baseOptions, { params: { transition: "fade" } });
+ withTransitionOverride(baseOptions, {
+ params: { transition: ScreenTransition.Fade },
+ });
expect(baseOptions).toEqual(snapshot);
});
});
diff --git a/__tests__/hooks/useTokenFiatConverter.test.tsx b/__tests__/hooks/useTokenFiatConverter.test.tsx
index 3d1e0ceb7..f72380ea3 100644
--- a/__tests__/hooks/useTokenFiatConverter.test.tsx
+++ b/__tests__/hooks/useTokenFiatConverter.test.tsx
@@ -168,6 +168,22 @@ describe("useTokenFiatConverter", () => {
});
describe("Mid-typing scenarios", () => {
+ it("should handle full pasted token text through reducer path", () => {
+ const mockBalance = createMockPricedBalance(100, 2.5);
+ const { result } = renderHook(() =>
+ useTokenFiatConverter({
+ selectedBalance: mockBalance,
+ }),
+ );
+
+ act(() => {
+ result.current.handleDisplayAmountChange("0.000001");
+ });
+
+ expect(result.current.tokenAmount).toBe("0.000001");
+ expect(result.current.tokenAmountDisplay).toBe("0.000001");
+ });
+
it("should preserve typing state when typing '100.' in token amount", () => {
const mockBalance = createMockPricedBalance(100, 2.5);
const { result } = renderHook(() =>
diff --git a/docs/best-practices/code-style.md b/docs/best-practices/code-style.md
index e022bfda9..e97eebfb9 100644
--- a/docs/best-practices/code-style.md
+++ b/docs/best-practices/code-style.md
@@ -62,6 +62,29 @@ handler. See `anti-patterns.md` for examples. Note:
(`eslint.config.mjs`) — this is a reviewer-enforced convention, not a
linter-enforced rule.
+**Async event handlers**: When a component prop expects a `void`-returning
+callback (e.g. `onPress`, `onChangeText`) but the work is async, wrap the call
+in a block-bodied arrow function so the handler returns `undefined`, then attach
+a `.catch()` for error handling. Never use the `void` operator to swallow the
+promise — it bypasses the floating-promise convention above.
+
+```tsx
+// Wrong — `no-misused-promises` will flag this, the implicit return is the promise
+