diff --git a/src/design-system/components/Alert/Alert.tsx b/src/design-system/components/Alert/Alert.tsx index 54cd4b200e..34f1673c09 100644 --- a/src/design-system/components/Alert/Alert.tsx +++ b/src/design-system/components/Alert/Alert.tsx @@ -82,17 +82,30 @@ export const Alert = () => { return ( - + - + {alert.text} {alert.description && ( - - {alert.description} - + + + {alert.description} + + )} diff --git a/src/entries/popup/pages/messages/SendTransaction/SendTransactionsInfo.tsx b/src/entries/popup/pages/messages/SendTransaction/SendTransactionsInfo.tsx index b509cd203b..ded9a4b337 100644 --- a/src/entries/popup/pages/messages/SendTransaction/SendTransactionsInfo.tsx +++ b/src/entries/popup/pages/messages/SendTransaction/SendTransactionsInfo.tsx @@ -1,4 +1,3 @@ -import { TransactionRequest } from '@ethersproject/abstract-provider'; import { AnimatePresence, motion } from 'framer-motion'; import { ReactNode, memo, useState } from 'react'; import { Address } from 'viem'; @@ -16,6 +15,7 @@ import { useTestnetModeStore } from '~/core/state/currentSettings/testnetMode'; import { useSelectedTokenStore } from '~/core/state/selectedToken'; import { ProviderRequestPayload } from '~/core/transports/providerRequestTransport'; import { ChainId } from '~/core/types/chains'; +import { truncateAddress } from '~/core/utils/address'; import { getChain } from '~/core/utils/chains'; import { copy, copyAddress } from '~/core/utils/copy'; import { getFaucetsUrl } from '~/core/utils/faucets'; @@ -58,20 +58,20 @@ import { SimulationOverview } from '../Simulation'; import { CopyButton, TabContent, Tabs } from '../Tabs'; import { useHasEnoughGas } from '../useHasEnoughGas'; import { - SimulationError, + SimulationQueryResult, TransactionSimulation, - useSimulateTransaction, } from '../useSimulateTransaction'; import { + getChainIdForRequest, getSendCallsParams, - getTransactionRequestFromRequest, getTransactionRequestsFromRequest, isWalletSendCallsRequest, } from './normalizeRequest'; interface SendTransactionProps { request: ProviderRequestPayload; + simulationResult?: SimulationQueryResult; onRejectRequest: ({ preventWindowClose, }: { @@ -119,15 +119,11 @@ const InfoRow = ({ const Overview = memo(function Overview({ chainId, - simulation, - status, - error, + simulationResult, metadata, }: { chainId: ChainId; - simulation: TransactionSimulation | undefined; - status: 'pending' | 'error' | 'success'; - error: SimulationError | null; + simulationResult?: SimulationQueryResult; metadata: DappMetadata | null; }) { const { badge, color } = getDappStatusBadge( @@ -136,6 +132,13 @@ const Overview = memo(function Overview({ ); const chainName = getChain({ chainId }).name; + const simulation = simulationResult?.data; + const status = + simulationResult?.status === 'error' && simulationResult?.isRefetching + ? 'pending' + : simulationResult?.status ?? 'pending'; + const error = simulationResult?.error ?? null; + return ( @@ -185,21 +188,23 @@ const Overview = memo(function Overview({ ); }); -const TransactionDetails = memo(function TransactionDetails({ - simulation, +const CallDetails = memo(function CallDetails({ + meta, session, + callIndex, + isBatch, }: { - simulation: TransactionSimulation; + meta: TransactionSimulation['metas'][number]; session: { address: Address; chainId: ChainId }; + callIndex: number; + isBatch: boolean; }) { - const metaTo = simulation.meta?.to; - const metaTransferTo = simulation.meta?.transferTo; + const metaTo = meta?.to; + const metaTransferTo = meta?.transferTo; const isContract = metaTo?.function || metaTo?.created; - const nonce = useNonceStore((s) => s.getNonce(session)?.currentNonce); - - const functionName = metaTo?.function.split('(')[0]; + const functionName = metaTo?.function?.split('(')[0]; const contract = metaTo && { address: metaTo.address as Address, name: metaTo.name, @@ -209,69 +214,105 @@ const TransactionDetails = memo(function TransactionDetails({ const contractCreatedAt = metaTo?.created; return ( - - {metaTransferTo && ( - - } - /> - )} - {contract && ( - - } - /> - )} - {functionName && ( - - {functionName} - - } - /> - )} - {metaTo?.sourceCodeStatus && ( - - {isSourceCodeVerified ? i18n.t('verified') : i18n.t('unverified')} - - } - /> + + {isBatch && ( + + {i18n.t('approve_request.batch_call_label', { index: callIndex })} + )} - {contractCreatedAt && ( - + {metaTransferTo && ( + + } + /> + )} + {contract && ( + + } + /> + )} + {functionName && ( + + {functionName} + + } + /> + )} + {metaTo?.sourceCodeStatus && ( + + {isSourceCodeVerified + ? i18n.t('verified') + : i18n.t('unverified')} + + } + /> + )} + {contractCreatedAt && ( + + )} + + + ); +}); + +const TransactionDetails = memo(function TransactionDetails({ + simulation, + session, +}: { + simulation: TransactionSimulation; + session: { address: Address; chainId: ChainId }; +}) { + const nonce = useNonceStore((s) => s.getNonce(session)?.currentNonce); + const metas = simulation.metas; + + return ( + + {metas.map((meta, index) => ( + 1} /> - )} - {!!nonce && ( + ))} + {!!nonce && metas.length <= 1 && ( )} @@ -304,6 +345,58 @@ const TransactionData = memo(function TransactionData({ ); }); +const BatchTransactionData = memo(function BatchTransactionData({ + callsData, + expanded, +}: { + callsData: Array<{ to?: string; data: string; value?: string }>; + expanded: boolean; +}) { + const allDataCopyValue = callsData + .map( + (call, i) => + `${i18n.t('approve_request.batch_call_label', { index: i + 1 })}${ + call.to ? ` → ${call.to}` : '' + }\n${call.data}`, + ) + .join('\n\n'); + + return ( + + + {callsData.map((call, index) => ( + + + {i18n.t('approve_request.batch_call_label', { + index: index + 1, + })} + {call.to ? ` → ${truncateAddress(call.to as Address)}` : ''} + + + {call.data} + + + ))} + + + copy({ + value: allDataCopyValue, + title: i18n.t('approve_request.transaction_data_copied'), + description: + callsData.length > 1 + ? i18n.t('approve_request.batch_of_calls', { + count: callsData.length, + }) + : truncateString(callsData[0]?.data ?? '', 20), + }) + } + /> + + ); +}); + function BalanceLoadingSkeleton() { const { currentTheme } = useCurrentThemeStore(); const overflowGradient = @@ -345,46 +438,45 @@ function BalanceLoadingSkeleton() { function TransactionInfo({ request, - dappUrl, dappMetadata, expanded, onExpand, + simulationResult, }: { - request: TransactionRequest; - dappUrl: string; + request: ProviderRequestPayload; dappMetadata: DappMetadata | null; expanded: boolean; onExpand: VoidFunction; + simulationResult?: SimulationQueryResult; }) { const { activeSession } = useAppSession({ host: dappMetadata?.appHost }); - const chainId = activeSession?.chainId || ChainId.mainnet; - - const txData = request?.data?.toString() || ''; - - const { - data: simulation, - status, - error, - isRefetching, - } = useSimulateTransaction({ - chainId, - transaction: { - from: request.from || '', - to: request.to || '', - value: request.value?.toString() || '0', - data: request.data?.toString() || '', - }, - domain: dappUrl, - }); + + const transactionRequests = getTransactionRequestsFromRequest(request); + const sendParams = getSendCallsParams(request); + const chainId = getChainIdForRequest(request, activeSession?.chainId); + const isBatch = (transactionRequests?.length ?? 0) > 1; + + const callsData = + sendParams?.calls.map((call) => ({ + to: call.to, + data: call.data ?? '0x', + value: call.value, + })) ?? []; + + const simulation = simulationResult?.data; const tabLabel = (tab: string) => i18n.t(tab, { scope: 'simulation.tabs' }); + const dappBadge = dappMetadata + ? getDappStatusBadge(dappMetadata?.status || DAppStatus.Unverified, { + size: 12, + }) + : null; return ( <> {simulation && ( - + + {chainId && getChain({ chainId }).name && ( + + + + {getChain({ chainId }).name} + + + } + /> + )} + {dappMetadata && dappBadge && ( + {dappBadge.badge} + ) + } + > + {dappMetadata.appName} + + } + /> + )} + + )} - + {isBatch ? ( + + ) : ( + + )} @@ -621,6 +758,7 @@ function InsuficientGasFunds({ export function SendTransactionInfo({ request, + simulationResult, onRejectRequest, }: SendTransactionProps) { const dappUrl = request?.meta?.sender?.url || ''; @@ -750,11 +888,11 @@ export function SendTransactionInfo({ ) ) : getTransactionRequestsFromRequest(request) ? ( setExpanded((e) => !e)} + simulationResult={simulationResult} /> ) : null} diff --git a/src/entries/popup/pages/messages/SendTransaction/index.tsx b/src/entries/popup/pages/messages/SendTransaction/index.tsx index 8f6af93cb5..3223204fe7 100644 --- a/src/entries/popup/pages/messages/SendTransaction/index.tsx +++ b/src/entries/popup/pages/messages/SendTransaction/index.tsx @@ -1,4 +1,3 @@ -import { TransactionRequest } from '@ethersproject/abstract-provider'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Address, getAddress } from 'viem'; @@ -6,6 +5,7 @@ import { analytics } from '~/analytics'; import { event } from '~/analytics/event'; import { getWalletContext } from '~/analytics/util'; import config from '~/core/firebase/remoteConfig'; +import { DAppStatus } from '~/core/graphql/__generated__/metadata'; import { i18n } from '~/core/languages'; import { useDappMetadata } from '~/core/resources/metadata/dapp'; import { useGasStore } from '~/core/state'; @@ -14,7 +14,6 @@ import { useFeatureFlagLocalOverwriteStore } from '~/core/state/currentSettings/ import { useNetworkStore } from '~/core/state/networks/networks'; import { ProviderRequestPayload } from '~/core/transports/providerRequestTransport'; import { AddressOrEth } from '~/core/types/assets'; -import { ChainId } from '~/core/types/chains'; import { NewTransaction, TxHash } from '~/core/types/transactions'; import { chainIdToUse } from '~/core/utils/chains'; import { getDappHost } from '~/core/utils/connectedApps'; @@ -33,12 +32,15 @@ import { RainbowError, logger } from '~/logger'; import { popupClient } from '../../../handlers/background'; import * as wallet from '../../../handlers/wallet'; import { AccountSigningWith } from '../AccountSigningWith'; +import { useSimulateTransactions } from '../useSimulateTransaction'; import { SendTransactionActions } from './SendTransactionActions'; import { SendTransactionInfo } from './SendTransactionsInfo'; import { + getChainIdForRequest, getSendCallsParams, getTransactionRequestFromRequest, + getTransactionRequestsFromRequest, isWalletSendCallsRequest, } from './normalizeRequest'; @@ -76,6 +78,32 @@ export function SendTransaction({ const { allWallets, watchedWallets } = useWallets(); const { featureFlags } = useFeatureFlagLocalOverwriteStore(); + const transactionRequests = useMemo( + () => getTransactionRequestsFromRequest(request), + [request], + ); + + const chainId = getChainIdForRequest(request, activeSession?.chainId); + const transactions = useMemo( + () => + transactionRequests?.map((tx) => ({ + from: tx.from?.toString() ?? '', + to: tx.to?.toString() ?? '', + value: tx.value?.toString() ?? '0', + data: tx.data?.toString() ?? '0x', + })) ?? [], + [transactionRequests], + ); + const simulationResult = useSimulateTransactions({ + chainId, + transactions, + domain: request?.meta?.sender?.url || '', + }); + const effectiveDappStatus = + simulationResult.data?.scanning.result !== 'OK' + ? DAppStatus.Scam + : dappMetadata?.status; + const onAcceptRequest = useCallback(async () => { if (!config.tx_requests_enabled) return; if (!selectedWallet || !activeSession) return; @@ -299,36 +327,49 @@ export function SendTransaction({ flexDirection="column" style={{ height: POPUP_DIMENSIONS.height, overflow: 'hidden' }} > - + - + {transactionRequests?.length ? ( + { + triggerAlert({ + text: i18n.t('approve_request.batch_fee_note'), + }); + }, + } + : undefined + } + /> + ) : null} diff --git a/src/entries/popup/pages/messages/Simulation.tsx b/src/entries/popup/pages/messages/Simulation.tsx index df199ab039..45dd45c5e3 100644 --- a/src/entries/popup/pages/messages/Simulation.tsx +++ b/src/entries/popup/pages/messages/Simulation.tsx @@ -71,7 +71,7 @@ function SimulatedChangeRow({ const assetPrice = assetDataWithPrice?.price?.value; return ( {quantity !== 'UNLIMITED' && !!assetPrice ? ( - - + + { convertRawAmountToNativeDisplay( quantity, @@ -92,33 +92,45 @@ function SimulatedChangeRow({ currentCurrency, )?.display } - + ) : null} - - {asset?.type === 'nft' ? ( - - ) : ( - - )} - - - {quantity === 'UNLIMITED' || +changeAmount > 999e12 // say unlimited if more than 999T - ? i18n.t('approvals.unlimited') - : formatNumber(changeAmount)}{' '} - - - {asset.symbol} - + + + {asset?.type === 'nft' ? ( + + ) : ( + + )} + + 999e12 + ? i18n.t('approvals.unlimited') + : `${changeAmount} ${asset.symbol}` + } + > + + {quantity === 'UNLIMITED' || +changeAmount > 999e12 // say unlimited if more than 999T + ? i18n.t('approvals.unlimited') + : formatNumber(changeAmount)}{' '} + + + + {asset.symbol} + + - + ); } diff --git a/src/entries/popup/pages/messages/useSimulateTransaction.tsx b/src/entries/popup/pages/messages/useSimulateTransaction.tsx index ec18fe49cc..2d6c20796e 100644 --- a/src/entries/popup/pages/messages/useSimulateTransaction.tsx +++ b/src/entries/popup/pages/messages/useSimulateTransaction.tsx @@ -136,38 +136,100 @@ function parseSimulation( ...approval, asset: parseSimulationAsset(approval.asset, chainId), })), - meta: simulation.meta ?? null, + metas: simulation.meta ? [simulation.meta] : [], hasChanges: inChanges.length > 0 || outChanges.length > 0 || approvals.length > 0, }; } -export const useSimulateTransaction = ({ +/** Merges multiple simulation results into one (worst scanning, combined in/out/approvals) */ +function mergeSimulations( + results: TransactionSimulationResult[], + chainId: ChainId, +): TransactionSimulation { + const severity = (r: TransactionScanResultType) => + r === TransactionScanResultType.Malicious + ? 2 + : r === TransactionScanResultType.Warning + ? 1 + : 0; + + const worst = results.reduce((acc, r) => { + const accSev = severity( + acc.scanning?.result ?? TransactionScanResultType.Ok, + ); + const rSev = severity(r.scanning?.result ?? TransactionScanResultType.Ok); + return rSev > accSev ? r : acc; + }); + + const parsed = results.map((r) => parseSimulation(r, chainId)); + + const inChanges = parsed.flatMap((p) => p.in); + const outChanges = parsed.flatMap((p) => p.out); + const approvals = parsed.flatMap((p) => p.approvals); + const metas = parsed.flatMap((p) => p.metas); + + return { + chainId, + scanning: { + result: worst.scanning?.result ?? TransactionScanResultType.Ok, + description: parseScanningDescription( + (worst.scanning?.description ?? '').toLowerCase() as Lowercase, + ), + }, + in: inChanges, + out: outChanges, + approvals, + metas, + hasChanges: + inChanges.length > 0 || outChanges.length > 0 || approvals.length > 0, + }; +} + +/** + * Simulates one or more transactions using the backend's batch API. + * Works for both single (eth_sendTransaction) and batch (wallet_sendCalls) requests. + */ +export const useSimulateTransactions = ({ chainId, - transaction, + transactions, domain, }: { chainId: ChainId; - transaction: Transaction; + transactions: Transaction[]; domain: string; }) => { return useQuery({ - queryKey: createQueryKey('simulateTransaction', { - transaction, + queryKey: createQueryKey('simulateTransactions', { + transactions, chainId, domain, }), - enabled: !!chainId && (!!transaction.value || !!transaction.data), + enabled: + !!chainId && + !!transactions.length && + transactions.some((t) => t.value || t.data), queryFn: async () => { + const normalized = transactions.map((t) => ({ + ...t, + to: t.to || '', + })); const results = await simulateTransactions({ chainId, - transactions: [{ ...transaction, to: transaction.to || '' }], + transactions: normalized, domain, }); - if (!results[0]) throw 'UNSUPPORTED'; + if (results.length === 0) throw 'UNSUPPORTED'; + if (results.some((r) => r?.error)) { + const firstError = results.find((r) => r?.error); + if (firstError?.error) throw firstError.error.type; + } - return parseSimulation(results[0], chainId); + return mergeSimulations( + results as TransactionSimulationResult[], + chainId, + ); }, staleTime: 60 * 1000, // 1 min }); @@ -227,9 +289,17 @@ export type TransactionSimulation = { result: TransactionScanResultType; description: string; }; - meta: SimulationMeta | null; + metas: SimulationMeta[]; hasChanges: boolean; chainId: ChainId; }; export type SimulationError = 'REVERT' | 'UNSUPPORTED'; + +/** Shape passed down from useSimulateTransactions for consumers that need simulation state */ +export type SimulationQueryResult = { + data?: TransactionSimulation; + status: 'pending' | 'error' | 'success'; + error: SimulationError | null; + isRefetching: boolean; +};