[Feat] Bridge UIv2 - CCTPV2 claims for USDC & refactors (#792)

* did refreshCCTPMessageIfNeeded function

* added BridgeTransaction to own @types file

* successful claim tx

* remove constants for cctp contracts

* half way through useClaimingTx

* fix

* did cases for erc20 and eth transfer

* moved claim tx to useClaimingTx

* refactor for abievent types

* add getNativeBridgeMessageClaimedTxHash

* did useBridgeTransactionMessage

* fix build

* fix lint

* implement useCCTPFee

* removed auto-claim for usdc, move messageProof to TransactionDetail

* add loading ui for Claim button

* refactor history.ts file in utils

* fix linting errors

* implement caching of completed tx -> in empirical test cut requests from 24->9, ~2200ms -> ~1600ms response cycle time

* ci fix

* add comment for won't do

* remove 1 redundant cctp api call for claim params

* remove CCTP_TRANSFER_FEE_BUFFER

* did badge

* change linea main logo

* change linea main logo

* address edge case where reattest cause revert to pending

* small pr fixes

* Revert "small pr fixes"

This reverts commit 2404f98419b1b7221bda617ea39f20ab6cad5830.

* fixes applied again

* Update bridge-ui/src/utils/chains.ts

Co-authored-by: Victorien Gauch <85494462+VGau@users.noreply.github.com>
Signed-off-by: kyzooghost <73516204+kyzooghost@users.noreply.github.com>

* simplified code to handle expired cctp message edge case

* fix cctp casing

* fix casing again

* fix git casing issue

* remove comments

* add dynamiccontextprovider param

* change mobileprovider

---------

Signed-off-by: kyzooghost <73516204+kyzooghost@users.noreply.github.com>
Co-authored-by: Victorien Gauch <85494462+VGau@users.noreply.github.com>
This commit is contained in:
kyzooghost
2025-03-21 23:50:02 +11:00
committed by GitHub
parent 7334693931
commit cc8deee137
55 changed files with 1377 additions and 833 deletions

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="102 50 234 236" width="10em">
<defs>
<linearGradient id="linearGradient" x1="177.86" y1="291.18" x2="341.06" y2="127.98" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#b090f5"></stop>
<stop offset="1" stop-color="#5fbfff"></stop>
</linearGradient>
<linearGradient id="linearGradient2" x1="96.43" y1="207.75" x2="259.64" y2="44.55" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#68d7fa"></stop>
<stop offset="1" stop-color="#4ee498"></stop>
</linearGradient>
</defs>
<path fill="url(#linearGradient)" d="M319.43,110.37l-5-8.78a5.14,5.14,0,0,0-8.11-1.08L294.82,112a5.17,5.17,0,0,0-.64,6.51,90.22,90.22,0,0,1,10,20.58l0,0a90.2,90.2,0,0,1-85.45,119,89.38,89.38,0,0,1-42.26-10.49l19.45-19.46a64.41,64.41,0,0,0,80.77-88.29,5.15,5.15,0,0,0-8.29-1.41L256.76,150a5.14,5.14,0,0,0-1.37,4.82l1,4.18a38.63,38.63,0,0,1-56.75,42.39l-5.13-2.94a5.13,5.13,0,0,0-6.2.83l-47.51,47.5a5.15,5.15,0,0,0,.51,7.73l7,5.37a114.86,114.86,0,0,0,70.46,23.88A116,116,0,0,0,319.43,110.37Z"></path>
<path fill="url(#linearGradient2)" d="M289.21,75.82a114.83,114.83,0,0,0-70.46-23.89A116,116,0,0,0,118.06,225.37l5,8.77a5.16,5.16,0,0,0,8.12,1.09l11.48-11.48a5.19,5.19,0,0,0,.64-6.5,89.81,89.81,0,0,1-10-20.58l0,0a90.2,90.2,0,0,1,85.45-119A89.29,89.29,0,0,1,261,88.19l-19.46,19.45a64.39,64.39,0,0,0-87.21,60.23c0,1.07.29,5.95.38,6.79a64.76,64.76,0,0,0,6.07,21.27,5.16,5.16,0,0,0,8.3,1.41l11.64-11.65a5.15,5.15,0,0,0,1.38-4.81l-1-4.19a38.62,38.62,0,0,1,56.75-42.38l5.13,2.94a5.16,5.16,0,0,0,6.2-.83l47.5-47.5a5.16,5.16,0,0,0-.5-7.74Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -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%;
}
}
}

View File

@@ -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 (
<div className={styles.container}>
<button type="button" className={styles.button}>
<div className={styles["selected-label"]}>
<Image src={logoSrc} width={16} height={16} alt="{label}" />
<span>{label}</span>
</div>
</button>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import Skeleton from "@/components/bridge/claiming/skeleton";
import ReceivedAmount from "./received-amount";
import Fees from "./fees";
import { useFormStore, useChainStore } from "@/stores";
import BridgeMode from "./bridge-mode";
export default function Claiming() {
const fromChain = useChainStore.useFromChain();
@@ -17,6 +18,7 @@ export default function Claiming() {
const amount = useFormStore((state) => state.amount);
const balance = useFormStore((state) => state.balance);
const isTokenCanonicalUSDC = useFormStore((state) => state.isTokenCanonicalUSDC);
const originChainBalanceTooLow = amount && balance < amount;
@@ -37,9 +39,15 @@ export default function Claiming() {
<div className={styles.top}>
<p className={styles.title}>Receive</p>
<div className={styles.config}>
<button className={styles.setting} type="button" onClick={() => setShowAdvancedSettingsModal(true)}>
<SettingIcon />
</button>
<BridgeMode />
{
// There is no auto-claiming for USDC via CCTPV2
!isTokenCanonicalUSDC() && (
<button className={styles.setting} type="button" onClick={() => setShowAdvancedSettingsModal(true)}>
<SettingIcon />
</button>
)
}
</div>
</div>

View File

@@ -21,7 +21,7 @@ export default function TransactionConfirmed({ isModalOpen, transactionType, onC
<p className={styles["text"]}>
{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."}
</p>
<div className={styles["list-button"]}>
<Link

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import styles from "./list-transaction.module.scss";
import Transaction from "./item";
import TransactionDetails from "@/components/bridge/transaction-history/modal/transaction-details";
import { BridgeTransaction } from "@/utils";
import { BridgeTransaction } from "@/types";
type Props = {
transactions: BridgeTransaction[];

View File

@@ -4,8 +4,8 @@ import styles from "./item.module.scss";
import CheckIcon from "@/assets/icons/check.svg";
import ClockIcon from "@/assets/icons/clock.svg";
import BridgeTwoLogo from "@/components/bridge/bridge-two-logo";
import { BridgeTransaction, getChainLogoPath, formatHex, formatTimestamp } from "@/utils";
import { TransactionStatus } from "@/types";
import { getChainLogoPath, formatHex, formatTimestamp } from "@/utils";
import { BridgeTransaction, TransactionStatus } from "@/types";
type Props = BridgeTransaction & {
onClick: (code: string) => void;

View File

@@ -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 (
<Modal title="Transaction details" isOpen={isModalOpen} onClose={onCloseModal}>
<div className={styles["modal-inner"]}>
@@ -150,7 +172,11 @@ export default function TransactionDetails({ transaction, isModalOpen, onCloseMo
)}
</ul>
{transaction?.status === TransactionStatus.READY_TO_CLAIM && (
<Button disabled={isPending || isConfirming || isSwitchingChain} onClick={handleClaim} fullWidth>
<Button
disabled={isLoadingClaimTxParams || isPending || isConfirming || isSwitchingChain}
onClick={handleClaim}
fullWidth
>
{buttonText}
</Button>
)}

View File

@@ -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();

View File

@@ -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<Config> {

View File

@@ -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,
}}

View File

@@ -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";

View File

@@ -1,8 +0,0 @@
import { useChainStore } from "@/stores";
const useCctpDestinationDomain = () => {
const toChain = useChainStore.useToChain();
return toChain.cctpDomain;
};
export default useCctpDestinationDomain;

View File

@@ -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);
};

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<CctpV2BridgeMessage | NativeBridgeMessage> {
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;

View File

@@ -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({

View File

@@ -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<string> {
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 "";
}
}
}

View File

@@ -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]);
};

View File

@@ -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,
});

View File

@@ -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<CctpAttestationApiResponse> {
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<CctpAttestationApiResponse> {
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<CctpV2ReattestationApiResponse> {
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<CctpFeeApiResponse> {
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;
}

View File

@@ -88,20 +88,20 @@ export async function getTokenConfig(): Promise<NetworkTokens> {
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<Token> => 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<Token> => formatToken(token))))
// Feature toggle, remove .filter expression when feature toggle no longer needed
.filter(filterOutUSDCWhenCCTPNotEnabled),
.filter(filterOutUSDCWhenCctpNotEnabled),
];
return updatedTokensConfig;

View File

@@ -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<FormStore>((set) => {
createWithEqualityFn<FormStore>((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);

View File

@@ -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<string, BridgeTransaction>;
};
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<HistoryActions, "setCompleteTx" | "getCompleteTx">;
export type HistoryStore = HistoryState & HistoryActions;
export const defaultInitState: HistoryState = {
history: {},
isLoading: false,
// history: {},
completeTxHistory: {},
};
export const useHistoryStore = createWithEqualityFn<HistoryStore>()(
@@ -36,36 +43,51 @@ export const useHistoryStore = createWithEqualityFn<HistoryStore>()(
(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",

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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",
};

View File

@@ -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";

View File

@@ -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<boolean> => {
const isCctpNonceUsed = async (
client: GetPublicClientReturnType,
nonce: string,
cctpMessageTransmitterV2Address: `0x${string}`,
): Promise<boolean> => {
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<string | undefined> => {
if (!client) return undefined;
if (!isNonceUsed) return undefined;
const messageReceivedEvents = <CCTPMessageReceivedEvent[]>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<TransactionStatus> => {
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<CctpAttestationMessage | undefined> => {
const attestationApiResp = await fetchCctpAttestationByTxHash(fromChainCctpDomain, transactionHash, isTestnet);
if (!attestationApiResp) return;
const message = attestationApiResp.messages[0];
if (!message) return;
return message;
};

View File

@@ -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:

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<string> => {
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;
};

View File

@@ -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";

View File

@@ -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<BridgeTransaction[]> {
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<BridgeTransaction[]> {
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<BridgeTransaction[]> {
const transactionsMap = new Map<string, BridgeTransaction>();
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([<Promise<MessageSentEvent[]>>client.getLogs({
event: eventETH,
fromBlock: "earliest",
toBlock: "latest",
address: messageServiceAddress,
args: {
_from: address,
},
}), <Promise<MessageSentEvent[]>>client.getLogs({
event: eventETH,
fromBlock: "earliest",
toBlock: "latest",
address: messageServiceAddress,
args: {
_to: address,
},
})]);
const messageSentLogs = [...ethLogsForSender, ...ethLogsForRecipient];
const uniqueLogsMap = new Map<string, (typeof messageSentLogs)[0]>();
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<BridgeTransaction[]> {
const transactionsMap = new Map<string, BridgeTransaction>();
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([
<Promise<BridgingInitiatedV2Event[]>>client.getLogs({
event: eventERC20V2,
fromBlock: "earliest",
toBlock: "latest",
address: tokenBridgeAddress,
args: {
sender: address,
},
}),
<Promise<BridgingInitiatedV2Event[]>>client.getLogs({
event: eventERC20V2,
fromBlock: "earliest",
toBlock: "latest",
address: tokenBridgeAddress,
args: {
recipient: address,
},
}),
]);
const erc20Logs = [...erc20LogsForSender, ...erc20LogsForRecipient];
const uniqueLogsMap = new Map<string, (typeof erc20Logs)[0]>();
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<BridgeTransaction[]> {
const transactionsMap = new Map<string, BridgeTransaction>();
const fromChainClient = getPublicClient(wagmiConfig, {
chainId: fromChain.id,
});
const toChainClient = getPublicClient(wagmiConfig, {
chainId: toChain.id,
});
const usdcLogs = <DepositForBurnEvent[]>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;
}
}

View File

@@ -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<BridgeTransaction[]> {
const transactionsMap = new Map<string, BridgeTransaction>();
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());
}

View File

@@ -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<BridgeTransaction[]> {
const transactionsMap = new Map<string, BridgeTransaction>();
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<string, (typeof erc20Logs)[0]>();
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());
}

View File

@@ -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<BridgeTransaction[]> {
const transactionsMap = new Map<string, BridgeTransaction>();
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<string, (typeof messageSentLogs)[0]>();
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());
}

View File

@@ -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<BridgeTransaction[]> {
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<BridgeTransaction[]> {
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];
}

View File

@@ -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;
}
}

View File

@@ -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}`;
}

View File

@@ -0,0 +1,2 @@
export { fetchTransactionsHistory } from "./fetchTransactionsHistory";
export { getCompleteTxStoreKey, getCompleteTxStoreKeyForTx } from "./getCompleteTxStoreKey";

View File

@@ -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
);
}

View File

@@ -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";

View File

@@ -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"
);
}

View File

@@ -19,3 +19,5 @@ export const isCctp = (token: Token) => {
isAddress(token.L2)
);
};
export const USDC_SYMBOL = "USDC";

2
pnpm-lock.yaml generated
View File

@@ -245,6 +245,8 @@ importers:
specifier: 17.7.2
version: 17.7.2
contracts/lib/forge-std: {}
e2e:
devDependencies:
'@jest/globals':