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 && (
+
+
+
+
+
+ )}