mirror of
https://github.com/vacp2p/linea-monorepo.git
synced 2026-01-09 04:08:01 -05:00
[Feat] Postman Sponsor (#860)
* remove isTokenCanonicalUSDC * move logic to handleTokenClick * add clause * remove string for ClaimType * remove todos * rename for clarity * added switch for auto_free * replaced AUTO_PAID * some more auto_free * fix typo * did free text * comment fixes * add test todos * added config values to postman * added isForSponsorship * add isForSponsorship condition to MessageClaimingProcessor * removed config from messageclaimingprocessor * did tests for transactionvalidationservice * did one messageclaimingprocessor test * added cases to MessageClaimingProcessor test * rm valueAndFee magic value from e2e test * new test * working e2e test * test refactor * test refactor * new e2e test * add new e2e case * remove .only * new test case * test 2 workers for e2e * Revert "test 2 workers for e2e" This reverts commit 8256043df9613015e98c8b5507fcf87f3a8ccc06. * empty * adjust for comments 1 * empty * adjust for comment 2
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useTokens } from "@/hooks";
|
||||
import { useAccount } from "wagmi";
|
||||
import { FormState, FormStoreProvider, useChainStore } from "@/stores";
|
||||
import { ChainLayer } from "@/types";
|
||||
import { ChainLayer, ClaimType } from "@/types";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { address } = useAccount();
|
||||
@@ -12,7 +12,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const initialFormState: FormState = {
|
||||
token: tokens[0],
|
||||
claim: fromChain?.layer === ChainLayer.L1 ? "auto" : "manual",
|
||||
claim: fromChain?.layer === ChainLayer.L1 ? ClaimType.AUTO_SPONSORED : ClaimType.MANUAL,
|
||||
amount: null,
|
||||
minimumFees: 0n,
|
||||
gasFees: 0n,
|
||||
|
||||
@@ -3,6 +3,7 @@ import ManualClaim from "../manual-claim";
|
||||
import EstimatedTime from "../estimated-time";
|
||||
import WithFees from "./with-fees";
|
||||
import { useFormStore, useChainStore } from "@/stores";
|
||||
import { ClaimType } from "@/types";
|
||||
|
||||
export default function Fees() {
|
||||
const fromChain = useChainStore.useFromChain();
|
||||
@@ -13,7 +14,7 @@ export default function Fees() {
|
||||
<div className={styles.estimate}>
|
||||
<WithFees iconPath={fromChain.iconPath} />
|
||||
<EstimatedTime />
|
||||
{claim === "manual" && <ManualClaim />}
|
||||
{claim === ClaimType.MANUAL && <ManualClaim />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useAccount } from "wagmi";
|
||||
import BridgeTwoLogo from "@/components/bridge/bridge-two-logo";
|
||||
import styles from "./claiming.module.scss";
|
||||
@@ -9,6 +9,8 @@ import ReceivedAmount from "./received-amount";
|
||||
import Fees from "./fees";
|
||||
import { useFormStore, useChainStore } from "@/stores";
|
||||
import BridgeMode from "./bridge-mode";
|
||||
import { ChainLayer } from "@/types";
|
||||
import { isCctp } from "@/utils";
|
||||
|
||||
export default function Claiming() {
|
||||
const { isConnected } = useAccount();
|
||||
@@ -20,7 +22,7 @@ export default function Claiming() {
|
||||
|
||||
const amount = useFormStore((state) => state.amount);
|
||||
const balance = useFormStore((state) => state.balance);
|
||||
const isTokenCanonicalUSDC = useFormStore((state) => state.isTokenCanonicalUSDC);
|
||||
const token = useFormStore((state) => state.token);
|
||||
|
||||
const originChainBalanceTooLow = amount && balance < amount;
|
||||
|
||||
@@ -33,6 +35,15 @@ export default function Claiming() {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [amount]);
|
||||
|
||||
// Do not allow user to go to AdvancedSettings modal, when they have no choice of ClaimType anyway
|
||||
const showSettingIcon = useMemo(() => {
|
||||
if (fromChain.layer === ChainLayer.L2) return false;
|
||||
// No auto-claiming for USDC via CCTPV2
|
||||
if (isCctp(token)) return false;
|
||||
if (loading) return false;
|
||||
return true;
|
||||
}, [fromChain, token, loading]);
|
||||
|
||||
if (!amount || amount <= 0n) return null;
|
||||
if (isConnected && originChainBalanceTooLow) return null;
|
||||
|
||||
@@ -42,14 +53,11 @@ export default function Claiming() {
|
||||
<p className={styles.title}>Receive</p>
|
||||
<div className={styles.config}>
|
||||
<BridgeMode />
|
||||
{
|
||||
// There is no auto-claiming for USDC via CCTPV2
|
||||
!isTokenCanonicalUSDC() && (
|
||||
<button className={styles.setting} type="button" onClick={() => setShowAdvancedSettingsModal(true)}>
|
||||
<SettingIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
{showSettingIcon && (
|
||||
<button className={styles.setting} type="button" onClick={() => setShowAdvancedSettingsModal(true)}>
|
||||
<SettingIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { DestinationAddress } from "../destination-address";
|
||||
import Button from "../../ui/button";
|
||||
import { useChainStore, useFormStore, useNativeBridgeNavigationStore } from "@/stores";
|
||||
import { useTokenBalance } from "@/hooks";
|
||||
import { ChainLayer } from "@/types";
|
||||
import { ChainLayer, ClaimType } from "@/types";
|
||||
|
||||
export default function BridgeForm() {
|
||||
const [isDestinationAddressOpen, setIsDestinationAddressOpen] = useState(false);
|
||||
@@ -49,7 +49,7 @@ export default function BridgeForm() {
|
||||
}
|
||||
|
||||
if (fromChain.layer === ChainLayer.L2) {
|
||||
setClaim("manual");
|
||||
setClaim(ClaimType.MANUAL);
|
||||
}
|
||||
}, [balance, address, fromChain.layer, setBalance, setRecipient, setClaim]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import Modal from "@/components/modal";
|
||||
import CheckShieldIcon from "@/assets/icons/check-shield.svg";
|
||||
import styles from "./advanced-settings.module.scss";
|
||||
import ToggleSwitch from "@/components/ui/toggle-switch";
|
||||
import { ChainLayer } from "@/types";
|
||||
import { ChainLayer, ClaimType } from "@/types";
|
||||
import { useFormStore, useChainStore } from "@/stores";
|
||||
|
||||
type Props = {
|
||||
@@ -33,12 +33,12 @@ export default function AdvancedSettings({ isModalOpen, onCloseModal }: Props) {
|
||||
<div className={styles.toggle}>
|
||||
<ToggleSwitch
|
||||
disabled={fromChain?.layer === ChainLayer.L2}
|
||||
checked={claim === "manual"}
|
||||
checked={claim === ClaimType.MANUAL}
|
||||
onChange={(checked) => {
|
||||
if (checked) {
|
||||
setClaim("manual");
|
||||
setClaim(ClaimType.MANUAL);
|
||||
} else {
|
||||
setClaim("auto");
|
||||
setClaim(ClaimType.AUTO_PAID);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { formatEther } from "viem";
|
||||
import styles from "./gas-fees-list-item.module.scss";
|
||||
import { CurrencyOption } from "@/stores";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
@@ -10,11 +11,16 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function GasFeesListItem({ name, fee, fiatValue, currency }: Props) {
|
||||
const feeText = useMemo(() => {
|
||||
if (fee === 0n) return "Free";
|
||||
return `${parseFloat(formatEther(fee)).toFixed(8)} ETH`;
|
||||
}, [fee]);
|
||||
|
||||
return (
|
||||
<li className={styles["list-item"]}>
|
||||
<span>{name}</span>
|
||||
<div className={styles["fee-row"]}>
|
||||
<span className={styles["fee-value"]}>{`${parseFloat(formatEther(fee)).toFixed(8)} ETH`}</span>
|
||||
<span className={styles["fee-value"]}>{feeText}</span>
|
||||
{fiatValue && (
|
||||
<span className={styles["fee-fiat-value"]}>
|
||||
{fiatValue.toLocaleString("en-US", {
|
||||
|
||||
@@ -7,9 +7,9 @@ import SearchIcon from "@/assets/icons/search.svg";
|
||||
import styles from "./token-modal.module.scss";
|
||||
import TokenDetails from "./token-details";
|
||||
import { useDevice, useTokenPrices, useTokens } from "@/hooks";
|
||||
import { useTokenStore, useChainStore, useConfigStore } from "@/stores";
|
||||
import { Token } from "@/types";
|
||||
import { safeGetAddress, isEmptyObject, isEth } from "@/utils";
|
||||
import { useTokenStore, useChainStore, useConfigStore, useFormStore } from "@/stores";
|
||||
import { ChainLayer, ClaimType, Token } from "@/types";
|
||||
import { safeGetAddress, isEmptyObject, isEth, isCctp } from "@/utils";
|
||||
import { useAccount } from "wagmi";
|
||||
|
||||
interface TokenModalProps {
|
||||
@@ -21,6 +21,7 @@ export default function TokenModal({ isModalOpen, onCloseModal }: TokenModalProp
|
||||
const { isConnected } = useAccount();
|
||||
const tokensList = useTokens();
|
||||
const setSelectedToken = useTokenStore((state) => state.setSelectedToken);
|
||||
const setClaim = useFormStore((state) => state.setClaim);
|
||||
const fromChain = useChainStore.useFromChain();
|
||||
const currency = useConfigStore((state) => state.currency);
|
||||
const { isMobile } = useDevice();
|
||||
@@ -60,12 +61,19 @@ export default function TokenModal({ isModalOpen, onCloseModal }: TokenModalProp
|
||||
|
||||
const { data: tokenPrices } = useTokenPrices(tokenAddresses, chainId);
|
||||
|
||||
// Set default claim type for token selection here.
|
||||
const handleTokenClick = useCallback(
|
||||
(token: Token) => {
|
||||
setSelectedToken(token);
|
||||
// For L2->L1, there is only manual claiming. This is set in the parent component BridgeForm, and we take care here to not override it.
|
||||
if (fromChain.layer === ChainLayer.L1) {
|
||||
if (isCctp(token)) setClaim(ClaimType.MANUAL);
|
||||
// Initial claim type is AUTO_SPONSORED, this can change when bridge fee computed in useBridgingFee
|
||||
else setClaim(ClaimType.AUTO_SPONSORED);
|
||||
}
|
||||
onCloseModal();
|
||||
},
|
||||
[onCloseModal, setSelectedToken],
|
||||
[onCloseModal, setSelectedToken, setClaim, fromChain.layer],
|
||||
);
|
||||
|
||||
const getTokenPrice = useCallback(
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export const INBOX_L1L2_MESSAGE_STATUS_MAPPING_SLOT = 176n;
|
||||
|
||||
export const MAX_POSTMAN_SPONSOR_GAS_LIMIT = 250000n;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { default as useBridgingFee } from "./useBridgingFee";
|
||||
export { default as useEthBridgingFee } from "./useEthBridgingFee";
|
||||
export { default as useERC20BridgingFee } from "./useERC20BridgingFee";
|
||||
export { default as useEthBridgingGasUsed } from "./useEthBridgingGasUsed";
|
||||
export { default as useERC20BridgingGasUsed } from "./useERC20BridgingGasUsed";
|
||||
export { default as useFeeData } from "./useFeeData";
|
||||
export { default as useFees } from "./useFees";
|
||||
export { default as useGasFees } from "./useGasFees";
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useMemo, useEffect } from "react";
|
||||
import { Address } from "viem";
|
||||
import useFeeData from "./useFeeData";
|
||||
import useMessageNumber from "../useMessageNumber";
|
||||
import useERC20BridgingFee from "./useERC20BridgingFee";
|
||||
import useEthBridgingFee from "./useEthBridgingFee";
|
||||
import useERC20BridgingGasUsed from "./useERC20BridgingGasUsed";
|
||||
import useEthBridgingGasUsed from "./useEthBridgingGasUsed";
|
||||
import { useFormStore, useChainStore } from "@/stores";
|
||||
import { Token } from "@/types";
|
||||
import { Token, ClaimType } from "@/types";
|
||||
import { isEth, isUndefined } from "@/utils";
|
||||
import { DEFAULT_ADDRESS_FOR_NON_CONNECTED_USER } from "@/constants";
|
||||
import { DEFAULT_ADDRESS_FOR_NON_CONNECTED_USER, MAX_POSTMAN_SPONSOR_GAS_LIMIT } from "@/constants";
|
||||
|
||||
type UseBridgingFeeProps = {
|
||||
isConnected: boolean;
|
||||
@@ -15,13 +15,14 @@ type UseBridgingFeeProps = {
|
||||
account?: Address;
|
||||
recipient: Address;
|
||||
amount: bigint;
|
||||
claimingType: "auto" | "manual";
|
||||
claimingType: ClaimType;
|
||||
};
|
||||
|
||||
const useBridgingFee = ({ isConnected, account, token, claimingType, amount, recipient }: UseBridgingFeeProps) => {
|
||||
const fromChain = useChainStore.useFromChain();
|
||||
const toChain = useChainStore.useToChain();
|
||||
const setBridgingFees = useFormStore((state) => state.setBridgingFees);
|
||||
const setClaim = useFormStore((state) => state.setClaim);
|
||||
|
||||
const { feeData } = useFeeData(toChain.id);
|
||||
const nextMessageNumber = useMessageNumber({ fromChain, claimingType });
|
||||
@@ -29,7 +30,7 @@ const useBridgingFee = ({ isConnected, account, token, claimingType, amount, rec
|
||||
const fromAddress = isConnected ? account : DEFAULT_ADDRESS_FOR_NON_CONNECTED_USER;
|
||||
const toAddress = isConnected ? recipient : DEFAULT_ADDRESS_FOR_NON_CONNECTED_USER;
|
||||
|
||||
const eth = useEthBridgingFee({
|
||||
const eth = useEthBridgingGasUsed({
|
||||
account: fromAddress,
|
||||
fromChain,
|
||||
toChain,
|
||||
@@ -40,7 +41,7 @@ const useBridgingFee = ({ isConnected, account, token, claimingType, amount, rec
|
||||
claimingType,
|
||||
});
|
||||
|
||||
const erc20 = useERC20BridgingFee({
|
||||
const erc20 = useERC20BridgingGasUsed({
|
||||
account: fromAddress,
|
||||
token,
|
||||
fromChain,
|
||||
@@ -56,14 +57,34 @@ const useBridgingFee = ({ isConnected, account, token, claimingType, amount, rec
|
||||
const gasLimit = isEth(token) ? eth.data : erc20.data;
|
||||
|
||||
const computedBridgingFees = useMemo(() => {
|
||||
if (claimingType === "manual") {
|
||||
// Highest priority claim type, if L2->L1 or USDC, do not enable any path to other claim types.
|
||||
if (claimingType === ClaimType.MANUAL) {
|
||||
return 0n;
|
||||
}
|
||||
if (isLoading || isError || isUndefined(gasLimit) || isUndefined(feeData)) {
|
||||
return null;
|
||||
}
|
||||
return feeData * (gasLimit + fromChain.gasLimitSurplus) * fromChain.profitMargin;
|
||||
}, [isLoading, isError, gasLimit, feeData, claimingType, fromChain.gasLimitSurplus, fromChain.profitMargin]);
|
||||
|
||||
// Computation for AUTO_SPONSORED, i.e. sponsored by the Postman
|
||||
const bridgingGasUsedWithSurplus = gasLimit + fromChain.gasLimitSurplus;
|
||||
if (bridgingGasUsedWithSurplus < MAX_POSTMAN_SPONSOR_GAS_LIMIT) {
|
||||
setClaim(ClaimType.AUTO_SPONSORED);
|
||||
return 0n;
|
||||
}
|
||||
|
||||
// Computation for ClaimType.AUTO_PAID
|
||||
setClaim(ClaimType.AUTO_PAID);
|
||||
return feeData * bridgingGasUsedWithSurplus * fromChain.profitMargin;
|
||||
}, [
|
||||
isLoading,
|
||||
isError,
|
||||
gasLimit,
|
||||
feeData,
|
||||
claimingType,
|
||||
fromChain.gasLimitSurplus,
|
||||
fromChain.profitMargin,
|
||||
setClaim,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (computedBridgingFees !== null) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Address } from "viem";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BridgeProvider, Chain, ChainLayer, Token } from "@/types";
|
||||
import { estimateERC20GasFee, isEth } from "@/utils";
|
||||
import { BridgeProvider, Chain, ChainLayer, ClaimType, Token } from "@/types";
|
||||
import { estimateERC20BridgingGasUsed, isEth } from "@/utils";
|
||||
|
||||
type UseERC20BridgingFeeProps = {
|
||||
account?: Address;
|
||||
@@ -11,10 +11,10 @@ type UseERC20BridgingFeeProps = {
|
||||
fromChain: Chain;
|
||||
toChain: Chain;
|
||||
nextMessageNumber: bigint;
|
||||
claimingType: "auto" | "manual";
|
||||
claimingType: ClaimType;
|
||||
};
|
||||
|
||||
const useERC20BridgingFee = ({
|
||||
const useERC20BridgingGasUsed = ({
|
||||
account,
|
||||
token,
|
||||
fromChain,
|
||||
@@ -48,9 +48,9 @@ const useERC20BridgingFee = ({
|
||||
!!nextMessageNumber &&
|
||||
!!amount &&
|
||||
!!recipient &&
|
||||
claimingType === "auto",
|
||||
(claimingType === ClaimType.AUTO_PAID || claimingType === ClaimType.AUTO_SPONSORED),
|
||||
queryFn: async () =>
|
||||
await estimateERC20GasFee({
|
||||
await estimateERC20BridgingGasUsed({
|
||||
address: account!,
|
||||
recipient,
|
||||
token,
|
||||
@@ -65,4 +65,4 @@ const useERC20BridgingFee = ({
|
||||
return { data, isLoading, isError, refetch };
|
||||
};
|
||||
|
||||
export default useERC20BridgingFee;
|
||||
export default useERC20BridgingGasUsed;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Address } from "viem";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { BridgeProvider, Chain, ChainLayer, Token } from "@/types";
|
||||
import { estimateEthGasFee, isEth } from "@/utils";
|
||||
import { BridgeProvider, Chain, ChainLayer, ClaimType, Token } from "@/types";
|
||||
import { estimateEthBridgingGasUsed, isEth } from "@/utils";
|
||||
|
||||
type UseEthBridgingFeeProps = {
|
||||
account?: Address;
|
||||
@@ -11,10 +11,10 @@ type UseEthBridgingFeeProps = {
|
||||
toChain: Chain;
|
||||
nextMessageNumber: bigint;
|
||||
token: Token;
|
||||
claimingType: "auto" | "manual";
|
||||
claimingType: ClaimType;
|
||||
};
|
||||
|
||||
const useEthBridgingFee = ({
|
||||
const useEthBridgingGasUsed = ({
|
||||
token,
|
||||
account,
|
||||
recipient,
|
||||
@@ -46,9 +46,9 @@ const useEthBridgingFee = ({
|
||||
!!nextMessageNumber &&
|
||||
!!amount &&
|
||||
!!recipient &&
|
||||
claimingType === "auto",
|
||||
(claimingType === ClaimType.AUTO_PAID || claimingType === ClaimType.AUTO_SPONSORED),
|
||||
queryFn: async () =>
|
||||
await estimateEthGasFee({
|
||||
await estimateEthBridgingGasUsed({
|
||||
address: account!,
|
||||
recipient,
|
||||
amount,
|
||||
@@ -62,4 +62,4 @@ const useEthBridgingFee = ({
|
||||
return { data, isLoading, isError, refetch };
|
||||
};
|
||||
|
||||
export default useEthBridgingFee;
|
||||
export default useEthBridgingGasUsed;
|
||||
@@ -6,6 +6,7 @@ import useMinimumFee from "./useMinimumFee";
|
||||
import useBridgingFee from "./useBridgingFee";
|
||||
import useTokenPrices from "../useTokenPrices";
|
||||
import { useFormStore, useChainStore } from "@/stores";
|
||||
import { ClaimType } from "@/types";
|
||||
import { isZero, isUndefined } from "@/utils";
|
||||
|
||||
const useFees = () => {
|
||||
@@ -57,7 +58,14 @@ const useFees = () => {
|
||||
fiatValue: getFiatValue(gasFeesResult.gasFees),
|
||||
});
|
||||
|
||||
if (claim === "auto" && bridgingFees) {
|
||||
if (claim === ClaimType.AUTO_SPONSORED) {
|
||||
feesArray.push({
|
||||
name: `${toChain.name} fee`,
|
||||
fee: 0n,
|
||||
fiatValue: null,
|
||||
});
|
||||
}
|
||||
if (claim === ClaimType.AUTO_PAID && bridgingFees) {
|
||||
feesArray.push({
|
||||
name: `${toChain.name} fee`,
|
||||
fee: bridgingFees,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { encodeFunctionData } from "viem";
|
||||
import { useFormStore, useChainStore } from "@/stores";
|
||||
import TokenBridge from "@/abis/TokenBridge.json";
|
||||
import { isEth, isNull, isUndefined, isUndefinedOrNull, isZero, isUndefinedOrEmptyString } from "@/utils";
|
||||
import { BridgeProvider, ChainLayer } from "@/types";
|
||||
import { BridgeProvider, ChainLayer, ClaimType } from "@/types";
|
||||
import { DEFAULT_ADDRESS_FOR_NON_CONNECTED_USER } from "@/constants";
|
||||
|
||||
type UseERC20BridgeTxArgsProps = {
|
||||
@@ -30,7 +30,7 @@ const useERC20BridgeTxArgs = ({ isConnected, allowance }: UseERC20BridgeTxArgsPr
|
||||
isUndefinedOrEmptyString(toAddress) ||
|
||||
(isZero(minimumFees) && fromChain.layer === ChainLayer.L2) ||
|
||||
(isUndefinedOrNull(bridgingFees) && fromChain.layer === ChainLayer.L1) ||
|
||||
(isZero(bridgingFees) && claim === "auto") ||
|
||||
(isZero(bridgingFees) && claim === ClaimType.AUTO_PAID) ||
|
||||
token.bridgeProvider !== BridgeProvider.NATIVE
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { encodeFunctionData } from "viem";
|
||||
import { useFormStore, useChainStore } from "@/stores";
|
||||
import MessageService from "@/abis/MessageService.json";
|
||||
import { isEth, isUndefinedOrNull, isZero, isUndefinedOrEmptyString } from "@/utils";
|
||||
import { BridgeProvider, ChainLayer } from "@/types";
|
||||
import { BridgeProvider, ChainLayer, ClaimType } from "@/types";
|
||||
import { DEFAULT_ADDRESS_FOR_NON_CONNECTED_USER } from "@/constants";
|
||||
|
||||
type UseEthBridgeTxArgsProps = {
|
||||
@@ -28,7 +28,7 @@ const useEthBridgeTxArgs = ({ isConnected }: UseEthBridgeTxArgsProps) => {
|
||||
isUndefinedOrEmptyString(toAddress) ||
|
||||
(isZero(minimumFees) && fromChain.layer === ChainLayer.L2) ||
|
||||
(isUndefinedOrNull(bridgingFees) && fromChain.layer === ChainLayer.L1) ||
|
||||
(isZero(bridgingFees) && claim === "auto") ||
|
||||
(isZero(bridgingFees) && claim === ClaimType.AUTO_PAID) ||
|
||||
!isEth(token) ||
|
||||
token.bridgeProvider !== BridgeProvider.NATIVE
|
||||
) {
|
||||
|
||||
@@ -9,8 +9,7 @@ import { isUndefinedOrEmptyString, isUndefined } 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],
|
||||
queryKey: ["useClaimingTx", transaction?.bridgingTx, transaction?.toChain?.id, transaction?.status],
|
||||
queryFn: async () => getClaimTx(transaction),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useReadContract } from "wagmi";
|
||||
import MessageService from "@/abis/MessageService.json";
|
||||
import { Chain, ChainLayer } from "@/types";
|
||||
import { Chain, ChainLayer, ClaimType } from "@/types";
|
||||
|
||||
type UseMessageNumberProps = {
|
||||
fromChain: Chain;
|
||||
claimingType: "auto" | "manual";
|
||||
claimingType: ClaimType;
|
||||
};
|
||||
|
||||
const useMessageNumber = ({ fromChain, claimingType }: UseMessageNumberProps) => {
|
||||
@@ -14,7 +14,7 @@ const useMessageNumber = ({ fromChain, claimingType }: UseMessageNumberProps) =>
|
||||
functionName: "nextMessageNumber",
|
||||
chainId: fromChain.id,
|
||||
query: {
|
||||
enabled: fromChain.layer === ChainLayer.L1 && claimingType === "auto",
|
||||
enabled: fromChain.layer === ChainLayer.L1 && claimingType === ClaimType.AUTO_PAID,
|
||||
},
|
||||
});
|
||||
return data as bigint;
|
||||
|
||||
@@ -2,15 +2,14 @@ import { Address } from "viem";
|
||||
import { defaultTokensConfig } from "./tokenStore";
|
||||
import { createWithEqualityFn } from "zustand/traditional";
|
||||
import { shallow } from "zustand/vanilla/shallow";
|
||||
import { Token } from "@/types";
|
||||
import { isCctp } from "@/utils";
|
||||
import { Token, ClaimType } from "@/types";
|
||||
|
||||
export type FormState = {
|
||||
token: Token;
|
||||
recipient: Address;
|
||||
amount: bigint | null;
|
||||
balance: bigint;
|
||||
claim: "auto" | "manual";
|
||||
claim: ClaimType;
|
||||
gasFees: bigint;
|
||||
bridgingFees: bigint;
|
||||
minimumFees: bigint;
|
||||
@@ -21,13 +20,11 @@ export type FormActions = {
|
||||
setRecipient: (recipient: Address) => void;
|
||||
setAmount: (amount: bigint) => void;
|
||||
setBalance: (balance: bigint) => void;
|
||||
setClaim: (claim: "auto" | "manual") => void;
|
||||
setClaim: (claim: ClaimType) => void;
|
||||
setGasFees: (gasFees: bigint) => void;
|
||||
setBridgingFees: (bridgingFees: bigint) => void;
|
||||
setMinimumFees: (minimumFees: bigint) => void;
|
||||
resetForm(): void;
|
||||
// Custom getter function
|
||||
isTokenCanonicalUSDC: () => boolean;
|
||||
};
|
||||
|
||||
export type FormStore = FormState & FormActions;
|
||||
@@ -37,22 +34,18 @@ export const defaultInitState: FormState = {
|
||||
amount: 0n,
|
||||
balance: 0n,
|
||||
recipient: "0x",
|
||||
claim: "auto",
|
||||
claim: ClaimType.AUTO_SPONSORED,
|
||||
gasFees: 0n,
|
||||
bridgingFees: 0n,
|
||||
minimumFees: 0n,
|
||||
};
|
||||
|
||||
export const createFormStore = (defaultValues?: FormState) =>
|
||||
createWithEqualityFn<FormStore>((set, get) => {
|
||||
createWithEqualityFn<FormStore>((set) => {
|
||||
return {
|
||||
...defaultInitState,
|
||||
...defaultValues,
|
||||
setToken: (token) => {
|
||||
set({ token });
|
||||
// No auto-claim for CCTP
|
||||
isCctp(token) ? set({ claim: "manual" }) : set({ claim: "auto" });
|
||||
},
|
||||
setToken: (token) => set({ token }),
|
||||
setRecipient: (recipient) => set({ recipient }),
|
||||
setAmount: (amount) => set({ amount }),
|
||||
setBalance: (balance) => set({ balance }),
|
||||
@@ -61,7 +54,5 @@ export const createFormStore = (defaultValues?: FormState) =>
|
||||
setBridgingFees: (bridgingFees) => set({ bridgingFees }),
|
||||
setMinimumFees: (minimumFees) => set({ minimumFees }),
|
||||
resetForm: () => set(defaultInitState),
|
||||
// Custom getter function
|
||||
isTokenCanonicalUSDC: () => isCctp(get().token),
|
||||
};
|
||||
}, shallow);
|
||||
|
||||
@@ -28,6 +28,15 @@ export enum BridgeTransactionType {
|
||||
USDC = "USDC",
|
||||
}
|
||||
|
||||
export enum ClaimType {
|
||||
// Only for L1 -> L2, sponsored by the Postman
|
||||
AUTO_SPONSORED = "AUTO_SPONSORED",
|
||||
// Only for L1 -> L2, practically this will only be available when the L2 token contract does not exist (costing ~460K gas to claimMessage on L2).
|
||||
AUTO_PAID = "AUTO_PAID",
|
||||
// L2 -> L1 must be MANUAL
|
||||
MANUAL = "MANUAL",
|
||||
}
|
||||
|
||||
// BridgeTransaction object that is populated when user opens "TransactionHistory" component, and is passed to child components.
|
||||
export interface BridgeTransaction {
|
||||
type: BridgeTransactionType;
|
||||
|
||||
@@ -26,4 +26,5 @@ export {
|
||||
type CctpV2BridgeMessage,
|
||||
type BridgeTransaction,
|
||||
BridgeTransactionType,
|
||||
ClaimType,
|
||||
} from "./bridge";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getPublicClient } from "@wagmi/core";
|
||||
import TokenBridge from "@/abis/TokenBridge.json";
|
||||
import MessageService from "@/abis/MessageService.json";
|
||||
import { computeMessageHash, computeMessageStorageSlot } from "./message";
|
||||
import { Chain, Token } from "@/types";
|
||||
import { Chain, ClaimType, Token } from "@/types";
|
||||
import { config } from "@/lib/wagmi";
|
||||
import { isUndefined } from "@/utils";
|
||||
|
||||
@@ -14,7 +14,7 @@ interface EstimationParams {
|
||||
nextMessageNumber: bigint;
|
||||
fromChain: Chain;
|
||||
toChain: Chain;
|
||||
claimingType: "auto" | "manual";
|
||||
claimingType: ClaimType;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,7 @@ async function prepareERC20TokenParams(
|
||||
/**
|
||||
* Generic helper to call gas estimation.
|
||||
*/
|
||||
async function estimateGasFee(
|
||||
async function estimateClaimMessageGasUsed(
|
||||
publicClient: ReturnType<typeof getPublicClient>,
|
||||
contractAddress: Address,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -99,7 +99,7 @@ async function estimateGasFee(
|
||||
/**
|
||||
* Estimates the gas fee for bridging an ERC20 token.
|
||||
*/
|
||||
export async function estimateERC20GasFee({
|
||||
export async function estimateERC20BridgingGasUsed({
|
||||
address,
|
||||
recipient,
|
||||
amount,
|
||||
@@ -109,7 +109,7 @@ export async function estimateERC20GasFee({
|
||||
token,
|
||||
claimingType,
|
||||
}: EstimationParams & { token: Token }): Promise<bigint> {
|
||||
if (claimingType === "manual") return 0n;
|
||||
if (claimingType === ClaimType.MANUAL) return 0n;
|
||||
|
||||
const destinationChainPublicClient = getPublicClient(config, {
|
||||
chainId: toChain.id,
|
||||
@@ -157,13 +157,19 @@ export async function estimateERC20GasFee({
|
||||
nextMessageNumber,
|
||||
];
|
||||
|
||||
return estimateGasFee(destinationChainPublicClient, toChain.messageServiceAddress, argsArray, stateOverride, address);
|
||||
return estimateClaimMessageGasUsed(
|
||||
destinationChainPublicClient,
|
||||
toChain.messageServiceAddress,
|
||||
argsArray,
|
||||
stateOverride,
|
||||
address,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the gas fee for bridging ETH.
|
||||
*/
|
||||
export async function estimateEthGasFee({
|
||||
export async function estimateEthBridgingGasUsed({
|
||||
address,
|
||||
recipient,
|
||||
amount,
|
||||
@@ -171,7 +177,7 @@ export async function estimateEthGasFee({
|
||||
toChain,
|
||||
claimingType,
|
||||
}: EstimationParams): Promise<bigint> {
|
||||
if (claimingType === "manual") return 0n;
|
||||
if (claimingType === ClaimType.MANUAL) return 0n;
|
||||
|
||||
const destinationChainPublicClient = getPublicClient(config, {
|
||||
chainId: toChain.id,
|
||||
@@ -185,5 +191,11 @@ export async function estimateEthGasFee({
|
||||
|
||||
const argsArray = [address, recipient, 0n, amount, zeroAddress, "0x", nextMessageNumber];
|
||||
|
||||
return estimateGasFee(destinationChainPublicClient, toChain.messageServiceAddress, argsArray, stateOverride, address);
|
||||
return estimateClaimMessageGasUsed(
|
||||
destinationChainPublicClient,
|
||||
toChain.messageServiceAddress,
|
||||
argsArray,
|
||||
stateOverride,
|
||||
address,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { getNativeBridgeMessageClaimedTxHash } from "./events";
|
||||
export { generateChain, generateChains, getChainLogoPath, getChainNetworkLayer } from "./chains";
|
||||
export { estimateEthGasFee, estimateERC20GasFee } from "./fees";
|
||||
export { estimateEthBridgingGasUsed, estimateERC20BridgingGasUsed } from "./fees";
|
||||
export { formatAddress, formatBalance, formatHex, formatTimestamp, safeGetAddress } from "./format";
|
||||
export { fetchTransactionsHistory } from "./history";
|
||||
export * from "./message";
|
||||
|
||||
@@ -24,6 +24,7 @@ export const test = metaMaskFixtures(setup).extend<{
|
||||
selectTokenAndInputAmount: (tokenSymbol: string, amount: string) => Promise<void>;
|
||||
waitForNewTxAdditionToTxList: (txCountBeforeUpdate: number) => Promise<void>;
|
||||
waitForTxListUpdateForClaimTx: (claimTxCountBeforeUpdate: number) => Promise<void>;
|
||||
openGasFeeModal: () => Promise<void>;
|
||||
|
||||
// Metamask Actions - Should be ok to reuse within other fixture functions
|
||||
connectMetamaskToDapp: () => Promise<void>;
|
||||
@@ -129,6 +130,14 @@ export const test = metaMaskFixtures(setup).extend<{
|
||||
} while (!listUpdated && tryCount < maxTries);
|
||||
});
|
||||
},
|
||||
openGasFeeModal: async ({ page }, use) => {
|
||||
await use(async () => {
|
||||
const gasFeeBtn = page.getByRole("button", { name: "fee-chain-icon" });
|
||||
// bridge-ui-known-flaky-line - Unsure why, the gas fees may not load within 5s
|
||||
await expect(gasFeeBtn).not.toContainText("0.00000000");
|
||||
await gasFeeBtn.click();
|
||||
});
|
||||
},
|
||||
|
||||
// Metamask Actions - Should be ok to reuse within other fixture functions
|
||||
connectMetamaskToDapp: async ({ page, metamask }, use) => {
|
||||
|
||||
@@ -110,6 +110,91 @@ describe("L1 > L2 via Native Bridge", () => {
|
||||
await expect(approvalButton).toBeVisible();
|
||||
await expect(approvalButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("should see Free gas fees for ETH transfer to L2", async ({
|
||||
page,
|
||||
connectMetamaskToDapp,
|
||||
clickNativeBridgeButton,
|
||||
openNativeBridgeFormSettings,
|
||||
toggleShowTestNetworksInNativeBridgeForm,
|
||||
selectTokenAndInputAmount,
|
||||
openGasFeeModal,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await connectMetamaskToDapp();
|
||||
await clickNativeBridgeButton();
|
||||
await openNativeBridgeFormSettings();
|
||||
await toggleShowTestNetworksInNativeBridgeForm();
|
||||
|
||||
await selectTokenAndInputAmount(ETH_SYMBOL, WEI_AMOUNT);
|
||||
await openGasFeeModal();
|
||||
|
||||
// Assert text items
|
||||
const lineaSepoliaFeeText = page.getByText("Linea Sepolia fee");
|
||||
const freeText = page.getByText("Free");
|
||||
await expect(lineaSepoliaFeeText).toBeVisible();
|
||||
await expect(freeText).toBeVisible();
|
||||
const listItem = page
|
||||
.locator("li")
|
||||
.filter({
|
||||
has: lineaSepoliaFeeText,
|
||||
})
|
||||
.filter({
|
||||
has: freeText,
|
||||
});
|
||||
await expect(listItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("should not see Free gas fees for USDC transfer to L2", async ({
|
||||
page,
|
||||
connectMetamaskToDapp,
|
||||
clickNativeBridgeButton,
|
||||
openNativeBridgeFormSettings,
|
||||
toggleShowTestNetworksInNativeBridgeForm,
|
||||
selectTokenAndInputAmount,
|
||||
openGasFeeModal,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await connectMetamaskToDapp();
|
||||
await clickNativeBridgeButton();
|
||||
await openNativeBridgeFormSettings();
|
||||
await toggleShowTestNetworksInNativeBridgeForm();
|
||||
|
||||
await selectTokenAndInputAmount(USDC_SYMBOL, USDC_AMOUNT);
|
||||
await openGasFeeModal();
|
||||
|
||||
// Assert text items
|
||||
const freeText = page.getByText("Free");
|
||||
await expect(freeText).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("should not see Free gas fees for ETH transfer to L1", async ({
|
||||
page,
|
||||
connectMetamaskToDapp,
|
||||
clickNativeBridgeButton,
|
||||
openNativeBridgeFormSettings,
|
||||
toggleShowTestNetworksInNativeBridgeForm,
|
||||
selectTokenAndInputAmount,
|
||||
openGasFeeModal,
|
||||
switchToLineaSepolia,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await connectMetamaskToDapp();
|
||||
await clickNativeBridgeButton();
|
||||
await openNativeBridgeFormSettings();
|
||||
await toggleShowTestNetworksInNativeBridgeForm();
|
||||
|
||||
await switchToLineaSepolia();
|
||||
await selectTokenAndInputAmount(ETH_SYMBOL, WEI_AMOUNT);
|
||||
await openGasFeeModal();
|
||||
|
||||
// Assert text items
|
||||
const freeText = page.getByText("Free");
|
||||
await expect(freeText).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Blockchain tx cases", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function selectTokenAndWaitForBalance(tokenSymbol: string, page: Pa
|
||||
|
||||
// Timeout implementation
|
||||
let fetchTokenTimeUsed = 0;
|
||||
// bridge-ui-known-flaky-line - Sometimes the RPC call to get ETH/ERC20 balance fails
|
||||
while ((await tokenBalance.textContent()) === `0 ${tokenSymbol}`) {
|
||||
if (fetchTokenTimeUsed >= PAGE_TIMEOUT)
|
||||
throw `Could not find any balance for ${tokenSymbol}, does the testing wallet have funds?`;
|
||||
|
||||
@@ -38,4 +38,7 @@ POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=postman_db
|
||||
DB_CLEANER_ENABLED=false
|
||||
ENABLE_LINEA_ESTIMATE_GAS=false
|
||||
ENABLE_LINEA_ESTIMATE_GAS=false
|
||||
L1_L2_ENABLE_POSTMAN_SPONSORING=true
|
||||
L2_L1_ENABLE_POSTMAN_SPONSORING=false
|
||||
MAX_POSTMAN_SPONSOR_GAS_LIMIT=250000
|
||||
@@ -10,17 +10,18 @@ async function sendL1ToL2Message(
|
||||
{
|
||||
l1Account,
|
||||
l2Account,
|
||||
fee = 0n,
|
||||
withCalldata = false,
|
||||
}: {
|
||||
l1Account: Wallet;
|
||||
l2Account: Wallet;
|
||||
fee: bigint;
|
||||
withCalldata: boolean;
|
||||
},
|
||||
) {
|
||||
const dummyContract = config.getL2DummyContract(l2Account);
|
||||
const lineaRollup = config.getLineaRollupContract(l1Account);
|
||||
|
||||
const valueAndFee = etherToWei("1.1");
|
||||
const calldata = withCalldata
|
||||
? encodeFunctionCall(dummyContract.interface, "setPayload", [ethers.randomBytes(100)])
|
||||
: "0x";
|
||||
@@ -36,8 +37,8 @@ async function sendL1ToL2Message(
|
||||
const nonce = await l1Provider.getTransactionCount(l1Account.address, "pending");
|
||||
logger.debug(`Fetched nonce. nonce=${nonce} account=${l1Account.address}`);
|
||||
|
||||
const tx = await lineaRollup.sendMessage(destinationAddress, valueAndFee, calldata, {
|
||||
value: valueAndFee,
|
||||
const tx = await lineaRollup.sendMessage(destinationAddress, fee, calldata, {
|
||||
value: fee,
|
||||
nonce,
|
||||
maxPriorityFeePerGas,
|
||||
maxFeePerGas,
|
||||
@@ -61,10 +62,12 @@ async function sendL2ToL1Message(
|
||||
{
|
||||
l1Account,
|
||||
l2Account,
|
||||
fee = 0n,
|
||||
withCalldata = false,
|
||||
}: {
|
||||
l1Account: Wallet;
|
||||
l2Account: Wallet;
|
||||
fee: bigint;
|
||||
withCalldata: boolean;
|
||||
},
|
||||
) {
|
||||
@@ -73,7 +76,6 @@ async function sendL2ToL1Message(
|
||||
const l2MessageService = config.getL2MessageServiceContract(l2Account);
|
||||
const lineaEstimateGasClient = new LineaEstimateGasClient(config.getL2BesuNodeEndpoint()!);
|
||||
|
||||
const valueAndFee = etherToWei("0.001");
|
||||
const calldata = withCalldata
|
||||
? encodeFunctionCall(dummyContract.interface, "setPayload", [ethers.randomBytes(100)])
|
||||
: "0x";
|
||||
@@ -85,13 +87,13 @@ async function sendL2ToL1Message(
|
||||
const { maxPriorityFeePerGas, maxFeePerGas, gasLimit } = await lineaEstimateGasClient.lineaEstimateGas(
|
||||
l2Account.address,
|
||||
await l2MessageService.getAddress(),
|
||||
l2MessageService.interface.encodeFunctionData("sendMessage", [destinationAddress, valueAndFee, calldata]),
|
||||
l2MessageService.interface.encodeFunctionData("sendMessage", [destinationAddress, fee, calldata]),
|
||||
etherToWei("0.001").toString(16),
|
||||
);
|
||||
logger.debug(`Fetched fee data. maxPriorityFeePerGas=${maxPriorityFeePerGas} maxFeePerGas=${maxFeePerGas}`);
|
||||
|
||||
const tx = await l2MessageService.sendMessage(destinationAddress, valueAndFee, calldata, {
|
||||
value: valueAndFee,
|
||||
const tx = await l2MessageService.sendMessage(destinationAddress, fee, calldata, {
|
||||
value: fee,
|
||||
nonce,
|
||||
maxPriorityFeePerGas,
|
||||
maxFeePerGas,
|
||||
@@ -117,14 +119,19 @@ const l2AccountManager = config.getL2AccountManager();
|
||||
|
||||
describe("Messaging test suite", () => {
|
||||
it.concurrent(
|
||||
"Should send a transaction with calldata to L1 message service, be successfully claimed it on L2",
|
||||
"Should send a transaction with fee and calldata to L1 message service, be successfully claimed it on L2",
|
||||
async () => {
|
||||
const [l1Account, l2Account] = await Promise.all([
|
||||
l1AccountManager.generateAccount(),
|
||||
l2AccountManager.generateAccount(),
|
||||
]);
|
||||
|
||||
const { tx, receipt } = await sendL1ToL2Message(logger, { l1Account, l2Account, withCalldata: true });
|
||||
const { tx, receipt } = await sendL1ToL2Message(logger, {
|
||||
l1Account,
|
||||
l2Account,
|
||||
fee: etherToWei("1.1"),
|
||||
withCalldata: true,
|
||||
});
|
||||
|
||||
const [messageSentEvent] = receipt.logs.filter((log) => log.topics[0] === MESSAGE_SENT_EVENT_SIGNATURE);
|
||||
const messageHash = messageSentEvent.topics[3];
|
||||
@@ -146,14 +153,53 @@ describe("Messaging test suite", () => {
|
||||
);
|
||||
|
||||
it.concurrent(
|
||||
"Should send a transaction without calldata to L1 message service, be successfully claimed it on L2",
|
||||
"Should send a transaction with fee and without calldata to L1 message service, be successfully claimed it on L2",
|
||||
async () => {
|
||||
const [l1Account, l2Account] = await Promise.all([
|
||||
l1AccountManager.generateAccount(),
|
||||
l2AccountManager.generateAccount(),
|
||||
]);
|
||||
|
||||
const { tx, receipt } = await sendL1ToL2Message(logger, { l1Account, l2Account, withCalldata: false });
|
||||
const { tx, receipt } = await sendL1ToL2Message(logger, {
|
||||
l1Account,
|
||||
l2Account,
|
||||
fee: etherToWei("1.1"),
|
||||
withCalldata: false,
|
||||
});
|
||||
|
||||
const [messageSentEvent] = receipt.logs.filter((log) => log.topics[0] === MESSAGE_SENT_EVENT_SIGNATURE);
|
||||
const messageHash = messageSentEvent.topics[3];
|
||||
logger.debug(`L1 message sent. messageHash=${messageHash} transactionHash=${tx.hash}`);
|
||||
|
||||
logger.debug(`Waiting for MessageClaimed event on L2. messageHash=${messageHash}`);
|
||||
const l2MessageService = config.getL2MessageServiceContract();
|
||||
const [messageClaimedEvent] = await waitForEvents(
|
||||
l2MessageService,
|
||||
l2MessageService.filters.MessageClaimed(messageHash),
|
||||
);
|
||||
expect(messageClaimedEvent).toBeDefined();
|
||||
logger.debug(
|
||||
`Message claimed on L2. messageHash=${messageClaimedEvent.args._messageHash} transactionHash=${messageClaimedEvent.transactionHash}`,
|
||||
);
|
||||
},
|
||||
100_000,
|
||||
);
|
||||
|
||||
// Test that Postman sponsoring works for L1->L2
|
||||
it.concurrent(
|
||||
"Should send a transaction without fee and without calldata to L1 message service, be successfully claimed it on L2",
|
||||
async () => {
|
||||
const [l1Account, l2Account] = await Promise.all([
|
||||
l1AccountManager.generateAccount(),
|
||||
l2AccountManager.generateAccount(),
|
||||
]);
|
||||
|
||||
const { tx, receipt } = await sendL1ToL2Message(logger, {
|
||||
l1Account,
|
||||
l2Account,
|
||||
fee: 0n,
|
||||
withCalldata: false,
|
||||
});
|
||||
|
||||
const [messageSentEvent] = receipt.logs.filter((log) => log.topics[0] === MESSAGE_SENT_EVENT_SIGNATURE);
|
||||
const messageHash = messageSentEvent.topics[3];
|
||||
@@ -174,7 +220,7 @@ describe("Messaging test suite", () => {
|
||||
);
|
||||
|
||||
it.concurrent(
|
||||
"Should send a transaction with calldata to L2 message service, be successfully claimed it on L1",
|
||||
"Should send a transaction with with fee and calldata to L2 message service, be successfully claimed it on L1",
|
||||
async () => {
|
||||
const [l1Account, l2Account] = await Promise.all([
|
||||
l1AccountManager.generateAccount(),
|
||||
@@ -182,7 +228,12 @@ describe("Messaging test suite", () => {
|
||||
]);
|
||||
|
||||
const lineaRollup = config.getLineaRollupContract();
|
||||
const { tx, receipt } = await sendL2ToL1Message(logger, { l1Account, l2Account, withCalldata: true });
|
||||
const { tx, receipt } = await sendL2ToL1Message(logger, {
|
||||
l1Account,
|
||||
l2Account,
|
||||
fee: etherToWei("0.001"),
|
||||
withCalldata: true,
|
||||
});
|
||||
|
||||
const [messageSentEvent] = receipt.logs.filter((log) => log.topics[0] === MESSAGE_SENT_EVENT_SIGNATURE);
|
||||
const messageHash = messageSentEvent.topics[3];
|
||||
@@ -211,7 +262,7 @@ describe("Messaging test suite", () => {
|
||||
);
|
||||
|
||||
it.concurrent(
|
||||
"Should send a transaction without calldata to L2 message service, be successfully claimed it on L1",
|
||||
"Should send a transaction with fee and without calldata to L2 message service, be successfully claimed it on L1",
|
||||
async () => {
|
||||
const [l1Account, l2Account] = await Promise.all([
|
||||
l1AccountManager.generateAccount(),
|
||||
@@ -219,7 +270,12 @@ describe("Messaging test suite", () => {
|
||||
]);
|
||||
|
||||
const lineaRollup = config.getLineaRollupContract();
|
||||
const { tx, receipt } = await sendL2ToL1Message(logger, { l1Account, l2Account, withCalldata: false });
|
||||
const { tx, receipt } = await sendL2ToL1Message(logger, {
|
||||
l1Account,
|
||||
l2Account,
|
||||
fee: etherToWei("0.001"),
|
||||
withCalldata: false,
|
||||
});
|
||||
|
||||
const [messageSentEvent] = receipt.logs.filter((log) => log.topics[0] === MESSAGE_SENT_EVENT_SIGNATURE);
|
||||
const messageHash = messageSentEvent.topics[3];
|
||||
|
||||
@@ -42,6 +42,9 @@ DB_CLEANER_ENABLED=false
|
||||
DB_CLEANING_INTERVAL=10000
|
||||
DB_DAYS_BEFORE_NOW_TO_DELETE=1
|
||||
ENABLE_LINEA_ESTIMATE_GAS=false
|
||||
L1_L2_ENABLE_POSTMAN_SPONSORING=true
|
||||
L2_L1_ENABLE_POSTMAN_SPONSORING=false
|
||||
MAX_POSTMAN_SPONSOR_GAS_LIMIT=250000
|
||||
|
||||
# Optional event filter params
|
||||
L1_EVENT_FILTER_FROM_ADDRESS=<FROM_ADDRESS>
|
||||
|
||||
@@ -70,6 +70,7 @@ All messages are stored in a configurable Postgres DB.
|
||||
- `MAX_NUMBER_OF_RETRIES`: Maximum retry attempts
|
||||
- `RETRY_DELAY_IN_SECONDS`: Delay between retries
|
||||
- `MAX_CLAIM_GAS_LIMIT`: Maximum gas limit for claim transactions
|
||||
- `MAX_POSTMAN_SPONSOR_GAS_LIMIT`: Maximum gas limit for sponsored Postman claim transactions
|
||||
|
||||
#### Feature Flags
|
||||
- `L1_L2_EOA_ENABLED`: Enable L1->L2 EOA messages
|
||||
@@ -80,6 +81,8 @@ All messages are stored in a configurable Postgres DB.
|
||||
- `L2_L1_AUTO_CLAIM_ENABLED`: Enable auto-claiming for L2->L1 messages
|
||||
- `ENABLE_LINEA_ESTIMATE_GAS`: Enable `linea_estimateGas`endpoint usage for L2 chain gas fees estimation
|
||||
- `DB_CLEANER_ENABLED`: Enable DB cleaning to delete old claimed messages
|
||||
- `L1_L2_ENABLE_POSTMAN_SPONSORING`: Enable L1->L2 Postman sponsoring for claiming messages
|
||||
- `L2_L1_ENABLE_POSTMAN_SPONSORING`: Enable L2->L1 Postman sponsoring for claiming messages
|
||||
|
||||
#### DB cleaning
|
||||
- `DB_CLEANING_INTERVAL`: DB cleaning polling interval (ms)
|
||||
|
||||
@@ -62,6 +62,10 @@ async function main() {
|
||||
maxClaimGasLimit: process.env.MAX_CLAIM_GAS_LIMIT ? BigInt(process.env.MAX_CLAIM_GAS_LIMIT) : undefined,
|
||||
maxTxRetries: process.env.MAX_TX_RETRIES ? parseInt(process.env.MAX_TX_RETRIES) : undefined,
|
||||
isMaxGasFeeEnforced: process.env.L1_MAX_GAS_FEE_ENFORCED === "true",
|
||||
isPostmanSponsorshipEnabled: process.env.L2_L1_ENABLE_POSTMAN_SPONSORING === "true",
|
||||
maxPostmanSponsorGasLimit: process.env.MAX_POSTMAN_SPONSOR_GAS_LIMIT
|
||||
? BigInt(process.env.MAX_POSTMAN_SPONSOR_GAS_LIMIT)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
l2Options: {
|
||||
@@ -120,6 +124,10 @@ async function main() {
|
||||
maxClaimGasLimit: process.env.MAX_CLAIM_GAS_LIMIT ? BigInt(process.env.MAX_CLAIM_GAS_LIMIT) : undefined,
|
||||
maxTxRetries: process.env.MAX_TX_RETRIES ? parseInt(process.env.MAX_TX_RETRIES) : undefined,
|
||||
isMaxGasFeeEnforced: process.env.L2_MAX_GAS_FEE_ENFORCED === "true",
|
||||
isPostmanSponsorshipEnabled: process.env.L1_L2_ENABLE_POSTMAN_SPONSORING === "true",
|
||||
maxPostmanSponsorGasLimit: process.env.MAX_POSTMAN_SPONSOR_GAS_LIMIT
|
||||
? BigInt(process.env.MAX_POSTMAN_SPONSOR_GAS_LIMIT)
|
||||
: undefined,
|
||||
},
|
||||
l2MessageTreeDepth: process.env.L2_MESSAGE_TREE_DEPTH ? parseInt(process.env.L2_MESSAGE_TREE_DEPTH) : undefined,
|
||||
enableLineaEstimateGas: process.env.ENABLE_LINEA_ESTIMATE_GAS === "true",
|
||||
|
||||
@@ -105,7 +105,7 @@ export class PostmanServiceClient {
|
||||
this.db = DB.create(config.databaseOptions);
|
||||
|
||||
const messageRepository = new TypeOrmMessageRepository(this.db);
|
||||
const lineaMessageDBService = new LineaMessageDBService(l2Provider, messageRepository);
|
||||
const lineaMessageDBService = new LineaMessageDBService(messageRepository);
|
||||
const ethereumMessageDBService = new EthereumMessageDBService(l1GasProvider, messageRepository);
|
||||
|
||||
// L1 -> L2 flow
|
||||
@@ -162,6 +162,8 @@ export class PostmanServiceClient {
|
||||
{
|
||||
profitMargin: config.l2Config.claiming.profitMargin,
|
||||
maxClaimGasLimit: BigInt(config.l2Config.claiming.maxClaimGasLimit),
|
||||
isPostmanSponsorshipEnabled: config.l2Config.claiming.isPostmanSponsorshipEnabled,
|
||||
maxPostmanSponsorGasLimit: config.l2Config.claiming.maxPostmanSponsorGasLimit,
|
||||
},
|
||||
l2Provider,
|
||||
l2MessageServiceClient,
|
||||
@@ -287,6 +289,8 @@ export class PostmanServiceClient {
|
||||
const l1TransactionValidationService = new EthereumTransactionValidationService(lineaRollupClient, l1GasProvider, {
|
||||
profitMargin: config.l1Config.claiming.profitMargin,
|
||||
maxClaimGasLimit: BigInt(config.l1Config.claiming.maxClaimGasLimit),
|
||||
isPostmanSponsorshipEnabled: config.l1Config.claiming.isPostmanSponsorshipEnabled,
|
||||
maxPostmanSponsorGasLimit: config.l1Config.claiming.maxPostmanSponsorGasLimit,
|
||||
});
|
||||
|
||||
const l1MessageClaimingProcessor = new MessageClaimingProcessor(
|
||||
@@ -296,13 +300,13 @@ export class PostmanServiceClient {
|
||||
l1TransactionValidationService,
|
||||
{
|
||||
direction: Direction.L2_TO_L1,
|
||||
originContractAddress: config.l2Config.messageServiceContractAddress,
|
||||
maxNonceDiff: config.l1Config.claiming.maxNonceDiff,
|
||||
feeRecipientAddress: config.l1Config.claiming.feeRecipientAddress,
|
||||
profitMargin: config.l1Config.claiming.profitMargin,
|
||||
maxNumberOfRetries: config.l1Config.claiming.maxNumberOfRetries,
|
||||
retryDelayInSeconds: config.l1Config.claiming.retryDelayInSeconds,
|
||||
maxClaimGasLimit: BigInt(config.l1Config.claiming.maxClaimGasLimit),
|
||||
originContractAddress: config.l2Config.messageServiceContractAddress,
|
||||
},
|
||||
new WinstonLogger(`L1${MessageClaimingProcessor.name}`, config.loggerOptions),
|
||||
);
|
||||
|
||||
@@ -59,6 +59,8 @@ const postmanServiceClientOptions: PostmanOptions = {
|
||||
profitMargin: 1.0,
|
||||
maxNumberOfRetries: 100,
|
||||
retryDelayInSeconds: 30,
|
||||
isPostmanSponsorshipEnabled: false,
|
||||
maxPostmanSponsorGasLimit: 250000n,
|
||||
},
|
||||
},
|
||||
l2Options: {
|
||||
@@ -81,6 +83,8 @@ const postmanServiceClientOptions: PostmanOptions = {
|
||||
maxNumberOfRetries: 100,
|
||||
retryDelayInSeconds: 30,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
isPostmanSponsorshipEnabled: false,
|
||||
maxPostmanSponsorGasLimit: 250000n,
|
||||
},
|
||||
},
|
||||
l1L2AutoClaimEnabled: true,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DEFAULT_DB_CLEANER_ENABLED,
|
||||
DEFAULT_DB_CLEANING_INTERVAL,
|
||||
DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
|
||||
DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
DEFAULT_ENFORCE_MAX_GAS_FEE,
|
||||
DEFAULT_EOA_ENABLED,
|
||||
DEFAULT_GAS_ESTIMATION_PERCENTILE,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
|
||||
DEFAULT_MAX_NONCE_DIFF,
|
||||
DEFAULT_MAX_NUMBER_OF_RETRIES,
|
||||
DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
DEFAULT_MAX_TX_RETRIES,
|
||||
DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
|
||||
DEFAULT_PROFIT_MARGIN,
|
||||
@@ -82,6 +84,8 @@ describe("Config utils", () => {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
signerPrivateKey: TEST_L1_SIGNER_PRIVATE_KEY,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
|
||||
isEOAEnabled: DEFAULT_EOA_ENABLED,
|
||||
@@ -110,6 +114,8 @@ describe("Config utils", () => {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
signerPrivateKey: TEST_L2_SIGNER_PRIVATE_KEY,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
enableLineaEstimateGas: false,
|
||||
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
|
||||
@@ -187,6 +193,8 @@ describe("Config utils", () => {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
signerPrivateKey: TEST_L1_SIGNER_PRIVATE_KEY,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
|
||||
isEOAEnabled: DEFAULT_EOA_ENABLED,
|
||||
@@ -215,6 +223,8 @@ describe("Config utils", () => {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
signerPrivateKey: TEST_L2_SIGNER_PRIVATE_KEY,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
enableLineaEstimateGas: true,
|
||||
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
|
||||
|
||||
@@ -84,6 +84,8 @@ export type ClaimingOptions = {
|
||||
retryDelayInSeconds?: number;
|
||||
maxClaimGasLimit?: bigint;
|
||||
maxTxRetries?: number;
|
||||
isPostmanSponsorshipEnabled?: boolean;
|
||||
maxPostmanSponsorGasLimit?: bigint;
|
||||
};
|
||||
|
||||
export type ClaimingConfig = Omit<Required<ClaimingOptions>, "feeRecipientAddress"> & {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Interface, isAddress } from "ethers";
|
||||
import { compileExpression, useDotAccessOperator } from "filtrex";
|
||||
import {
|
||||
DEFAULT_CALLDATA_ENABLED,
|
||||
DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
DEFAULT_EOA_ENABLED,
|
||||
DEFAULT_GAS_ESTIMATION_PERCENTILE,
|
||||
DEFAULT_INITIAL_FROM_BLOCK,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
|
||||
DEFAULT_MAX_NONCE_DIFF,
|
||||
DEFAULT_MAX_NUMBER_OF_RETRIES,
|
||||
DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
DEFAULT_MAX_TX_RETRIES,
|
||||
DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
|
||||
DEFAULT_PROFIT_MARGIN,
|
||||
@@ -73,6 +75,10 @@ export function getConfig(postmanOptions: PostmanOptions): PostmanConfig {
|
||||
retryDelayInSeconds: l1Options.claiming.retryDelayInSeconds ?? DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
maxClaimGasLimit: l1Options.claiming.maxClaimGasLimit ?? DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
maxTxRetries: l1Options.claiming.maxTxRetries ?? DEFAULT_MAX_TX_RETRIES,
|
||||
isPostmanSponsorshipEnabled:
|
||||
l1Options.claiming.isPostmanSponsorshipEnabled ?? DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit:
|
||||
l1Options.claiming.maxPostmanSponsorGasLimit ?? DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
},
|
||||
l2Config: {
|
||||
@@ -103,6 +109,10 @@ export function getConfig(postmanOptions: PostmanOptions): PostmanConfig {
|
||||
retryDelayInSeconds: l2Options.claiming.retryDelayInSeconds ?? DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
maxClaimGasLimit: l2Options.claiming.maxClaimGasLimit ?? DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
maxTxRetries: l2Options.claiming.maxTxRetries ?? DEFAULT_MAX_TX_RETRIES,
|
||||
isPostmanSponsorshipEnabled:
|
||||
l1Options.claiming.isPostmanSponsorshipEnabled ?? DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit:
|
||||
l1Options.claiming.maxPostmanSponsorGasLimit ?? DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
},
|
||||
l1L2AutoClaimEnabled,
|
||||
|
||||
@@ -153,11 +153,6 @@ export class TypeOrmMessageRepository<TransactionResponse extends ContractTransa
|
||||
messageStatuses: MessageStatus[],
|
||||
maxRetry: number,
|
||||
retryDelay: number,
|
||||
feeEstimationOptions: {
|
||||
minimumMargin: number;
|
||||
extraDataVariableCost: number;
|
||||
extraDataFixedCost: number;
|
||||
},
|
||||
): Promise<Message | null> {
|
||||
try {
|
||||
const message = await this.createQueryBuilder("message")
|
||||
@@ -174,15 +169,7 @@ export class TypeOrmMessageRepository<TransactionResponse extends ContractTransa
|
||||
});
|
||||
}),
|
||||
)
|
||||
.andWhere(
|
||||
"CAST(message.fee AS numeric) > :minimumMargin * ((:extraDataVariableCost * message.compressedTransactionSize) / message.claimTxGasLimit + :extraDataFixedCost) * message.claimTxGasLimit",
|
||||
{
|
||||
minimumMargin: feeEstimationOptions.minimumMargin,
|
||||
extraDataVariableCost: feeEstimationOptions.extraDataVariableCost,
|
||||
extraDataFixedCost: feeEstimationOptions.extraDataFixedCost,
|
||||
},
|
||||
)
|
||||
.orderBy("CAST(message.status as CHAR)", "ASC")
|
||||
.orderBy("CAST(message.status as CHAR)", "DESC")
|
||||
.addOrderBy("CAST(message.fee AS numeric)", "DESC")
|
||||
.addOrderBy("message.sentBlockNumber", "ASC")
|
||||
.getOne();
|
||||
|
||||
@@ -17,9 +17,11 @@ export const DEFAULT_RETRY_DELAY_IN_SECONDS = 30;
|
||||
export const DEFAULT_EOA_ENABLED = false;
|
||||
export const DEFAULT_CALLDATA_ENABLED = false;
|
||||
export const DEFAULT_RATE_LIMIT_MARGIN = 0.95;
|
||||
export const DEFAULT_MAX_CLAIM_GAS_LIMIT = 100_000n;
|
||||
export const DEFAULT_MAX_CLAIM_GAS_LIMIT = 500_000n;
|
||||
export const DEFAULT_MAX_TX_RETRIES = 20;
|
||||
export const DEFAULT_L2_MESSAGE_TREE_DEPTH = 5;
|
||||
export const DEFAULT_INITIAL_FROM_BLOCK = -1;
|
||||
export const DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT = 250_000n; // Should be < DEFAULT_MAX_CLAIM_GAS_LIMIT
|
||||
export const DEFAULT_ENABLE_POSTMAN_SPONSORING = false;
|
||||
|
||||
export const PROFIT_MARGIN_MULTIPLIER = 100;
|
||||
|
||||
@@ -22,11 +22,6 @@ export interface IMessageRepository<ContractTransactionResponse> {
|
||||
messageStatuses: MessageStatus[],
|
||||
maxRetry: number,
|
||||
retryDelay: number,
|
||||
feeEstimationOptions: {
|
||||
minimumMargin: number;
|
||||
extraDataVariableCost: number;
|
||||
extraDataFixedCost: number;
|
||||
},
|
||||
): Promise<Message | null>;
|
||||
getLatestMessageSent(direction: Direction, contractAddress: string): Promise<Message | null>;
|
||||
getNFirstMessagesByStatus(
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ITransactionValidationService {
|
||||
hasZeroFee: boolean;
|
||||
isUnderPriced: boolean;
|
||||
isRateLimitExceeded: boolean;
|
||||
isForSponsorship: boolean;
|
||||
estimatedGasLimit: bigint | null;
|
||||
threshold: number;
|
||||
maxPriorityFeePerGas: bigint;
|
||||
@@ -18,4 +19,6 @@ export interface ITransactionValidationService {
|
||||
export type TransactionValidationServiceConfig = {
|
||||
profitMargin: number;
|
||||
maxClaimGasLimit: bigint;
|
||||
isPostmanSponsorshipEnabled: boolean;
|
||||
maxPostmanSponsorGasLimit: bigint;
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ export class EthereumTransactionValidationService implements ITransactionValidat
|
||||
* hasZeroFee: boolean;
|
||||
* isUnderPriced: boolean;
|
||||
* isRateLimitExceeded: boolean;
|
||||
* isForSponsorship: boolean;
|
||||
* estimatedGasLimit: bigint | null;
|
||||
* threshold: number;
|
||||
* maxPriorityFeePerGas: bigint;
|
||||
@@ -57,6 +58,7 @@ export class EthereumTransactionValidationService implements ITransactionValidat
|
||||
hasZeroFee: boolean;
|
||||
isUnderPriced: boolean;
|
||||
isRateLimitExceeded: boolean;
|
||||
isForSponsorship: boolean;
|
||||
estimatedGasLimit: bigint | null;
|
||||
threshold: number;
|
||||
maxPriorityFeePerGas: bigint;
|
||||
@@ -75,11 +77,17 @@ export class EthereumTransactionValidationService implements ITransactionValidat
|
||||
const isUnderPriced = this.isUnderPriced(gasLimit, message.fee, maxFeePerGas);
|
||||
const hasZeroFee = this.hasZeroFee(message);
|
||||
const isRateLimitExceeded = await this.isRateLimitExceeded(message.fee, message.value);
|
||||
const isForSponsorship = this.isForSponsorship(
|
||||
gasLimit,
|
||||
this.config.isPostmanSponsorshipEnabled,
|
||||
this.config.maxPostmanSponsorGasLimit,
|
||||
);
|
||||
|
||||
return {
|
||||
hasZeroFee,
|
||||
isUnderPriced,
|
||||
isRateLimitExceeded,
|
||||
isForSponsorship,
|
||||
estimatedGasLimit,
|
||||
threshold,
|
||||
maxPriorityFeePerGas,
|
||||
@@ -143,4 +151,21 @@ export class EthereumTransactionValidationService implements ITransactionValidat
|
||||
private async isRateLimitExceeded(messageFee: bigint, messageValue: bigint): Promise<boolean> {
|
||||
return this.lineaRollupClient.isRateLimitExceeded(messageFee, messageValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the claim transaction is for sponsorship
|
||||
*
|
||||
* @param {bigint} gasLimit - The gas limit for the transaction.
|
||||
* @param {boolean} isPostmanSponsorshipEnabled - `true` if Postman sponsorship is enabled, `false` otherwise
|
||||
* @param {bigint} maxPostmanSponsorGasLimit - Maximum gas limit for sponsored Postman claim transactions
|
||||
* @returns {boolean} `true` if the message is for sponsorsing, `false` otherwise.
|
||||
*/
|
||||
private isForSponsorship(
|
||||
gasLimit: bigint,
|
||||
isPostmanSponsorshipEnabled: boolean,
|
||||
maxPostmanSponsorGasLimit: bigint,
|
||||
): boolean {
|
||||
if (!isPostmanSponsorshipEnabled) return false;
|
||||
return gasLimit < maxPostmanSponsorGasLimit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export class LineaTransactionValidationService implements ITransactionValidation
|
||||
* hasZeroFee: boolean;
|
||||
* isUnderPriced: boolean;
|
||||
* isRateLimitExceeded: boolean;
|
||||
* isForSponsorship: boolean;
|
||||
* estimatedGasLimit: bigint | null;
|
||||
* threshold: number;
|
||||
* maxPriorityFeePerGas: bigint;
|
||||
@@ -68,6 +69,7 @@ export class LineaTransactionValidationService implements ITransactionValidation
|
||||
hasZeroFee: boolean;
|
||||
isUnderPriced: boolean;
|
||||
isRateLimitExceeded: boolean;
|
||||
isForSponsorship: boolean;
|
||||
estimatedGasLimit: bigint | null;
|
||||
threshold: number;
|
||||
maxPriorityFeePerGas: bigint;
|
||||
@@ -83,11 +85,17 @@ export class LineaTransactionValidationService implements ITransactionValidation
|
||||
const isUnderPriced = await this.isUnderPriced(gasLimit, message.fee, message.compressedTransactionSize!);
|
||||
const hasZeroFee = this.hasZeroFee(message);
|
||||
const isRateLimitExceeded = await this.isRateLimitExceeded(message.fee, message.value);
|
||||
const isForSponsorship = this.isForSponsorship(
|
||||
gasLimit,
|
||||
this.config.isPostmanSponsorshipEnabled,
|
||||
this.config.maxPostmanSponsorGasLimit,
|
||||
);
|
||||
|
||||
return {
|
||||
hasZeroFee,
|
||||
isUnderPriced,
|
||||
isRateLimitExceeded,
|
||||
isForSponsorship,
|
||||
estimatedGasLimit,
|
||||
threshold,
|
||||
maxPriorityFeePerGas,
|
||||
@@ -147,6 +155,23 @@ export class LineaTransactionValidationService implements ITransactionValidation
|
||||
return this.l2MessageServiceClient.isRateLimitExceeded(messageFee, messageValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the claim transaction is for sponsorship
|
||||
*
|
||||
* @param {bigint} gasLimit - The gas limit for the transaction.
|
||||
* @param {boolean} isPostmanSponsorshipEnabled - `true` if Postman sponsorship is enabled, `false` otherwise
|
||||
* @param {bigint} maxPostmanSponsorGasLimit - Maximum gas limit for sponsored Postman claim transactions
|
||||
* @returns {boolean} `true` if the message is for sponsorsing, `false` otherwise.
|
||||
*/
|
||||
private isForSponsorship(
|
||||
gasLimit: bigint,
|
||||
isPostmanSponsorshipEnabled: boolean,
|
||||
maxPostmanSponsorGasLimit: bigint,
|
||||
): boolean {
|
||||
if (!isPostmanSponsorshipEnabled) return false;
|
||||
return gasLimit < maxPostmanSponsorGasLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the gas estimation threshold based on the message fee and gas limit.
|
||||
*
|
||||
|
||||
@@ -1,38 +1,18 @@
|
||||
import {
|
||||
Block,
|
||||
ContractTransactionResponse,
|
||||
JsonRpcProvider,
|
||||
TransactionReceipt,
|
||||
TransactionRequest,
|
||||
TransactionResponse,
|
||||
} from "ethers";
|
||||
import { ContractTransactionResponse } from "ethers";
|
||||
import { Direction } from "@consensys/linea-sdk";
|
||||
import { Message } from "../../core/entities/Message";
|
||||
import { MessageStatus } from "../../core/enums";
|
||||
import { IMessageRepository } from "../../core/persistence/IMessageRepository";
|
||||
import { ILineaProvider } from "../../core/clients/blockchain/linea/ILineaProvider";
|
||||
import { BaseError } from "../../core/errors";
|
||||
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
|
||||
import { MessageDBService } from "./MessageDBService";
|
||||
import { MINIMUM_MARGIN } from "../../core/constants";
|
||||
|
||||
export class LineaMessageDBService extends MessageDBService implements IMessageDBService<ContractTransactionResponse> {
|
||||
/**
|
||||
* Creates an instance of `LineaMessageDBService`.
|
||||
*
|
||||
* @param {ILineaProvider} provider - The provider for interacting with the blockchain.
|
||||
* @param {IMessageRepository} messageRepository - The message repository for interacting with the message database.
|
||||
*/
|
||||
constructor(
|
||||
private readonly provider: ILineaProvider<
|
||||
TransactionReceipt,
|
||||
Block,
|
||||
TransactionRequest,
|
||||
TransactionResponse,
|
||||
JsonRpcProvider
|
||||
>,
|
||||
messageRepository: IMessageRepository<ContractTransactionResponse>,
|
||||
) {
|
||||
constructor(messageRepository: IMessageRepository<ContractTransactionResponse>) {
|
||||
super(messageRepository);
|
||||
}
|
||||
|
||||
@@ -67,40 +47,12 @@ export class LineaMessageDBService extends MessageDBService implements IMessageD
|
||||
maxRetry: number,
|
||||
retryDelay: number,
|
||||
): Promise<Message | null> {
|
||||
const feeEstimationOptions = await this.getClaimDBQueryFeeOptions();
|
||||
return this.messageRepository.getFirstMessageToClaimOnL2(
|
||||
Direction.L1_TO_L2,
|
||||
contractAddress,
|
||||
[MessageStatus.TRANSACTION_SIZE_COMPUTED, MessageStatus.FEE_UNDERPRICED],
|
||||
maxRetry,
|
||||
retryDelay,
|
||||
feeEstimationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves fee estimation options for querying the database.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise<{ minimumMargin: number; extraDataVariableCost: number; extraDataFixedCost: number }>} A promise that resolves to an object containing fee estimation options.
|
||||
* @throws {BaseError} If no extra data is available.
|
||||
*/
|
||||
private async getClaimDBQueryFeeOptions(): Promise<{
|
||||
minimumMargin: number;
|
||||
extraDataVariableCost: number;
|
||||
extraDataFixedCost: number;
|
||||
}> {
|
||||
const minimumMargin = MINIMUM_MARGIN;
|
||||
const blockNumber = await this.provider.getBlockNumber();
|
||||
const extraData = await this.provider.getBlockExtraData(blockNumber);
|
||||
|
||||
if (!extraData) {
|
||||
throw new BaseError("no extra data.");
|
||||
}
|
||||
return {
|
||||
minimumMargin,
|
||||
extraDataVariableCost: extraData.variableCost,
|
||||
extraDataFixedCost: extraData.fixedCost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,19 +87,30 @@ export class MessageClaimingProcessor implements IMessageClaimingProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasZeroFee, isUnderPriced, isRateLimitExceeded, estimatedGasLimit, threshold, ...claimTxFees } =
|
||||
await this.transactionValidationService.evaluateTransaction(
|
||||
nextMessageToClaim,
|
||||
this.config.feeRecipientAddress,
|
||||
);
|
||||
const {
|
||||
hasZeroFee,
|
||||
isUnderPriced,
|
||||
isRateLimitExceeded,
|
||||
isForSponsorship,
|
||||
estimatedGasLimit,
|
||||
threshold,
|
||||
...claimTxFees
|
||||
} = await this.transactionValidationService.evaluateTransaction(
|
||||
nextMessageToClaim,
|
||||
this.config.feeRecipientAddress,
|
||||
);
|
||||
|
||||
if (await this.handleZeroFee(hasZeroFee, nextMessageToClaim)) return;
|
||||
// If isForSponsorship = true, then we ignore hasZeroFee and isUnderPriced
|
||||
if (!isForSponsorship && (await this.handleZeroFee(hasZeroFee, nextMessageToClaim))) return;
|
||||
if (await this.handleNonExecutable(nextMessageToClaim, estimatedGasLimit)) return;
|
||||
|
||||
nextMessageToClaim.edit({ claimGasEstimationThreshold: threshold });
|
||||
await this.databaseService.updateMessage(nextMessageToClaim);
|
||||
|
||||
if (await this.handleUnderpriced(nextMessageToClaim, isUnderPriced, estimatedGasLimit, claimTxFees.maxFeePerGas))
|
||||
if (
|
||||
!isForSponsorship &&
|
||||
(await this.handleUnderpriced(nextMessageToClaim, isUnderPriced, estimatedGasLimit, claimTxFees.maxFeePerGas))
|
||||
)
|
||||
return;
|
||||
if (this.handleRateLimitExceeded(nextMessageToClaim, isRateLimitExceeded)) return;
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
testMessage,
|
||||
} from "../../../utils/testing/constants";
|
||||
import {
|
||||
DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
DEFAULT_GAS_ESTIMATION_PERCENTILE,
|
||||
DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
DEFAULT_MAX_FEE_PER_GAS_CAP,
|
||||
DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
DEFAULT_PROFIT_MARGIN,
|
||||
} from "../../../core/constants";
|
||||
import { EthereumTransactionValidationService } from "../../EthereumTransactionValidationService";
|
||||
@@ -57,6 +59,8 @@ describe("EthereumTransactionValidationService", () => {
|
||||
lineaTransactionValidationService = new EthereumTransactionValidationService(lineaRollupClient, gasProvider, {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
});
|
||||
|
||||
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
|
||||
@@ -82,6 +86,7 @@ describe("EthereumTransactionValidationService", () => {
|
||||
estimatedGasLimit: estimatedGasLimit,
|
||||
hasZeroFee: true,
|
||||
isRateLimitExceeded: false,
|
||||
isForSponsorship: false,
|
||||
isUnderPriced: true,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
@@ -102,6 +107,7 @@ describe("EthereumTransactionValidationService", () => {
|
||||
hasZeroFee: false,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: true,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 0,
|
||||
@@ -120,6 +126,7 @@ describe("EthereumTransactionValidationService", () => {
|
||||
hasZeroFee: false,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: true,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 0,
|
||||
@@ -138,6 +145,7 @@ describe("EthereumTransactionValidationService", () => {
|
||||
hasZeroFee: false,
|
||||
isRateLimitExceeded: true,
|
||||
isUnderPriced: true,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 0,
|
||||
@@ -157,10 +165,55 @@ describe("EthereumTransactionValidationService", () => {
|
||||
hasZeroFee: false,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: false,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 2000000000000000,
|
||||
});
|
||||
});
|
||||
|
||||
it("When isPostmanSponsorshipEnabled is false, should return transaction evaluation criteria with isForSponsorship = false", async () => {
|
||||
const estimatedGasLimit = 50_000n;
|
||||
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
|
||||
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(false);
|
||||
testMessage.fee = 0n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria.isForSponsorship).toBe(false);
|
||||
});
|
||||
|
||||
describe("isPostmanSponsorshipEnabled is true", () => {
|
||||
beforeEach(() => {
|
||||
lineaTransactionValidationService = new EthereumTransactionValidationService(lineaRollupClient, gasProvider, {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
isPostmanSponsorshipEnabled: true,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
});
|
||||
});
|
||||
|
||||
it("When gas limit < sponsor threshold, should return transaction evaluation criteria with isForSponsorship = true", async () => {
|
||||
const estimatedGasLimit = DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT - 1n;
|
||||
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
|
||||
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(false);
|
||||
testMessage.fee = 0n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria.isForSponsorship).toBe(true);
|
||||
});
|
||||
|
||||
it("When gas limit > sponsor threshold, should return transaction evaluation criteria with isForSponsorship = false", async () => {
|
||||
const estimatedGasLimit = DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT + 1n;
|
||||
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
|
||||
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(false);
|
||||
|
||||
testMessage.fee = 0n;
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria.isForSponsorship).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
testMessage,
|
||||
} from "../../../utils/testing/constants";
|
||||
import {
|
||||
DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
DEFAULT_MAX_FEE_PER_GAS_CAP,
|
||||
DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
DEFAULT_PROFIT_MARGIN,
|
||||
} from "../../../core/constants";
|
||||
import { IL2MessageServiceClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceClient";
|
||||
@@ -37,6 +39,24 @@ describe("LineaTransactionValidationService", () => {
|
||||
>;
|
||||
let provider: MockProxy<LineaProvider>;
|
||||
|
||||
const setup = (estimatedGasLimit: bigint, isNullExtraData = false) => {
|
||||
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
|
||||
gasLimit: estimatedGasLimit,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
});
|
||||
jest.spyOn(provider, "getBlockExtraData").mockResolvedValueOnce(
|
||||
isNullExtraData
|
||||
? null
|
||||
: {
|
||||
version: 1,
|
||||
variableCost: 1_000_000,
|
||||
fixedCost: 1_000_000,
|
||||
ethGasPrice: 1_000_000,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
provider = mock<LineaProvider>();
|
||||
const clients = testingHelpers.generateL2MessageServiceClient(
|
||||
@@ -57,10 +77,14 @@ describe("LineaTransactionValidationService", () => {
|
||||
{
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
provider,
|
||||
l2ContractClient,
|
||||
);
|
||||
|
||||
jest.spyOn(l2ContractClient, "getSigner").mockReturnValueOnce(new Wallet(TEST_L2_SIGNER_PRIVATE_KEY));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -69,34 +93,17 @@ describe("LineaTransactionValidationService", () => {
|
||||
|
||||
describe("evaluateTransaction", () => {
|
||||
it("Should throw an error when there is no extraData in the L2 block", async () => {
|
||||
jest.spyOn(l2ContractClient, "getSigner").mockReturnValueOnce(new Wallet(TEST_L2_SIGNER_PRIVATE_KEY));
|
||||
const estimatedGasLimit = 50_000n;
|
||||
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
|
||||
gasLimit: estimatedGasLimit,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
});
|
||||
jest.spyOn(provider, "getBlockExtraData").mockResolvedValueOnce(null);
|
||||
setup(estimatedGasLimit, true);
|
||||
|
||||
await expect(lineaTransactionValidationService.evaluateTransaction(testMessage)).rejects.toThrow("No extra data");
|
||||
});
|
||||
|
||||
it("Should return transaction evaluation criteria with hasZeroFee = true", async () => {
|
||||
jest.spyOn(l2ContractClient, "getSigner").mockReturnValueOnce(new Wallet(TEST_L2_SIGNER_PRIVATE_KEY));
|
||||
const estimatedGasLimit = 50_000n;
|
||||
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
|
||||
gasLimit: estimatedGasLimit,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
});
|
||||
jest.spyOn(provider, "getBlockExtraData").mockResolvedValueOnce({
|
||||
version: 1,
|
||||
variableCost: 1_000_000,
|
||||
fixedCost: 1_000_000,
|
||||
ethGasPrice: 1_000_000,
|
||||
});
|
||||
|
||||
setup(estimatedGasLimit);
|
||||
testMessage.fee = 0n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria).toStrictEqual({
|
||||
@@ -104,6 +111,7 @@ describe("LineaTransactionValidationService", () => {
|
||||
hasZeroFee: true,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: true,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 0,
|
||||
@@ -111,21 +119,10 @@ describe("LineaTransactionValidationService", () => {
|
||||
});
|
||||
|
||||
it("Should return transaction evaluation criteria with isUnderPriced = true", async () => {
|
||||
jest.spyOn(l2ContractClient, "getSigner").mockReturnValueOnce(new Wallet(TEST_L2_SIGNER_PRIVATE_KEY));
|
||||
const estimatedGasLimit = 50_000n;
|
||||
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
|
||||
gasLimit: estimatedGasLimit,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
});
|
||||
jest.spyOn(provider, "getBlockExtraData").mockResolvedValueOnce({
|
||||
version: 1,
|
||||
variableCost: 1_000_000,
|
||||
fixedCost: 1_000_000,
|
||||
ethGasPrice: 1_000_000,
|
||||
});
|
||||
|
||||
setup(estimatedGasLimit);
|
||||
testMessage.fee = 1n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria).toStrictEqual({
|
||||
@@ -133,6 +130,7 @@ describe("LineaTransactionValidationService", () => {
|
||||
hasZeroFee: false,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: true,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 0,
|
||||
@@ -140,18 +138,8 @@ describe("LineaTransactionValidationService", () => {
|
||||
});
|
||||
|
||||
it("Should return transaction evaluation criteria with estimatedGasLimit = null", async () => {
|
||||
jest.spyOn(l2ContractClient, "getSigner").mockReturnValueOnce(new Wallet(TEST_L2_SIGNER_PRIVATE_KEY));
|
||||
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
|
||||
gasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT + 1n,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
});
|
||||
jest.spyOn(provider, "getBlockExtraData").mockResolvedValueOnce({
|
||||
version: 1,
|
||||
variableCost: 1_000_000,
|
||||
fixedCost: 1_000_000,
|
||||
ethGasPrice: 1_000_000,
|
||||
});
|
||||
const estimatedGasLimit = DEFAULT_MAX_CLAIM_GAS_LIMIT + 1n;
|
||||
setup(estimatedGasLimit);
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
@@ -160,6 +148,7 @@ describe("LineaTransactionValidationService", () => {
|
||||
hasZeroFee: false,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: true,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 0,
|
||||
@@ -167,21 +156,10 @@ describe("LineaTransactionValidationService", () => {
|
||||
});
|
||||
|
||||
it("Should return transaction evaluation criteria for a valid message", async () => {
|
||||
jest.spyOn(l2ContractClient, "getSigner").mockReturnValueOnce(new Wallet(TEST_L2_SIGNER_PRIVATE_KEY));
|
||||
const estimatedGasLimit = 50_000n;
|
||||
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
|
||||
gasLimit: estimatedGasLimit,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
});
|
||||
jest.spyOn(provider, "getBlockExtraData").mockResolvedValueOnce({
|
||||
version: 1,
|
||||
variableCost: 1_000_000,
|
||||
fixedCost: 1_000_000,
|
||||
ethGasPrice: 1_000_000,
|
||||
});
|
||||
|
||||
setup(estimatedGasLimit);
|
||||
testMessage.fee = 100000000000000000000n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria).toStrictEqual({
|
||||
@@ -189,10 +167,56 @@ describe("LineaTransactionValidationService", () => {
|
||||
hasZeroFee: false,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: false,
|
||||
isForSponsorship: false,
|
||||
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
threshold: 2000000000000000,
|
||||
});
|
||||
});
|
||||
|
||||
it("When isPostmanSponsorshipEnabled is false, should return transaction evaluation criteria with isForSponsorship = false", async () => {
|
||||
const estimatedGasLimit = 50_000n;
|
||||
setup(estimatedGasLimit);
|
||||
testMessage.fee = 0n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria.isForSponsorship).toBe(false);
|
||||
});
|
||||
|
||||
describe("isPostmanSponsorshipEnabled is true", () => {
|
||||
beforeEach(() => {
|
||||
lineaTransactionValidationService = new LineaTransactionValidationService(
|
||||
{
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
isPostmanSponsorshipEnabled: true,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
provider,
|
||||
l2ContractClient,
|
||||
);
|
||||
});
|
||||
|
||||
it("When gas limit < sponsor threshold, should return transaction evaluation criteria with isForSponsorship = true", async () => {
|
||||
const estimatedGasLimit = DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT - 1n;
|
||||
setup(estimatedGasLimit);
|
||||
testMessage.fee = 0n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria.isForSponsorship).toBe(true);
|
||||
});
|
||||
|
||||
it("When gas limit > sponsor threshold, should return transaction evaluation criteria with isForSponsorship = false", async () => {
|
||||
const estimatedGasLimit = DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT + 1n;
|
||||
setup(estimatedGasLimit);
|
||||
testMessage.fee = 0n;
|
||||
|
||||
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
|
||||
|
||||
expect(criteria.isForSponsorship).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,10 +30,12 @@ import { Message } from "../../../core/entities/Message";
|
||||
import { ErrorParser } from "../../../utils/ErrorParser";
|
||||
import { EthereumMessageDBService } from "../../persistence/EthereumMessageDBService";
|
||||
import {
|
||||
DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
DEFAULT_GAS_ESTIMATION_PERCENTILE,
|
||||
DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
DEFAULT_MAX_FEE_PER_GAS_CAP,
|
||||
DEFAULT_MAX_NUMBER_OF_RETRIES,
|
||||
DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
DEFAULT_PROFIT_MARGIN,
|
||||
DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
} from "../../../core/constants";
|
||||
@@ -72,6 +74,8 @@ describe("TestMessageClaimingProcessor", () => {
|
||||
transactionValidationService = new EthereumTransactionValidationService(lineaRollupContractMock, gasProvider, {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
});
|
||||
messageClaimingProcessor = new MessageClaimingProcessor(
|
||||
lineaRollupContractMock,
|
||||
@@ -141,6 +145,7 @@ describe("TestMessageClaimingProcessor", () => {
|
||||
hasZeroFee: true,
|
||||
isRateLimitExceeded: false,
|
||||
isUnderPriced: false,
|
||||
isForSponsorship: false,
|
||||
estimatedGasLimit: 50_000n,
|
||||
threshold: 5,
|
||||
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
|
||||
@@ -203,7 +208,7 @@ describe("TestMessageClaimingProcessor", () => {
|
||||
.mockResolvedValue({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n });
|
||||
jest.spyOn(databaseService, "getMessageToClaim").mockResolvedValue(testAnchoredMessage);
|
||||
jest.spyOn(lineaRollupContractMock, "getMessageStatus").mockResolvedValue(OnChainMessageStatus.CLAIMABLE);
|
||||
jest.spyOn(lineaRollupContractMock, "estimateClaimGas").mockResolvedValue(200_000n);
|
||||
jest.spyOn(lineaRollupContractMock, "estimateClaimGas").mockResolvedValue(DEFAULT_MAX_CLAIM_GAS_LIMIT * 2n);
|
||||
const expectedLoggingMessage = new Message(testAnchoredMessage);
|
||||
const expectedSavedMessage = new Message({
|
||||
...testAnchoredMessage,
|
||||
@@ -219,7 +224,7 @@ describe("TestMessageClaimingProcessor", () => {
|
||||
"Estimated gas limit is higher than the max allowed gas limit for this message: messageHash=%s messageInfo=%s estimatedGasLimit=%s maxAllowedGasLimit=%s",
|
||||
expectedLoggingMessage.messageHash,
|
||||
expectedLoggingMessage.toString(),
|
||||
undefined, //"200000",
|
||||
undefined, // DEFAULT_MAX_CLAIM_GAS_LIMIT * 2n,
|
||||
testL2NetworkConfig.claiming.maxClaimGasLimit!.toString(),
|
||||
);
|
||||
expect(messageRepositorySaveSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -374,4 +379,115 @@ describe("TestMessageClaimingProcessor", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("process with sponsorship", () => {
|
||||
beforeEach(() => {
|
||||
transactionValidationService = new EthereumTransactionValidationService(lineaRollupContractMock, gasProvider, {
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
isPostmanSponsorshipEnabled: true,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
});
|
||||
messageClaimingProcessor = new MessageClaimingProcessor(
|
||||
lineaRollupContractMock,
|
||||
signer,
|
||||
databaseService,
|
||||
transactionValidationService,
|
||||
{
|
||||
maxNonceDiff: 5,
|
||||
profitMargin: DEFAULT_PROFIT_MARGIN,
|
||||
maxNumberOfRetries: DEFAULT_MAX_NUMBER_OF_RETRIES,
|
||||
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
direction: Direction.L2_TO_L1,
|
||||
originContractAddress: TEST_CONTRACT_ADDRESS_2,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
it("Should successfully claim message with fee", async () => {
|
||||
const lineaRollupContractMsgStatusSpy = jest.spyOn(lineaRollupContractMock, "getMessageStatus");
|
||||
const l2MessageServiceContractClaimSpy = jest.spyOn(lineaRollupContractMock, "claim");
|
||||
const messageRepositorySaveSpy = jest.spyOn(databaseService, "updateMessage");
|
||||
const messageRepositoryUpdateAtomicSpy = jest.spyOn(databaseService, "updateMessageWithClaimTxAtomic");
|
||||
jest.spyOn(databaseService, "getLastClaimTxNonce").mockResolvedValue(100);
|
||||
jest.spyOn(signer, "getNonce").mockResolvedValue(99);
|
||||
jest
|
||||
.spyOn(gasProvider, "getGasFees")
|
||||
.mockResolvedValue({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n });
|
||||
jest.spyOn(databaseService, "getMessageToClaim").mockResolvedValue(testAnchoredMessage);
|
||||
jest.spyOn(lineaRollupContractMock, "getMessageStatus").mockResolvedValue(OnChainMessageStatus.CLAIMABLE);
|
||||
jest.spyOn(lineaRollupContractMock, "estimateClaimGas").mockResolvedValue(100_000n);
|
||||
jest.spyOn(lineaRollupContractMock, "isRateLimitExceeded").mockResolvedValue(false);
|
||||
const expectedLoggingMessage = new Message({
|
||||
...testAnchoredMessage,
|
||||
claimGasEstimationThreshold: 10000000000,
|
||||
updatedAt: mockedDate,
|
||||
});
|
||||
await messageClaimingProcessor.process();
|
||||
|
||||
expect(lineaRollupContractMsgStatusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositorySaveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositorySaveSpy).toHaveBeenCalledWith(expectedLoggingMessage);
|
||||
expect(l2MessageServiceContractClaimSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositoryUpdateAtomicSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should successfully claim message with zero fee", async () => {
|
||||
const lineaRollupContractMsgStatusSpy = jest.spyOn(lineaRollupContractMock, "getMessageStatus");
|
||||
const l2MessageServiceContractClaimSpy = jest.spyOn(lineaRollupContractMock, "claim");
|
||||
const messageRepositorySaveSpy = jest.spyOn(databaseService, "updateMessage");
|
||||
const messageRepositoryUpdateAtomicSpy = jest.spyOn(databaseService, "updateMessageWithClaimTxAtomic");
|
||||
jest.spyOn(databaseService, "getLastClaimTxNonce").mockResolvedValue(100);
|
||||
jest.spyOn(signer, "getNonce").mockResolvedValue(99);
|
||||
jest
|
||||
.spyOn(gasProvider, "getGasFees")
|
||||
.mockResolvedValue({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n });
|
||||
jest.spyOn(databaseService, "getMessageToClaim").mockResolvedValue(testZeroFeeAnchoredMessage);
|
||||
jest.spyOn(lineaRollupContractMock, "getMessageStatus").mockResolvedValue(OnChainMessageStatus.CLAIMABLE);
|
||||
jest.spyOn(lineaRollupContractMock, "estimateClaimGas").mockResolvedValue(100_000n);
|
||||
jest.spyOn(lineaRollupContractMock, "isRateLimitExceeded").mockResolvedValue(false);
|
||||
const expectedLoggingMessage = new Message({
|
||||
...testZeroFeeAnchoredMessage,
|
||||
updatedAt: mockedDate,
|
||||
});
|
||||
|
||||
await messageClaimingProcessor.process();
|
||||
|
||||
expect(lineaRollupContractMsgStatusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositorySaveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositorySaveSpy).toHaveBeenCalledWith(expectedLoggingMessage);
|
||||
expect(l2MessageServiceContractClaimSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositoryUpdateAtomicSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Should successfully claim message with underpriced fee", async () => {
|
||||
const lineaRollupContractMsgStatusSpy = jest.spyOn(lineaRollupContractMock, "getMessageStatus");
|
||||
const l2MessageServiceContractClaimSpy = jest.spyOn(lineaRollupContractMock, "claim");
|
||||
const messageRepositorySaveSpy = jest.spyOn(databaseService, "updateMessage");
|
||||
const messageRepositoryUpdateAtomicSpy = jest.spyOn(databaseService, "updateMessageWithClaimTxAtomic");
|
||||
jest.spyOn(databaseService, "getLastClaimTxNonce").mockResolvedValue(100);
|
||||
jest.spyOn(signer, "getNonce").mockResolvedValue(99);
|
||||
jest
|
||||
.spyOn(gasProvider, "getGasFees")
|
||||
.mockResolvedValue({ maxFeePerGas: 1000000000n, maxPriorityFeePerGas: 1000000000n });
|
||||
jest.spyOn(databaseService, "getMessageToClaim").mockResolvedValue(testUnderpricedAnchoredMessage);
|
||||
jest.spyOn(lineaRollupContractMock, "getMessageStatus").mockResolvedValue(OnChainMessageStatus.CLAIMABLE);
|
||||
jest.spyOn(lineaRollupContractMock, "estimateClaimGas").mockResolvedValue(100_000n);
|
||||
const expectedLoggingMessage = new Message({
|
||||
...testUnderpricedAnchoredMessage,
|
||||
claimGasEstimationThreshold: 10,
|
||||
updatedAt: mockedDate,
|
||||
});
|
||||
|
||||
await messageClaimingProcessor.process();
|
||||
|
||||
expect(lineaRollupContractMsgStatusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositorySaveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositorySaveSpy).toHaveBeenCalledWith(expectedLoggingMessage);
|
||||
expect(l2MessageServiceContractClaimSpy).toHaveBeenCalledTimes(1);
|
||||
expect(messageRepositoryUpdateAtomicSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { L1NetworkConfig, L2NetworkConfig } from "../../application/postman/app/
|
||||
import { Message, MessageProps } from "../../core/entities/Message";
|
||||
import { MessageStatus } from "../../core/enums";
|
||||
import {
|
||||
DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
DEFAULT_INITIAL_FROM_BLOCK,
|
||||
DEFAULT_L2_MESSAGE_TREE_DEPTH,
|
||||
DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
|
||||
@@ -201,6 +203,8 @@ export const testL1NetworkConfig: L1NetworkConfig = {
|
||||
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
|
||||
maxTxRetries: DEFAULT_MAX_TX_RETRIES,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
listener: {
|
||||
pollingInterval: 4000,
|
||||
@@ -228,6 +232,8 @@ export const testL2NetworkConfig: L2NetworkConfig = {
|
||||
maxNumberOfRetries: DEFAULT_MAX_NUMBER_OF_RETRIES,
|
||||
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
|
||||
maxTxRetries: DEFAULT_MAX_TX_RETRIES,
|
||||
isPostmanSponsorshipEnabled: DEFAULT_ENABLE_POSTMAN_SPONSORING,
|
||||
maxPostmanSponsorGasLimit: DEFAULT_MAX_POSTMAN_SPONSOR_GAS_LIMIT,
|
||||
},
|
||||
listener: {
|
||||
pollingInterval: 100,
|
||||
|
||||
Reference in New Issue
Block a user