diff --git a/apps/bridge/.env.goerli.example b/apps/bridge/.env.goerli.example index c1b5f8b..075e4be 100644 --- a/apps/bridge/.env.goerli.example +++ b/apps/bridge/.env.goerli.example @@ -1,4 +1,4 @@ -ASSETS=eth,cbeth,comp +ASSETS=eth,cbeth,comp,usdc L1_CHAIN_ID=5 L1_EXPLORER_URL=https://goerli.etherscan.io L1_EXPLORER_API_URL=https://api-goerli.etherscan.io/api @@ -10,6 +10,12 @@ L1_OPTIMISM_PORTAL_PROXY_ADDRESS=0xe93c8cD0D409341205A592f8c4Ac1A5fe5585cfA L2_L1_MESSAGE_PASSER_ADDRESS=0x4200000000000000000000000000000000000016 L2_STANDARD_BRIDGE=0x4200000000000000000000000000000000000010 L2_OUTPUT_ORACLE_PROXY_ADDRESS=0x2A35891ff30313CcFa6CE88dcf3858bb075A2298 +L1_CCTP_MESSAGE_TRANSMITTER_ADDRESS=0x26413e8157cd32011e726065a5462e97dd4d03d9 +L1_CCTP_TOKEN_MESSENGER_ADDRESS=0xd0c3da58f55358142b8d3e06c1c30c5c6114efe8 +L2_CCTP_MESSAGE_TRANSMITTER_ADDRESS=0x9ff9a4da6f2157A9c82CE756f8fD7E0d75be8895 +L2_CCTP_TOKEN_MESSENGER_ADDRESS=0x877b8e8c9e2383077809787ED6F279ce01CB4cc8 +L1_CCTP_DOMAIN=0 +L2_CCTP_DOMAIN=6 MARKETING_URL=https://base.org DOCS_URL=https://docs.base.org BLOG_URL=https://base.mirror.xyz @@ -25,3 +31,5 @@ TOS_VERSION=0x01 MAINNET_GA_LAUNCH_FLAG=true WALLET_CONNECT_PROJECT_ID= COMPLIANCE_API_URL=https://bridge-api.base.org +CCTP_ATTESTATIONS_API_URL=https://iris-api-sandbox.circle.com +CCTP_ENABLED=true \ No newline at end of file diff --git a/apps/bridge/.eslintrc.js b/apps/bridge/.eslintrc.js index ad8092e..f4555b9 100644 --- a/apps/bridge/.eslintrc.js +++ b/apps/bridge/.eslintrc.js @@ -13,5 +13,6 @@ module.exports = { rules: { // Does not work with `:` aliases 'import/extensions': 'off', + 'react/prop-types': 'off', }, }; diff --git a/apps/bridge/assets.ts b/apps/bridge/assets.ts index 8c0cb46..f72714d 100644 --- a/apps/bridge/assets.ts +++ b/apps/bridge/assets.ts @@ -10,6 +10,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/eth.svg', L2icon: '/icons/currency/eth.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'cbETH', @@ -22,6 +23,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/cbeth.svg', L2icon: '/icons/currency/cbeth.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'DAI', @@ -34,6 +36,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/dai.svg', L2icon: '/icons/currency/dai.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'USDC', @@ -46,6 +49,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/usdc.svg', L2icon: '/icons/currency/usdbc.svg', decimals: 6, + protocol: 'OP', }, { L1symbol: 'COMP', @@ -58,6 +62,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/comp.svg', L2icon: '/icons/currency/comp.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'BAL', @@ -70,6 +75,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/balancer.svg', L2icon: '/icons/currency/balancer.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'RPL', @@ -82,6 +88,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/rocket-pool.png', L2icon: '/icons/currency/rocket-pool.png', decimals: 18, + protocol: 'OP', }, { L1symbol: 'rETH', @@ -94,6 +101,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/rocket-pool-eth.svg', L2icon: '/icons/currency/rocket-pool-eth.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'SOFI', @@ -106,6 +114,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/sofi.png', L2icon: '/icons/currency/sofi.png', decimals: 18, + protocol: 'OP', }, { L1symbol: 'ZRX', @@ -118,6 +127,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/0x.svg', L2icon: '/icons/currency/0x.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'SUSHI', @@ -130,6 +140,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/sushi.svg', L2icon: '/icons/currency/sushi.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'CRV', @@ -142,6 +153,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/curve.svg', L2icon: '/icons/currency/curve.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: '1INCH', @@ -154,6 +166,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/1inch.svg', L2icon: '/icons/currency/1inch.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'WAMPL', @@ -166,6 +179,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/wampl.svg', L2icon: '/icons/currency/wampl.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'KNC', @@ -178,6 +192,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/knc.svg', L2icon: '/icons/currency/knc.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'ETH', @@ -188,6 +203,20 @@ const assets: Asset[] = [ L1icon: '/icons/currency/eth.svg', L2icon: '/icons/currency/eth.svg', decimals: 18, + protocol: 'OP', + }, + { + L1symbol: 'USDC', + L2symbol: 'USDbC', + L1chainId: 5, + L2chainId: 84531, + L1contract: '0x07865c6E87B9F70255377e024ace6630C1Eaa37F', + L2contract: '0x853154e2A5604E5C74a2546E2871Ad44932eB92C', + apiId: 'usd-coin', + L1icon: '/icons/currency/usdc.svg', + L2icon: '/icons/currency/usdbc.svg', + decimals: 6, + protocol: 'OP', }, { L1symbol: 'cbETH', @@ -200,6 +229,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/cbeth.svg', L2icon: '/icons/currency/cbeth.svg', decimals: 18, + protocol: 'OP', }, { L1symbol: 'COMP', @@ -212,6 +242,20 @@ const assets: Asset[] = [ L1icon: '/icons/currency/comp.svg', L2icon: '/icons/currency/comp.svg', decimals: 18, + protocol: 'OP', + }, + { + L1symbol: 'USDC', + L2symbol: 'USDC', + L1chainId: 5, + L2chainId: 84531, + L1contract: '0x07865c6e87b9f70255377e024ace6630c1eaa37f', + L2contract: '0xf175520c52418dfe19c8098071a252da48cd1c19', + apiId: 'usd-coin', + L1icon: '/icons/currency/usdc.svg', + L2icon: '/icons/currency/usdc.svg', + decimals: 6, + protocol: 'CCTP', }, { L1symbol: 'ETH', @@ -222,6 +266,7 @@ const assets: Asset[] = [ L1icon: '/icons/currency/eth.svg', L2icon: '/icons/currency/eth.svg', decimals: 18, + protocol: 'OP', }, ]; diff --git a/apps/bridge/global.d.ts b/apps/bridge/global.d.ts index 76d2a6c..c32ab58 100644 --- a/apps/bridge/global.d.ts +++ b/apps/bridge/global.d.ts @@ -14,6 +14,12 @@ declare module 'next/config' { l2L1MessagePasserAddress: `0x${string}`; L2StandardBridge: `0x${string}`; l2OutputOracleProxyAddress: `0x${string}`; + l1CCTPMessageTransmitterAddress: `0x${string}`; + l1CCTPTokenMessengerAddress: `0x${string}`; + l2CCTPMessageTransmitterAddress: `0x${string}`; + l2CCTPTokenMessengerAddress: `0x${string}`; + l1CCTPDomain: string; + l2CCTPDomain: string; marketingURL: string; docsURL: string; blogURL: string; @@ -33,6 +39,8 @@ declare module 'next/config' { appStage: string; complianceApiURL: string; sepoliaBridgeURL: string; + cctpAttestationsAPIURL: string; + cctpEnabled: string; }; }; diff --git a/apps/bridge/next.config.js b/apps/bridge/next.config.js index 58eafb6..305ae86 100644 --- a/apps/bridge/next.config.js +++ b/apps/bridge/next.config.js @@ -75,6 +75,8 @@ const contentSecurityPolicy = { 'https://sepolia.etherscan.io', // Sepolia Etherscan 'https://api-sepolia.etherscan.io/api', // Sepolia Etherscan API 'https://base-sepolia.blockscout.com', // Sepolia Blockscout + 'https://base-goerli.blockscout.com/api', // Blockscout + 'https://iris-api-sandbox.circle.com/attestations/', // Circle ], 'img-src': ["'self'", 'data:', 'https://*.walletconnect.com/'], // WalletConnect, }; @@ -161,6 +163,12 @@ module.exports = extendBaseConfig({ l2L1MessagePasserAddress: process.env.L2_L1_MESSAGE_PASSER_ADDRESS, L2StandardBridge: process.env.L2_STANDARD_BRIDGE, l2OutputOracleProxyAddress: process.env.L2_OUTPUT_ORACLE_PROXY_ADDRESS, + l1CCTPMessageTransmitterAddress: process.env.L1_CCTP_MESSAGE_TRANSMITTER_ADDRESS, + l1CCTPTokenMessengerAddress: process.env.L1_CCTP_TOKEN_MESSENGER_ADDRESS, + l2CCTPMessageTransmitterAddress: process.env.L2_CCTP_MESSAGE_TRANSMITTER_ADDRESS, + l2CCTPTokenMessengerAddress: process.env.L2_CCTP_TOKEN_MESSENGER_ADDRESS, + l1CCTPDomain: process.env.L1_CCTP_DOMAIN, + l2CCTPDomain: process.env.L2_CCTP_DOMAIN, marketingURL: process.env.MARKETING_URL, docsURL: process.env.DOCS_URL, twitterURL: process.env.TWITTER_URL, @@ -181,6 +189,8 @@ module.exports = extendBaseConfig({ appStage: process.env.APP_STAGE, complianceApiURL: process.env.COMPLIANCE_API_URL, sepoliaBridgeURL: process.env.SEPOLIA_BRIDGE_URL, + cctpAttestationsAPIURL: process.env.CCTP_ATTESTATIONS_API_URL, + cctpEnabled: process.env.CCTP_ENABLED, }, ...baseConfig, async headers() { diff --git a/apps/bridge/package.json b/apps/bridge/package.json index 6b0cce1..4b69530 100644 --- a/apps/bridge/package.json +++ b/apps/bridge/package.json @@ -28,7 +28,7 @@ "react-query": "^3.39.3", "typescript": "next", "viem": "latest", - "wagmi": "^1.4.3", + "wagmi": "^1.4.4", "webpack-bugsnag-plugins": "^1.8.0" }, "devDependencies": { diff --git a/apps/bridge/pages/transactions.tsx b/apps/bridge/pages/transactions.tsx index fba9dff..fc1e620 100644 --- a/apps/bridge/pages/transactions.tsx +++ b/apps/bridge/pages/transactions.tsx @@ -16,6 +16,7 @@ import { useDisclosure } from 'apps/bridge/src/utils/hooks/useDisclosure'; import { useBlockNumberOfLatestL2OutputProposal } from 'apps/bridge/src/utils/hooks/useBlockNumberOfLatestL2OutputProposal'; import Head from 'next/head'; import Image from 'next/image'; +import { FinalizeCCTPBridgeModal } from 'apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeModal'; const COLUMNS = ['Time', 'Type', 'Amount', 'Phase', 'Status']; @@ -27,7 +28,10 @@ const TransactionsTable = memo(function TransactionsTable({ transactions }: Tran const blockNumberOfLatestL2OutputProposal = useBlockNumberOfLatestL2OutputProposal(); const [modalProveTxHash, setModalProveTxHash] = useState<`0x${string}` | undefined>(undefined); - const [modalFinalizeTxHash, setModalFinalizeTxHash] = useState<`0x${string}` | undefined>( + const [modalFinalizeOPTxHash, setModalFinalizeOPTxHash] = useState<`0x${string}` | undefined>( + undefined, + ); + const [modalFinalizeCCTPTxHash, setModalFinalizeCCTPTxHash] = useState<`0x${string}` | undefined>( undefined, ); @@ -41,6 +45,11 @@ const TransactionsTable = memo(function TransactionsTable({ transactions }: Tran onOpen: onOpenFinalizeWithdrawalModal, onClose: onCloseFinalizeWithdrawalModal, } = useDisclosure(); + const { + isOpen: isFinalizeCCTPBridgeModalOpen, + onOpen: onOpenFinalizeCCTPBridgeModal, + onClose: onCloseFinalizeCCTPBridgeModal, + } = useDisclosure(); return (
@@ -52,13 +61,26 @@ const TransactionsTable = memo(function TransactionsTable({ transactions }: Tran + { if (transaction.type === 'Deposit') { - return ; + return ( + + ); } return ( ); })} diff --git a/apps/bridge/src/components/BridgeInput/BridgeInput.tsx b/apps/bridge/src/components/BridgeInput/BridgeInput.tsx index d16e23f..d4e4af7 100644 --- a/apps/bridge/src/components/BridgeInput/BridgeInput.tsx +++ b/apps/bridge/src/components/BridgeInput/BridgeInput.tsx @@ -87,7 +87,7 @@ export function BridgeInput({ return () => { onClose(); setSelectedAsset(asset); - setAmount('0'); + setAmount(''); }; }, [onClose, setAmount, setSelectedAsset], @@ -154,7 +154,7 @@ export function BridgeInput({ isDesktop ? { minWidth: 40, - width: amount.length * 37, + width: amount.length * 37 + 20, maxWidth: 16 * 31, } : { diff --git a/apps/bridge/src/components/CCTPBridgeProgressBar/CCTPBridgeProgressBar.tsx b/apps/bridge/src/components/CCTPBridgeProgressBar/CCTPBridgeProgressBar.tsx new file mode 100644 index 0000000..8fc349c --- /dev/null +++ b/apps/bridge/src/components/CCTPBridgeProgressBar/CCTPBridgeProgressBar.tsx @@ -0,0 +1,83 @@ +import { ReactNode } from 'react'; +import Link from 'next/link'; + +type BadgeStatus = 'NOT_STARTED' | 'STARTED' | 'DONE'; + +type StepBadgeProps = { + num: string; + label: string; + status: BadgeStatus; +}; + +const BadgeClassNames = { + NOT_STARTED: '', + STARTED: 'border-white text-white', + DONE: 'border-white bg-white text-black font-bold', +}; +const LabelClassNames = { + NOT_STARTED: '', + STARTED: 'text-white', + DONE: 'text-white', +}; + +function StepBadge({ num, label, status }: StepBadgeProps) { + const badgeClassNames = BadgeClassNames[status]; + const labelClassNames = LabelClassNames[status]; + return ( +
+
+ {status === 'DONE' ? '✓' : num} +
+ {label} +
+ ); +} + +type BarStatus = 'REQUEST_SENT' | 'VERIFIED'; +type CCTPBridgeProgressBarProps = { + status: BarStatus; +}; + +const BarStatusToBadgeStatuses: Record = { + REQUEST_SENT: ['STARTED', 'NOT_STARTED'], + VERIFIED: ['DONE', 'STARTED'], +}; + +const DisclaimerContent: Record = { + REQUEST_SENT: ( + <> + USDC deposits and withdrawals use Circle's CCTP. After you initiate a deposit or + withdrawal, you must complete the bridge in order to access your funds, on{' '} + + the transactions page + + . + + ), + VERIFIED: + 'It takes a few minutes for the transaction to complete onchain. After this period, you can access your funds.', +}; + +export function CCTPBridgeProgressBar({ status }: CCTPBridgeProgressBarProps) { + const badgeStatuses = BarStatusToBadgeStatuses[status]; + return ( +
+
+
+ + Takes a few minutes +
+
+ + Takes a few minutes +
+
+ {DisclaimerContent[status]} + + + Learn more + + +
+ ); +} diff --git a/apps/bridge/src/components/DepositContainer/DepositContainer.tsx b/apps/bridge/src/components/DepositContainer/DepositContainer.tsx index 6a8a46a..990d814 100644 --- a/apps/bridge/src/components/DepositContainer/DepositContainer.tsx +++ b/apps/bridge/src/components/DepositContainer/DepositContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { BridgeButton } from 'apps/bridge/src/components/BridgeButton/BridgeButton'; import { BridgeInput } from 'apps/bridge/src/components/BridgeInput/BridgeInput'; import { BridgeToInput } from 'apps/bridge/src/components/BridgeToInput/BridgeToInput'; @@ -8,7 +8,6 @@ import { FaqSidebar } from 'apps/bridge/src/components/Faq/FaqSidebar'; import { BaseButton } from 'apps/bridge/src/components/SwitchNetworkButton/SwitchNetworkButton'; import { TransactionSummary } from 'apps/bridge/src/components/TransactionSummary/TransactionSummary'; import { Asset } from 'apps/bridge/src/types/Asset'; -import { getAssetListForChainEnv } from 'apps/bridge/src/utils/assets/getAssetListForChainEnv'; import { useApproveContract } from 'apps/bridge/src/utils/hooks/useApproveContract'; import { useChainEnv } from 'apps/bridge/src/utils/hooks/useChainEnv'; import { useDisclosure } from 'apps/bridge/src/utils/hooks/useDisclosure'; @@ -20,29 +19,36 @@ import { usePrepareERC20Deposit } from 'apps/bridge/src/utils/hooks/usePrepareER import { usePrepareERC20DepositTo } from 'apps/bridge/src/utils/hooks/usePrepareERC20DepositTo'; import { usePrepareETHDeposit } from 'apps/bridge/src/utils/hooks/usePrepareETHDeposit'; import { isAddress, parseUnits } from 'viem'; -import { waitForTransaction } from 'wagmi/actions'; import getConfig from 'next/config'; -import { useAccount, useBalance, useContractWrite } from 'wagmi'; +import { useAccount, useBalance, useContractWrite, usePublicClient, useSwitchNetwork } from 'wagmi'; +import { writeContract } from 'wagmi/actions'; import { useIsPermittedToBridgeTo } from 'apps/bridge/src/utils/hooks/useIsPermittedToBridgeTo'; import { getL1NetworkForChainEnv } from 'apps/bridge/src/utils/networks/getL1NetworkForChainEnv'; import { getL2NetworkForChainEnv } from 'apps/bridge/src/utils/networks/getL2NetworkForChainEnv'; - -const assetList = getAssetListForChainEnv(); +import { getDepositAssetsForChainEnv } from 'apps/bridge/src/utils/assets/getDepositAssetsForChainEnv'; +import { usePrepareInitiateCCTPBridge } from 'apps/bridge/src/utils/hooks/usePrepareInitiateCCTPBridge'; const { publicRuntimeConfig } = getConfig(); + +const activeAssets = getDepositAssetsForChainEnv(); + const chainId = parseInt(publicRuntimeConfig.l1ChainID); export function DepositContainer() { - const [depositAmount, setDepositAmount] = useState('0'); + const [depositAmount, setDepositAmount] = useState(''); const [L1ApproveTxHash, setL1ApproveTxHash] = useState<`0x${string}` | undefined>(undefined); const [L1DepositTxHash, setL1DepositTxHash] = useState<`0x${string}` | undefined>(undefined); const [depositTo, setDepositTo] = useState(''); const [isApprovalTx, setIsApprovalTx] = useState(false); const isWalletConnected = useIsWalletConnected(); - const [selectedAsset, setSelectedAsset] = useState(assetList[0]); - const activeAssets = assetList.filter((asset) => - publicRuntimeConfig.assets.split(',').includes(asset.L1symbol.toLowerCase()), - ); + const [selectedAsset, setSelectedAsset] = useState(activeAssets[0]); + const publicClient = usePublicClient({ chainId }); + const { switchNetwork } = useSwitchNetwork(); + + useEffect(() => { + switchNetwork?.(chainId); + }, [switchNetwork]); + const { address } = useAccount(); const codeAtAddress = useGetCode(chainId, address); const isSmartContractWallet = !!codeAtAddress && codeAtAddress !== '0x'; @@ -53,9 +59,16 @@ export function DepositContainer() { chainId: parseInt(publicRuntimeConfig.l1ChainID), }); + const erc20Spender = + selectedAsset.protocol === 'CCTP' + ? publicRuntimeConfig.l1CCTPTokenMessengerAddress + : publicRuntimeConfig.l1BridgeProxyAddress; + const { data: readERC20Approval, error: readERC20ApprovalError } = useIsContractApproved({ contactAddress: selectedAsset.L1contract, address, + spender: erc20Spender, + bridgeDirection: 'deposit', }); const readApprovalResult = useMemo(() => { @@ -75,15 +88,18 @@ export function DepositContainer() { const handleCloseDepositModal = useCallback(() => { onCloseDepositModal(); setL1DepositTxHash(undefined); + setL1ApproveTxHash(undefined); + setIsApprovalTx(false); }, [onCloseDepositModal]); // approve erc20 const approveConfig = useApproveContract({ contractAddress: selectedAsset.L1contract, + spender: erc20Spender, approveAmount: depositAmount, decimals: selectedAsset.decimals, + bridgeDirection: 'deposit', }); - const { writeAsync: approveWrite } = useContractWrite(approveConfig); const chainEnv = useChainEnv(); @@ -102,7 +118,6 @@ export function DepositContainer() { isPermittedToBridge, includeTosVersionByte, }); - const { writeAsync: depositETHWrite } = useContractWrite(depositETHConfig); // deposit erc20 @@ -121,10 +136,21 @@ export function DepositContainer() { isPermittedToBridge, includeTosVersionByte, }); - const { writeAsync: depositERC20Write } = useContractWrite(depositERC20Config); const { writeAsync: depositERC20ToWrite } = useContractWrite(depositERC20ToConfig); + // deposit using CCTP (eg USDC) + const depositCCTPAssetConfig = usePrepareInitiateCCTPBridge({ + mintRecipient: isSmartContractWallet ? (depositTo as `0x${string}`) : address, + asset: selectedAsset, + amount: depositAmount, + destinationDomain: parseInt(publicRuntimeConfig.l2CCTPDomain), + isPermittedToBridge, + includeTosVersionByte, + bridgeDirection: 'deposit', + }); + const { writeAsync: depositCCTPAssetWrite } = useContractWrite(depositCCTPAssetConfig); + const initiateApproval = useCallback(() => { void (async () => { setIsApprovalTx(true); @@ -134,19 +160,27 @@ export function DepositContainer() { if (approveResult?.hash) { const approveTxHash: `0x${string}` = approveResult.hash; setL1ApproveTxHash(approveTxHash); - // wait for confirmations - await waitForTransaction({ hash: approveResult?.hash }); - } - // next, call the transfer function - setIsApprovalTx(false); - const depositResult = await (isSmartContractWallet - ? depositERC20ToWrite?.() - : depositERC20Write?.()); - if (depositResult?.hash) { - const depositTxHash = depositResult.hash; - setL1DepositTxHash(depositTxHash); - setDepositAmount('0'); + // wait for confirmations + await publicClient.waitForTransactionReceipt({ hash: approveResult.hash }); + + // next, call the transfer function + setIsApprovalTx(false); + + let depositMethod; + if (selectedAsset.protocol === 'CCTP') { + // because of how React works we need to use the writeContract wagmi/core action + // here (the hook still thinks the approval has not been set) + depositMethod = async () => await writeContract(depositCCTPAssetConfig); + } else { + depositMethod = isSmartContractWallet ? depositERC20ToWrite : depositERC20Write; + } + const depositResult = await depositMethod?.(); + if (depositResult?.hash) { + const depositTxHash = depositResult.hash; + setL1DepositTxHash(depositTxHash); + setDepositAmount(''); + } } } catch (error) { onCloseDepositModal(); @@ -154,11 +188,14 @@ export function DepositContainer() { })(); }, [ approveWrite, + depositCCTPAssetConfig, depositERC20ToWrite, depositERC20Write, isSmartContractWallet, onCloseDepositModal, onOpenDepositModal, + publicClient, + selectedAsset.protocol, ]); const initiateDeposit = useCallback(() => { @@ -169,7 +206,11 @@ export function DepositContainer() { if (isPermittedToBridge) { let depositMethod; if (selectedAsset.L1contract) { - depositMethod = isSmartContractWallet ? depositERC20ToWrite : depositERC20Write; + if (selectedAsset.protocol === 'CCTP') { + depositMethod = depositCCTPAssetWrite; + } else { + depositMethod = isSmartContractWallet ? depositERC20ToWrite : depositERC20Write; + } } else { depositMethod = depositETHWrite; } @@ -177,7 +218,7 @@ export function DepositContainer() { if (depositResult?.hash) { const depositTxHash = depositResult.hash; setL1DepositTxHash(depositTxHash); - setDepositAmount('0'); + setDepositAmount(''); } } else { onCloseDepositModal(); @@ -190,6 +231,8 @@ export function DepositContainer() { onOpenDepositModal, isPermittedToBridge, selectedAsset.L1contract, + selectedAsset.protocol, + depositCCTPAssetWrite, isSmartContractWallet, depositERC20ToWrite, depositERC20Write, @@ -245,6 +288,7 @@ export function DepositContainer() { L1ApproveTxHash={L1ApproveTxHash} L1DepositTxHash={L1DepositTxHash} isApprovalTx={isApprovalTx} + protocol={selectedAsset.protocol} /> = { APPROVAL_NOT_STARTED: 'Approval will initiate after confirmation.', APPROVAL_LOADING: 'Waiting for confirmations...', APPROVAL_CONFIRMED: 'Transaction confirmed.', DEPOSIT_NOT_STARTED: 'Deposit will initiate after confirmation.', DEPOSIT_LOADING: 'Waiting for confirmations...', - DEPOSIT_CONFIRMED: ( + OP_DEPOSIT_STARTED: ( <> Deposits typically take a few minutes to reach the Base network. When this is complete, you can view this transaction at{' '} @@ -63,24 +69,27 @@ const ModalContents = { . ), + CCTP_DEPOSIT_STARTED: , }; -const Titles = { +const Titles: Record = { APPROVAL_NOT_STARTED: 'APPROVE IN YOUR WALLET', APPROVAL_LOADING: 'APPROVING', APPROVAL_CONFIRMED: 'APPROVED', DEPOSIT_NOT_STARTED: 'CONFIRM DEPOSIT IN WALLET', DEPOSIT_LOADING: 'CONFIRMING', - DEPOSIT_CONFIRMED: 'DEPOSIT IN TRANSIT TO BASE', + OP_DEPOSIT_STARTED: 'DEPOSIT IN TRANSIT TO BASE', + CCTP_DEPOSIT_STARTED: 'DEPOSIT IN PROGRESS', }; -const Icons = { +const Icons: Record = { APPROVAL_NOT_STARTED: 'wallet', APPROVAL_LOADING: 'wallet', APPROVAL_CONFIRMED: 'confirm', DEPOSIT_NOT_STARTED: 'wallet', DEPOSIT_LOADING: 'wallet', - DEPOSIT_CONFIRMED: 'deposit', + OP_DEPOSIT_STARTED: 'deposit', + CCTP_DEPOSIT_STARTED: '', }; export function DepositModal({ @@ -89,6 +98,7 @@ export function DepositModal({ L1ApproveTxHash, L1DepositTxHash, isApprovalTx, + protocol, }: DepositModalProps) { const { isLoading: isApproveLoading, isSuccess: isApproveSuccess } = useWaitForTransaction({ hash: L1ApproveTxHash, @@ -104,6 +114,7 @@ export function DepositModal({ isApproveSuccess, isDepositLoading, isDepositSuccess, + protocol, ); const L1TxHash = isApprovalTx ? L1ApproveTxHash : L1DepositTxHash; diff --git a/apps/bridge/src/components/TransactionIcon/TransactionIcon.tsx b/apps/bridge/src/components/TransactionIcon/TransactionIcon.tsx index 8d81333..f01ee76 100644 --- a/apps/bridge/src/components/TransactionIcon/TransactionIcon.tsx +++ b/apps/bridge/src/components/TransactionIcon/TransactionIcon.tsx @@ -1,4 +1,9 @@ -import type { DepositPhase, WithdrawalPhase } from 'apps/bridge/src/utils/transactions/phase'; +import { BridgeProtocol } from 'apps/bridge/src/types/Asset'; +import type { + CCTPBridgePhase, + DepositPhase, + WithdrawalPhase, +} from 'apps/bridge/src/utils/transactions/phase'; import Image from 'next/image'; const networkSvgPaths: Record<'deposit' | 'withdraw', string> = { @@ -21,22 +26,53 @@ const phaseSvgPaths: Record = { FUNDS_DEPOSITED: '/icons/phases/send.svg', }; +const ccptPhasePaths: Record> = { + INITIATE_CCTP_BRIDGE_PENDING: { + deposit: '/icons/phases/send.svg', + withdraw: '/icons/phases/receive.svg', + }, + INITIATE_CCTP_BRIDGE_FAILED: { + deposit: '/icons/phases/user_action.svg', + withdraw: '/icons/phases/user_action.svg', + }, + FINALIZE_CCTP_BRIDGE: { + deposit: '/icons/phases/user_action.svg', + withdraw: '/icons/phases/user_action.svg', + }, + FINALIZE_CCTP_BRIDGE_PENDING: { + deposit: '/icons/phases/wait.svg', + withdraw: '/icons/phases/wait.svg', + }, + FINALIZE_CCTP_BRIDGE_FAILED: { + deposit: '/icons/phases/user_action.svg', + withdraw: '/icons/phases/user_action.svg', + }, + CCTP_BRIDGE_COMPLETE: { + deposit: '/icons/phases/send.svg', + withdraw: '/icons/phases/receive.svg', + }, +}; + type Props = { - phase: DepositPhase | WithdrawalPhase; + phase: DepositPhase | WithdrawalPhase | CCTPBridgePhase; + protocol?: BridgeProtocol; bridgeDirection: 'deposit' | 'withdraw'; size?: number; }; -export function TransactionIcon({ phase, bridgeDirection, size = 32 }: Props) { - const phaseIcon = ( - - ); +export function TransactionIcon({ phase, protocol, bridgeDirection, size = 32 }: Props) { + const icon = + protocol === 'CCTP' + ? ccptPhasePaths[phase as CCTPBridgePhase][bridgeDirection] + : phaseSvgPaths[phase as DepositPhase | WithdrawalPhase]; + + const phaseIcon = ; return ( -
+
{phaseIcon} = { + PROPOSING_ON_CHAIN: 1, + PROVE: 2, + PROVE_TX_PENDING: 2, + PROVE_TX_FAILURE: 2, + CHALLENGE_WINDOW: 2, + FINALIZE: 3, + FINALIZE_TX_PENDING: 3, + FINALIZE_TX_FAILURE: 3, + FUNDS_WITHDRAWN: 4, + INITIATE_CCTP_BRIDGE_PENDING: 1, + INITIATE_CCTP_BRIDGE_FAILED: 1, + FINALIZE_CCTP_BRIDGE: 2, + FINALIZE_CCTP_BRIDGE_PENDING: 2, + FINALIZE_CCTP_BRIDGE_FAILED: 2, + CCTP_BRIDGE_COMPLETE: 3, +}; + +function generatePhaseIndicator( + phase: WithdrawalPhase | CCTPBridgePhase, + protocol?: BridgeProtocol, +): JSX.Element[] { + const rv: JSX.Element[] = []; + const totalPhases = protocol === 'CCTP' ? 3 : 4; + const indicatorWidth = protocol === 'CCTP' ? `w-[calc(8rem/3)]` : `w-[calc(8rem/4)]`; + + for (let i = 0; i < PHASE_MAP[phase]; i += 1) { + rv.push(
); + } + + for (let i = 0; i < totalPhases - PHASE_MAP[phase]; i += 1) { + rv.push( +
, + ); + } + return rv; +} + +type BridgePhaseIndicatorProps = { + phase: WithdrawalPhase | CCTPBridgePhase; + protocol?: BridgeProtocol; +}; + +// Component to generate the phase indicator for any multi-step bridge transaction. +// This currently includes OP bridge withdrawals and CCTP bridge transactions in either direction. +export const BridgePhaseIndicator = memo(function BridgePhaseIndicator({ + phase, + protocol, +}: BridgePhaseIndicatorProps) { + return ( +
+ {generatePhaseIndicator(phase, protocol)} +
+ ); +}); diff --git a/apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeRow.tsx b/apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeRow.tsx new file mode 100644 index 0000000..384f08e --- /dev/null +++ b/apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeRow.tsx @@ -0,0 +1,124 @@ +import { TransactionIcon } from 'apps/bridge/src/components/TransactionIcon/TransactionIcon'; +import { BridgePhaseIndicator } from 'apps/bridge/src/components/Transactions/BridgePhaseIndicator'; +import { CCTPBridgeStatus } from 'apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeStatus'; +import { cctpBridgePhaseText } from 'apps/bridge/src/constants/phaseText'; +import { BridgeTransaction } from 'apps/bridge/src/types/BridgeTransaction'; +import { useCCTPBridgeStatus } from 'apps/bridge/src/utils/hooks/useCCTPBridgeStatus'; +import { useGetUSDAmount } from 'apps/bridge/src/utils/hooks/useGetUSDAmount'; +import { truncateMiddle } from 'apps/bridge/src/utils/string/truncateMiddle'; +import { formatTimestamp } from 'apps/bridge/src/utils/transactions/formatBlockTimestamp'; +import { + blockExplorerUrlForL1Transaction, + blockExplorerUrlForL2Transaction, +} from 'apps/bridge/src/utils/url/blockExplorer'; +import { formatUnits } from 'viem'; +import { Dispatch, SetStateAction, memo } from 'react'; + +type CCTPBridgeRowProps = { + transaction: BridgeTransaction; + bridgeDirection: 'deposit' | 'withdraw'; + onOpenFinalizeCCTPBridgeModal: () => void; + onCloseFinalizeCCTPBridgeModal: () => void; + setModalFinalizeCCTPTxHash: Dispatch>; +}; + +// Transactions table row component for Bridges made using Circle's CCTP. +export const CCTPBridgeRow = memo(function CCTPBridgeRow({ + transaction, + bridgeDirection, + onOpenFinalizeCCTPBridgeModal, + onCloseFinalizeCCTPBridgeModal, + setModalFinalizeCCTPTxHash, +}: CCTPBridgeRowProps) { + const { date, shortDate, time } = formatTimestamp(transaction.blockTimestamp); + const { status, setStatus, message, attestation } = useCCTPBridgeStatus({ + initiateTxHash: transaction.hash, + bridgeDirection, + }); + + const explorerURL = + bridgeDirection === 'deposit' + ? blockExplorerUrlForL1Transaction(transaction.hash) + : blockExplorerUrlForL2Transaction(transaction.hash); + const abridgedHash = truncateMiddle(transaction.hash, 6, 4); + + const bridgeAmount = formatUnits(BigInt(transaction.amount), transaction.assetDecimals ?? 18); + const amountFiat = useGetUSDAmount(transaction.priceApiId, bridgeAmount); + + return ( +
+ + {/* mobile design - left column */} + + + {/* mobile design - right column */} + + + + + + ); +}); diff --git a/apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeStatus.tsx b/apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeStatus.tsx new file mode 100644 index 0000000..2cee549 --- /dev/null +++ b/apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeStatus.tsx @@ -0,0 +1,56 @@ +import { Dispatch, SetStateAction, memo } from 'react'; +import { PendingButton } from 'apps/bridge/src/components/Transactions/PendingButton'; +import { CCTPBridgePhase } from 'apps/bridge/src/utils/transactions/phase'; +import { FinalizeCCTPBridgeButton } from 'apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeButton'; + +type CCTPBridgeStatusProps = { + phase: CCTPBridgePhase; + message?: `0x${string}`; + attestation?: `0x${string}`; + bridgeDirection: 'deposit' | 'withdraw'; + setStatus: (status: CCTPBridgePhase) => void; + onOpenFinalizeCCTPBridgeModal: () => void; + onCloseFinalizeCCTPBridgeModal: () => void; + setModalFinalizeCCTPTxHash: Dispatch>; +}; + +export const CCTPBridgeStatus = memo(function CCTPBridgeStatus({ + phase, + message, + attestation, + bridgeDirection, + setStatus, + onOpenFinalizeCCTPBridgeModal, + onCloseFinalizeCCTPBridgeModal, + setModalFinalizeCCTPTxHash, +}: CCTPBridgeStatusProps) { + const isPending = + phase === 'INITIATE_CCTP_BRIDGE_PENDING' || phase === 'FINALIZE_CCTP_BRIDGE_PENDING'; + // If the finalize tx failed we can just try again + const isReadyToFinazlie = + phase === 'FINALIZE_CCTP_BRIDGE' || phase === 'FINALIZE_CCTP_BRIDGE_FAILED'; + const isFailed = phase === 'INITIATE_CCTP_BRIDGE_FAILED'; + + // Return pending button if initiate or finalize tx is pending + if (isPending) return ; + + // Return 'Ready to finalize' if finalize tx is ready to be sent + if (isReadyToFinazlie) + return ( + + ); + + // Return 'Failed' if initiate tx failed + if (isFailed) return 'Failed'; + + // Otherwise the bridge is complete + return Complete; +}); diff --git a/apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeButton.tsx b/apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeButton.tsx new file mode 100644 index 0000000..705a731 --- /dev/null +++ b/apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeButton.tsx @@ -0,0 +1,108 @@ +import { Dispatch, SetStateAction, memo, useCallback } from 'react'; +import { usePrepareFinalizeCCTPBridge } from 'apps/bridge/src/utils/hooks/usePrepareFinalizeCCTPBridge'; +import { useIsPermittedToBridge } from 'apps/bridge/src/utils/hooks/useIsPermittedToBridge'; +import { useContractWrite, useNetwork, usePublicClient, useSwitchNetwork } from 'wagmi'; +import getConfig from 'next/config'; +import { CCTPBridgePhase } from 'apps/bridge/src/utils/transactions/phase'; +import { useChainEnv } from 'apps/bridge/src/utils/hooks/useChainEnv'; + +const { publicRuntimeConfig } = getConfig(); +const l1ChainID = parseInt(publicRuntimeConfig.l1ChainID); +const l2ChainID = parseInt(publicRuntimeConfig.l2ChainID); + +type FinalizeCCTPBridgeButtonProps = { + message?: `0x${string}`; + attestation?: `0x${string}`; + bridgeDirection: 'deposit' | 'withdraw'; + setStatus: (status: CCTPBridgePhase) => void; + onOpenFinalizeCCTPBridgeModal: () => void; + onCloseFinalizeCCTPBridgeModal: () => void; + setModalFinalizeCCTPTxHash: Dispatch>; +}; + +export const FinalizeCCTPBridgeButton = memo(function FinalizeCCTPBridgeButton({ + message, + attestation, + bridgeDirection, + setStatus, + onOpenFinalizeCCTPBridgeModal, + onCloseFinalizeCCTPBridgeModal, + setModalFinalizeCCTPTxHash, +}: FinalizeCCTPBridgeButtonProps) { + const { chain } = useNetwork(); + const { switchNetwork } = useSwitchNetwork(); + const isPermittedToBridge = useIsPermittedToBridge(); + const publicClient = usePublicClient({ + chainId: bridgeDirection === 'deposit' ? l2ChainID : l1ChainID, + }); + const chainEnv = useChainEnv(); + const isMainnet = chainEnv === 'mainnet'; + const includeTosVersionByte = isMainnet; + + const finalizeCCTPBridgeConfig = usePrepareFinalizeCCTPBridge({ + isPermittedToBridge, + message, + attestation, + bridgeDirection, + includeTosVersionByte, + }); + const { writeAsync: finalizeCCTPBridge } = useContractWrite(finalizeCCTPBridgeConfig); + + const handleSwitchToCorrectNetwork = useCallback(() => { + switchNetwork?.(bridgeDirection === 'deposit' ? l2ChainID : l1ChainID); + }, [bridgeDirection, switchNetwork]); + + const handleFinalizeCCTPBridge = useCallback(() => { + setModalFinalizeCCTPTxHash(undefined); + onOpenFinalizeCCTPBridgeModal(); + void (async () => { + try { + if (isPermittedToBridge) { + const finalizeResult = await finalizeCCTPBridge?.(); + setStatus('FINALIZE_CCTP_BRIDGE_PENDING'); + if (finalizeResult?.hash) { + const finalizeTxHash = finalizeResult.hash; + setModalFinalizeCCTPTxHash(finalizeTxHash); + const finalizeTxReceipt = await publicClient.waitForTransactionReceipt({ + hash: finalizeResult.hash, + }); + setStatus( + finalizeTxReceipt?.status === 'success' + ? 'CCTP_BRIDGE_COMPLETE' + : 'FINALIZE_CCTP_BRIDGE_FAILED', + ); + } + } else { + onCloseFinalizeCCTPBridgeModal(); + } + } catch { + setStatus('FINALIZE_CCTP_BRIDGE_FAILED'); + onCloseFinalizeCCTPBridgeModal(); + } + })(); + }, [ + finalizeCCTPBridge, + isPermittedToBridge, + onCloseFinalizeCCTPBridgeModal, + onOpenFinalizeCCTPBridgeModal, + publicClient, + setModalFinalizeCCTPTxHash, + setStatus, + ]); + + const isOnCorrectNetwork = + (bridgeDirection === 'deposit' && chain?.id === l2ChainID) || + (bridgeDirection === 'withdraw' && chain?.id === l1ChainID); + + return ( + + ); +}); diff --git a/apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeModal.tsx b/apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeModal.tsx new file mode 100644 index 0000000..c2c31eb --- /dev/null +++ b/apps/bridge/src/components/Transactions/CCTPBridgeRow/FinalizeCCTPBridgeModal.tsx @@ -0,0 +1,108 @@ +import { memo } from 'react'; +import { Modal } from 'apps/bridge/src/components/Modal/Modal'; +import { useWaitForTransaction } from 'wagmi'; +import { + blockExplorerUrlForL1Transaction, + blockExplorerUrlForL2Transaction, +} from 'apps/bridge/src/utils/url/blockExplorer'; +import { CCTPBridgeProgressBar } from 'apps/bridge/src/components/CCTPBridgeProgressBar/CCTPBridgeProgressBar'; +import getConfig from 'next/config'; + +const { publicRuntimeConfig } = getConfig(); + +const l2ChainID = parseInt(publicRuntimeConfig.l2ChainID); + +type FinalizeCCTPBridgeModalProps = { + isOpen: boolean; + onClose: () => void; + finalizeTxHash?: `0x${string}`; +}; + +type State = 'FINALIZE_NOT_SUBMITTED' | 'FINALIZE_SUBMITTED' | 'FINALIZE_RECEIVED'; + +const Titles = { + FINALIZE_NOT_SUBMITTED: 'CONFIRM COMPLETION IN WALLET', + FINALIZE_SUBMITTED: 'COMPLETING BRIDGE', + FINALIZE_RECEIVED: 'COMPLETING BRIDGE', +}; + +const Icons = { + FINALIZE_NOT_SUBMITTED: 'confirm_deposit', + FINALIZE_SUBMITTED: 'confirm_deposit', + FINALIZE_RECEIVED: '', +}; + +const ModalContents = { + FINALIZE_NOT_SUBMITTED: 'Completion will begin after confirmation', + FINALIZE_SUBMITTED: 'Waiting for confirmations...', + FINALIZE_RECEIVED: , +}; + +function getState( + finalizeTxHash?: string, + isFinalizationLoading?: boolean, + isFinalizationSuccess?: boolean, +): State { + if (!finalizeTxHash) { + return 'FINALIZE_NOT_SUBMITTED'; + } + if (isFinalizationSuccess) { + return 'FINALIZE_RECEIVED'; + } + if (isFinalizationLoading) { + return 'FINALIZE_SUBMITTED'; + } + return 'FINALIZE_NOT_SUBMITTED'; +} + +export const FinalizeCCTPBridgeModal = memo(function FinalizeCCTPBridgeModal({ + isOpen, + onClose, + finalizeTxHash, +}: FinalizeCCTPBridgeModalProps) { + const { isLoading: isFinalizationLoading, isSuccess: isFinalizationSuccess } = + useWaitForTransaction({ + hash: finalizeTxHash, + }); + + // We don't have access to wheter this is a deposit or withdrawal here, so just + // see if we can get a receipt for the transaction on L2. If we can, it's a deposit, + // because deposits are finalized with an L2 tx. + const { isSuccess: isDeposit, isLoading } = useWaitForTransaction({ + hash: finalizeTxHash, + chainId: l2ChainID, + }); + const bridgeDirection = isDeposit ? 'deposit' : 'withdraw'; + + const state = getState(finalizeTxHash, isFinalizationLoading, isFinalizationSuccess); + + const explorerURL = + bridgeDirection === 'withdraw' + ? blockExplorerUrlForL1Transaction(finalizeTxHash ?? '') + : blockExplorerUrlForL2Transaction(finalizeTxHash ?? ''); + + return ( + + + {`View on ${bridgeDirection === 'withdraw' ? 'Etherscan' : 'Basescan'}`} + + + ) + } + /> + ); +}); diff --git a/apps/bridge/src/components/Transactions/DepositRow/DepositRow.tsx b/apps/bridge/src/components/Transactions/DepositRow/DepositRow.tsx index 7a2ffba..01fbb58 100644 --- a/apps/bridge/src/components/Transactions/DepositRow/DepositRow.tsx +++ b/apps/bridge/src/components/Transactions/DepositRow/DepositRow.tsx @@ -1,176 +1,32 @@ -import { memo } from 'react'; -import { TransactionIcon } from 'apps/bridge/src/components/TransactionIcon/TransactionIcon'; -import { depositPhaseStatusText, depositPhaseText } from 'apps/bridge/src/constants/phaseText'; +import { Dispatch, SetStateAction, memo } from 'react'; import { BridgeTransaction } from 'apps/bridge/src/types/BridgeTransaction'; -import { usdFormatter } from 'apps/bridge/src/utils/formatter/balance'; -import { useConversionRate } from 'apps/bridge/src/utils/hooks/useConversionRate'; -import { truncateMiddle } from 'apps/bridge/src/utils/string/truncateMiddle'; -import type { DepositPhase } from 'apps/bridge/src/utils/transactions/phase'; -import { - blockExplorerUrlForL1Transaction, - blockExplorerUrlForL2Transaction, -} from 'apps/bridge/src/utils/url/blockExplorer'; -import { formatUnits } from 'viem'; -import getConfig from 'next/config'; -import { useWaitForTransaction } from 'wagmi'; - -const { publicRuntimeConfig } = getConfig(); +import { OPDepositRow } from 'apps/bridge/src/components/Transactions/DepositRow/OPDepositRow'; +import { CCTPBridgeRow } from 'apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeRow'; type DepositRowProps = { transaction: BridgeTransaction; + onOpenFinalizeCCTPBridgeModal: () => void; + onCloseFinalizeCCTPBridgeModal: () => void; + setModalFinalizeCCTPTxHash: Dispatch>; }; -export const DepositRow = memo(function WithdrawalRow({ transaction }: DepositRowProps) { - const date = transaction.blockTimestamp - ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }) - : undefined; - const dateMonthDayOnly = transaction.blockTimestamp - ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) - : undefined; - const time = transaction.blockTimestamp - ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleTimeString('en-US', { - timeStyle: 'short', - }) - : undefined; - const depositAmount = formatUnits( - BigInt(transaction.amount), - // TODO: get decimals from asset list - transaction.assetSymbol === 'USDC' ? 6 : 18, - ); - - const conversionRateData = useConversionRate({ - asset: transaction.priceApiId, - }); - - const amountFiat = - conversionRateData && depositAmount - ? usdFormatter(conversionRateData * +depositAmount) - : '$0.00'; - - const { isLoading: isDepositLoading, isSuccess: isDepositSuccess } = useWaitForTransaction({ - chainId: parseInt(publicRuntimeConfig.l1ChainID), - hash: transaction.hash, - }); - - const explorerURL = - transaction.type === 'Deposit' - ? blockExplorerUrlForL1Transaction(transaction.hash) - : blockExplorerUrlForL2Transaction(transaction.hash); - const abridgedHash = truncateMiddle(transaction.hash, 6, 4); - - const pendingButton = ( - - ); - - const PHASE_TO_STATUS = { - DEPOSIT_TX_PENDING: pendingButton, - DEPOSIT_TX_FAILURE: depositPhaseStatusText.DEPOSIT_TX_FAILURE, - FUNDS_DEPOSITED: depositPhaseStatusText.FUNDS_DEPOSITED, - }; - - let depositStatus: DepositPhase; - if (isDepositLoading) { - depositStatus = 'DEPOSIT_TX_PENDING'; - } else if (isDepositSuccess) { - depositStatus = 'FUNDS_DEPOSITED'; - } else { - depositStatus = 'DEPOSIT_TX_FAILURE'; +export const DepositRow = memo(function DepositRow({ + transaction, + onOpenFinalizeCCTPBridgeModal, + onCloseFinalizeCCTPBridgeModal, + setModalFinalizeCCTPTxHash, +}: DepositRowProps) { + if (transaction.protocol === 'CCTP') { + return ( + + ); } - return ( - - - {/* mobile design - left column */} - - - {/* mobile design - right column */} - - - - - - ); + return ; }); diff --git a/apps/bridge/src/components/Transactions/DepositRow/OPDepositRow.tsx b/apps/bridge/src/components/Transactions/DepositRow/OPDepositRow.tsx new file mode 100644 index 0000000..088cedb --- /dev/null +++ b/apps/bridge/src/components/Transactions/DepositRow/OPDepositRow.tsx @@ -0,0 +1,130 @@ +import { memo } from 'react'; +import { TransactionIcon } from 'apps/bridge/src/components/TransactionIcon/TransactionIcon'; +import { depositPhaseStatusText, depositPhaseText } from 'apps/bridge/src/constants/phaseText'; +import { BridgeTransaction } from 'apps/bridge/src/types/BridgeTransaction'; +import { truncateMiddle } from 'apps/bridge/src/utils/string/truncateMiddle'; +import type { DepositPhase } from 'apps/bridge/src/utils/transactions/phase'; +import { + blockExplorerUrlForL1Transaction, + blockExplorerUrlForL2Transaction, +} from 'apps/bridge/src/utils/url/blockExplorer'; +import getConfig from 'next/config'; +import { formatUnits } from 'viem'; +import { useWaitForTransaction } from 'wagmi'; +import { formatTimestamp } from 'apps/bridge/src/utils/transactions/formatBlockTimestamp'; +import { useGetUSDAmount } from 'apps/bridge/src/utils/hooks/useGetUSDAmount'; +import { PendingButton } from 'apps/bridge/src/components/Transactions/PendingButton'; + +const { publicRuntimeConfig } = getConfig(); + +const PHASE_TO_STATUS: Record, string> = { + DEPOSIT_TX_FAILURE: depositPhaseStatusText.DEPOSIT_TX_FAILURE, + FUNDS_DEPOSITED: depositPhaseStatusText.FUNDS_DEPOSITED, +}; + +type OPDepositRowProps = { + transaction: BridgeTransaction; +}; + +// Transactions table row component for deposits made using the OP bridge. +export const OPDepositRow = memo(function OPDepositRow({ transaction }: OPDepositRowProps) { + const { date, shortDate: dateMonthDayOnly, time } = formatTimestamp(transaction.blockTimestamp); + const depositAmount = formatUnits(BigInt(transaction.amount), transaction.assetDecimals ?? 18); + const amountFiat = useGetUSDAmount(transaction.priceApiId, depositAmount); + + const { isLoading: isDepositLoading, isSuccess: isDepositSuccess } = useWaitForTransaction({ + chainId: parseInt(publicRuntimeConfig.l1ChainID), + hash: transaction.hash, + }); + + const explorerURL = + transaction.type === 'Deposit' + ? blockExplorerUrlForL1Transaction(transaction.hash) + : blockExplorerUrlForL2Transaction(transaction.hash); + const abridgedHash = truncateMiddle(transaction.hash, 6, 4); + + let depositStatus: DepositPhase; + if (isDepositLoading) { + depositStatus = 'DEPOSIT_TX_PENDING'; + } else if (isDepositSuccess) { + depositStatus = 'FUNDS_DEPOSITED'; + } else { + depositStatus = 'DEPOSIT_TX_FAILURE'; + } + + return ( + + + {/* mobile design - left column */} + + + {/* mobile design - right column */} + + + + + + ); +}); diff --git a/apps/bridge/src/components/Transactions/PendingButton.tsx b/apps/bridge/src/components/Transactions/PendingButton.tsx new file mode 100644 index 0000000..4c86a2b --- /dev/null +++ b/apps/bridge/src/components/Transactions/PendingButton.tsx @@ -0,0 +1,25 @@ +import { memo } from 'react'; + +export const PendingButton = memo(function PendingButton() { + return ( + + ); +}); diff --git a/apps/bridge/src/components/Transactions/WithdrawalRow/OPWithdrawalRow.tsx b/apps/bridge/src/components/Transactions/WithdrawalRow/OPWithdrawalRow.tsx new file mode 100644 index 0000000..95b7bc2 --- /dev/null +++ b/apps/bridge/src/components/Transactions/WithdrawalRow/OPWithdrawalRow.tsx @@ -0,0 +1,213 @@ +import { Dispatch, memo, SetStateAction, useState } from 'react'; +import { TransactionIcon } from 'apps/bridge/src/components/TransactionIcon/TransactionIcon'; +import { + withdrawalPhaseStatusText, + withdrawalPhaseText, +} from 'apps/bridge/src/constants/phaseText'; +import { BridgeTransaction } from 'apps/bridge/src/types/BridgeTransaction'; +import { usdFormatter } from 'apps/bridge/src/utils/formatter/balance'; +import { useConversionRate } from 'apps/bridge/src/utils/hooks/useConversionRate'; +import { useWithdrawalStatus } from 'apps/bridge/src/utils/hooks/useWithdrawalStatus'; +import { truncateMiddle } from 'apps/bridge/src/utils/string/truncateMiddle'; +import { + blockExplorerUrlForL1Transaction, + blockExplorerUrlForL2Transaction, +} from 'apps/bridge/src/utils/url/blockExplorer'; +import { formatUnits } from 'viem'; + +import { FinalizeWithdrawalButton } from './FinalizeWithdrawalButton'; +import { ProveWithdrawalButton } from './ProveWithdrawalButton'; +import { BridgePhaseIndicator } from 'apps/bridge/src/components/Transactions/BridgePhaseIndicator'; + +type OPWithdrawalRowProps = { + transaction: BridgeTransaction; + blockNumberOfLatestL2OutputProposal?: bigint; + onOpenProveWithdrawalModal: () => void; + onCloseProveWithdrawalModal: () => void; + onOpenFinalizeWithdrawalModal: () => void; + onCloseFinalizeWithdrawalModal: () => void; + setModalProveTxHash: Dispatch>; + setModalFinalizeTxHash: Dispatch>; +}; + +export const OPWithdrawalRow = memo(function OPWithdrawalRow({ + transaction, + blockNumberOfLatestL2OutputProposal, + onOpenProveWithdrawalModal, + onCloseProveWithdrawalModal, + onOpenFinalizeWithdrawalModal, + onCloseFinalizeWithdrawalModal, + setModalProveTxHash, + setModalFinalizeTxHash, +}: OPWithdrawalRowProps) { + const isERC20Withdrawal = transaction.assetSymbol !== 'ETH'; + const [proveTxHash, setProveTxHash] = useState<`0x${string}` | undefined>(undefined); + const [finalizeTxHash, setFinalizeTxHash] = useState<`0x${string}` | undefined>(undefined); + const { status: withdrawalStatus, challengeWindowEndTime } = useWithdrawalStatus({ + initializeTxHash: transaction.hash, + blockNumberOfLatestL2OutputProposal, + isERC20Withdrawal, + proveTxHash, + finalizeTxHash, + }); + + const date = transaction.blockTimestamp + ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : undefined; + const dateMonthDayOnly = transaction.blockTimestamp + ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + : undefined; + const time = transaction.blockTimestamp + ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleTimeString('en-US', { + timeStyle: 'short', + }) + : undefined; + const withdrawalAmount = formatUnits( + BigInt(transaction.amount), + // TODO: get decimals from asset list + transaction.assetSymbol === 'USDbC' ? 6 : 18, + ); + + const conversionRateData = useConversionRate({ + asset: transaction.priceApiId, + }); + const amountFiat = + conversionRateData && withdrawalAmount + ? usdFormatter(conversionRateData * +withdrawalAmount) + : '$0.00'; + + const explorerURL = + transaction.type === 'Deposit' + ? blockExplorerUrlForL1Transaction(transaction.hash) + : blockExplorerUrlForL2Transaction(transaction.hash); + const abridgedHash = truncateMiddle(transaction.hash, 6, 4); + + const pendingButton = ( + + ); + const PHASE_TO_STATUS = { + PROPOSING_ON_CHAIN: withdrawalPhaseStatusText.PROPOSING_ON_CHAIN, + PROVE: ( + + ), + PROVE_TX_PENDING: pendingButton, + PROVE_TX_FAILURE: withdrawalPhaseStatusText.PROVE_TX_FAILURE, + CHALLENGE_WINDOW: withdrawalPhaseStatusText.CHALLENGE_WINDOW(Number(challengeWindowEndTime)), + FINALIZE: ( + + ), + FINALIZE_TX_PENDING: pendingButton, + FINALIZE_TX_FAILURE: withdrawalPhaseStatusText.FINALIZE_TX_FAILURE, + FUNDS_WITHDRAWN: withdrawalPhaseStatusText.FUNDS_WITHDRAWN, + }; + + return ( + + + {/* mobile design - left column */} + + + {/* mobile design - right column */} + + + + + + ); +}); diff --git a/apps/bridge/src/components/Transactions/WithdrawalRow/WithdrawalRow.tsx b/apps/bridge/src/components/Transactions/WithdrawalRow/WithdrawalRow.tsx index 841d903..d3a8475 100644 --- a/apps/bridge/src/components/Transactions/WithdrawalRow/WithdrawalRow.tsx +++ b/apps/bridge/src/components/Transactions/WithdrawalRow/WithdrawalRow.tsx @@ -1,23 +1,7 @@ -import { Dispatch, memo, SetStateAction, useState } from 'react'; -import { TransactionIcon } from 'apps/bridge/src/components/TransactionIcon/TransactionIcon'; -import { - withdrawalPhaseStatusText, - withdrawalPhaseText, -} from 'apps/bridge/src/constants/phaseText'; +import { Dispatch, SetStateAction, memo } from 'react'; import { BridgeTransaction } from 'apps/bridge/src/types/BridgeTransaction'; -import { usdFormatter } from 'apps/bridge/src/utils/formatter/balance'; -import { useConversionRate } from 'apps/bridge/src/utils/hooks/useConversionRate'; -import { useWithdrawalStatus } from 'apps/bridge/src/utils/hooks/useWithdrawalStatus'; -import { truncateMiddle } from 'apps/bridge/src/utils/string/truncateMiddle'; -import type { WithdrawalPhase } from 'apps/bridge/src/utils/transactions/phase'; -import { - blockExplorerUrlForL1Transaction, - blockExplorerUrlForL2Transaction, -} from 'apps/bridge/src/utils/url/blockExplorer'; -import { formatUnits } from 'viem'; - -import { FinalizeWithdrawalButton } from './FinalizeWithdrawalButton'; -import { ProveWithdrawalButton } from './ProveWithdrawalButton'; +import { CCTPBridgeRow } from 'apps/bridge/src/components/Transactions/CCTPBridgeRow/CCTPBridgeRow'; +import { OPWithdrawalRow } from 'apps/bridge/src/components/Transactions/WithdrawalRow/OPWithdrawalRow'; type WithdrawalRowProps = { transaction: BridgeTransaction; @@ -26,8 +10,11 @@ type WithdrawalRowProps = { onCloseProveWithdrawalModal: () => void; onOpenFinalizeWithdrawalModal: () => void; onCloseFinalizeWithdrawalModal: () => void; + onOpenFinalizeCCTPBridgeModal: () => void; + onCloseFinalizeCCTPBridgeModal: () => void; setModalProveTxHash: Dispatch>; - setModalFinalizeTxHash: Dispatch>; + setModalFinalizeOPTxHash: Dispatch>; + setModalFinalizeCCTPTxHash: Dispatch>; }; export const WithdrawalRow = memo(function WithdrawalRow({ @@ -37,206 +24,34 @@ export const WithdrawalRow = memo(function WithdrawalRow({ onCloseProveWithdrawalModal, onOpenFinalizeWithdrawalModal, onCloseFinalizeWithdrawalModal, + onOpenFinalizeCCTPBridgeModal, + onCloseFinalizeCCTPBridgeModal, setModalProveTxHash, - setModalFinalizeTxHash, + setModalFinalizeOPTxHash, + setModalFinalizeCCTPTxHash, }: WithdrawalRowProps) { - const isERC20Withdrawal = transaction.assetSymbol !== 'ETH'; - const [proveTxHash, setProveTxHash] = useState<`0x${string}` | undefined>(undefined); - const [finalizeTxHash, setFinalizeTxHash] = useState<`0x${string}` | undefined>(undefined); - const { status: withdrawalStatus, challengeWindowEndTime } = useWithdrawalStatus({ - initializeTxHash: transaction.hash, - blockNumberOfLatestL2OutputProposal, - isERC20Withdrawal, - proveTxHash, - finalizeTxHash, - }); - - const date = transaction.blockTimestamp - ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }) - : undefined; - const dateMonthDayOnly = transaction.blockTimestamp - ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }) - : undefined; - const time = transaction.blockTimestamp - ? new Date(Number(transaction.blockTimestamp) * 1000).toLocaleTimeString('en-US', { - timeStyle: 'short', - }) - : undefined; - const withdrawalAmount = formatUnits( - BigInt(transaction.amount), - // TODO: get decimals from asset list - transaction.assetSymbol === 'USDbC' ? 6 : 18, - ); - - const conversionRateData = useConversionRate({ - asset: transaction.priceApiId, - }); - const amountFiat = - conversionRateData && withdrawalAmount - ? usdFormatter(conversionRateData * +withdrawalAmount) - : '$0.00'; - - const explorerURL = - transaction.type === 'Deposit' - ? blockExplorerUrlForL1Transaction(transaction.hash) - : blockExplorerUrlForL2Transaction(transaction.hash); - const abridgedHash = truncateMiddle(transaction.hash, 6, 4); - - const generatePhaseIndicator = (phase: WithdrawalPhase): JSX.Element[] => { - const PHASE_MAP = { - PROPOSING_ON_CHAIN: 1, - PROVE: 2, - PROVE_TX_PENDING: 2, - PROVE_TX_FAILURE: 2, - CHALLENGE_WINDOW: 2, - FINALIZE: 3, - FINALIZE_TX_PENDING: 3, - FINALIZE_TX_FAILURE: 3, - FUNDS_WITHDRAWN: 4, - }; - const rv: JSX.Element[] = []; - for (let i = 0; i < PHASE_MAP[phase]; i += 1) { - rv.push(
); - } - for (let i = 0; i < 4 - PHASE_MAP[phase]; i += 1) { - rv.push( -
, - ); - } - return rv; - }; - - const pendingButton = ( - - ); - const PHASE_TO_STATUS = { - PROPOSING_ON_CHAIN: withdrawalPhaseStatusText.PROPOSING_ON_CHAIN, - PROVE: ( - - ), - PROVE_TX_PENDING: pendingButton, - PROVE_TX_FAILURE: withdrawalPhaseStatusText.PROVE_TX_FAILURE, - CHALLENGE_WINDOW: withdrawalPhaseStatusText.CHALLENGE_WINDOW(Number(challengeWindowEndTime)), - FINALIZE: ( - - ), - FINALIZE_TX_PENDING: pendingButton, - FINALIZE_TX_FAILURE: withdrawalPhaseStatusText.FINALIZE_TX_FAILURE, - FUNDS_WITHDRAWN: withdrawalPhaseStatusText.FUNDS_WITHDRAWN, - }; + ); + } return ( -
- - {/* mobile design - left column */} - - - {/* mobile design - right column */} - - - - - + ); }); diff --git a/apps/bridge/src/components/WithdrawContainer/WithdrawContainer.tsx b/apps/bridge/src/components/WithdrawContainer/WithdrawContainer.tsx index 84b644c..bc260fc 100644 --- a/apps/bridge/src/components/WithdrawContainer/WithdrawContainer.tsx +++ b/apps/bridge/src/components/WithdrawContainer/WithdrawContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { BridgeInput } from 'apps/bridge/src/components/BridgeInput/BridgeInput'; import { BridgeToInput } from 'apps/bridge/src/components/BridgeToInput/BridgeToInput'; import { ConnectWalletButton } from 'apps/bridge/src/components/ConnectWalletButton/ConnectWalletButton'; @@ -7,7 +7,6 @@ import { BaseButton } from 'apps/bridge/src/components/SwitchNetworkButton/Switc import { TransactionSummary } from 'apps/bridge/src/components/TransactionSummary/TransactionSummary'; import { WithdrawModal } from 'apps/bridge/src/components/WithdrawModal/WithdrawModal'; import { Asset } from 'apps/bridge/src/types/Asset'; -import { getAssetListForChainEnv } from 'apps/bridge/src/utils/assets/getAssetListForChainEnv'; import { useChainEnv } from 'apps/bridge/src/utils/hooks/useChainEnv'; import { useDisclosure } from 'apps/bridge/src/utils/hooks/useDisclosure'; import { useGetCode } from 'apps/bridge/src/utils/hooks/useGetCode'; @@ -18,30 +17,69 @@ import { usePrepareERC20WithdrawalTo } from 'apps/bridge/src/utils/hooks/usePrep import { usePrepareETHWithdrawal } from 'apps/bridge/src/utils/hooks/usePrepareETHWithdrawal'; import { isAddress } from 'viem'; import getConfig from 'next/config'; -import { useAccount, useBalance, useContractWrite } from 'wagmi'; +import { useAccount, useBalance, useContractWrite, usePublicClient, useSwitchNetwork } from 'wagmi'; +import { writeContract } from 'wagmi/actions'; import { useIsPermittedToBridgeTo } from 'apps/bridge/src/utils/hooks/useIsPermittedToBridgeTo'; import { getL2NetworkForChainEnv } from 'apps/bridge/src/utils/networks/getL2NetworkForChainEnv'; import { getL1NetworkForChainEnv } from 'apps/bridge/src/utils/networks/getL1NetworkForChainEnv'; +import { getWithdrawalAssetsForChainEnv } from 'apps/bridge/src/utils/assets/getWithdrawalAssetsForChainEnv'; +import { usePrepareInitiateCCTPBridge } from 'apps/bridge/src/utils/hooks/usePrepareInitiateCCTPBridge'; +import { useIsContractApproved } from 'apps/bridge/src/utils/hooks/useIsContractApproved'; +import { useApproveContract } from 'apps/bridge/src/utils/hooks/useApproveContract'; +import { BridgeButton } from 'apps/bridge/src/components/BridgeButton/BridgeButton'; +import { parseUnits } from 'viem'; -const assetList = getAssetListForChainEnv(); +const activeAssets = getWithdrawalAssetsForChainEnv(); const { publicRuntimeConfig } = getConfig(); const chainId = parseInt(publicRuntimeConfig.l2ChainID); export function WithdrawContainer() { const [withdrawAmount, setWithdrawAmount] = useState(''); - const [L2TxHash, setL2TxHash] = useState(''); + const [L2ApproveTxHash, setL2ApproveTxHash] = useState<`0x${string}` | undefined>(undefined); + const [L2WithdrawTxHash, setL2WithdrawTxHash] = useState<`0x${string}` | undefined>(undefined); const [withdrawTo, setWithdrawTo] = useState(''); + const [isApprovalTx, setIsApprovalTx] = useState(false); const isWalletConnected = useIsWalletConnected(); - const activeAssets = assetList.filter((asset) => - publicRuntimeConfig.assets.split(',').includes(asset.L1symbol.toLowerCase()), - ); - const [selectedAsset, setSelectedAsset] = useState(assetList[0]); + const [selectedAsset, setSelectedAsset] = useState(activeAssets[0]); + const publicClient = usePublicClient({ chainId }); + const { switchNetwork } = useSwitchNetwork(); + + useEffect(() => { + switchNetwork?.(chainId); + }, [switchNetwork]); const { address } = useAccount(); const codeAtAddress = useGetCode(chainId, address); const isSmartContractWallet = !!codeAtAddress && codeAtAddress !== '0x'; + const erc20Spender = publicRuntimeConfig.l2CCTPTokenMessengerAddress; + + const { data: readERC20Approval, error: readERC20ApprovalError } = useIsContractApproved({ + contactAddress: selectedAsset.L2contract, + address, + spender: erc20Spender, + bridgeDirection: 'withdraw', + }); + + const readApprovalResult = useMemo(() => { + const withdrawAmountBN = + withdrawAmount === '' || Number.isNaN(Number(withdrawAmount)) + ? parseUnits('0', selectedAsset.decimals) + : parseUnits(withdrawAmount, selectedAsset.decimals); + return !readERC20ApprovalError && readERC20Approval && readERC20Approval >= withdrawAmountBN; + }, [withdrawAmount, selectedAsset.decimals, readERC20ApprovalError, readERC20Approval]); + + // approve erc20 + const approveConfig = useApproveContract({ + contractAddress: selectedAsset.L2contract, + spender: erc20Spender, + approveAmount: withdrawAmount, + decimals: selectedAsset.decimals, + bridgeDirection: 'withdraw', + }); + const { writeAsync: approveWrite } = useContractWrite(approveConfig); + const { data: L2Balance } = useBalance({ address, token: selectedAsset.L2contract, @@ -50,7 +88,7 @@ export function WithdrawContainer() { const chainEnv = useChainEnv(); const isMainnet = chainEnv === 'mainnet'; - const includeTosVersionByte = isMainnet && withdrawTo === ''; + const includeTosVersionByte = isMainnet; const isUserPermittedToBridge = useIsPermittedToBridge(); const isPermittedToBridgeTo = useIsPermittedToBridgeTo(withdrawTo as `0x${string}`); const isPermittedToBridge = isSmartContractWallet @@ -82,6 +120,18 @@ export function WithdrawContainer() { }); const { writeAsync: withdraw } = useContractWrite(withdrawConfig); + // withdraw using CCTP (eg USDC) + const withdrawCCTPAssetConfig = usePrepareInitiateCCTPBridge({ + mintRecipient: isSmartContractWallet ? (withdrawTo as `0x${string}`) : address, + asset: selectedAsset, + amount: withdrawAmount, + destinationDomain: parseInt(publicRuntimeConfig.l1CCTPDomain), + isPermittedToBridge, + includeTosVersionByte, + bridgeDirection: 'withdraw', + }); + const { writeAsync: withdrawCCTPAssetWrite } = useContractWrite(withdrawCCTPAssetConfig); + const { isOpen: isWithdrawModalOpen, onOpen: onOpenWithdrawModal, @@ -90,9 +140,58 @@ export function WithdrawContainer() { const handleCloseWithdrawModal = useCallback(() => { onCloseWithdrawModal(); - setL2TxHash(''); + setL2WithdrawTxHash(undefined); + setL2ApproveTxHash(undefined); + setIsApprovalTx(false); }, [onCloseWithdrawModal]); + const initiateApproval = useCallback(() => { + void (async () => { + setIsApprovalTx(true); + onOpenWithdrawModal(); + try { + const approveResult = await approveWrite?.(); + if (approveResult?.hash) { + const approveTxHash: `0x${string}` = approveResult.hash; + setL2ApproveTxHash(approveTxHash); + + // wait for confirmations + await publicClient.waitForTransactionReceipt({ hash: approveResult.hash }); + + // next, call the transfer function + setIsApprovalTx(false); + + let withdrawMethod; + if (selectedAsset.protocol === 'CCTP') { + // because of how React works we need to use the writeContract wagmi/core action + // here (the hook still thinks the approval has not been set) + withdrawMethod = async () => await writeContract(withdrawCCTPAssetConfig); + } else { + withdrawMethod = isSmartContractWallet ? withdrawERC20To : withdrawERC20; + } + const withdrawResult = await withdrawMethod?.(); + if (withdrawResult?.hash) { + const withdrawTxHash = withdrawResult.hash; + setL2WithdrawTxHash(withdrawTxHash); + setWithdrawAmount(''); + } + } + } catch (error) { + onCloseWithdrawModal(); + } + })(); + }, [ + approveWrite, + isSmartContractWallet, + onCloseWithdrawModal, + onOpenWithdrawModal, + publicClient, + selectedAsset.protocol, + withdrawCCTPAssetConfig, + withdrawERC20, + withdrawERC20To, + ]); + const initiateWithdrawal = useCallback(() => { void (async () => { onOpenWithdrawModal(); @@ -101,15 +200,19 @@ export function WithdrawContainer() { if (isPermittedToBridge) { let withdrawMethod; if (selectedAsset.L1contract) { - withdrawMethod = isSmartContractWallet ? withdrawERC20To : withdrawERC20; + if (selectedAsset.protocol === 'CCTP') { + withdrawMethod = withdrawCCTPAssetWrite; + } else { + withdrawMethod = isSmartContractWallet ? withdrawERC20To : withdrawERC20; + } } else { withdrawMethod = withdraw; } const withdrawalResult = await withdrawMethod?.(); if (withdrawalResult?.hash) { const withdrawalTxHsh = withdrawalResult.hash; - setL2TxHash(withdrawalTxHsh); - setWithdrawAmount('0'); + setL2WithdrawTxHash(withdrawalTxHsh); + setWithdrawAmount(''); } } else { onCloseWithdrawModal(); @@ -122,6 +225,8 @@ export function WithdrawContainer() { onOpenWithdrawModal, isPermittedToBridge, selectedAsset.L1contract, + selectedAsset.protocol, + withdrawCCTPAssetWrite, isSmartContractWallet, withdrawERC20To, withdrawERC20, @@ -136,6 +241,19 @@ export function WithdrawContainer() { button = ( ); + } else if (selectedAsset.protocol === 'CCTP' && !readApprovalResult) { + withdrawDisabled = + (isSmartContractWallet && !isAddress(withdrawTo ?? '')) || !isPermittedToBridge; + + button = ( + + Approval + + ); } else { withdrawDisabled = parseFloat(withdrawAmount) <= 0 || @@ -162,7 +280,10 @@ export function WithdrawContainer() { void; - L2TxHash: string; + L2ApproveTxHash: `0x${string}` | undefined; + L2WithdrawTxHash: `0x${string}` | undefined; + isApprovalTx: boolean; + protocol: BridgeProtocol; }; -const Titles = { - WITHDRAW_NOT_STARTED: 'CONFIRM WITHDRAWAL IN WALLET', - WITHDRAW_STARTED: 'WITHDRAWAL IN PROGRESS', +type STATE = + | 'APPROVAL_LOADING' + | 'APPROVAL_CONFIRMED' + | 'APPROVAL_NOT_STARTED' + | 'WITHDRAWAL_LOADING' + | 'OP_WITHDRAWAL_STARTED' + | 'CCTP_WITHDRAWAL_STARTED' + | 'WITHDRAWAL_NOT_STARTED'; + +function getState( + isApprovalTx: boolean, + isApproveLoading: boolean, + isApproveSuccess: boolean, + isWithdrawalLoading: boolean, + isWithdrawalSuccess: boolean, + protocol: BridgeProtocol, +): STATE { + if (isApprovalTx && isApproveLoading) { + return 'APPROVAL_LOADING'; + } + if (isApprovalTx && isApproveSuccess) { + return 'APPROVAL_CONFIRMED'; + } + if (isApprovalTx) { + return 'APPROVAL_NOT_STARTED'; + } + if (!isApprovalTx && isWithdrawalLoading) { + return 'WITHDRAWAL_LOADING'; + } + if (!isApprovalTx && isWithdrawalSuccess) { + return protocol === 'CCTP' ? 'CCTP_WITHDRAWAL_STARTED' : 'OP_WITHDRAWAL_STARTED'; + } + + return 'WITHDRAWAL_NOT_STARTED'; +} + +const Titles: Record = { + APPROVAL_NOT_STARTED: 'APPROVE IN YOUR WALLET', + APPROVAL_LOADING: 'APPROVING', + APPROVAL_CONFIRMED: 'APPROVED', + WITHDRAWAL_NOT_STARTED: 'CONFIRM WITHDRAWAL IN WALLET', + WITHDRAWAL_LOADING: 'CONFIRMING', + OP_WITHDRAWAL_STARTED: 'WITHDRAWAL IN PROGRESS', + CCTP_WITHDRAWAL_STARTED: 'WITHDRAWAL IN PROGRESS', }; -const Icons = { - WITHDRAW_NOT_STARTED: 'wallet', - WITHDRAW_STARTED: '', +const Icons: Record = { + APPROVAL_NOT_STARTED: 'wallet', + APPROVAL_LOADING: 'wallet', + APPROVAL_CONFIRMED: 'confirm', + WITHDRAWAL_NOT_STARTED: 'wallet', + WITHDRAWAL_LOADING: 'wallet', + OP_WITHDRAWAL_STARTED: '', + CCTP_WITHDRAWAL_STARTED: '', }; -const ModalContents = { - WITHDRAW_NOT_STARTED: 'Withdrawal will begin after confirmation.', - WITHDRAW_STARTED: , +const ModalContents: Record = { + APPROVAL_NOT_STARTED: 'Approval will initiate after confirmation.', + APPROVAL_LOADING: 'Waiting for confirmations...', + APPROVAL_CONFIRMED: 'Transaction confirmed.', + WITHDRAWAL_NOT_STARTED: 'Withdrawal will initiate after confirmation.', + WITHDRAWAL_LOADING: 'Waiting for confirmations...', + OP_WITHDRAWAL_STARTED: , + CCTP_WITHDRAWAL_STARTED: , }; -export function WithdrawModal({ isOpen, onClose, L2TxHash }: WithdrawModalProps) { - const state = L2TxHash === '' ? 'WITHDRAW_NOT_STARTED' : 'WITHDRAW_STARTED'; +export function WithdrawModal({ + isOpen, + onClose, + L2ApproveTxHash, + L2WithdrawTxHash, + isApprovalTx, + protocol, +}: WithdrawModalProps) { + const { isLoading: isApproveLoading, isSuccess: isApproveSuccess } = useWaitForTransaction({ + hash: L2ApproveTxHash, + }); + + const { isLoading: isWithdrawalLoading, isSuccess: isWithdrawalSuccess } = useWaitForTransaction({ + hash: L2WithdrawTxHash, + }); + + const state = getState( + isApprovalTx, + isApproveLoading, + isApproveSuccess, + isWithdrawalLoading, + isWithdrawalSuccess, + protocol, + ); return ( diff --git a/apps/bridge/src/components/WithdrawProgressBar/WithdrawProgressBar.tsx b/apps/bridge/src/components/WithdrawProgressBar/WithdrawProgressBar.tsx index 120761a..04c0ec3 100644 --- a/apps/bridge/src/components/WithdrawProgressBar/WithdrawProgressBar.tsx +++ b/apps/bridge/src/components/WithdrawProgressBar/WithdrawProgressBar.tsx @@ -54,9 +54,9 @@ const DisclaimerContent: Record = { <> In order to minimize security risk, withdrawals using the official Base Bridge take up to{' '} {challengeWindow}. After your withdrawal request is proposed onchain (within an hour) you must - verify and complete the transaction in order to access your funds, at{' '} + verify and complete the transaction in order to access your funds, on{' '} - bridge.base.org/transactions + the transactions page . @@ -64,9 +64,9 @@ const DisclaimerContent: Record = { VERIFYING: ( <> In order to keep attackers from withdrawing your funds, there is a {challengeWindow} waiting - period until you can complete your withdrawal. Check back at{' '} + period until you can complete your withdrawal. Check back on{' '} - bridge.base.org/transactions + the transactions page {' '} to complete your withdrawal. diff --git a/apps/bridge/src/constants/phaseText.ts b/apps/bridge/src/constants/phaseText.ts index 41a83b5..f91b020 100644 --- a/apps/bridge/src/constants/phaseText.ts +++ b/apps/bridge/src/constants/phaseText.ts @@ -1,3 +1,4 @@ +import { CCTPBridgePhase } from 'apps/bridge/src/utils/transactions/phase'; import moment from 'moment'; const withdrawalPhaseText = { @@ -18,6 +19,15 @@ const depositPhaseText = { FUNDS_DEPOSITED: 'Funds deposited', }; +const cctpBridgePhaseText: Record = { + INITIATE_CCTP_BRIDGE_PENDING: 'Processing', + INITIATE_CCTP_BRIDGE_FAILED: 'Failed', + FINALIZE_CCTP_BRIDGE: 'Ready to complete', + FINALIZE_CCTP_BRIDGE_PENDING: 'Processing', + FINALIZE_CCTP_BRIDGE_FAILED: 'Failed', + CCTP_BRIDGE_COMPLETE: 'Funds moved', +}; + const withdrawalPhaseStatusText = { PROPOSING_ON_CHAIN: 'Wait up to 1 hr', PROVE: '', @@ -38,4 +48,10 @@ const depositPhaseStatusText = { FUNDS_DEPOSITED: 'Complete', }; -export { depositPhaseStatusText, depositPhaseText, withdrawalPhaseStatusText, withdrawalPhaseText }; +export { + depositPhaseStatusText, + depositPhaseText, + withdrawalPhaseStatusText, + withdrawalPhaseText, + cctpBridgePhaseText, +}; diff --git a/apps/bridge/src/contract-abis/MessageTransmitter.ts b/apps/bridge/src/contract-abis/MessageTransmitter.ts new file mode 100644 index 0000000..10f4864 --- /dev/null +++ b/apps/bridge/src/contract-abis/MessageTransmitter.ts @@ -0,0 +1,326 @@ +export default [ + { + inputs: [ + { internalType: 'uint32', name: '_localDomain', type: 'uint32' }, + { internalType: 'address', name: '_attester', type: 'address' }, + { internalType: 'uint32', name: '_maxMessageBodySize', type: 'uint32' }, + { internalType: 'uint32', name: '_version', type: 'uint32' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'attester', type: 'address' }], + name: 'AttesterDisabled', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'attester', type: 'address' }], + name: 'AttesterEnabled', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'previousAttesterManager', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newAttesterManager', type: 'address' }, + ], + name: 'AttesterManagerUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'uint256', name: 'newMaxMessageBodySize', type: 'uint256' }, + ], + name: 'MaxMessageBodySizeUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'caller', type: 'address' }, + { indexed: false, internalType: 'uint32', name: 'sourceDomain', type: 'uint32' }, + { indexed: true, internalType: 'uint64', name: 'nonce', type: 'uint64' }, + { indexed: false, internalType: 'bytes32', name: 'sender', type: 'bytes32' }, + { indexed: false, internalType: 'bytes', name: 'messageBody', type: 'bytes' }, + ], + name: 'MessageReceived', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: 'bytes', name: 'message', type: 'bytes' }], + name: 'MessageSent', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'previousOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferStarted', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'previousOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { anonymous: false, inputs: [], name: 'Pause', type: 'event' }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'newAddress', type: 'address' }], + name: 'PauserChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'newRescuer', type: 'address' }], + name: 'RescuerChanged', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'uint256', name: 'oldSignatureThreshold', type: 'uint256' }, + { indexed: false, internalType: 'uint256', name: 'newSignatureThreshold', type: 'uint256' }, + ], + name: 'SignatureThresholdUpdated', + type: 'event', + }, + { anonymous: false, inputs: [], name: 'Unpause', type: 'event' }, + { + inputs: [], + name: 'acceptOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'attesterManager', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'attester', type: 'address' }], + name: 'disableAttester', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newAttester', type: 'address' }], + name: 'enableAttester', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'index', type: 'uint256' }], + name: 'getEnabledAttester', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getNumEnabledAttesters', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'attester', type: 'address' }], + name: 'isEnabledAttester', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'localDomain', + outputs: [{ internalType: 'uint32', name: '', type: 'uint32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'maxMessageBodySize', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'nextAvailableNonce', + outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { inputs: [], name: 'pause', outputs: [], stateMutability: 'nonpayable', type: 'function' }, + { + inputs: [], + name: 'paused', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pauser', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pendingOwner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes', name: 'message', type: 'bytes' }, + { internalType: 'bytes', name: 'attestation', type: 'bytes' }, + ], + name: 'receiveMessage', + outputs: [{ internalType: 'bool', name: 'success', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes', name: 'originalMessage', type: 'bytes' }, + { internalType: 'bytes', name: 'originalAttestation', type: 'bytes' }, + { internalType: 'bytes', name: 'newMessageBody', type: 'bytes' }, + { internalType: 'bytes32', name: 'newDestinationCaller', type: 'bytes32' }, + ], + name: 'replaceMessage', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'contract IERC20', name: 'tokenContract', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'rescueERC20', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'rescuer', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint32', name: 'destinationDomain', type: 'uint32' }, + { internalType: 'bytes32', name: 'recipient', type: 'bytes32' }, + { internalType: 'bytes', name: 'messageBody', type: 'bytes' }, + ], + name: 'sendMessage', + outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint32', name: 'destinationDomain', type: 'uint32' }, + { internalType: 'bytes32', name: 'recipient', type: 'bytes32' }, + { internalType: 'bytes32', name: 'destinationCaller', type: 'bytes32' }, + { internalType: 'bytes', name: 'messageBody', type: 'bytes' }, + ], + name: 'sendMessageWithCaller', + outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'newMaxMessageBodySize', type: 'uint256' }], + name: 'setMaxMessageBodySize', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'newSignatureThreshold', type: 'uint256' }], + name: 'setSignatureThreshold', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'signatureThreshold', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { inputs: [], name: 'unpause', outputs: [], stateMutability: 'nonpayable', type: 'function' }, + { + inputs: [{ internalType: 'address', name: 'newAttesterManager', type: 'address' }], + name: 'updateAttesterManager', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: '_newPauser', type: 'address' }], + name: 'updatePauser', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newRescuer', type: 'address' }], + name: 'updateRescuer', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + name: 'usedNonces', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'version', + outputs: [{ internalType: 'uint32', name: '', type: 'uint32' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/apps/bridge/src/contract-abis/TokenMessenger.ts b/apps/bridge/src/contract-abis/TokenMessenger.ts new file mode 100644 index 0000000..14c67c5 --- /dev/null +++ b/apps/bridge/src/contract-abis/TokenMessenger.ts @@ -0,0 +1,254 @@ +export default [ + { + inputs: [ + { internalType: 'address', name: '_messageTransmitter', type: 'address' }, + { internalType: 'uint32', name: '_messageBodyVersion', type: 'uint32' }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'uint64', name: 'nonce', type: 'uint64' }, + { indexed: true, internalType: 'address', name: 'burnToken', type: 'address' }, + { indexed: false, internalType: 'uint256', name: 'amount', type: 'uint256' }, + { indexed: true, internalType: 'address', name: 'depositor', type: 'address' }, + { indexed: false, internalType: 'bytes32', name: 'mintRecipient', type: 'bytes32' }, + { indexed: false, internalType: 'uint32', name: 'destinationDomain', type: 'uint32' }, + { + indexed: false, + internalType: 'bytes32', + name: 'destinationTokenMessenger', + type: 'bytes32', + }, + { indexed: false, internalType: 'bytes32', name: 'destinationCaller', type: 'bytes32' }, + ], + name: 'DepositForBurn', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: 'address', name: 'localMinter', type: 'address' }], + name: 'LocalMinterAdded', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: 'address', name: 'localMinter', type: 'address' }], + name: 'LocalMinterRemoved', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'mintRecipient', type: 'address' }, + { indexed: false, internalType: 'uint256', name: 'amount', type: 'uint256' }, + { indexed: true, internalType: 'address', name: 'mintToken', type: 'address' }, + ], + name: 'MintAndWithdraw', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'previousOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferStarted', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'previousOwner', type: 'address' }, + { indexed: true, internalType: 'address', name: 'newOwner', type: 'address' }, + ], + name: 'OwnershipTransferred', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'uint32', name: 'domain', type: 'uint32' }, + { indexed: false, internalType: 'bytes32', name: 'tokenMessenger', type: 'bytes32' }, + ], + name: 'RemoteTokenMessengerAdded', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: 'uint32', name: 'domain', type: 'uint32' }, + { indexed: false, internalType: 'bytes32', name: 'tokenMessenger', type: 'bytes32' }, + ], + name: 'RemoteTokenMessengerRemoved', + type: 'event', + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: 'address', name: 'newRescuer', type: 'address' }], + name: 'RescuerChanged', + type: 'event', + }, + { + inputs: [], + name: 'acceptOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newLocalMinter', type: 'address' }], + name: 'addLocalMinter', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint32', name: 'domain', type: 'uint32' }, + { internalType: 'bytes32', name: 'tokenMessenger', type: 'bytes32' }, + ], + name: 'addRemoteTokenMessenger', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'uint32', name: 'destinationDomain', type: 'uint32' }, + { internalType: 'bytes32', name: 'mintRecipient', type: 'bytes32' }, + { internalType: 'address', name: 'burnToken', type: 'address' }, + ], + name: 'depositForBurn', + outputs: [{ internalType: 'uint64', name: '_nonce', type: 'uint64' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'uint32', name: 'destinationDomain', type: 'uint32' }, + { internalType: 'bytes32', name: 'mintRecipient', type: 'bytes32' }, + { internalType: 'address', name: 'burnToken', type: 'address' }, + { internalType: 'bytes32', name: 'destinationCaller', type: 'bytes32' }, + ], + name: 'depositForBurnWithCaller', + outputs: [{ internalType: 'uint64', name: 'nonce', type: 'uint64' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'uint32', name: 'remoteDomain', type: 'uint32' }, + { internalType: 'bytes32', name: 'sender', type: 'bytes32' }, + { internalType: 'bytes', name: 'messageBody', type: 'bytes' }, + ], + name: 'handleReceiveMessage', + outputs: [{ internalType: 'bool', name: '', type: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'localMessageTransmitter', + outputs: [{ internalType: 'contract IMessageTransmitter', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'localMinter', + outputs: [{ internalType: 'contract ITokenMinter', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'messageBodyVersion', + outputs: [{ internalType: 'uint32', name: '', type: 'uint32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'owner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'pendingOwner', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint32', name: '', type: 'uint32' }], + name: 'remoteTokenMessengers', + outputs: [{ internalType: 'bytes32', name: '', type: 'bytes32' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'removeLocalMinter', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'uint32', name: 'domain', type: 'uint32' }], + name: 'removeRemoteTokenMessenger', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'bytes', name: 'originalMessage', type: 'bytes' }, + { internalType: 'bytes', name: 'originalAttestation', type: 'bytes' }, + { internalType: 'bytes32', name: 'newDestinationCaller', type: 'bytes32' }, + { internalType: 'bytes32', name: 'newMintRecipient', type: 'bytes32' }, + ], + name: 'replaceDepositForBurn', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { internalType: 'contract IERC20', name: 'tokenContract', type: 'address' }, + { internalType: 'address', name: 'to', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + ], + name: 'rescueERC20', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'rescuer', + outputs: [{ internalType: 'address', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newOwner', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'newRescuer', type: 'address' }], + name: 'updateRescuer', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; diff --git a/apps/bridge/src/types/Asset.ts b/apps/bridge/src/types/Asset.ts index fae98b2..c5ff7e7 100644 --- a/apps/bridge/src/types/Asset.ts +++ b/apps/bridge/src/types/Asset.ts @@ -1,6 +1,10 @@ import { Address } from 'wagmi'; import { Chain } from 'wagmi/chains'; +// OP --> Optimism bridge +// CCTP --> Circle Cross-Chain Transfer Protocol (eg USDC) +export type BridgeProtocol = 'OP' | 'CCTP'; + export type Asset = { L1symbol: string; L2symbol: string; @@ -12,6 +16,7 @@ export type Asset = { L1contract?: Address; L2contract?: Address; decimals: number; + protocol: BridgeProtocol; }; export type CustomChain = Chain & { diff --git a/apps/bridge/src/types/BridgeTransaction.ts b/apps/bridge/src/types/BridgeTransaction.ts index 4b72f58..4e08289 100644 --- a/apps/bridge/src/types/BridgeTransaction.ts +++ b/apps/bridge/src/types/BridgeTransaction.ts @@ -1,4 +1,5 @@ import { TransactionStatus } from 'apps/bridge/src/types/API'; +import { BridgeProtocol } from 'apps/bridge/src/types/Asset'; type TransactionType = 'Deposit' | 'Withdrawal'; @@ -12,4 +13,6 @@ export type BridgeTransaction = { hash: `0x${string}`; status?: TransactionStatus; priceApiId: string; + assetDecimals?: number; + protocol: BridgeProtocol; }; diff --git a/apps/bridge/src/utils/assets/getAssetListForChainEnv.ts b/apps/bridge/src/utils/assets/getAssetListForChainEnv.ts index 3317025..611625a 100644 --- a/apps/bridge/src/utils/assets/getAssetListForChainEnv.ts +++ b/apps/bridge/src/utils/assets/getAssetListForChainEnv.ts @@ -3,10 +3,12 @@ import getConfig from 'next/config'; const { publicRuntimeConfig } = getConfig(); +// Get all assets for the current chain environment. export function getAssetListForChainEnv() { return assetList.filter( (asset) => asset.L1chainId === parseInt(publicRuntimeConfig.l1ChainID) && - asset.L2chainId === parseInt(publicRuntimeConfig.l2ChainID), + asset.L2chainId === parseInt(publicRuntimeConfig.l2ChainID) && + publicRuntimeConfig.assets.split(',').includes(asset.L1symbol.toLowerCase()), ); } diff --git a/apps/bridge/src/utils/assets/getDepositAssetsForChainEnv.ts b/apps/bridge/src/utils/assets/getDepositAssetsForChainEnv.ts new file mode 100644 index 0000000..77f4dc4 --- /dev/null +++ b/apps/bridge/src/utils/assets/getDepositAssetsForChainEnv.ts @@ -0,0 +1,28 @@ +import { getAssetListForChainEnv } from 'apps/bridge/src/utils/assets/getAssetListForChainEnv'; +import getConfig from 'next/config'; + +const { publicRuntimeConfig } = getConfig(); + +// Get depositable assets on current chain. Note that we do not want to include +// bridged USDC (USDbC on L2) because it is preferable to bridge native USDC using +// CCTP. Returned list is sorted as [ETH, USDC, ...rest]. +export function getDepositAssetsForChainEnv() { + const assetList = getAssetListForChainEnv(); + + const nonUSDCAssets = assetList.filter((asset) => asset.L1symbol !== 'USDC'); + const bridgedUSDC = assetList.filter( + (asset) => asset.L1symbol === 'USDC' && asset.protocol === 'OP', + ); + const nativeUSDC = assetList.filter( + (asset) => asset.L1symbol === 'USDC' && asset.protocol === 'CCTP', + ); + + const usdcForChainEnv = publicRuntimeConfig.cctpEnabled === 'true' ? nativeUSDC : bridgedUSDC; + + return [...nonUSDCAssets, ...usdcForChainEnv].sort((a, b) => { + if (a.L1symbol === 'ETH') return -1; + if (b.L1symbol === 'ETH') return 1; + + return a.L1symbol === 'USDC' ? -1 : 0; + }); +} diff --git a/apps/bridge/src/utils/assets/getWithdrawalAssetsForChainEnv.ts b/apps/bridge/src/utils/assets/getWithdrawalAssetsForChainEnv.ts new file mode 100644 index 0000000..2471a21 --- /dev/null +++ b/apps/bridge/src/utils/assets/getWithdrawalAssetsForChainEnv.ts @@ -0,0 +1,30 @@ +import { getAssetListForChainEnv } from 'apps/bridge/src/utils/assets/getAssetListForChainEnv'; +import getConfig from 'next/config'; + +const { publicRuntimeConfig } = getConfig(); + +// Get withdrawable assets on current chain. Note that we want to include +// both bridge and native USDC so users who have previously minted USDbC +// on L2 have a way of bridging it back to USDC on L1. +// Returned list is sorted as [ETH, USDC, ...rest]. +export function getWithdrawalAssetsForChainEnv() { + const assetList = getAssetListForChainEnv(); + + const nonUSDCAssets = assetList.filter((asset) => asset.L1symbol !== 'USDC'); + const bridgedUSDC = assetList.filter( + (asset) => asset.L1symbol === 'USDC' && asset.protocol === 'OP', + ); + const nativeUSDC = assetList.filter( + (asset) => asset.L1symbol === 'USDC' && asset.protocol === 'CCTP', + ); + + const usdcForChainEnv = + publicRuntimeConfig.cctpEnabled === 'true' ? [...bridgedUSDC, ...nativeUSDC] : bridgedUSDC; + + return [...nonUSDCAssets, ...usdcForChainEnv].sort((a, b) => { + if (a.L1symbol === 'ETH') return -1; + if (b.L1symbol === 'ETH') return 1; + + return a.L1symbol === 'USDC' ? -1 : 0; + }); +} diff --git a/apps/bridge/src/utils/hooks/useApproveContract.ts b/apps/bridge/src/utils/hooks/useApproveContract.ts index 59c02dc..f651590 100644 --- a/apps/bridge/src/utils/hooks/useApproveContract.ts +++ b/apps/bridge/src/utils/hooks/useApproveContract.ts @@ -6,14 +6,18 @@ const { publicRuntimeConfig } = getConfig(); type UseApproveContractProps = { contractAddress?: Address; + spender: Address; approveAmount: string; decimals: number; + bridgeDirection: 'deposit' | 'withdraw'; }; export function useApproveContract({ contractAddress, + spender, approveAmount, decimals, + bridgeDirection, }: UseApproveContractProps) { const approveAmountBN = approveAmount === '' || Number.isNaN(Number(approveAmount)) @@ -21,12 +25,13 @@ export function useApproveContract({ : parseUnits(approveAmount, decimals); const { config: depositConfig } = usePrepareContractWrite({ address: contractAddress, - // TODO: Replace with dynamic abi importer abi: erc20ABI, functionName: 'approve', - chainId: parseInt(publicRuntimeConfig.l1ChainID), - // TODO: Add Allowance selection components - args: [publicRuntimeConfig.l1BridgeProxyAddress, approveAmountBN], + chainId: + bridgeDirection === 'deposit' + ? parseInt(publicRuntimeConfig.l1ChainID) + : parseInt(publicRuntimeConfig.l2ChainID), + args: [spender, approveAmountBN], cacheTime: 0, }); return depositConfig; diff --git a/apps/bridge/src/utils/hooks/useCCTPBridgeStatus.ts b/apps/bridge/src/utils/hooks/useCCTPBridgeStatus.ts new file mode 100644 index 0000000..f9b105f --- /dev/null +++ b/apps/bridge/src/utils/hooks/useCCTPBridgeStatus.ts @@ -0,0 +1,131 @@ +import { useEffect, useState } from 'react'; +import { usePublicClient, useWaitForTransaction } from 'wagmi'; +import getConfig from 'next/config'; +import { CCTPBridgePhase } from 'apps/bridge/src/utils/transactions/phase'; +import { useQuery } from 'react-query'; +import { keccak256, getEventSelector, decodeAbiParameters, parseAbiParameter } from 'viem'; +import MessageTransmitter from 'apps/bridge/src/contract-abis/MessageTransmitter'; + +const { publicRuntimeConfig } = getConfig(); +const l1ChainID = parseInt(publicRuntimeConfig.l1ChainID); +const l2ChainID = parseInt(publicRuntimeConfig.l2ChainID); + +type BridgeAttestation = { + attestation: `0x${string}`; + status: string; +}; + +async function fetchBridgeAttestation(messageHash: string): Promise { + const response = await fetch( + `${publicRuntimeConfig.cctpAttestationsAPIURL}/attestations/${messageHash}`, + ); + + return (await response.json()) as BridgeAttestation; +} + +type UseCCTPBridgeStatusProps = { + initiateTxHash: `0x${string}`; + bridgeDirection: 'deposit' | 'withdraw'; +}; + +type CCTPBridgeStatus = { + status: CCTPBridgePhase; + message?: `0x${string}`; + attestation?: `0x${string}`; + setStatus: (newStatus: CCTPBridgePhase) => void; +}; + +export function useCCTPBridgeStatus({ + initiateTxHash, + bridgeDirection, +}: UseCCTPBridgeStatusProps): CCTPBridgeStatus { + const [status, setStatus] = useState('INITIATE_CCTP_BRIDGE_PENDING'); + const [message, setMessage] = useState<`0x${string}` | undefined>(undefined); + const [messageHash, setMessageHash] = useState<`0x${string}` | undefined>(undefined); + const isDeposit = bridgeDirection === 'deposit'; + + // The public client we will use to simulate finalizing the bridge + // on the other chain. Ie if we are depositing this will be an L2 client, + // and if we are withdrawing this will be an L1 client. + const publicClient = usePublicClient({ + chainId: isDeposit ? l2ChainID : l1ChainID, + }); + + // Waiting for the bridge initiation on the chain that the user is bridging from. + // Ie if we are depositing this will be an L1 tx, + // and if we are withdrawing this will be an L2 tx. + const { data: initiateTxReceipt } = useWaitForTransaction({ + hash: initiateTxHash, + chainId: isDeposit ? l1ChainID : l2ChainID, + }); + + const { data: bridgeAttestation } = useQuery( + ['bridgeAttestation', { initiateTxHash, messageHash }], + async () => fetchBridgeAttestation(messageHash as string), + { + enabled: !!messageHash, + refetchOnMount: false, + refetchOnWindowFocus: false, + placeholderData: undefined, + }, + ); + + useEffect(() => { + if (initiateTxReceipt) { + if (initiateTxReceipt.status === 'reverted') { + setStatus('INITIATE_CCTP_BRIDGE_FAILED'); + return; + } + + const eventTopic = getEventSelector('MessageSent(bytes)'); + const log = initiateTxReceipt.logs.find((l) => l.topics[0] === eventTopic); + const messageBytes = decodeAbiParameters( + [parseAbiParameter('bytes message')], + log?.data as `0x${string}`, + )[0]; + const hash = keccak256(messageBytes); + setMessage(messageBytes); + setMessageHash(hash); + } + }, [initiateTxReceipt]); + + useEffect(() => { + if (bridgeAttestation?.attestation && message) { + void (async () => { + if (bridgeAttestation.status === 'pending_confirmations') { + setStatus('INITIATE_CCTP_BRIDGE_PENDING'); + return; + } + + // We still need to check whether or not the message was received on the other chain. + // If it hasn't been received yet, the user needs to call receiveMessage on the MessageTransmitter. + // It it has been received, the bridge is complete. + // We can check if the message has been received by simulating a call to receiveMessage. + // If it fails, assume it's because the message has already been received. + try { + await publicClient.simulateContract({ + address: isDeposit + ? publicRuntimeConfig.l2CCTPMessageTransmitterAddress + : publicRuntimeConfig.l1CCTPMessageTransmitterAddress, + abi: MessageTransmitter, + functionName: 'receiveMessage', + args: [message, bridgeAttestation.attestation], + }); + // Success, ie the message still needs to be received. + setStatus('FINALIZE_CCTP_BRIDGE'); + } catch (e) { + // Failed, ie the message has already been received. + setStatus('CCTP_BRIDGE_COMPLETE'); + } + })(); + } + }, [bridgeAttestation, bridgeDirection, initiateTxReceipt, isDeposit, message, publicClient]); + + // We need to be able to set the status from the FinzliaeCCTPBridgeButton, so we expose a setter. + return { + status, + message, + attestation: bridgeAttestation?.attestation, + setStatus: setStatus, + }; +} diff --git a/apps/bridge/src/utils/hooks/useGetUSDAmount.ts b/apps/bridge/src/utils/hooks/useGetUSDAmount.ts new file mode 100644 index 0000000..8ca711a --- /dev/null +++ b/apps/bridge/src/utils/hooks/useGetUSDAmount.ts @@ -0,0 +1,14 @@ +import { usdFormatter } from 'apps/bridge/src/utils/formatter/balance'; +import { useConversionRate } from 'apps/bridge/src/utils/hooks/useConversionRate'; + +export function useGetUSDAmount(priceAPIID: string, formattedAmount: string) { + const conversionRateData = useConversionRate({ + asset: priceAPIID, + }); + + const amountFiat = conversionRateData + ? usdFormatter(conversionRateData * +formattedAmount) + : '$0.00'; + + return amountFiat; +} diff --git a/apps/bridge/src/utils/hooks/useIsContractApproved.ts b/apps/bridge/src/utils/hooks/useIsContractApproved.ts index 262bc82..cd13705 100644 --- a/apps/bridge/src/utils/hooks/useIsContractApproved.ts +++ b/apps/bridge/src/utils/hooks/useIsContractApproved.ts @@ -6,15 +6,25 @@ const { publicRuntimeConfig } = getConfig(); type UseIsContractApprovedProps = { contactAddress?: `0x${string}`; address?: `0x${string}`; + spender: `0x${string}`; + bridgeDirection: 'deposit' | 'withdraw'; }; -export function useIsContractApproved({ contactAddress, address }: UseIsContractApprovedProps) { +export function useIsContractApproved({ + contactAddress, + address, + spender, + bridgeDirection, +}: UseIsContractApprovedProps) { return useContractRead({ address: contactAddress, abi: erc20ABI, functionName: 'allowance', watch: true, - chainId: parseInt(publicRuntimeConfig.l1ChainID), - args: [address as Address, publicRuntimeConfig.l1BridgeProxyAddress], + chainId: + bridgeDirection === 'deposit' + ? parseInt(publicRuntimeConfig.l1ChainID) + : parseInt(publicRuntimeConfig.l2ChainID), + args: [address as Address, spender], }); } diff --git a/apps/bridge/src/utils/hooks/usePrepareFinalizeCCTPBridge.ts b/apps/bridge/src/utils/hooks/usePrepareFinalizeCCTPBridge.ts new file mode 100644 index 0000000..6e3c00b --- /dev/null +++ b/apps/bridge/src/utils/hooks/usePrepareFinalizeCCTPBridge.ts @@ -0,0 +1,44 @@ +import { usePrepareContractWrite } from 'wagmi'; +import getConfig from 'next/config'; +import MessageTransmitter from 'apps/bridge/src/contract-abis/MessageTransmitter'; + +const { publicRuntimeConfig } = getConfig(); + +const BRIDGE_DIRECTION_TO_MESSAGE_TRANSMITTER: Record<'deposit' | 'withdraw', `0x${string}`> = { + deposit: publicRuntimeConfig.l2CCTPMessageTransmitterAddress, + withdraw: publicRuntimeConfig.l1CCTPMessageTransmitterAddress, +}; + +const BRIDGE_DIRECTION_TO_CHAIN_ID: Record<'deposit' | 'withdraw', number> = { + deposit: parseInt(publicRuntimeConfig.l2ChainID), + withdraw: parseInt(publicRuntimeConfig.l1ChainID), +}; + +type UsePrepareFinalizeCCTPBridgeProps = { + isPermittedToBridge: boolean; + message?: `0x${string}`; + attestation?: `0x${string}`; + bridgeDirection: 'deposit' | 'withdraw'; + includeTosVersionByte: boolean; +}; + +export function usePrepareFinalizeCCTPBridge({ + isPermittedToBridge, + message, + attestation, + bridgeDirection, + includeTosVersionByte, +}: UsePrepareFinalizeCCTPBridgeProps) { + const shouldPrepare = isPermittedToBridge && message && attestation; + + const { config } = usePrepareContractWrite({ + address: shouldPrepare ? BRIDGE_DIRECTION_TO_MESSAGE_TRANSMITTER[bridgeDirection] : undefined, + abi: MessageTransmitter, + functionName: 'receiveMessage', + chainId: BRIDGE_DIRECTION_TO_CHAIN_ID[bridgeDirection], + args: shouldPrepare ? [message, attestation] : undefined, + dataSuffix: includeTosVersionByte ? publicRuntimeConfig.tosVersion : undefined, + }); + + return config; +} diff --git a/apps/bridge/src/utils/hooks/usePrepareInitiateCCTPBridge.ts b/apps/bridge/src/utils/hooks/usePrepareInitiateCCTPBridge.ts new file mode 100644 index 0000000..0c2b6e6 --- /dev/null +++ b/apps/bridge/src/utils/hooks/usePrepareInitiateCCTPBridge.ts @@ -0,0 +1,56 @@ +import TokenMessenger from 'apps/bridge/src/contract-abis/TokenMessenger'; +import { Asset } from 'apps/bridge/src/types/Asset'; +import { parseUnits, pad } from 'viem'; +import getConfig from 'next/config'; +import { Address, usePrepareContractWrite } from 'wagmi'; + +const { publicRuntimeConfig } = getConfig(); + +const BRIDGE_DIRECTION_TO_TOKEN_MESSENGER: Record<'deposit' | 'withdraw', `0x${string}`> = { + deposit: publicRuntimeConfig.l1CCTPTokenMessengerAddress, + withdraw: publicRuntimeConfig.l2CCTPTokenMessengerAddress, +}; + +const BRIDGE_DIRECTION_TO_CHAIN_ID: Record<'deposit' | 'withdraw', number> = { + deposit: parseInt(publicRuntimeConfig.l1ChainID), + withdraw: parseInt(publicRuntimeConfig.l2ChainID), +}; + +type UsePrepareInitiateCCTPBridgeProps = { + mintRecipient?: Address; + asset: Asset; + amount: string; + destinationDomain: number; + isPermittedToBridge: boolean; + includeTosVersionByte: boolean; + bridgeDirection: 'deposit' | 'withdraw'; +}; + +export function usePrepareInitiateCCTPBridge({ + mintRecipient, + asset, + amount, + destinationDomain, + isPermittedToBridge, + bridgeDirection, + includeTosVersionByte, +}: UsePrepareInitiateCCTPBridgeProps) { + const shouldPrepare = isPermittedToBridge && amount !== '' && mintRecipient; + + const { config } = usePrepareContractWrite({ + address: shouldPrepare ? BRIDGE_DIRECTION_TO_TOKEN_MESSENGER[bridgeDirection] : undefined, + abi: TokenMessenger, + functionName: 'depositForBurn', + chainId: BRIDGE_DIRECTION_TO_CHAIN_ID[bridgeDirection], + args: shouldPrepare + ? [ + amount !== '' ? parseUnits(amount, asset.decimals) : parseUnits('0', asset.decimals), + destinationDomain, + pad(mintRecipient), + (bridgeDirection === 'deposit' ? asset.L1contract : asset.L2contract) as Address, + ] + : undefined, + dataSuffix: includeTosVersionByte ? publicRuntimeConfig.tosVersion : undefined, + }); + return config; +} diff --git a/apps/bridge/src/utils/transactions/explorerTxToBridgeDeposit.ts b/apps/bridge/src/utils/transactions/explorerTxToBridgeDeposit.ts index 51503f9..efb1603 100644 --- a/apps/bridge/src/utils/transactions/explorerTxToBridgeDeposit.ts +++ b/apps/bridge/src/utils/transactions/explorerTxToBridgeDeposit.ts @@ -3,8 +3,9 @@ import { Asset } from 'apps/bridge/src/types/Asset'; import { decodeFunctionData } from 'viem'; import { BridgeTransaction } from 'apps/bridge/src/types/BridgeTransaction'; import { getAssetListForChainEnv } from 'apps/bridge/src/utils/assets/getAssetListForChainEnv'; -import getConfig from 'next/config'; import { l1StandardBridgeABI } from '@eth-optimism/contracts-ts'; +import getConfig from 'next/config'; +import TokenMessenger from 'apps/bridge/src/contract-abis/TokenMessenger'; const assetList = getAssetListForChainEnv(); @@ -14,8 +15,13 @@ const ETH_DEPOSIT_ADDRESS = ( publicRuntimeConfig?.l1OptimismPortalProxyAddress ?? '0xe93c8cD0D409341205A592f8c4Ac1A5fe5585cfA' ).toLowerCase(); +const CCTP_DEPOSIT_ADDRESS = ( + publicRuntimeConfig?.l1CCTPTokenMessengerAddress ?? '0xd0c3da58f55358142b8d3e06c1c30c5c6114efe8' +).toLowerCase(); + export function explorerTxToBridgeDeposit(tx: BlockExplorerTransaction): BridgeTransaction { if (tx.to === ETH_DEPOSIT_ADDRESS) { + // ETH deposit (OP) return { type: 'Deposit', from: tx.from, @@ -26,6 +32,32 @@ export function explorerTxToBridgeDeposit(tx: BlockExplorerTransaction): BridgeT hash: tx.hash as `0x${string}`, status: 'Complete', priceApiId: 'ethereum', + assetDecimals: 18, + protocol: 'OP', + }; + } else if (tx.to === CCTP_DEPOSIT_ADDRESS) { + // CCTP deposit (CCTP) + const { args } = decodeFunctionData({ + abi: TokenMessenger, + data: tx.input, + }); + const token = assetList.find( + (asset) => + asset.L1chainId === parseInt(publicRuntimeConfig.l1ChainID) && + asset.L1contract?.toLowerCase() === (args?.[3] as string).toLowerCase() && + asset.protocol === 'CCTP', + ) as Asset; + return { + type: 'Deposit', + from: tx.from, + to: tx.to, + assetSymbol: token.L1symbol ?? '', + amount: (args?.[0] as bigint).toString(), + blockTimestamp: tx.timeStamp, + hash: tx.hash as `0x${string}`, + priceApiId: token.apiId, + assetDecimals: token.decimals, + protocol: 'CCTP', }; } @@ -48,5 +80,7 @@ export function explorerTxToBridgeDeposit(tx: BlockExplorerTransaction): BridgeT hash: tx.hash as `0x${string}`, status: 'Complete', priceApiId: token.apiId, + assetDecimals: token.decimals, + protocol: 'OP', }; } diff --git a/apps/bridge/src/utils/transactions/explorerTxToBridgeWithdrawal.ts b/apps/bridge/src/utils/transactions/explorerTxToBridgeWithdrawal.ts index 153ad29..bf28100 100644 --- a/apps/bridge/src/utils/transactions/explorerTxToBridgeWithdrawal.ts +++ b/apps/bridge/src/utils/transactions/explorerTxToBridgeWithdrawal.ts @@ -1,4 +1,5 @@ import { l2StandardBridgeABI } from '@eth-optimism/contracts-ts'; +import TokenMessenger from 'apps/bridge/src/contract-abis/TokenMessenger'; import { BlockExplorerTransaction } from 'apps/bridge/src/types/API'; import { Asset } from 'apps/bridge/src/types/Asset'; import { BridgeTransaction } from 'apps/bridge/src/types/BridgeTransaction'; @@ -14,8 +15,13 @@ const ETH_WITHDRAWAL_ADDRESS = ( publicRuntimeConfig?.l2L1MessagePasserAddress ?? '0x4200000000000000000000000000000000000016' ).toLowerCase(); +const CCTP_WITHDRAWAL_ADDRESS = ( + publicRuntimeConfig?.l2CCTPTokenMessengerAddress ?? '0x877b8e8c9e2383077809787ED6F279ce01CB4cc8' +).toLowerCase(); + export function explorerTxToBridgeWithdrawal(tx: BlockExplorerTransaction): BridgeTransaction { if (tx.to === ETH_WITHDRAWAL_ADDRESS) { + // ETH withdrawal (OP) return { type: 'Withdrawal', from: tx.from, @@ -25,6 +31,28 @@ export function explorerTxToBridgeWithdrawal(tx: BlockExplorerTransaction): Brid blockTimestamp: tx.timeStamp, hash: tx.hash as `0x${string}`, priceApiId: 'ethereum', + protocol: 'OP', + }; + } else if (tx.to === CCTP_WITHDRAWAL_ADDRESS) { + // CCTP withdrawal (CCTP) + const { args } = decodeFunctionData({ abi: TokenMessenger, data: tx.input }); + const token = assetList.find( + (asset) => + asset.L1chainId === parseInt(publicRuntimeConfig.l1ChainID) && + asset.L2contract?.toLowerCase() === (args?.[3] as string).toLowerCase() && + asset.protocol === 'CCTP', + ) as Asset; + return { + type: 'Withdrawal', + from: tx.from, + to: tx.to, + assetSymbol: token.L2symbol ?? '', + amount: (args?.[0] as bigint).toString(), + blockTimestamp: tx.timeStamp, + hash: tx.hash as `0x${string}`, + priceApiId: token.apiId, + assetDecimals: token.decimals, + protocol: 'CCTP', }; } @@ -42,5 +70,6 @@ export function explorerTxToBridgeWithdrawal(tx: BlockExplorerTransaction): Brid blockTimestamp: tx.timeStamp, hash: tx.hash as `0x${string}`, priceApiId: token?.apiId, + protocol: 'OP', }; } diff --git a/apps/bridge/src/utils/transactions/formatBlockTimestamp.ts b/apps/bridge/src/utils/transactions/formatBlockTimestamp.ts new file mode 100644 index 0000000..132cbb4 --- /dev/null +++ b/apps/bridge/src/utils/transactions/formatBlockTimestamp.ts @@ -0,0 +1,24 @@ +type FormattedTimestamp = { + date: string; + shortDate: string; + time: string; +}; + +export function formatTimestamp(timestamp: string): FormattedTimestamp { + const date = new Date(Number(timestamp) * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + const shortDate = new Date(Number(timestamp) * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + const time = new Date(Number(timestamp) * 1000).toLocaleTimeString('en-US', { + timeStyle: 'short', + }); + + return { date, shortDate, time }; +} diff --git a/apps/bridge/src/utils/transactions/isETHOrERC20Deposit.ts b/apps/bridge/src/utils/transactions/isETHOrERC20Deposit.ts index 2338d39..85e331e 100644 --- a/apps/bridge/src/utils/transactions/isETHOrERC20Deposit.ts +++ b/apps/bridge/src/utils/transactions/isETHOrERC20Deposit.ts @@ -2,6 +2,7 @@ import { l1StandardBridgeABI } from '@eth-optimism/contracts-ts'; import { decodeFunctionData } from 'viem'; import { BlockExplorerTransaction } from 'apps/bridge/src/types/API'; import getConfig from 'next/config'; +import TokenMessenger from 'apps/bridge/src/contract-abis/TokenMessenger'; const { publicRuntimeConfig } = getConfig(); @@ -13,9 +14,17 @@ const ERC20_DEPOSIT_ADDRESS = ( publicRuntimeConfig?.l1BridgeProxyAddress ?? '0xfA6D8Ee5BE770F84FC001D098C4bD604Fe01284a' ).toLowerCase(); +const CCTP_DEPOSIT_ADDRESS = ( + publicRuntimeConfig?.l1CCTPTokenMessengerAddress ?? '0xd0c3da58f55358142b8d3e06c1c30c5c6114efe8' +).toLowerCase(); + export function isETHOrERC20Deposit(tx: BlockExplorerTransaction) { // Immediately filter out if tx is not to an address we don't care about - if (tx.to !== ETH_DEPOSIT_ADDRESS && tx.to !== ERC20_DEPOSIT_ADDRESS) { + if ( + tx.to !== ETH_DEPOSIT_ADDRESS && + tx.to !== ERC20_DEPOSIT_ADDRESS && + tx.to !== CCTP_DEPOSIT_ADDRESS + ) { return false; } @@ -35,5 +44,16 @@ export function isETHOrERC20Deposit(tx: BlockExplorerTransaction) { } } + // CCTP deposit + if (tx.to === CCTP_DEPOSIT_ADDRESS) { + const { functionName } = decodeFunctionData({ + abi: TokenMessenger, + data: tx.input, + }); + if (functionName === 'depositForBurn') { + return true; + } + } + return false; } diff --git a/apps/bridge/src/utils/transactions/isETHOrERCWithdrawal.ts b/apps/bridge/src/utils/transactions/isETHOrERCWithdrawal.ts index af678dc..7ce72a6 100644 --- a/apps/bridge/src/utils/transactions/isETHOrERCWithdrawal.ts +++ b/apps/bridge/src/utils/transactions/isETHOrERCWithdrawal.ts @@ -1,4 +1,5 @@ import { l2StandardBridgeABI } from '@eth-optimism/contracts-ts'; +import TokenMessenger from 'apps/bridge/src/contract-abis/TokenMessenger'; import { BlockExplorerTransaction } from 'apps/bridge/src/types/API'; import getConfig from 'next/config'; import { decodeFunctionData } from 'viem'; @@ -13,9 +14,17 @@ const ERC20_WITHDRAWAL_ADDRESS = ( publicRuntimeConfig?.L2StandardBridge ?? '0x4200000000000000000000000000000000000010' ).toLowerCase(); +const CCTP_WITHDRAWAL_ADDRESS = ( + publicRuntimeConfig?.l2CCTPTokenMessengerAddress ?? '0x877b8e8c9e2383077809787ED6F279ce01CB4cc8' +).toLowerCase(); + export function isETHOrERC20Withdrawal(tx: BlockExplorerTransaction) { // Immediately filter out if tx is not to an address we don't care about - if (tx.to !== ETH_WITHDRAWAL_ADDRESS && tx.to !== ERC20_WITHDRAWAL_ADDRESS) { + if ( + tx.to !== ETH_WITHDRAWAL_ADDRESS && + tx.to !== ERC20_WITHDRAWAL_ADDRESS && + tx.to !== CCTP_WITHDRAWAL_ADDRESS + ) { return false; } @@ -32,5 +41,13 @@ export function isETHOrERC20Withdrawal(tx: BlockExplorerTransaction) { } } + // CCTP deposit + if (tx.to === CCTP_WITHDRAWAL_ADDRESS) { + const { functionName } = decodeFunctionData({ abi: TokenMessenger, data: tx.input }); + if (functionName === 'depositForBurn') { + return true; + } + } + return false; } diff --git a/apps/bridge/src/utils/transactions/phase.ts b/apps/bridge/src/utils/transactions/phase.ts index 708e978..2d275c5 100644 --- a/apps/bridge/src/utils/transactions/phase.ts +++ b/apps/bridge/src/utils/transactions/phase.ts @@ -10,4 +10,13 @@ type WithdrawalPhase = | 'FUNDS_WITHDRAWN'; type DepositPhase = 'DEPOSIT_TX_PENDING' | 'FUNDS_DEPOSITED' | 'DEPOSIT_TX_FAILURE'; -export type { DepositPhase, WithdrawalPhase }; + +type CCTPBridgePhase = + | 'INITIATE_CCTP_BRIDGE_PENDING' + | 'INITIATE_CCTP_BRIDGE_FAILED' + | 'FINALIZE_CCTP_BRIDGE' + | 'FINALIZE_CCTP_BRIDGE_PENDING' + | 'FINALIZE_CCTP_BRIDGE_FAILED' + | 'CCTP_BRIDGE_COMPLETE'; + +export type { DepositPhase, WithdrawalPhase, CCTPBridgePhase }; diff --git a/yarn.lock b/yarn.lock index 2a55a50..cbe24b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -273,7 +273,7 @@ __metadata: tailwindcss: ^3.2.4 typescript: next viem: latest - wagmi: ^1.4.3 + wagmi: ^1.4.4 webpack-bugsnag-plugins: ^1.8.0 languageName: unknown linkType: soft @@ -6656,9 +6656,9 @@ __metadata: languageName: node linkType: hard -"@wagmi/core@npm:1.4.3": - version: 1.4.3 - resolution: "@wagmi/core@npm:1.4.3" +"@wagmi/core@npm:1.4.4": + version: 1.4.4 + resolution: "@wagmi/core@npm:1.4.4" dependencies: "@wagmi/connectors": 3.1.2 abitype: 0.8.7 @@ -6670,7 +6670,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: a8a0622324076bc3087eb05eb1d74652511433bc68e1972e24f699018140508e56473760c5de0a56c92f621aed52ef7e683f3e0aca34aec5428464d28559c27f + checksum: ee4946a6ebdc9526024898e10d472b64eba673f7d9075f56aa564541a4a3c394c5e285a64f297f1a315471a8b7b7649e688ddd2cd82c9b38c77d036d2278b86f languageName: node linkType: hard @@ -21207,14 +21207,14 @@ typescript@next: languageName: node linkType: hard -"wagmi@npm:^1.4.3": - version: 1.4.3 - resolution: "wagmi@npm:1.4.3" +"wagmi@npm:^1.4.4": + version: 1.4.4 + resolution: "wagmi@npm:1.4.4" dependencies: "@tanstack/query-sync-storage-persister": ^4.27.1 "@tanstack/react-query": ^4.28.0 "@tanstack/react-query-persist-client": ^4.28.0 - "@wagmi/core": 1.4.3 + "@wagmi/core": 1.4.4 abitype: 0.8.7 use-sync-external-store: ^1.2.0 peerDependencies: @@ -21224,7 +21224,7 @@ typescript@next: peerDependenciesMeta: typescript: optional: true - checksum: df0dfddb898a59246c14e7053caeeea313801340d589e5545bad631ec47fc66e2cc1dafaaa7880c9947931efad442f834ed3d3c6b2d0952b586f6da575c3293f + checksum: 4cf7ce978400d21e27d25871a4c8bc6b05fabd61d6d3d6e705c120a1c1dd4ba260d6ddfb2bb28b5e18591ba825c89839d0f3c874c55dfa255a9cde96d0785202 languageName: node linkType: hard
+
+ +
+

{date ?? ''}

+

{time ?? ''}

+
+
+
+
+ +
+ + {abridgedHash} + +
+ {transaction.type} +

+

{shortDate ?? ''}

+
+
+
+
+
+ {transaction.type} + + {abridgedHash} + +
+
+
+
{`${bridgeAmount} ${transaction.assetSymbol}`}
+
{`${amountFiat} USD`}
+
+
+
+
{`${bridgeAmount} ${transaction.assetSymbol}`}
+
{`${amountFiat} USD`}
+
+
+
+ +
{cctpBridgePhaseText[status]}
+
+
-
- -
-

{date ?? ''}

-

{time ?? ''}

-
-
-
-
- -
- - {abridgedHash} - -
- {transaction.type} -

-

{dateMonthDayOnly ?? ''}

-
-
-
-
-
- {transaction.type} - - {abridgedHash} - -
-
-
-
{`${depositAmount} ${transaction.assetSymbol}`}
-
{`${amountFiat} USD`}
-
-
-
-
{`${depositAmount} ${transaction.assetSymbol}`}
-
{`${amountFiat} USD`}
-
-
-
-
-
-
-
{depositPhaseText[depositStatus]}
-
-
- {PHASE_TO_STATUS[depositStatus]} -
+
+ +
+

{date ?? ''}

+

{time ?? ''}

+
+
+
+
+ +
+ + {abridgedHash} + +
+ {transaction.type} +

+

{dateMonthDayOnly ?? ''}

+
+
+
+
+
+ {transaction.type} + + {abridgedHash} + +
+
+
+
{`${depositAmount} ${transaction.assetSymbol}`}
+
{`${amountFiat} USD`}
+
+
+
+
{`${depositAmount} ${transaction.assetSymbol}`}
+
{`${amountFiat} USD`}
+
+
+
+
+
+
+
{depositPhaseText[depositStatus]}
+
+
+ {depositStatus === 'DEPOSIT_TX_PENDING' ? ( + + ) : ( + PHASE_TO_STATUS[depositStatus] + )} +
+
+ +
+

{date ?? ''}

+

{time ?? ''}

+
+
+
+
+ +
+ + {abridgedHash} + +
+ {transaction.type} +

+

{dateMonthDayOnly ?? ''}

+
+
+
+
+
+ {transaction.type} + + {abridgedHash} + +
+
+
+
{`${withdrawalAmount} ${transaction.assetSymbol}`}
+
{`${amountFiat} USD`}
+
+
+
+
{`${withdrawalAmount} ${transaction.assetSymbol}`}
+
{`${amountFiat} USD`}
+
+
+
+ +
{withdrawalPhaseText[withdrawalStatus]}
+
+
+ {PHASE_TO_STATUS[withdrawalStatus]} +
-
- -
-

{date ?? ''}

-

{time ?? ''}

-
-
-
-
- -
- - {abridgedHash} - -
- {transaction.type} -

-

{dateMonthDayOnly ?? ''}

-
-
-
-
-
- {transaction.type} - - {abridgedHash} - -
-
-
-
{`${withdrawalAmount} ${transaction.assetSymbol}`}
-
{`${amountFiat} USD`}
-
-
-
-
{`${withdrawalAmount} ${transaction.assetSymbol}`}
-
{`${amountFiat} USD`}
-
-
-
-
- {generatePhaseIndicator(withdrawalStatus)} -
-
{withdrawalPhaseText[withdrawalStatus]}
-
-
- {PHASE_TO_STATUS[withdrawalStatus]} -