From 4d2597d193a55965916ef58ca4101cddf1108f20 Mon Sep 17 00:00:00 2001 From: "S.Zviahelskyi" Date: Mon, 1 Dec 2025 19:17:10 +0200 Subject: [PATCH 01/11] refactor: replace custom mnemonic validation with bip39.validateMnemonic --- src/app/wallet-setup/import-wallet.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/app/wallet-setup/import-wallet.tsx b/src/app/wallet-setup/import-wallet.tsx index c699cd4..11b454a 100644 --- a/src/app/wallet-setup/import-wallet.tsx +++ b/src/app/wallet-setup/import-wallet.tsx @@ -1,4 +1,5 @@ import { SeedPhrase } from '@/components/SeedPhrase'; +import * as bip39 from 'bip39'; import * as Clipboard from 'expo-clipboard'; import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; import { ChevronLeft, Download, FileText, ScanText } from 'lucide-react-native'; @@ -72,22 +73,7 @@ export default function ImportWalletScreen() { }; const validateSeedPhrase = (phrase: string): boolean => { - const words = phrase - .trim() - .split(' ') - .filter(word => word.length > 0); - - // Check if we have exactly 12 or 24 words - if (words.length !== 12 && words.length !== 24) { - return false; - } - - // Basic word validation - each word should be at least 3 characters - const validWords = words.every( - word => word.length >= 3 && /^[a-z]+$/.test(word) // only lowercase letters - ); - - return validWords; + return bip39.validateMnemonic(phrase, bip39.wordlists.english); }; const handleImportWallet = () => { From 32e327a7c4c2b8838630a4275ee9440304fd943e Mon Sep 17 00:00:00 2001 From: "S.Zviahelskyi" Date: Thu, 4 Dec 2025 00:43:17 +0200 Subject: [PATCH 02/11] chore(tests): setup Jest and testing utilities --- babel.config.js | 6 +++++ eslint.config.js | 11 ++++++++ jest.config.js | 13 ++++++++++ package.json | 10 ++++++- src/__tests__/example.test.ts | 5 ++++ .../__tests__/import-wallet.test.tsx | 19 ++++++++++++++ src/tests/jest.setup.js | 26 +++++++++++++++++++ src/tests/utils/renderWithTheme.tsx | 11 ++++++++ tsconfig.json | 6 +++-- 9 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 babel.config.js create mode 100644 jest.config.js create mode 100644 src/__tests__/example.test.ts create mode 100644 src/app/wallet-setup/__tests__/import-wallet.test.tsx create mode 100644 src/tests/jest.setup.js create mode 100644 src/tests/utils/renderWithTheme.tsx diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..2fa5a52 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo', '@babel/preset-typescript'], + }; +}; diff --git a/eslint.config.js b/eslint.config.js index fbb44f1..3c8a061 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,4 +15,15 @@ module.exports = defineConfig([ 'prettier/prettier': 'error', }, }, + { + files: [ + '**/__tests__/**/*.ts', + '**/__tests__/**/*.tsx', + '**/jest.setup.js', + '**/jest.setup.ts', + ], + env: { + jest: true, + }, + }, ]); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..6061d7d --- /dev/null +++ b/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + preset: 'jest-expo', + setupFilesAfterEnv: ['/src/tests/jest.setup.js'], + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + }, + transformIgnorePatterns: [ + // Ignore all node_modules except packages that use ESM or need to be transpiled + // This allows Jest to process modern Expo/React Native packages correctly + 'node_modules/(?!(react-native|@react-native|expo|@expo|@tetherto/wdk-uikit-react-native|expo-modules-core)/)', + ], +}; diff --git a/package.json b/package.json index 2875c8d..c8fafe1 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "format:check": "prettier --check .", "typecheck": "tsc --noEmit", "prebuild": "expo prebuild", - "prebuild:clean": "expo prebuild --clean" + "prebuild:clean": "expo prebuild --clean", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "dependencies": { "@craftzdog/react-native-buffer": "^6.1.0", @@ -86,12 +89,17 @@ "stream-http": "^3.2.0" }, "devDependencies": { + "@testing-library/jest-native": "^5.4.3", + "@testing-library/react-native": "^13.3.3", "@types/b4a": "^1.6.5", + "@types/jest": "29.5.14", "@types/react": "~19.1.0", "eslint": "^9.25.0", "eslint-config-expo": "~10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", + "jest": "~29.7.0", + "jest-expo": "~54.0.13", "prettier": "^3.6.2", "react-native-svg-transformer": "^1.5.1", "sharp-cli": "^5.2.0", diff --git a/src/__tests__/example.test.ts b/src/__tests__/example.test.ts new file mode 100644 index 0000000..682de85 --- /dev/null +++ b/src/__tests__/example.test.ts @@ -0,0 +1,5 @@ +describe('Example test suite', () => { + it('should pass trivially', () => { + expect(true).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/app/wallet-setup/__tests__/import-wallet.test.tsx b/src/app/wallet-setup/__tests__/import-wallet.test.tsx new file mode 100644 index 0000000..49dcf4a --- /dev/null +++ b/src/app/wallet-setup/__tests__/import-wallet.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react-native'; +import ImportWalletScreen from '../import-wallet'; +import { Alert } from 'react-native'; +import { renderWithTheme } from '@/tests/utils/renderWithTheme'; + +test('shows alert if Import Wallet clicked with empty words', () => { + renderWithTheme(); + const importButton = screen.getByText('Import Wallet'); + + fireEvent.press(importButton); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Incomplete', + 'Please fill in all 12 words of your secret phrase', + [{ text: 'OK' }] + ); +}); + diff --git a/src/tests/jest.setup.js b/src/tests/jest.setup.js new file mode 100644 index 0000000..17181de --- /dev/null +++ b/src/tests/jest.setup.js @@ -0,0 +1,26 @@ +import '@testing-library/jest-native/extend-expect'; + +import { Alert } from 'react-native'; + +// Mock expo-clipboard so tests don't break when calling clipboard functions +jest.mock('expo-clipboard', () => ({ + getStringAsync: jest.fn(), +})); + +// Mock react-native-safe-area-context to provide default insets for tests +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})); + +// Mock expo-router to prevent real navigation during tests +jest.mock('expo-router', () => ({ + useRouter: () => ({ push: jest.fn(), back: jest.fn() }), +})); + +// Mock sonner-native toast functions to avoid actual UI rendering +jest.mock('sonner-native', () => ({ + toast: { success: jest.fn(), error: jest.fn() }, +})); + +// Mock Alert to prevent native alerts from breaking tests +jest.spyOn(Alert, 'alert').mockImplementation(jest.fn()); \ No newline at end of file diff --git a/src/tests/utils/renderWithTheme.tsx b/src/tests/utils/renderWithTheme.tsx new file mode 100644 index 0000000..173620a --- /dev/null +++ b/src/tests/utils/renderWithTheme.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { render, RenderAPI } from '@testing-library/react-native'; +import { ThemeProvider } from '@tetherto/wdk-uikit-react-native'; + +/** + * Utility to render components wrapped with ThemeProvider + * @param ui - the component to render + */ +export const renderWithTheme = (ui: React.ReactElement): RenderAPI => { + return render({ui}); +}; diff --git a/tsconfig.json b/tsconfig.json index ec96c84..db476f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,11 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, + "types": ["jest", "node"], "paths": { - "@/*": ["./src/*"] - } + "@/*": ["./src/*"], + "@tests/*": ["tests/*"] + }, }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] } From aa704c606a56f03a574e82e730969c16ce8d110a Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Thu, 4 Dec 2025 01:31:09 +0200 Subject: [PATCH 03/11] Add address validation for existing networks --- package.json | 4 + src/app/send/details.tsx | 71 +++++++-- src/app/send/select-token.tsx | 2 +- src/app/wallet.tsx | 12 +- src/enum/chain-id.ts | 10 ++ src/enum/index.ts | 1 + src/types/multicoin-address-validator.d.ts | 17 ++ src/utils/address-validation.ts | 171 +++++++++++++++++++++ 8 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 src/enum/chain-id.ts create mode 100644 src/enum/index.ts create mode 100644 src/types/multicoin-address-validator.d.ts create mode 100644 src/utils/address-validation.ts diff --git a/package.json b/package.json index 2875c8d..4761055 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,13 @@ "@tetherto/wdk-pricing-provider": "^1.0.0-beta.1", "@tetherto/wdk-react-native-provider": "^1.0.0-beta.3", "@tetherto/wdk-uikit-react-native": "^1.0.0-beta.2", + "@ton/core": "^0.62.0", "b4a": "^1.7.2", "bip39": "^3.1.0", "browserify-zlib": "^0.2.0", + "bs58": "^6.0.0", "decimal.js": "^10.6.0", + "ethers": "^6.15.0", "events": "^3.3.0", "expo": "~54.0.8", "expo-build-properties": "~1.0.9", @@ -54,6 +57,7 @@ "http2-wrapper": "^2.2.1", "https-browserify": "^1.0.0", "lucide-react-native": "^0.544.0", + "multicoin-address-validator": "^0.5.26", "nice-grpc-web": "^3.3.8", "path-browserify": "^1.0.1", "process": "^0.11.10", diff --git a/src/app/send/details.tsx b/src/app/send/details.tsx index fb7b837..11be7a9 100644 --- a/src/app/send/details.tsx +++ b/src/app/send/details.tsx @@ -1,4 +1,9 @@ -import { AssetTicker, useWallet, WDKService } from '@tetherto/wdk-react-native-provider'; +import { + AssetTicker, + useWallet, + WDKService, + NetworkType, +} from '@tetherto/wdk-react-native-provider'; import { CryptoAddressInput } from '@tetherto/wdk-uikit-react-native'; import { useLocalSearchParams } from 'expo-router'; import { useDebouncedNavigation } from '@/hooks/use-debounced-navigation'; @@ -34,6 +39,7 @@ import formatTokenAmount from '@/utils/format-token-amount'; import formatUSDValue from '@/utils/format-usd-value'; import Header from '@/components/header'; import { toast } from 'sonner-native'; +import { validateAddressByNetwork } from '@/utils/address-validation'; export default function SendDetailsScreen() { const insets = useSafeAreaInsets(); @@ -57,11 +63,12 @@ export default function SendDetailsScreen() { tokenBalance: string; tokenBalanceUSD: string; networkName: string; - networkId: string; + networkId: NetworkType; scannedAddress?: string; }; const [recipientAddress, setRecipientAddress] = useState(''); + const [addressError, setAddressError] = useState(''); const [amount, setAmount] = useState(''); const [inputMode, setInputMode] = useState<'token' | 'fiat'>('token'); const [showConfirmation, setShowConfirmation] = useState(false); @@ -80,12 +87,33 @@ export default function SendDetailsScreen() { const [isAmountInputFocused, setIsAmountInputFocused] = useState(false); const keyboard = useKeyboard(); + const handleRecipientAddressChange = useCallback( + (value: string) => { + setRecipientAddress(value); + + const trimmed = value.trim(); + if (!trimmed) { + setAddressError(''); + return; + } + + const result = validateAddressByNetwork(networkId as NetworkType, trimmed); + + if (!result.valid) { + setAddressError(result.error); + } else { + setAddressError(''); + } + }, + [networkId] + ); + // Handle scanned address from QR scanner useEffect(() => { if (scannedAddress) { - setRecipientAddress(scannedAddress); + handleRecipientAddressChange(scannedAddress); } - }, [scannedAddress]); + }, [scannedAddress, handleRecipientAddressChange]); // Auto-scroll when keyboard opens useEffect(() => { @@ -135,7 +163,7 @@ export default function SendDetailsScreen() { async (showLoading = true, amountValue?: string) => { if (showLoading) { setIsLoadingGasEstimate(true); - setGasEstimate(prev => ({ ...prev, error: undefined })); + setGasEstimate((prev) => ({ ...prev, error: undefined })); } // Convert amount to token value if provided @@ -213,8 +241,8 @@ export default function SendDetailsScreen() { ]); const handlePasteAddress = useCallback(() => { - Clipboard.getStringAsync().then(setRecipientAddress); - }, []); + Clipboard.getStringAsync().then(handleRecipientAddressChange); + }, [handleRecipientAddressChange]); const handleUseMax = useCallback(() => { const numericBalance = parseFloat(tokenBalance.replace(/,/g, '')); @@ -242,7 +270,7 @@ export default function SendDetailsScreen() { }, [inputMode, tokenBalance, tokenBalanceUSD, gasEstimate.fee, tokenPrice]); const toggleInputMode = useCallback(() => { - setInputMode(prev => (prev === 'token' ? 'fiat' : 'token')); + setInputMode((prev) => (prev === 'token' ? 'fiat' : 'token')); setAmount(''); setAmountError(null); }, []); @@ -313,6 +341,12 @@ export default function SendDetailsScreen() { Alert.alert('Error', 'Please enter a recipient address'); return false; } + + if (addressError) { + Alert.alert('Error', addressError); + return false; + } + if (!amount || parseFloat(amount) <= 0) { Alert.alert('Error', 'Please enter a valid amount'); return false; @@ -328,7 +362,7 @@ export default function SendDetailsScreen() { } return true; - }, [recipientAddress, amount, tokenBalance, inputMode]); + }, [recipientAddress, addressError, amount, tokenBalance, inputMode]); const handleSend = useCallback(async () => { if (!validateTransaction()) { @@ -451,9 +485,10 @@ export default function SendDetailsScreen() { @@ -545,11 +580,23 @@ export default function SendDetailsScreen() { (); - balances.list.forEach(balance => { + balances.list.forEach((balance) => { const current = balanceMap.get(balance.denomination) || { totalBalance: 0 }; balanceMap.set(balance.denomination, { totalBalance: current.totalBalance + parseFloat(balance.value), diff --git a/src/app/wallet.tsx b/src/app/wallet.tsx index 1a8cf06..e362329 100644 --- a/src/app/wallet.tsx +++ b/src/app/wallet.tsx @@ -89,7 +89,7 @@ export default function WalletScreen() { const map = new Map(); // Sum up balances by denomination across all networks - balances.list.forEach(balance => { + balances.list.forEach((balance) => { const current = map.get(balance.denomination) || { totalBalance: 0 }; map.set(balance.denomination, { totalBalance: current.totalBalance + parseFloat(balance.value), @@ -117,7 +117,7 @@ export default function WalletScreen() { return (await Promise.all(promises)) .filter(Boolean) - .filter(asset => asset && asset.balance > 0) // Only show tokens with positive balance + .filter((asset) => asset && asset.balance > 0) // Only show tokens with positive balance .sort((a, b) => (b?.usdValue || 0) - (a?.usdValue || 0)); // Sort by USD value descending }; @@ -163,7 +163,7 @@ export default function WalletScreen() { // Get the wallet's own addresses for comparison const walletAddresses = addresses - ? Object.values(addresses).map(addr => addr?.toLowerCase()) + ? Object.values(addresses).map((addr) => addr?.toLowerCase()) : []; const result = await Promise.all( @@ -351,7 +351,7 @@ export default function WalletScreen() { {/* Portfolio */} {aggregatedBalances.length > 0 ? ( - aggregatedBalances.map(asset => { + aggregatedBalances.map((asset) => { if (!asset) return null; return ( @@ -405,7 +405,7 @@ export default function WalletScreen() { - {suggestions.map(suggestion => ( + {suggestions.map((suggestion) => ( { Linking.openURL(suggestion.url); @@ -434,7 +434,7 @@ export default function WalletScreen() { {transactions.length > 0 ? ( - transactions.map(tx => ( + transactions.map((tx) => ( diff --git a/src/enum/chain-id.ts b/src/enum/chain-id.ts new file mode 100644 index 0000000..1828ecb --- /dev/null +++ b/src/enum/chain-id.ts @@ -0,0 +1,10 @@ +export enum ChainId { + ETHEREUM = 'ethereum', + POLYGON = 'polygon', + ARBITRUM = 'arbitrum', + BITCOIN = 'bitcoin', + TON = 'ton', + TRON = 'tron', + SOLANA = 'solana', + UNKNOWN = 'unknown', +} diff --git a/src/enum/index.ts b/src/enum/index.ts new file mode 100644 index 0000000..81ee1ec --- /dev/null +++ b/src/enum/index.ts @@ -0,0 +1 @@ +export * from './chain-id'; diff --git a/src/types/multicoin-address-validator.d.ts b/src/types/multicoin-address-validator.d.ts new file mode 100644 index 0000000..dd535b6 --- /dev/null +++ b/src/types/multicoin-address-validator.d.ts @@ -0,0 +1,17 @@ +declare module 'multicoin-address-validator' { + interface ValidateOptions { + networkType?: 'prod' | 'test'; + } + + interface Validator { + validate( + address: string, + currency: string, + options?: ValidateOptions + ): boolean; + } + + const WAValidator: Validator; + + export = WAValidator; +} diff --git a/src/utils/address-validation.ts b/src/utils/address-validation.ts new file mode 100644 index 0000000..1c5fdf7 --- /dev/null +++ b/src/utils/address-validation.ts @@ -0,0 +1,171 @@ +import { networkConfigs } from '@/config/networks'; +import { NetworkType } from '@tetherto/wdk-react-native-provider'; +import { Address } from '@ton/core'; +import { ethers } from 'ethers'; +import { ChainId } from '@/enum'; +import WAValidator from 'multicoin-address-validator'; +import bs58 from 'bs58'; + +export type AddressValidationResult = { valid: true } | { valid: false; error: string }; + +const NETWORK_TO_CHAIN_MAP: Record = Object.fromEntries( + Object.values(networkConfigs) + .map((config) => config.id) + .filter((id): id is ChainId => Object.values(ChainId).includes(id as ChainId)) + .map((id) => [id, id as ChainId]) +); + +const VALIDATORS: Record AddressValidationResult> = { + [ChainId.ETHEREUM]: validateEvmAddress, + [ChainId.POLYGON]: validateEvmAddress, + [ChainId.ARBITRUM]: validateEvmAddress, + + [ChainId.BITCOIN]: validateBitcoinAddress, + [ChainId.TON]: validateTonAddress, + [ChainId.TRON]: validateTronAddress, + [ChainId.SOLANA]: validateSolanaAddress, + + [ChainId.UNKNOWN]: () => ({ + valid: false, + error: 'Address validation is not supported for this network yet.', + }), +}; + +export function getChainIdFromNetwork(networkId: NetworkType): ChainId | 'unknown' { + const config = networkConfigs[networkId]; + if (!config) return 'unknown'; + + return NETWORK_TO_CHAIN_MAP[config.id] ?? 'unknown'; +} + +/** + * EVM: Ethereum / Polygon / Arbitrum + */ +function validateEvmAddress(address: string): AddressValidationResult { + const trimmed = address.trim(); + + if (!trimmed) { + return { valid: false, error: 'Recipient address is required' }; + } + + const isValid = ethers.isAddress(trimmed); + + if (!isValid) { + return { + valid: false, + error: 'Invalid EVM address. Please check the address and try again.', + }; + } + + return { valid: true }; +} + +/** + * Bitcoin (SegWit) + */ +function validateBitcoinAddress(address: string): AddressValidationResult { + const trimmed = address.trim(); + + if (!trimmed) { + return { valid: false, error: 'Recipient address is required' }; + } + + const isValid = WAValidator.validate(trimmed, 'btc'); + + if (!isValid) { + return { + valid: false, + error: 'Invalid Bitcoin address. Please check the address format.', + }; + } + + return { valid: true }; +} + +/** + * TON: @ton/core Address.parse + */ +function validateTonAddress(address: string): AddressValidationResult { + const trimmed = address.trim(); + + if (!trimmed) { + return { valid: false, error: 'Recipient address is required' }; + } + + try { + Address.parse(trimmed); + return { valid: true }; + } catch { + return { + valid: false, + error: 'Invalid TON address. Please check the address and try again.', + }; + } +} + +/** + * Tron + */ +function validateTronAddress(address: string): AddressValidationResult { + const trimmed = address.trim(); + + if (!trimmed) { + return { valid: false, error: 'Recipient address is required' }; + } + + const isValid = WAValidator.validate(trimmed, 'trx'); + + if (!isValid) { + return { + valid: false, + error: 'Invalid Tron address. Tron addresses usually start with "T".', + }; + } + + return { valid: true }; +} + +/** + * Solana + */ +function validateSolanaAddress(address: string): AddressValidationResult { + const trimmed = address.trim(); + + if (!trimmed) { + return { valid: false, error: 'Recipient address is required' }; + } + + try { + const decoded = bs58.decode(trimmed); + + // Solana public key = 32 bytes + if (decoded.length !== 32) { + return { + valid: false, + error: 'Invalid Solana address. Please check the address and try again.', + }; + } + + return { valid: true }; + } catch { + return { + valid: false, + error: 'Invalid Solana address. Please check the address and try again.', + }; + } +} + +export function validateAddressByNetwork( + networkId: NetworkType, + address: string +): AddressValidationResult { + const trimmed = address.trim(); + if (!trimmed) { + return { valid: false, error: 'Recipient address is required' }; + } + + const chainId = getChainIdFromNetwork(networkId); + const validator = VALIDATORS[chainId]; + + return validator(trimmed); +} From 67d0522ce1e7d28968b821044f6fd13ff26f5c74 Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Thu, 4 Dec 2025 15:59:25 +0200 Subject: [PATCH 04/11] refactor the code according the PR feedback --- package.json | 2 - src/app/send/details.tsx | 4 +- src/config/networks.ts | 16 ++++ src/enum/chain-id.ts | 10 --- src/enum/index.ts | 1 - ...ss-validation.ts => address-validators.ts} | 74 ++++++------------- 6 files changed, 41 insertions(+), 66 deletions(-) delete mode 100644 src/enum/chain-id.ts delete mode 100644 src/enum/index.ts rename src/utils/{address-validation.ts => address-validators.ts} (55%) diff --git a/package.json b/package.json index 4761055..8995582 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,7 @@ "b4a": "^1.7.2", "bip39": "^3.1.0", "browserify-zlib": "^0.2.0", - "bs58": "^6.0.0", "decimal.js": "^10.6.0", - "ethers": "^6.15.0", "events": "^3.3.0", "expo": "~54.0.8", "expo-build-properties": "~1.0.9", diff --git a/src/app/send/details.tsx b/src/app/send/details.tsx index 11be7a9..1e76135 100644 --- a/src/app/send/details.tsx +++ b/src/app/send/details.tsx @@ -39,7 +39,7 @@ import formatTokenAmount from '@/utils/format-token-amount'; import formatUSDValue from '@/utils/format-usd-value'; import Header from '@/components/header'; import { toast } from 'sonner-native'; -import { validateAddressByNetwork } from '@/utils/address-validation'; +import { validateAddressByNetwork } from '@/utils/address-validators'; export default function SendDetailsScreen() { const insets = useSafeAreaInsets(); @@ -97,7 +97,7 @@ export default function SendDetailsScreen() { return; } - const result = validateAddressByNetwork(networkId as NetworkType, trimmed); + const result = validateAddressByNetwork(networkId, trimmed); if (!result.valid) { setAddressError(result.error); diff --git a/src/config/networks.ts b/src/config/networks.ts index 1ae6c16..9200101 100644 --- a/src/config/networks.ts +++ b/src/config/networks.ts @@ -1,4 +1,12 @@ import { NetworkType } from '@tetherto/wdk-react-native-provider'; +import { + validateEvmAddress, + validateBitcoinAddress, + validateTonAddress, + validateTronAddress, + validateSolanaAddress, + AddressValidator, +} from '@/utils/address-validators'; export interface Network { id: string; @@ -7,6 +15,7 @@ export interface Network { gasColor: string; icon: string | any; color: string; + addressValidator?: AddressValidator; } export const networkConfigs: Record = { @@ -17,6 +26,7 @@ export const networkConfigs: Record = { gasColor: '#FF3B30', icon: require('../../assets/images/chains/ethereum-eth-logo.png'), color: '#627EEA', + addressValidator: validateEvmAddress, }, [NetworkType.POLYGON]: { id: 'polygon', @@ -25,6 +35,7 @@ export const networkConfigs: Record = { gasColor: '#34C759', icon: require('../../assets/images/chains/polygon-matic-logo.png'), color: '#8247E5', + addressValidator: validateEvmAddress, }, [NetworkType.ARBITRUM]: { id: 'arbitrum', @@ -33,6 +44,7 @@ export const networkConfigs: Record = { gasColor: '#FF9500', icon: require('../../assets/images/chains/arbitrum-arb-logo.png'), color: '#28A0F0', + addressValidator: validateEvmAddress, }, [NetworkType.TON]: { id: 'ton', @@ -41,6 +53,7 @@ export const networkConfigs: Record = { gasColor: '#34C759', icon: require('../../assets/images/chains/ton-logo.png'), color: '#0088CC', + addressValidator: validateTonAddress, }, [NetworkType.TRON]: { id: 'tron', @@ -49,6 +62,7 @@ export const networkConfigs: Record = { gasColor: '#34C759', icon: require('../../assets/images/chains/tron-trx-logo.png'), color: '#FF060A', + addressValidator: validateTronAddress, }, [NetworkType.SOLANA]: { id: 'solana', @@ -57,6 +71,7 @@ export const networkConfigs: Record = { gasColor: '#34C759', icon: require('../../assets/images/chains/solana-sol-logo.png'), color: '#9945FF', + addressValidator: validateSolanaAddress, }, [NetworkType.SEGWIT]: { id: 'bitcoin', @@ -65,6 +80,7 @@ export const networkConfigs: Record = { gasColor: '#FF9500', icon: require('../../assets/images/chains/bitcoin-btc-logo.png'), color: '#F7931A', + addressValidator: validateBitcoinAddress, }, [NetworkType.LIGHTNING]: { id: 'lightning', diff --git a/src/enum/chain-id.ts b/src/enum/chain-id.ts deleted file mode 100644 index 1828ecb..0000000 --- a/src/enum/chain-id.ts +++ /dev/null @@ -1,10 +0,0 @@ -export enum ChainId { - ETHEREUM = 'ethereum', - POLYGON = 'polygon', - ARBITRUM = 'arbitrum', - BITCOIN = 'bitcoin', - TON = 'ton', - TRON = 'tron', - SOLANA = 'solana', - UNKNOWN = 'unknown', -} diff --git a/src/enum/index.ts b/src/enum/index.ts deleted file mode 100644 index 81ee1ec..0000000 --- a/src/enum/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './chain-id'; diff --git a/src/utils/address-validation.ts b/src/utils/address-validators.ts similarity index 55% rename from src/utils/address-validation.ts rename to src/utils/address-validators.ts index 1c5fdf7..b276aae 100644 --- a/src/utils/address-validation.ts +++ b/src/utils/address-validators.ts @@ -1,54 +1,28 @@ import { networkConfigs } from '@/config/networks'; import { NetworkType } from '@tetherto/wdk-react-native-provider'; import { Address } from '@ton/core'; -import { ethers } from 'ethers'; -import { ChainId } from '@/enum'; import WAValidator from 'multicoin-address-validator'; -import bs58 from 'bs58'; export type AddressValidationResult = { valid: true } | { valid: false; error: string }; +export type AddressValidator = (address: string) => AddressValidationResult; -const NETWORK_TO_CHAIN_MAP: Record = Object.fromEntries( - Object.values(networkConfigs) - .map((config) => config.id) - .filter((id): id is ChainId => Object.values(ChainId).includes(id as ChainId)) - .map((id) => [id, id as ChainId]) -); - -const VALIDATORS: Record AddressValidationResult> = { - [ChainId.ETHEREUM]: validateEvmAddress, - [ChainId.POLYGON]: validateEvmAddress, - [ChainId.ARBITRUM]: validateEvmAddress, - - [ChainId.BITCOIN]: validateBitcoinAddress, - [ChainId.TON]: validateTonAddress, - [ChainId.TRON]: validateTronAddress, - [ChainId.SOLANA]: validateSolanaAddress, - - [ChainId.UNKNOWN]: () => ({ - valid: false, - error: 'Address validation is not supported for this network yet.', - }), -}; - -export function getChainIdFromNetwork(networkId: NetworkType): ChainId | 'unknown' { - const config = networkConfigs[networkId]; - if (!config) return 'unknown'; - - return NETWORK_TO_CHAIN_MAP[config.id] ?? 'unknown'; +export function getAddressValidatorForNetwork( + networkId: NetworkType +): AddressValidator | undefined { + return networkConfigs[networkId]?.addressValidator; } /** * EVM: Ethereum / Polygon / Arbitrum */ -function validateEvmAddress(address: string): AddressValidationResult { +export function validateEvmAddress(address: string): AddressValidationResult { const trimmed = address.trim(); if (!trimmed) { return { valid: false, error: 'Recipient address is required' }; } - const isValid = ethers.isAddress(trimmed); + const isValid = WAValidator.validate(address, 'eth'); if (!isValid) { return { @@ -63,7 +37,7 @@ function validateEvmAddress(address: string): AddressValidationResult { /** * Bitcoin (SegWit) */ -function validateBitcoinAddress(address: string): AddressValidationResult { +export function validateBitcoinAddress(address: string): AddressValidationResult { const trimmed = address.trim(); if (!trimmed) { @@ -85,7 +59,7 @@ function validateBitcoinAddress(address: string): AddressValidationResult { /** * TON: @ton/core Address.parse */ -function validateTonAddress(address: string): AddressValidationResult { +export function validateTonAddress(address: string): AddressValidationResult { const trimmed = address.trim(); if (!trimmed) { @@ -106,7 +80,7 @@ function validateTonAddress(address: string): AddressValidationResult { /** * Tron */ -function validateTronAddress(address: string): AddressValidationResult { +export function validateTronAddress(address: string): AddressValidationResult { const trimmed = address.trim(); if (!trimmed) { @@ -128,31 +102,23 @@ function validateTronAddress(address: string): AddressValidationResult { /** * Solana */ -function validateSolanaAddress(address: string): AddressValidationResult { +export function validateSolanaAddress(address: string): AddressValidationResult { const trimmed = address.trim(); if (!trimmed) { return { valid: false, error: 'Recipient address is required' }; } - try { - const decoded = bs58.decode(trimmed); - - // Solana public key = 32 bytes - if (decoded.length !== 32) { - return { - valid: false, - error: 'Invalid Solana address. Please check the address and try again.', - }; - } + const isValidFormat = WAValidator.validate(trimmed, 'sol'); - return { valid: true }; - } catch { + if (!isValidFormat) { return { valid: false, error: 'Invalid Solana address. Please check the address and try again.', }; } + + return { valid: true }; } export function validateAddressByNetwork( @@ -164,8 +130,14 @@ export function validateAddressByNetwork( return { valid: false, error: 'Recipient address is required' }; } - const chainId = getChainIdFromNetwork(networkId); - const validator = VALIDATORS[chainId]; + const validator = getAddressValidatorForNetwork(networkId); + + if (!validator) { + return { + valid: false, + error: 'Address validation is not supported for this network.', + }; + } return validator(trimmed); } From ac45ab2e08121f35cadff6b7bddf3bbbb0ce2857 Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Thu, 4 Dec 2025 21:34:55 +0200 Subject: [PATCH 05/11] delete redundant code --- src/utils/address-validators.ts | 38 ++++----------------------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/src/utils/address-validators.ts b/src/utils/address-validators.ts index b276aae..d19ce29 100644 --- a/src/utils/address-validators.ts +++ b/src/utils/address-validators.ts @@ -16,12 +16,6 @@ export function getAddressValidatorForNetwork( * EVM: Ethereum / Polygon / Arbitrum */ export function validateEvmAddress(address: string): AddressValidationResult { - const trimmed = address.trim(); - - if (!trimmed) { - return { valid: false, error: 'Recipient address is required' }; - } - const isValid = WAValidator.validate(address, 'eth'); if (!isValid) { @@ -38,13 +32,7 @@ export function validateEvmAddress(address: string): AddressValidationResult { * Bitcoin (SegWit) */ export function validateBitcoinAddress(address: string): AddressValidationResult { - const trimmed = address.trim(); - - if (!trimmed) { - return { valid: false, error: 'Recipient address is required' }; - } - - const isValid = WAValidator.validate(trimmed, 'btc'); + const isValid = WAValidator.validate(address, 'btc'); if (!isValid) { return { @@ -60,14 +48,8 @@ export function validateBitcoinAddress(address: string): AddressValidationResult * TON: @ton/core Address.parse */ export function validateTonAddress(address: string): AddressValidationResult { - const trimmed = address.trim(); - - if (!trimmed) { - return { valid: false, error: 'Recipient address is required' }; - } - try { - Address.parse(trimmed); + Address.parse(address); return { valid: true }; } catch { return { @@ -81,13 +63,7 @@ export function validateTonAddress(address: string): AddressValidationResult { * Tron */ export function validateTronAddress(address: string): AddressValidationResult { - const trimmed = address.trim(); - - if (!trimmed) { - return { valid: false, error: 'Recipient address is required' }; - } - - const isValid = WAValidator.validate(trimmed, 'trx'); + const isValid = WAValidator.validate(address, 'trx'); if (!isValid) { return { @@ -103,13 +79,7 @@ export function validateTronAddress(address: string): AddressValidationResult { * Solana */ export function validateSolanaAddress(address: string): AddressValidationResult { - const trimmed = address.trim(); - - if (!trimmed) { - return { valid: false, error: 'Recipient address is required' }; - } - - const isValidFormat = WAValidator.validate(trimmed, 'sol'); + const isValidFormat = WAValidator.validate(address, 'sol'); if (!isValidFormat) { return { From e095b49c0601e6551c55747821d92331dcc87409 Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Thu, 4 Dec 2025 22:37:03 +0200 Subject: [PATCH 06/11] extract send button disabled logic into reusable variable --- src/app/send/details.tsx | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/src/app/send/details.tsx b/src/app/send/details.tsx index 1e76135..654f1a3 100644 --- a/src/app/send/details.tsx +++ b/src/app/send/details.tsx @@ -450,6 +450,9 @@ export default function SendDetailsScreen() { return tokenId.toLowerCase() !== 'btc' && gasEstimate.fee === undefined; }, [tokenId, gasEstimate.fee]); + const isSendDisabled = + !!amountError || !!addressError || !amount || !recipientAddress || sendingTransaction; + return ( <> {sendingTransaction ? 'Sending...' : 'Send'} From 1054cb9b4a86a2f405661217523a0f76d87bbf58 Mon Sep 17 00:00:00 2001 From: "S.Zviahelskyi" Date: Fri, 5 Dec 2025 11:57:12 +0200 Subject: [PATCH 07/11] refactor(tests): simplify Jest config and add unit tests for formatAmount --- jest.config.js | 5 --- package.json | 2 - .../__tests__/import-wallet.test.tsx | 19 ---------- src/tests/jest.setup.js | 28 +------------- src/tests/utils/renderWithTheme.tsx | 11 ------ src/utils/__tests__/format-amount.test.ts | 38 +++++++++++++++++++ 6 files changed, 40 insertions(+), 63 deletions(-) delete mode 100644 src/app/wallet-setup/__tests__/import-wallet.test.tsx delete mode 100644 src/tests/utils/renderWithTheme.tsx create mode 100644 src/utils/__tests__/format-amount.test.ts diff --git a/jest.config.js b/jest.config.js index 6061d7d..b329eb2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,9 +5,4 @@ module.exports = { transform: { '^.+\\.[tj]sx?$': 'babel-jest', }, - transformIgnorePatterns: [ - // Ignore all node_modules except packages that use ESM or need to be transpiled - // This allows Jest to process modern Expo/React Native packages correctly - 'node_modules/(?!(react-native|@react-native|expo|@expo|@tetherto/wdk-uikit-react-native|expo-modules-core)/)', - ], }; diff --git a/package.json b/package.json index c8fafe1..ac8fa5e 100644 --- a/package.json +++ b/package.json @@ -89,8 +89,6 @@ "stream-http": "^3.2.0" }, "devDependencies": { - "@testing-library/jest-native": "^5.4.3", - "@testing-library/react-native": "^13.3.3", "@types/b4a": "^1.6.5", "@types/jest": "29.5.14", "@types/react": "~19.1.0", diff --git a/src/app/wallet-setup/__tests__/import-wallet.test.tsx b/src/app/wallet-setup/__tests__/import-wallet.test.tsx deleted file mode 100644 index 49dcf4a..0000000 --- a/src/app/wallet-setup/__tests__/import-wallet.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react-native'; -import ImportWalletScreen from '../import-wallet'; -import { Alert } from 'react-native'; -import { renderWithTheme } from '@/tests/utils/renderWithTheme'; - -test('shows alert if Import Wallet clicked with empty words', () => { - renderWithTheme(); - const importButton = screen.getByText('Import Wallet'); - - fireEvent.press(importButton); - - expect(Alert.alert).toHaveBeenCalledWith( - 'Incomplete', - 'Please fill in all 12 words of your secret phrase', - [{ text: 'OK' }] - ); -}); - diff --git a/src/tests/jest.setup.js b/src/tests/jest.setup.js index 17181de..3e1e6b8 100644 --- a/src/tests/jest.setup.js +++ b/src/tests/jest.setup.js @@ -1,26 +1,2 @@ -import '@testing-library/jest-native/extend-expect'; - -import { Alert } from 'react-native'; - -// Mock expo-clipboard so tests don't break when calling clipboard functions -jest.mock('expo-clipboard', () => ({ - getStringAsync: jest.fn(), -})); - -// Mock react-native-safe-area-context to provide default insets for tests -jest.mock('react-native-safe-area-context', () => ({ - useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), -})); - -// Mock expo-router to prevent real navigation during tests -jest.mock('expo-router', () => ({ - useRouter: () => ({ push: jest.fn(), back: jest.fn() }), -})); - -// Mock sonner-native toast functions to avoid actual UI rendering -jest.mock('sonner-native', () => ({ - toast: { success: jest.fn(), error: jest.fn() }, -})); - -// Mock Alert to prevent native alerts from breaking tests -jest.spyOn(Alert, 'alert').mockImplementation(jest.fn()); \ No newline at end of file +// This file is reserved for custom Jest setup if needed in the future. +// Add additional configuration, mocks, or global settings here. \ No newline at end of file diff --git a/src/tests/utils/renderWithTheme.tsx b/src/tests/utils/renderWithTheme.tsx deleted file mode 100644 index 173620a..0000000 --- a/src/tests/utils/renderWithTheme.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { render, RenderAPI } from '@testing-library/react-native'; -import { ThemeProvider } from '@tetherto/wdk-uikit-react-native'; - -/** - * Utility to render components wrapped with ThemeProvider - * @param ui - the component to render - */ -export const renderWithTheme = (ui: React.ReactElement): RenderAPI => { - return render({ui}); -}; diff --git a/src/utils/__tests__/format-amount.test.ts b/src/utils/__tests__/format-amount.test.ts new file mode 100644 index 0000000..9a46d39 --- /dev/null +++ b/src/utils/__tests__/format-amount.test.ts @@ -0,0 +1,38 @@ +import formatAmount from '../format-amount'; + +describe('formatAmount', () => { + test('formats a number with default 2 fraction digits', () => { + expect(formatAmount(1234.5)).toBe('1,234.50'); + }); + + test('formats a number with a custom minimumFractionDigits', () => { + expect( + formatAmount(10, { minimumFractionDigits: 4, maximumFractionDigits: 4 }) + ).toBe('10.0000'); + }); + + test('formats a number with a custom maximumFractionDigits', () => { + expect(formatAmount(3.14159, { maximumFractionDigits: 3 })).toBe('3.142'); + }); + + test('formats a number with both custom minimum and maximum fraction digits', () => { + expect( + formatAmount(99.1, { + minimumFractionDigits: 1, + maximumFractionDigits: 4, + }) + ).toBe('99.1'); + }); + + test('formats an integer value', () => { + expect(formatAmount(5000)).toBe('5,000.00'); + }); + + test('formats zero correctly', () => { + expect(formatAmount(0)).toBe('0.00'); + }); + + test('formats negative values', () => { + expect(formatAmount(-1200.5)).toBe('-1,200.50'); + }); +}); From 338d2d11868cafb323163721eef45f78445d1b31 Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Mon, 8 Dec 2025 15:03:15 +0200 Subject: [PATCH 08/11] fix(validation): standardize Tron address error message and remove misleading hint --- src/utils/address-validators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/address-validators.ts b/src/utils/address-validators.ts index d19ce29..852892c 100644 --- a/src/utils/address-validators.ts +++ b/src/utils/address-validators.ts @@ -68,7 +68,7 @@ export function validateTronAddress(address: string): AddressValidationResult { if (!isValid) { return { valid: false, - error: 'Invalid Tron address. Tron addresses usually start with "T".', + error: 'Invalid Tron address. Please check the address and try again.', }; } From 693732bc0333189096ad9aa22491307b8297c680 Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Mon, 8 Dec 2025 15:23:42 +0200 Subject: [PATCH 09/11] refactor(send): debounce address validation to avoid running on every keystroke --- src/app/send/details.tsx | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/app/send/details.tsx b/src/app/send/details.tsx index 654f1a3..b9975b0 100644 --- a/src/app/send/details.tsx +++ b/src/app/send/details.tsx @@ -40,6 +40,7 @@ import formatUSDValue from '@/utils/format-usd-value'; import Header from '@/components/header'; import { toast } from 'sonner-native'; import { validateAddressByNetwork } from '@/utils/address-validators'; +import { useDebouncedCallback } from '@/hooks/use-debounced-callback'; export default function SendDetailsScreen() { const insets = useSafeAreaInsets(); @@ -87,25 +88,28 @@ export default function SendDetailsScreen() { const [isAmountInputFocused, setIsAmountInputFocused] = useState(false); const keyboard = useKeyboard(); - const handleRecipientAddressChange = useCallback( - (value: string) => { - setRecipientAddress(value); + const debouncedValidateAddress = useDebouncedCallback((value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + setAddressError(''); + return; + } - const trimmed = value.trim(); - if (!trimmed) { - setAddressError(''); - return; - } + const result = validateAddressByNetwork(networkId, trimmed); - const result = validateAddressByNetwork(networkId, trimmed); + if (!result.valid) { + setAddressError(result.error); + } else { + setAddressError(''); + } + }, 300); - if (!result.valid) { - setAddressError(result.error); - } else { - setAddressError(''); - } + const handleRecipientAddressChange = useCallback( + (value: string) => { + setRecipientAddress(value); + debouncedValidateAddress(value); }, - [networkId] + [debouncedValidateAddress] ); // Handle scanned address from QR scanner From 230cfee60c793462f7531eeda749ddea1607366d Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Mon, 8 Dec 2025 16:41:00 +0200 Subject: [PATCH 10/11] add use-debounced-callback.ts file to repo --- src/hooks/use-debounced-callback.ts | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/hooks/use-debounced-callback.ts diff --git a/src/hooks/use-debounced-callback.ts b/src/hooks/use-debounced-callback.ts new file mode 100644 index 0000000..2de79a6 --- /dev/null +++ b/src/hooks/use-debounced-callback.ts @@ -0,0 +1,32 @@ +import { useCallback, useEffect, useRef } from 'react'; + +type DebouncedFn = (...args: any[]) => void; + +const DELAY_TIMEOUT_MS = 400; + +export function useDebouncedCallback(callback: DebouncedFn, delay = DELAY_TIMEOUT_MS): DebouncedFn { + const timeoutRef = useRef | null>(null); + + const debouncedFn = useCallback( + (...args: any[]) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay] + ); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return debouncedFn; +} From 08d65046dc4575ef193639d0b2f900d4591800ba Mon Sep 17 00:00:00 2001 From: "Kyrylo.Makarchuk" Date: Thu, 18 Dec 2025 21:23:11 +0200 Subject: [PATCH 11/11] (security) mnemonic clipboard auto-clear logic --- src/app/wallet-setup/import-wallet.tsx | 5 ++- src/hooks/useMnemonicClipboardCleanup.ts | 45 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useMnemonicClipboardCleanup.ts diff --git a/src/app/wallet-setup/import-wallet.tsx b/src/app/wallet-setup/import-wallet.tsx index 11b454a..f9055c3 100644 --- a/src/app/wallet-setup/import-wallet.tsx +++ b/src/app/wallet-setup/import-wallet.tsx @@ -17,11 +17,13 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { toast } from 'sonner-native'; +import { useMnemonicClipboardCleanup } from '@/hooks/useMnemonicClipboardCleanup'; export default function ImportWalletScreen() { const router = useDebouncedNavigation(); const insets = useSafeAreaInsets(); const [secretWords, setSecretWords] = useState(Array(12).fill('')); + const { scheduleClearClipboard } = useMnemonicClipboardCleanup(); const handleWordChange = (index: number, text: string) => { const newWords = [...secretWords]; @@ -56,6 +58,7 @@ export default function ImportWalletScreen() { setSecretWords(newWords); toast.success('12 words have been pasted from clipboard'); + scheduleClearClipboard(words.join(' ')); } catch (error) { console.error('Paste error:', error); toast.error('Could not paste from clipboard'); @@ -69,7 +72,7 @@ export default function ImportWalletScreen() { }; const isFormValid = () => { - return secretWords.every(word => word.trim().length > 0); + return secretWords.every((word) => word.trim().length > 0); }; const validateSeedPhrase = (phrase: string): boolean => { diff --git a/src/hooks/useMnemonicClipboardCleanup.ts b/src/hooks/useMnemonicClipboardCleanup.ts new file mode 100644 index 0000000..6ddb33d --- /dev/null +++ b/src/hooks/useMnemonicClipboardCleanup.ts @@ -0,0 +1,45 @@ +import * as Clipboard from 'expo-clipboard'; +import { useEffect, useRef } from 'react'; + +const CLEAR_CLIPBOARD_AFTER_MS = 5 * 60 * 1000; //5 mins + +const normalizePhrase = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' '); + +const checkIsMnemonic = (s: string) => { + const words = normalizePhrase(s).split(' '); + return words.length === 12 && words.every(Boolean); +}; + +export function useMnemonicClipboardCleanup() { + const timerRef = useRef | null>(null); + const lastMnemonicRef = useRef(null); + + const scheduleClearClipboard = (mnemonic: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + + lastMnemonicRef.current = normalizePhrase(mnemonic); + + timerRef.current = setTimeout(async () => { + try { + const current = await Clipboard.getStringAsync(); + const normalizedCurrent = normalizePhrase(current); + + if ( + lastMnemonicRef.current && + normalizedCurrent === lastMnemonicRef.current && + checkIsMnemonic(normalizedCurrent) + ) { + await Clipboard.setStringAsync(''); + } + } catch {} + }, CLEAR_CLIPBOARD_AFTER_MS); + }; + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + return { scheduleClearClipboard }; +}