From 9b2dad1a0491835a9b2d60d699752895b492a075 Mon Sep 17 00:00:00 2001 From: devprom6 Date: Fri, 28 Aug 2026 19:57:53 +0100 Subject: [PATCH] fix(wallet): require confirmation before sending Stellar payments SendTokensScreen previously called signAndSubmitPayment / openLobstrForPayment on a single tap with no confirmation step, making irreversible on-chain payments trivially accidental. - Split handleSend into validation + a confirmation Alert (Cancel/Confirm) shown before any signing or submission. - Alert displays a truncated destination (via truncatePublicKey), the full amount, and the asset name (XLM/ECO/USDC). - isSending is set true while the dialog is open so the button is disabled; Cancel resets it to false, Confirm proceeds to confirmSend. - Both the in-app wallet and Lobstr paths show the confirmation. - Adds SendTokensScreen.test.tsx covering: dialog shown with truncated destination/amount/asset, cancel aborts, confirm proceeds (in-app), and Lobstr path confirms before opening the deep link. --- src/__tests__/SendTokensScreen.test.tsx | 238 ++++++++++++++++++++++++ src/screens/SendTokensScreen.tsx | 59 ++++-- 2 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/SendTokensScreen.test.tsx diff --git a/src/__tests__/SendTokensScreen.test.tsx b/src/__tests__/SendTokensScreen.test.tsx new file mode 100644 index 0000000..6003933 --- /dev/null +++ b/src/__tests__/SendTokensScreen.test.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import renderer, { act } from 'react-test-renderer'; +import { Alert, Text, TextInput, TouchableOpacity } from 'react-native'; + +jest.mock('../hooks/useStellarWallet', () => ({ + useStellarWallet: jest.fn(), +})); + +jest.mock('../store/walletStore', () => ({ + useWalletStore: jest.fn(), +})); + +jest.mock('../services/walletVault', () => ({ + getInAppSecret: jest.fn(), +})); + +jest.mock('../services/stellar', () => ({ + signAndSubmitPayment: jest.fn(), + isValidPublicKey: jest.fn(), + isValidAmount: jest.fn(), +})); + +jest.mock('../services/lobstr', () => ({ + openLobstrForPayment: jest.fn(), + LobstrNotInstalledError: class LobstrNotInstalledError extends Error {}, +})); + +jest.mock('../navigation/useAppNavigation', () => ({ + useRootNavigation: () => ({ goBack: jest.fn() }), +})); + +jest.mock('react-native-config', () => ({ + ECO_TOKEN_ASSET_CODE: 'ECO', + ECO_TOKEN_ISSUER: 'GISSUER', + USDC_ISSUER: 'GUSDC', +})); + +import SendTokensScreen from '../screens/SendTokensScreen'; +import { useStellarWallet } from '../hooks/useStellarWallet'; +import { useWalletStore } from '../store/walletStore'; +import { getInAppSecret } from '../services/walletVault'; +import { + signAndSubmitPayment, + isValidPublicKey, + isValidAmount, +} from '../services/stellar'; +import { openLobstrForPayment } from '../services/lobstr'; + +const mockUseStellarWallet = useStellarWallet as jest.Mock; +const mockUseWalletStore = useWalletStore as unknown as jest.Mock; +const mockGetInAppSecret = getInAppSecret as jest.Mock; +const mockSignAndSubmitPayment = signAndSubmitPayment as jest.Mock; +const mockOpenLobstrForPayment = openLobstrForPayment as jest.Mock; +const mockIsValidPublicKey = isValidPublicKey as jest.Mock; +const mockIsValidAmount = isValidAmount as jest.Mock; + +const DEST = 'G' + 'A'.repeat(55); // 56-char Stellar key +const TRUNCATED = 'GAAA...AAAA'; + +let alertSpy: jest.SpyInstance; +let lastAlert: { + title: string; + message?: string; + buttons?: { text: string; onPress?: () => void }[]; +} | null = null; + +interface RenderOptions { + walletType?: string; + hasSecret?: boolean; +} + +function renderScreen({ + walletType = 'inapp', + hasSecret = true, +}: RenderOptions = {}) { + mockUseWalletStore.mockReturnValue({ + publicKey: 'GABC', + walletType, + }); + mockUseStellarWallet.mockReturnValue({ + refreshBalance: jest.fn(), + refreshEcoBalance: jest.fn(), + refreshUsdcBalance: jest.fn(), + }); + mockGetInAppSecret.mockReturnValue(hasSecret ? 'SSECRET' : null); + mockIsValidPublicKey.mockReturnValue(true); + mockIsValidAmount.mockReturnValue(true); + + let tree!: renderer.ReactTestRenderer; + act(() => { + tree = renderer.create(); + }); + return tree; +} + +function findSendButton(root: renderer.ReactTestRenderer['root']) { + const buttons = root.findAllByType(TouchableOpacity); + return buttons.find(btn => { + const texts = btn.findAllByType(Text); + return texts.some( + t => + t.props.children === 'Send' || t.props.children === 'Send via Lobstr', + ); + }); +} + +function setInput( + root: renderer.ReactTestRenderer['root'], + placeholder: string, + value: string, +) { + const inputs = root.findAllByType(TextInput); + const input = inputs.find(i => i.props.placeholder === placeholder); + act(() => { + input?.props.onChangeText(value); + }); +} + +function pressSend(root: renderer.ReactTestRenderer['root']) { + const btn = findSendButton(root); + act(() => { + btn?.props.onPress(); + }); +} + +function confirmAlert() { + const confirm = lastAlert?.buttons?.find(b => b.text === 'Confirm'); + act(() => { + confirm?.onPress?.(); + }); +} + +function cancelAlert() { + const cancel = lastAlert?.buttons?.find(b => b.text === 'Cancel'); + act(() => { + cancel?.onPress?.(); + }); +} + +describe('SendTokensScreen confirmation', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + lastAlert = null; + alertSpy = jest + .spyOn(Alert, 'alert') + .mockImplementation((title, message, buttons) => { + lastAlert = { + title: title as string, + message: message as string | undefined, + buttons: buttons as { text: string; onPress?: () => void }[], + }; + return 0; + }); + }); + + afterEach(() => { + alertSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('shows a confirmation dialog with truncated destination, amount, and asset', () => { + const tree = renderScreen(); + setInput(tree.root, 'G...', DEST); + setInput(tree.root, '0.00', '12.5'); + pressSend(tree.root); + + expect(alertSpy).toHaveBeenCalledTimes(1); + expect(lastAlert?.title).toBe('Confirm Payment'); + expect(lastAlert?.message).toContain('12.5'); + expect(lastAlert?.message).toContain('XLM'); + // Destination must be truncated, not the full 56-char key. + expect(lastAlert?.message).toContain(TRUNCATED); + expect(lastAlert?.message).not.toContain(DEST); + const labels = (lastAlert?.buttons ?? []).map(b => b.text); + expect(labels).toContain('Cancel'); + expect(labels).toContain('Confirm'); + }); + + it('does not send when the user cancels', () => { + const tree = renderScreen(); + setInput(tree.root, 'G...', DEST); + setInput(tree.root, '0.00', '12.5'); + pressSend(tree.root); + + cancelAlert(); + + expect(mockSignAndSubmitPayment).not.toHaveBeenCalled(); + // Button is re-enabled after cancel (isSending reset to false). + const btn = findSendButton(tree.root); + expect(btn?.props.disabled).toBe(false); + }); + + it('proceeds to sign and submit when the user confirms (in-app wallet)', async () => { + mockSignAndSubmitPayment.mockResolvedValue({ hash: 'ABCDEF1234567890' }); + const tree = renderScreen(); + setInput(tree.root, 'G...', DEST); + setInput(tree.root, '0.00', '12.5'); + pressSend(tree.root); + + await act(async () => { + confirmAlert(); + await Promise.resolve(); + }); + + expect(mockSignAndSubmitPayment).toHaveBeenCalledTimes(1); + expect(mockSignAndSubmitPayment).toHaveBeenCalledWith( + expect.objectContaining({ + destination: DEST, + amount: '12.5', + }), + ); + }); + + it('shows the confirmation before opening Lobstr (Lobstr path)', async () => { + mockOpenLobstrForPayment.mockResolvedValue(undefined); + const tree = renderScreen({ walletType: 'lobstr' }); + setInput(tree.root, 'G...', DEST); + setInput(tree.root, '0.00', '12.5'); + pressSend(tree.root); + + // Confirmation must appear before the Lobstr deep link fires. + expect(mockOpenLobstrForPayment).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + confirmAlert(); + await Promise.resolve(); + }); + + expect(mockOpenLobstrForPayment).toHaveBeenCalledTimes(1); + expect(mockOpenLobstrForPayment).toHaveBeenCalledWith( + DEST, + '12.5', + undefined, + ); + }); +}); diff --git a/src/screens/SendTokensScreen.tsx b/src/screens/SendTokensScreen.tsx index 576f95d..2d10508 100644 --- a/src/screens/SendTokensScreen.tsx +++ b/src/screens/SendTokensScreen.tsx @@ -23,6 +23,7 @@ import { openLobstrForPayment, LobstrNotInstalledError, } from '../services/lobstr'; +import { truncatePublicKey } from '../utils/validation'; import { useRootNavigation } from '../navigation/useAppNavigation'; type AssetChoice = 'native' | 'eco' | 'usdc'; @@ -39,21 +40,12 @@ export default function SendTokensScreen() { const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); - const handleSend = useCallback(async () => { - setError(null); + // Performs the actual signing/submission. Only called after the user + // confirms the payment in the confirmation dialog. + const confirmSend = useCallback(async () => { if (!publicKey) { return; } - if (!isValidPublicKey(destination.trim())) { - setError('Enter a valid Stellar public key (G...)'); - return; - } - if (!isValidAmount(amount)) { - setError('Enter an amount greater than 0'); - return; - } - - setIsSending(true); try { let assetParam: { code: string; issuer: string } | undefined; if (asset === 'eco') { @@ -148,6 +140,49 @@ export default function SendTokensScreen() { refreshUsdcBalance, ]); + // Validates inputs, then shows an explicit confirmation dialog before any + // irreversible signing/submission happens. A single tap must not send funds. + const handleSend = useCallback(() => { + setError(null); + if (!publicKey) { + return; + } + if (!isValidPublicKey(destination.trim())) { + setError('Enter a valid Stellar public key (G...)'); + return; + } + if (!isValidAmount(amount)) { + setError('Enter an amount greater than 0'); + return; + } + + const assetName = asset === 'native' ? 'XLM' : asset.toUpperCase(); + const truncatedDest = truncatePublicKey(destination.trim()); + + // Block the send button while the confirmation dialog is open so a second + // tap cannot queue another payment. + setIsSending(true); + Alert.alert( + 'Confirm Payment', + `Send ${amount.trim()} ${assetName} to ${truncatedDest}?`, + [ + { + text: 'Cancel', + style: 'cancel', + onPress: () => setIsSending(false), + }, + { + text: 'Confirm', + style: 'default', + onPress: () => { + void confirmSend(); + }, + }, + ], + { cancelable: false }, + ); + }, [publicKey, destination, amount, asset, confirmSend]); + const isLobstr = walletType === 'lobstr'; return (