From a5ae3806b0da066ed61b7c076ae3f784bd82e683 Mon Sep 17 00:00:00 2001 From: Blake Kaufman <68204898+BlakeKaufman@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:55:11 -0400 Subject: [PATCH] Handle evm amounts (#721) * update presendPageParsing to handle address with amounts * adding method to correctly navigate to send usdc or usdt path based on address params * adding unsupportive token message at top of page stablecoin params page * handling prefilled amount * adding supported tokens translation * fix margin --- .../homeLightning/halfModalSendOptions.js | 19 +- .../homeLightning/manualEnterSendAddress.js | 9 + app/functions/index.js | 7 +- .../sendBitcoin/handlePreSendPageParsing.js | 188 ++++++++++++++++++ app/functions/sendBitcoin/index.js | 67 ++++++- .../inAccount/selectStablecoinParamsScreen.js | 32 ++- app/screens/inAccount/sendBtcPage.js | 34 +--- app/screens/inAccount/stablecoinSendScreen.js | 6 +- locales/de-DE/translation.json | 3 +- locales/en/translation.json | 3 +- locales/es/translation.json | 3 +- locales/fr/translation.json | 3 +- locales/it/translation.json | 3 +- locales/pt-BR/translation.json | 3 +- locales/ru/translation.json | 3 +- locales/sv/translation.json | 3 +- 16 files changed, 332 insertions(+), 54 deletions(-) diff --git a/app/components/admin/homeComponents/homeLightning/halfModalSendOptions.js b/app/components/admin/homeComponents/homeLightning/halfModalSendOptions.js index cdf98383..6fef2ddb 100644 --- a/app/components/admin/homeComponents/homeLightning/halfModalSendOptions.js +++ b/app/components/admin/homeComponents/homeLightning/halfModalSendOptions.js @@ -9,6 +9,7 @@ import { useNavigation } from '@react-navigation/native'; import { navigateToSendUsingClipboard, getQRImage, + resolveExternalChainNavigation, } from '../../../../functions'; import handlePreSendPageParsing from '../../../../functions/sendBitcoin/handlePreSendPageParsing'; import { ThemeText } from '../../../../functions/CustomElements'; @@ -387,10 +388,11 @@ export default function HalfModalSendOptions({ return; } if (parsed.isExternalChain) { - navigate.replace('SelectStablecoinParamsScreen', { - address: parsed.address, - chainFamily: parsed.chainFamily, - }); + const { method, screen, params } = resolveExternalChainNavigation( + parsed, + 'notHome', + ); + navigate[method](screen, params); return; } navigate.replace('ConfirmPaymentScreen', { @@ -428,10 +430,11 @@ export default function HalfModalSendOptions({ } if (response.isExternalChain) { - navigate.replace('SelectStablecoinParamsScreen', { - address: response.address, - chainFamily: response.chainFamily, - }); + const { method, screen, params } = resolveExternalChainNavigation( + response, + 'notHome', + ); + navigate[method](screen, params); return; } diff --git a/app/components/admin/homeComponents/homeLightning/manualEnterSendAddress.js b/app/components/admin/homeComponents/homeLightning/manualEnterSendAddress.js index 434dee03..d9912d9a 100644 --- a/app/components/admin/homeComponents/homeLightning/manualEnterSendAddress.js +++ b/app/components/admin/homeComponents/homeLightning/manualEnterSendAddress.js @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import CustomSearchInput from '../../../../functions/CustomElements/searchInput'; import { crashlyticsLogReport } from '../../../../functions/crashlyticsLogs'; import handlePreSendPageParsing from '../../../../functions/sendBitcoin/handlePreSendPageParsing'; +import { resolveExternalChainNavigation } from '../../../../functions'; import { keyboardNavigate } from '../../../../functions/customNavigation'; import ThemeIcon from '../../../../functions/CustomElements/themeIcon'; @@ -120,6 +121,14 @@ export default function ManualEnterSendAddress(props) { }); return; } + if (response.isExternalChain) { + const { method, screen, params } = resolveExternalChainNavigation( + response, + 'notHome', + ); + navigate[method](screen, params); + return; + } navigate.replace('ConfirmPaymentScreen', { btcAdress: response.btcAdress, }); diff --git a/app/functions/index.js b/app/functions/index.js index f41219fb..8766aa35 100644 --- a/app/functions/index.js +++ b/app/functions/index.js @@ -15,7 +15,11 @@ import { import formatBalanceAmount from './formatNumber'; import copyToClipboard from './copyToClipboard'; -import { navigateToSendUsingClipboard, getQRImage } from './sendBitcoin'; +import { + navigateToSendUsingClipboard, + getQRImage, + resolveExternalChainNavigation, +} from './sendBitcoin'; import numberConverter from './numberConverter'; import { createAccountMnemonic } from './seed'; import { normalizeNumber } from './normalizeNumber'; @@ -37,6 +41,7 @@ export { copyToClipboard, navigateToSendUsingClipboard, getQRImage, + resolveExternalChainNavigation, numberConverter, normalizeNumber, }; diff --git a/app/functions/sendBitcoin/handlePreSendPageParsing.js b/app/functions/sendBitcoin/handlePreSendPageParsing.js index d73d60ac..8fc34418 100644 --- a/app/functions/sendBitcoin/handlePreSendPageParsing.js +++ b/app/functions/sendBitcoin/handlePreSendPageParsing.js @@ -7,6 +7,92 @@ const EVM_REGEX = /^0x[0-9a-fA-F]{40}$/; const TRON_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/; const SOLANA_REGEX = /^[1-9A-HJ-NP-Za-km-z]{43,44}$/; +// URI scheme regexes +const EIP681_REGEX = + /^ethereum:(0x[0-9a-fA-F]{40})(?:@\d+)?(?:\/([^?]*))?(?:\?(.*))?$/i; +const SOLANA_URI_REGEX = /^solana:([1-9A-HJ-NP-Za-km-z]{32,44})(?:\?(.*))?$/; +const TRON_URI_REGEX = /^tron:(T[1-9A-HJ-NP-Za-km-z]{33})(?:\?(.*))?$/; + +const EVM_TOKEN_MAP = { + // Ethereum + '0xdac17f958d2ee523a2206206994597c13d831ec7': { + asset: 'USDT', + chain: 'ethereum', + chainLabel: 'Ethereum', + }, + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48': { + asset: 'USDC', + chain: 'ethereum', + chainLabel: 'Ethereum', + }, + // Arbitrum + '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9': { + asset: 'USDT', + chain: 'arbitrum', + chainLabel: 'Arbitrum', + }, + '0xaf88d065e77c8cc2239327c5edb3a432268e5831': { + asset: 'USDC', + chain: 'arbitrum', + chainLabel: 'Arbitrum', + }, + // Base + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { + asset: 'USDC', + chain: 'base', + chainLabel: 'Base', + }, + // Optimism + '0x94b008aa00579c1307b0ef2c499ad98a8ce58e58': { + asset: 'USDT', + chain: 'optimism', + chainLabel: 'Optimism', + }, + '0x0b2c639c533813f4aa9d7837caf62653d097ff85': { + asset: 'USDC', + chain: 'optimism', + chainLabel: 'Optimism', + }, + // Polygon + '0xc2132d05d31c914a87c6611c10748aeb04b58e8f': { + asset: 'USDT', + chain: 'polygon', + chainLabel: 'Polygon', + }, + '0x3c499c542cef5e3811e1192ce70d8cc03d5c3359': { + asset: 'USDC', + chain: 'polygon', + chainLabel: 'Polygon', + }, +}; + +// Solana SPL token mints +const SOLANA_TOKEN_MAP = { + EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v: { + asset: 'USDC', + chain: 'solana', + chainLabel: 'Solana', + }, +}; + +// Tron TRC-20 contracts +const TRON_TOKEN_MAP = { + TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t: { + asset: 'USDT', + chain: 'tron', + chainLabel: 'Tron', + }, +}; + +function parseQueryString(qs) { + if (!qs) return {}; + return qs.split('&').reduce((acc, pair) => { + const [k, v] = pair.split('='); + if (k) acc[decodeURIComponent(k)] = decodeURIComponent(v ?? ''); + return acc; + }, {}); +} + export default function handlePreSendPageParsing(data) { try { if (!data) throw new Error(i18next.t('errormessages.invalidData')); @@ -32,6 +118,108 @@ export default function handlePreSendPageParsing(data) { }; } + // ethereum:0xAddress[?token=0xContract&amount=X] + const evmMatch = EIP681_REGEX.exec(trimmed); + if (evmMatch) { + const recipient = evmMatch[1]; + const params = parseQueryString(evmMatch[3]); + const tokenContract = params.token ? params.token.toLowerCase() : null; + const amount = + params.amount && Number(params.amount) > 0 + ? String(Number(params.amount)) + : null; + + if (tokenContract) { + const resolvedToken = EVM_TOKEN_MAP[tokenContract] ?? null; + return { + didWork: true, + error: null, + isExternalChain: true, + address: recipient, + chainFamily: 'EVM', + resolvedToken, + prefillAmount: amount, + unsupportedTokenAddress: resolvedToken ? null : tokenContract, + }; + } + + return { + didWork: true, + error: null, + isExternalChain: true, + address: recipient, + chainFamily: 'EVM', + }; + } + + // solana: URI (Solana Pay) + const solanaUriMatch = SOLANA_URI_REGEX.exec(trimmed); + if (solanaUriMatch) { + const recipient = solanaUriMatch[1]; + const params = parseQueryString(solanaUriMatch[2]); + const splToken = params['token'] ?? params['spl-token'] ?? null; + const amount = + params.amount && Number(params.amount) > 0 + ? String(Number(params.amount)) + : null; + + console.log(solanaUriMatch, recipient, params, splToken, amount); + + if (splToken) { + const resolvedToken = SOLANA_TOKEN_MAP[splToken] ?? null; + return { + didWork: true, + error: null, + isExternalChain: true, + address: recipient, + chainFamily: 'Solana', + resolvedToken, + prefillAmount: amount, + unsupportedTokenAddress: resolvedToken ? null : splToken, + }; + } + return { + didWork: true, + error: null, + isExternalChain: true, + address: recipient, + chainFamily: 'Solana', + }; + } + + // tron: URI + const tronUriMatch = TRON_URI_REGEX.exec(trimmed); + if (tronUriMatch) { + const recipient = tronUriMatch[1]; + const params = parseQueryString(tronUriMatch[2]); + const splToken = params['token'] ?? params['spl-token'] ?? null; + const amount = + params.amount && Number(params.amount) > 0 + ? String(Number(params.amount)) + : null; + + if (splToken) { + const resolvedToken = TRON_TOKEN_MAP[splToken] ?? null; + return { + didWork: true, + error: null, + isExternalChain: true, + address: recipient, + chainFamily: 'Tron', + resolvedToken, + prefillAmount: amount, + unsupportedTokenAddress: resolvedToken ? null : splToken, + }; + } + return { + didWork: true, + error: null, + isExternalChain: true, + address: recipient, + chainFamily: 'Tron', + }; + } + if (WEBSITE_REGEX.test(data)) { if (IS_BLITZ_URL_REGEX.test(data)) throw new Error(i18next.t('errormessages.invalidData')); diff --git a/app/functions/sendBitcoin/index.js b/app/functions/sendBitcoin/index.js index a187062f..aa543b99 100644 --- a/app/functions/sendBitcoin/index.js +++ b/app/functions/sendBitcoin/index.js @@ -2,6 +2,7 @@ import { getImageFromLibrary } from '../imagePickerWrapper'; import getClipboardText from '../getClipboardText'; import handlePreSendPageParsing from './handlePreSendPageParsing'; import { detectQRCode } from '../detectQrCode'; +import i18next from 'i18next'; async function navigateToSendUsingClipboard(navigate, callLocation, from, t) { const response = await getClipboardText(); @@ -30,16 +31,11 @@ async function navigateToSendUsingClipboard(navigate, callLocation, from, t) { } if (preParsingResponse.isExternalChain) { - if (from === 'home') - navigate.navigate('SelectStablecoinParamsScreen', { - address: preParsingResponse.address, - chainFamily: preParsingResponse.chainFamily, - }); - else - navigate.replace('SelectStablecoinParamsScreen', { - address: preParsingResponse.address, - chainFamily: preParsingResponse.chainFamily, - }); + const { method, screen, params } = resolveExternalChainNavigation( + preParsingResponse, + from, + ); + navigate[method](screen, params); return; } @@ -114,6 +110,9 @@ async function getQRImage() { isExternalChain: true, address: preParsingResponse.address, chainFamily: preParsingResponse.chainFamily, + resolvedToken: preParsingResponse.resolvedToken, + prefillAmount: preParsingResponse.prefillAmount, + unsupportedTokenAddress: preParsingResponse.unsupportedTokenAddress, didWork: true, error: '', }; @@ -127,4 +126,50 @@ function formatStablecoinAmount(rawAmount, decimals = 2) { return value.toFixed(decimals); } -export { navigateToSendUsingClipboard, getQRImage, formatStablecoinAmount }; +function resolveExternalChainNavigation(parsedResult, from) { + const method = from === 'home' ? 'navigate' : 'replace'; + + if (parsedResult.resolvedToken) { + return { + method, + screen: 'StablecoinSendScreen', + params: { + address: parsedResult.address, + chain: parsedResult.resolvedToken.chain, + chainLabel: parsedResult.resolvedToken.chainLabel, + asset: parsedResult.resolvedToken.asset, + ...(parsedResult.prefillAmount != null + ? { prefillAmount: parsedResult.prefillAmount } + : {}), + }, + }; + } + + if (parsedResult.unsupportedTokenAddress) { + return { + method, + screen: 'SelectStablecoinParamsScreen', + params: { + address: parsedResult.address, + chainFamily: parsedResult.chainFamily, + unsupportedTokenMessage: i18next.t('errormessages.usdcUsdtTokensOnly'), + }, + }; + } + + return { + method, + screen: 'SelectStablecoinParamsScreen', + params: { + address: parsedResult.address, + chainFamily: parsedResult.chainFamily, + }, + }; +} + +export { + navigateToSendUsingClipboard, + getQRImage, + formatStablecoinAmount, + resolveExternalChainNavigation, +}; diff --git a/app/screens/inAccount/selectStablecoinParamsScreen.js b/app/screens/inAccount/selectStablecoinParamsScreen.js index ccc55586..c8d1921b 100644 --- a/app/screens/inAccount/selectStablecoinParamsScreen.js +++ b/app/screens/inAccount/selectStablecoinParamsScreen.js @@ -18,6 +18,7 @@ import { useGlobalThemeContext } from '../../../context-store/theme'; import { useGlobalInsets } from '../../../context-store/insetsProvider'; import { Image } from 'expo-image'; import { HIDDEN_OPACITY, INSET_WINDOW_WIDTH } from '../../constants/theme'; +import ThemeIcon from '../../functions/CustomElements/themeIcon'; const EVM_CHAINS = ['Base', 'Ethereum', 'Arbitrum', 'Optimism', 'Polygon']; const TRON_ASSETS = [{ id: 'USDT', label: 'USDT', icon: ICONS.usdtLogo }]; @@ -26,7 +27,7 @@ const SOLANA_ASSETS = [{ id: 'USDC', label: 'USDC', icon: ICONS.usdcLogo }]; export default function SelectStablecoinParamsScreen() { const navigate = useNavigation(); const route = useRoute(); - const { address, chainFamily } = route.params; + const { address, chainFamily, unsupportedTokenMessage } = route.params; const { t } = useTranslation(); const { backgroundOffset, backgroundColor } = GetThemeColors(); const { theme, darkModeType } = useGlobalThemeContext(); @@ -75,6 +76,21 @@ export default function SelectStablecoinParamsScreen() { : 'wallet.stablecoinSend.selectChain', )} /> + {!!unsupportedTokenMessage && ( + + + + + + )}