diff --git a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx index 191223f331..114fa3950e 100644 --- a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionHistoryTable.tsx @@ -31,6 +31,14 @@ import { TransactionsTableRow } from './TransactionsTableRow' import { EmptyTransactionHistory } from './EmptyTransactionHistory' import { Address } from '../../util/AddressUtils' +export const BatchTransferEthTooltip = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ) +} + export const ContentWrapper = ({ children, className = '' diff --git a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableDetails.tsx b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableDetails.tsx index 196d46fedc..755a7a1475 100644 --- a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableDetails.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableDetails.tsx @@ -6,6 +6,7 @@ import Image from 'next/image' import dayjs from 'dayjs' import CctpLogoColor from '@/images/CctpLogoColor.svg' import ArbitrumLogo from '@/images/ArbitrumLogo.svg' +import EthereumLogoRoundLight from '@/images/EthereumLogoRoundLight.svg' import { useTxDetailsStore } from './TransactionHistory' import { getExplorerUrl, getNetworkName, isNetwork } from '../../util/networks' @@ -22,6 +23,8 @@ import { shortenAddress } from '../../util/CommonUtils' import { isTxCompleted } from './helpers' import { Address } from '../../util/AddressUtils' import { sanitizeTokenSymbol } from '../../util/TokenUtils' +import { isBatchTransfer } from '../../util/TokenDepositUtils' +import { BatchTransferEthTooltip } from './TransactionHistoryTable' const DetailsBox = ({ children, @@ -135,17 +138,41 @@ export const TransactionsTableDetails = ({ {dayjs(tx.createdAt).format('MMMM DD, YYYY')} {dayjs(tx.createdAt).format('h:mma')} -
- - - {formatAmount(Number(tx.value), { - symbol: tokenSymbol - })} - - {showPriceInUsd && ( - - {formatUSD(ethToUSD(Number(tx.value)))} +
+
+ + + {formatAmount(Number(tx.value), { + symbol: tokenSymbol + })} + {showPriceInUsd && ( + + {formatUSD(ethToUSD(Number(tx.value)))} + + )} +
+ {isBatchTransfer(tx) && ( + +
+ ETH logo + + {formatAmount(Number(tx.value2), { + symbol: ether.symbol + })} + + {isNetwork(tx.sourceChainId).isEthereumMainnet && ( + + {formatUSD(ethToUSD(Number(tx.value2)))} + + )} +
+
)}
diff --git a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableRow.tsx b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableRow.tsx index 9c55e7fef3..a153c0682c 100644 --- a/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableRow.tsx +++ b/packages/arb-token-bridge-ui/src/components/TransactionHistory/TransactionsTableRow.tsx @@ -7,6 +7,8 @@ import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline' +import EthereumLogoRoundLight from '@/images/EthereumLogoRoundLight.svg' +import Image from 'next/image' import { DepositStatus, MergedTransaction } from '../../state/app/state' import { formatAmount } from '../../util/NumberUtils' @@ -28,6 +30,9 @@ import { TransactionsTableTokenImage } from './TransactionsTableTokenImage' import { useTxDetailsStore } from './TransactionHistory' import { TransactionsTableExternalLink } from './TransactionsTableExternalLink' import { Address } from '../../util/AddressUtils' +import { ether } from '../../constants' +import { isBatchTransfer } from '../../util/TokenDepositUtils' +import { BatchTransferEthTooltip } from './TransactionHistoryTable' const StatusLabel = ({ tx }: { tx: MergedTransaction }) => { const { sourceChainId, destinationChainId } = tx @@ -170,18 +175,37 @@ export function TransactionsTableRow({ )} >
{txRelativeTime}
-
- - - - {formatAmount(Number(tx.value), { - symbol: tokenSymbol - })} - - +
+
+ + + + {formatAmount(Number(tx.value), { + symbol: tokenSymbol + })} + + +
+ {isBatchTransfer(tx) && ( + +
+ ETH logo + + {formatAmount(Number(tx.value2), { + symbol: ether.symbol + })} + +
+
+ )}
0 + const timestampCreated = Math.floor(Date.now() / 1000).toString() const txHistoryCompatibleObject = convertBridgeSdkToMergedTransaction({ @@ -941,6 +943,7 @@ export function TransferPanel() { destinationAddress, nativeCurrency, amount: amountBigNumber, + amount2: isBatchTransfer ? utils.parseEther(amount2) : undefined, timestampCreated }) @@ -959,6 +962,7 @@ export function TransferPanel() { destinationAddress, nativeCurrency, amount: amountBigNumber, + amount2: isBatchTransfer ? utils.parseEther(amount2) : undefined, timestampCreated }) ) diff --git a/packages/arb-token-bridge-ui/src/components/TransferPanel/bridgeSdkConversionUtils.ts b/packages/arb-token-bridge-ui/src/components/TransferPanel/bridgeSdkConversionUtils.ts index 1e33ea38f5..de4174c18c 100644 --- a/packages/arb-token-bridge-ui/src/components/TransferPanel/bridgeSdkConversionUtils.ts +++ b/packages/arb-token-bridge-ui/src/components/TransferPanel/bridgeSdkConversionUtils.ts @@ -23,6 +23,7 @@ type SdkToUiConversionProps = { destinationAddress?: string nativeCurrency: NativeCurrency amount: BigNumber + amount2?: BigNumber timestampCreated: string } @@ -34,7 +35,8 @@ export const convertBridgeSdkToMergedTransaction = ({ walletAddress, destinationAddress, nativeCurrency, - amount + amount, + amount2 }: SdkToUiConversionProps): MergedTransaction => { const { transferType } = bridgeTransfer const isDeposit = @@ -57,6 +59,7 @@ export const convertBridgeSdkToMergedTransaction = ({ amount, selectedToken ? selectedToken.decimals : nativeCurrency.decimals ), + value2: amount2 ? utils.formatEther(amount2) : undefined, depositStatus: isDeposit ? DepositStatus.L1_PENDING : undefined, uniqueId: null, isWithdrawal: !isDeposit, @@ -78,6 +81,7 @@ export const convertBridgeSdkToPendingDepositTransaction = ({ nativeCurrency, destinationAddress, amount, + amount2, timestampCreated }: SdkToUiConversionProps): Deposit => { const transaction = @@ -95,6 +99,7 @@ export const convertBridgeSdkToPendingDepositTransaction = ({ amount, selectedToken ? selectedToken.decimals : nativeCurrency.decimals ), + value2: amount2 ? utils.formatEther(amount2) : undefined, parentChainId, childChainId, direction: 'deposit', diff --git a/packages/arb-token-bridge-ui/src/hooks/useTransactions.ts b/packages/arb-token-bridge-ui/src/hooks/useTransactions.ts index bcfe7d054c..b2fd7b83b6 100644 --- a/packages/arb-token-bridge-ui/src/hooks/useTransactions.ts +++ b/packages/arb-token-bridge-ui/src/hooks/useTransactions.ts @@ -89,6 +89,7 @@ type TransactionBase = { type: TxnType status?: TxnStatus value: string | null + value2?: string txID?: string assetName: string assetType: AssetType diff --git a/packages/arb-token-bridge-ui/src/state/app/state.ts b/packages/arb-token-bridge-ui/src/state/app/state.ts index 3c832e064b..3e42176cea 100644 --- a/packages/arb-token-bridge-ui/src/state/app/state.ts +++ b/packages/arb-token-bridge-ui/src/state/app/state.ts @@ -53,6 +53,7 @@ export interface MergedTransaction { asset: string assetType: AssetType value: string | null + value2?: string uniqueId: BigNumber | null isWithdrawal: boolean blockNum: number | null diff --git a/packages/arb-token-bridge-ui/src/state/app/utils.ts b/packages/arb-token-bridge-ui/src/state/app/utils.ts index ab48e5ca39..e6e9580fc6 100644 --- a/packages/arb-token-bridge-ui/src/state/app/utils.ts +++ b/packages/arb-token-bridge-ui/src/state/app/utils.ts @@ -153,6 +153,7 @@ export const transformDeposit = ( asset: tx.assetName || '', assetType: tx.assetType, value: tx.value, + value2: tx.value2, uniqueId: null, // not needed isWithdrawal: false, blockNum: tx.blockNumber || null, diff --git a/packages/arb-token-bridge-ui/src/util/TokenDepositUtils.ts b/packages/arb-token-bridge-ui/src/util/TokenDepositUtils.ts index c6ab9300f3..39ecfd63b8 100644 --- a/packages/arb-token-bridge-ui/src/util/TokenDepositUtils.ts +++ b/packages/arb-token-bridge-ui/src/util/TokenDepositUtils.ts @@ -8,10 +8,12 @@ import { fetchErc20ParentChainGatewayAddress, getL2ERC20Address } from './TokenUtils' -import { DepositGasEstimates } from '../hooks/arbTokenBridge.types' +import { AssetType, DepositGasEstimates } from '../hooks/arbTokenBridge.types' import { addressIsSmartContract } from './AddressUtils' import { getChainIdFromProvider } from '../token-bridge-sdk/utils' import { captureSentryErrorWithExtraData } from './SentryUtils' +import { MergedTransaction } from '../state/app/state' +import { isExperimentalFeatureEnabled } from '.' async function fetchTokenFallbackGasEstimates({ inboxAddress, @@ -216,3 +218,14 @@ async function addressIsCustomGatewayToken({ childChainNetwork.tokenBridge?.parentCustomGateway.toLowerCase() ) } + +export function isBatchTransfer(tx: MergedTransaction) { + return ( + isExperimentalFeatureEnabled('batch') && + !tx.isCctp && + !tx.isWithdrawal && + tx.assetType === AssetType.ERC20 && + typeof tx.value2 !== 'undefined' && + Number(tx.value2) > 0 + ) +} diff --git a/packages/arb-token-bridge-ui/src/util/deposits/helpers.ts b/packages/arb-token-bridge-ui/src/util/deposits/helpers.ts index 185cb08479..52586e5e27 100644 --- a/packages/arb-token-bridge-ui/src/util/deposits/helpers.ts +++ b/packages/arb-token-bridge-ui/src/util/deposits/helpers.ts @@ -8,8 +8,9 @@ import { EthL1L3DepositStatus, Erc20L1L3DepositStatus } from '@arbitrum/sdk' +import { utils } from 'ethers' -import { Provider } from '@ethersproject/providers' +import { Provider, TransactionReceipt } from '@ethersproject/providers' import { AssetType } from '../../hooks/arbTokenBridge.types' import { ParentToChildMessageData, @@ -110,14 +111,125 @@ export const updateAdditionalDepositData = async ({ }) } - // finally, else if the transaction is not ETH ie. it's a ERC20 token deposit - return updateTokenDepositStatusData({ + // ERC-20 deposit + const tokenDeposit = await updateTokenDepositStatusData({ depositTx, l1ToL2Msg: l1ToL2Msg as ParentToChildMessageReader, timestampCreated, l1Provider, l2Provider }) + + // check local storage first, fallback to fetching on chain + if (depositTx.value2) { + return { ...tokenDeposit, value2: depositTx.value2 } + } + + const { value2 } = await getBatchTransferDepositData({ + l1ToL2Msg: l1ToL2Msg as ParentToChildMessageReader, + depositStatus: tokenDeposit.status + }) + + return { + ...tokenDeposit, + value2 + } +} + +const getBatchTransferDepositData = async ({ + l1ToL2Msg, + depositStatus +}: { + l1ToL2Msg: ParentToChildMessageReader + depositStatus: TxnStatus | undefined +}): Promise<{ + value2: Transaction['value2'] +}> => { + if (!isPotentialBatchTransfer({ l1ToL2Msg })) { + return { value2: undefined } + } + + // get maxSubmissionCost, which is the amount of ETH sent in batched ERC-20 deposit + max gas cost + const maxSubmissionCost = Number( + utils.formatEther(l1ToL2Msg.messageData.maxSubmissionFee.toString()) + ) + + // we deduct gas cost from max submission fee, which leaves us with amount2 (extra ETH sent with ERC-20) + if (depositStatus === 'success') { + // if success, we use the actual gas cost + const retryableFee = await getRetryableFee({ + l1ToL2Msg + }) + + if (!retryableFee) { + return { value2: undefined } + } + + return { value2: String(Number(maxSubmissionCost) - Number(retryableFee)) } + } + + // when not success, we don't know the final gas cost yet so we use estimates + const estimatedRetryableFee = utils.formatEther( + l1ToL2Msg.messageData.gasLimit.mul(l1ToL2Msg.messageData.maxFeePerGas) + ) + + return { + value2: String(Number(maxSubmissionCost) - Number(estimatedRetryableFee)) + } +} + +const isPotentialBatchTransfer = ({ + l1ToL2Msg +}: { + l1ToL2Msg: ParentToChildMessageReader +}) => { + const { maxSubmissionFee, gasLimit, maxFeePerGas } = l1ToL2Msg.messageData + + const estimatedGas = gasLimit.mul(maxFeePerGas) + + const maxSubmissionFeeNumber = Number(utils.formatEther(maxSubmissionFee)) + const estimatedGasNumber = Number(utils.formatEther(estimatedGas)) + + const excessGasFee = maxSubmissionFeeNumber - estimatedGasNumber + const percentageGasUsed = (estimatedGasNumber / maxSubmissionFeeNumber) * 100 + + // heuristic for determining if it's a batch transfer (based on maxSubmissionFee) + return excessGasFee >= 0.001 && percentageGasUsed < 10 +} + +const getRetryableFee = async ({ + l1ToL2Msg +}: { + l1ToL2Msg: ParentToChildMessageReader +}) => { + const autoRedeemReceipt = ( + (await l1ToL2Msg.getSuccessfulRedeem()) as { + status: ParentToChildMessageStatus.REDEEMED + childTxReceipt: TransactionReceipt + } + ).childTxReceipt + + if (!autoRedeemReceipt) { + return undefined + } + + const autoRedeemGas = autoRedeemReceipt.gasUsed.mul( + autoRedeemReceipt.effectiveGasPrice + ) + + const retryableCreationReceipt = await l1ToL2Msg.getRetryableCreationReceipt() + + if (!retryableCreationReceipt) { + return undefined + } + + const retryableCreationGas = retryableCreationReceipt.gasUsed.mul( + retryableCreationReceipt.effectiveGasPrice + ) + + const gasUsed = autoRedeemGas.add(retryableCreationGas) + + return utils.formatEther(gasUsed) } const updateETHDepositStatusData = async ({