[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:
kyzooghost
2025-04-22 19:28:53 +10:00
committed by GitHub
parent 04db5c7204
commit 9a16c5e152
47 changed files with 703 additions and 240 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,3 @@
export const INBOX_L1L2_MESSAGE_STATUS_MAPPING_SLOT = 176n;
export const MAX_POSTMAN_SPONSOR_GAS_LIMIT = 250000n;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,4 +26,5 @@ export {
type CctpV2BridgeMessage,
type BridgeTransaction,
BridgeTransactionType,
ClaimType,
} from "./bridge";

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,8 @@ export type ClaimingOptions = {
retryDelayInSeconds?: number;
maxClaimGasLimit?: bigint;
maxTxRetries?: number;
isPostmanSponsorshipEnabled?: boolean;
maxPostmanSponsorGasLimit?: bigint;
};
export type ClaimingConfig = Omit<Required<ClaimingOptions>, "feeRecipientAddress"> & {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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