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 (
+
{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 && (
-
+
{buttonText}
)}
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':