diff --git a/bridge-ui/public/images/logo/cctp.svg b/bridge-ui/public/images/logo/cctp.svg new file mode 100644 index 00000000..b22882e6 --- /dev/null +++ b/bridge-ui/public/images/logo/cctp.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bridge-ui/src/components/bridge/claiming/bridge-mode/bridge-mode.module.scss b/bridge-ui/src/components/bridge/claiming/bridge-mode/bridge-mode.module.scss new file mode 100644 index 00000000..ca04d144 --- /dev/null +++ b/bridge-ui/src/components/bridge/claiming/bridge-mode/bridge-mode.module.scss @@ -0,0 +1,30 @@ +.container { + position: relative; + font-family: var(--font-atyp-text); + + .button { + width: 100%; + padding: 0.25rem 0.5rem; + border-radius: 6.25rem; + background-color: var(--v2-color-white); + display: flex; + column-gap: 0.5rem; + justify-content: space-between; + align-items: center; + cursor: pointer; + font-size: 0.875rem; + } + + .selected-label { + display: flex; + align-items: center; + gap: 0.375rem; + line-height: 1; + + img { + width: 1rem; + height: 1rem; + border-radius: 50%; + } + } +} \ No newline at end of file diff --git a/bridge-ui/src/components/bridge/claiming/bridge-mode/index.tsx b/bridge-ui/src/components/bridge/claiming/bridge-mode/index.tsx new file mode 100644 index 00000000..b9e328c0 --- /dev/null +++ b/bridge-ui/src/components/bridge/claiming/bridge-mode/index.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import styles from "./bridge-mode.module.scss"; +import Image from "next/image"; +import { useFormStore } from "@/stores"; +import { BridgeProvider } from "@/types"; + +export default function BridgeMode() { + const token = useFormStore((state) => state.token); + const label = token.bridgeProvider === BridgeProvider.NATIVE ? "Native bridge" : "CCTP"; + const logoSrc = + token.bridgeProvider === BridgeProvider.NATIVE ? "/images/logo/linea-rounded.svg" : "/images/logo/cctp.svg"; + + return ( +
+ +
+ ); +} diff --git a/bridge-ui/src/components/bridge/claiming/index.tsx b/bridge-ui/src/components/bridge/claiming/index.tsx index ca208b9a..84d52c4f 100644 --- a/bridge-ui/src/components/bridge/claiming/index.tsx +++ b/bridge-ui/src/components/bridge/claiming/index.tsx @@ -7,6 +7,7 @@ import Skeleton from "@/components/bridge/claiming/skeleton"; import ReceivedAmount from "./received-amount"; import Fees from "./fees"; import { useFormStore, useChainStore } from "@/stores"; +import BridgeMode from "./bridge-mode"; export default function Claiming() { const fromChain = useChainStore.useFromChain(); @@ -17,6 +18,7 @@ export default function Claiming() { const amount = useFormStore((state) => state.amount); const balance = useFormStore((state) => state.balance); + const isTokenCanonicalUSDC = useFormStore((state) => state.isTokenCanonicalUSDC); const originChainBalanceTooLow = amount && balance < amount; @@ -37,9 +39,15 @@ export default function Claiming() {

Receive

- + + { + // There is no auto-claiming for USDC via CCTPV2 + !isTokenCanonicalUSDC() && ( + + ) + }
diff --git a/bridge-ui/src/components/bridge/modal/transaction-confirmed/index.tsx b/bridge-ui/src/components/bridge/modal/transaction-confirmed/index.tsx index 1cc4293c..694dff63 100644 --- a/bridge-ui/src/components/bridge/modal/transaction-confirmed/index.tsx +++ b/bridge-ui/src/components/bridge/modal/transaction-confirmed/index.tsx @@ -21,7 +21,7 @@ export default function TransactionConfirmed({ isModalOpen, transactionType, onC

{transactionType === "approve" ? "You have successfully approved the token. You can now bridge your token." - : "You may now bridge another transaction, check your transaction history, or stay ahead of the curve with thelatest trending tokens."} + : "You may now bridge another transaction, check your transaction history, or stay ahead of the curve with the latest trending tokens."}

void; diff --git a/bridge-ui/src/components/bridge/transaction-history/modal/transaction-details/index.tsx b/bridge-ui/src/components/bridge/transaction-history/modal/transaction-details/index.tsx index 15f30e4c..7af221d6 100644 --- a/bridge-ui/src/components/bridge/transaction-history/modal/transaction-details/index.tsx +++ b/bridge-ui/src/components/bridge/transaction-history/modal/transaction-details/index.tsx @@ -7,9 +7,9 @@ import Modal from "@/components/modal"; import styles from "./transaction-details.module.scss"; import Button from "@/components/ui/button"; import ArrowRightIcon from "@/assets/icons/arrow-right.svg"; -import { useClaim } from "@/hooks"; -import { TransactionStatus } from "@/types"; -import { formatBalance, formatHex, formatTimestamp, BridgeTransaction } from "@/utils"; +import { useClaim, useClaimingTx, useBridgeTransactionMessage } from "@/hooks"; +import { BridgeTransaction, TransactionStatus } from "@/types"; +import { formatBalance, formatHex, formatTimestamp } from "@/utils"; type Props = { transaction: BridgeTransaction | undefined; @@ -24,14 +24,23 @@ export default function TransactionDetails({ transaction, isModalOpen, onCloseMo const formattedDate = transaction?.timestamp ? formatTimestamp(Number(transaction.timestamp), "MMM, dd, yyyy") : ""; const formattedTime = transaction?.timestamp ? formatTimestamp(Number(transaction.timestamp), "ppp") : ""; - const queryClient = useQueryClient(); + // Hydrate BridgeTransaction.message with params required for claim tx + const { message, isLoading: isLoadingClaimTxParams } = useBridgeTransactionMessage(transaction); + if (transaction && message) transaction.message = message; + + // Hydrate BridgeTransaction.claimingTx + const claimingTx = useClaimingTx(transaction); + if (transaction && claimingTx && !transaction?.claimingTx) transaction.claimingTx = claimingTx; + const { claim, isConfirming, isPending, isConfirmed } = useClaim({ status: transaction?.status, + type: transaction?.type, fromChain: transaction?.fromChain, toChain: transaction?.toChain, args: transaction?.message, }); + const queryClient = useQueryClient(); useEffect(() => { if (isConfirmed) { queryClient.invalidateQueries({ queryKey: ["transactionHistory"], exact: false }); @@ -75,6 +84,10 @@ export default function TransactionDetails({ transaction, isModalOpen, onCloseMo }, [initialTransactionReceipt, claimingTransactionReceipt]); const buttonText = useMemo(() => { + if (isLoadingClaimTxParams) { + return "Loading Claim Data..."; + } + if (isPending || isConfirming) { return "Waiting for confirmation..."; } @@ -88,7 +101,15 @@ export default function TransactionDetails({ transaction, isModalOpen, onCloseMo } return "Claim"; - }, [isPending, isConfirming, isSwitchingChain, chain?.id, transaction?.toChain.id, transaction?.toChain.name]); + }, [ + isPending, + isConfirming, + isSwitchingChain, + isLoadingClaimTxParams, + chain?.id, + transaction?.toChain.id, + transaction?.toChain.name, + ]); const handleClaim = () => { if (transaction?.toChain.id && chain?.id && chain.id !== transaction?.toChain.id) { @@ -100,6 +121,7 @@ export default function TransactionDetails({ transaction, isModalOpen, onCloseMo claim(); } }; + return (
@@ -150,7 +172,11 @@ export default function TransactionDetails({ transaction, isModalOpen, onCloseMo )} {transaction?.status === TransactionStatus.READY_TO_CLAIM && ( - )} diff --git a/bridge-ui/src/config/config.schema.ts b/bridge-ui/src/config/config.schema.ts index 5053590f..e993b1fc 100644 --- a/bridge-ui/src/config/config.schema.ts +++ b/bridge-ui/src/config/config.schema.ts @@ -12,6 +12,12 @@ const chainConfigSchema = z.object({ gasLimitSurplus: z.bigint().positive(), profitMargin: z.bigint().positive(), cctpDomain: z.number().gte(0).int(), + cctpTokenMessengerV2Address: z.string().refine((val) => isAddress(val), { + message: "Invalid Ethereum address", + }), + cctpMessageTransmitterV2Address: z.string().refine((val) => isAddress(val), { + message: "Invalid Ethereum address", + }), }); export const configSchema = z @@ -22,7 +28,7 @@ export const configSchema = z minVersion: z.number().positive().int(), }), // Feature toggle for CCTPV2 for USDC transfers - isCCTPEnabled: z.boolean(), + isCctpEnabled: z.boolean(), }) .strict(); diff --git a/bridge-ui/src/config/config.ts b/bridge-ui/src/config/config.ts index 1590b4f6..6db74a26 100644 --- a/bridge-ui/src/config/config.ts +++ b/bridge-ui/src/config/config.ts @@ -14,9 +14,11 @@ export const config: Config = { ? BigInt(process.env.NEXT_PUBLIC_MAINNET_PROFIT_MARGIN) : BigInt(1), cctpDomain: 0, + cctpTokenMessengerV2Address: getAddress("0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d"), + cctpMessageTransmitterV2Address: getAddress("0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"), }, 59144: { - iconPath: "/images/logo/linea-mainnet.svg", + iconPath: "/images/logo/linea-rounded.svg", messageServiceAddress: getAddress(process.env.NEXT_PUBLIC_MAINNET_LINEA_MESSAGE_SERVICE ?? ""), tokenBridgeAddress: getAddress(process.env.NEXT_PUBLIC_MAINNET_LINEA_TOKEN_BRIDGE ?? ""), gasLimitSurplus: process.env.NEXT_PUBLIC_MAINNET_DEFAULT_GAS_LIMIT_SURPLUS @@ -26,6 +28,8 @@ export const config: Config = { ? BigInt(process.env.NEXT_PUBLIC_MAINNET_PROFIT_MARGIN) : BigInt(1), cctpDomain: 11, + cctpTokenMessengerV2Address: getAddress("0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d"), + cctpMessageTransmitterV2Address: getAddress("0x81D40F21F12A8F0E3252Bccb954D722d4c464B64"), }, 11155111: { iconPath: "/images/logo/ethereum-rounded.svg", @@ -38,6 +42,8 @@ export const config: Config = { ? BigInt(process.env.NEXT_PUBLIC_SEPOLIA_PROFIT_MARGIN) : BigInt(1), cctpDomain: 0, + cctpTokenMessengerV2Address: getAddress("0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA"), + cctpMessageTransmitterV2Address: getAddress("0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275"), }, 59141: { iconPath: "/images/logo/linea-sepolia.svg", @@ -50,6 +56,8 @@ export const config: Config = { ? BigInt(process.env.NEXT_PUBLIC_SEPOLIA_PROFIT_MARGIN) : BigInt(1), cctpDomain: 11, + cctpTokenMessengerV2Address: getAddress("0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA"), + cctpMessageTransmitterV2Address: getAddress("0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275"), }, }, walletConnectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_ID ?? "", @@ -57,7 +65,7 @@ export const config: Config = { // The storage will be cleared if its version is smaller than the one configured minVersion: process.env.NEXT_PUBLIC_STORAGE_MIN_VERSION ? parseInt(process.env.NEXT_PUBLIC_STORAGE_MIN_VERSION) : 1, }, - isCCTPEnabled: process.env.NEXT_PUBLIC_IS_CCTP_ENABLED === "true", + isCctpEnabled: process.env.NEXT_PUBLIC_IS_CCTP_ENABLED === "true", }; export async function getConfiguration(): Promise { diff --git a/bridge-ui/src/contexts/web3.context.tsx b/bridge-ui/src/contexts/web3.context.tsx index 7f71a052..ce699b2a 100644 --- a/bridge-ui/src/contexts/web3.context.tsx +++ b/bridge-ui/src/contexts/web3.context.tsx @@ -67,6 +67,7 @@ export function Web3Provider({ children }: Web3ProviderProps) { settings={{ environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID!, walletConnectors: [EthereumWalletConnectors], + mobileExperience: "redirect", appName: "Linea Bridge", cssOverrides, }} diff --git a/bridge-ui/src/hooks/index.ts b/bridge-ui/src/hooks/index.ts index b611d353..3dc2ec79 100644 --- a/bridge-ui/src/hooks/index.ts +++ b/bridge-ui/src/hooks/index.ts @@ -2,8 +2,10 @@ export * from "./fees"; export * from "./transaction-args"; export { default as useAllowance } from "./useAllowance"; export { default as useBridge } from "./useBridge"; +export { default as useBridgeTransactionMessage } from "./useBridgeTransactionMessage"; export { default as useChains } from "./useChains"; export { default as useClaim } from "./useClaim"; +export { default as useClaimingTx } from "./useClaimingTx"; export { default as useDebounce } from "./useDebounce"; export { default as useDevice } from "./useDevice"; export { default as useInitialiseChain } from "./useInitialiseChain"; diff --git a/bridge-ui/src/hooks/transaction-args/cctp/useCctpDestinationDomain.ts b/bridge-ui/src/hooks/transaction-args/cctp/useCctpDestinationDomain.ts deleted file mode 100644 index bd9a2645..00000000 --- a/bridge-ui/src/hooks/transaction-args/cctp/useCctpDestinationDomain.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useChainStore } from "@/stores"; - -const useCctpDestinationDomain = () => { - const toChain = useChainStore.useToChain(); - return toChain.cctpDomain; -}; - -export default useCctpDestinationDomain; diff --git a/bridge-ui/src/hooks/transaction-args/cctp/useCctpUtilHooks.ts b/bridge-ui/src/hooks/transaction-args/cctp/useCctpUtilHooks.ts new file mode 100644 index 00000000..8c268cdf --- /dev/null +++ b/bridge-ui/src/hooks/transaction-args/cctp/useCctpUtilHooks.ts @@ -0,0 +1,28 @@ +// Break pattern of 1 hook-1 file because TypeScript and CI were going nuts on filenames such as useCctpDestinationDomain.ts + +import { useChainStore } from "@/stores"; +import { getCctpFee } from "@/services/cctp"; +import { useQuery } from "@tanstack/react-query"; +import { CCTP_TRANSFER_MAX_FEE_FALLBACK } from "@/utils/cctp"; + +const useCctpSrcDomain = () => { + const fromChain = useChainStore.useFromChain(); + return fromChain.cctpDomain; +}; + +export const useCctpDestinationDomain = () => { + const toChain = useChainStore.useToChain(); + return toChain.cctpDomain; +}; + +export const useCctpFee = (): bigint => { + const fromChain = useChainStore.useFromChain(); + const srcDomain = useCctpSrcDomain(); + const dstDomain = useCctpDestinationDomain(); + const { data } = useQuery({ + queryKey: ["useCctpFee", srcDomain, dstDomain], + queryFn: async () => getCctpFee(srcDomain, dstDomain, fromChain.testnet), + }); + if (!data) return CCTP_TRANSFER_MAX_FEE_FALLBACK; + return BigInt(data.minimumFee); +}; diff --git a/bridge-ui/src/hooks/transaction-args/cctp/useDepositForBurnTxArgs.ts b/bridge-ui/src/hooks/transaction-args/cctp/useDepositForBurnTxArgs.ts index b3e6d5a3..02f2f461 100644 --- a/bridge-ui/src/hooks/transaction-args/cctp/useDepositForBurnTxArgs.ts +++ b/bridge-ui/src/hooks/transaction-args/cctp/useDepositForBurnTxArgs.ts @@ -3,8 +3,8 @@ import { useAccount } from "wagmi"; import { encodeFunctionData, padHex, zeroHash } from "viem"; import { useFormStore, useChainStore } from "@/stores"; import { isCctp } from "@/utils/tokens"; -import useCctpDestinationDomain from "./useCctpDestinationDomain"; -import { CCTP_MIN_FINALITY_THRESHOLD, CCTP_TOKEN_MESSENGER, CCTP_TRANSFER_MAX_FEE } from "@/utils/cctp"; +import { useCctpFee, useCctpDestinationDomain } from "./useCctpUtilHooks"; +import { CCTP_MIN_FINALITY_THRESHOLD } from "@/utils"; type UseDepositForBurnTxArgs = { allowance?: bigint; @@ -17,6 +17,7 @@ const useDepositForBurnTxArgs = ({ allowance }: UseDepositForBurnTxArgs) => { const token = useFormStore((state) => state.token); const amount = useFormStore((state) => state.amount); const recipient = useFormStore((state) => state.recipient); + const fee = useCctpFee(); return useMemo(() => { if ( @@ -35,7 +36,7 @@ const useDepositForBurnTxArgs = ({ allowance }: UseDepositForBurnTxArgs) => { return { type: "depositForBurn", args: { - to: CCTP_TOKEN_MESSENGER, + to: fromChain.cctpTokenMessengerV2Address, data: encodeFunctionData({ abi: [ { @@ -61,7 +62,7 @@ const useDepositForBurnTxArgs = ({ allowance }: UseDepositForBurnTxArgs) => { padHex(recipient), token[fromChain.layer], zeroHash, - CCTP_TRANSFER_MAX_FEE, + fee, CCTP_MIN_FINALITY_THRESHOLD, ], }), @@ -69,7 +70,7 @@ const useDepositForBurnTxArgs = ({ allowance }: UseDepositForBurnTxArgs) => { chainId: fromChain.id, }, }; - }, [address, allowance, amount, cctpDestinationDomain, fromChain, recipient, token]); + }, [address, allowance, amount, fee, cctpDestinationDomain, fromChain, recipient, token]); }; export default useDepositForBurnTxArgs; diff --git a/bridge-ui/src/hooks/transaction-args/useApproveTxArgs.ts b/bridge-ui/src/hooks/transaction-args/useApproveTxArgs.ts index ae3ee48d..d2ff6bdb 100644 --- a/bridge-ui/src/hooks/transaction-args/useApproveTxArgs.ts +++ b/bridge-ui/src/hooks/transaction-args/useApproveTxArgs.ts @@ -4,7 +4,6 @@ import { encodeFunctionData, erc20Abi } from "viem"; import { useFormStore, useChainStore } from "@/stores"; import { isEth } from "@/utils"; import { isCctp } from "@/utils/tokens"; -import { CCTP_TOKEN_MESSENGER } from "@/utils/cctp"; type UseERC20ApproveTxArgsProps = { allowance?: bigint; @@ -21,7 +20,7 @@ const useApproveTxArgs = ({ allowance }: UseERC20ApproveTxArgsProps) => { return; } - const spender = !isCctp(token) ? fromChain.tokenBridgeAddress : CCTP_TOKEN_MESSENGER; + const spender = !isCctp(token) ? fromChain.tokenBridgeAddress : fromChain.cctpTokenMessengerV2Address; return { type: "approve", diff --git a/bridge-ui/src/hooks/transaction-args/useClaimTransactionTxArgs.ts b/bridge-ui/src/hooks/transaction-args/useClaimTransactionTxArgs.ts index 477001da..a0a3f5ab 100644 --- a/bridge-ui/src/hooks/transaction-args/useClaimTransactionTxArgs.ts +++ b/bridge-ui/src/hooks/transaction-args/useClaimTransactionTxArgs.ts @@ -2,77 +2,109 @@ import { useMemo } from "react"; import { useAccount } from "wagmi"; import { encodeFunctionData, zeroAddress } from "viem"; import MessageService from "@/abis/MessageService.json"; -import { Chain, ChainLayer, TransactionStatus } from "@/types"; -import { CCTPV2BridgeMessage, NativeBridgeMessage } from "@/utils/history"; -import { isNativeBridgeMessage } from "@/utils/message"; +import MessageTransmitterV2 from "@/abis/MessageTransmitterV2.json"; +import { + BridgeTransactionType, + CctpV2BridgeMessage, + Chain, + ChainLayer, + NativeBridgeMessage, + TransactionStatus, +} from "@/types"; +import { isCctpV2BridgeMessage, isNativeBridgeMessage } from "@/utils/message"; type UseClaimTxArgsProps = { status?: TransactionStatus; + type?: BridgeTransactionType; fromChain?: Chain; toChain?: Chain; - args?: NativeBridgeMessage | CCTPV2BridgeMessage; + args?: NativeBridgeMessage | CctpV2BridgeMessage; }; -const useClaimTxArgs = ({ status, fromChain, toChain, args }: UseClaimTxArgsProps) => { +const useClaimTxArgs = ({ status, type, fromChain, toChain, args }: UseClaimTxArgsProps) => { const { address } = useAccount(); return useMemo(() => { - if ( - !address || - !status || - status !== TransactionStatus.READY_TO_CLAIM || - !fromChain || - !toChain || - !args || - !isNativeBridgeMessage(args) || - !args.from || - !args.to || - args.fee === undefined || - args.value === undefined || - !args.nonce || - !args.calldata || - !args.messageHash || - (!args.proof && toChain.layer === ChainLayer.L1) - ) { - return; - } + // Missing props + if (!address || !status || !type || !fromChain || !toChain || !args) return; - const encodedData = - toChain.layer === ChainLayer.L1 - ? encodeFunctionData({ - abi: MessageService.abi, - functionName: "claimMessageWithProof", - args: [ - { - data: args.calldata, - fee: args.fee, - feeRecipient: zeroAddress, - from: args.from, - to: args.to, - leafIndex: args.proof?.leafIndex, - merkleRoot: args.proof?.root, - messageNumber: args.nonce, - proof: args.proof?.proof, - value: args.value, - }, - ], - }) - : encodeFunctionData({ - abi: MessageService.abi, - functionName: "claimMessage", - args: [args.from, args.to, args.fee, args.value, zeroAddress, args.calldata, args.nonce], - }); + // Must be 'READY_TO_CLAIM' status + if (status !== TransactionStatus.READY_TO_CLAIM) return; + + let toAddress: `0x${string}`; + let encodedData: `0x${string}`; + + switch (type) { + case BridgeTransactionType.ERC20: + case BridgeTransactionType.ETH: + if ( + !isNativeBridgeMessage(args) || + !args.from || + !args.to || + args.fee === undefined || + args.value === undefined || + !args.nonce || + !args.calldata || + !args.messageHash || + (!args.proof && toChain.layer === ChainLayer.L1) + ) { + return; + } + + toAddress = toChain.messageServiceAddress; + + encodedData = + toChain.layer === ChainLayer.L1 + ? encodeFunctionData({ + abi: MessageService.abi, + functionName: "claimMessageWithProof", + args: [ + { + data: args.calldata, + fee: args.fee, + feeRecipient: zeroAddress, + from: args.from, + to: args.to, + leafIndex: args.proof?.leafIndex, + merkleRoot: args.proof?.root, + messageNumber: args.nonce, + proof: args.proof?.proof, + value: args.value, + }, + ], + }) + : encodeFunctionData({ + abi: MessageService.abi, + functionName: "claimMessage", + args: [args.from, args.to, args.fee, args.value, zeroAddress, args.calldata, args.nonce], + }); + + break; + case BridgeTransactionType.USDC: + if (!isCctpV2BridgeMessage(args) || !args.attestation || !args.message) { + return; + } + toAddress = toChain.cctpMessageTransmitterV2Address; + encodedData = encodeFunctionData({ + abi: MessageTransmitterV2.abi, + functionName: "receiveMessage", + args: [args.message, args.attestation], + }); + break; + default: + return; + } return { type: "claim", args: { - to: toChain.messageServiceAddress, + to: toAddress, data: encodedData, value: 0n, chainId: toChain.id, }, }; - }, [address, args, fromChain, status, toChain]); + }, [address, status, type, fromChain, toChain, args]); }; export default useClaimTxArgs; diff --git a/bridge-ui/src/hooks/useAllowance.ts b/bridge-ui/src/hooks/useAllowance.ts index 8b207e27..83bb9286 100644 --- a/bridge-ui/src/hooks/useAllowance.ts +++ b/bridge-ui/src/hooks/useAllowance.ts @@ -3,13 +3,12 @@ import { erc20Abi } from "viem"; import { useFormStore, useChainStore } from "@/stores"; import { isEth } from "@/utils"; import { isCctp } from "@/utils/tokens"; -import { CCTP_TOKEN_MESSENGER } from "@/utils/cctp"; const useAllowance = () => { const { address } = useAccount(); const token = useFormStore((state) => state.token); const fromChain = useChainStore.useFromChain(); - const spender = !isCctp(token) ? fromChain.tokenBridgeAddress : CCTP_TOKEN_MESSENGER; + const spender = !isCctp(token) ? fromChain.tokenBridgeAddress : fromChain.cctpTokenMessengerV2Address; const { data: allowance, diff --git a/bridge-ui/src/hooks/useBridgeTransactionMessage.ts b/bridge-ui/src/hooks/useBridgeTransactionMessage.ts new file mode 100644 index 00000000..cd623d52 --- /dev/null +++ b/bridge-ui/src/hooks/useBridgeTransactionMessage.ts @@ -0,0 +1,61 @@ +import { + BridgeTransaction, + BridgeTransactionType, + CctpV2BridgeMessage, + ChainLayer, + NativeBridgeMessage, + TransactionStatus, +} from "@/types"; +import { isNativeBridgeMessage } from "@/utils/message"; +import { useQuery } from "@tanstack/react-query"; +import useLineaSDK from "./useLineaSDK"; + +const useBridgeTransactionMessage = ( + transaction: BridgeTransaction | undefined, +): { message: CctpV2BridgeMessage | NativeBridgeMessage | undefined; isLoading: boolean } => { + const { lineaSDK } = useLineaSDK(); + + // queryFn for useQuery cannot return undefined - https://tanstack.com/query/latest/docs/framework/react/reference/useQuery + async function getBridgeTransactionMessage( + transaction: BridgeTransaction | undefined, + ): Promise { + const { status, type, fromChain, toChain, message } = transaction as BridgeTransaction; + if (!status || !type || !fromChain || !toChain || !message) return message; + // Cannot claim, so don't waste time getting claim parameters + if (status !== TransactionStatus.READY_TO_CLAIM) return message; + + switch (type) { + case BridgeTransactionType.ETH: { + if (toChain.layer === ChainLayer.L2) return message; + if (!isNativeBridgeMessage(message) || !message?.messageHash) return message; + const { messageHash } = message; + message.proof = await lineaSDK.getL1ClaimingService().getMessageProof(messageHash); + return message; + } + case BridgeTransactionType.ERC20: { + if (toChain.layer === ChainLayer.L2) return message; + if (!isNativeBridgeMessage(message) || !message?.messageHash) return message; + const { messageHash } = message; + message.proof = await lineaSDK.getL1ClaimingService().getMessageProof(messageHash); + return message; + } + case BridgeTransactionType.USDC: { + // If message is READY_TO_CLAIM, then we will have already gotten the required params in TransactionList component. + return message; + } + default: { + return message; + } + } + } + + const { data, isLoading } = useQuery({ + // TODO - Do we need to account for undefined props here? Otherwise caching behaviour is not as expected? + queryKey: ["useBridgeTransactionMessage", transaction?.bridgingTx, transaction?.toChain?.id], + queryFn: async () => getBridgeTransactionMessage(transaction), + }); + + return { message: data, isLoading }; +}; + +export default useBridgeTransactionMessage; diff --git a/bridge-ui/src/hooks/useClaim.ts b/bridge-ui/src/hooks/useClaim.ts index 2bc80a44..37b1ba8c 100644 --- a/bridge-ui/src/hooks/useClaim.ts +++ b/bridge-ui/src/hooks/useClaim.ts @@ -1,18 +1,17 @@ import { useSendTransaction, useWaitForTransactionReceipt } from "wagmi"; import useClaimTxArgs from "./transaction-args/useClaimTransactionTxArgs"; -import { Chain, TransactionStatus } from "@/types"; -import { CCTPV2BridgeMessage, NativeBridgeMessage } from "@/utils/history"; +import { BridgeTransactionType, CctpV2BridgeMessage, Chain, NativeBridgeMessage, TransactionStatus } from "@/types"; type UseClaimProps = { status?: TransactionStatus; + type?: BridgeTransactionType; fromChain?: Chain; toChain?: Chain; - args?: NativeBridgeMessage | CCTPV2BridgeMessage; + args?: NativeBridgeMessage | CctpV2BridgeMessage; }; const useClaim = (props: UseClaimProps) => { const transactionArgs = useClaimTxArgs(props); - const { data: hash, sendTransaction, isPending, error, isSuccess } = useSendTransaction(); const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ diff --git a/bridge-ui/src/hooks/useClaimingTx.ts b/bridge-ui/src/hooks/useClaimingTx.ts new file mode 100644 index 00000000..49b964e8 --- /dev/null +++ b/bridge-ui/src/hooks/useClaimingTx.ts @@ -0,0 +1,69 @@ +import { BridgeTransaction, BridgeTransactionType, TransactionStatus, CctpMessageReceivedAbiEvent } from "@/types"; +import { getPublicClient } from "@wagmi/core"; +import { config as wagmiConfig } from "@/lib/wagmi"; +import { isNativeBridgeMessage, isCctpV2BridgeMessage } from "@/utils/message"; +import { useQuery } from "@tanstack/react-query"; +import { getNativeBridgeMessageClaimedTxHash } from "@/utils"; + +const useClaimingTx = (transaction: BridgeTransaction | undefined): string | undefined => { + // queryFn for useQuery cannot return undefined - https://tanstack.com/query/latest/docs/framework/react/reference/useQuery + const { data } = useQuery({ + // TODO - Do we need to account for undefined props here? Otherwise caching behaviour is not as expected? + queryKey: ["useClaimingTx", transaction?.bridgingTx, transaction?.toChain?.id], + queryFn: async () => getClaimTx(transaction), + }); + + if (!data || data === "") return; + return data; +}; + +export default useClaimingTx; + +async function getClaimTx(transaction: BridgeTransaction | undefined): Promise { + if (!transaction) return ""; + if (transaction?.claimingTx) return ""; + const { status, type, toChain, message } = transaction; + if (!status || !type || !toChain || !message) return ""; + // Not completed -> no existing claim tx + if (status !== TransactionStatus.COMPLETED) return ""; + + const toChainClient = getPublicClient(wagmiConfig, { + chainId: toChain.id, + }); + + switch (type) { + case BridgeTransactionType.ETH: { + if (!isNativeBridgeMessage(message)) return ""; + return await getNativeBridgeMessageClaimedTxHash( + toChainClient, + toChain.messageServiceAddress, + message?.messageHash as `0x${string}`, + ); + } + case BridgeTransactionType.ERC20: { + if (!isNativeBridgeMessage(message)) return ""; + return await getNativeBridgeMessageClaimedTxHash( + toChainClient, + toChain.messageServiceAddress, + message?.messageHash as `0x${string}`, + ); + } + case BridgeTransactionType.USDC: { + if (!isCctpV2BridgeMessage(message) || !message.nonce) return ""; + const messageReceivedEvents = await toChainClient.getLogs({ + event: CctpMessageReceivedAbiEvent, + fromBlock: "earliest", + toBlock: "latest", + address: toChain.cctpMessageTransmitterV2Address, + args: { + nonce: message?.nonce, + }, + }); + if (messageReceivedEvents.length === 0) return ""; + return messageReceivedEvents[0].transactionHash; + } + default: { + return ""; + } + } +} diff --git a/bridge-ui/src/hooks/useTokens.ts b/bridge-ui/src/hooks/useTokens.ts index d14bb02d..523f03aa 100644 --- a/bridge-ui/src/hooks/useTokens.ts +++ b/bridge-ui/src/hooks/useTokens.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useChainStore, useTokenStore } from "@/stores"; import { ChainLayer, Token } from "@/types"; import { config } from "@/config"; +import { USDC_SYMBOL } from "@/utils"; const useTokens = (): Token[] => { const tokensList = useTokenStore((state) => state.tokensList); @@ -13,19 +14,23 @@ const useTokens = (): Token[] => { if (fromChain.testnet) { if (fromChain.layer !== ChainLayer.L2) { return tokensList.SEPOLIA.filter( - (token) => !token.type.includes("native") && (config.isCCTPEnabled || token.symbol !== "USDC"), + (token) => !token.type.includes("native") && (config.isCctpEnabled || token.symbol !== USDC_SYMBOL), ); } - return config.isCCTPEnabled ? tokensList.SEPOLIA : tokensList.SEPOLIA.filter((token) => token.symbol !== "USDC"); + return config.isCctpEnabled + ? tokensList.SEPOLIA + : tokensList.SEPOLIA.filter((token) => token.symbol !== USDC_SYMBOL); } if (fromChain.layer !== ChainLayer.L2) { return tokensList.MAINNET.filter( - (token) => !token.type.includes("native") && (config.isCCTPEnabled || token.symbol !== "USDC"), + (token) => !token.type.includes("native") && (config.isCctpEnabled || token.symbol !== USDC_SYMBOL), ); } - return config.isCCTPEnabled ? tokensList.MAINNET : tokensList.MAINNET.filter((token) => token.symbol !== "USDC"); + return config.isCctpEnabled + ? tokensList.MAINNET + : tokensList.MAINNET.filter((token) => token.symbol !== USDC_SYMBOL); }, [fromChain, tokensList.MAINNET, tokensList.SEPOLIA]); }; diff --git a/bridge-ui/src/hooks/useTransactionHistory.ts b/bridge-ui/src/hooks/useTransactionHistory.ts index 17e0b6fe..c9dbb292 100644 --- a/bridge-ui/src/hooks/useTransactionHistory.ts +++ b/bridge-ui/src/hooks/useTransactionHistory.ts @@ -1,22 +1,38 @@ import { useAccount } from "wagmi"; import { useQuery } from "@tanstack/react-query"; -import { useChainStore } from "@/stores"; -import { fetchTransactionsHistory } from "@/utils"; +import { HistoryActionsForCompleteTxCaching, useChainStore } from "@/stores"; import useLineaSDK from "./useLineaSDK"; import useTokens from "./useTokens"; +import { useHistoryStore } from "@/stores"; +import { fetchTransactionsHistory } from "@/utils"; const useTransactionHistory = () => { const { address } = useAccount(); const fromChain = useChainStore.useFromChain(); const toChain = useChainStore.useToChain(); - const { lineaSDK, lineaSDKContracts } = useLineaSDK(); + const { lineaSDK } = useLineaSDK(); const tokens = useTokens(); + const { setCompleteTx, getCompleteTx } = useHistoryStore((state) => ({ + setCompleteTx: state.setCompleteTx, + getCompleteTx: state.getCompleteTx, + })); + const historyStoreActions: HistoryActionsForCompleteTxCaching = { + setCompleteTx, + getCompleteTx, + }; const { data, isLoading, refetch } = useQuery({ enabled: !!address && !!fromChain && !!toChain, queryKey: ["transactionHistory", address, fromChain.id, toChain.id], queryFn: () => - fetchTransactionsHistory({ lineaSDK, lineaSDKContracts, fromChain, toChain, address: address!, tokens }), + fetchTransactionsHistory({ + lineaSDK, + fromChain, + toChain, + address: address!, + tokens, + historyStoreActions, + }), staleTime: 1000 * 60 * 2, }); diff --git a/bridge-ui/src/services/cctp.ts b/bridge-ui/src/services/cctp.ts index 86765e10..5499f3ca 100644 --- a/bridge-ui/src/services/cctp.ts +++ b/bridge-ui/src/services/cctp.ts @@ -1,23 +1,83 @@ -import { CctpAttestationApiResponse } from "@/types/cctp"; +import { CctpAttestationApiResponse, CctpV2ReattestationApiResponse, CctpFeeApiResponse } from "@/types/cctp"; -export async function fetchCctpAttestation( - transactionHash: string, +export async function fetchCctpAttestationByTxHash( cctpDomain: number, + transactionHash: string, + isTestnet: boolean, ): Promise { const response = await fetch( - `https://iris-api-sandbox.circle.com/v2/messages/${cctpDomain}?transactionHash=${transactionHash}`, + `https://iris-api${isTestnet ? "-sandbox" : ""}.circle.com/v2/messages/${cctpDomain}?transactionHash=${transactionHash}`, { headers: { "Content-Type": "application/json", }, }, ); - if (!response.ok) { - throw new Error(`Error in fetchCctpAttestation: transactionHash=${transactionHash} cctpDomain=${cctpDomain}`); + throw new Error( + `Error in fetchCctpAttestationByTxHash: isTestnet=${isTestnet} cctpDomain=${cctpDomain} transactionHash=${transactionHash}`, + ); } - const data: CctpAttestationApiResponse = await response.json(); - + return data; +} + +export async function fetchCctpAttestationByNonce( + cctpDomain: number, + nonce: string, + isTestnet: boolean, +): Promise { + const response = await fetch( + `https://iris-api${isTestnet ? "-sandbox" : ""}.circle.com/v2/messages/${cctpDomain}?nonce=${nonce}`, + { + headers: { + "Content-Type": "application/json", + }, + }, + ); + if (!response.ok) { + throw new Error( + `Error in fetchCctpAttestationByNonce: isTestnet=${isTestnet} cctpDomain=${cctpDomain} nonce=${nonce}`, + ); + } + const data: CctpAttestationApiResponse = await response.json(); + return data; +} + +// https://developers.circle.com/api-reference/stablecoins/common/reattest-message +export async function reattestCctpV2PreFinalityMessage( + nonce: string, + isTestnet: boolean, +): Promise { + const response = await fetch(`https://iris-api${isTestnet ? "-sandbox" : ""}.circle.com/v2/reattest/${nonce}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + throw new Error(`Error in reattestCctpV2PreFinalityMessage: isTestnet=${isTestnet} nonce=${nonce}`); + } + const data: CctpV2ReattestationApiResponse = await response.json(); + return data; +} + +export async function getCctpFee( + srcDomain: number, + dstDomain: number, + isTestnet: boolean, +): Promise { + const response = await fetch( + `https://iris-api${isTestnet ? "-sandbox" : ""}.circle.com/v2/fastBurn/USDC/fees/${srcDomain}/${dstDomain}`, + { + headers: { + "Content-Type": "application/json", + }, + }, + ); + if (!response.ok) { + throw new Error(`Error in getCctpFee: isTestnet=${isTestnet} srcDomain=${srcDomain} dstDomain=${dstDomain}`); + } + const data: CctpFeeApiResponse = await response.json(); return data; } diff --git a/bridge-ui/src/services/tokenService.ts b/bridge-ui/src/services/tokenService.ts index 92c64b97..2f2266e2 100644 --- a/bridge-ui/src/services/tokenService.ts +++ b/bridge-ui/src/services/tokenService.ts @@ -88,20 +88,20 @@ export async function getTokenConfig(): Promise { const updatedTokensConfig = { ...defaultTokensConfig }; // Feature toggle, remove when feature toggle no longer needed - const filterOutUSDCWhenCCTPNotEnabled = (token: Token) => config.isCCTPEnabled || token.symbol !== "USDC"; + const filterOutUSDCWhenCctpNotEnabled = (token: Token) => config.isCctpEnabled || token.symbol !== "USDC"; updatedTokensConfig.MAINNET = [ ...defaultTokensConfig.MAINNET, ...(await Promise.all(mainnetTokens.map(async (token: GithubTokenListToken): Promise => formatToken(token)))) // Feature toggle, remove .filter expression when feature toggle no longer needed - .filter(filterOutUSDCWhenCCTPNotEnabled), + .filter(filterOutUSDCWhenCctpNotEnabled), ]; updatedTokensConfig.SEPOLIA = [ ...defaultTokensConfig.SEPOLIA, ...(await Promise.all(sepoliaTokens.map((token: GithubTokenListToken): Promise => formatToken(token)))) // Feature toggle, remove .filter expression when feature toggle no longer needed - .filter(filterOutUSDCWhenCCTPNotEnabled), + .filter(filterOutUSDCWhenCctpNotEnabled), ]; return updatedTokensConfig; diff --git a/bridge-ui/src/stores/formStore.ts b/bridge-ui/src/stores/formStore.ts index ff53a4dc..8fd405f9 100644 --- a/bridge-ui/src/stores/formStore.ts +++ b/bridge-ui/src/stores/formStore.ts @@ -3,6 +3,7 @@ import { defaultTokensConfig } from "./tokenStore"; import { createWithEqualityFn } from "zustand/traditional"; import { shallow } from "zustand/vanilla/shallow"; import { Token } from "@/types"; +import { isCctp } from "@/utils"; export type FormState = { token: Token; @@ -25,6 +26,8 @@ export type FormActions = { setBridgingFees: (bridgingFees: bigint) => void; setMinimumFees: (minimumFees: bigint) => void; resetForm(): void; + // Custom getter function + isTokenCanonicalUSDC: () => boolean; }; export type FormStore = FormState & FormActions; @@ -41,11 +44,15 @@ export const defaultInitState: FormState = { }; export const createFormStore = (defaultValues?: FormState) => - createWithEqualityFn((set) => { + createWithEqualityFn((set, get) => { return { ...defaultInitState, ...defaultValues, - setToken: (token) => set({ token }), + setToken: (token) => { + set({ token }); + // No auto-claim for CCTP + isCctp(token) ? set({ claim: "manual" }) : set({ claim: "auto" }); + }, setRecipient: (recipient) => set({ recipient }), setAmount: (amount) => set({ amount }), setBalance: (balance) => set({ balance }), @@ -54,5 +61,7 @@ export const createFormStore = (defaultValues?: FormState) => setBridgingFees: (bridgingFees) => set({ bridgingFees }), setMinimumFees: (minimumFees) => set({ minimumFees }), resetForm: () => set(defaultInitState), + // Custom getter function + isTokenCanonicalUSDC: () => isCctp(get().token), }; }, shallow); diff --git a/bridge-ui/src/stores/historyStore.ts b/bridge-ui/src/stores/historyStore.ts index 7616caf7..c58b849e 100644 --- a/bridge-ui/src/stores/historyStore.ts +++ b/bridge-ui/src/stores/historyStore.ts @@ -2,33 +2,40 @@ import { createWithEqualityFn } from "zustand/traditional"; import { shallow } from "zustand/vanilla/shallow"; import { createJSONStorage, persist } from "zustand/middleware"; import { config } from "@/config"; -import { isEmptyObject, BridgeTransaction } from "@/utils"; +import { BridgeTransaction, TransactionStatus } from "@/types"; +import { getCompleteTxStoreKeyForTx } from "@/utils/history"; export type HistoryState = { isLoading: boolean; - history: Record< - string, - { transactions: BridgeTransaction[]; lastL1FetchedBlockNumber: bigint; lastL2FetchedBlockNumber: bigint } - >; + // history: Record< + // string, + // { transactions: BridgeTransaction[]; lastL1FetchedBlockNumber: bigint; lastL2FetchedBlockNumber: bigint } + // >; + completeTxHistory: Record; }; -export type HistoryActions = { +type HistoryActions = { setIsLoading: (isLoading: boolean) => void; - setTransactions: ( - key: string, - transactions: BridgeTransaction[], - lastL1FetchedBlockNumber?: bigint, - lastL2FetchedBlockNumber?: bigint, - ) => void; - getTransactionsByKey: (key: string) => BridgeTransaction[]; - getFromBlockNumbers: (key: string) => { l1FromBlock: bigint; l2FromBlock: bigint }; + // setTransactions: ( + // key: string, + // transactions: BridgeTransaction[], + // lastL1FetchedBlockNumber?: bigint, + // lastL2FetchedBlockNumber?: bigint, + // ) => void; + setCompleteTx: (transaction: BridgeTransaction) => void; + // getTransactionsByKey: (key: string) => BridgeTransaction[]; + // getFromBlockNumbers: (key: string) => { l1FromBlock: bigint; l2FromBlock: bigint }; + getCompleteTx: (key: string) => BridgeTransaction | undefined; }; +export type HistoryActionsForCompleteTxCaching = Pick; + export type HistoryStore = HistoryState & HistoryActions; export const defaultInitState: HistoryState = { - history: {}, isLoading: false, + // history: {}, + completeTxHistory: {}, }; export const useHistoryStore = createWithEqualityFn()( @@ -36,36 +43,51 @@ export const useHistoryStore = createWithEqualityFn()( (set, get) => ({ ...defaultInitState, setIsLoading: (isLoading) => set({ isLoading }), - setTransactions: (key, transactions, lastL1FetchedBlockNumber, lastL2FetchedBlockNumber) => - set((state) => ({ - history: { - ...state.history, - [key]: { - transactions, - lastL1FetchedBlockNumber: lastL1FetchedBlockNumber || 0n, - lastL2FetchedBlockNumber: lastL2FetchedBlockNumber || 0n, - }, - }, - })), - getTransactionsByKey: (key) => { - const { history } = get(); - return history[key]?.transactions ?? []; - }, - getFromBlockNumbers: (key) => { - const { history } = get(); - - if (isEmptyObject(history) || !history[key]) { + // setTransactions: (key, transactions, lastL1FetchedBlockNumber, lastL2FetchedBlockNumber) => + // set((state) => ({ + // history: { + // ...state.history, + // [key]: { + // transactions, + // lastL1FetchedBlockNumber: lastL1FetchedBlockNumber || 0n, + // lastL2FetchedBlockNumber: lastL2FetchedBlockNumber || 0n, + // }, + // }, + // })), + setCompleteTx: (transaction) => + set((state) => { + if (transaction.status !== TransactionStatus.COMPLETED) return state; + const key = getCompleteTxStoreKeyForTx(transaction); return { - l1FromBlock: 0n, - l2FromBlock: 0n, + completeTxHistory: { + ...state.completeTxHistory, + [key]: transaction, + }, }; - } - - return { - l1FromBlock: history[key].lastL1FetchedBlockNumber, - l2FromBlock: history[key].lastL2FetchedBlockNumber, - }; + }), + getCompleteTx: (key) => { + const { completeTxHistory } = get(); + return completeTxHistory[key]; }, + // getTransactionsByKey: (key) => { + // const { history } = get(); + // return history[key]?.transactions ?? []; + // }, + // getFromBlockNumbers: (key) => { + // const { history } = get(); + + // if (isEmptyObject(history) || !history[key]) { + // return { + // l1FromBlock: 0n, + // l2FromBlock: 0n, + // }; + // } + + // return { + // l1FromBlock: history[key].lastL1FetchedBlockNumber, + // l2FromBlock: history[key].lastL2FetchedBlockNumber, + // }; + // }, }), { name: "history-storage", diff --git a/bridge-ui/src/stores/index.ts b/bridge-ui/src/stores/index.ts index 668aa188..1fae6dde 100644 --- a/bridge-ui/src/stores/index.ts +++ b/bridge-ui/src/stores/index.ts @@ -1,7 +1,7 @@ export { useChainStore } from "./chainStore"; export { FormStoreProvider, useFormStore } from "./formStoreProvider"; export { type FormState } from "./formStore"; -export { useHistoryStore } from "./historyStore"; +export { useHistoryStore, type HistoryActionsForCompleteTxCaching } from "./historyStore"; export { useNativeBridgeNavigationStore } from "./nativeBridgeNavigationStore"; export { useTokenStore, TokenStoreProvider } from "./tokenStoreProvider"; export { defaultTokensConfig } from "./tokenStore"; diff --git a/bridge-ui/src/types/bridge.ts b/bridge-ui/src/types/bridge.ts new file mode 100644 index 00000000..65190744 --- /dev/null +++ b/bridge-ui/src/types/bridge.ts @@ -0,0 +1,42 @@ +import { Proof } from "@consensys/linea-sdk/dist/lib/sdk/merkleTree/types"; +import { Chain, Token, TransactionStatus } from "@/types"; +import { Address } from "viem"; + +export type NativeBridgeMessage = { + from: Address; + to: Address; + fee: bigint; + value: bigint; + nonce: bigint; + calldata: string; + messageHash: string; + proof?: Proof; + amountSent: bigint; +}; + +// Params expected for `receiveMessage` as per https://developers.circle.com/stablecoins/transfer-usdc-on-testnet-from-ethereum-to-avalanche +export type CctpV2BridgeMessage = { + message?: string; + attestation?: string; + amountSent: bigint; + nonce: `0x${string}`; +}; + +export enum BridgeTransactionType { + ETH = "ETH", + ERC20 = "ERC20", + USDC = "USDC", +} + +// BridgeTransaction object that is populated when user opens "TransactionHistory" component, and is passed to child components. +export interface BridgeTransaction { + type: BridgeTransactionType; + status: TransactionStatus; + timestamp: bigint; + fromChain: Chain; + toChain: Chain; + token: Token; + message: NativeBridgeMessage | CctpV2BridgeMessage; + bridgingTx: string; + claimingTx?: string; +} diff --git a/bridge-ui/src/types/cctp.ts b/bridge-ui/src/types/cctp.ts index b8a2f43e..1fd2b622 100644 --- a/bridge-ui/src/types/cctp.ts +++ b/bridge-ui/src/types/cctp.ts @@ -1,6 +1,8 @@ -export type CctpAttestationMessageStatus = "pending_confirmations" | "complete"; - -type CctpAttestationMessage = { +export enum CctpAttestationMessageStatus { + PENDING_CONFIRMATIONS = "pending_confirmations", + COMPLETE = "complete", +} +export type CctpAttestationMessage = { attestation: `0x${string}`; message: `0x${string}`; eventNonce: `0x${string}`; @@ -11,3 +13,12 @@ type CctpAttestationMessage = { export type CctpAttestationApiResponse = { messages: CctpAttestationMessage[]; }; + +export type CctpV2ReattestationApiResponse = { + message: string; + nonce: `0x${string}`; +}; + +export type CctpFeeApiResponse = { + minimumFee: number; +}; diff --git a/bridge-ui/src/types/chain.ts b/bridge-ui/src/types/chain.ts index 77615deb..5b8d285c 100644 --- a/bridge-ui/src/types/chain.ts +++ b/bridge-ui/src/types/chain.ts @@ -23,11 +23,13 @@ export type Chain = { apiUrl?: string | undefined; }; }; - testnet?: boolean; + testnet: boolean; layer: ChainLayer; messageServiceAddress: Address; tokenBridgeAddress: Address; gasLimitSurplus: bigint; profitMargin: bigint; cctpDomain: number; + cctpTokenMessengerV2Address: Address; + cctpMessageTransmitterV2Address: Address; }; diff --git a/bridge-ui/src/types/events.ts b/bridge-ui/src/types/events.ts index ba6e1711..47641041 100644 --- a/bridge-ui/src/types/events.ts +++ b/bridge-ui/src/types/events.ts @@ -1,6 +1,6 @@ -import { Address, Log } from "viem"; +import { Address, Log, AbiEvent } from "viem"; -export type MessageSentEvent = Log & { +export type MessageSentLogEvent = Log & { blockNumber: bigint; transactionHash: Address; args: { @@ -14,7 +14,7 @@ export type MessageSentEvent = Log & { }; }; -export type BridgingInitiatedV2Event = Log & { +export type BridgingInitiatedV2LogEvent = Log & { blockNumber: bigint; transactionHash: Address; args: { @@ -25,7 +25,7 @@ export type BridgingInitiatedV2Event = Log & { }; }; -export type DepositForBurnEvent = Log & { +export type DepositForBurnLogEvent = Log & { blockNumber: bigint; transactionHash: Address; args: { @@ -42,15 +42,205 @@ export type DepositForBurnEvent = Log & { }; }; -export type CCTPMessageReceivedEvent = Log & { - blockNumber: bigint; - transactionHash: Address; - args: { - caller: Address; - sourceDomain: number; - nonce: `0x${string}`; - sender: `0x${string}`; - finalityThresholdExecuted: number; - messageBody: `0x${string}`; - }; +export const CctpMessageReceivedAbiEvent: AbiEvent = { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "caller", type: "address" }, + { indexed: false, internalType: "uint32", name: "sourceDomain", type: "uint32" }, + { indexed: true, internalType: "bytes32", name: "nonce", type: "bytes32" }, + { indexed: false, internalType: "bytes32", name: "sender", type: "bytes32" }, + { indexed: true, internalType: "uint32", name: "finalityThresholdExecuted", type: "uint32" }, + { indexed: false, internalType: "bytes", name: "messageBody", type: "bytes" }, + ], + name: "MessageReceived", + type: "event", +}; + +export const BridgingInitiatedABIEvent: AbiEvent = { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + { + indexed: false, + internalType: "address", + name: "recipient", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "token", + type: "address", + }, + { + indexed: true, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "BridgingInitiated", + type: "event", +}; + +export const BridgingInitiatedV2ABIEvent: AbiEvent = { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "recipient", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "token", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "BridgingInitiatedV2", + type: "event", +}; + +export const MessageSentABIEvent: AbiEvent = { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "_from", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "_to", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "_fee", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "_value", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "_nonce", + type: "uint256", + }, + { + indexed: false, + internalType: "bytes", + name: "_calldata", + type: "bytes", + }, + { + indexed: true, + internalType: "bytes32", + name: "_messageHash", + type: "bytes32", + }, + ], + name: "MessageSent", + type: "event", +}; + +export const MessageClaimedABIEvent: AbiEvent = { + anonymous: false, + inputs: [{ indexed: true, internalType: "bytes32", name: "_messageHash", type: "bytes32" }], + name: "MessageClaimed", + type: "event", +}; + +export const CctpDepositForBurnAbiEvent: AbiEvent = { + anonymous: false, + inputs: [ + { + 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", + }, + { + indexed: false, + internalType: "uint256", + name: "maxFee", + type: "uint256", + }, + { + indexed: true, + internalType: "uint32", + name: "minFinalityThreshold", + type: "uint32", + }, + { + indexed: false, + internalType: "bytes", + name: "hookData", + type: "bytes", + }, + ], + name: "DepositForBurn", + type: "event", }; diff --git a/bridge-ui/src/types/index.ts b/bridge-ui/src/types/index.ts index aaf76902..ee5e7e87 100644 --- a/bridge-ui/src/types/index.ts +++ b/bridge-ui/src/types/index.ts @@ -4,9 +4,26 @@ export { type TransactionType, TransactionStatus } from "./transaction"; export { type Token, type GithubTokenListToken, type NetworkTokens } from "./token"; export { BridgeProvider } from "./providers"; export { - type MessageSentEvent, - type BridgingInitiatedV2Event, - type DepositForBurnEvent, - type CCTPMessageReceivedEvent, + type MessageSentLogEvent, + type BridgingInitiatedV2LogEvent, + type DepositForBurnLogEvent, + CctpMessageReceivedAbiEvent, + BridgingInitiatedABIEvent, + BridgingInitiatedV2ABIEvent, + MessageSentABIEvent, + MessageClaimedABIEvent, + CctpDepositForBurnAbiEvent, } from "./events"; -export { type CctpAttestationApiResponse, type CctpAttestationMessageStatus } from "./cctp"; +export { + type CctpAttestationApiResponse, + type CctpAttestationMessage, + type CctpV2ReattestationApiResponse, + type CctpFeeApiResponse, + CctpAttestationMessageStatus, +} from "./cctp"; +export { + type NativeBridgeMessage, + type CctpV2BridgeMessage, + type BridgeTransaction, + BridgeTransactionType, +} from "./bridge"; diff --git a/bridge-ui/src/utils/cctp.ts b/bridge-ui/src/utils/cctp.ts index 880486f2..2566d47b 100644 --- a/bridge-ui/src/utils/cctp.ts +++ b/bridge-ui/src/utils/cctp.ts @@ -1,21 +1,22 @@ import MessageTransmitterV2 from "@/abis/MessageTransmitterV2.json"; -import { CCTPMessageReceivedEvent, CctpAttestationMessageStatus, TransactionStatus } from "@/types"; +import { CctpAttestationMessage, Chain, TransactionStatus, CctpAttestationMessageStatus } from "@/types"; import { GetPublicClientReturnType } from "@wagmi/core"; -import { getAddress } from "viem"; -import { eventCCTPMessageReceived } from "./events"; +import { fetchCctpAttestationByTxHash, reattestCctpV2PreFinalityMessage } from "@/services/cctp"; +import { getPublicClient } from "@wagmi/core"; +import { config as wagmiConfig } from "@/lib/wagmi"; -// Contract for sending CCTP message, appears constant for each chain -export const CCTP_TOKEN_MESSENGER = getAddress("0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA"); // TODO - Find optimal value -export const CCTP_TRANSFER_MAX_FEE = 500n; +export const CCTP_TRANSFER_MAX_FEE_FALLBACK = 5n; // 1000 Fast transfer, 2000 Standard transfer export const CCTP_MIN_FINALITY_THRESHOLD = 1000; -// Contract for receiving CCTP message, appears constant for each chain -export const CCTP_MESSAGE_TRANSMITTER = getAddress("0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275"); // Appears constant for each chain -export const isCCTPNonceUsed = async (client: GetPublicClientReturnType, nonce: string): Promise => { +const isCctpNonceUsed = async ( + client: GetPublicClientReturnType, + nonce: string, + cctpMessageTransmitterV2Address: `0x${string}`, +): Promise => { const resp = await client?.readContract({ - address: CCTP_MESSAGE_TRANSMITTER, + address: cctpMessageTransmitterV2Address, abi: MessageTransmitterV2.abi, functionName: "usedNonces", args: [nonce], @@ -24,35 +25,62 @@ export const isCCTPNonceUsed = async (client: GetPublicClientReturnType, nonce: return resp === 1n; }; -export const getCCTPTransactionStatus = ( - cctpMessageStatus: CctpAttestationMessageStatus, - isNonceUsed: boolean, -): TransactionStatus => { - if (cctpMessageStatus === "pending_confirmations") return TransactionStatus.PENDING; - if (!isNonceUsed) return TransactionStatus.READY_TO_CLAIM; - return TransactionStatus.COMPLETED; +const getCctpMessageExpiryBlock = (message: string): bigint => { + // See CCTPV2 message format at https://developers.circle.com/stablecoins/message-format + const expiryInHex = message.substring(2 + 344 * 2, 2 + 376 * 2); + // Return bigint because this is also returned by Viem client.getBlockNumber() + return BigInt(parseInt(expiryInHex, 16)); }; -export const getCCTPClaimTx = async ( - client: GetPublicClientReturnType, - status: CctpAttestationMessageStatus, - isNonceUsed: boolean, - nonce: `0x${string}`, -): Promise => { - if (!client) return undefined; - if (!isNonceUsed) return undefined; - - const messageReceivedEvents = await client.getLogs({ - event: eventCCTPMessageReceived, - // TODO - Find more efficient `fromBlock` param than 'earliest' - fromBlock: "earliest", - toBlock: "latest", - address: CCTP_MESSAGE_TRANSMITTER, - args: { - nonce: nonce, - }, +export const getCctpTransactionStatus = async ( + toChain: Chain, + cctpAttestationMessage: CctpAttestationMessage, + nonce: string, +): Promise => { + const toChainClient = getPublicClient(wagmiConfig, { + chainId: toChain.id, }); + if (!toChainClient) return TransactionStatus.PENDING; + const isNonceUsed = await isCctpNonceUsed(toChainClient, nonce, toChain.cctpMessageTransmitterV2Address); + if (isNonceUsed) return TransactionStatus.COMPLETED; + const messageExpiryBlock = getCctpMessageExpiryBlock(cctpAttestationMessage.message); + // Message has no expiry + if (messageExpiryBlock === 0n) + return cctpAttestationMessage.status === CctpAttestationMessageStatus.PENDING_CONFIRMATIONS + ? TransactionStatus.PENDING + : TransactionStatus.READY_TO_CLAIM; - if (messageReceivedEvents.length === 0) return undefined; - return messageReceivedEvents[0].transactionHash; + // Message not expired + const currentToBlock = await toChainClient.getBlockNumber(); + if (currentToBlock < messageExpiryBlock) + return cctpAttestationMessage.status === CctpAttestationMessageStatus.PENDING_CONFIRMATIONS + ? TransactionStatus.PENDING + : TransactionStatus.READY_TO_CLAIM; + + // Message has expired, must reattest + await reattestCctpV2PreFinalityMessage(nonce, toChain.testnet); + + /** + * We will not re-query to get a new message/attestation set here: + * + * 1.) There is a concrete possibility that the new message status will be 'pending_confirmations', which will be a regression from the old message status of 'completed' + * - To avoid this edge case, we will simply deem the transaction status as 'TransactionStatus.PENDING' + * - TransactionStatus.PENDING means the user will not be able to claim, hence they have no need for a valid message/attestation set for this Transaction + * + * 2.) We avoid a CCTP API call that has a concrete possibility of returning a result consistent with TransactionStatus.PENDING anyway + * - Even in the case that the new message status is 'complete', the only cost to the user is a page refresh + */ + return TransactionStatus.PENDING; +}; + +export const getCctpMessageByTxHash = async ( + transactionHash: string, + fromChainCctpDomain: number, + isTestnet: boolean, +): Promise => { + const attestationApiResp = await fetchCctpAttestationByTxHash(fromChainCctpDomain, transactionHash, isTestnet); + if (!attestationApiResp) return; + const message = attestationApiResp.messages[0]; + if (!message) return; + return message; }; diff --git a/bridge-ui/src/utils/chains.ts b/bridge-ui/src/utils/chains.ts index 50c29c7d..4c79a212 100644 --- a/bridge-ui/src/utils/chains.ts +++ b/bridge-ui/src/utils/chains.ts @@ -11,13 +11,16 @@ export const generateChain = (chain: ViemChain): Chain => { iconPath: config.chains[chain.id].iconPath, nativeCurrency: chain.nativeCurrency, blockExplorers: chain.blockExplorers, - testnet: chain.testnet, + // Possibly the wrong assumption to fallback to 'false', but fallback to 'true' makes the app crash mysteriously + testnet: Boolean(chain.testnet), layer: getChainNetworkLayer(chain.id), messageServiceAddress: config.chains[chain.id].messageServiceAddress as Address, tokenBridgeAddress: config.chains[chain.id].tokenBridgeAddress as Address, gasLimitSurplus: config.chains[chain.id].gasLimitSurplus, profitMargin: config.chains[chain.id].profitMargin, cctpDomain: config.chains[chain.id].cctpDomain, + cctpTokenMessengerV2Address: config.chains[chain.id].cctpTokenMessengerV2Address as Address, + cctpMessageTransmitterV2Address: config.chains[chain.id].cctpMessageTransmitterV2Address as Address, }; }; @@ -41,7 +44,7 @@ export const getChainNetworkLayer = (chainId: number) => { export const getChainLogoPath = (chainId: number) => { switch (chainId) { case linea.id: - return "/images/logo/linea-mainnet.svg"; + return "/images/logo/linea-rounded.svg"; case lineaSepolia.id: return "/images/logo/linea-sepolia.svg"; case mainnet.id: diff --git a/bridge-ui/src/utils/events/eventCCTPMessageReceived.ts b/bridge-ui/src/utils/events/eventCCTPMessageReceived.ts deleted file mode 100644 index 25ffbcfe..00000000 --- a/bridge-ui/src/utils/events/eventCCTPMessageReceived.ts +++ /dev/null @@ -1,15 +0,0 @@ -const eventCCTPMessageReceived = { - anonymous: false, - inputs: [ - { indexed: true, internalType: "address", name: "caller", type: "address" }, - { indexed: false, internalType: "uint32", name: "sourceDomain", type: "uint32" }, - { indexed: true, internalType: "bytes32", name: "nonce", type: "bytes32" }, - { indexed: false, internalType: "bytes32", name: "sender", type: "bytes32" }, - { indexed: true, internalType: "uint32", name: "finalityThresholdExecuted", type: "uint32" }, - { indexed: false, internalType: "bytes", name: "messageBody", type: "bytes" }, - ], - name: "MessageReceived", - type: "event", -} as const; - -export default eventCCTPMessageReceived; diff --git a/bridge-ui/src/utils/events/eventERC20.ts b/bridge-ui/src/utils/events/eventERC20.ts deleted file mode 100644 index 06a3fa9e..00000000 --- a/bridge-ui/src/utils/events/eventERC20.ts +++ /dev/null @@ -1,33 +0,0 @@ -const eventERC20 = { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: "address", - name: "sender", - type: "address", - }, - { - indexed: false, - internalType: "address", - name: "recipient", - type: "address", - }, - { - indexed: true, - internalType: "address", - name: "token", - type: "address", - }, - { - indexed: true, - internalType: "uint256", - name: "amount", - type: "uint256", - }, - ], - name: "BridgingInitiated", - type: "event", -} as const; - -export default eventERC20; diff --git a/bridge-ui/src/utils/events/eventERC20V2.ts b/bridge-ui/src/utils/events/eventERC20V2.ts deleted file mode 100644 index bc1b8c61..00000000 --- a/bridge-ui/src/utils/events/eventERC20V2.ts +++ /dev/null @@ -1,33 +0,0 @@ -const eventERC20V2 = { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: "address", - name: "sender", - type: "address", - }, - { - indexed: true, - internalType: "address", - name: "recipient", - type: "address", - }, - { - indexed: true, - internalType: "address", - name: "token", - type: "address", - }, - { - indexed: false, - internalType: "uint256", - name: "amount", - type: "uint256", - }, - ], - name: "BridgingInitiatedV2", - type: "event", -} as const; - -export default eventERC20V2; diff --git a/bridge-ui/src/utils/events/eventETH.ts b/bridge-ui/src/utils/events/eventETH.ts deleted file mode 100644 index 2679c6d9..00000000 --- a/bridge-ui/src/utils/events/eventETH.ts +++ /dev/null @@ -1,51 +0,0 @@ -const eventETH = { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: "address", - name: "_from", - type: "address", - }, - { - indexed: true, - internalType: "address", - name: "_to", - type: "address", - }, - { - indexed: false, - internalType: "uint256", - name: "_fee", - type: "uint256", - }, - { - indexed: false, - internalType: "uint256", - name: "_value", - type: "uint256", - }, - { - indexed: false, - internalType: "uint256", - name: "_nonce", - type: "uint256", - }, - { - indexed: false, - internalType: "bytes", - name: "_calldata", - type: "bytes", - }, - { - indexed: true, - internalType: "bytes32", - name: "_messageHash", - type: "bytes32", - }, - ], - name: "MessageSent", - type: "event", -} as const; - -export default eventETH; diff --git a/bridge-ui/src/utils/events/eventUSDC.ts b/bridge-ui/src/utils/events/eventUSDC.ts deleted file mode 100644 index f97911c4..00000000 --- a/bridge-ui/src/utils/events/eventUSDC.ts +++ /dev/null @@ -1,69 +0,0 @@ -const eventUSDC = { - anonymous: false, - inputs: [ - { - 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", - }, - { - indexed: false, - internalType: "uint256", - name: "maxFee", - type: "uint256", - }, - { - indexed: true, - internalType: "uint32", - name: "minFinalityThreshold", - type: "uint32", - }, - { - indexed: false, - internalType: "bytes", - name: "hookData", - type: "bytes", - }, - ], - name: "DepositForBurn", - type: "event", -} as const; - -export default eventUSDC; diff --git a/bridge-ui/src/utils/events/getNativeBridgeMessageClaimedTxHash.ts b/bridge-ui/src/utils/events/getNativeBridgeMessageClaimedTxHash.ts new file mode 100644 index 00000000..1e2c8863 --- /dev/null +++ b/bridge-ui/src/utils/events/getNativeBridgeMessageClaimedTxHash.ts @@ -0,0 +1,20 @@ +import { PublicClient } from "viem"; +import { MessageClaimedABIEvent } from "@/types"; + +export const getNativeBridgeMessageClaimedTxHash = async ( + chainClient: PublicClient, + messageServiceAddress: `0x${string}`, + messageHash: `0x${string}`, +): Promise => { + const messageClaimedEvents = await chainClient.getLogs({ + event: MessageClaimedABIEvent, + fromBlock: "earliest", + toBlock: "latest", + address: messageServiceAddress, + args: { + _messageHash: messageHash, + }, + }); + if (messageClaimedEvents.length === 0) return ""; + return messageClaimedEvents[0].transactionHash; +}; diff --git a/bridge-ui/src/utils/events/index.ts b/bridge-ui/src/utils/events/index.ts index 625d7f1b..7c5e92b8 100644 --- a/bridge-ui/src/utils/events/index.ts +++ b/bridge-ui/src/utils/events/index.ts @@ -1,5 +1 @@ -export { default as eventERC20 } from "./eventERC20"; -export { default as eventERC20V2 } from "./eventERC20V2"; -export { default as eventETH } from "./eventETH"; -export { default as eventUSDC } from "./eventUSDC"; -export { default as eventCCTPMessageReceived } from "./eventCCTPMessageReceived"; +export { getNativeBridgeMessageClaimedTxHash } from "./getNativeBridgeMessageClaimedTxHash"; diff --git a/bridge-ui/src/utils/history.ts b/bridge-ui/src/utils/history.ts deleted file mode 100644 index ec4bc958..00000000 --- a/bridge-ui/src/utils/history.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { Address, decodeAbiParameters } from "viem"; -import { compareAsc, fromUnixTime, subDays } from "date-fns"; -import { getPublicClient } from "@wagmi/core"; -import { LineaSDK, OnChainMessageStatus } from "@consensys/linea-sdk"; -import { config as wagmiConfig } from "@/lib/wagmi"; -import { config } from "@/config"; -import { Proof } from "@consensys/linea-sdk/dist/lib/sdk/merkleTree/types"; -import { defaultTokensConfig } from "@/stores"; -import { LineaSDKContracts } from "@/hooks"; -import { Chain, ChainLayer, Token, TransactionStatus, BridgingInitiatedV2Event, MessageSentEvent } from "@/types"; -import { - CCTP_TOKEN_MESSENGER, - eventETH, - eventERC20V2, - eventUSDC, - getCCTPClaimTx, - isCCTPNonceUsed, - getCCTPTransactionStatus, -} from "@/utils"; -import { DepositForBurnEvent } from "@/types/events"; -import { fetchCctpAttestation } from "@/services/cctp"; - -type TransactionHistoryParams = { - lineaSDK: LineaSDK; - lineaSDKContracts: LineaSDKContracts; - fromChain: Chain; - toChain: Chain; - address: Address; - tokens: Token[]; -}; - -export type NativeBridgeMessage = { - from: Address; - to: Address; - fee: bigint; - value: bigint; - nonce: bigint; - calldata: string; - messageHash: string; - proof?: Proof; - amountSent: bigint; -}; - -// Params expected for `receiveMessage` as per https://developers.circle.com/stablecoins/transfer-usdc-on-testnet-from-ethereum-to-avalanche -export type CCTPV2BridgeMessage = { - message: string; - attestation: string; - amountSent: bigint; -}; -export interface BridgeTransaction { - type: "ETH" | "ERC20"; - status: TransactionStatus; - timestamp: bigint; - fromChain: Chain; - toChain: Chain; - token: Token; - message: NativeBridgeMessage | CCTPV2BridgeMessage; - bridgingTx: string; - claimingTx?: string; -} - -export async function fetchTransactionsHistory({ - lineaSDK, - lineaSDKContracts, - fromChain, - toChain, - address, - tokens, -}: TransactionHistoryParams): Promise { - const events = await Promise.all([ - fetchBridgeEvents(lineaSDK, lineaSDKContracts, fromChain, toChain, address, tokens), - fetchBridgeEvents(lineaSDK, lineaSDKContracts, toChain, fromChain, address, tokens), - ]); - return events.flat().sort((a, b) => Number(b.timestamp.toString()) - Number(a.timestamp.toString())); -} - -async function fetchBridgeEvents( - lineaSDK: LineaSDK, - lineaSDKContracts: LineaSDKContracts, - fromChain: Chain, - toChain: Chain, - address: Address, - tokens: Token[], -): Promise { - const [ethEvents, erc20Events, cctpEvents] = await Promise.all([ - fetchETHBridgeEvents(lineaSDK, lineaSDKContracts, address, fromChain, toChain, tokens), - fetchERC20BridgeEvents(lineaSDK, lineaSDKContracts, address, fromChain, toChain, tokens), - // Feature toggle for CCTP, will filter out USDC transactions if isCCTPEnabled == false - config.isCCTPEnabled ? fetchCCTPBridgeEvents(address, fromChain, toChain, tokens) : [], - ]); - - return [...ethEvents, ...erc20Events, ...cctpEvents]; -} - -async function fetchETHBridgeEvents( - lineaSDK: LineaSDK, - lineaSDKContracts: LineaSDKContracts, - address: Address, - fromChain: Chain, - toChain: Chain, - tokens: Token[], -): Promise { - const transactionsMap = new Map(); - - const client = getPublicClient(wagmiConfig, { - chainId: fromChain.id, - }); - - const contract = fromChain.layer === ChainLayer.L1 ? lineaSDK.getL2Contract() : lineaSDK.getL1Contract(); - - const messageServiceAddress = fromChain.messageServiceAddress; - const [ethLogsForSender, ethLogsForRecipient] = await Promise.all([>client.getLogs({ - event: eventETH, - fromBlock: "earliest", - toBlock: "latest", - address: messageServiceAddress, - args: { - _from: address, - }, - }), >client.getLogs({ - event: eventETH, - fromBlock: "earliest", - toBlock: "latest", - address: messageServiceAddress, - args: { - _to: address, - }, - })]); - - const messageSentLogs = [...ethLogsForSender, ...ethLogsForRecipient]; - - const uniqueLogsMap = new Map(); - for (const log of messageSentLogs) { - const uniqueKey = `${log.args._from}-${log.args._to}-${log.transactionHash}`; - if (!uniqueLogsMap.has(uniqueKey)) { - uniqueLogsMap.set(uniqueKey, log); - } - } - - const currentTimestamp = new Date(); - - await Promise.all( - Array.from(uniqueLogsMap.values()).map(async (log) => { - const messageHash = log.args._messageHash; - - const block = await client.getBlock({ blockNumber: log.blockNumber, includeTransactions: false }); - - if (compareAsc(fromUnixTime(Number(block.timestamp.toString())), subDays(currentTimestamp, 90)) === -1) { - return; - } - - const [messageStatus, [messageClaimedEvent]] = await Promise.all([ - contract.getMessageStatus(messageHash), - contract.getEvents(lineaSDKContracts[toChain.layer].contract.filters.MessageClaimed(messageHash)), - ]); - - const messageProof = - toChain.layer === ChainLayer.L1 && messageStatus === OnChainMessageStatus.CLAIMABLE - ? await lineaSDK.getL1ClaimingService().getMessageProof(messageHash) - : undefined; - - const token = tokens.find((token) => token.type.includes("eth")); - const uniqueKey = `${log.args._from}-${log.args._to}-${log.transactionHash}`; - transactionsMap.set(uniqueKey, { - type: "ETH", - status: formatStatus(messageStatus), - token: token || defaultTokensConfig.MAINNET[0], - fromChain, - toChain, - timestamp: block.timestamp, - bridgingTx: log.transactionHash, - claimingTx: messageClaimedEvent?.transactionHash, - message: { - from: log.args._from, - to: log.args._to, - fee: log.args._fee, - value: log.args._value, - nonce: log.args._nonce, - calldata: log.args._calldata, - messageHash: log.args._messageHash, - proof: messageProof, - amountSent: log.args._value, - }, - }); - }), - ); - - return Array.from(transactionsMap.values()); -} - -async function fetchERC20BridgeEvents( - lineaSDK: LineaSDK, - lineaSDKContracts: LineaSDKContracts, - address: Address, - fromChain: Chain, - toChain: Chain, - tokens: Token[], -): Promise { - const transactionsMap = new Map(); - - const client = getPublicClient(wagmiConfig, { - chainId: fromChain.id, - }); - - const [originContract, destinationContract] = - fromChain.layer === ChainLayer.L1 - ? [lineaSDK.getL1Contract(), lineaSDK.getL2Contract()] - : [lineaSDK.getL2Contract(), lineaSDK.getL1Contract()]; - - const tokenBridgeAddress = fromChain.tokenBridgeAddress; - const [erc20LogsForSender, erc20LogsForRecipient] = await Promise.all([ - >client.getLogs({ - event: eventERC20V2, - fromBlock: "earliest", - toBlock: "latest", - address: tokenBridgeAddress, - args: { - sender: address, - }, - }), - >client.getLogs({ - event: eventERC20V2, - fromBlock: "earliest", - toBlock: "latest", - address: tokenBridgeAddress, - args: { - recipient: address, - }, - }), - ]); - - const erc20Logs = [...erc20LogsForSender, ...erc20LogsForRecipient]; - - const uniqueLogsMap = new Map(); - for (const log of erc20Logs) { - const transactionHash = log.transactionHash; - if (!uniqueLogsMap.has(transactionHash)) { - uniqueLogsMap.set(transactionHash, log); - } - } - - const currentTimestamp = new Date(); - - await Promise.all( - Array.from(uniqueLogsMap.values()).map(async (log) => { - const transactionHash = log.transactionHash; - - const block = await client.getBlock({ blockNumber: log.blockNumber, includeTransactions: false }); - - if (compareAsc(fromUnixTime(Number(block.timestamp.toString())), subDays(currentTimestamp, 90)) === -1) { - return; - } - - const message = await originContract.getMessagesByTransactionHash(transactionHash); - if (!message || message.length === 0) { - return; - } - - const [messageStatus, [messageClaimedEvent]] = await Promise.all([ - destinationContract.getMessageStatus(message[0].messageHash), - destinationContract.getEvents( - lineaSDKContracts[toChain.layer].contract.filters.MessageClaimed(message[0].messageHash), - ), - ]); - - const messageProof = - toChain.layer === ChainLayer.L1 && messageStatus === OnChainMessageStatus.CLAIMABLE - ? await lineaSDK.getL1ClaimingService().getMessageProof(message[0].messageHash) - : undefined; - - const token = tokens.find( - (token) => - token.L1?.toLowerCase() === log.args.token.toLowerCase() || - token.L2?.toLowerCase() === log.args.token.toLowerCase(), - ); - - if (!token) { - return; - } - - const [amount] = decodeAbiParameters([{ type: "uint256", name: "amount" }], log.data); - - transactionsMap.set(transactionHash, { - type: "ERC20", - status: formatStatus(messageStatus), - token, - fromChain, - toChain, - timestamp: block.timestamp, - bridgingTx: log.transactionHash, - claimingTx: messageClaimedEvent?.transactionHash, - message: { - from: message[0].messageSender as Address, - to: message[0].destination as Address, - fee: message[0].fee, - value: message[0].value, - nonce: message[0].messageNonce, - calldata: message[0].calldata, - messageHash: message[0].messageHash, - proof: messageProof, - amountSent: amount, - }, - }); - }), - ); - - return Array.from(transactionsMap.values()); -} - -async function fetchCCTPBridgeEvents( - address: Address, - fromChain: Chain, - toChain: Chain, - tokens: Token[], -): Promise { - const transactionsMap = new Map(); - const fromChainClient = getPublicClient(wagmiConfig, { - chainId: fromChain.id, - }); - const toChainClient = getPublicClient(wagmiConfig, { - chainId: toChain.id, - }); - - const usdcLogs = await fromChainClient.getLogs({ - event: eventUSDC, - fromBlock: "earliest", - toBlock: "latest", - address: CCTP_TOKEN_MESSENGER, - args: { - depositor: address, - }, - }); - - // TODO - Consider deduplication - - const currentTimestamp = new Date(); - - await Promise.all( - usdcLogs.map(async (log) => { - const transactionHash = log.transactionHash; - - const block = await fromChainClient.getBlock({ blockNumber: log.blockNumber, includeTransactions: false }); - - if (compareAsc(fromUnixTime(Number(block.timestamp.toString())), subDays(currentTimestamp, 90)) === -1) { - return; - } - - const token = tokens.find((token) => token.symbol === "USDC" && token.type.includes("bridge-reserved")); - if (!token) return; - - const attestationApiResp = await fetchCctpAttestation(transactionHash, fromChain.cctpDomain); - if (!attestationApiResp) return; - // console.log("attestationApiResp:", attestationApiResp, "transactionHash:", transactionHash); - - const message = attestationApiResp.messages[0]; - if (!message) return; - - const nonce = message.eventNonce; - // console.log("messageNonce:", nonce); - - const isNonceUsed = await isCCTPNonceUsed(toChainClient, nonce); - // console.log("isNonceUsed:", isNonceUsed); - - const status = getCCTPTransactionStatus(message.status, isNonceUsed); - // console.log("status:", status); - - const claimTx = await getCCTPClaimTx(toChainClient, message.status, isNonceUsed, nonce); - // console.log("claimTx:", claimTx); - - transactionsMap.set(transactionHash, { - type: "ERC20", - status, - token, - fromChain, - toChain, - timestamp: block.timestamp, - bridgingTx: log.transactionHash, - claimingTx: claimTx, - message: { - attestation: message.attestation, - message: message.message, - amountSent: BigInt(log.args.amount), - }, - }); - }), - ); - - return Array.from(transactionsMap.values()); -} - -function formatStatus(status: OnChainMessageStatus): TransactionStatus { - switch (status) { - case OnChainMessageStatus.UNKNOWN: - return TransactionStatus.PENDING; - case OnChainMessageStatus.CLAIMABLE: - return TransactionStatus.READY_TO_CLAIM; - case OnChainMessageStatus.CLAIMED: - return TransactionStatus.COMPLETED; - default: - return TransactionStatus.PENDING; - } -} diff --git a/bridge-ui/src/utils/history/fetchCctpBridgeEvents.ts b/bridge-ui/src/utils/history/fetchCctpBridgeEvents.ts new file mode 100644 index 00000000..ae7c5fe3 --- /dev/null +++ b/bridge-ui/src/utils/history/fetchCctpBridgeEvents.ts @@ -0,0 +1,79 @@ +import { Address } from "viem"; +import { getPublicClient } from "@wagmi/core"; +import { config as wagmiConfig } from "@/lib/wagmi"; +import { BridgeTransaction, BridgeTransactionType, Chain, Token, CctpDepositForBurnAbiEvent } from "@/types"; +import { isCctp, getCctpMessageByTxHash, getCctpTransactionStatus } from "@/utils"; +import { DepositForBurnLogEvent } from "@/types/events"; +import { HistoryActionsForCompleteTxCaching } from "@/stores"; +import { getCompleteTxStoreKey } from "./getCompleteTxStoreKey"; +import { isBlockTooOld } from "./isBlockTooOld"; + +export async function fetchCctpBridgeEvents( + historyStoreActions: HistoryActionsForCompleteTxCaching, + address: Address, + fromChain: Chain, + toChain: Chain, + tokens: Token[], +): Promise { + const transactionsMap = new Map(); + const fromChainClient = getPublicClient(wagmiConfig, { + chainId: fromChain.id, + }); + + const usdcLogs = (await fromChainClient.getLogs({ + event: CctpDepositForBurnAbiEvent, + fromBlock: "earliest", + toBlock: "latest", + address: fromChain.cctpTokenMessengerV2Address, + args: { + depositor: address, + }, + })) as unknown as DepositForBurnLogEvent[]; + + await Promise.all( + usdcLogs.map(async (log) => { + const transactionHash = log.transactionHash; + + // Search cache for completed tx for this txHash, if cache-hit can skip remaining logic + const cacheKey = getCompleteTxStoreKey(fromChain.id, transactionHash); + const cachedCompletedTx = historyStoreActions.getCompleteTx(cacheKey); + if (cachedCompletedTx) { + transactionsMap.set(transactionHash, cachedCompletedTx); + return; + } + + const fromBlock = await fromChainClient.getBlock({ blockNumber: log.blockNumber, includeTransactions: false }); + if (isBlockTooOld(fromBlock)) return; + + const token = tokens.find((token) => isCctp(token)); + if (!token) return; + + const cctpMessage = await getCctpMessageByTxHash(transactionHash, fromChain.cctpDomain, fromChain.testnet); + if (!cctpMessage) return; + const nonce = cctpMessage.eventNonce; + const status = await getCctpTransactionStatus(toChain, cctpMessage, nonce); + + const tx: BridgeTransaction = { + type: BridgeTransactionType.USDC, + status, + token, + fromChain, + toChain, + timestamp: fromBlock.timestamp, + bridgingTx: log.transactionHash, + message: { + amountSent: BigInt(log.args.amount), + nonce: nonce, + attestation: cctpMessage.attestation, + message: cctpMessage.message, + }, + }; + + // Store COMPLETE tx in cache + historyStoreActions.setCompleteTx(tx); + transactionsMap.set(transactionHash, tx); + }), + ); + + return Array.from(transactionsMap.values()); +} diff --git a/bridge-ui/src/utils/history/fetchERC20BridgeEvents.ts b/bridge-ui/src/utils/history/fetchERC20BridgeEvents.ts new file mode 100644 index 00000000..5ff86eac --- /dev/null +++ b/bridge-ui/src/utils/history/fetchERC20BridgeEvents.ts @@ -0,0 +1,130 @@ +import { Address, decodeAbiParameters } from "viem"; +import { getPublicClient } from "@wagmi/core"; +import { LineaSDK } from "@consensys/linea-sdk"; +import { config as wagmiConfig } from "@/lib/wagmi"; +import { + BridgeTransaction, + BridgeTransactionType, + Chain, + ChainLayer, + Token, + BridgingInitiatedV2LogEvent, + BridgingInitiatedV2ABIEvent, +} from "@/types"; +import { formatOnChainMessageStatus } from "./formatOnChainMessageStatus"; +import { HistoryActionsForCompleteTxCaching } from "@/stores"; +import { getCompleteTxStoreKey } from "./getCompleteTxStoreKey"; +import { isBlockTooOld } from "./isBlockTooOld"; + +export async function fetchERC20BridgeEvents( + historyStoreActions: HistoryActionsForCompleteTxCaching, + lineaSDK: LineaSDK, + address: Address, + fromChain: Chain, + toChain: Chain, + tokens: Token[], +): Promise { + const transactionsMap = new Map(); + + const client = getPublicClient(wagmiConfig, { + chainId: fromChain.id, + }); + + const [originContract, destinationContract] = + fromChain.layer === ChainLayer.L1 + ? [lineaSDK.getL1Contract(), lineaSDK.getL2Contract()] + : [lineaSDK.getL2Contract(), lineaSDK.getL1Contract()]; + + const tokenBridgeAddress = fromChain.tokenBridgeAddress; + const [erc20LogsForSender, erc20LogsForRecipient] = await Promise.all([ + client.getLogs({ + event: BridgingInitiatedV2ABIEvent, + fromBlock: "earliest", + toBlock: "latest", + address: tokenBridgeAddress, + args: { + sender: address, + }, + }), + client.getLogs({ + event: BridgingInitiatedV2ABIEvent, + fromBlock: "earliest", + toBlock: "latest", + address: tokenBridgeAddress, + args: { + recipient: address, + }, + }), + ]); + + const erc20Logs = [...erc20LogsForSender, ...erc20LogsForRecipient] as unknown as BridgingInitiatedV2LogEvent[]; + + const uniqueLogsMap = new Map(); + for (const log of erc20Logs) { + const transactionHash = log.transactionHash; + if (!uniqueLogsMap.has(transactionHash)) { + uniqueLogsMap.set(transactionHash, log); + } + } + + await Promise.all( + Array.from(uniqueLogsMap.values()).map(async (log) => { + const transactionHash = log.transactionHash; + + // Search cache for completed tx for this txHash, if cache-hit can skip remaining logic + const cacheKey = getCompleteTxStoreKey(fromChain.id, transactionHash); + const cachedCompletedTx = historyStoreActions.getCompleteTx(cacheKey); + if (cachedCompletedTx) { + transactionsMap.set(transactionHash, cachedCompletedTx); + return; + } + + const block = await client.getBlock({ blockNumber: log.blockNumber, includeTransactions: false }); + if (isBlockTooOld(block)) return; + + const message = await originContract.getMessagesByTransactionHash(transactionHash); + if (!message || message.length === 0) { + return; + } + + const messageStatus = await destinationContract.getMessageStatus(message[0].messageHash); + + const token = tokens.find( + (token) => + token.L1?.toLowerCase() === log.args.token.toLowerCase() || + token.L2?.toLowerCase() === log.args.token.toLowerCase(), + ); + + if (!token) { + return; + } + + const [amount] = decodeAbiParameters([{ type: "uint256", name: "amount" }], log.data); + const tx = { + type: BridgeTransactionType.ERC20, + status: formatOnChainMessageStatus(messageStatus), + token, + fromChain, + toChain, + timestamp: block.timestamp, + bridgingTx: log.transactionHash, + message: { + from: message[0].messageSender as Address, + to: message[0].destination as Address, + fee: message[0].fee, + value: message[0].value, + nonce: message[0].messageNonce, + calldata: message[0].calldata, + messageHash: message[0].messageHash, + amountSent: amount, + }, + }; + + // Store COMPLETE tx in cache + historyStoreActions.setCompleteTx(tx); + transactionsMap.set(transactionHash, tx); + }), + ); + + return Array.from(transactionsMap.values()); +} diff --git a/bridge-ui/src/utils/history/fetchETHBridgeEvents.ts b/bridge-ui/src/utils/history/fetchETHBridgeEvents.ts new file mode 100644 index 00000000..b7a55276 --- /dev/null +++ b/bridge-ui/src/utils/history/fetchETHBridgeEvents.ts @@ -0,0 +1,117 @@ +import { Address } from "viem"; +import { getPublicClient } from "@wagmi/core"; +import { LineaSDK } from "@consensys/linea-sdk"; +import { config as wagmiConfig } from "@/lib/wagmi"; +import { defaultTokensConfig, HistoryActionsForCompleteTxCaching } from "@/stores"; +import { + BridgeTransaction, + BridgeTransactionType, + Chain, + ChainLayer, + Token, + MessageSentABIEvent, + MessageSentLogEvent, +} from "@/types"; +import { formatOnChainMessageStatus } from "./formatOnChainMessageStatus"; +import { getCompleteTxStoreKey } from "./getCompleteTxStoreKey"; +import { isBlockTooOld } from "./isBlockTooOld"; + +export async function fetchETHBridgeEvents( + historyStoreActions: HistoryActionsForCompleteTxCaching, + lineaSDK: LineaSDK, + address: Address, + fromChain: Chain, + toChain: Chain, + tokens: Token[], +): Promise { + const transactionsMap = new Map(); + + const client = getPublicClient(wagmiConfig, { + chainId: fromChain.id, + }); + + const contract = fromChain.layer === ChainLayer.L1 ? lineaSDK.getL2Contract() : lineaSDK.getL1Contract(); + + const messageServiceAddress = fromChain.messageServiceAddress; + const [ethLogsForSender, ethLogsForRecipient] = await Promise.all([ + client.getLogs({ + event: MessageSentABIEvent, + // No need to find more 'optimal' value than earliest. + // Empirical testing showed no practical difference when using hardcoded block number (that was 90 days old). + fromBlock: "earliest", + toBlock: "latest", + address: messageServiceAddress, + args: { + _from: address, + }, + }), + client.getLogs({ + event: MessageSentABIEvent, + fromBlock: "earliest", + toBlock: "latest", + address: messageServiceAddress, + args: { + _to: address, + }, + }), + ]); + + const messageSentLogs = [...ethLogsForSender, ...ethLogsForRecipient] as unknown as MessageSentLogEvent[]; + + const uniqueLogsMap = new Map(); + for (const log of messageSentLogs) { + const uniqueKey = `${log.args._from}-${log.args._to}-${log.transactionHash}`; + if (!uniqueLogsMap.has(uniqueKey)) { + uniqueLogsMap.set(uniqueKey, log); + } + } + + await Promise.all( + Array.from(uniqueLogsMap.values()).map(async (log) => { + const uniqueKey = `${log.args._from}-${log.args._to}-${log.transactionHash}`; + + // Search cache for completed tx for this txHash, if cache-hit can skip remaining logic + const cacheKey = getCompleteTxStoreKey(fromChain.id, log.transactionHash); + const cachedCompletedTx = historyStoreActions.getCompleteTx(cacheKey); + if (cachedCompletedTx) { + transactionsMap.set(uniqueKey, cachedCompletedTx); + return; + } + + const messageHash = log.args._messageHash; + + const block = await client.getBlock({ blockNumber: log.blockNumber, includeTransactions: false }); + if (isBlockTooOld(block)) return; + + const messageStatus = await contract.getMessageStatus(messageHash); + + const token = tokens.find((token) => token.type.includes("eth")); + + const tx = { + type: BridgeTransactionType.ETH, + status: formatOnChainMessageStatus(messageStatus), + token: token || defaultTokensConfig.MAINNET[0], + fromChain, + toChain, + timestamp: block.timestamp, + bridgingTx: log.transactionHash, + message: { + from: log.args._from, + to: log.args._to, + fee: log.args._fee, + value: log.args._value, + nonce: log.args._nonce, + calldata: log.args._calldata, + messageHash: log.args._messageHash, + amountSent: log.args._value, + }, + }; + + // Store COMPLETE tx in cache + historyStoreActions.setCompleteTx(tx); + transactionsMap.set(uniqueKey, tx); + }), + ); + + return Array.from(transactionsMap.values()); +} diff --git a/bridge-ui/src/utils/history/fetchTransactionsHistory.ts b/bridge-ui/src/utils/history/fetchTransactionsHistory.ts new file mode 100644 index 00000000..7f9b4f22 --- /dev/null +++ b/bridge-ui/src/utils/history/fetchTransactionsHistory.ts @@ -0,0 +1,50 @@ +import { Address } from "viem"; +import { LineaSDK } from "@consensys/linea-sdk"; +import { config } from "@/config"; +import { BridgeTransaction, Chain, Token } from "@/types"; +import { fetchETHBridgeEvents } from "./fetchETHBridgeEvents"; +import { fetchERC20BridgeEvents } from "./fetchERC20BridgeEvents"; +import { fetchCctpBridgeEvents } from "./fetchCctpBridgeEvents"; +import { HistoryActionsForCompleteTxCaching } from "@/stores"; + +type TransactionHistoryParams = { + lineaSDK: LineaSDK; + historyStoreActions: HistoryActionsForCompleteTxCaching; + fromChain: Chain; + toChain: Chain; + address: Address; + tokens: Token[]; +}; + +export async function fetchTransactionsHistory({ + lineaSDK, + fromChain, + toChain, + address, + tokens, + historyStoreActions, +}: TransactionHistoryParams): Promise { + const events = await Promise.all([ + fetchBridgeEvents(lineaSDK, fromChain, toChain, address, tokens, historyStoreActions), + fetchBridgeEvents(lineaSDK, toChain, fromChain, address, tokens, historyStoreActions), + ]); + return events.flat().sort((a, b) => Number(b.timestamp.toString()) - Number(a.timestamp.toString())); +} + +async function fetchBridgeEvents( + lineaSDK: LineaSDK, + fromChain: Chain, + toChain: Chain, + address: Address, + tokens: Token[], + historyStoreActions: HistoryActionsForCompleteTxCaching, +): Promise { + const [ethEvents, erc20Events, cctpEvents] = await Promise.all([ + fetchETHBridgeEvents(historyStoreActions, lineaSDK, address, fromChain, toChain, tokens), + fetchERC20BridgeEvents(historyStoreActions, lineaSDK, address, fromChain, toChain, tokens), + // Feature toggle for CCTP, will filter out USDC transactions if isCctpEnabled == false + config.isCctpEnabled ? fetchCctpBridgeEvents(historyStoreActions, address, fromChain, toChain, tokens) : [], + ]); + + return [...ethEvents, ...erc20Events, ...cctpEvents]; +} diff --git a/bridge-ui/src/utils/history/formatOnChainMessageStatus.ts b/bridge-ui/src/utils/history/formatOnChainMessageStatus.ts new file mode 100644 index 00000000..e743dba2 --- /dev/null +++ b/bridge-ui/src/utils/history/formatOnChainMessageStatus.ts @@ -0,0 +1,15 @@ +import { OnChainMessageStatus } from "@consensys/linea-sdk"; +import { TransactionStatus } from "@/types"; + +export function formatOnChainMessageStatus(status: OnChainMessageStatus): TransactionStatus { + switch (status) { + case OnChainMessageStatus.UNKNOWN: + return TransactionStatus.PENDING; + case OnChainMessageStatus.CLAIMABLE: + return TransactionStatus.READY_TO_CLAIM; + case OnChainMessageStatus.CLAIMED: + return TransactionStatus.COMPLETED; + default: + return TransactionStatus.PENDING; + } +} diff --git a/bridge-ui/src/utils/history/getCompleteTxStoreKey.ts b/bridge-ui/src/utils/history/getCompleteTxStoreKey.ts new file mode 100644 index 00000000..442da658 --- /dev/null +++ b/bridge-ui/src/utils/history/getCompleteTxStoreKey.ts @@ -0,0 +1,10 @@ +import { BridgeTransaction } from "@/types"; +import { SupportedChainId } from "@/lib/wagmi"; + +export function getCompleteTxStoreKeyForTx(transaction: BridgeTransaction): string { + return getCompleteTxStoreKey(transaction.fromChain.id, transaction.bridgingTx); +} + +export function getCompleteTxStoreKey(fromChainId: SupportedChainId, bridgingTxHash: string): string { + return `${fromChainId}-${bridgingTxHash}`; +} diff --git a/bridge-ui/src/utils/history/index.ts b/bridge-ui/src/utils/history/index.ts new file mode 100644 index 00000000..8b512443 --- /dev/null +++ b/bridge-ui/src/utils/history/index.ts @@ -0,0 +1,2 @@ +export { fetchTransactionsHistory } from "./fetchTransactionsHistory"; +export { getCompleteTxStoreKey, getCompleteTxStoreKeyForTx } from "./getCompleteTxStoreKey"; diff --git a/bridge-ui/src/utils/history/isBlockTooOld.ts b/bridge-ui/src/utils/history/isBlockTooOld.ts new file mode 100644 index 00000000..1c4b726d --- /dev/null +++ b/bridge-ui/src/utils/history/isBlockTooOld.ts @@ -0,0 +1,15 @@ +import { GetBlockReturnType } from "viem"; +import { compareAsc, fromUnixTime, subDays } from "date-fns"; + +// Transactions with an age > TOO_OLD_IN_DAYS_THRESHOLD will not be shown in the TransactionHistory +const TOO_OLD_IN_DAYS_THRESHOLD = 90; + +export function isBlockTooOld(block: GetBlockReturnType): boolean { + const currentTimestamp = new Date(); + return ( + compareAsc( + fromUnixTime(Number(block.timestamp.toString())), + subDays(currentTimestamp, TOO_OLD_IN_DAYS_THRESHOLD), + ) === -1 + ); +} diff --git a/bridge-ui/src/utils/index.ts b/bridge-ui/src/utils/index.ts index d2f0a820..16c4ab42 100644 --- a/bridge-ui/src/utils/index.ts +++ b/bridge-ui/src/utils/index.ts @@ -1,9 +1,14 @@ -export { eventETH, eventERC20, eventERC20V2, eventUSDC } from "./events"; +export { getNativeBridgeMessageClaimedTxHash } from "./events"; export { generateChain, generateChains, getChainLogoPath, getChainNetworkLayer } from "./chains"; export { estimateEthGasFee, estimateERC20GasFee } from "./fees"; export { formatAddress, formatBalance, formatHex, formatTimestamp, safeGetAddress } from "./format"; -export { fetchTransactionsHistory, type BridgeTransaction } from "./history"; -export { computeMessageHash, computeMessageStorageSlot } from "./message"; -export { isEth } from "./tokens"; +export { fetchTransactionsHistory } from "./history"; +export { computeMessageHash, computeMessageStorageSlot, isCctpV2BridgeMessage, isNativeBridgeMessage } from "./message"; +export { isEth, isCctp, USDC_SYMBOL } from "./tokens"; export { isEmptyObject } from "./utils"; -export { CCTP_TOKEN_MESSENGER, getCCTPClaimTx, isCCTPNonceUsed, getCCTPTransactionStatus } from "./cctp"; +export { + CCTP_TRANSFER_MAX_FEE_FALLBACK, + CCTP_MIN_FINALITY_THRESHOLD, + getCctpTransactionStatus, + getCctpMessageByTxHash, +} from "./cctp"; diff --git a/bridge-ui/src/utils/message.ts b/bridge-ui/src/utils/message.ts index 838ee979..e6dc26b2 100644 --- a/bridge-ui/src/utils/message.ts +++ b/bridge-ui/src/utils/message.ts @@ -1,5 +1,5 @@ import { keccak256, encodeAbiParameters, Address } from "viem"; -import { CCTPV2BridgeMessage, NativeBridgeMessage } from "./history"; +import { CctpV2BridgeMessage, NativeBridgeMessage } from "@/types"; const INBOX_L1L2_MESSAGE_STATUS_MAPPING_SLOT = 176n; @@ -38,7 +38,7 @@ export function computeMessageStorageSlot(messageHash: `0x${string}`) { ); } -export function isNativeBridgeMessage(msg: NativeBridgeMessage | CCTPV2BridgeMessage): msg is NativeBridgeMessage { +export function isNativeBridgeMessage(msg: NativeBridgeMessage | CctpV2BridgeMessage): msg is NativeBridgeMessage { return ( typeof (msg as NativeBridgeMessage).from === "string" && typeof (msg as NativeBridgeMessage).to === "string" && @@ -49,9 +49,10 @@ export function isNativeBridgeMessage(msg: NativeBridgeMessage | CCTPV2BridgeMes ); } -export function isCCTPV2BridgeMessage(msg: NativeBridgeMessage | CCTPV2BridgeMessage): msg is CCTPV2BridgeMessage { +export function isCctpV2BridgeMessage(msg: NativeBridgeMessage | CctpV2BridgeMessage): msg is CctpV2BridgeMessage { return ( - typeof (msg as CCTPV2BridgeMessage).message === "string" && - typeof (msg as CCTPV2BridgeMessage).attestation === "string" + typeof (msg as CctpV2BridgeMessage).nonce === "string" && + typeof (msg as CctpV2BridgeMessage).message === "string" && + typeof (msg as CctpV2BridgeMessage).attestation === "string" ); } diff --git a/bridge-ui/src/utils/tokens.ts b/bridge-ui/src/utils/tokens.ts index bf7819c3..b523aaf4 100644 --- a/bridge-ui/src/utils/tokens.ts +++ b/bridge-ui/src/utils/tokens.ts @@ -19,3 +19,5 @@ export const isCctp = (token: Token) => { isAddress(token.L2) ); }; + +export const USDC_SYMBOL = "USDC"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8593f105..6f2d1500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,8 @@ importers: specifier: 17.7.2 version: 17.7.2 + contracts/lib/forge-std: {} + e2e: devDependencies: '@jest/globals':