diff --git a/bridge-ui/src/app/native-bridge/layout.tsx b/bridge-ui/src/app/native-bridge/layout.tsx index abb7d810..1141038e 100644 --- a/bridge-ui/src/app/native-bridge/layout.tsx +++ b/bridge-ui/src/app/native-bridge/layout.tsx @@ -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, diff --git a/bridge-ui/src/components/bridge/claiming/fees/index.tsx b/bridge-ui/src/components/bridge/claiming/fees/index.tsx index e728124f..ae0e963d 100644 --- a/bridge-ui/src/components/bridge/claiming/fees/index.tsx +++ b/bridge-ui/src/components/bridge/claiming/fees/index.tsx @@ -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() {
- {claim === "manual" && } + {claim === ClaimType.MANUAL && }
); diff --git a/bridge-ui/src/components/bridge/claiming/index.tsx b/bridge-ui/src/components/bridge/claiming/index.tsx index 80b85ce6..36761068 100644 --- a/bridge-ui/src/components/bridge/claiming/index.tsx +++ b/bridge-ui/src/components/bridge/claiming/index.tsx @@ -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() {

Receive

- { - // There is no auto-claiming for USDC via CCTPV2 - !isTokenCanonicalUSDC() && ( - - ) - } + {showSettingIcon && ( + + )}
diff --git a/bridge-ui/src/components/bridge/form/index.tsx b/bridge-ui/src/components/bridge/form/index.tsx index 3a86630c..d6fd59be 100644 --- a/bridge-ui/src/components/bridge/form/index.tsx +++ b/bridge-ui/src/components/bridge/form/index.tsx @@ -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]); diff --git a/bridge-ui/src/components/bridge/modal/advanced-settings/index.tsx b/bridge-ui/src/components/bridge/modal/advanced-settings/index.tsx index 956cc199..3c92ebd0 100644 --- a/bridge-ui/src/components/bridge/modal/advanced-settings/index.tsx +++ b/bridge-ui/src/components/bridge/modal/advanced-settings/index.tsx @@ -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) {
{ if (checked) { - setClaim("manual"); + setClaim(ClaimType.MANUAL); } else { - setClaim("auto"); + setClaim(ClaimType.AUTO_PAID); } }} /> diff --git a/bridge-ui/src/components/bridge/modal/gas-fees/gas-fees-list/gas-fees-list-item/index.tsx b/bridge-ui/src/components/bridge/modal/gas-fees/gas-fees-list/gas-fees-list-item/index.tsx index 7b2aefaa..c40e289a 100644 --- a/bridge-ui/src/components/bridge/modal/gas-fees/gas-fees-list/gas-fees-list-item/index.tsx +++ b/bridge-ui/src/components/bridge/modal/gas-fees/gas-fees-list/gas-fees-list-item/index.tsx @@ -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 (
  • {name}
    - {`${parseFloat(formatEther(fee)).toFixed(8)} ETH`} + {feeText} {fiatValue && ( {fiatValue.toLocaleString("en-US", { diff --git a/bridge-ui/src/components/bridge/modal/token-modal/index.tsx b/bridge-ui/src/components/bridge/modal/token-modal/index.tsx index feb3e269..f8fafe6a 100644 --- a/bridge-ui/src/components/bridge/modal/token-modal/index.tsx +++ b/bridge-ui/src/components/bridge/modal/token-modal/index.tsx @@ -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( diff --git a/bridge-ui/src/constants/message.ts b/bridge-ui/src/constants/message.ts index 8291b696..e7822f89 100644 --- a/bridge-ui/src/constants/message.ts +++ b/bridge-ui/src/constants/message.ts @@ -1 +1,3 @@ export const INBOX_L1L2_MESSAGE_STATUS_MAPPING_SLOT = 176n; + +export const MAX_POSTMAN_SPONSOR_GAS_LIMIT = 250000n; diff --git a/bridge-ui/src/hooks/fees/index.ts b/bridge-ui/src/hooks/fees/index.ts index 125438a6..e7e7780c 100644 --- a/bridge-ui/src/hooks/fees/index.ts +++ b/bridge-ui/src/hooks/fees/index.ts @@ -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"; diff --git a/bridge-ui/src/hooks/fees/useBridgingFee.ts b/bridge-ui/src/hooks/fees/useBridgingFee.ts index 4532f938..88180889 100644 --- a/bridge-ui/src/hooks/fees/useBridgingFee.ts +++ b/bridge-ui/src/hooks/fees/useBridgingFee.ts @@ -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) { diff --git a/bridge-ui/src/hooks/fees/useERC20BridgingFee.ts b/bridge-ui/src/hooks/fees/useERC20BridgingGasUsed.ts similarity index 75% rename from bridge-ui/src/hooks/fees/useERC20BridgingFee.ts rename to bridge-ui/src/hooks/fees/useERC20BridgingGasUsed.ts index 46bc6284..34d18d95 100644 --- a/bridge-ui/src/hooks/fees/useERC20BridgingFee.ts +++ b/bridge-ui/src/hooks/fees/useERC20BridgingGasUsed.ts @@ -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; diff --git a/bridge-ui/src/hooks/fees/useEthBridgingFee.ts b/bridge-ui/src/hooks/fees/useEthBridgingGasUsed.ts similarity index 75% rename from bridge-ui/src/hooks/fees/useEthBridgingFee.ts rename to bridge-ui/src/hooks/fees/useEthBridgingGasUsed.ts index 2d4ccd64..950aa983 100644 --- a/bridge-ui/src/hooks/fees/useEthBridgingFee.ts +++ b/bridge-ui/src/hooks/fees/useEthBridgingGasUsed.ts @@ -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; diff --git a/bridge-ui/src/hooks/fees/useFees.ts b/bridge-ui/src/hooks/fees/useFees.ts index c0f7039e..24faf8c6 100644 --- a/bridge-ui/src/hooks/fees/useFees.ts +++ b/bridge-ui/src/hooks/fees/useFees.ts @@ -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, diff --git a/bridge-ui/src/hooks/transaction-args/useERC20BridgeTxArgs.ts b/bridge-ui/src/hooks/transaction-args/useERC20BridgeTxArgs.ts index e816f3eb..782d475c 100644 --- a/bridge-ui/src/hooks/transaction-args/useERC20BridgeTxArgs.ts +++ b/bridge-ui/src/hooks/transaction-args/useERC20BridgeTxArgs.ts @@ -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; diff --git a/bridge-ui/src/hooks/transaction-args/useEthBridgeTxArgs.ts b/bridge-ui/src/hooks/transaction-args/useEthBridgeTxArgs.ts index c1a33f0e..b6e1d6f5 100644 --- a/bridge-ui/src/hooks/transaction-args/useEthBridgeTxArgs.ts +++ b/bridge-ui/src/hooks/transaction-args/useEthBridgeTxArgs.ts @@ -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 ) { diff --git a/bridge-ui/src/hooks/useClaimingTx.ts b/bridge-ui/src/hooks/useClaimingTx.ts index b73c7879..27aa92f9 100644 --- a/bridge-ui/src/hooks/useClaimingTx.ts +++ b/bridge-ui/src/hooks/useClaimingTx.ts @@ -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), }); diff --git a/bridge-ui/src/hooks/useMessageNumber.ts b/bridge-ui/src/hooks/useMessageNumber.ts index c0a985e9..e361d0e9 100644 --- a/bridge-ui/src/hooks/useMessageNumber.ts +++ b/bridge-ui/src/hooks/useMessageNumber.ts @@ -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; diff --git a/bridge-ui/src/stores/formStore.ts b/bridge-ui/src/stores/formStore.ts index 8fd405f9..65b4e4d6 100644 --- a/bridge-ui/src/stores/formStore.ts +++ b/bridge-ui/src/stores/formStore.ts @@ -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((set, get) => { + createWithEqualityFn((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); diff --git a/bridge-ui/src/types/bridge.ts b/bridge-ui/src/types/bridge.ts index 65190744..d7f20a50 100644 --- a/bridge-ui/src/types/bridge.ts +++ b/bridge-ui/src/types/bridge.ts @@ -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; diff --git a/bridge-ui/src/types/index.ts b/bridge-ui/src/types/index.ts index d03ae3fe..e9982f79 100644 --- a/bridge-ui/src/types/index.ts +++ b/bridge-ui/src/types/index.ts @@ -26,4 +26,5 @@ export { type CctpV2BridgeMessage, type BridgeTransaction, BridgeTransactionType, + ClaimType, } from "./bridge"; diff --git a/bridge-ui/src/utils/fees.ts b/bridge-ui/src/utils/fees.ts index 5426b8cc..8f79108e 100644 --- a/bridge-ui/src/utils/fees.ts +++ b/bridge-ui/src/utils/fees.ts @@ -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, 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 { - 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 { - 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, + ); } diff --git a/bridge-ui/src/utils/index.ts b/bridge-ui/src/utils/index.ts index 16046791..3310e4e6 100644 --- a/bridge-ui/src/utils/index.ts +++ b/bridge-ui/src/utils/index.ts @@ -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"; diff --git a/bridge-ui/test/advancedFixtures.ts b/bridge-ui/test/advancedFixtures.ts index 1273b355..c8ea1d41 100644 --- a/bridge-ui/test/advancedFixtures.ts +++ b/bridge-ui/test/advancedFixtures.ts @@ -24,6 +24,7 @@ export const test = metaMaskFixtures(setup).extend<{ selectTokenAndInputAmount: (tokenSymbol: string, amount: string) => Promise; waitForNewTxAdditionToTxList: (txCountBeforeUpdate: number) => Promise; waitForTxListUpdateForClaimTx: (claimTxCountBeforeUpdate: number) => Promise; + openGasFeeModal: () => Promise; // Metamask Actions - Should be ok to reuse within other fixture functions connectMetamaskToDapp: () => Promise; @@ -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) => { diff --git a/bridge-ui/test/e2e/bridge-l1-l2.spec.ts b/bridge-ui/test/e2e/bridge-l1-l2.spec.ts index 83368b37..2f50c5e7 100644 --- a/bridge-ui/test/e2e/bridge-l1-l2.spec.ts +++ b/bridge-ui/test/e2e/bridge-l1-l2.spec.ts @@ -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", () => { diff --git a/bridge-ui/test/utils/selectTokenAndWaitForBalance.ts b/bridge-ui/test/utils/selectTokenAndWaitForBalance.ts index 36b68db6..1a2e02ad 100644 --- a/bridge-ui/test/utils/selectTokenAndWaitForBalance.ts +++ b/bridge-ui/test/utils/selectTokenAndWaitForBalance.ts @@ -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?`; diff --git a/docker/config/postman/env b/docker/config/postman/env index edbd26f8..61111afa 100644 --- a/docker/config/postman/env +++ b/docker/config/postman/env @@ -38,4 +38,7 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=postman_db DB_CLEANER_ENABLED=false -ENABLE_LINEA_ESTIMATE_GAS=false \ No newline at end of file +ENABLE_LINEA_ESTIMATE_GAS=false +L1_L2_ENABLE_POSTMAN_SPONSORING=true +L2_L1_ENABLE_POSTMAN_SPONSORING=false +MAX_POSTMAN_SPONSOR_GAS_LIMIT=250000 \ No newline at end of file diff --git a/e2e/src/messaging.spec.ts b/e2e/src/messaging.spec.ts index 278affad..572cd24f 100644 --- a/e2e/src/messaging.spec.ts +++ b/e2e/src/messaging.spec.ts @@ -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]; diff --git a/postman/.env.sample b/postman/.env.sample index 2b5ce5d6..7953d518 100644 --- a/postman/.env.sample +++ b/postman/.env.sample @@ -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= diff --git a/postman/README.md b/postman/README.md index 6fe5fa7f..a3971601 100644 --- a/postman/README.md +++ b/postman/README.md @@ -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) diff --git a/postman/scripts/runPostman.ts b/postman/scripts/runPostman.ts index 962a7f57..a908c371 100644 --- a/postman/scripts/runPostman.ts +++ b/postman/scripts/runPostman.ts @@ -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", diff --git a/postman/src/application/postman/app/PostmanServiceClient.ts b/postman/src/application/postman/app/PostmanServiceClient.ts index 9bbfe21c..7204cdf7 100644 --- a/postman/src/application/postman/app/PostmanServiceClient.ts +++ b/postman/src/application/postman/app/PostmanServiceClient.ts @@ -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), ); diff --git a/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts b/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts index 527cef7a..e978b25c 100644 --- a/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts +++ b/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts @@ -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, diff --git a/postman/src/application/postman/app/config/__tests__/utils.test.ts b/postman/src/application/postman/app/config/__tests__/utils.test.ts index 0a9b346f..cfe6aa31 100644 --- a/postman/src/application/postman/app/config/__tests__/utils.test.ts +++ b/postman/src/application/postman/app/config/__tests__/utils.test.ts @@ -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, diff --git a/postman/src/application/postman/app/config/config.ts b/postman/src/application/postman/app/config/config.ts index 9ffa1b47..04572ea9 100644 --- a/postman/src/application/postman/app/config/config.ts +++ b/postman/src/application/postman/app/config/config.ts @@ -84,6 +84,8 @@ export type ClaimingOptions = { retryDelayInSeconds?: number; maxClaimGasLimit?: bigint; maxTxRetries?: number; + isPostmanSponsorshipEnabled?: boolean; + maxPostmanSponsorGasLimit?: bigint; }; export type ClaimingConfig = Omit, "feeRecipientAddress"> & { diff --git a/postman/src/application/postman/app/config/utils.ts b/postman/src/application/postman/app/config/utils.ts index f92171c9..edfac096 100644 --- a/postman/src/application/postman/app/config/utils.ts +++ b/postman/src/application/postman/app/config/utils.ts @@ -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, diff --git a/postman/src/application/postman/persistence/repositories/TypeOrmMessageRepository.ts b/postman/src/application/postman/persistence/repositories/TypeOrmMessageRepository.ts index d4557ced..82bc894a 100644 --- a/postman/src/application/postman/persistence/repositories/TypeOrmMessageRepository.ts +++ b/postman/src/application/postman/persistence/repositories/TypeOrmMessageRepository.ts @@ -153,11 +153,6 @@ export class TypeOrmMessageRepository { try { const message = await this.createQueryBuilder("message") @@ -174,15 +169,7 @@ export class TypeOrmMessageRepository :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(); diff --git a/postman/src/core/constants/common.ts b/postman/src/core/constants/common.ts index 0a6c79e7..ddc1e0f9 100644 --- a/postman/src/core/constants/common.ts +++ b/postman/src/core/constants/common.ts @@ -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; diff --git a/postman/src/core/persistence/IMessageRepository.ts b/postman/src/core/persistence/IMessageRepository.ts index 174b121b..0d4612e5 100644 --- a/postman/src/core/persistence/IMessageRepository.ts +++ b/postman/src/core/persistence/IMessageRepository.ts @@ -22,11 +22,6 @@ export interface IMessageRepository { messageStatuses: MessageStatus[], maxRetry: number, retryDelay: number, - feeEstimationOptions: { - minimumMargin: number; - extraDataVariableCost: number; - extraDataFixedCost: number; - }, ): Promise; getLatestMessageSent(direction: Direction, contractAddress: string): Promise; getNFirstMessagesByStatus( diff --git a/postman/src/core/services/ITransactionValidationService.ts b/postman/src/core/services/ITransactionValidationService.ts index febf7701..b877d139 100644 --- a/postman/src/core/services/ITransactionValidationService.ts +++ b/postman/src/core/services/ITransactionValidationService.ts @@ -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; }; diff --git a/postman/src/services/EthereumTransactionValidationService.ts b/postman/src/services/EthereumTransactionValidationService.ts index 6b0a6adf..458e274a 100644 --- a/postman/src/services/EthereumTransactionValidationService.ts +++ b/postman/src/services/EthereumTransactionValidationService.ts @@ -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 { 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; + } } diff --git a/postman/src/services/LineaTransactionValidationService.ts b/postman/src/services/LineaTransactionValidationService.ts index 846bc310..9390824d 100644 --- a/postman/src/services/LineaTransactionValidationService.ts +++ b/postman/src/services/LineaTransactionValidationService.ts @@ -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. * diff --git a/postman/src/services/persistence/LineaMessageDBService.ts b/postman/src/services/persistence/LineaMessageDBService.ts index de5edbad..c549ce68 100644 --- a/postman/src/services/persistence/LineaMessageDBService.ts +++ b/postman/src/services/persistence/LineaMessageDBService.ts @@ -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 { /** * 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, - ) { + constructor(messageRepository: IMessageRepository) { super(messageRepository); } @@ -67,40 +47,12 @@ export class LineaMessageDBService extends MessageDBService implements IMessageD maxRetry: number, retryDelay: number, ): Promise { - 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, - }; - } } diff --git a/postman/src/services/processors/MessageClaimingProcessor.ts b/postman/src/services/processors/MessageClaimingProcessor.ts index f210ebfb..b5ccf4f4 100644 --- a/postman/src/services/processors/MessageClaimingProcessor.ts +++ b/postman/src/services/processors/MessageClaimingProcessor.ts @@ -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; diff --git a/postman/src/services/processors/__tests__/EthereumTransactionValidationService.test.ts b/postman/src/services/processors/__tests__/EthereumTransactionValidationService.test.ts index b36f8bd5..ebafa0fa 100644 --- a/postman/src/services/processors/__tests__/EthereumTransactionValidationService.test.ts +++ b/postman/src/services/processors/__tests__/EthereumTransactionValidationService.test.ts @@ -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); + }); + }); }); }); diff --git a/postman/src/services/processors/__tests__/LineaTransactionValidationService.test.ts b/postman/src/services/processors/__tests__/LineaTransactionValidationService.test.ts index 0bf9fc78..24df07ac 100644 --- a/postman/src/services/processors/__tests__/LineaTransactionValidationService.test.ts +++ b/postman/src/services/processors/__tests__/LineaTransactionValidationService.test.ts @@ -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; + 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(); 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); + }); + }); }); }); diff --git a/postman/src/services/processors/__tests__/MessageClaimingProcessor.test.ts b/postman/src/services/processors/__tests__/MessageClaimingProcessor.test.ts index 5897ab9b..52b8fbdf 100644 --- a/postman/src/services/processors/__tests__/MessageClaimingProcessor.test.ts +++ b/postman/src/services/processors/__tests__/MessageClaimingProcessor.test.ts @@ -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); + }); + }); }); diff --git a/postman/src/utils/testing/constants.ts b/postman/src/utils/testing/constants.ts index e44be8d0..72cfc1f7 100644 --- a/postman/src/utils/testing/constants.ts +++ b/postman/src/utils/testing/constants.ts @@ -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,