From 19d4a1acd90cd01990b02e895f3f3120f856ec66 Mon Sep 17 00:00:00 2001 From: TanmayDagur Date: Tue, 4 Aug 2026 05:57:28 +0530 Subject: [PATCH] feat(mobile): support ERC-681 payment QR code scanning and prefilled Send flow (#8022) --- .../RecipientSelect/RecipientScanModal.tsx | 3 + .../Requests/ScanSheet/WalletConnectModal.tsx | 23 +++ .../Requests/ScanSheet/util.test.ts | 30 +++ .../src/components/Requests/ScanSheet/util.ts | 22 +- .../features/transactions/send/erc681.test.ts | 108 ++++++++++ .../src/features/transactions/send/erc681.ts | 189 ++++++++++++++++++ 6 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 packages/uniswap/src/features/transactions/send/erc681.test.ts create mode 100644 packages/uniswap/src/features/transactions/send/erc681.ts diff --git a/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx index df8134dba9e..74981e4fd26 100644 --- a/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx +++ b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx @@ -43,6 +43,9 @@ export function RecipientScanModal({ onSelectRecipient, onClose }: Props): JSX.E if (supportedURI?.type === URIType.Address) { onSelectRecipient(supportedURI.value) onClose() + } else if (supportedURI?.type === URIType.ERC681) { + onSelectRecipient(supportedURI.value.recipient) + onClose() } else { Alert.alert(t('qrScanner.recipient.error.title'), t('qrScanner.recipient.error.message'), [ { diff --git a/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx b/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx index e26bc50985f..8d919dcadc6 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx +++ b/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx @@ -30,7 +30,10 @@ import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants import Trace from 'uniswap/src/features/telemetry/Trace' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { UwULinkRequest } from 'uniswap/src/types/walletConnect' +import { TransactionState } from 'uniswap/src/features/transactions/types/transactionState' import { logger } from 'utilities/src/logger/logger' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { getSendPrefilledState } from 'wallet/src/features/transactions/send/getSendPrefilledState' import { useContractManager, useProviderManager } from 'wallet/src/features/wallet/context' import { useActiveAccount } from 'wallet/src/features/wallet/hooks' @@ -51,6 +54,7 @@ export function WalletConnectModal({ const [currentScreenState, setCurrentScreenState] = useState(initialScreenState) const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false) const { preload, navigate } = useEagerExternalProfileRootNavigation() + const { navigateToSend } = useWalletNavigation() const dispatch = useDispatch() const isUwULinkEnabled = useFeatureFlag(FeatureFlags.UwULink) const isScantasticEnabled = useFeatureFlag(FeatureFlags.Scantastic) @@ -105,6 +109,24 @@ export function WalletConnectModal({ return } + if (supportedURI.type === URIType.ERC681) { + setShouldFreezeCamera(true) + const baseState = getSendPrefilledState({ + chainId: supportedURI.value.chainId, + currencyAddress: supportedURI.value.tokenAddress, + }) + const initialState: TransactionState = { + ...baseState, + recipient: supportedURI.value.recipient, + showRecipientSelector: false, + exactAmountToken: supportedURI.value.formattedAmount ?? '', + isFiatInput: false, + } + navigateToSend({ initialState }) + onClose() + return + } + if (supportedURI.type === URIType.WalletConnectURL) { setShouldFreezeCamera(true) Alert.alert(t('walletConnect.error.unsupportedV1.title'), t('walletConnect.error.unsupportedV1.message'), [ @@ -219,6 +241,7 @@ export function WalletConnectModal({ t, preload, navigate, + navigateToSend, onClose, dispatch, uwuLinkContractAllowlist, diff --git a/apps/mobile/src/components/Requests/ScanSheet/util.test.ts b/apps/mobile/src/components/Requests/ScanSheet/util.test.ts index 413943b4266..241eeb50e6f 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/util.test.ts +++ b/apps/mobile/src/components/Requests/ScanSheet/util.test.ts @@ -126,6 +126,36 @@ describe('getSupportedURI', () => { expect(await getSupportedURI('ethereum:invalid_address')).toBeUndefined() }) + it('should extract correct ERC681 payload from payment request URI', async () => { + const validErc681Uri = 'ethereum:0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359@8453?value=1e16' + const result = await getSupportedURI(validErc681Uri) + expect(result).toEqual({ + type: URIType.ERC681, + value: { + chainId: 8453, + recipient: '0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359', + tokenAddress: undefined, + rawAmount: '10000000000000000', + formattedAmount: '0.01', + }, + }) + }) + + it('should extract correct ERC681 payload from token transfer URI', async () => { + const tokenTransferUri = 'ethereum:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913@8453/transfer?address=0x54235780057CC828C92aA40e3b02053881990153&uint256=1e6' + const result = await getSupportedURI(tokenTransferUri) + expect(result).toEqual({ + type: URIType.ERC681, + value: { + chainId: 8453, + recipient: '0x54235780057CC828C92aA40e3b02053881990153', + tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + rawAmount: '1000000', + formattedAmount: undefined, + }, + }) + }) + describe('URL and HTML encoded URIs', () => { it('should handle percent-encoded WalletConnect v2 URI', async () => { // Simulate a URI that has been percent-encoded (& becomes %26) diff --git a/apps/mobile/src/components/Requests/ScanSheet/util.ts b/apps/mobile/src/components/Requests/ScanSheet/util.ts index f8f07334473..e29403e71f1 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/util.ts +++ b/apps/mobile/src/components/Requests/ScanSheet/util.ts @@ -12,6 +12,7 @@ import { UNISWAP_WALLETCONNECT_URL, } from 'src/features/deepLinking/constants' import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { ERC681TransferRequest, parseERC681URI } from 'uniswap/src/features/transactions/send/erc681' import { getValidAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' import { ScantasticParams, ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' @@ -23,12 +24,18 @@ export enum URIType { EasterEgg = 'easter-egg', Scantastic = 'scantastic', UwULink = 'uwu-link', + ERC681 = 'erc681', } -type URIFormat = { - type: URIType - value: string -} +export type URIFormat = + | { + type: URIType.ERC681 + value: ERC681TransferRequest + } + | { + type: Exclude + value: string + } interface EnabledFeatureFlags { isUwULinkEnabled: boolean @@ -60,10 +67,15 @@ export async function getSupportedURI( } const maybeMetamaskAddress = getMetamaskAddress(uri) - if (maybeMetamaskAddress) { + if (maybeMetamaskAddress && !uri.includes('?') && !uri.includes('@') && !uri.includes('/')) { return { type: URIType.Address, value: maybeMetamaskAddress } } + const maybeERC681Request = parseERC681URI(uri) + if (maybeERC681Request) { + return { type: URIType.ERC681, value: maybeERC681Request } + } + const maybeScantasticQueryParams = getScantasticQueryParams(uri) if (enabledFeatureFlags?.isScantasticEnabled && maybeScantasticQueryParams) { return { type: URIType.Scantastic, value: maybeScantasticQueryParams } diff --git a/packages/uniswap/src/features/transactions/send/erc681.test.ts b/packages/uniswap/src/features/transactions/send/erc681.test.ts new file mode 100644 index 00000000000..1f5e3d77e68 --- /dev/null +++ b/packages/uniswap/src/features/transactions/send/erc681.test.ts @@ -0,0 +1,108 @@ +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { formatERC681Amount, parseERC681URI, parseScientificOrIntString } from 'uniswap/src/features/transactions/send/erc681' + +describe('parseScientificOrIntString', () => { + it('parses scientific notation accurately without precision loss', () => { + expect(parseScientificOrIntString('2.014e18')).toBe('2014000000000000000') + expect(parseScientificOrIntString('1e16')).toBe('10000000000000000') + expect(parseScientificOrIntString('1e6')).toBe('1000000') + expect(parseScientificOrIntString('1.5e6')).toBe('1500000') + }) + + it('parses standard integer strings and hex values', () => { + expect(parseScientificOrIntString('1000000')).toBe('1000000') + expect(parseScientificOrIntString('0xde0b6b3a7640000')).toBe('1000000000000000000') + }) + + it('returns undefined for invalid strings', () => { + expect(parseScientificOrIntString('')).toBeUndefined() + expect(parseScientificOrIntString('invalid')).toBeUndefined() + }) +}) + +describe('formatERC681Amount', () => { + it('formats raw units cleanly without trailing zeros', () => { + expect(formatERC681Amount('2014000000000000000', 18)).toBe('2.014') + expect(formatERC681Amount('10000000000000000', 18)).toBe('0.01') + expect(formatERC681Amount('1000000', 6)).toBe('1') + expect(formatERC681Amount('1500000', 6)).toBe('1.5') + }) +}) + +describe('parseERC681URI', () => { + const TEST_RECIPIENT = '0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359' + const TEST_RECIPIENT_2 = '0x54235780057CC828C92aA40e3b02053881990153' + const TEST_USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' + const TEST_DAI = '0x6B175474E89094C44Da98b954EedeAC495271d0F' + + it('parses simple native ETH transfer on Mainnet (Test Vector 1)', () => { + const uri = `ethereum:${TEST_RECIPIENT}?value=2.014e18` + const result = parseERC681URI(uri) + expect(result).toEqual({ + chainId: UniverseChainId.Mainnet, + recipient: TEST_RECIPIENT, + tokenAddress: undefined, + rawAmount: '2014000000000000000', + formattedAmount: '2.014', + }) + }) + + it('parses native ETH transfer on Base (Test Vector 2)', () => { + const uri = `ethereum:${TEST_RECIPIENT}@8453?value=1e16` + const result = parseERC681URI(uri) + expect(result).toEqual({ + chainId: UniverseChainId.Base, + recipient: TEST_RECIPIENT, + tokenAddress: undefined, + rawAmount: '10000000000000000', + formattedAmount: '0.01', + }) + }) + + it('parses USDC transfer on Base (Test Vector 3)', () => { + const uri = `ethereum:${TEST_USDC}@8453/transfer?address=${TEST_RECIPIENT_2}&uint256=1e6` + const result = parseERC681URI(uri) + expect(result).toEqual({ + chainId: UniverseChainId.Base, + recipient: TEST_RECIPIENT_2, + tokenAddress: TEST_USDC, + rawAmount: '1000000', + formattedAmount: undefined, // ERC-20 decimals require external resolution in UI + }) + }) + + it('parses DAI transfer on Mainnet (Test Vector 4)', () => { + const uri = `ethereum:${TEST_DAI}/transfer?address=${TEST_RECIPIENT_2}&uint256=1e18` + const result = parseERC681URI(uri) + expect(result).toEqual({ + chainId: UniverseChainId.Mainnet, + recipient: TEST_RECIPIENT_2, + tokenAddress: TEST_DAI, + rawAmount: '1000000000000000000', + formattedAmount: undefined, + }) + }) + + it('parses plain ethereum:
URI without parameters', () => { + const uri = `ethereum:${TEST_RECIPIENT}` + const result = parseERC681URI(uri) + expect(result).toEqual({ + chainId: UniverseChainId.Mainnet, + recipient: TEST_RECIPIENT, + tokenAddress: undefined, + rawAmount: undefined, + formattedAmount: undefined, + }) + }) + + it('returns undefined for invalid URI schemes or addresses', () => { + expect(parseERC681URI('bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa')).toBeUndefined() + expect(parseERC681URI('ethereum:invalid_address')).toBeUndefined() + expect(parseERC681URI('ethereum:0x123/transfer?address=invalid_recipient')).toBeUndefined() + }) + + it('returns undefined for unsupported smart contract functions', () => { + const uri = `ethereum:${TEST_USDC}/approve?address=${TEST_RECIPIENT}&uint256=1000` + expect(parseERC681URI(uri)).toBeUndefined() + }) +}) diff --git a/packages/uniswap/src/features/transactions/send/erc681.ts b/packages/uniswap/src/features/transactions/send/erc681.ts new file mode 100644 index 00000000000..43e275c82a8 --- /dev/null +++ b/packages/uniswap/src/features/transactions/send/erc681.ts @@ -0,0 +1,189 @@ +import { formatUnits } from '@ethersproject/units' +import { UniverseChainId } from 'uniswap/src/features/chains/types' +import { Platform } from 'uniswap/src/features/platforms/types/Platform' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { logger } from 'utilities/src/logger/logger' + +export interface ERC681TransferRequest { + chainId: UniverseChainId + recipient: string + tokenAddress?: string // Present if ERC-20 token transfer, undefined if native currency transfer + rawAmount?: string // The atomic unit integer string (in Wei or tokens' smallest unit) + formattedAmount?: string // Human-readable amount (for native ETH transfers where decimals is 18) +} + +/** + * Converts scientific notation strings (e.g. "2.014e18", "1e16"), hex ("0xde0b6b3a7640000"), or integer strings ("1000000") + * into precision-safe standard decimal integer strings. + */ +export function parseScientificOrIntString(value?: string | null): string | undefined { + if (!value) { + return undefined + } + + const trimmed = value.trim() + if (!trimmed) { + return undefined + } + + try { + // Handle hex format + if (trimmed.toLowerCase().startsWith('0x')) { + return BigInt(trimmed).toString() + } + + // Handle scientific notation (e.g., 2.014e18 or 1e16) + if (trimmed.toLowerCase().includes('e')) { + const parts = trimmed.toLowerCase().split('e') + if (parts.length !== 2) { + return undefined + } + + const baseStr = parts[0] + const expStr = parts[1] + if (!baseStr || !expStr) { + return undefined + } + + const exp = parseInt(expStr, 10) + if (isNaN(exp) || exp < 0) { + return undefined + } + + const baseParts = baseStr.split('.') + const integerPart = baseParts[0] || '0' + const fractionalPart = baseParts[1] || '' + + if (fractionalPart.length > exp) { + // Underflow / fractional remainder in atomic units is invalid in EIP-681 integer parameters + return undefined + } + + const combined = (integerPart === '0' ? '' : integerPart) + fractionalPart + const trailingZeros = '0'.repeat(exp - fractionalPart.length) + const resultString = combined + trailingZeros + + return BigInt(resultString.length === 0 ? '0' : resultString).toString() + } + + // Standard decimal string (remove any trailing ".00" if present) + const normalized = trimmed.includes('.') ? trimmed.split('.')[0] : trimmed + if (!normalized) { + return undefined + } + return BigInt(normalized).toString() + } catch (error) { + logger.debug('erc681', 'parseScientificOrIntString', `Failed to parse numerical value: ${value}`, { error }) + return undefined + } +} + +/** + * Formats a raw atomic token amount using token decimals, cleanly removing trailing ".0" or excess zeros. + */ +export function formatERC681Amount(rawAmount?: string, decimals: number = 18): string | undefined { + if (!rawAmount) { + return undefined + } + try { + const formatted = formatUnits(rawAmount, decimals) + if (formatted.includes('.')) { + // Clean up trailing zeros after decimal point and trailing dot (e.g. "1.0" -> "1", "2.01400" -> "2.014") + return formatted.replace(/0+$/, '').replace(/\.$/, '') + } + return formatted + } catch { + return undefined + } +} + +/** + * Parses an EIP-681 Ethereum URI into a structured transfer request for Native ETH or ERC-20 transfers. + * Syntax specification: ethereum:[@][/][?] + */ +export function parseERC681URI(uri?: string): ERC681TransferRequest | undefined { + if (!uri || !uri.toLowerCase().startsWith('ethereum:')) { + return undefined + } + + try { + const withoutScheme = uri.slice(9).trim() + const queryIndex = withoutScheme.indexOf('?') + + const pathPart = queryIndex !== -1 ? withoutScheme.slice(0, queryIndex) : withoutScheme + const queryPart = queryIndex !== -1 ? withoutScheme.slice(queryIndex + 1) : '' + + const slashIndex = pathPart.indexOf('/') + const targetAndChain = slashIndex !== -1 ? pathPart.slice(0, slashIndex) : pathPart + const functionName = slashIndex !== -1 ? pathPart.slice(slashIndex + 1).trim() : undefined + + const atIndex = targetAndChain.indexOf('@') + const rawAddress = atIndex !== -1 ? targetAndChain.slice(0, atIndex) : targetAndChain + const rawChainId = atIndex !== -1 ? parseInt(targetAndChain.slice(atIndex + 1), 10) : NaN + + const targetAddress = getValidAddress({ + address: rawAddress, + platform: Platform.EVM, + withEVMChecksum: true, + log: false, + }) + if (!targetAddress) { + return undefined + } + + const chainId = (!isNaN(rawChainId) && rawChainId > 0 ? rawChainId : UniverseChainId.Mainnet) as UniverseChainId + const searchParams = new URLSearchParams(queryPart) + + // Case 1: Native currency transfer (no function specified, or explicit "pay" function) + if (!functionName || functionName.toLowerCase() === 'pay') { + const valueParam = searchParams.get('value') + const rawAmount = parseScientificOrIntString(valueParam) + const formattedAmount = formatERC681Amount(rawAmount, 18) + + return { + chainId, + recipient: targetAddress, + tokenAddress: undefined, + rawAmount, + formattedAmount, + } + } + + // Case 2: ERC-20 token transfer (function_name == "transfer") + if (functionName === 'transfer') { + const recipientParam = searchParams.get('address') + if (!recipientParam) { + return undefined + } + + const recipient = getValidAddress({ + address: recipientParam, + platform: Platform.EVM, + withEVMChecksum: true, + log: false, + }) + if (!recipient) { + return undefined + } + + const amountParam = searchParams.get('uint256') ?? searchParams.get('value') + const rawAmount = parseScientificOrIntString(amountParam) + + return { + chainId, + recipient, + tokenAddress: targetAddress, + rawAmount, + } + } + + // Ignore unsupported smart contract execution function calls per safety guidance + return undefined + } catch (error) { + logger.error('Failed to parse ERC-681 URI', { + tags: { file: 'erc681.ts', function: 'parseERC681URI' }, + extra: { uri, error }, + }) + return undefined + } +}