chore: Initial commit

Co-authored-by: Franklin Delehelle <franklin.delehelle@odena.eu>
Co-authored-by: Alexandre Belling <alexandrebelling8@gmail.com>
Co-authored-by: Pedro Novais <jpvnovais@gmail.com>
Co-authored-by: Roman Vaseev <4833306+Filter94@users.noreply.github.com>
Co-authored-by: Bradley Bown <bradbown@googlemail.com>
Co-authored-by: Victorien Gauch <85494462+VGau@users.noreply.github.com>
Co-authored-by: Nikolai Golub <nikolai.golub@consensys.net>
Co-authored-by: The Dark Jester <thedarkjester@users.noreply.github.com>
Co-authored-by: jonesho <81145364+jonesho@users.noreply.github.com>
Co-authored-by: Gaurav Ahuja <gauravahuja9@gmail.com>
Co-authored-by: Azam Soleimanian <49027816+Soleimani193@users.noreply.github.com>
Co-authored-by: Andrei A <andrei.alexandru@consensys.net>
Co-authored-by: Arijit Dutta <37040536+arijitdutta67@users.noreply.github.com>
Co-authored-by: Gautam Botrel <gautam.botrel@gmail.com>
Co-authored-by: Ivo Kubjas <ivo.kubjas@consensys.net>
Co-authored-by: gusiri <dreamerty@postech.ac.kr>
Co-authored-by: FlorianHuc <florian.huc@gmail.com>
Co-authored-by: Arya Tabaie <arya.pourtabatabaie@gmail.com>
Co-authored-by: Julink <julien.fontanel@consensys.net>
Co-authored-by: Bogdan Ursu <bogdanursuoffice@gmail.com>
Co-authored-by: Jakub Trąd <jakubtrad@gmail.com>
Co-authored-by: Alessandro Sforzin <alessandro.sforzin@consensys.net>
Co-authored-by: Olivier Bégassat <olivier.begassat.cours@gmail.com>
Co-authored-by: Steve Huang <97596526+stevehuangc7s@users.noreply.github.com>
Co-authored-by: bkolad <blazejkolad@gmail.com>
Co-authored-by: fadyabuhatoum1 <139905934+fadyabuhatoum1@users.noreply.github.com>
Co-authored-by: Blas Rodriguez Irizar <rodrigblas@gmail.com>
Co-authored-by: Eduardo Andrade <eduardofandrade@gmail.com>
Co-authored-by: Ivo Kubjas <tsimmm@gmail.com>
Co-authored-by: Ludcour <ludovic.courcelas@consensys.net>
Co-authored-by: m4sterbunny <harrie.bickle@consensys.net>
Co-authored-by: Alex Panayi <145478258+alexandrospanayi@users.noreply.github.com>
Co-authored-by: Diana Borbe - ConsenSys <diana.borbe@consensys.net>
Co-authored-by: ThomasPiellard <thomas.piellard@gmail.com>
This commit is contained in:
Julien Marchand
2024-07-31 18:16:31 +02:00
commit a001342170
2702 changed files with 695073 additions and 0 deletions

41
sdk/.env.sample Normal file
View File

@@ -0,0 +1,41 @@
L1_RPC_URL=http://localhost:8445
L1_CONTRACT_ADDRESS=0x378041D4A8C8392cF26d5D86eB1E84A880b0E0F2
L1_SIGNER_PRIVATE_KEY=
L1_LISTENER_INTERVAL=4000
L1_LISTENER_INITIAL_FROM_BLOCK=0
L1_LISTENER_BLOCK_CONFIRMATION=4
L1_MAX_BLOCKS_TO_FETCH_LOGS=1000
L1_MAX_GAS_FEE_ENFORCED=false
L2_RPC_URL=http://localhost:8545
L2_CONTRACT_ADDRESS=0x5767aB2Ed64666bFE27e52D4675EDd60Ec7D6EDF
L2_SIGNER_PRIVATE_KEY=
L2_LISTENER_INTERVAL=4000
L2_LISTENER_INITIAL_FROM_BLOCK=0
L2_LISTENER_BLOCK_CONFIRMATION=0
L2_MAX_BLOCKS_TO_FETCH_LOGS=1000
L2_MESSAGE_TREE_DEPTH=5
L2_MAX_GAS_FEE_ENFORCED=false
MESSAGE_SUBMISSION_TIMEOUT=300000
MAX_FETCH_MESSAGES_FROM_DB=1000
MAX_NONCE_DIFF=10000
MAX_FEE_PER_GAS=100000000000
GAS_ESTIMATION_PERCENTILE=50
PROFIT_MARGIN=1.0
MAX_NUMBER_OF_RETRIES=100
RETRY_DELAY_IN_SECONDS=30
MAX_CLAIM_GAS_LIMIT=100000
MAX_TX_RETRIES=20
L1_L2_EOA_ENABLED=true
L1_L2_CALLDATA_ENABLED=false
L1_L2_AUTO_CLAIM_ENABLED=true
L2_L1_EOA_ENABLED=true
L2_L1_CALLDATA_ENABLED=false
L2_L1_AUTO_CLAIM_ENABLED=true
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postman_db
DB_CLEANER_ENABLED=true
DB_CLEANING_INTERVAL=10000
DB_DAYS_BEFORE_NOW_TO_DELETE=1

3
sdk/.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
dist
node_modules
typechain

15
sdk/.eslintrc.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
extends: "../.eslintrc.js",
env: {
commonjs: true,
es2021: true,
node: true,
jest: true,
},
parserOptions: {
sourceType: "module",
},
rules: {
"prettier/prettier": "error",
},
};

3
sdk/.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
dist
node_modules
typechain

3
sdk/.prettierrc.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
...require('../.prettierrc.js'),
};

42
sdk/Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
FROM node:lts-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS builder
WORKDIR /usr/src/app
ARG GITHUB_API_ACCESS_TOKEN
ARG NATIVE_LIBS_RELEASE_TAG
ENV GITHUB_API_ACCESS_TOKEN=${GITHUB_API_ACCESS_TOKEN}
ENV NATIVE_LIBS_RELEASE_TAG=${NATIVE_LIBS_RELEASE_TAG}
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./
COPY ./sdk/package.json ./sdk/package.json
COPY ./ts-libs/linea-native-libs/package.json ./ts-libs/linea-native-libs/package.json
RUN --mount=type=cache,id=pnpm,target=/pnpm/store apt-get update && apt-get install -y --no-install-recommends python3 ca-certificates bash curl make g++ \
&& pnpm install --frozen-lockfile --prefer-offline --ignore-scripts \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
COPY ./sdk ./sdk
COPY ts-libs/linea-native-libs ./ts-libs/linea-native-libs
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm run build \
&& pnpm deploy --filter=./sdk --prod ./prod/sdk
FROM base AS production
ENV NODE_ENV=production
WORKDIR /usr/src/app
USER node
COPY --from=builder /usr/src/app/prod/sdk ./sdk
CMD [ "node", "./sdk/dist/scripts/runPostman.js" ]

13
sdk/LICENSE Normal file
View File

@@ -0,0 +1,13 @@
Copyright 2023 Consensys Software Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

44
sdk/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Linea SDK
## Description
The Linea SDK package is a comprehensive solution consisting of two distinct parts that facilitate message delivery between Ethereum and Linea networks. It provides functionality for interacting with contracts and retrieving; message status and information.
## Part 1: SDK
The Linea SDK is the first component of the package. It focuses on interacting with smart contracts on both Ethereum and Linea networks and provides custom functions to obtain message information. Notable features of the Linea SDK include:
- Feature 1: Getting contract instances and addresses
- Feature 2: Getting message information by message hash
- Feature 3: Getting messages by transaction hash
- Feature 4: Getting a message status by message hash
- Feature 5: Claiming messages
## Part 2: Postman
The Postman component is the second part of the package and enables the delivery of messages between Ethereum and Linea networks. It offers the following key features:
- Feature 1: Listening for message sent events on Ethereum
- Feature 2: Listening for message hash anchoring events to check if a message is ready to be claimed
- Feature 3: Automatic claiming of messages with a configurable retry mechanism
- Feature 4: Checking receipt status for each transaction
All messages are stored in a configurable Postgres DB.
## Installation
To install this package, execute the following command:
`npm install @consensys/linea-sdk`
## Usage
This package exposes two main classes for usage:
- The `PostmanServiceClient` class is used to run a Postman service for delivering messages.
- The `LineaSDK` class is used to interact with smart contracts on Ethereum and Linea (both Goerli and Mainnet).
## License
This package is licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for more information.

25
sdk/jest.config.js Normal file
View File

@@ -0,0 +1,25 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
testRegex: "test.ts$",
verbose: true,
collectCoverage: true,
collectCoverageFrom: ["src/**/*.ts"],
coverageReporters: ["html", "lcov", "text"],
testPathIgnorePatterns: [
"src/clients/blockchain/typechain",
"src/application/postman/persistence/migrations/",
"src/application/postman/persistence/repositories/",
"src/index.ts",
"src/utils/WinstonLogger.ts",
],
coveragePathIgnorePatterns: [
"src/clients/blockchain/typechain",
"src/application/postman/persistence/migrations/",
"src/application/postman/persistence/repositories/",
"src/index.ts",
"src/utils/WinstonLogger.ts",
"src/utils/testing/",
],
};

49
sdk/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@consensys/linea-sdk",
"version": "1.0.0",
"author": "Consensys Software Inc.",
"license": "Apache-2.0",
"description": "",
"main": "dist/lib/index.js",
"types": "dist/lib/index.d.ts",
"scripts": {
"lint:ts": "npx eslint '**/*.ts'",
"lint:ts:fix": "npx eslint --fix '**/*.ts'",
"prettier": "prettier -c '**/*.ts'",
"prettier:fix": "prettier -w '**/*.ts'",
"clean": "rimraf dist src/typechain node_modules coverage tsconfig.build.tsbuildinfo",
"build:pre": "pnpm run typechain",
"build": "pnpm run build:pre && tsc -p tsconfig.build.json",
"build:runSdk": "tsc ./scripts/runSdk.ts",
"typechain": "typechain --target ethers-v6 --out-dir ./src/clients/blockchain/typechain './src/clients/blockchain/abis/*.json'",
"test": "npx jest --bail --detectOpenHandles --forceExit",
"test:run": "ts-node ./scripts/runSdk.ts",
"lint:fix": "pnpm run lint:ts:fix && pnpm run prettier:fix"
},
"dependencies": {
"@consensys/linea-native-libs": "workspace:*",
"better-sqlite3": "9.6.0",
"class-validator": "0.14.1",
"dotenv": "16.4.5",
"ethers": "6.12.0",
"lru-cache": "10.2.2",
"pg": "8.11.3",
"typeorm": "0.3.20",
"typeorm-naming-strategies": "4.1.0",
"winston": "3.13.0"
},
"devDependencies": {
"@jest/globals": "29.7.0",
"@typechain/ethers-v6": "0.5.1",
"@types/jest": "29.5.12",
"@types/yargs": "17.0.32",
"jest": "29.7.0",
"jest-mock-extended": "3.0.5",
"ts-jest": "29.1.2",
"typechain": "8.3.2",
"yargs": "17.7.2"
},
"files": [
"dist/**/*"
]
}

32
sdk/scripts/cli.ts Normal file
View File

@@ -0,0 +1,32 @@
const HEXADECIMAL_REGEX = new RegExp("^0[xX][0-9a-fA-F]+$");
const ADDRESS_HEX_STR_SIZE = 42;
const PRIVKEY_HEX_STR_SIZE = 66;
function sanitizeHexBytes(paramName: string, value: string, expectedSize: number) {
if (!value.startsWith("0x")) {
value = "0x" + value;
}
if (!HEXADECIMAL_REGEX.test(value)) {
throw new Error(`${paramName}: '${value}' is not a valid Hexadecimal notation!`);
}
if (value.length !== expectedSize) {
throw new Error(`${paramName} has size ${value.length} expected ${expectedSize}`);
}
return value;
}
function sanitizeAddress(argName: string) {
return (input: string) => {
return sanitizeHexBytes(argName, input, ADDRESS_HEX_STR_SIZE);
};
}
function sanitizePrivKey(argName: string) {
return (input: string) => {
return sanitizeHexBytes(argName, input, PRIVKEY_HEX_STR_SIZE);
};
}
export { sanitizeHexBytes, sanitizeAddress, sanitizePrivKey };

View File

@@ -0,0 +1,156 @@
import { BaseContract, ContractFactory, JsonRpcProvider, Wallet, ethers } from "ethers";
import { config } from "dotenv";
import fs from "fs";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import {
ZkEvmV2__factory,
TransparentUpgradeableProxy__factory,
ProxyAdmin__factory,
PlonkVerifier__factory,
L2MessageService__factory,
} from "../src/clients/blockchain/typechain/factories";
import { ProxyAdmin } from "../src/clients/blockchain/typechain/ProxyAdmin";
import { sanitizePrivKey } from "./cli";
config();
const argv = yargs(hideBin(process.argv))
.option("l1-rpc-url", {
describe: "L1 rpc url",
type: "string",
demandOption: true,
})
.option("l2-rpc-url", {
describe: "L2 rpc url",
type: "string",
demandOption: true,
})
.option("l1-deployer-priv-key", {
describe: "L1 deployer private key",
type: "string",
demandOption: true,
coerce: sanitizePrivKey("priv-key"),
})
.option("l2-deployer-priv-key", {
describe: "L2 deployer private key",
type: "string",
demandOption: true,
coerce: sanitizePrivKey("priv-key"),
})
.parseSync();
const getInitializerData = (contractInterface: ethers.Interface, args: unknown[]) => {
const initializer = "initialize";
const fragment = contractInterface.getFunction(initializer);
return contractInterface.encodeFunctionData(fragment!, args);
};
const deployUpgradableContract = async <T extends ContractFactory>(
contractFactory: T,
deployer: Wallet,
admin: ProxyAdmin,
initializerData = "0x",
): Promise<BaseContract> => {
const instance = await contractFactory.connect(deployer).deploy();
await instance.waitForDeployment();
const proxy = await new TransparentUpgradeableProxy__factory()
.connect(deployer)
.deploy(await instance.getAddress(), await admin.getAddress(), initializerData);
await proxy.waitForDeployment();
return instance.attach(await proxy.getAddress());
};
const deployLineaRollup = async (deployer: Wallet): Promise<{ zkevmV2ContractAddress: string }> => {
const proxyFactory = new ProxyAdmin__factory(deployer);
const proxyAdmin = await proxyFactory.connect(deployer).deploy();
await proxyAdmin.waitForDeployment();
console.log(`ProxyAdmin contract deployed at address: ${await proxyAdmin.getAddress()}`);
const plonkVerifierFactory = new PlonkVerifier__factory(deployer);
const plonkVerifier = await plonkVerifierFactory.deploy();
await plonkVerifier.waitForDeployment();
console.log(`PlonkVerifier contract deployed at address: ${await plonkVerifier.getAddress()}`);
const zkevmV2Contract = await deployUpgradableContract(
new ZkEvmV2__factory(deployer),
deployer,
proxyAdmin,
getInitializerData(ZkEvmV2__factory.createInterface(), [
ethers.ZeroHash,
0,
await plonkVerifier.getAddress(),
deployer.address,
[deployer.address],
86400,
ethers.parseEther("5"),
]),
);
const zkEvmV2Address = await zkevmV2Contract.getAddress();
console.log(`ZkEvmV2 contract deployed at address: ${zkEvmV2Address}`);
return { zkevmV2ContractAddress: zkEvmV2Address };
};
const deployL2MessageService = async (deployer: Wallet): Promise<string> => {
const proxyFactory = new ProxyAdmin__factory(deployer);
const proxyAdmin = await proxyFactory.connect(deployer).deploy();
await proxyAdmin.waitForDeployment();
console.log(`L2 ProxyAdmin contract deployed at address: ${await proxyAdmin.getAddress()}`);
const l2MessageService = await deployUpgradableContract(
new L2MessageService__factory(deployer),
deployer,
proxyAdmin,
getInitializerData(L2MessageService__factory.createInterface(), [
deployer.address,
deployer.address,
86400,
ethers.parseEther("5"),
]),
);
const l2MessageServiceAddress = await l2MessageService.getAddress();
console.log(`L2MessageService contract deployed at address: ${l2MessageServiceAddress}`);
return l2MessageServiceAddress;
};
const main = async (args: typeof argv) => {
const l1Provider = new JsonRpcProvider(args.l1RpcUrl);
const l2Provider = new JsonRpcProvider(args.l2RpcUrl);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const l1Deployer = new Wallet(args.l1DeployerPrivKey, l1Provider);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const l2Deployer = new Wallet(args.l2DeployerPrivKey, l2Provider);
const { zkevmV2ContractAddress } = await deployLineaRollup(l1Deployer);
const l2MessageServiceAddress = await deployL2MessageService(l2Deployer);
const tx = await l2Deployer.sendTransaction({
to: l2MessageServiceAddress,
value: ethers.parseEther("1000"),
data: "0x",
});
await tx.wait();
const tx2 = await l1Deployer.sendTransaction({
to: zkevmV2ContractAddress,
value: ethers.parseEther("1000"),
data: "0x",
});
await tx2.wait();
fs.writeFileSync(
"./scripts/contractAddresses.json",
JSON.stringify({ zkevmV2ContractAddress, l2MessageServiceAddress }, null, 2),
);
};
main(argv)
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

18
sdk/scripts/helpers.ts Normal file
View File

@@ -0,0 +1,18 @@
import { ethers } from "ethers";
export async function encodeSendMessage(
sender: string,
receiver: string,
fee: bigint,
amount: bigint,
messageNonce: bigint,
calldata: string,
) {
const abiCoder = new ethers.AbiCoder();
const data = abiCoder.encode(
["address", "address", "uint256", "uint256", "uint256", "bytes"],
[sender, receiver, fee, amount, messageNonce, calldata],
);
return ethers.keccak256(data);
}

130
sdk/scripts/runPostman.ts Normal file
View File

@@ -0,0 +1,130 @@
import * as dotenv from "dotenv";
import { transports } from "winston";
import { PostmanServiceClient } from "../src/application/postman/app/PostmanServiceClient";
dotenv.config();
async function main() {
const client = new PostmanServiceClient({
l1Options: {
rpcUrl: process.env.L1_RPC_URL ?? "",
messageServiceContractAddress: process.env.L1_CONTRACT_ADDRESS ?? "",
isEOAEnabled: process.env.L1_L2_EOA_ENABLED === "true",
isCalldataEnabled: process.env.L1_L2_CALLDATA_ENABLED === "true",
listener: {
pollingInterval: process.env.L1_LISTENER_INTERVAL ? parseInt(process.env.L1_LISTENER_INTERVAL) : undefined,
maxFetchMessagesFromDb: process.env.MAX_FETCH_MESSAGES_FROM_DB
? parseInt(process.env.MAX_FETCH_MESSAGES_FROM_DB)
: undefined,
maxBlocksToFetchLogs: process.env.L1_MAX_BLOCKS_TO_FETCH_LOGS
? parseInt(process.env.L1_MAX_BLOCKS_TO_FETCH_LOGS)
: undefined,
...(parseInt(process.env.L1_LISTENER_INITIAL_FROM_BLOCK ?? "") >= 0
? { initialFromBlock: parseInt(process.env.L1_LISTENER_INITIAL_FROM_BLOCK ?? "") }
: {}),
...(parseInt(process.env.L1_LISTENER_BLOCK_CONFIRMATION ?? "") >= 0
? { blockConfirmation: parseInt(process.env.L1_LISTENER_BLOCK_CONFIRMATION ?? "") }
: {}),
},
claiming: {
signerPrivateKey: process.env.L1_SIGNER_PRIVATE_KEY ?? "",
messageSubmissionTimeout: process.env.MESSAGE_SUBMISSION_TIMEOUT
? parseInt(process.env.MESSAGE_SUBMISSION_TIMEOUT)
: undefined,
maxNonceDiff: process.env.MAX_NONCE_DIFF ? parseInt(process.env.MAX_NONCE_DIFF) : undefined,
maxFeePerGas: process.env.MAX_FEE_PER_GAS ? BigInt(process.env.MAX_FEE_PER_GAS) : undefined,
gasEstimationPercentile: process.env.GAS_ESTIMATION_PERCENTILE
? parseInt(process.env.GAS_ESTIMATION_PERCENTILE)
: undefined,
profitMargin: process.env.PROFIT_MARGIN ? parseFloat(process.env.PROFIT_MARGIN) : undefined,
maxNumberOfRetries: process.env.MAX_NUMBER_OF_RETRIES ? parseInt(process.env.MAX_NUMBER_OF_RETRIES) : undefined,
retryDelayInSeconds: process.env.RETRY_DELAY_IN_SECONDS
? parseInt(process.env.RETRY_DELAY_IN_SECONDS)
: undefined,
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",
},
},
l2Options: {
rpcUrl: process.env.L2_RPC_URL ?? "",
messageServiceContractAddress: process.env.L2_CONTRACT_ADDRESS ?? "",
isEOAEnabled: process.env.L2_L1_EOA_ENABLED === "true",
isCalldataEnabled: process.env.L2_L1_CALLDATA_ENABLED === "true",
listener: {
pollingInterval: process.env.L2_LISTENER_INTERVAL ? parseInt(process.env.L2_LISTENER_INTERVAL) : undefined,
maxFetchMessagesFromDb: process.env.MAX_FETCH_MESSAGES_FROM_DB
? parseInt(process.env.MAX_FETCH_MESSAGES_FROM_DB)
: undefined,
maxBlocksToFetchLogs: process.env.L2_MAX_BLOCKS_TO_FETCH_LOGS
? parseInt(process.env.L2_MAX_BLOCKS_TO_FETCH_LOGS)
: undefined,
...(parseInt(process.env.L2_LISTENER_INITIAL_FROM_BLOCK ?? "") >= 0
? { initialFromBlock: parseInt(process.env.L2_LISTENER_INITIAL_FROM_BLOCK ?? "") }
: {}),
...(parseInt(process.env.L2_LISTENER_BLOCK_CONFIRMATION ?? "") >= 0
? { blockConfirmation: parseInt(process.env.L2_LISTENER_BLOCK_CONFIRMATION ?? "") }
: {}),
},
claiming: {
signerPrivateKey: process.env.L2_SIGNER_PRIVATE_KEY ?? "",
messageSubmissionTimeout: process.env.MESSAGE_SUBMISSION_TIMEOUT
? parseInt(process.env.MESSAGE_SUBMISSION_TIMEOUT)
: undefined,
maxNonceDiff: process.env.MAX_NONCE_DIFF ? parseInt(process.env.MAX_NONCE_DIFF) : undefined,
maxFeePerGas: process.env.MAX_FEE_PER_GAS ? BigInt(process.env.MAX_FEE_PER_GAS) : undefined,
gasEstimationPercentile: process.env.GAS_ESTIMATION_PERCENTILE
? parseInt(process.env.GAS_ESTIMATION_PERCENTILE)
: undefined,
profitMargin: process.env.PROFIT_MARGIN ? parseFloat(process.env.PROFIT_MARGIN) : undefined,
maxNumberOfRetries: process.env.MAX_NUMBER_OF_RETRIES ? parseInt(process.env.MAX_NUMBER_OF_RETRIES) : undefined,
retryDelayInSeconds: process.env.RETRY_DELAY_IN_SECONDS
? parseInt(process.env.RETRY_DELAY_IN_SECONDS)
: undefined,
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",
},
l2MessageTreeDepth: process.env.L2_MESSAGE_TREE_DEPTH ? parseInt(process.env.L2_MESSAGE_TREE_DEPTH) : undefined,
enableLineaEstimateGas: process.env.ENABLE_LINEA_ESTIMATE_GAS === "true",
},
l1L2AutoClaimEnabled: process.env.L1_L2_AUTO_CLAIM_ENABLED === "true",
l2L1AutoClaimEnabled: process.env.L2_L1_AUTO_CLAIM_ENABLED === "true",
loggerOptions: {
level: "info",
transports: [new transports.Console()],
},
databaseOptions: {
type: "postgres",
host: process.env.POSTGRES_HOST ?? "127.0.0.1",
port: parseInt(process.env.POSTGRES_PORT ?? "5432"),
username: process.env.POSTGRES_USER ?? "postgres",
password: process.env.POSTGRES_PASSWORD ?? "postgres",
database: process.env.POSTGRES_DB ?? "postman_db",
},
databaseCleanerOptions: {
enabled: process.env.DB_CLEANER_ENABLED === "true",
cleaningInterval: process.env.DB_CLEANING_INTERVAL ? parseInt(process.env.DB_CLEANING_INTERVAL) : undefined,
daysBeforeNowToDelete: process.env.DB_DAYS_BEFORE_NOW_TO_DELETE
? parseInt(process.env.DB_DAYS_BEFORE_NOW_TO_DELETE)
: undefined,
},
});
await client.connectDatabase();
client.startAllServices();
}
main()
.then()
.catch((error) => {
console.error("", error);
process.exit(1);
});
process.on("SIGINT", () => {
process.exit(0);
});
process.on("SIGTERM", () => {
process.exit(0);
});

42
sdk/scripts/runSdk.ts Normal file
View File

@@ -0,0 +1,42 @@
import * as dotenv from "dotenv";
import { LineaSDK } from "../src";
import { ZERO_ADDRESS } from "../src/core/constants";
import { Direction, MessageStatus } from "../src/core/enums/MessageEnums";
dotenv.config();
async function main() {
const sdk = new LineaSDK({
l1RpcUrlOrProvider: process.env.L1_RPC_URL ?? "",
l2RpcUrlOrProvider: process.env.L2_RPC_URL ?? "",
network: "linea-sepolia",
mode: "read-only",
});
const l2Contract = sdk.getL2Contract();
const data = l2Contract.encodeClaimMessageTransactionData({
messageSender: "0x5eEeA0e70FFE4F5419477056023c4b0acA016562",
destination: "0x5eEeA0e70FFE4F5419477056023c4b0acA016562",
fee: 0n,
value: 100000000000000000n,
feeRecipient: ZERO_ADDRESS,
calldata: "0x",
messageNonce: 3105n,
messageHash: "",
contractAddress: "",
sentBlockNumber: 0,
direction: Direction.L1_TO_L2,
status: MessageStatus.SENT,
claimNumberOfRetry: 0,
});
console.log(data);
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,197 @@
import { BytesLike, ContractTransactionReceipt, Overrides, Wallet, JsonRpcProvider } from "ethers";
import { config } from "dotenv";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { SendMessageArgs } from "./types";
import { sanitizeAddress, sanitizePrivKey } from "./cli";
import {
L2MessageService,
L2MessageService__factory,
ZkEvmV2__factory,
ZkEvmV2,
LineaRollup,
LineaRollup__factory,
} from "../src/clients/blockchain/typechain";
import { encodeSendMessage } from "./helpers";
config();
const argv = yargs(hideBin(process.argv))
.option("l1-rpc-url", {
describe: "Rpc url",
type: "string",
demandOption: true,
})
.option("l2-rpc-url", {
describe: "Rpc url",
type: "string",
demandOption: true,
})
.option("l1-priv-key", {
describe: "Signer private key on L1",
type: "string",
demandOption: true,
coerce: sanitizePrivKey("priv-key"),
})
.option("l2-priv-key", {
describe: "Signer private key on L2 from account with L1_L2_MESSAGE_SETTER_ROLE",
type: "string",
demandOption: false,
coerce: sanitizePrivKey("priv-key"),
})
.option("l1-contract-address", {
describe: "Smart contract address",
type: "string",
demandOption: true,
coerce: sanitizeAddress("smc-address"),
})
.option("l2-contract-address", {
describe: "Smart contract address",
type: "string",
demandOption: true,
coerce: sanitizeAddress("smc-address"),
})
.option("to", {
describe: "Destination address",
type: "string",
demandOption: true,
coerce: sanitizeAddress("to"),
})
.option("fee", {
describe: "Fee passed to send message function",
type: "number",
demandOption: true,
})
.option("value", {
describe: "Value passed to send message function",
type: "number",
demandOption: true,
})
.option("calldata", {
describe: "Encoded message calldata",
type: "string",
demandOption: true,
})
.option("number-of-message", {
describe: "Number of messages to send",
type: "number",
demandOption: true,
})
.option("auto-anchoring", {
describe: "Auto anchoring",
type: "boolean",
demandOption: false,
default: false,
})
.parseSync();
const sendMessage = async (
contract: ZkEvmV2,
args: SendMessageArgs,
overrides: Overrides = {},
): Promise<ContractTransactionReceipt | null> => {
const tx = await contract.sendMessage(args.to, args.fee, args.calldata, overrides);
return await tx.wait();
};
const sendMessages = async (
contract: ZkEvmV2,
signer: Wallet,
numberOfMessages: number,
args: SendMessageArgs,
overrides?: Overrides,
) => {
let nonce = await signer.getNonce();
const sendMessagePromises: Promise<ContractTransactionReceipt | null>[] = [];
for (let i = 0; i < numberOfMessages; i++) {
sendMessagePromises.push(
sendMessage(contract, args, {
...overrides,
nonce,
}),
);
nonce++;
}
await Promise.all(sendMessagePromises);
};
const getMessageCounter = async (contractAddress: string, signer: Wallet) => {
const lineaRollup = ZkEvmV2__factory.connect(contractAddress, signer) as ZkEvmV2;
return lineaRollup.nextMessageNumber();
};
const anchorMessageHashesOnL2 = async (
lineaRollup: LineaRollup,
l2MessageService: L2MessageService,
messageHashes: BytesLike[],
startingMessageNumber: bigint,
) => {
const finalMessageNumber = startingMessageNumber + BigInt(messageHashes.length - 1);
const rollingHashes = await lineaRollup.rollingHashes(finalMessageNumber);
const tx = await l2MessageService.anchorL1L2MessageHashes(
messageHashes,
startingMessageNumber,
finalMessageNumber,
rollingHashes,
);
await tx.wait();
};
const main = async (args: typeof argv) => {
if (args.autoAnchoring && !args.l2PrivKey) {
console.error(
`private key from an L2 account with L1_L2_MESSAGE_SETTER_ROLE must be given if auto-anchoring is true`,
);
return;
}
const l1Provider = new JsonRpcProvider(args.l1RpcUrl);
const l2Provider = new JsonRpcProvider(args.l2RpcUrl);
const l1Signer = new Wallet(args.l1PrivKey, l1Provider);
const functionArgs: SendMessageArgs & Overrides = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
to: args.to,
fee: BigInt(args.fee.toString()),
calldata: args.calldata,
};
const zkEvmV2 = ZkEvmV2__factory.connect(args.l1ContractAddress, l1Signer) as ZkEvmV2;
await sendMessages(zkEvmV2, l1Signer, args.numberOfMessage, functionArgs, { value: BigInt(args.value.toString()) });
// Anchor messages hash on L2
const nextMessageCounter = await getMessageCounter(args.l1ContractAddress, l1Signer);
const startCounter = nextMessageCounter - BigInt(args.numberOfMessage);
const messageHashesToAnchor: string[] = [];
for (let i = startCounter; i < nextMessageCounter; i++) {
const messageHash = await encodeSendMessage(
l1Signer.address,
args.to,
BigInt(args.fee.toString()),
BigInt(args.value.toString()) - BigInt(args.fee.toString()),
BigInt(i),
args.calldata,
);
console.log(messageHash);
messageHashesToAnchor.push(messageHash);
}
if (!args.autoAnchoring) return;
const l2Signer = new Wallet(args.l2PrivKey!, l2Provider);
const lineaRollup = LineaRollup__factory.connect(args.l1ContractAddress, l1Signer) as LineaRollup;
const l2MessageService = L2MessageService__factory.connect(args.l2ContractAddress, l2Signer) as L2MessageService;
const startingMessageNumber = startCounter;
await anchorMessageHashesOnL2(lineaRollup, l2MessageService, messageHashesToAnchor, startingMessageNumber);
};
main(argv)
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,109 @@
import { ContractTransactionReceipt, Overrides, JsonRpcProvider, Wallet } from "ethers";
import { config } from "dotenv";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { SendMessageArgs } from "./types";
import { sanitizeAddress, sanitizePrivKey } from "./cli";
import { L2MessageService, L2MessageService__factory } from "../src/clients/blockchain/typechain";
config();
const argv = yargs(hideBin(process.argv))
.option("rpc-url", {
describe: "Rpc url",
type: "string",
demandOption: true,
})
.option("priv-key", {
describe: "Signer private key",
type: "string",
demandOption: true,
coerce: sanitizePrivKey("priv-key"),
})
.option("contract-address", {
describe: "Smart contract address",
type: "string",
demandOption: true,
coerce: sanitizeAddress("smc-address"),
})
.option("to", {
describe: "Destination address",
type: "string",
demandOption: true,
coerce: sanitizeAddress("to"),
})
.option("fee", {
describe: "Fee passed to send message function",
type: "string",
})
.option("value", {
describe: "Value passed to send message function",
type: "string",
})
.option("calldata", {
describe: "Encoded message calldata",
type: "string",
demandOption: true,
})
.option("number-of-message", {
describe: "Number of messages to send",
type: "number",
demandOption: true,
})
.parseSync();
const sendMessage = async (
contract: L2MessageService,
args: SendMessageArgs,
overrides: Overrides = {},
): Promise<ContractTransactionReceipt | null> => {
const tx = await contract.sendMessage(args.to, args.fee, args.calldata, overrides);
return await tx.wait();
};
const sendMessages = async (
contract: L2MessageService,
signer: Wallet,
numberOfMessages: number,
args: SendMessageArgs,
overrides?: Overrides,
) => {
let nonce = await signer.getNonce();
const sendMessagePromises: Promise<ContractTransactionReceipt | null>[] = [];
for (let i = 0; i < numberOfMessages; i++) {
sendMessagePromises.push(
sendMessage(contract, args, {
...overrides,
nonce,
}),
);
nonce++;
}
await Promise.all(sendMessagePromises);
};
const main = async (args: typeof argv) => {
const provider = new JsonRpcProvider(args.rpcUrl);
const signer = new Wallet(args.privKey, provider);
const functionArgs: SendMessageArgs & Overrides = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
to: args.to,
fee: BigInt(args.fee!),
calldata: args.calldata,
value: args.value,
};
const l2MessageService = L2MessageService__factory.connect(args.contractAddress, signer) as L2MessageService;
await sendMessages(l2MessageService, signer, args.numberOfMessage, functionArgs, { value: args.value });
};
main(argv)
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});

7
sdk/scripts/types.ts Normal file
View File

@@ -0,0 +1,7 @@
import { BytesLike } from "ethers";
export type SendMessageArgs = {
to: string;
fee: bigint;
calldata: BytesLike;
};

View File

@@ -0,0 +1,481 @@
import { Wallet, JsonRpcProvider } from "ethers";
import { DataSource } from "typeorm";
import { ILogger } from "../../../core/utils/logging/ILogger";
import { DatabaseCleaner } from "../../../services/persistence/DatabaseCleaner";
import { TypeOrmMessageRepository } from "../persistence/repositories/TypeOrmMessageRepository";
import { LineaRollupClient } from "../../../clients/blockchain/ethereum/LineaRollupClient";
import { L2MessageServiceClient } from "../../../clients/blockchain/linea/L2MessageServiceClient";
import { EthersLineaRollupLogClient } from "../../../clients/blockchain/ethereum/EthersLineaRollupLogClient";
import { ChainQuerier } from "../../../clients/blockchain/ChainQuerier";
import { WinstonLogger } from "../../../utils/WinstonLogger";
import { EthersL2MessageServiceLogClient } from "../../../clients/blockchain/linea/EthersL2MessageServiceLogClient";
import { MessageSentEventPoller } from "../../../services/pollers/MessageSentEventPoller";
import { IPoller } from "../../../core/services/pollers/IPoller";
import { MessageAnchoringPoller } from "../../../services/pollers/MessageAnchoringPoller";
import { MessageAnchoringProcessor } from "../../../services/processors/MessageAnchoringProcessor";
import { PostmanOptions } from "./config/config";
import { DB } from "../persistence/dataSource";
import { Direction } from "../../../core/enums/MessageEnums";
import { MessageClaimingProcessor } from "../../../services/processors/MessageClaimingProcessor";
import { MessageClaimingPoller } from "../../../services/pollers/MessageClaimingPoller";
import { MessageClaimingPersister } from "../../../services/processors/MessageClaimingPersister";
import { MessagePersistingPoller } from "../../../services/pollers/MessagePersistingPoller";
import { MessageSentEventProcessor } from "../../../services/processors/MessageSentEventProcessor";
import { DatabaseCleaningPoller } from "../../../services/pollers/DatabaseCleaningPoller";
import { BaseError } from "../../../core/errors/Base";
import { LineaRollupMessageRetriever } from "../../../clients/blockchain/ethereum/LineaRollupMessageRetriever";
import { L2MessageServiceMessageRetriever } from "../../../clients/blockchain/linea/L2MessageServiceMessageRetriever";
import { MerkleTreeService } from "../../../clients/blockchain/ethereum/MerkleTreeService";
import { LineaMessageDBService } from "../../../services/persistence/LineaMessageDBService";
import { L2ChainQuerier } from "../../../clients/blockchain/linea/L2ChainQuerier";
import { EthereumMessageDBService } from "../../../services/persistence/EthereumMessageDBService";
import { L2ClaimMessageTransactionSizePoller } from "../../../services/pollers/L2ClaimMessageTransactionSizePoller";
import { L2ClaimMessageTransactionSizeProcessor } from "../../../services/processors/L2ClaimMessageTransactionSizeProcessor";
import { L2ClaimTransactionSizeCalculator } from "../../../services/L2ClaimTransactionSizeCalculator";
import { GasProvider } from "../../../clients/blockchain/gas/GasProvider";
import { LineaTransactionValidationService } from "../../../services/LineaTransactionValidationService";
import { EthereumTransactionValidationService } from "../../../services/EthereumTransactionValidationService";
import { getConfig } from "./config/utils";
export class PostmanServiceClient {
// L1 -> L2 flow
private l1MessageSentEventPoller: IPoller;
private l2MessageAnchoringPoller: IPoller;
private l2MessageClaimingPoller: IPoller;
private l2MessagePersistingPoller: IPoller;
private l2ClaimMessageTransactionSizePoller: IPoller;
// L2 -> L1 flow
private l2MessageSentEventPoller: IPoller;
private l1MessageAnchoringPoller: IPoller;
private l1MessageClaimingPoller: IPoller;
private l1MessagePersistingPoller: IPoller;
// Database Cleaner
private databaseCleaningPoller: IPoller;
private logger: ILogger;
private db: DataSource;
private l1L2AutoClaimEnabled: boolean;
private l2L1AutoClaimEnabled: boolean;
/**
* Initializes a new instance of the PostmanServiceClient.
*
* @param {PostmanOptions} options - Configuration options for the Postman service, including network details, database options, and logging configurations.
*/
constructor(options: PostmanOptions) {
const config = getConfig(options);
this.logger = new WinstonLogger(PostmanServiceClient.name, config.loggerOptions);
this.l1L2AutoClaimEnabled = config.l1L2AutoClaimEnabled;
this.l2L1AutoClaimEnabled = config.l2L1AutoClaimEnabled;
const l1Provider = new JsonRpcProvider(config.l1Config.rpcUrl);
const l2Provider = new JsonRpcProvider(config.l2Config.rpcUrl);
const l1Signer = this.getSigner(config.l1Config.claiming.signerPrivateKey, l1Provider);
const l2Signer = this.getSigner(config.l2Config.claiming.signerPrivateKey, l2Provider);
const l1Querier = new ChainQuerier(l1Provider, l1Signer);
const l2Querier = new L2ChainQuerier(l2Provider, l2Signer);
const lineaRollupLogClient = new EthersLineaRollupLogClient(
l1Provider,
config.l1Config.messageServiceContractAddress,
);
const l2MessageServiceLogClient = new EthersL2MessageServiceLogClient(
l2Provider,
config.l2Config.messageServiceContractAddress,
);
const l1GasProvider = new GasProvider(l1Querier, {
maxFeePerGas: config.l1Config.claiming.maxFeePerGas,
gasEstimationPercentile: config.l1Config.claiming.gasEstimationPercentile,
enforceMaxGasFee: config.l1Config.claiming.isMaxGasFeeEnforced,
enableLineaEstimateGas: false,
direction: Direction.L2_TO_L1,
});
const l2GasProvider = new GasProvider(l2Querier, {
maxFeePerGas: config.l2Config.claiming.maxFeePerGas,
gasEstimationPercentile: config.l2Config.claiming.gasEstimationPercentile,
enforceMaxGasFee: config.l2Config.claiming.isMaxGasFeeEnforced,
enableLineaEstimateGas: config.l2Config.enableLineaEstimateGas,
direction: Direction.L1_TO_L2,
});
const lineaRollupMessageRetriever = new LineaRollupMessageRetriever(
l1Querier,
lineaRollupLogClient,
config.l1Config.messageServiceContractAddress,
);
const l1MerkleTreeService = new MerkleTreeService(
l1Querier,
config.l1Config.messageServiceContractAddress,
lineaRollupLogClient,
l2MessageServiceLogClient,
config.l2Config.l2MessageTreeDepth,
);
const l2MessageServiceMessageRetriever = new L2MessageServiceMessageRetriever(
l2Querier,
l2MessageServiceLogClient,
config.l2Config.messageServiceContractAddress,
);
const l1MessageServiceContract = new LineaRollupClient(
l1Querier,
config.l1Config.messageServiceContractAddress,
lineaRollupLogClient,
l2MessageServiceLogClient,
l1GasProvider,
lineaRollupMessageRetriever,
l1MerkleTreeService,
"read-write",
l1Signer,
);
const l2MessageServiceContract = new L2MessageServiceClient(
l2Querier,
config.l2Config.messageServiceContractAddress,
l2MessageServiceMessageRetriever,
l2GasProvider,
"read-write",
l2Signer,
);
this.db = DB.create(config.databaseOptions);
const messageRepository = new TypeOrmMessageRepository(this.db);
const lineaMessageDBService = new LineaMessageDBService(l2Querier, messageRepository);
const ethereumMessageDBService = new EthereumMessageDBService(l1GasProvider, messageRepository);
// L1 -> L2 flow
const l1MessageSentEventProcessor = new MessageSentEventProcessor(
lineaMessageDBService,
lineaRollupLogClient,
l1Querier,
{
direction: Direction.L1_TO_L2,
maxBlocksToFetchLogs: config.l1Config.listener.maxBlocksToFetchLogs,
blockConfirmation: config.l1Config.listener.blockConfirmation,
isEOAEnabled: config.l1Config.isEOAEnabled,
isCalldataEnabled: config.l1Config.isCalldataEnabled,
},
new WinstonLogger(`L1${MessageSentEventProcessor.name}`, config.loggerOptions),
);
this.l1MessageSentEventPoller = new MessageSentEventPoller(
l1MessageSentEventProcessor,
l1Querier,
lineaMessageDBService,
{
direction: Direction.L1_TO_L2,
pollingInterval: config.l1Config.listener.pollingInterval,
initialFromBlock: config.l1Config.listener.initialFromBlock,
originContractAddress: config.l1Config.messageServiceContractAddress,
},
new WinstonLogger(`L1${MessageSentEventPoller.name}`, config.loggerOptions),
);
const l2MessageAnchoringProcessor = new MessageAnchoringProcessor(
l2MessageServiceContract,
l2Querier,
lineaMessageDBService,
{
maxFetchMessagesFromDb: config.l1Config.listener.maxFetchMessagesFromDb,
originContractAddress: config.l1Config.messageServiceContractAddress,
},
new WinstonLogger(`L2${MessageAnchoringProcessor.name}`, config.loggerOptions),
);
this.l2MessageAnchoringPoller = new MessageAnchoringPoller(
l2MessageAnchoringProcessor,
{
direction: Direction.L1_TO_L2,
pollingInterval: config.l2Config.listener.pollingInterval,
},
new WinstonLogger(`L2${MessageAnchoringPoller.name}`, config.loggerOptions),
);
const l2TransactionValidationService = new LineaTransactionValidationService(
{
profitMargin: config.l2Config.claiming.profitMargin,
maxClaimGasLimit: BigInt(config.l2Config.claiming.maxClaimGasLimit),
},
l2Querier,
l2MessageServiceContract,
);
const l2MessageClaimingProcessor = new MessageClaimingProcessor(
l2MessageServiceContract,
l2Querier,
lineaMessageDBService,
l2TransactionValidationService,
{
direction: Direction.L1_TO_L2,
originContractAddress: config.l1Config.messageServiceContractAddress,
maxNonceDiff: config.l2Config.claiming.maxNonceDiff,
feeRecipientAddress: config.l2Config.claiming.feeRecipientAddress,
profitMargin: config.l2Config.claiming.profitMargin,
maxNumberOfRetries: config.l2Config.claiming.maxNumberOfRetries,
retryDelayInSeconds: config.l2Config.claiming.retryDelayInSeconds,
maxClaimGasLimit: BigInt(config.l2Config.claiming.maxClaimGasLimit),
},
new WinstonLogger(`L2${MessageClaimingProcessor.name}`, config.loggerOptions),
);
this.l2MessageClaimingPoller = new MessageClaimingPoller(
l2MessageClaimingProcessor,
{
direction: Direction.L1_TO_L2,
pollingInterval: config.l2Config.listener.pollingInterval,
},
new WinstonLogger(`L2${MessageClaimingPoller.name}`, config.loggerOptions),
);
const l2MessageClaimingPersister = new MessageClaimingPersister(
lineaMessageDBService,
l2MessageServiceContract,
l2Querier,
{
direction: Direction.L1_TO_L2,
messageSubmissionTimeout: config.l2Config.claiming.messageSubmissionTimeout,
maxTxRetries: config.l2Config.claiming.maxTxRetries,
},
new WinstonLogger(`L2${MessageClaimingPersister.name}`, config.loggerOptions),
);
this.l2MessagePersistingPoller = new MessagePersistingPoller(
l2MessageClaimingPersister,
{
direction: Direction.L1_TO_L2,
pollingInterval: config.l2Config.listener.pollingInterval,
},
new WinstonLogger(`L2${MessagePersistingPoller.name}`, config.loggerOptions),
);
const transactionSizeCalculator = new L2ClaimTransactionSizeCalculator(l2MessageServiceContract);
const transactionSizeCompressor = new L2ClaimMessageTransactionSizeProcessor(
lineaMessageDBService,
l2MessageServiceContract,
transactionSizeCalculator,
{
direction: Direction.L1_TO_L2,
originContractAddress: config.l1Config.messageServiceContractAddress,
},
new WinstonLogger(`${L2ClaimMessageTransactionSizeProcessor.name}`, config.loggerOptions),
);
this.l2ClaimMessageTransactionSizePoller = new L2ClaimMessageTransactionSizePoller(
transactionSizeCompressor,
{
pollingInterval: config.l2Config.listener.pollingInterval,
},
new WinstonLogger(`${L2ClaimMessageTransactionSizePoller.name}`, config.loggerOptions),
);
// L2 -> L1 flow
const l2MessageSentEventProcessor = new MessageSentEventProcessor(
ethereumMessageDBService,
l2MessageServiceLogClient,
l2Querier,
{
direction: Direction.L2_TO_L1,
maxBlocksToFetchLogs: config.l2Config.listener.maxBlocksToFetchLogs,
blockConfirmation: config.l2Config.listener.blockConfirmation,
isEOAEnabled: config.l2Config.isEOAEnabled,
isCalldataEnabled: config.l2Config.isCalldataEnabled,
},
new WinstonLogger(`L2${MessageSentEventProcessor.name}`, config.loggerOptions),
);
this.l2MessageSentEventPoller = new MessageSentEventPoller(
l2MessageSentEventProcessor,
l2Querier,
ethereumMessageDBService,
{
direction: Direction.L2_TO_L1,
pollingInterval: config.l2Config.listener.pollingInterval,
initialFromBlock: config.l2Config.listener.initialFromBlock,
originContractAddress: config.l2Config.messageServiceContractAddress,
},
new WinstonLogger(`L2${MessageSentEventPoller.name}`, config.loggerOptions),
);
const l1MessageAnchoringProcessor = new MessageAnchoringProcessor(
l1MessageServiceContract,
l1Querier,
ethereumMessageDBService,
{
maxFetchMessagesFromDb: config.l1Config.listener.maxFetchMessagesFromDb,
originContractAddress: config.l2Config.messageServiceContractAddress,
},
new WinstonLogger(`L1${MessageAnchoringProcessor.name}`, config.loggerOptions),
);
this.l1MessageAnchoringPoller = new MessageAnchoringPoller(
l1MessageAnchoringProcessor,
{
direction: Direction.L2_TO_L1,
pollingInterval: config.l1Config.listener.pollingInterval,
},
new WinstonLogger(`L1${MessageAnchoringPoller.name}`, config.loggerOptions),
);
const l1TransactionValidationService = new EthereumTransactionValidationService(
l1MessageServiceContract,
l1GasProvider,
{
profitMargin: config.l1Config.claiming.profitMargin,
maxClaimGasLimit: BigInt(config.l1Config.claiming.maxClaimGasLimit),
},
);
const l1MessageClaimingProcessor = new MessageClaimingProcessor(
l1MessageServiceContract,
l1Querier,
ethereumMessageDBService,
l1TransactionValidationService,
{
direction: Direction.L2_TO_L1,
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),
);
this.l1MessageClaimingPoller = new MessageClaimingPoller(
l1MessageClaimingProcessor,
{
direction: Direction.L2_TO_L1,
pollingInterval: config.l1Config.listener.pollingInterval,
},
new WinstonLogger(`L1${MessageClaimingPoller.name}`, config.loggerOptions),
);
const l1MessageClaimingPersister = new MessageClaimingPersister(
ethereumMessageDBService,
l1MessageServiceContract,
l1Querier,
{
direction: Direction.L2_TO_L1,
messageSubmissionTimeout: config.l1Config.claiming.messageSubmissionTimeout,
maxTxRetries: config.l1Config.claiming.maxTxRetries,
},
new WinstonLogger(`L1${MessageClaimingPersister.name}`, config.loggerOptions),
);
this.l1MessagePersistingPoller = new MessagePersistingPoller(
l1MessageClaimingPersister,
{
direction: Direction.L2_TO_L1,
pollingInterval: config.l1Config.listener.pollingInterval,
},
new WinstonLogger(`L1${MessagePersistingPoller.name}`, config.loggerOptions),
);
// Database Cleaner
const databaseCleaner = new DatabaseCleaner(
ethereumMessageDBService,
new WinstonLogger(`${DatabaseCleaner.name}`, config.loggerOptions),
);
this.databaseCleaningPoller = new DatabaseCleaningPoller(
databaseCleaner,
new WinstonLogger(`${DatabaseCleaningPoller.name}`, config.loggerOptions),
{
enabled: config.databaseCleanerConfig.enabled,
daysBeforeNowToDelete: config.databaseCleanerConfig.daysBeforeNowToDelete,
cleaningInterval: config.databaseCleanerConfig.cleaningInterval,
},
);
}
/**
* Creates a Wallet instance as a signer using the provided private key and JSON RPC provider.
*
* @param {string} privateKey - The private key to use for the signer.
* @param {JsonRpcProvider} provider - The JSON RPC provider associated with the network.
* @returns {Wallet} A Wallet instance configured with the provided private key and provider.
*/
private getSigner(privateKey: string, provider: JsonRpcProvider): Wallet {
try {
return new Wallet(privateKey, provider);
} catch (e) {
throw new BaseError(
"Something went wrong when trying to generate Wallet. Please check your private key and the provider url.",
);
}
}
/**
* Initializes the database connection using the configuration provided.
*/
public async connectDatabase() {
await this.db.initialize();
}
/**
* Starts all configured services and pollers. This includes message event pollers for both L1 to L2 and L2 to L1 flows, message anchoring, claiming, persisting pollers, and the database cleaning poller.
*/
public startAllServices(): void {
if (this.l1L2AutoClaimEnabled) {
// L1 -> L2 flow
this.l1MessageSentEventPoller.start();
this.l2MessageAnchoringPoller.start();
this.l2MessageClaimingPoller.start();
this.l2MessagePersistingPoller.start();
this.l2ClaimMessageTransactionSizePoller.start();
}
if (this.l2L1AutoClaimEnabled) {
// L2 -> L1 flow
this.l2MessageSentEventPoller.start();
this.l1MessageAnchoringPoller.start();
this.l1MessageClaimingPoller.start();
this.l1MessagePersistingPoller.start();
}
// Database Cleaner
this.databaseCleaningPoller.start();
this.logger.info("All listeners and message deliverers have been started.");
}
/**
* Stops all running services and pollers to gracefully shut down the Postman service.
*/
public stopAllServices(): void {
if (this.l1L2AutoClaimEnabled) {
// L1 -> L2 flow
this.l1MessageSentEventPoller.stop();
this.l2MessageAnchoringPoller.stop();
this.l2MessageClaimingPoller.stop();
this.l2MessagePersistingPoller.stop();
this.l2ClaimMessageTransactionSizePoller.stop();
}
if (this.l2L1AutoClaimEnabled) {
// L2 -> L1 flow
this.l2MessageSentEventPoller.stop();
this.l1MessageAnchoringPoller.stop();
this.l1MessageClaimingPoller.stop();
this.l1MessagePersistingPoller.stop();
}
// Database Cleaner
this.databaseCleaningPoller.stop();
this.logger.info("All listeners and message deliverers have been stopped.");
}
}

View File

@@ -0,0 +1,197 @@
import { describe, it } from "@jest/globals";
import { mockClear } from "jest-mock-extended";
import { DataSource } from "typeorm";
import { SnakeNamingStrategy } from "typeorm-naming-strategies";
import { PostmanServiceClient } from "../PostmanServiceClient";
import {
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
TEST_L1_SIGNER_PRIVATE_KEY,
TEST_L2_SIGNER_PRIVATE_KEY,
TEST_RPC_URL,
} from "../../../../utils/testing/constants";
import { WinstonLogger } from "../../../../utils/WinstonLogger";
import { PostmanOptions } from "../config/config";
import { MessageEntity } from "../../persistence/entities/Message.entity";
import { InitialDatabaseSetup1685985945638 } from "../../persistence/migrations/1685985945638-InitialDatabaseSetup";
import { AddNewColumns1687890326970 } from "../../persistence/migrations/1687890326970-AddNewColumns";
import { UpdateStatusColumn1687890694496 } from "../../persistence/migrations/1687890694496-UpdateStatusColumn";
import { RemoveUniqueConstraint1689084924789 } from "../../persistence/migrations/1689084924789-RemoveUniqueConstraint";
import { AddNewIndexes1701265652528 } from "../../persistence/migrations/1701265652528-AddNewIndexes";
import { MessageSentEventPoller } from "../../../../services/pollers/MessageSentEventPoller";
import { MessageAnchoringPoller } from "../../../../services/pollers/MessageAnchoringPoller";
import { MessageClaimingPoller } from "../../../../services/pollers/MessageClaimingPoller";
import { MessagePersistingPoller } from "../../../../services/pollers/MessagePersistingPoller";
import { DatabaseCleaningPoller } from "../../../../services/pollers/DatabaseCleaningPoller";
import { TypeOrmMessageRepository } from "../../persistence/repositories/TypeOrmMessageRepository";
import { L2ClaimMessageTransactionSizePoller } from "../../../../services/pollers/L2ClaimMessageTransactionSizePoller";
import { DEFAULT_MAX_CLAIM_GAS_LIMIT } from "../../../../core/constants";
jest.mock("ethers", () => {
const allAutoMocked = jest.createMockFromModule("ethers");
const actual = jest.requireActual("ethers");
return {
__esModules: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
...allAutoMocked,
Wallet: actual.Wallet,
};
});
const postmanServiceClientOptions: PostmanOptions = {
l1Options: {
rpcUrl: TEST_RPC_URL,
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_1,
isEOAEnabled: true,
isCalldataEnabled: false,
listener: {
pollingInterval: 4000,
maxFetchMessagesFromDb: 1000,
maxBlocksToFetchLogs: 1000,
},
claiming: {
signerPrivateKey: TEST_L1_SIGNER_PRIVATE_KEY,
messageSubmissionTimeout: 300000,
maxNonceDiff: 10000,
maxFeePerGas: 100000000000n,
gasEstimationPercentile: 50,
profitMargin: 1.0,
maxNumberOfRetries: 100,
retryDelayInSeconds: 30,
},
},
l2Options: {
rpcUrl: TEST_RPC_URL,
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_2,
isEOAEnabled: true,
isCalldataEnabled: false,
listener: {
pollingInterval: 4000,
maxFetchMessagesFromDb: 1000,
maxBlocksToFetchLogs: 1000,
},
claiming: {
signerPrivateKey: TEST_L2_SIGNER_PRIVATE_KEY,
messageSubmissionTimeout: 300000,
maxNonceDiff: 10000,
maxFeePerGas: 100000000000n,
gasEstimationPercentile: 50,
profitMargin: 1.0,
maxNumberOfRetries: 100,
retryDelayInSeconds: 30,
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
},
},
l1L2AutoClaimEnabled: true,
l2L1AutoClaimEnabled: true,
loggerOptions: {
silent: true,
},
databaseOptions: {
type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "postgres",
database: "db_name",
},
};
describe("PostmanServiceClient", () => {
let postmanServiceClient: PostmanServiceClient;
let loggerSpy: unknown;
beforeEach(() => {
postmanServiceClient = new PostmanServiceClient(postmanServiceClientOptions);
loggerSpy = jest.spyOn(WinstonLogger.prototype, "info");
});
afterEach(() => {
mockClear(loggerSpy);
});
describe("constructor", () => {
it("should throw an error when at least one private key is invalid", () => {
const postmanServiceClientOptionsWithInvalidPrivateKey: PostmanOptions = {
...postmanServiceClientOptions,
l1Options: {
...postmanServiceClientOptions.l1Options,
claiming: {
...postmanServiceClientOptions.l1Options.claiming,
signerPrivateKey: "",
},
},
};
expect(() => new PostmanServiceClient(postmanServiceClientOptionsWithInvalidPrivateKey)).toThrow(
new Error(
"Something went wrong when trying to generate Wallet. Please check your private key and the provider url.",
),
);
});
});
describe("connectDatabase", () => {
it("should initialize the db", async () => {
const initializeSpy = jest.spyOn(DataSource.prototype, "initialize").mockResolvedValue(
new DataSource({
type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "postgres",
database: "db_name",
entities: [MessageEntity],
namingStrategy: new SnakeNamingStrategy(),
migrations: [
InitialDatabaseSetup1685985945638,
AddNewColumns1687890326970,
UpdateStatusColumn1687890694496,
RemoveUniqueConstraint1689084924789,
AddNewIndexes1701265652528,
],
migrationsTableName: "migrations",
logging: ["error"],
migrationsRun: true,
}),
);
await postmanServiceClient.connectDatabase();
expect(initializeSpy).toHaveBeenCalledTimes(1);
});
});
describe("startAllServices", () => {
it("should start all postman services", () => {
jest.spyOn(MessageSentEventPoller.prototype, "start").mockImplementationOnce(jest.fn());
jest.spyOn(MessageAnchoringPoller.prototype, "start").mockImplementationOnce(jest.fn());
jest.spyOn(MessageClaimingPoller.prototype, "start").mockImplementationOnce(jest.fn());
jest.spyOn(L2ClaimMessageTransactionSizePoller.prototype, "start").mockImplementationOnce(jest.fn());
jest.spyOn(MessagePersistingPoller.prototype, "start").mockImplementationOnce(jest.fn());
jest.spyOn(DatabaseCleaningPoller.prototype, "start").mockImplementationOnce(jest.fn());
jest.spyOn(TypeOrmMessageRepository.prototype, "getLatestMessageSent").mockImplementationOnce(jest.fn());
postmanServiceClient.startAllServices();
expect(loggerSpy).toHaveBeenCalledTimes(5);
expect(loggerSpy).toHaveBeenCalledWith("All listeners and message deliverers have been started.");
postmanServiceClient.stopAllServices();
});
it("should stop all postman services", () => {
jest.spyOn(MessageSentEventPoller.prototype, "stop").mockImplementationOnce(jest.fn());
jest.spyOn(MessageAnchoringPoller.prototype, "stop").mockImplementationOnce(jest.fn());
jest.spyOn(MessageClaimingPoller.prototype, "stop").mockImplementationOnce(jest.fn());
jest.spyOn(L2ClaimMessageTransactionSizePoller.prototype, "stop").mockImplementationOnce(jest.fn());
jest.spyOn(MessagePersistingPoller.prototype, "stop").mockImplementationOnce(jest.fn());
jest.spyOn(DatabaseCleaningPoller.prototype, "stop").mockImplementationOnce(jest.fn());
postmanServiceClient.stopAllServices();
expect(loggerSpy).toHaveBeenCalledTimes(9);
expect(loggerSpy).toHaveBeenCalledWith("All listeners and message deliverers have been stopped.");
});
});
});

View File

@@ -0,0 +1,238 @@
import { describe } from "@jest/globals";
import { getConfig } from "../utils";
import {
TEST_ADDRESS_1,
TEST_ADDRESS_2,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
TEST_L1_SIGNER_PRIVATE_KEY,
TEST_L2_SIGNER_PRIVATE_KEY,
TEST_RPC_URL,
} from "../../../../../utils/testing/constants";
import {
DEFAULT_CALLDATA_ENABLED,
DEFAULT_DB_CLEANER_ENABLED,
DEFAULT_DB_CLEANING_INTERVAL,
DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
DEFAULT_ENFORCE_MAX_GAS_FEE,
DEFAULT_EOA_ENABLED,
DEFAULT_GAS_ESTIMATION_PERCENTILE,
DEFAULT_INITIAL_FROM_BLOCK,
DEFAULT_L2_MESSAGE_TREE_DEPTH,
DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
DEFAULT_LISTENER_INTERVAL,
DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
DEFAULT_MAX_CLAIM_GAS_LIMIT,
DEFAULT_MAX_FEE_PER_GAS,
DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
DEFAULT_MAX_NONCE_DIFF,
DEFAULT_MAX_NUMBER_OF_RETRIES,
DEFAULT_MAX_TX_RETRIES,
DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
DEFAULT_PROFIT_MARGIN,
DEFAULT_RETRY_DELAY_IN_SECONDS,
} from "../../../../../core/constants";
describe("Config utils", () => {
describe("getConfig", () => {
it("should return the default config when no optional parameters are passed.", () => {
const config = getConfig({
l1Options: {
rpcUrl: TEST_RPC_URL,
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_1,
listener: {},
claiming: {
signerPrivateKey: TEST_L1_SIGNER_PRIVATE_KEY,
},
},
l2Options: {
rpcUrl: TEST_RPC_URL,
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_2,
listener: {},
claiming: {
signerPrivateKey: TEST_L2_SIGNER_PRIVATE_KEY,
},
},
l1L2AutoClaimEnabled: false,
l2L1AutoClaimEnabled: false,
databaseOptions: {
type: "postgres",
},
});
expect(config).toStrictEqual({
databaseCleanerConfig: {
cleaningInterval: DEFAULT_DB_CLEANING_INTERVAL,
daysBeforeNowToDelete: DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
enabled: DEFAULT_DB_CLEANER_ENABLED,
},
databaseOptions: {
type: "postgres",
},
l1Config: {
claiming: {
feeRecipientAddress: undefined,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
isMaxGasFeeEnforced: DEFAULT_ENFORCE_MAX_GAS_FEE,
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxNonceDiff: DEFAULT_MAX_NONCE_DIFF,
maxNumberOfRetries: DEFAULT_MAX_NUMBER_OF_RETRIES,
maxTxRetries: DEFAULT_MAX_TX_RETRIES,
messageSubmissionTimeout: DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
profitMargin: DEFAULT_PROFIT_MARGIN,
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
signerPrivateKey: TEST_L1_SIGNER_PRIVATE_KEY,
},
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
isEOAEnabled: DEFAULT_EOA_ENABLED,
listener: {
blockConfirmation: DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
initialFromBlock: DEFAULT_INITIAL_FROM_BLOCK,
maxBlocksToFetchLogs: DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
maxFetchMessagesFromDb: DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
pollingInterval: DEFAULT_LISTENER_INTERVAL,
},
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_1,
rpcUrl: TEST_RPC_URL,
},
l1L2AutoClaimEnabled: false,
l2Config: {
claiming: {
feeRecipientAddress: undefined,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
isMaxGasFeeEnforced: DEFAULT_ENFORCE_MAX_GAS_FEE,
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxNonceDiff: DEFAULT_MAX_NONCE_DIFF,
maxNumberOfRetries: DEFAULT_MAX_NUMBER_OF_RETRIES,
maxTxRetries: DEFAULT_MAX_TX_RETRIES,
messageSubmissionTimeout: DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
profitMargin: DEFAULT_PROFIT_MARGIN,
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
signerPrivateKey: TEST_L2_SIGNER_PRIVATE_KEY,
},
enableLineaEstimateGas: false,
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
isEOAEnabled: DEFAULT_EOA_ENABLED,
l2MessageTreeDepth: DEFAULT_L2_MESSAGE_TREE_DEPTH,
listener: {
blockConfirmation: DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
initialFromBlock: DEFAULT_INITIAL_FROM_BLOCK,
maxBlocksToFetchLogs: DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
maxFetchMessagesFromDb: DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
pollingInterval: DEFAULT_LISTENER_INTERVAL,
},
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_2,
rpcUrl: TEST_RPC_URL,
},
l2L1AutoClaimEnabled: false,
loggerOptions: undefined,
});
});
it("should return the config when some optional parameters are passed.", () => {
const config = getConfig({
l1Options: {
rpcUrl: TEST_RPC_URL,
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_1,
listener: {
pollingInterval: DEFAULT_LISTENER_INTERVAL + 1000,
},
claiming: {
signerPrivateKey: TEST_L1_SIGNER_PRIVATE_KEY,
feeRecipientAddress: TEST_ADDRESS_1,
},
},
l2Options: {
rpcUrl: TEST_RPC_URL,
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_2,
enableLineaEstimateGas: true,
listener: {
pollingInterval: DEFAULT_LISTENER_INTERVAL + 1000,
},
claiming: {
signerPrivateKey: TEST_L2_SIGNER_PRIVATE_KEY,
feeRecipientAddress: TEST_ADDRESS_2,
},
},
l1L2AutoClaimEnabled: true,
l2L1AutoClaimEnabled: true,
databaseOptions: {
type: "postgres",
},
databaseCleanerOptions: {
enabled: true,
},
});
expect(config).toStrictEqual({
databaseCleanerConfig: {
cleaningInterval: DEFAULT_DB_CLEANING_INTERVAL,
daysBeforeNowToDelete: DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
enabled: true,
},
databaseOptions: {
type: "postgres",
},
l1Config: {
claiming: {
feeRecipientAddress: TEST_ADDRESS_1,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
isMaxGasFeeEnforced: DEFAULT_ENFORCE_MAX_GAS_FEE,
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxNonceDiff: DEFAULT_MAX_NONCE_DIFF,
maxNumberOfRetries: DEFAULT_MAX_NUMBER_OF_RETRIES,
maxTxRetries: DEFAULT_MAX_TX_RETRIES,
messageSubmissionTimeout: DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
profitMargin: DEFAULT_PROFIT_MARGIN,
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
signerPrivateKey: TEST_L1_SIGNER_PRIVATE_KEY,
},
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
isEOAEnabled: DEFAULT_EOA_ENABLED,
listener: {
blockConfirmation: DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
initialFromBlock: DEFAULT_INITIAL_FROM_BLOCK,
maxBlocksToFetchLogs: DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
maxFetchMessagesFromDb: DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
pollingInterval: DEFAULT_LISTENER_INTERVAL + 1000,
},
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_1,
rpcUrl: TEST_RPC_URL,
},
l1L2AutoClaimEnabled: true,
l2Config: {
claiming: {
feeRecipientAddress: TEST_ADDRESS_2,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
isMaxGasFeeEnforced: DEFAULT_ENFORCE_MAX_GAS_FEE,
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxNonceDiff: DEFAULT_MAX_NONCE_DIFF,
maxNumberOfRetries: DEFAULT_MAX_NUMBER_OF_RETRIES,
maxTxRetries: DEFAULT_MAX_TX_RETRIES,
messageSubmissionTimeout: DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
profitMargin: DEFAULT_PROFIT_MARGIN,
retryDelayInSeconds: DEFAULT_RETRY_DELAY_IN_SECONDS,
signerPrivateKey: TEST_L2_SIGNER_PRIVATE_KEY,
},
enableLineaEstimateGas: true,
isCalldataEnabled: DEFAULT_CALLDATA_ENABLED,
isEOAEnabled: DEFAULT_EOA_ENABLED,
l2MessageTreeDepth: DEFAULT_L2_MESSAGE_TREE_DEPTH,
listener: {
blockConfirmation: DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
initialFromBlock: DEFAULT_INITIAL_FROM_BLOCK,
maxBlocksToFetchLogs: DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
maxFetchMessagesFromDb: DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
pollingInterval: DEFAULT_LISTENER_INTERVAL + 1000,
},
messageServiceContractAddress: TEST_CONTRACT_ADDRESS_2,
rpcUrl: TEST_RPC_URL,
},
l2L1AutoClaimEnabled: true,
loggerOptions: undefined,
});
});
});
});

View File

@@ -0,0 +1,104 @@
import { LoggerOptions } from "winston";
import { DBOptions, DBCleanerOptions, DBCleanerConfig } from "../../persistence/config/types";
type DeepRequired<T> = {
[P in keyof T]-?: T[P] extends object ? DeepRequired<T[P]> : T[P];
};
/**
* Configuration for the Postman service, including network configurations, database options, and logging.
*/
export type PostmanOptions = {
l1Options: L1NetworkOptions;
l2Options: L2NetworkOptions;
l1L2AutoClaimEnabled: boolean;
l2L1AutoClaimEnabled: boolean;
databaseOptions: DBOptions;
databaseCleanerOptions?: DBCleanerOptions;
loggerOptions?: LoggerOptions;
};
/**
* Configuration for the Postman service, including network configurations, database options, and logging.
*/
export type PostmanConfig = {
l1Config: L1NetworkConfig;
l2Config: L2NetworkConfig;
l1L2AutoClaimEnabled: boolean;
l2L1AutoClaimEnabled: boolean;
databaseOptions: DBOptions;
databaseCleanerConfig: DBCleanerConfig;
loggerOptions?: LoggerOptions;
};
/**
* Base configuration for a network, including claiming, listener settings, and contract details.
*/
type NetworkOptions = {
claiming: ClaimingOptions;
listener: ListenerOptions;
rpcUrl: string;
messageServiceContractAddress: string;
isEOAEnabled?: boolean;
isCalldataEnabled?: boolean;
};
type NetworkConfig = Omit<DeepRequired<NetworkOptions>, "claiming" | "listener"> & {
claiming: ClaimingConfig;
listener: ListenerConfig;
};
/**
* Configuration specific to the L1 network, extending the base NetworkConfig.
*/
export type L1NetworkOptions = NetworkOptions;
export type L1NetworkConfig = NetworkConfig;
/**
* Configuration specific to the L2 network, extending the base NetworkConfig with additional options.
*/
export type L2NetworkOptions = NetworkOptions & {
l2MessageTreeDepth?: number;
enableLineaEstimateGas?: boolean;
};
export type L2NetworkConfig = NetworkConfig & {
l2MessageTreeDepth: number;
enableLineaEstimateGas: boolean;
};
/**
* Configuration for claiming operations, including signer details, fee settings, and retry policies.
*/
export type ClaimingOptions = {
signerPrivateKey: string;
messageSubmissionTimeout?: number;
feeRecipientAddress?: string;
maxNonceDiff?: number;
maxFeePerGas?: bigint;
gasEstimationPercentile?: number;
isMaxGasFeeEnforced?: boolean;
profitMargin?: number;
maxNumberOfRetries?: number;
retryDelayInSeconds?: number;
maxClaimGasLimit?: bigint;
maxTxRetries?: number;
};
export type ClaimingConfig = Omit<Required<ClaimingOptions>, "feeRecipientAddress"> & {
feeRecipientAddress?: string;
};
/**
* Configuration for the event listener, including polling settings and block fetching limits.
*/
export type ListenerOptions = {
pollingInterval?: number;
initialFromBlock?: number;
blockConfirmation?: number;
maxFetchMessagesFromDb?: number;
maxBlocksToFetchLogs?: number;
};
export type ListenerConfig = Required<ListenerOptions>;

View File

@@ -0,0 +1,106 @@
import {
DEFAULT_CALLDATA_ENABLED,
DEFAULT_EOA_ENABLED,
DEFAULT_GAS_ESTIMATION_PERCENTILE,
DEFAULT_INITIAL_FROM_BLOCK,
DEFAULT_L2_MESSAGE_TREE_DEPTH,
DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
DEFAULT_LISTENER_INTERVAL,
DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
DEFAULT_MAX_CLAIM_GAS_LIMIT,
DEFAULT_MAX_FEE_PER_GAS,
DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
DEFAULT_MAX_NONCE_DIFF,
DEFAULT_MAX_NUMBER_OF_RETRIES,
DEFAULT_MAX_TX_RETRIES,
DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
DEFAULT_PROFIT_MARGIN,
DEFAULT_RETRY_DELAY_IN_SECONDS,
} from "../../../../core/constants";
import { PostmanConfig, PostmanOptions } from "./config";
/**
* @notice Generates the configuration for the Postman service based on provided options.
* @dev This function merges the provided options with default values where necessary.
* @param postmanOptions The options provided to configure the Postman service.
* @return postmanConfig The complete configuration for the Postman service.
*/
export function getConfig(postmanOptions: PostmanOptions): PostmanConfig {
const {
l1Options,
l2Options,
l1L2AutoClaimEnabled,
l2L1AutoClaimEnabled,
databaseOptions,
databaseCleanerOptions,
loggerOptions,
} = postmanOptions;
return {
l1Config: {
rpcUrl: l1Options.rpcUrl,
messageServiceContractAddress: l1Options.messageServiceContractAddress,
isEOAEnabled: l1Options.isEOAEnabled ?? DEFAULT_EOA_ENABLED,
isCalldataEnabled: l1Options.isCalldataEnabled ?? DEFAULT_CALLDATA_ENABLED,
listener: {
pollingInterval: l1Options.listener.pollingInterval ?? DEFAULT_LISTENER_INTERVAL,
maxFetchMessagesFromDb: l1Options.listener.maxFetchMessagesFromDb ?? DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
maxBlocksToFetchLogs: l1Options.listener.maxBlocksToFetchLogs ?? DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
initialFromBlock: l1Options.listener.initialFromBlock ?? DEFAULT_INITIAL_FROM_BLOCK,
blockConfirmation: l1Options.listener.blockConfirmation ?? DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
},
claiming: {
signerPrivateKey: l1Options.claiming.signerPrivateKey,
messageSubmissionTimeout: l1Options.claiming.messageSubmissionTimeout ?? DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
feeRecipientAddress: l1Options.claiming.feeRecipientAddress,
maxNonceDiff: l1Options.claiming.maxNonceDiff ?? DEFAULT_MAX_NONCE_DIFF,
maxFeePerGas: l1Options.claiming.maxFeePerGas ?? DEFAULT_MAX_FEE_PER_GAS,
gasEstimationPercentile: l1Options.claiming.gasEstimationPercentile ?? DEFAULT_GAS_ESTIMATION_PERCENTILE,
isMaxGasFeeEnforced: l1Options.claiming.isMaxGasFeeEnforced ?? false,
profitMargin: l1Options.claiming.profitMargin ?? DEFAULT_PROFIT_MARGIN,
maxNumberOfRetries: l1Options.claiming.maxNumberOfRetries ?? DEFAULT_MAX_NUMBER_OF_RETRIES,
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,
},
},
l2Config: {
rpcUrl: l2Options.rpcUrl,
messageServiceContractAddress: l2Options.messageServiceContractAddress,
isEOAEnabled: l2Options.isEOAEnabled ?? DEFAULT_EOA_ENABLED,
isCalldataEnabled: l2Options.isCalldataEnabled ?? DEFAULT_CALLDATA_ENABLED,
l2MessageTreeDepth: l2Options.l2MessageTreeDepth ?? DEFAULT_L2_MESSAGE_TREE_DEPTH,
enableLineaEstimateGas: l2Options.enableLineaEstimateGas ?? false,
listener: {
pollingInterval: l2Options.listener.pollingInterval ?? DEFAULT_LISTENER_INTERVAL,
maxFetchMessagesFromDb: l2Options.listener.maxFetchMessagesFromDb ?? DEFAULT_MAX_FETCH_MESSAGES_FROM_DB,
maxBlocksToFetchLogs: l2Options.listener.maxBlocksToFetchLogs ?? DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS,
initialFromBlock: l2Options.listener.initialFromBlock ?? DEFAULT_INITIAL_FROM_BLOCK,
blockConfirmation: l2Options.listener.blockConfirmation ?? DEFAULT_LISTENER_BLOCK_CONFIRMATIONS,
},
claiming: {
signerPrivateKey: l2Options.claiming.signerPrivateKey,
messageSubmissionTimeout: l2Options.claiming.messageSubmissionTimeout ?? DEFAULT_MESSAGE_SUBMISSION_TIMEOUT,
feeRecipientAddress: l2Options.claiming.feeRecipientAddress,
maxNonceDiff: l2Options.claiming.maxNonceDiff ?? DEFAULT_MAX_NONCE_DIFF,
maxFeePerGas: l2Options.claiming.maxFeePerGas ?? DEFAULT_MAX_FEE_PER_GAS,
gasEstimationPercentile: l2Options.claiming.gasEstimationPercentile ?? DEFAULT_GAS_ESTIMATION_PERCENTILE,
isMaxGasFeeEnforced: l2Options.claiming.isMaxGasFeeEnforced ?? false,
profitMargin: l2Options.claiming.profitMargin ?? DEFAULT_PROFIT_MARGIN,
maxNumberOfRetries: l2Options.claiming.maxNumberOfRetries ?? DEFAULT_MAX_NUMBER_OF_RETRIES,
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,
},
},
l1L2AutoClaimEnabled,
l2L1AutoClaimEnabled,
databaseOptions,
databaseCleanerConfig: {
enabled: databaseCleanerOptions?.enabled ?? false,
cleaningInterval: databaseCleanerOptions?.cleaningInterval ?? 43200000,
daysBeforeNowToDelete: databaseCleanerOptions?.daysBeforeNowToDelete ?? 14,
},
loggerOptions,
};
}

View File

@@ -0,0 +1,12 @@
import { BetterSqlite3ConnectionOptions } from "typeorm/driver/better-sqlite3/BetterSqlite3ConnectionOptions";
import { PostgresConnectionOptions } from "typeorm/driver/postgres/PostgresConnectionOptions";
export type DBOptions = PostgresConnectionOptions | BetterSqlite3ConnectionOptions;
export type DBCleanerOptions = {
enabled: boolean;
cleaningInterval?: number;
daysBeforeNowToDelete?: number;
};
export type DBCleanerConfig = Required<DBCleanerOptions>;

View File

@@ -0,0 +1,33 @@
import { DataSource } from "typeorm";
import { SnakeNamingStrategy } from "typeorm-naming-strategies";
import { InitialDatabaseSetup1685985945638 } from "./migrations/1685985945638-InitialDatabaseSetup";
import { AddNewColumns1687890326970 } from "./migrations/1687890326970-AddNewColumns";
import { UpdateStatusColumn1687890694496 } from "./migrations/1687890694496-UpdateStatusColumn";
import { RemoveUniqueConstraint1689084924789 } from "./migrations/1689084924789-RemoveUniqueConstraint";
import { AddNewIndexes1701265652528 } from "./migrations/1701265652528-AddNewIndexes";
import { AddUniqueConstraint1709901138056 } from "./migrations/1709901138056-AddUniqueConstraint";
import { DBOptions } from "./config/types";
import { MessageEntity } from "./entities/Message.entity";
import { AddCompressedTxSizeColumn1718026260629 } from "./migrations/1718026260629-AddCompressedTxSizeColumn";
export class DB {
public static create(config: DBOptions): DataSource {
return new DataSource({
...config,
entities: [MessageEntity],
namingStrategy: new SnakeNamingStrategy(),
migrations: [
InitialDatabaseSetup1685985945638,
AddNewColumns1687890326970,
UpdateStatusColumn1687890694496,
RemoveUniqueConstraint1689084924789,
AddNewIndexes1701265652528,
AddUniqueConstraint1709901138056,
AddCompressedTxSizeColumn1718026260629,
],
migrationsTableName: "migrations",
logging: ["error"],
migrationsRun: true,
});
}
}

View File

@@ -0,0 +1,95 @@
import { IsDate, IsDecimal, IsEnum, IsNumber, IsString } from "class-validator";
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from "typeorm";
import { Direction, MessageStatus } from "../../../../core/enums/MessageEnums";
@Entity({ name: "message" })
export class MessageEntity {
@PrimaryGeneratedColumn()
id: number;
@Column()
@IsString()
messageSender: string;
@Column()
@IsString()
destination: string;
@Column()
@IsString()
fee: string;
@Column()
@IsString()
value: string;
@Column()
@IsNumber()
messageNonce: number;
@Column()
@IsString()
calldata: string;
@Column()
@IsString()
messageHash: string;
@Column()
@IsString()
messageContractAddress: string;
@Column()
@IsNumber()
sentBlockNumber: number;
@Column()
@IsEnum(Direction)
direction: Direction;
@Column()
@IsEnum(MessageStatus)
status: MessageStatus;
@Column({ nullable: true })
@IsDate()
claimTxCreationDate?: Date;
@Column({ nullable: true })
claimTxGasLimit?: number;
@Column({ nullable: true, type: "bigint" })
claimTxMaxFeePerGas?: bigint;
@Column({ nullable: true, type: "bigint" })
claimTxMaxPriorityFeePerGas?: bigint;
@Column({ nullable: true })
claimTxNonce?: number;
@Column({ nullable: true })
@IsString()
claimTxHash?: string;
@Column()
@IsNumber()
claimNumberOfRetry: number;
@Column({ nullable: true })
@IsDate()
claimLastRetriedAt?: Date;
@Column({ nullable: true })
@IsDecimal()
claimGasEstimationThreshold?: number;
@Column({ nullable: true })
@IsNumber()
compressedTransactionSize?: number;
@CreateDateColumn()
public createdAt: Date;
@UpdateDateColumn()
public updatedAt: Date;
}

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from "@jest/globals";
import { generateMessage, generateMessageEntity } from "../../../../../utils/testing/helpers";
import { mapMessageEntityToMessage, mapMessageToMessageEntity } from "../messageMappers";
import {
TEST_ADDRESS_1,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
TEST_MESSAGE_HASH,
} from "../../../../../utils/testing/constants";
import { Direction, MessageStatus } from "../../../../../core/enums/MessageEnums";
import { Message } from "../../../../../core/entities/Message";
describe("Message Mappers", () => {
describe("mapMessageToMessageEntity", () => {
it("should map a message to a message entity", () => {
const message = generateMessage();
expect(mapMessageToMessageEntity(message)).toStrictEqual({
calldata: "0x",
claimGasEstimationThreshold: undefined,
claimLastRetriedAt: undefined,
claimNumberOfRetry: 0,
claimTxCreationDate: undefined,
claimTxGasLimit: undefined,
claimTxHash: undefined,
claimTxMaxFeePerGas: undefined,
claimTxMaxPriorityFeePerGas: undefined,
claimTxNonce: undefined,
compressedTransactionSize: undefined,
contractAddress: TEST_CONTRACT_ADDRESS_2,
createdAt: new Date("2023-08-04"),
destination: TEST_CONTRACT_ADDRESS_1,
direction: Direction.L1_TO_L2,
fee: "10",
id: 1,
messageContractAddress: TEST_CONTRACT_ADDRESS_2,
messageHash: TEST_MESSAGE_HASH,
messageNonce: 1,
messageSender: TEST_ADDRESS_1,
sentBlockNumber: 100_000,
status: MessageStatus.SENT,
updatedAt: new Date("2023-08-04"),
value: "2",
});
});
});
describe("mapMessageToMessageEntity", () => {
it("should map a message entity to a message", () => {
const messageEntity = generateMessageEntity();
expect(mapMessageEntityToMessage(messageEntity)).toStrictEqual(
new Message({
calldata: "0x",
claimGasEstimationThreshold: undefined,
claimLastRetriedAt: undefined,
claimNumberOfRetry: 0,
claimTxCreationDate: undefined,
claimTxGasLimit: undefined,
claimTxHash: undefined,
claimTxMaxFeePerGas: undefined,
claimTxMaxPriorityFeePerGas: undefined,
claimTxNonce: undefined,
contractAddress: TEST_CONTRACT_ADDRESS_2,
createdAt: new Date("2023-08-04"),
destination: TEST_CONTRACT_ADDRESS_1,
direction: Direction.L1_TO_L2,
fee: 10n,
id: 1,
messageHash: TEST_MESSAGE_HASH,
messageNonce: 1n,
messageSender: TEST_ADDRESS_1,
sentBlockNumber: 100_000,
status: MessageStatus.SENT,
updatedAt: new Date("2023-08-04"),
value: 2n,
}),
);
});
});
});

View File

@@ -0,0 +1,44 @@
import { Message } from "../../../../core/entities/Message";
import { MessageEntity } from "../entities/Message.entity";
export const mapMessageToMessageEntity = (message: Message): MessageEntity => {
return {
id: message?.id as number,
...message,
fee: message.fee.toString(),
value: message.value.toString(),
messageNonce: parseInt(message.messageNonce.toString()),
messageContractAddress: message.contractAddress,
createdAt: message.createdAt ?? new Date(),
updatedAt: message.updatedAt ?? new Date(),
};
};
export const mapMessageEntityToMessage = (entity: MessageEntity): Message => {
return new Message({
id: entity.id,
messageSender: entity.messageSender,
destination: entity.destination,
fee: BigInt(entity.fee),
value: BigInt(entity.value),
messageNonce: BigInt(entity.messageNonce),
calldata: entity.calldata,
messageHash: entity.messageHash,
contractAddress: entity.messageContractAddress,
sentBlockNumber: entity.sentBlockNumber,
direction: entity.direction,
status: entity.status,
claimTxCreationDate: entity.claimTxCreationDate,
claimTxGasLimit: entity.claimTxGasLimit,
claimTxMaxFeePerGas: entity.claimTxMaxFeePerGas,
claimTxMaxPriorityFeePerGas: entity.claimTxMaxPriorityFeePerGas,
claimTxNonce: entity.claimTxNonce,
claimTxHash: entity.claimTxHash,
claimNumberOfRetry: entity.claimNumberOfRetry,
claimLastRetriedAt: entity.claimLastRetriedAt,
claimGasEstimationThreshold: entity.claimGasEstimationThreshold,
compressedTransactionSize: entity.compressedTransactionSize,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
});
};

View File

@@ -0,0 +1,137 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
export class InitialDatabaseSetup1685985945638 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
name: "message",
columns: [
{
name: "id",
type: "integer",
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "message_sender",
type: "varchar",
},
{
name: "destination",
type: "varchar",
},
{
name: "fee",
type: "varchar",
},
{
name: "value",
type: "varchar",
},
{
name: "message_nonce",
type: "integer",
},
{
name: "calldata",
type: "varchar",
},
{
name: "message_hash",
type: "varchar",
isUnique: true,
},
{
name: "message_contract_address",
type: "varchar",
},
{
name: "sent_block_number",
type: "integer",
},
{
name: "direction",
type: "enum",
enum: ["L1_TO_L2", "L2_TO_L1"],
enumName: "directionEnum",
},
{
name: "status",
type: "enum",
enum: ["SENT", "ANCHORED", "PENDING", "CLAIMED_SUCCESS", "CLAIMED_REVERTED", "NON_EXECUTABLE"],
enumName: "statusEnum",
},
{
name: "claim_tx_creation_date",
type: "timestamp with time zone",
isNullable: true,
},
{
name: "claim_tx_gas_limit",
type: "integer",
isNullable: true,
},
{
name: "claim_tx_max_fee_per_gas",
type: "bigint",
isNullable: true,
},
{
name: "claim_tx_max_priority_fee_per_gas",
type: "bigint",
isNullable: true,
},
{
name: "claim_tx_nonce",
type: "integer",
isNullable: true,
},
{
name: "claim_tx_hash",
type: "varchar",
isNullable: true,
},
{
name: "created_at",
type: "timestamp with time zone",
default: "now()",
},
{
name: "updated_at",
type: "timestamp with time zone",
default: "now()",
},
],
}),
true,
);
const messageHashIndex = new TableIndex({
columnNames: ["message_hash"],
name: "message_hash_index",
});
const transactionHashIndex = new TableIndex({
columnNames: ["claim_tx_hash"],
name: "claim_tx__hash_index",
});
await queryRunner.createIndex("message", messageHashIndex);
await queryRunner.createIndex("message", transactionHashIndex);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("message");
await queryRunner.dropIndices("message", [
new TableIndex({
columnNames: ["message_hash"],
name: "message_hash_index",
}),
new TableIndex({
columnNames: ["claim_tx_hash"],
name: "claim_tx__hash_index",
}),
]);
}
}

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class AddNewColumns1687890326970 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns("message", [
new TableColumn({
name: "claim_number_of_retry",
type: "integer",
isNullable: false,
default: 0,
}),
new TableColumn({
name: "claim_last_retried_at",
type: "timestamp with time zone",
isNullable: true,
}),
new TableColumn({
name: "claim_gas_estimation_threshold",
type: "numeric",
isNullable: true,
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("message", "claim_number_of_retry");
await queryRunner.dropColumn("message", "claim_last_retried_at");
await queryRunner.dropColumn("message", "claim_gas_estimation_threshold");
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UpdateStatusColumn1687890694496 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "statusEnum" ADD VALUE 'ZERO_FEE'`);
await queryRunner.query(`ALTER TYPE "statusEnum" ADD VALUE 'FEE_UNDERPRICED'`);
await queryRunner.query(`ALTER TYPE "statusEnum" ADD VALUE 'EXCLUDED'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TYPE "statusEnum" DROP VALUE 'ZERO_FEE'`);
await queryRunner.query(`ALTER TYPE "statusEnum" DROP VALUE 'FEE_UNDERPRICED'`);
await queryRunner.query(`ALTER TYPE "statusEnum" DROP VALUE 'EXCLUDED'`);
}
}

View File

@@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner, TableUnique } from "typeorm";
export class RemoveUniqueConstraint1689084924789 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropUniqueConstraint("message", "UQ_4ae806cf878a218ad891a030ab5");
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createUniqueConstraint("message", new TableUnique({ columnNames: ["message_hash"] }));
}
}

View File

@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
export class AddNewIndexes1701265652528 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createIndices("message", [
new TableIndex({
columnNames: ["direction"],
name: "direction_index",
}),
new TableIndex({
columnNames: ["claim_tx_nonce"],
name: "claim_tx_nonce_index",
}),
new TableIndex({
columnNames: ["created_at"],
name: "created_at_index",
}),
new TableIndex({
columnNames: ["message_contract_address"],
name: "message_contract_address_index",
}),
new TableIndex({
columnNames: ["status"],
name: "status_index",
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndices("message", [
new TableIndex({
columnNames: ["direction"],
name: "direction_index",
}),
new TableIndex({
columnNames: ["claim_tx_nonce"],
name: "claim_tx_nonce_index",
}),
new TableIndex({
columnNames: ["created_at"],
name: "created_at_index",
}),
new TableIndex({
columnNames: ["message_contract_address"],
name: "message_contract_address_index",
}),
new TableIndex({
columnNames: ["status"],
name: "status_index",
}),
]);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner, TableUnique } from "typeorm";
export class AddUniqueConstraint1709901138056 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const messageUniqueConstraint = new TableUnique({
columnNames: ["message_hash", "direction"],
});
await queryRunner.createUniqueConstraint("message", messageUniqueConstraint);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const messageUniqueConstraint = new TableUnique({
columnNames: ["message_hash", "direction"],
});
await queryRunner.dropUniqueConstraint("message", messageUniqueConstraint);
}
}

View File

@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class AddCompressedTxSizeColumn1718026260629 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
"message",
new TableColumn({
name: "compressed_transaction_size",
type: "numeric",
isNullable: true,
}),
);
await queryRunner.query(`ALTER TYPE "statusEnum" ADD VALUE 'TRANSACTION_SIZE_COMPUTED'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("message", "compressed_transaction_size");
await queryRunner.query(`ALTER TYPE "statusEnum" DROP VALUE 'TRANSACTION_SIZE_COMPUTED'`);
}
}

View File

@@ -0,0 +1,320 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Brackets, DataSource, Repository } from "typeorm";
import { Message } from "../../../../core/entities/Message";
import { mapMessageEntityToMessage, mapMessageToMessageEntity } from "../mappers/messageMappers";
import { Direction, MessageStatus } from "../../../../core/enums/MessageEnums";
import { DatabaseErrorType, DatabaseRepoName } from "../../../../core/enums/DatabaseEnums";
import { DatabaseAccessError } from "../../../../core/errors/DatabaseErrors";
import { MessageEntity } from "../entities/Message.entity";
import { subtractSeconds } from "../../../../core/utils/shared";
import { ContractTransactionResponse } from "ethers";
import { IMessageRepository } from "../../../../core/persistence/IMessageRepository";
export class TypeOrmMessageRepository<TransactionResponse extends ContractTransactionResponse>
extends Repository<MessageEntity>
implements IMessageRepository<TransactionResponse>
{
constructor(readonly dataSource: DataSource) {
super(MessageEntity, dataSource.createEntityManager());
}
async findByMessageHash(message: Message, direction: Direction): Promise<Message | null> {
try {
const messageInDb = await this.findOneBy({
messageHash: message.messageHash,
direction,
});
if (!messageInDb) {
return null;
}
return mapMessageEntityToMessage(messageInDb);
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err, message);
}
}
async insertMessage(message: Message): Promise<void> {
try {
const messageInDb = await this.findOneBy({
messageHash: message.messageHash,
direction: message.direction,
});
if (!messageInDb) {
await this.manager.save(MessageEntity, mapMessageToMessageEntity(message));
}
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Insert, err, message);
}
}
async updateMessage(message: Message): Promise<void> {
try {
const messageInDb = await this.findOneBy({
messageHash: message.messageHash,
direction: message.direction,
});
if (messageInDb) {
await this.manager.save(MessageEntity, mapMessageToMessageEntity(message));
}
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Update, err, message);
}
}
async updateMessageByTransactionHash(transactionHash: string, direction: Direction, message: Message): Promise<void> {
try {
const messageInDb = await this.findOneBy({
claimTxHash: transactionHash,
direction,
});
if (messageInDb) {
await this.manager.save(MessageEntity, mapMessageToMessageEntity(message));
}
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Update, err);
}
}
async saveMessages(messages: Message[]): Promise<void> {
try {
await this.manager.save(MessageEntity, messages.map(mapMessageToMessageEntity));
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Insert, err);
}
}
async deleteMessages(msBeforeNowToDelete: number): Promise<number> {
try {
const d = new Date();
d.setTime(d.getTime() - msBeforeNowToDelete);
const formattedDateStr = d.toISOString().replace("T", " ").replace("Z", "");
const deleteResult = await this.createQueryBuilder("message")
.delete()
.where("message.status IN(:...statuses)", {
statuses: [
MessageStatus.CLAIMED_SUCCESS,
MessageStatus.CLAIMED_REVERTED,
MessageStatus.EXCLUDED,
MessageStatus.ZERO_FEE,
],
})
.andWhere("message.updated_at < :updated_before", { updated_before: formattedDateStr })
.execute();
return deleteResult.affected ?? 0;
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Delete, err);
}
}
async getFirstMessageToClaimOnL1(
direction: Direction,
contractAddress: string,
currentGasPrice: bigint,
gasEstimationMargin: number,
maxRetry: number,
retryDelay: number,
): Promise<Message | null> {
try {
const message = await this.createQueryBuilder("message")
.where("message.direction = :direction", { direction })
.andWhere("message.messageContractAddress = :contractAddress", { contractAddress })
.andWhere("message.status IN(:...statuses)", {
statuses: [MessageStatus.ANCHORED, MessageStatus.FEE_UNDERPRICED],
})
.andWhere("message.claimNumberOfRetry < :maxRetry", { maxRetry })
.andWhere(
new Brackets((qb) => {
qb.where("message.claimLastRetriedAt IS NULL").orWhere("message.claimLastRetriedAt < :lastRetriedDate", {
lastRetriedDate: subtractSeconds(new Date(), retryDelay).toISOString(),
});
}),
)
.andWhere(
new Brackets((qb) => {
qb.where("message.claimGasEstimationThreshold > :threshold", {
threshold: parseFloat(currentGasPrice.toString()) * gasEstimationMargin,
}).orWhere("message.claimGasEstimationThreshold IS NULL");
}),
)
.orderBy("CAST(message.status as CHAR)", "ASC")
.addOrderBy("message.claimGasEstimationThreshold", "DESC")
.addOrderBy("message.sentBlockNumber", "ASC")
.getOne();
return message ? mapMessageEntityToMessage(message) : null;
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err);
}
}
async getFirstMessageToClaimOnL2(
direction: Direction,
contractAddress: string,
messageStatuses: MessageStatus[],
maxRetry: number,
retryDelay: number,
feeEstimationOptions: {
minimumMargin: number;
extraDataVariableCost: number;
extraDataFixedCost: number;
},
): Promise<Message | null> {
try {
const message = await this.createQueryBuilder("message")
.where("message.direction = :direction", { direction })
.andWhere("message.messageContractAddress = :contractAddress", { contractAddress })
.andWhere("message.status IN(:...statuses)", {
statuses: messageStatuses,
})
.andWhere("message.claimNumberOfRetry < :maxRetry", { maxRetry })
.andWhere(
new Brackets((qb) => {
qb.where("message.claimLastRetriedAt IS NULL").orWhere("message.claimLastRetriedAt < :lastRetriedDate", {
lastRetriedDate: subtractSeconds(new Date(), retryDelay).toISOString(),
});
}),
)
.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")
.addOrderBy("CAST(message.fee AS numeric)", "DESC")
.addOrderBy("message.sentBlockNumber", "ASC")
.getOne();
return message ? mapMessageEntityToMessage(message) : null;
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err);
}
}
async getLatestMessageSent(direction: Direction, contractAddress: string): Promise<Message | null> {
try {
const pendingMessages = await this.find({
where: {
direction,
messageContractAddress: contractAddress,
},
take: 1,
order: {
createdAt: "DESC",
},
});
if (pendingMessages.length === 0) return null;
return mapMessageEntityToMessage(pendingMessages[0]);
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err);
}
}
async getNFirstMessagesByStatus(
status: MessageStatus,
direction: Direction,
limit: number,
contractAddress: string,
): Promise<Message[]> {
try {
const messages = await this.find({
where: {
direction,
status,
messageContractAddress: contractAddress,
},
take: limit,
order: {
sentBlockNumber: "ASC",
},
});
return messages.map(mapMessageEntityToMessage);
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err);
}
}
async getMessageSent(direction: Direction, contractAddress: string): Promise<Message | null> {
try {
const message = await this.findOne({
where: {
direction,
status: MessageStatus.SENT,
messageContractAddress: contractAddress,
},
});
return message ? mapMessageEntityToMessage(message) : null;
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err);
}
}
async getLastClaimTxNonce(direction: Direction): Promise<number | null> {
try {
const message = await this.createQueryBuilder("message")
.select("MAX(message.claimTxNonce)", "lastTxNonce")
.where("message.direction = :direction", { direction })
.getRawOne();
if (!message.lastTxNonce) {
return null;
}
return message.lastTxNonce;
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err);
}
}
async getFirstPendingMessage(direction: Direction): Promise<Message | null> {
try {
const message = await this.createQueryBuilder("message")
.where("message.direction = :direction", { direction })
.andWhere("message.status = :status", { status: MessageStatus.PENDING })
.orderBy("message.claimTxNonce", "ASC")
.getOne();
return message ? mapMessageEntityToMessage(message) : null;
} catch (err: any) {
throw new DatabaseAccessError(DatabaseRepoName.MessageRepository, DatabaseErrorType.Read, err);
}
}
async updateMessageWithClaimTxAtomic(
message: Message,
nonce: number,
claimTxResponsePromise: Promise<ContractTransactionResponse>,
): Promise<void> {
await this.manager.transaction(async (entityManager) => {
await entityManager.update(
MessageEntity,
{ messageHash: message.messageHash, direction: message.direction },
{
claimTxCreationDate: new Date(),
claimTxNonce: nonce,
status: MessageStatus.PENDING,
...(message.status === MessageStatus.FEE_UNDERPRICED
? { claimNumberOfRetry: message.claimNumberOfRetry + 1, claimLastRetriedAt: new Date() }
: {}),
},
);
const tx = await claimTxResponsePromise;
await entityManager.update(
MessageEntity,
{ messageHash: message.messageHash, direction: message.direction },
{
claimTxGasLimit: parseInt(tx.gasLimit.toString()),
claimTxMaxFeePerGas: tx.maxFeePerGas ?? undefined,
claimTxMaxPriorityFeePerGas: tx.maxPriorityFeePerGas ?? undefined,
claimTxHash: tx.hash,
},
);
});
}
}

View File

@@ -0,0 +1,152 @@
import {
Block,
BlockTag,
JsonRpcProvider,
Signer,
TransactionReceipt,
TransactionRequest,
TransactionResponse,
} from "ethers";
import { BaseError } from "../../core/errors/Base";
import { IChainQuerier } from "../../core/clients/blockchain/IChainQuerier";
import { GasFees } from "../../core/clients/blockchain/IGasProvider";
export class ChainQuerier
implements IChainQuerier<TransactionReceipt, Block, TransactionRequest, TransactionResponse, JsonRpcProvider>
{
/**
* Creates an instance of ChainQuerier.
*
* @param {JsonRpcProvider} provider - The JSON RPC provider for interacting with the Ethereum network.
* @param {Signer} [signer] - An optional Ethers.js signer object for signing transactions.
*/
constructor(
protected readonly provider: JsonRpcProvider,
protected readonly signer?: Signer,
) {}
/**
* Retrieves the current nonce for a given account address.
*
* @param {string} [accountAddress] - The Ethereum account address to fetch the nonce for. Optional if a signer is provided.
* @returns {Promise<number>} A promise that resolves to the current nonce of the account.
* @throws {BaseError} If no account address is provided and no signer is available.
*/
public async getCurrentNonce(accountAddress?: string): Promise<number> {
if (accountAddress) {
return this.provider.getTransactionCount(accountAddress);
}
if (!this.signer) {
throw new BaseError("Please provide a signer.");
}
return this.provider.getTransactionCount(await this.signer.getAddress());
}
/**
* Retrieves the current block number of the blockchain.
*
* @returns {Promise<number>} A promise that resolves to the current block number.
*/
public async getCurrentBlockNumber(): Promise<number> {
return this.provider.getBlockNumber();
}
/**
* Retrieves the transaction receipt for a given transaction hash.
*
* @param {string} transactionHash - The hash of the transaction to fetch the receipt for.
* @returns {Promise<TransactionReceipt | null>} A promise that resolves to the transaction receipt, or null if the transaction is not found.
*/
public async getTransactionReceipt(transactionHash: string): Promise<TransactionReceipt | null> {
return this.provider.getTransactionReceipt(transactionHash);
}
/**
* Retrieves a block by its number or tag.
*
* @param {BlockTag} blockNumber - The block number or tag to fetch.
* @returns {Promise<Block | null>} A promise that resolves to the block, or null if the block is not found.
*/
public async getBlock(blockNumber: BlockTag): Promise<Block | null> {
return this.provider.getBlock(blockNumber);
}
/**
* Sends a custom JSON-RPC request.
*
* @param {string} methodName - The name of the JSON-RPC method to call.
* @param {any[]} params - The parameters to pass to the JSON-RPC method.
* @returns {Promise<any>} A promise that resolves to the result of the JSON-RPC call.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public async sendRequest(methodName: string, params: any[]): Promise<any> {
return this.provider.send(methodName, params);
}
/**
* Estimates the gas required for a transaction.
*
* @param {TransactionRequest} transactionRequest - The transaction request to estimate gas for.
* @returns {Promise<bigint>} A promise that resolves to the estimated gas.
*/
public async estimateGas(transactionRequest: TransactionRequest): Promise<bigint> {
return this.provider.estimateGas(transactionRequest);
}
/**
* Gets the JSON RPC provider.
*
* @returns {JsonRpcProvider} The JSON RPC provider.
*/
public getProvider(): JsonRpcProvider {
return this.provider;
}
/**
* Retrieves a transaction by its hash.
*
* @param {string} transactionHash - The hash of the transaction to fetch.
* @returns {Promise<TransactionResponse | null>} A promise that resolves to the transaction, or null if the transaction is not found.
*/
public async getTransaction(transactionHash: string): Promise<TransactionResponse | null> {
return this.provider.getTransaction(transactionHash);
}
/**
* Sends a signed transaction.
*
* @param {string} signedTx - The signed transaction to broadcast.
* @returns {Promise<TransactionResponse>} A promise that resolves to the transaction response.
*/
public async broadcastTransaction(signedTx: string): Promise<TransactionResponse> {
return this.provider.broadcastTransaction(signedTx);
}
/**
* Executes a call on the Ethereum network.
*
* @param {TransactionRequest} transactionRequest - The transaction request to execute.
* @returns {Promise<string>} A promise that resolves to the result of the call.
*/
public ethCall(transactionRequest: TransactionRequest): Promise<string> {
return this.provider.call(transactionRequest);
}
/**
* Retrieves the current gas fees.
*
* @returns {Promise<GasFees>} A promise that resolves to an object containing the current gas fees.
* @throws {BaseError} If there is an error getting the fee data.
*/
public async getFees(): Promise<GasFees> {
const { maxPriorityFeePerGas, maxFeePerGas } = await this.provider.getFeeData();
if (!maxPriorityFeePerGas || !maxFeePerGas) {
throw new BaseError("Error getting fee data");
}
return { maxPriorityFeePerGas, maxFeePerGas };
}
}

View File

@@ -0,0 +1,56 @@
import { JsonRpcProvider, Wallet } from "ethers";
import { describe, afterEach, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { ChainQuerier } from "../ChainQuerier";
import { TEST_ADDRESS_1, TEST_L1_SIGNER_PRIVATE_KEY, TEST_TRANSACTION_HASH } from "../../../utils/testing/constants";
import { generateTransactionReceipt } from "../../../utils/testing/helpers";
describe("ChainQuerier", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let chainQuerier: ChainQuerier;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
chainQuerier = new ChainQuerier(providerMock, new Wallet(TEST_L1_SIGNER_PRIVATE_KEY, providerMock));
});
afterEach(() => {
mockClear(providerMock);
});
describe("getCurrentNonce", () => {
it("should throw an error when accountAddress param is undefined and no signer has been passed to the ChainQuerier class", async () => {
const chainQuerier = new ChainQuerier(providerMock);
await expect(chainQuerier.getCurrentNonce()).rejects.toThrow("Please provide a signer.");
});
it("should return the nonce of the accountAddress passed in parameter", async () => {
const getTransactionCountSpy = jest.spyOn(providerMock, "getTransactionCount").mockResolvedValueOnce(10);
expect(await chainQuerier.getCurrentNonce(TEST_ADDRESS_1)).toEqual(10);
expect(getTransactionCountSpy).toHaveBeenCalledTimes(1);
expect(getTransactionCountSpy).toHaveBeenCalledWith(TEST_ADDRESS_1);
});
it("should return the nonce of the signer address", async () => {
const getTransactionCountSpy = jest.spyOn(providerMock, "getTransactionCount").mockResolvedValueOnce(10);
expect(await chainQuerier.getCurrentNonce()).toEqual(10);
expect(getTransactionCountSpy).toHaveBeenCalledTimes(1);
expect(getTransactionCountSpy).toHaveBeenCalledWith("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf");
});
});
describe("getCurrentBlockNumber", () => {
it("should return the current block number", async () => {
const mockBlockNumber = 10_000;
jest.spyOn(providerMock, "getBlockNumber").mockResolvedValueOnce(mockBlockNumber);
expect(await chainQuerier.getCurrentBlockNumber()).toEqual(mockBlockNumber);
});
});
describe("getTransactionReceipt", () => {
it("should return the transaction receipt", async () => {
const mockTransactionReceipt = generateTransactionReceipt();
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValueOnce(mockTransactionReceipt);
expect(await chainQuerier.getTransactionReceipt(TEST_TRANSACTION_HASH)).toEqual(mockTransactionReceipt);
});
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,158 @@
{
"contractName": "ProxyAdmin",
"abi": [
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferred",
"type": "event"
},
{
"inputs": [
{
"internalType": "contract ITransparentUpgradeableProxy",
"name": "proxy",
"type": "address"
},
{
"internalType": "address",
"name": "newAdmin",
"type": "address"
}
],
"name": "changeProxyAdmin",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "contract ITransparentUpgradeableProxy",
"name": "proxy",
"type": "address"
}
],
"name": "getProxyAdmin",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "contract ITransparentUpgradeableProxy",
"name": "proxy",
"type": "address"
}
],
"name": "getProxyImplementation",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "contract ITransparentUpgradeableProxy",
"name": "proxy",
"type": "address"
},
{
"internalType": "address",
"name": "implementation",
"type": "address"
}
],
"name": "upgrade",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "contract ITransparentUpgradeableProxy",
"name": "proxy",
"type": "address"
},
{
"internalType": "address",
"name": "implementation",
"type": "address"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "upgradeAndCall",
"outputs": [],
"stateMutability": "payable",
"type": "function"
}
],
"bytecode": "0x608060405234801561001057600080fd5b5061001a3361001f565b61006f565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b61069a8061007e6000396000f3fe60806040526004361061007b5760003560e01c80639623609d1161004e5780639623609d1461011157806399a88ec414610124578063f2fde38b14610144578063f3b7dead1461016457600080fd5b8063204e1c7a14610080578063715018a6146100bc5780637eff275e146100d35780638da5cb5b146100f3575b600080fd5b34801561008c57600080fd5b506100a061009b366004610499565b610184565b6040516001600160a01b03909116815260200160405180910390f35b3480156100c857600080fd5b506100d1610215565b005b3480156100df57600080fd5b506100d16100ee3660046104bd565b610229565b3480156100ff57600080fd5b506000546001600160a01b03166100a0565b6100d161011f36600461050c565b610291565b34801561013057600080fd5b506100d161013f3660046104bd565b610300565b34801561015057600080fd5b506100d161015f366004610499565b610336565b34801561017057600080fd5b506100a061017f366004610499565b6103b4565b6000806000836001600160a01b03166040516101aa90635c60da1b60e01b815260040190565b600060405180830381855afa9150503d80600081146101e5576040519150601f19603f3d011682016040523d82523d6000602084013e6101ea565b606091505b5091509150816101f957600080fd5b8080602001905181019061020d91906105e2565b949350505050565b61021d6103da565b6102276000610434565b565b6102316103da565b6040516308f2839760e41b81526001600160a01b038281166004830152831690638f283970906024015b600060405180830381600087803b15801561027557600080fd5b505af1158015610289573d6000803e3d6000fd5b505050505050565b6102996103da565b60405163278f794360e11b81526001600160a01b03841690634f1ef2869034906102c990869086906004016105ff565b6000604051808303818588803b1580156102e257600080fd5b505af11580156102f6573d6000803e3d6000fd5b5050505050505050565b6103086103da565b604051631b2ce7f360e11b81526001600160a01b038281166004830152831690633659cfe69060240161025b565b61033e6103da565b6001600160a01b0381166103a85760405162461bcd60e51b815260206004820152602660248201527f4f776e61626c653a206e6577206f776e657220697320746865207a65726f206160448201526564647265737360d01b60648201526084015b60405180910390fd5b6103b181610434565b50565b6000806000836001600160a01b03166040516101aa906303e1469160e61b815260040190565b6000546001600160a01b031633146102275760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015260640161039f565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6001600160a01b03811681146103b157600080fd5b6000602082840312156104ab57600080fd5b81356104b681610484565b9392505050565b600080604083850312156104d057600080fd5b82356104db81610484565b915060208301356104eb81610484565b809150509250929050565b634e487b7160e01b600052604160045260246000fd5b60008060006060848603121561052157600080fd5b833561052c81610484565b9250602084013561053c81610484565b9150604084013567ffffffffffffffff8082111561055957600080fd5b818601915086601f83011261056d57600080fd5b81358181111561057f5761057f6104f6565b604051601f8201601f19908116603f011681019083821181831017156105a7576105a76104f6565b816040528281528960208487010111156105c057600080fd5b8260208601602083013760006020848301015280955050505050509250925092565b6000602082840312156105f457600080fd5b81516104b681610484565b60018060a01b038316815260006020604081840152835180604085015260005b8181101561063b5785810183015185820160600152820161061f565b8181111561064d576000606083870101525b50601f01601f19169290920160600194935050505056fea26469706673582212207ad53e1008cce369999f6b5f2f77109510b404ff1de9b47b639981fd68e6239264736f6c63430008090033",
"deployedBytecode": "0x60806040526004361061007b5760003560e01c80639623609d1161004e5780639623609d1461011157806399a88ec414610124578063f2fde38b14610144578063f3b7dead1461016457600080fd5b8063204e1c7a14610080578063715018a6146100bc5780637eff275e146100d35780638da5cb5b146100f3575b600080fd5b34801561008c57600080fd5b506100a061009b366004610499565b610184565b6040516001600160a01b03909116815260200160405180910390f35b3480156100c857600080fd5b506100d1610215565b005b3480156100df57600080fd5b506100d16100ee3660046104bd565b610229565b3480156100ff57600080fd5b506000546001600160a01b03166100a0565b6100d161011f36600461050c565b610291565b34801561013057600080fd5b506100d161013f3660046104bd565b610300565b34801561015057600080fd5b506100d161015f366004610499565b610336565b34801561017057600080fd5b506100a061017f366004610499565b6103b4565b6000806000836001600160a01b03166040516101aa90635c60da1b60e01b815260040190565b600060405180830381855afa9150503d80600081146101e5576040519150601f19603f3d011682016040523d82523d6000602084013e6101ea565b606091505b5091509150816101f957600080fd5b8080602001905181019061020d91906105e2565b949350505050565b61021d6103da565b6102276000610434565b565b6102316103da565b6040516308f2839760e41b81526001600160a01b038281166004830152831690638f283970906024015b600060405180830381600087803b15801561027557600080fd5b505af1158015610289573d6000803e3d6000fd5b505050505050565b6102996103da565b60405163278f794360e11b81526001600160a01b03841690634f1ef2869034906102c990869086906004016105ff565b6000604051808303818588803b1580156102e257600080fd5b505af11580156102f6573d6000803e3d6000fd5b5050505050505050565b6103086103da565b604051631b2ce7f360e11b81526001600160a01b038281166004830152831690633659cfe69060240161025b565b61033e6103da565b6001600160a01b0381166103a85760405162461bcd60e51b815260206004820152602660248201527f4f776e61626c653a206e6577206f776e657220697320746865207a65726f206160448201526564647265737360d01b60648201526084015b60405180910390fd5b6103b181610434565b50565b6000806000836001600160a01b03166040516101aa906303e1469160e61b815260040190565b6000546001600160a01b031633146102275760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e6572604482015260640161039f565b600080546001600160a01b038381166001600160a01b0319831681178455604051919092169283917f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09190a35050565b6001600160a01b03811681146103b157600080fd5b6000602082840312156104ab57600080fd5b81356104b681610484565b9392505050565b600080604083850312156104d057600080fd5b82356104db81610484565b915060208301356104eb81610484565b809150509250929050565b634e487b7160e01b600052604160045260246000fd5b60008060006060848603121561052157600080fd5b833561052c81610484565b9250602084013561053c81610484565b9150604084013567ffffffffffffffff8082111561055957600080fd5b818601915086601f83011261056d57600080fd5b81358181111561057f5761057f6104f6565b604051601f8201601f19908116603f011681019083821181831017156105a7576105a76104f6565b816040528281528960208487010111156105c057600080fd5b8260208601602083013760006020848301015280955050505050509250925092565b6000602082840312156105f457600080fd5b81516104b681610484565b60018060a01b038316815260006020604081840152835180604085015260005b8181101561063b5785810183015185820160600152820161061f565b8181111561064d576000606083870101525b50601f01601f19169290920160600194935050505056fea26469706673582212207ad53e1008cce369999f6b5f2f77109510b404ff1de9b47b639981fd68e6239264736f6c63430008090033",
"linkReferences": {},
"deployedLinkReferences": {}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,167 @@
import { JsonRpcProvider } from "ethers";
import {
MessageSentEventFilters,
ILineaRollupLogClient,
L2MessagingBlockAnchoredFilters,
MessageClaimedFilters,
} from "../../../core/clients/blockchain/ethereum/ILineaRollupLogClient";
import { L2MessagingBlockAnchored, MessageClaimed, MessageSent } from "../../../core/types/Events";
import { LineaRollup, LineaRollup__factory } from "../typechain";
import { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog } from "../typechain/common";
import { L2MessagingBlockAnchoredEvent, MessageClaimedEvent, MessageSentEvent } from "../typechain/LineaRollup";
import { isUndefined } from "../../../core/utils/shared";
export class EthersLineaRollupLogClient implements ILineaRollupLogClient {
private lineaRollup: LineaRollup;
/**
* Initializes a new instance of the `EthersLineaRollupLogClient`.
*
* @param {JsonRpcProvider} provider - The JSON RPC provider for interacting with the Ethereum network.
* @param {string} contractAddress - The address of the Linea Rollup contract.
*/
constructor(provider: JsonRpcProvider, contractAddress: string) {
this.lineaRollup = LineaRollup__factory.connect(contractAddress, provider);
}
/**
* Fetches event logs from the Linea Rollup contract based on the provided filters and block range.
*
* This generic method queries the Ethereum blockchain for events emitted by the Linea Rollup contract that match the given criteria. It filters the events further based on the optional parameters for block range and log index, ensuring that only relevant events are returned.
*
* @template TCEevent - A type parameter extending `TypedContractEvent`, representing the specific event type to fetch.
* @param {TypedDeferredTopicFilter<TypedContractEvent>} eventFilter - The filter criteria used to select the events to be fetched. This includes the contract address, event signature, and any additional filter parameters.
* @param {number} [fromBlock=0] - The block number from which to start fetching events. Defaults to `0` if not specified.
* @param {string|number} [toBlock='latest'] - The block number until which to fetch events. Defaults to 'latest' if not specified.
* @param {number} [fromBlockLogIndex] - The log index within the `fromBlock` from which to start fetching events. This allows for more granular control over the event fetch start point within a block.
* @returns {Promise<TypedEventLog<TCEevent>[]>} A promise that resolves to an array of event logs of the specified type that match the filter criteria.
*/
private async getEvents<TCEevent extends TypedContractEvent>(
eventFilter: TypedDeferredTopicFilter<TypedContractEvent>,
fromBlock?: number,
toBlock?: string | number,
fromBlockLogIndex?: number,
): Promise<TypedEventLog<TCEevent>[]> {
const events = await this.lineaRollup.queryFilter(eventFilter, fromBlock, toBlock);
return events
.filter((event) => {
if (isUndefined(fromBlockLogIndex) || isUndefined(fromBlock)) {
return true;
}
if (event.blockNumber === fromBlock && event.index < fromBlockLogIndex) {
return false;
}
return true;
})
.filter((e) => e.removed === false);
}
/**
* Retrieves `MessageSent` events that match the given filters.
*
* @param {Object} params - The parameters for fetching the events.
* @param {MessageSentEventFilters} [params.filters] - The messageSent event filters to apply.
* @param {number} [params.fromBlock=0] - The starting block number. Defaults to `0` if not specified.
* @param {string|number} [params.toBlock='latest'] - The ending block number. Defaults to `latest` if not specified.
* @param {number} [params.fromBlockLogIndex] - The log index to start from within the `fromBlock`.
* @returns {Promise<MessageSent[]>} A promise that resolves to an array of `MessageSent` events.
*/
public async getMessageSentEvents(params: {
filters?: MessageSentEventFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageSent[]> {
const { filters, fromBlock, toBlock, fromBlockLogIndex } = params;
const messageSentEventFilter = this.lineaRollup.filters.MessageSent(
filters?.from,
filters?.to,
undefined,
undefined,
undefined,
undefined,
filters?.messageHash,
);
return (
await this.getEvents<MessageSentEvent.Event>(messageSentEventFilter, fromBlock, toBlock, fromBlockLogIndex)
).map((event) => ({
messageSender: event.args._from,
destination: event.args._to,
fee: event.args._fee,
value: event.args._value,
messageNonce: event.args._nonce,
calldata: event.args._calldata,
messageHash: event.args._messageHash,
blockNumber: event.blockNumber,
logIndex: event.index,
contractAddress: event.address,
transactionHash: event.transactionHash,
}));
}
/**
* Retrieves `L2MessagingBlockAnchored` events that match the given filters.
*
* @param {Object} params - The parameters for fetching the events.
* @param {L2MessagingBlockAnchoredFilters} [params.filters] - The L2MessagingBlockAnchored event filters to apply.
* @param {number} [params.fromBlock=0] - The starting block number. Defaults to `0` if not specified.
* @param {string|number} [params.toBlock='latest'] - The ending block number. Defaults to `latest` if not specified.
* @param {number} [params.fromBlockLogIndex] - The log index to start from within the `fromBlock`.
* @returns {Promise<L2MessagingBlockAnchored[]>} A promise that resolves to an array of `L2MessagingBlockAnchored` events.
*/
public async getL2MessagingBlockAnchoredEvents(params: {
filters?: L2MessagingBlockAnchoredFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<L2MessagingBlockAnchored[]> {
const { filters, fromBlock, toBlock, fromBlockLogIndex } = params;
const l2MessagingBlockAnchoredFilter = this.lineaRollup.filters.L2MessagingBlockAnchored(filters?.l2Block);
return (
await this.getEvents<L2MessagingBlockAnchoredEvent.Event>(
l2MessagingBlockAnchoredFilter,
fromBlock,
toBlock,
fromBlockLogIndex,
)
).map((event) => ({
l2Block: event.args.l2Block,
blockNumber: event.blockNumber,
logIndex: event.index,
contractAddress: event.address,
transactionHash: event.transactionHash,
}));
}
/**
* Retrieves `MessageClaimed` events that match the given filters.
*
* @param {Object} params - The parameters for fetching the events.
* @param {MessageClaimedFilters} [params.filters] - The MessageClaimed event filters to apply.
* @param {number} [params.fromBlock=0] - The starting block number. Defaults to `0` if not specified.
* @param {string|number} [params.toBlock='latest'] - The ending block number. Defaults to `latest` if not specified.
* @param {number} [params.fromBlockLogIndex] - The log index to start from within the `fromBlock`.
* @returns {Promise<MessageClaimed[]>} A promise that resolves to an array of `MessageClaimed` events.
*/
public async getMessageClaimedEvents(params: {
filters?: MessageClaimedFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageClaimed[]> {
const { filters, fromBlock, toBlock, fromBlockLogIndex } = params;
const messageClaimedFilter = this.lineaRollup.filters.MessageClaimed(filters?.messageHash);
return (
await this.getEvents<MessageClaimedEvent.Event>(messageClaimedFilter, fromBlock, toBlock, fromBlockLogIndex)
).map((event) => ({
messageHash: event.args._messageHash,
blockNumber: event.blockNumber,
logIndex: event.index,
contractAddress: event.address,
transactionHash: event.transactionHash,
}));
}
}

View File

@@ -0,0 +1,489 @@
import {
Overrides,
ContractTransactionResponse,
JsonRpcProvider,
TransactionRequest,
TransactionResponse,
Signer,
TransactionReceipt,
Block,
} from "ethers";
import { LineaRollup, LineaRollup__factory } from "../typechain";
import { GasEstimationError } from "../../../core/errors/GasFeeErrors";
import { Message, MessageProps } from "../../../core/entities/Message";
import { OnChainMessageStatus } from "../../../core/enums/MessageEnums";
import {
MESSAGE_UNKNOWN_STATUS,
MESSAGE_CLAIMED_STATUS,
ZERO_ADDRESS,
DEFAULT_RATE_LIMIT_MARGIN,
} from "../../../core/constants";
import { ILineaRollupClient } from "../../../core/clients/blockchain/ethereum/ILineaRollupClient";
import { ILineaRollupLogClient } from "../../../core/clients/blockchain/ethereum/ILineaRollupLogClient";
import { IL2MessageServiceLogClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceLogClient";
import { BaseError } from "../../../core/errors/Base";
import { SDKMode } from "../../../sdk/config";
import { formatMessageStatus } from "../../../core/utils/message";
import { GasFees, IEthereumGasProvider } from "../../../core/clients/blockchain/IGasProvider";
import { IMessageRetriever } from "../../../core/clients/blockchain/IMessageRetriever";
import {
FinalizationMessagingInfo,
IMerkleTreeService,
Proof,
} from "../../../core/clients/blockchain/ethereum/IMerkleTreeService";
import { MessageSent } from "../../../core/types/Events";
import { IChainQuerier } from "../../../core/clients/blockchain/IChainQuerier";
export class LineaRollupClient
implements ILineaRollupClient<Overrides, TransactionReceipt, TransactionResponse, ContractTransactionResponse>
{
private readonly contract: LineaRollup;
/**
* @notice Initializes a new instance of the `LineaRollupClient`.
* @dev This constructor sets up the Linea Rollup Client with the necessary dependencies and configurations.
* @param {IChainQuerier} chainQuerier The chain querier for interacting with the blockchain.
* @param {string} contractAddress The address of the Linea Rollup contract.
* @param {ILineaRollupLogClient} lineaRollupLogClient An instance of a class implementing the `ILineaRollupLogClient` interface for fetching events from the blockchain.
* @param {IL2MessageServiceLogClient} l2MessageServiceLogClient An instance of a class implementing the `IL2MessageServiceLogClient` interface for fetching events from the blockchain.
* @param {IEthereumGasProvider} gasProvider An instance of a class implementing the `IEthereumGasProvider` interface for providing gas estimates.
* @param {IMessageRetriever} messageRetriever An instance of a class implementing the `IMessageRetriever` interface for retrieving messages.
* @param {IMerkleTreeService} merkleTreeService An instance of a class implementing the `IMerkleTreeService` interface for managing Merkle trees.
* @param {SDKMode} mode The mode in which the SDK is operating, e.g., `read-only` or `read-write`.
* @param {Signer} signer An optional Ethers.js signer object for signing transactions.
*/
constructor(
private readonly chainQuerier: IChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly contractAddress: string,
private readonly lineaRollupLogClient: ILineaRollupLogClient,
private readonly l2MessageServiceLogClient: IL2MessageServiceLogClient,
private readonly gasProvider: IEthereumGasProvider<TransactionRequest>,
private readonly messageRetriever: IMessageRetriever<TransactionReceipt>,
private readonly merkleTreeService: IMerkleTreeService,
private readonly mode: SDKMode,
private readonly signer?: Signer,
) {
this.contract = this.getContract(this.contractAddress, this.signer);
}
/**
* Retrieves message information by message hash.
* @param {string} messageHash - The hash of the message sent on L1.
* @returns {Promise<MessageSent | null>} The message information or null if not found.
*/
public async getMessageByMessageHash(messageHash: string): Promise<MessageSent | null> {
return this.messageRetriever.getMessageByMessageHash(messageHash);
}
/**
* Retrieves messages information by the transaction hash.
* @param {string} transactionHash - The hash of the `sendMessage` transaction on L1.
* @returns {Promise<MessageSent[] | null>} An array of message information or null if not found.
*/
public async getMessagesByTransactionHash(transactionHash: string): Promise<MessageSent[] | null> {
return this.messageRetriever.getMessagesByTransactionHash(transactionHash);
}
/**
* Retrieves the transaction receipt by message hash.
* @param {string} messageHash - The hash of the message sent on L1.
* @returns {Promise<TransactionReceipt | null>} The transaction receipt or null if not found.
*/
public async getTransactionReceiptByMessageHash(messageHash: string): Promise<TransactionReceipt | null> {
return this.messageRetriever.getTransactionReceiptByMessageHash(messageHash);
}
/**
* Retrieves the finalization messaging info. This function is used in the L1 claiming flow.
* @param {string} transactionHash - The finalization transaction hash.
* @returns {Promise<FinalizationMessagingInfo>} The finalization messaging info: l2MessagingBlocksRange, l2MerkleRoots, treeDepth.
*/
public async getFinalizationMessagingInfo(transactionHash: string): Promise<FinalizationMessagingInfo> {
return this.merkleTreeService.getFinalizationMessagingInfo(transactionHash);
}
/**
* Retrieves L2 message hashes in a specific L2 block range.
* @param {number} fromBlock - The starting block number.
* @param {number} toBlock - The ending block number.
* @returns {Promise<string[]>} A list of all L2 message hashes in the specified block range.
*/
public async getL2MessageHashesInBlockRange(fromBlock: number, toBlock: number): Promise<string[]> {
return this.merkleTreeService.getL2MessageHashesInBlockRange(fromBlock, toBlock);
}
/**
* Retrieves message siblings for building the merkle tree. This merkle tree will be used to generate the proof to be able to claim the message on L1.
* @param {string} messageHash - The message hash.
* @param {string[]} messageHashes - The list of all L2 message hashes finalized in the same finalization transaction on L1.
* @param {number} treeDepth - The merkle tree depth.
* @returns {string[]} The message siblings.
*/
public getMessageSiblings(messageHash: string, messageHashes: string[], treeDepth: number): string[] {
return this.merkleTreeService.getMessageSiblings(messageHash, messageHashes, treeDepth);
}
/**
* Retrieves the message proof for claiming the message on L1.
* @param {string} messageHash - The message hash.
* @returns {Promise<Proof>} The merkle root, the merkle proof and the message leaf index.
*/
public async getMessageProof(messageHash: string): Promise<Proof> {
return this.merkleTreeService.getMessageProof(messageHash);
}
public async getGasFees(): Promise<GasFees> {
return this.gasProvider.getGasFees();
}
public getMaxFeePerGas(): bigint {
return this.gasProvider.getMaxFeePerGas();
}
/**
* Retrieves the LineaRollup contract instance.
* @param {string} contractAddress - Address of the L1 contract.
* @param {Signer} [signer] - The signer instance.
* @returns {LineaRollup} The LineaRollup contract instance.
*/
private getContract(contractAddress: string, signer?: Signer): LineaRollup {
if (this.mode === "read-only") {
return LineaRollup__factory.connect(contractAddress, this.chainQuerier.getProvider());
}
if (!signer) {
throw new BaseError("Please provide a signer.");
}
return LineaRollup__factory.connect(contractAddress, signer);
}
/**
* Retrieves the L2 message status on L1 using the message hash (for messages sent before migration).
* @param {string} messageHash - The hash of the message sent on L2.
* @param {Overrides} [overrides={}] - Ethers call overrides. Defaults to `{}` if not specified.
* @returns {Promise<OnChainMessageStatus>} The message status (CLAIMED, CLAIMABLE, UNKNOWN).
*/
public async getMessageStatusUsingMessageHash(
messageHash: string,
overrides: Overrides = {},
): Promise<OnChainMessageStatus> {
let status = await this.contract.inboxL2L1MessageStatus(messageHash, overrides);
if (status === BigInt(MESSAGE_UNKNOWN_STATUS)) {
const events = await this.lineaRollupLogClient.getMessageClaimedEvents({
filters: { messageHash },
fromBlock: 0,
toBlock: "latest",
});
if (events.length > 0) {
status = BigInt(MESSAGE_CLAIMED_STATUS);
}
}
return formatMessageStatus(status);
}
/**
* Retrieves the L2 message status on L1.
* @param {string} messageHash - The hash of the message sent on L2.
* @param {Overrides} [overrides={}] - Ethers call overrides. Defaults to `{}` if not specified.
* @returns {Promise<OnChainMessageStatus>} The message status (CLAIMED, CLAIMABLE, UNKNOWN).
*/
public async getMessageStatus(messageHash: string, overrides: Overrides = {}): Promise<OnChainMessageStatus> {
return this.getMessageStatusUsingMerkleTree(messageHash, overrides);
}
/**
* Retrieves the L2 message status on L1 using merkle tree (for messages sent after migration).
* @param {string} messageHash - The hash of the message sent on L2.
* @param {Overrides} [overrides={}] - Ethers call overrides. Defaults to `{}` if not specified.
* @returns {Promise<OnChainMessageStatus>} The message status (CLAIMED, CLAIMABLE, UNKNOWN).
*/
public async getMessageStatusUsingMerkleTree(
messageHash: string,
overrides: Overrides = {},
): Promise<OnChainMessageStatus> {
const [messageEvent] = await this.l2MessageServiceLogClient.getMessageSentEventsByMessageHash({ messageHash });
if (!messageEvent) {
throw new BaseError(`Message hash does not exist on L2. Message hash: ${messageHash}`);
}
const [[l2MessagingBlockAnchoredEvent], isMessageClaimed] = await Promise.all([
this.lineaRollupLogClient.getL2MessagingBlockAnchoredEvents({
filters: { l2Block: BigInt(messageEvent.blockNumber) },
}),
this.contract.isMessageClaimed(messageEvent.messageNonce, overrides),
]);
if (isMessageClaimed) {
return OnChainMessageStatus.CLAIMED;
}
if (l2MessagingBlockAnchoredEvent) {
return OnChainMessageStatus.CLAIMABLE;
}
return OnChainMessageStatus.UNKNOWN;
}
/**
* Estimates the gas required for the claimMessage transaction.
* @param {MessageProps & { feeRecipient?: string }} message - The message information.
* @param {Overrides} [overrides={}] - Ethers payable overrides. Defaults to `{}` if not specified.
* @returns {Promise<bigint>} The estimated transaction gas.
*/
public async estimateClaimWithoutProofGas(
message: MessageProps & { feeRecipient?: string },
overrides: Overrides = {},
): Promise<bigint> {
if (this.mode === "read-only") {
throw new BaseError("'EstimateClaimGas' function not callable using readOnly mode.");
}
const { messageSender, destination, fee, value, calldata, messageNonce, feeRecipient } = message;
const l1FeeRecipient = feeRecipient ?? ZERO_ADDRESS;
try {
return await this.contract.claimMessage.estimateGas(
messageSender,
destination,
fee,
value,
l1FeeRecipient,
calldata,
messageNonce,
{
...(await this.gasProvider.getGasFees()),
...overrides,
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
throw new GasEstimationError(e, message);
}
}
/**
* Claims the message on L1 without merkle tree (for message sent before the migration).
* @param {MessageProps & { feeRecipient?: string }} message - The message information.
* @param {Overrides} [overrides={}] - Ethers payable overrides. Defaults to `{}` if not specified.
* @returns {Promise<ContractTransactionResponse>} The transaction response.
*/
public async claimWithoutProof(
message: MessageProps & { feeRecipient?: string },
overrides: Overrides = {},
): Promise<ContractTransactionResponse> {
if (this.mode === "read-only") {
throw new BaseError("'claim' function not callable using readOnly mode.");
}
const { messageSender, destination, fee, value, calldata, messageNonce, feeRecipient } = message;
const l1FeeRecipient = feeRecipient ?? ZERO_ADDRESS;
return await this.contract.claimMessage(
messageSender,
destination,
fee,
value,
l1FeeRecipient,
calldata,
messageNonce,
{
...(await this.gasProvider.getGasFees()),
...overrides,
},
);
}
/**
* Estimates the gas required for the claimMessageWithProof transaction.
* @param {Message & { feeRecipient?: string }} message - The message information.
* @param {Overrides} [overrides={}] - Ethers payable overrides. Defaults to `{}` if not specified.
* @returns {Promise<bigint>} The estimated gas.
*/
public async estimateClaimGas(
message: Message & { feeRecipient?: string },
overrides: Overrides = {},
): Promise<bigint> {
if (this.mode === "read-only") {
throw new BaseError("'EstimateClaimGasFees' function not callable using readOnly mode.");
}
const { messageSender, destination, fee, value, calldata, messageNonce, feeRecipient } = message;
const { proof, leafIndex, root } = await this.merkleTreeService.getMessageProof(message.messageHash);
const l1FeeRecipient = feeRecipient ?? ZERO_ADDRESS;
try {
return await this.contract.claimMessageWithProof.estimateGas(
{
from: messageSender,
to: destination,
fee,
value,
data: calldata,
messageNumber: messageNonce,
proof,
leafIndex,
merkleRoot: root,
feeRecipient: l1FeeRecipient,
},
{
...(await this.gasProvider.getGasFees()),
...overrides,
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
throw new GasEstimationError(e, message);
}
}
/**
* Claims the message using merkle proof on L1.
* @param {Message & { feeRecipient?: string }} message - The message information.
* @param {Overrides} [overrides={}] - Ethers payable overrides. Defaults to `{}` if not specified.
* @returns {Promise<ContractTransactionResponse>} The transaction response.
*/
public async claim(
message: Message & { feeRecipient?: string },
overrides: Overrides = {},
): Promise<ContractTransactionResponse> {
if (this.mode === "read-only") {
throw new BaseError("'claim' function not callable using readOnly mode.");
}
const { messageSender, destination, fee, value, calldata, messageNonce, feeRecipient } = message;
const l1FeeRecipient = feeRecipient ?? ZERO_ADDRESS;
const { proof, leafIndex, root } = await this.merkleTreeService.getMessageProof(message.messageHash);
return await this.contract.claimMessageWithProof(
{
from: messageSender,
to: destination,
fee,
value,
data: calldata,
messageNumber: messageNonce,
proof,
leafIndex,
merkleRoot: root,
feeRecipient: l1FeeRecipient,
},
{
...(await this.gasProvider.getGasFees()),
...overrides,
},
);
}
/**
* Retries a specific transaction with a higher fee.
* @param {string} transactionHash - The transaction hash.
* @param {number} [priceBumpPercent=10] - The percentage of price increase. Defaults to `10` if not specified.
* @returns {Promise<TransactionResponse>} The transaction response.
*/
public async retryTransactionWithHigherFee(
transactionHash: string,
priceBumpPercent: number = 10,
): Promise<TransactionResponse> {
if (!Number.isInteger(priceBumpPercent)) {
throw new Error("'priceBumpPercent' must be an integer");
}
if (this.mode === "read-only") {
throw new BaseError("'retryTransactionWithHigherFee' function not callable using readOnly mode.");
}
const transaction = await this.chainQuerier.getTransaction(transactionHash);
if (!transaction) {
throw new BaseError(`Transaction with hash ${transactionHash} not found.`);
}
let maxPriorityFeePerGas;
let maxFeePerGas;
if (!transaction.maxPriorityFeePerGas || !transaction.maxFeePerGas) {
const txFees = await this.gasProvider.getGasFees();
maxPriorityFeePerGas = txFees.maxPriorityFeePerGas;
maxFeePerGas = txFees.maxFeePerGas;
} else {
maxPriorityFeePerGas = (transaction.maxPriorityFeePerGas * (BigInt(priceBumpPercent) + 100n)) / 100n;
maxFeePerGas = (transaction.maxFeePerGas * (BigInt(priceBumpPercent) + 100n)) / 100n;
const maxFeePerGasFromConfig = this.gasProvider.getMaxFeePerGas();
if (maxPriorityFeePerGas > maxFeePerGasFromConfig) {
maxPriorityFeePerGas = maxFeePerGasFromConfig;
}
if (maxFeePerGas > maxFeePerGasFromConfig) {
maxFeePerGas = maxFeePerGasFromConfig;
}
}
const updatedTransaction: TransactionRequest = {
to: transaction.to,
value: transaction.value,
data: transaction.data,
nonce: transaction.nonce,
gasLimit: transaction.gasLimit,
chainId: transaction.chainId,
type: 2,
maxPriorityFeePerGas,
maxFeePerGas,
};
const signedTransaction = await this.signer!.signTransaction(updatedTransaction);
return await this.chainQuerier.broadcastTransaction(signedTransaction);
}
/**
* Checks if the withdrawal rate limit has been exceeded.
* @param {bigint} messageFee - The message fee.
* @param {bigint} messageValue - The message value.
* @returns {Promise<boolean>} True if the rate limit has been exceeded, false otherwise.
*/
public async isRateLimitExceeded(messageFee: bigint, messageValue: bigint): Promise<boolean> {
const rateLimitInWei = await this.contract.limitInWei();
const currentPeriodAmountInWei = await this.contract.currentPeriodAmountInWei();
return (
parseFloat((currentPeriodAmountInWei + BigInt(messageFee) + BigInt(messageValue)).toString()) >
parseFloat(rateLimitInWei.toString()) * DEFAULT_RATE_LIMIT_MARGIN
);
}
/**
* Checks if an error is of type 'RateLimitExceeded'.
* @param {string} transactionHash - The transaction hash.
* @returns {Promise<boolean>} True if the error type is 'RateLimitExceeded', false otherwise.
*/
public async isRateLimitExceededError(transactionHash: string): Promise<boolean> {
try {
const tx = await this.chainQuerier.getTransaction(transactionHash);
const errorEncodedData = await this.chainQuerier.ethCall({
to: tx?.to,
from: tx?.from,
nonce: tx?.nonce,
gasLimit: tx?.gasLimit,
data: tx?.data,
value: tx?.value,
chainId: tx?.chainId,
accessList: tx?.accessList,
maxPriorityFeePerGas: tx?.maxPriorityFeePerGas,
maxFeePerGas: tx?.maxFeePerGas,
});
const error = this.contract.interface.parseError(errorEncodedData);
return error?.name === "RateLimitExceeded";
} catch (e) {
return false;
}
}
}

View File

@@ -0,0 +1,91 @@
import { ILineaRollupLogClient } from "../../../core/clients/blockchain/ethereum/ILineaRollupLogClient";
import { Block, JsonRpcProvider, TransactionReceipt, TransactionRequest, TransactionResponse } from "ethers";
import { MessageSent } from "../../../core/types/Events";
import { MESSAGE_SENT_EVENT_SIGNATURE } from "../../../core/constants";
import { isNull } from "../../../core/utils/shared";
import { LineaRollup, LineaRollup__factory } from "../typechain";
import { IMessageRetriever } from "../../../core/clients/blockchain/IMessageRetriever";
import { IChainQuerier } from "sdk/src/core/clients/blockchain/IChainQuerier";
export class LineaRollupMessageRetriever implements IMessageRetriever<TransactionReceipt> {
private readonly contract: LineaRollup;
/**
* Initializes a new instance of the `LineaRollupMessageRetriever`.
*
* @param {IChainQuerier} chainQuerier - The chain querier for interacting with the blockchain.
* @param {ILineaRollupLogClient} lineaRollupLogClient - An instance of a class implementing the `ILineaRollupLogClient` interface for fetching events from the blockchain.
* @param {string} contractAddress - The address of the Linea Rollup contract.
*/
constructor(
private readonly chainQuerier: IChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly lineaRollupLogClient: ILineaRollupLogClient,
private readonly contractAddress: string,
) {
this.contract = LineaRollup__factory.connect(contractAddress, this.chainQuerier.getProvider());
}
/**
* Retrieves message information by message hash.
* @param {string} messageHash - The hash of the message sent on L1.
* @returns {Promise<MessageSent | null>} The message information or null if not found.
*/
public async getMessageByMessageHash(messageHash: string): Promise<MessageSent | null> {
const [event] = await this.lineaRollupLogClient.getMessageSentEvents({
filters: { messageHash },
fromBlock: 0,
toBlock: "latest",
});
return event ?? null;
}
/**
* Retrieves messages information by the transaction hash.
* @param {string} transactionHash - The hash of the `sendMessage` transaction on L1.
* @returns {Promise<MessageSent[] | null>} An array of message information or null if not found.
*/
public async getMessagesByTransactionHash(transactionHash: string): Promise<MessageSent[] | null> {
const receipt = await this.chainQuerier.getTransactionReceipt(transactionHash);
if (!receipt) {
return null;
}
const messageSentEvents = await Promise.all(
receipt.logs
.filter((log) => log.address === this.contractAddress && log.topics[0] === MESSAGE_SENT_EVENT_SIGNATURE)
.map((log) => this.contract.interface.parseLog(log))
.filter((log) => !isNull(log))
.map((log) => this.getMessageByMessageHash(log!.args._messageHash)),
);
return messageSentEvents.filter((log) => !isNull(log)) as MessageSent[];
}
/**
* Retrieves the transaction receipt by message hash.
* @param {string} messageHash - The hash of the message sent on L1.
* @returns {Promise<TransactionReceipt | null>} The transaction receipt or null if not found.
*/
public async getTransactionReceiptByMessageHash(messageHash: string): Promise<TransactionReceipt | null> {
const [event] = await this.lineaRollupLogClient.getMessageSentEvents({
filters: { messageHash },
fromBlock: 0,
toBlock: "latest",
});
if (!event) {
return null;
}
const receipt = await this.chainQuerier.getTransactionReceipt(event.transactionHash);
if (!receipt) {
return null;
}
return receipt;
}
}

View File

@@ -0,0 +1,178 @@
import { Block, JsonRpcProvider, TransactionReceipt, TransactionRequest, TransactionResponse } from "ethers";
import { SparseMerkleTreeFactory } from "../../../services/merkleTree/MerkleTreeFactory";
import { BaseError } from "../../../core/errors/Base";
import { ILineaRollupLogClient } from "../../../core/clients/blockchain/ethereum/ILineaRollupLogClient";
import { IL2MessageServiceLogClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceLogClient";
import {
L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE,
L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE,
ZERO_HASH,
} from "../../../core/constants";
import { LineaRollup, LineaRollup__factory } from "../typechain";
import {
FinalizationMessagingInfo,
IMerkleTreeService,
Proof,
} from "../../../core/clients/blockchain/ethereum/IMerkleTreeService";
import { IChainQuerier } from "../../../core/clients/blockchain/IChainQuerier";
export class MerkleTreeService implements IMerkleTreeService {
private readonly contract: LineaRollup;
/**
* Initializes a new instance of the `MerkleTreeService`.
*
* @param {IChainQuerier} chainQuerier - The chain querier for interacting with the blockchain.
* @param {string} contractAddress - The address of the Linea Rollup contract.
* @param {ILineaRollupLogClient} lineaRollupLogClient - An instance of a class implementing the `ILineaRollupLogClient` interface for fetching events from ethereum.
* @param {IL2MessageServiceLogClient} l2MessageServiceLogClient - An instance of a class implementing the `IL2MessageServiceLogClient` interface for fetching events from linea.
* @param {number} l2MessageTreeDepth - The depth of the L2 message tree.
*/
constructor(
private readonly chainQuerier: IChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly contractAddress: string,
private readonly lineaRollupLogClient: ILineaRollupLogClient,
private readonly l2MessageServiceLogClient: IL2MessageServiceLogClient,
private readonly l2MessageTreeDepth: number,
) {
this.contract = LineaRollup__factory.connect(contractAddress, this.chainQuerier.getProvider());
}
/**
* Retrieves the message proof for claiming the message on L1.
* @param {string} messageHash - The message hash.
* @returns {Promise<Proof>} The merkle root, the merkle proof and the message leaf index.
*/
public async getMessageProof(messageHash: string): Promise<Proof> {
const [messageEvent] = await this.l2MessageServiceLogClient.getMessageSentEventsByMessageHash({ messageHash });
if (!messageEvent) {
throw new BaseError(`Message hash does not exist on L2. Message hash: ${messageHash}`);
}
const [l2MessagingBlockAnchoredEvent] = await this.lineaRollupLogClient.getL2MessagingBlockAnchoredEvents({
filters: { l2Block: BigInt(messageEvent.blockNumber) },
});
if (!l2MessagingBlockAnchoredEvent) {
throw new BaseError(`L2 block number ${messageEvent.blockNumber} has not been finalized on L1.`);
}
const finalizationInfo = await this.getFinalizationMessagingInfo(l2MessagingBlockAnchoredEvent.transactionHash);
const l2MessageHashesInBlockRange = await this.getL2MessageHashesInBlockRange(
finalizationInfo.l2MessagingBlocksRange.startingBlock,
finalizationInfo.l2MessagingBlocksRange.endBlock,
);
const l2messages = this.getMessageSiblings(messageHash, l2MessageHashesInBlockRange, finalizationInfo.treeDepth);
const merkleTreeFactory = new SparseMerkleTreeFactory(this.l2MessageTreeDepth);
const tree = merkleTreeFactory.createAndAddLeaves(l2messages);
if (!finalizationInfo.l2MerkleRoots.includes(tree.getRoot())) {
throw new BaseError("Merkle tree build failed.");
}
return tree.getProof(l2messages.indexOf(messageHash));
}
/**
* Retrieves the finalization messaging info. This function is used in the L1 claiming flow.
* @param {string} transactionHash - The finalization transaction hash.
* @returns {Promise<FinalizationMessagingInfo>} The finalization messaging info: l2MessagingBlocksRange, l2MerkleRoots, treeDepth.
*/
public async getFinalizationMessagingInfo(transactionHash: string): Promise<FinalizationMessagingInfo> {
const receipt = await this.chainQuerier.getTransactionReceipt(transactionHash);
if (!receipt || receipt.logs.length === 0) {
throw new BaseError(`Transaction does not exist or no logs found in this transaction: ${transactionHash}.`);
}
let treeDepth = 0;
const l2MerkleRoots: string[] = [];
const blocksNumber: number[] = [];
const filteredLogs = receipt.logs.filter((log) => log.address === this.contractAddress);
for (const log of filteredLogs) {
const parsedLog = this.contract.interface.parseLog(log);
if (log.topics[0] === L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE) {
treeDepth = parseInt(parsedLog?.args.treeDepth);
l2MerkleRoots.push(parsedLog?.args.l2MerkleRoot);
} else if (log.topics[0] === L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE) {
blocksNumber.push(Number(parsedLog?.args.l2Block));
}
}
if (l2MerkleRoots.length === 0) {
throw new BaseError(`No L2MerkleRootAdded events found in this transaction.`);
}
if (blocksNumber.length === 0) {
throw new BaseError(`No L2MessagingBlocksAnchored events found in this transaction.`);
}
return {
l2MessagingBlocksRange: {
startingBlock: Math.min(...blocksNumber),
endBlock: Math.max(...blocksNumber),
},
l2MerkleRoots,
treeDepth,
};
}
/**
* Retrieves L2 message hashes in a specific L2 block range.
* @param {number} fromBlock - The starting block number.
* @param {number} toBlock - The ending block number.
* @returns {Promise<string[]>} A list of all L2 message hashes in the specified block range.
*/
public async getL2MessageHashesInBlockRange(fromBlock: number, toBlock: number): Promise<string[]> {
const events = await this.l2MessageServiceLogClient.getMessageSentEventsByBlockRange(fromBlock, toBlock);
if (events.length === 0) {
throw new BaseError(`No MessageSent events found in this block range on L2.`);
}
return events.map((event) => event.messageHash);
}
/**
* Retrieves message siblings for building the merkle tree. This merkle tree will be used to generate the proof to be able to claim the message on L1.
* @param {string} messageHash - The message hash.
* @param {string[]} messageHashes - The list of all L2 message hashes finalized in the same finalization transaction on L1.
* @param {number} treeDepth - The merkle tree depth.
* @returns {string[]} The message siblings.
*/
public getMessageSiblings(messageHash: string, messageHashes: string[], treeDepth: number): string[] {
const numberOfMessagesInTrees = 2 ** treeDepth;
const messageHashesLength = messageHashes.length;
const messageHashIndex = messageHashes.indexOf(messageHash);
if (messageHashIndex === -1) {
throw new BaseError("Message hash not found in messages.");
}
const start = Math.floor(messageHashIndex / numberOfMessagesInTrees) * numberOfMessagesInTrees;
const end = Math.min(messageHashesLength, start + numberOfMessagesInTrees);
const siblings = messageHashes.slice(start, end);
const remainder = siblings.length % numberOfMessagesInTrees;
if (remainder !== 0) {
siblings.push(...Array(numberOfMessagesInTrees - remainder).fill(ZERO_HASH));
}
return siblings;
}
}

View File

@@ -0,0 +1,88 @@
import { describe, afterEach, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { JsonRpcProvider } from "ethers";
import { EthersLineaRollupLogClient } from "../EthersLineaRollupLogClient";
import {
testL1NetworkConfig,
testL2MessagingBlockAnchoredEvent,
testL2MessagingBlockAnchoredEventLog,
testMessageClaimedEvent,
testMessageClaimedEventLog,
testMessageSentEvent,
testMessageSentEventLog,
} from "../../../../utils/testing/constants";
import { LineaRollup, LineaRollup__factory } from "../../typechain";
import { mockProperty } from "../../../../utils/testing/helpers";
describe("TestEthersLineaRollupLogClient", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let lineaRollupMock: MockProxy<LineaRollup>;
let lineaRollupLogClient: EthersLineaRollupLogClient;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
lineaRollupMock = mock<LineaRollup>();
mockProperty(lineaRollupMock, "filters", {
...lineaRollupMock.filters,
MessageSent: jest.fn(),
L2MessagingBlockAnchored: jest.fn(),
MessageClaimed: jest.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
jest.spyOn(LineaRollup__factory, "connect").mockReturnValue(lineaRollupMock);
lineaRollupLogClient = new EthersLineaRollupLogClient(
providerMock,
testL1NetworkConfig.messageServiceContractAddress,
);
});
afterEach(() => {
mockClear(providerMock);
mockClear(lineaRollupMock);
});
describe("getMessageSentEvents", () => {
it("should return a MessageSentEvent", async () => {
jest.spyOn(lineaRollupMock, "queryFilter").mockResolvedValue([testMessageSentEventLog]);
const messageSentEvents = await lineaRollupLogClient.getMessageSentEvents({
fromBlock: 51,
fromBlockLogIndex: 1,
});
expect(messageSentEvents).toStrictEqual([testMessageSentEvent]);
});
it("should return empty MessageSentEvent as event index is less than fromBlockLogIndex", async () => {
jest.spyOn(lineaRollupMock, "queryFilter").mockResolvedValue([testMessageSentEventLog]);
const messageSentEvents = await lineaRollupLogClient.getMessageSentEvents({
fromBlock: 51,
fromBlockLogIndex: 10,
});
expect(messageSentEvents).toStrictEqual([]);
});
});
describe("getL2MessagingBlockAnchoredEvents", () => {
it("should return a L2MessagingBlockAnchoredEvent", async () => {
jest.spyOn(lineaRollupMock, "queryFilter").mockResolvedValue([testL2MessagingBlockAnchoredEventLog]);
const l2MessagingBlockAnchoredEvents = await lineaRollupLogClient.getL2MessagingBlockAnchoredEvents({});
expect(l2MessagingBlockAnchoredEvents).toStrictEqual([testL2MessagingBlockAnchoredEvent]);
});
});
describe("getMessageClaimedEvents", () => {
it("should return a MessageClaimedEvent", async () => {
jest.spyOn(lineaRollupMock, "queryFilter").mockResolvedValue([testMessageClaimedEventLog]);
const messageClaimedEvents = await lineaRollupLogClient.getMessageClaimedEvents({});
expect(messageClaimedEvents).toStrictEqual([testMessageClaimedEvent]);
});
});
});

View File

@@ -0,0 +1,940 @@
import { describe, afterEach, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear, mockDeep } from "jest-mock-extended";
import { ContractTransactionResponse, JsonRpcProvider, Wallet } from "ethers";
import {
testMessageSentEvent,
TEST_MESSAGE_HASH,
TEST_CONTRACT_ADDRESS_1,
TEST_TRANSACTION_HASH,
TEST_ADDRESS_2,
testMessageClaimedEvent,
testL2MessagingBlockAnchoredEvent,
TEST_MERKLE_ROOT,
TEST_CONTRACT_ADDRESS_2,
TEST_ADDRESS_1,
} from "../../../../utils/testing/constants";
import { LineaRollup, LineaRollup__factory } from "../../typechain";
import {
generateL2MerkleTreeAddedLog,
generateL2MessagingBlockAnchoredLog,
generateLineaRollupClient,
generateMessage,
generateTransactionReceipt,
generateTransactionReceiptWithLogs,
generateTransactionResponse,
mockProperty,
} from "../../../../utils/testing/helpers";
import { LineaRollupClient } from "../LineaRollupClient";
import { DEFAULT_MAX_FEE_PER_GAS, ZERO_ADDRESS } from "../../../../core/constants";
import { OnChainMessageStatus } from "../../../../core/enums/MessageEnums";
import { GasEstimationError } from "../../../../core/errors/GasFeeErrors";
import { BaseError } from "../../../../core/errors/Base";
import { EthersL2MessageServiceLogClient } from "../../linea/EthersL2MessageServiceLogClient";
import { EthersLineaRollupLogClient } from "../EthersLineaRollupLogClient";
import { DefaultGasProvider } from "../../gas/DefaultGasProvider";
describe("TestLineaRollupClient", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let l2ProviderMock: MockProxy<JsonRpcProvider>;
let walletMock: MockProxy<Wallet>;
let lineaRollupMock: MockProxy<LineaRollup>;
let lineaRollupClient: LineaRollupClient;
let lineaRollupLogClient: EthersLineaRollupLogClient;
let l2MessageServiceLogClient: EthersL2MessageServiceLogClient;
let gasFeeProvider: DefaultGasProvider;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
l2ProviderMock = mock<JsonRpcProvider>();
walletMock = mock<Wallet>();
lineaRollupMock = mockDeep<LineaRollup>();
jest.spyOn(LineaRollup__factory, "connect").mockReturnValue(lineaRollupMock);
walletMock.getAddress.mockResolvedValue(TEST_ADDRESS_1);
lineaRollupMock.getAddress.mockResolvedValue(TEST_CONTRACT_ADDRESS_1);
const clients = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
walletMock,
);
lineaRollupClient = clients.lineaRollupClient;
lineaRollupLogClient = clients.lineaRollupLogClient;
l2MessageServiceLogClient = clients.l2MessageServiceLogClient;
gasFeeProvider = clients.gasProvider;
});
afterEach(() => {
mockClear(providerMock);
mockClear(l2ProviderMock);
mockClear(walletMock);
mockClear(lineaRollupMock);
jest.clearAllMocks();
});
describe("constructor", () => {
it("should throw an error when mode = 'read-write' and this.signer is undefined", async () => {
expect(
() =>
generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
).lineaRollupClient,
).toThrowError(new BaseError("Please provide a signer."));
});
});
describe("getMessageStatusUsingMessageHash", () => {
it("should return UNKNOWN when on chain message status === 0 and no claimed event was found", async () => {
jest.spyOn(lineaRollupMock, "inboxL2L1MessageStatus").mockResolvedValue(0n);
jest.spyOn(lineaRollupLogClient, "getMessageClaimedEvents").mockResolvedValue([]);
const messageStatus = await lineaRollupClient.getMessageStatusUsingMessageHash(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.UNKNOWN);
});
it("should return CLAIMABLE when on chain message status === 1", async () => {
jest.spyOn(lineaRollupMock, "inboxL2L1MessageStatus").mockResolvedValue(1n);
const messageStatus = await lineaRollupClient.getMessageStatusUsingMessageHash(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMABLE);
});
it("should return CLAIMED when on chain message status === 0 and the claimed event was found", async () => {
jest.spyOn(lineaRollupMock, "inboxL2L1MessageStatus").mockResolvedValue(0n);
jest.spyOn(lineaRollupLogClient, "getMessageClaimedEvents").mockResolvedValue([testMessageClaimedEvent]);
const messageStatus = await lineaRollupClient.getMessageStatusUsingMessageHash(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMED);
});
});
describe("getMessageStatus", () => {
it("should return UNKNOWN when l2MessagingBlockAnchoredEvent is absent and isMeessageClaimed return false", async () => {
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents").mockResolvedValue([]);
jest.spyOn(lineaRollupMock, "isMessageClaimed").mockResolvedValue(false);
const messageStatus = await lineaRollupClient.getMessageStatus(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.UNKNOWN);
});
it("should return CLAIMABLE when l2MessagingBlockAnchoredEvent is present and isMessageClaimed return false", async () => {
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(lineaRollupMock, "isMessageClaimed").mockResolvedValue(false);
const messageStatus = await lineaRollupClient.getMessageStatus(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMABLE);
});
it("should return CLAIMED when isMessageClaimed return true", async () => {
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents").mockResolvedValue([]);
jest.spyOn(lineaRollupMock, "isMessageClaimed").mockResolvedValue(true);
const messageStatus = await lineaRollupClient.getMessageStatus(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMED);
});
});
describe("getMessageStatusUsingMerkleTree", () => {
it("should throw error when the corresponding message sent event was not found on L2", async () => {
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash").mockResolvedValue([]);
await expect(lineaRollupClient.getMessageStatusUsingMerkleTree(TEST_MESSAGE_HASH)).rejects.toThrow(
new BaseError(`Message hash does not exist on L2. Message hash: ${TEST_MESSAGE_HASH}`),
);
});
it("should return UNKNOWN when l2MessagingBlockAnchoredEvent is absent and isMeessageClaimed return false", async () => {
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents").mockResolvedValue([]);
jest.spyOn(lineaRollupMock, "isMessageClaimed").mockResolvedValue(false);
const messageStatus = await lineaRollupClient.getMessageStatusUsingMerkleTree(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.UNKNOWN);
});
it("should return CLAIMABLE when l2MessagingBlockAnchoredEvent is present and isMessageClaimed return false", async () => {
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(lineaRollupMock, "isMessageClaimed").mockResolvedValue(false);
const messageStatus = await lineaRollupClient.getMessageStatusUsingMerkleTree(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMABLE);
});
it("should return CLAIMED when isMessageClaimed return true", async () => {
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents").mockResolvedValue([]);
jest.spyOn(lineaRollupMock, "isMessageClaimed").mockResolvedValue(true);
const messageStatus = await lineaRollupClient.getMessageStatusUsingMerkleTree(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMED);
});
});
describe("estimateClaimWithoutProofGasFees", () => {
it("should throw an error when mode = 'read-only'", async () => {
const lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-only",
).lineaRollupClient;
const message = generateMessage();
await expect(lineaRollupClient.estimateClaimWithoutProofGas(message)).rejects.toThrow(
new BaseError("'EstimateClaimGas' function not callable using readOnly mode."),
);
});
it("should throw a GasEstimationError when the gas estimation failed", async () => {
const message = generateMessage();
jest.spyOn(gasFeeProvider, "getGasFees").mockRejectedValue(new Error("Gas fees estimation failed").message);
await expect(lineaRollupClient.estimateClaimWithoutProofGas(message)).rejects.toThrow(
new GasEstimationError("Gas fees estimation failed", message),
);
});
it("should set feeRecipient === ZeroAddress when feeRecipient param is undefined", async () => {
const message = generateMessage();
const estimatedGasLimit = 50_000n;
mockProperty(lineaRollupMock, "claimMessage", {
estimateGas: jest.fn().mockResolvedValueOnce(estimatedGasLimit),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const gasFeesSpy = jest.spyOn(gasFeeProvider, "getGasFees").mockResolvedValue({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
const claimMessageSpy = jest.spyOn(lineaRollupMock.claimMessage, "estimateGas");
const estimatedGas = await lineaRollupClient.estimateClaimWithoutProofGas(message);
expect(estimatedGas).toStrictEqual(estimatedGasLimit);
expect(gasFeesSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledWith(
message.messageSender,
message.destination,
message.fee,
message.value,
ZERO_ADDRESS,
message.calldata,
message.messageNonce,
{
maxFeePerGas: 100000000000n,
maxPriorityFeePerGas: 100000000000n,
},
);
});
it("should return estimated gas limit for the claim message transaction", async () => {
const message = generateMessage();
const estimatedGasLimit = 50_000n;
mockProperty(lineaRollupMock, "claimMessage", {
estimateGas: jest.fn().mockResolvedValueOnce(estimatedGasLimit),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const claimMessageSpy = jest.spyOn(lineaRollupMock.claimMessage, "estimateGas");
const gasFeesSpy = jest.spyOn(gasFeeProvider, "getGasFees").mockResolvedValue({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
const estimateClaimGas = await lineaRollupClient.estimateClaimWithoutProofGas({
...message,
feeRecipient: TEST_ADDRESS_2,
});
expect(estimateClaimGas).toStrictEqual(estimatedGasLimit);
expect(gasFeesSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledWith(
message.messageSender,
message.destination,
message.fee,
message.value,
TEST_ADDRESS_2,
message.calldata,
message.messageNonce,
{
maxFeePerGas: 100000000000n,
maxPriorityFeePerGas: 100000000000n,
},
);
});
});
// it("should throw an error when mode = 'read-only'", async () => {
// lineaRollupClient = new LineaRollupClient(
// providerMock,
// TEST_CONTRACT_ADDRESS_1,
// lineaRollupLogClientMock,
// l2MessageServiceLogClientMock,
// "read-only",
// walletMock,
// );
// const message = generateMessage();
// await expect(
// lineaRollupClient.estimateClaimWithProofGas({
// ...message,
// leafIndex: 0,
// merkleRoot: TEST_MERKLE_ROOT,
// proof: [
// "0x0000000000000000000000000000000000000000000000000000000000000000",
// "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5",
// "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30",
// "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85",
// "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344",
// ],
// }),
// ).rejects.toThrow("'EstimateClaimWithProofGas' function not callable using readOnly mode.");
// });
// it("should throw GasEstimationError if estimateGas throws error", async () => {
// const message = generateMessage();
// mockProperty(lineaRollupMock, "claimMessageWithProof", {
// estimateGas: jest.fn().mockRejectedValueOnce(new Error("Failed to estimate gas")),
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// } as any);
// jest.spyOn(LineaRollup__factory, "connect").mockReturnValueOnce(lineaRollupMock);
// lineaRollupClient = new LineaRollupClient(
// providerMock,
// TEST_CONTRACT_ADDRESS_1,
// lineaRollupLogClientMock,
// l2MessageServiceLogClientMock,
// "read-write",
// walletMock,
// 1000000000n,
// undefined,
// true,
// );
// await expect(
// lineaRollupClient.estimateClaimWithProofGas({
// ...message,
// leafIndex: 0,
// merkleRoot: TEST_MERKLE_ROOT,
// proof: [
// "0x0000000000000000000000000000000000000000000000000000000000000000",
// "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5",
// "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30",
// "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85",
// "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344",
// ],
// }),
// ).rejects.toThrow("Failed to estimate gas");
// });
// });
describe("estimateClaimGas", () => {
it("should throw an error when mode = 'read-only'", async () => {
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-only",
walletMock,
).lineaRollupClient;
const message = generateMessage();
await expect(lineaRollupClient.estimateClaimGas(message)).rejects.toThrow(
"'EstimateClaimGasFees' function not callable using readOnly mode.",
);
});
it("should throw a GasEstimationError when the message hash does not exist on L2", async () => {
const message = generateMessage();
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash").mockResolvedValue([]);
await expect(lineaRollupClient.estimateClaimGas(message)).rejects.toThrow(
`Message hash does not exist on L2. Message hash: ${TEST_MESSAGE_HASH}`,
);
});
it("should throw a GasEstimationError when the L2 block number has not been finalized on L1", async () => {
const message = generateMessage();
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents").mockResolvedValue([]);
await expect(lineaRollupClient.estimateClaimGas(message)).rejects.toThrow(
"L2 block number 51 has not been finalized on L1",
);
});
it("should throw a GasEstimationError when finalization transaction does not exist", async () => {
const message = generateMessage();
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(null);
await expect(lineaRollupClient.estimateClaimGas(message)).rejects.toThrow(
`Transaction does not exist or no logs found in this transaction: ${TEST_TRANSACTION_HASH}.`,
);
});
it("should throw a GasEstimationError when no related event logs were found", async () => {
const message = generateMessage();
const transactionReceipt = generateTransactionReceipt();
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
await expect(lineaRollupClient.estimateClaimGas(message)).rejects.toThrow(
"No L2MerkleRootAdded events found in this transaction.",
);
});
it("should throw a GasEstimationError when no L2MessagingBlocksAnchored event logs were found", async () => {
const message = generateMessage();
const transactionReceipt = generateTransactionReceiptWithLogs(undefined, [
generateL2MerkleTreeAddedLog(TEST_TRANSACTION_HASH, 5),
]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
await expect(lineaRollupClient.estimateClaimGas(message)).rejects.toThrow(
"No L2MessagingBlocksAnchored events found in this transaction.",
);
});
it("should throw a GasEstimationError when no MessageSent events found in the given block range on L2", async () => {
const message = generateMessage();
const transactionReceipt = generateTransactionReceiptWithLogs(undefined, [
generateL2MerkleTreeAddedLog(TEST_TRANSACTION_HASH, 5),
generateL2MessagingBlockAnchoredLog(10n),
]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByBlockRange").mockResolvedValue([]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
await expect(lineaRollupClient.estimateClaimGas(message)).rejects.toThrowError();
});
it("should return estimated gas limit if all the relevant event logs were found", async () => {
const message = generateMessage();
const transactionReceipt = generateTransactionReceiptWithLogs(undefined, [
generateL2MerkleTreeAddedLog(TEST_MERKLE_ROOT, 5),
generateL2MessagingBlockAnchoredLog(10n),
]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByBlockRange")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
// const transactionData = LineaRollup__factory.createInterface().encodeFunctionData("claimMessageWithProof", [
// {
// from: message.messageSender,
// to: message.destination,
// fee: message.fee,
// value: message.value,
// feeRecipient: ZERO_ADDRESS,
// data: message.calldata,
// messageNumber: message.messageNonce,
// leafIndex: 0,
// merkleRoot: TEST_MERKLE_ROOT,
// proof: [
// "0x0000000000000000000000000000000000000000000000000000000000000000",
// "0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5",
// "0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30",
// "0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85",
// "0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344",
// ],
// },
// ]);
const estimatedGasLimit = 50_000n;
mockProperty(lineaRollupMock, "claimMessageWithProof", {
estimateGas: jest.fn().mockResolvedValueOnce(estimatedGasLimit),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
mockProperty(lineaRollupMock, "interface", {
parseLog: jest
.fn()
.mockReturnValueOnce({
args: { treeDepth: 5, l2MerkleRoot: TEST_MERKLE_ROOT },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
.mockReturnValueOnce({
args: { l2Block: 10n },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const gasFeesSpy = jest.spyOn(gasFeeProvider, "getGasFees").mockResolvedValue({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
const claimMessageWithProofSpy = jest.spyOn(lineaRollupMock.claimMessageWithProof, "estimateGas");
const estimatedClaimGas = await lineaRollupClient.estimateClaimGas(message);
expect(estimatedClaimGas).toStrictEqual(estimatedGasLimit);
expect(gasFeesSpy).toHaveBeenCalledTimes(1);
expect(claimMessageWithProofSpy).toHaveBeenCalledTimes(1);
expect(claimMessageWithProofSpy).toHaveBeenCalledWith(
{
from: message.messageSender,
to: message.destination,
fee: message.fee,
value: message.value,
feeRecipient: ZERO_ADDRESS,
data: message.calldata,
messageNumber: message.messageNonce,
leafIndex: 0,
merkleRoot: TEST_MERKLE_ROOT,
proof: [
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5",
"0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30",
"0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85",
"0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344",
],
},
{
maxFeePerGas: 100000000000n,
maxPriorityFeePerGas: 100000000000n,
},
);
});
});
describe("claimWithoutProof", () => {
it("should throw an error when mode = 'read-only'", async () => {
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-only",
walletMock,
).lineaRollupClient;
const message = generateMessage();
await expect(lineaRollupClient.claimWithoutProof(message)).rejects.toThrow(
new Error("'claim' function not callable using readOnly mode."),
);
});
it("should set feeRecipient === ZeroAddress when feeRecipient param is undefined", async () => {
const message = generateMessage();
const txResponse = generateTransactionResponse();
jest.spyOn(lineaRollupMock, "claimMessage").mockResolvedValue(txResponse as ContractTransactionResponse);
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
walletMock,
{
maxFeePerGas: 500000000n,
enforceMaxGasFee: true,
},
).lineaRollupClient;
const claimMessageSpy = jest.spyOn(lineaRollupMock, "claimMessage");
await lineaRollupClient.claimWithoutProof(message);
expect(txResponse).toStrictEqual(txResponse);
expect(claimMessageSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledWith(
message.messageSender,
message.destination,
message.fee,
message.value,
ZERO_ADDRESS,
message.calldata,
message.messageNonce,
{
maxPriorityFeePerGas: 500000000n,
maxFeePerGas: 500000000n,
},
);
});
it("should return executed claim message transaction", async () => {
const message = generateMessage();
const txResponse = generateTransactionResponse();
jest.spyOn(lineaRollupMock, "claimMessage").mockResolvedValue(txResponse as ContractTransactionResponse);
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
walletMock,
{
maxFeePerGas: 500000000n,
enforceMaxGasFee: true,
},
).lineaRollupClient;
const claimMessageSpy = jest.spyOn(lineaRollupMock, "claimMessage");
const txResponseReturned = await lineaRollupClient.claimWithoutProof({
...message,
feeRecipient: TEST_ADDRESS_2,
});
expect(txResponseReturned).toStrictEqual(txResponse);
expect(claimMessageSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledWith(
message.messageSender,
message.destination,
message.fee,
message.value,
TEST_ADDRESS_2,
message.calldata,
message.messageNonce,
{
maxPriorityFeePerGas: 500000000n,
maxFeePerGas: 500000000n,
},
);
});
});
describe("claim", () => {
it("should throw an error when mode = 'read-only'", async () => {
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-only",
walletMock,
).lineaRollupClient;
const message = generateMessage();
await expect(lineaRollupClient.claim(message)).rejects.toThrow(
new Error("'claim' function not callable using readOnly mode."),
);
});
it("should return executed claim message transaction", async () => {
const message = generateMessage();
const txResponse = generateTransactionResponse();
const transactionReceipt = generateTransactionReceiptWithLogs(undefined, [
generateL2MerkleTreeAddedLog(TEST_MERKLE_ROOT, 5),
generateL2MessagingBlockAnchoredLog(10n),
]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByBlockRange")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
mockProperty(lineaRollupMock, "interface", {
parseLog: jest
.fn()
.mockReturnValueOnce({
args: { treeDepth: 5, l2MerkleRoot: TEST_MERKLE_ROOT },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
.mockReturnValueOnce({
args: { l2Block: 10n },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
jest.spyOn(gasFeeProvider, "getGasFees").mockResolvedValue({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
const claimMessageWithProofSpy = jest
.spyOn(lineaRollupMock, "claimMessageWithProof")
.mockResolvedValue(txResponse as ContractTransactionResponse);
const txResponseReturned = await lineaRollupClient.claim(message);
expect(txResponseReturned).toStrictEqual(txResponse);
expect(claimMessageWithProofSpy).toHaveBeenCalledTimes(1);
expect(claimMessageWithProofSpy).toHaveBeenCalledWith(
{
from: message.messageSender,
to: message.destination,
fee: message.fee,
value: message.value,
feeRecipient: ZERO_ADDRESS,
data: message.calldata,
messageNumber: message.messageNonce,
leafIndex: 0,
merkleRoot: TEST_MERKLE_ROOT,
proof: [
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5",
"0xb4c11951957c6f8f642c4af61cd6b24640fec6dc7fc607ee8206a99e92410d30",
"0x21ddb9a356815c3fac1026b6dec5df3124afbadb485c9ba5a3e3398a04b7ba85",
"0xe58769b32a1beaf1ea27375a44095a0d1fb664ce2dd358e7fcbfb78c26a19344",
],
},
{
maxFeePerGas: 100000000000n,
maxPriorityFeePerGas: 100000000000n,
},
);
});
});
describe("retryTransactionWithHigherFee", () => {
it("should throw an error when mode = 'read-only'", async () => {
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-only",
walletMock,
).lineaRollupClient;
await expect(lineaRollupClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH)).rejects.toThrow(
new BaseError("'retryTransactionWithHigherFee' function not callable using readOnly mode."),
);
});
it("should throw an error when priceBumpPercent is not an integer", async () => {
await expect(lineaRollupClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH, 1.1)).rejects.toThrow(
new BaseError("'priceBumpPercent' must be an integer"),
);
});
it("should throw an error when getTransaction return null", async () => {
jest.spyOn(providerMock, "getTransaction").mockResolvedValue(null);
await expect(lineaRollupClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH)).rejects.toThrow(
new BaseError(`Transaction with hash ${TEST_TRANSACTION_HASH} not found.`),
);
});
it("should retry the transaction with higher fees", async () => {
const transactionResponse = generateTransactionResponse();
const getTransactionSpy = jest.spyOn(providerMock, "getTransaction").mockResolvedValue(transactionResponse);
const signTransactionSpy = jest.spyOn(walletMock, "signTransaction").mockResolvedValue("");
const sendTransactionSpy = jest.spyOn(providerMock, "broadcastTransaction");
await lineaRollupClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH);
expect(getTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledWith({
to: transactionResponse.to,
value: transactionResponse.value,
data: transactionResponse.data,
nonce: transactionResponse.nonce,
gasLimit: transactionResponse.gasLimit,
chainId: transactionResponse.chainId,
type: 2,
maxPriorityFeePerGas: 55000000n,
maxFeePerGas: 110000000n,
});
expect(sendTransactionSpy).toHaveBeenCalledTimes(1);
});
it("should retry the transaction with higher fees and capped by the predefined maxFeePerGas", async () => {
const transactionResponse = generateTransactionResponse();
const getTransactionSpy = jest.spyOn(providerMock, "getTransaction").mockResolvedValue(transactionResponse);
const signTransactionSpy = jest.spyOn(walletMock, "signTransaction").mockResolvedValue("");
const sendTransactionSpy = jest.spyOn(providerMock, "broadcastTransaction");
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
walletMock,
{
maxFeePerGas: 500000000n,
},
).lineaRollupClient;
await lineaRollupClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH, 1000);
expect(getTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledWith({
to: transactionResponse.to,
value: transactionResponse.value,
data: transactionResponse.data,
nonce: transactionResponse.nonce,
gasLimit: transactionResponse.gasLimit,
chainId: transactionResponse.chainId,
type: 2,
maxPriorityFeePerGas: 500000000n,
maxFeePerGas: 500000000n,
});
expect(sendTransactionSpy).toHaveBeenCalledTimes(1);
});
it("should retry the transaction with the predefined maxFeePerGas if enforceMaxGasFee is true", async () => {
const transactionResponse = generateTransactionResponse({
maxPriorityFeePerGas: undefined,
maxFeePerGas: undefined,
});
const getTransactionSpy = jest.spyOn(providerMock, "getTransaction").mockResolvedValue(transactionResponse);
const signTransactionSpy = jest.spyOn(walletMock, "signTransaction").mockResolvedValue("");
const sendTransactionSpy = jest.spyOn(providerMock, "broadcastTransaction");
lineaRollupClient = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
walletMock,
{
maxFeePerGas: 500000000n,
enforceMaxGasFee: true,
},
).lineaRollupClient;
await lineaRollupClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH, 1000);
expect(getTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledWith({
to: transactionResponse.to,
value: transactionResponse.value,
data: transactionResponse.data,
nonce: transactionResponse.nonce,
gasLimit: transactionResponse.gasLimit,
chainId: transactionResponse.chainId,
type: 2,
maxPriorityFeePerGas: 500000000n,
maxFeePerGas: 500000000n,
});
expect(sendTransactionSpy).toHaveBeenCalledTimes(1);
});
});
describe("isRateLimitExceeded", () => {
it("should return true if exceeded rate limit", async () => {
jest.spyOn(lineaRollupMock, "limitInWei").mockResolvedValue(2000000000n);
jest.spyOn(lineaRollupMock, "currentPeriodAmountInWei").mockResolvedValue(1000000000n);
const isRateLimitExceeded = await lineaRollupClient.isRateLimitExceeded(1000000000n, 1000000000n);
expect(isRateLimitExceeded).toBeTruthy();
});
it("should return false if not exceeded rate limit", async () => {
jest.spyOn(lineaRollupMock, "limitInWei").mockResolvedValue(2000000000n);
jest.spyOn(lineaRollupMock, "currentPeriodAmountInWei").mockResolvedValue(1000000000n);
const isRateLimitExceeded = await lineaRollupClient.isRateLimitExceeded(100000000n, 100000000n);
expect(isRateLimitExceeded).toBeFalsy();
});
});
describe("isRateLimitExceededError", () => {
it("should return false when something went wrong (http error etc)", async () => {
jest.spyOn(providerMock, "getTransaction").mockRejectedValueOnce({});
expect(
await lineaRollupClient.isRateLimitExceededError(
"0x825a7f1aa4453735597ddf7e9062413c906a7ad49bf17ff32c2cf42f41d438d9",
),
).toStrictEqual(false);
});
it("should return false when transaction revert reason is not RateLimitExceeded", async () => {
jest.spyOn(providerMock, "getTransaction").mockResolvedValueOnce(generateTransactionResponse());
jest.spyOn(providerMock, "call").mockResolvedValueOnce("0xa74c1c6d");
expect(
await lineaRollupClient.isRateLimitExceededError(
"0x825a7f1aa4453735597ddf7e9062413c906a7ad49bf17ff32c2cf42f41d438d9",
),
).toStrictEqual(false);
});
it("should return true when transaction revert reason is RateLimitExceeded", async () => {
mockProperty(lineaRollupMock, "interface", {
...lineaRollupMock.interface,
parseError: jest.fn().mockReturnValueOnce({ name: "RateLimitExceeded" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
jest.spyOn(providerMock, "getTransaction").mockResolvedValueOnce(generateTransactionResponse());
jest.spyOn(providerMock, "call").mockResolvedValueOnce("0xa74c1c5f");
expect(
await lineaRollupClient.isRateLimitExceededError(
"0x825a7f1aa4453735597ddf7e9062413c906a7ad49bf17ff32c2cf42f41d438d9",
),
).toStrictEqual(true);
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, beforeEach } from "@jest/globals";
import { JsonRpcProvider, Wallet } from "ethers";
import { MockProxy, mock } from "jest-mock-extended";
import {
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
TEST_MESSAGE_HASH,
TEST_TRANSACTION_HASH,
testMessageSentEvent,
} from "../../../../utils/testing/constants";
import { generateLineaRollupClient, generateTransactionReceipt } from "../../../../utils/testing/helpers";
import { EthersLineaRollupLogClient } from "../EthersLineaRollupLogClient";
import { LineaRollupMessageRetriever } from "../LineaRollupMessageRetriever";
describe("LineaRollupMessageRetriever", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let l2ProviderMock: MockProxy<JsonRpcProvider>;
let walletMock: MockProxy<Wallet>;
let messageRetriever: LineaRollupMessageRetriever;
let lineaRollupLogClient: EthersLineaRollupLogClient;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
l2ProviderMock = mock<JsonRpcProvider>();
walletMock = mock<Wallet>();
const clients = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
walletMock,
);
messageRetriever = clients.messageRetriever;
lineaRollupLogClient = clients.lineaRollupLogClient;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getMessageByMessageHash", () => {
it("should return a MessageSent", async () => {
jest.spyOn(lineaRollupLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
const messageSentEvent = await messageRetriever.getMessageByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentEvent).toStrictEqual(testMessageSentEvent);
});
it("should return null if empty events returned", async () => {
jest.spyOn(lineaRollupLogClient, "getMessageSentEvents").mockResolvedValue([]);
const messageSentEvent = await messageRetriever.getMessageByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentEvent).toStrictEqual(null);
});
});
describe("getMessagesByTransactionHash", () => {
it("should return null when message hash does not exist", async () => {
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(null);
const messageSentEvents = await messageRetriever.getMessagesByTransactionHash(TEST_TRANSACTION_HASH);
expect(messageSentEvents).toStrictEqual(null);
});
it("should return an array of messages when transaction hash exists and contains MessageSent events", async () => {
const transactionReceipt = generateTransactionReceipt();
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
jest.spyOn(lineaRollupLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
const messageSentEvents = await messageRetriever.getMessagesByTransactionHash(TEST_MESSAGE_HASH);
expect(messageSentEvents).toStrictEqual([testMessageSentEvent]);
});
});
describe("getTransactionReceiptByMessageHash", () => {
it("should return null when message hash does not exist", async () => {
jest.spyOn(lineaRollupLogClient, "getMessageSentEvents").mockResolvedValue([]);
const messageSentTxReceipt = await messageRetriever.getTransactionReceiptByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentTxReceipt).toStrictEqual(null);
});
it("should return null when transaction receipt does not exist", async () => {
jest.spyOn(lineaRollupLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(null);
const messageSentTxReceipt = await messageRetriever.getTransactionReceiptByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentTxReceipt).toStrictEqual(null);
});
it("should return an array of messages when transaction hash exists and contains MessageSent events", async () => {
const transactionReceipt = generateTransactionReceipt();
jest.spyOn(lineaRollupLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
const messageSentTxReceipt = await messageRetriever.getTransactionReceiptByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentTxReceipt).toStrictEqual(transactionReceipt);
});
});
});

View File

@@ -0,0 +1,90 @@
import { describe, beforeEach } from "@jest/globals";
import { JsonRpcProvider, Wallet } from "ethers";
import { MockProxy, mock } from "jest-mock-extended";
import {
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
TEST_MERKLE_ROOT_2,
TEST_MESSAGE_HASH,
TEST_MESSAGE_HASH_2,
testL2MessagingBlockAnchoredEvent,
testMessageSentEvent,
} from "../../../../utils/testing/constants";
import {
generateL2MerkleTreeAddedLog,
generateL2MessagingBlockAnchoredLog,
generateLineaRollupClient,
generateTransactionReceiptWithLogs,
} from "../../../../utils/testing/helpers";
import { LineaRollup, LineaRollup__factory } from "../../typechain";
import { EthersL2MessageServiceLogClient } from "../../linea/EthersL2MessageServiceLogClient";
import { EthersLineaRollupLogClient } from "../EthersLineaRollupLogClient";
import { MerkleTreeService } from "../MerkleTreeService";
describe("MerkleTreeService", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let l2ProviderMock: MockProxy<JsonRpcProvider>;
let walletMock: MockProxy<Wallet>;
let lineaRollupMock: MockProxy<LineaRollup>;
let merkleTreeService: MerkleTreeService;
let lineaRollupLogClient: EthersLineaRollupLogClient;
let l2MessageServiceLogClient: EthersL2MessageServiceLogClient;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
l2ProviderMock = mock<JsonRpcProvider>();
walletMock = mock<Wallet>();
lineaRollupMock = mock<LineaRollup>();
const clients = generateLineaRollupClient(
providerMock,
l2ProviderMock,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
walletMock,
);
merkleTreeService = clients.merkleTreeService;
l2MessageServiceLogClient = clients.l2MessageServiceLogClient;
lineaRollupLogClient = clients.lineaRollupLogClient;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getMessageSiblings", () => {
it("should throw a BaseError when message hash not found in messages", () => {
const messageHash = TEST_MESSAGE_HASH;
const messageHashes = [TEST_MESSAGE_HASH_2];
expect(() => merkleTreeService.getMessageSiblings(messageHash, messageHashes, 5)).toThrow(
"Message hash not found in messages.",
);
});
});
describe("getMessageProof", () => {
it("should throw a BaseError if merkle tree build failed", async () => {
const messageHash = TEST_MESSAGE_HASH;
const transactionReceipt = generateTransactionReceiptWithLogs(undefined, [
generateL2MerkleTreeAddedLog(TEST_MERKLE_ROOT_2, 5),
generateL2MessagingBlockAnchoredLog(10n),
]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByMessageHash")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(l2MessageServiceLogClient, "getMessageSentEventsByBlockRange")
.mockResolvedValue([testMessageSentEvent]);
jest
.spyOn(lineaRollupLogClient, "getL2MessagingBlockAnchoredEvents")
.mockResolvedValue([testL2MessagingBlockAnchoredEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
jest.spyOn(LineaRollup__factory, "connect").mockReturnValueOnce(lineaRollupMock);
await expect(merkleTreeService.getMessageProof(messageHash)).rejects.toThrow("Merkle tree build failed.");
});
});
});

View File

@@ -0,0 +1,141 @@
import { Block, JsonRpcProvider, TransactionReceipt, TransactionRequest, TransactionResponse } from "ethers";
import { FeeEstimationError } from "../../../core/errors/GasFeeErrors";
import {
DefaultGasProviderConfig,
FeeHistory,
GasFees,
IEthereumGasProvider,
} from "../../../core/clients/blockchain/IGasProvider";
import { IChainQuerier } from "../../../core/clients/blockchain/IChainQuerier";
export class DefaultGasProvider implements IEthereumGasProvider<TransactionRequest> {
private gasFeesCache: GasFees;
private cacheIsValidForBlockNumber: bigint;
/**
* Creates an instance of DefaultGasProvider.
*
* @param {IChainQuerier} chainQuerier - The chain querier for interacting with the blockchain.
* @param {DefaultGasProviderConfig} config - The configuration for the gas provider.
*/
constructor(
protected readonly chainQuerier: IChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly config: DefaultGasProviderConfig,
) {
this.cacheIsValidForBlockNumber = 0n;
this.gasFeesCache = { maxFeePerGas: this.config.maxFeePerGas, maxPriorityFeePerGas: this.config.maxFeePerGas };
}
/**
* Fetches EIP-1559 gas fee estimates.
*
* This method uses the `eth_feeHistory` RPC endpoint to fetch historical gas fee data and calculates the
* `maxPriorityFeePerGas` and `maxFeePerGas` based on the specified percentile. If `isMaxGasFeeEnforced` is true,
* it returns the `maxFeePerGas` as configured in the constructor. Otherwise, it calculates the fees based on
* the network conditions.
*
* The method caches the fee estimates and only fetches new data if the current block number has changed since
* the last fetch. This reduces the number of RPC calls made to fetch fee data.
*
* @param {number} [percentile=this.gasEstimationPercentile] - The percentile value to sample from each block's effective priority fees.
* @returns {Promise<Fees>} A promise that resolves to an object containing the `maxPriorityFeePerGas` and the `maxFeePerGas`.
*/
public async getGasFees(): Promise<GasFees> {
if (this.config.enforceMaxGasFee) {
return {
maxPriorityFeePerGas: this.config.maxFeePerGas,
maxFeePerGas: this.config.maxFeePerGas,
};
}
const currentBlockNumber = await this.chainQuerier.getCurrentBlockNumber();
if (this.isCacheValid(currentBlockNumber)) {
return this.gasFeesCache;
}
const feeHistory = await this.fetchFeeHistory();
const maxPriorityFeePerGas = this.calculateMaxPriorityFee(feeHistory.reward);
if (maxPriorityFeePerGas > this.config.maxFeePerGas) {
throw new FeeEstimationError(
`Estimated miner tip of ${maxPriorityFeePerGas} exceeds configured max fee per gas of ${this.config.maxFeePerGas}!`,
);
}
this.updateCache(currentBlockNumber, feeHistory.baseFeePerGas, maxPriorityFeePerGas);
return this.gasFeesCache;
}
/**
* Fetches the fee history from the blockchain.
*
* @private
* @returns {Promise<FeeHistory>} A promise that resolves to the fee history.
*/
private async fetchFeeHistory(): Promise<FeeHistory> {
return this.chainQuerier.sendRequest("eth_feeHistory", ["0x4", "latest", [this.config.gasEstimationPercentile]]);
}
/**
* Calculates the maximum priority fee based on the reward data.
*
* @private
* @param {string[][]} reward - The reward data from the fee history.
* @returns {bigint} The calculated maximum priority fee.
*/
private calculateMaxPriorityFee(reward: string[][]): bigint {
return (
reward.reduce((acc: bigint, currentValue: string[]) => acc + BigInt(currentValue[0]), 0n) / BigInt(reward.length)
);
}
/**
* Checks if the cached gas fees are still valid based on the current block number.
*
* @private
* @param {number} currentBlockNumber - The current block number.
* @returns {boolean} True if the cache is valid, false otherwise.
*/
private isCacheValid(currentBlockNumber: number): boolean {
return this.cacheIsValidForBlockNumber >= BigInt(currentBlockNumber);
}
/**
* Updates the gas fees cache with new data.
*
* @private
* @param {number} currentBlockNumber - The current block number.
* @param {string[]} baseFeePerGas - The base fee per gas from the fee history.
* @param {bigint} maxPriorityFeePerGas - The calculated maximum priority fee.
*/
private updateCache(currentBlockNumber: number, baseFeePerGas: string[], maxPriorityFeePerGas: bigint) {
this.cacheIsValidForBlockNumber = BigInt(currentBlockNumber);
const maxFeePerGas = BigInt(baseFeePerGas[baseFeePerGas.length - 1]) * 2n + maxPriorityFeePerGas;
if (maxFeePerGas > 0n && maxPriorityFeePerGas > 0n) {
this.gasFeesCache = {
maxPriorityFeePerGas,
maxFeePerGas: maxFeePerGas > this.config.maxFeePerGas ? this.config.maxFeePerGas : maxFeePerGas,
};
} else {
this.gasFeesCache = {
maxPriorityFeePerGas: this.config.maxFeePerGas,
maxFeePerGas: this.config.maxFeePerGas,
};
}
}
/**
* Gets the maximum fee per gas as configured.
*
* @returns {bigint} The maximum fee per gas.
*/
public getMaxFeePerGas(): bigint {
return this.config.maxFeePerGas;
}
}

View File

@@ -0,0 +1,85 @@
import { Block, JsonRpcProvider, TransactionReceipt, TransactionRequest, TransactionResponse } from "ethers";
import { DefaultGasProvider } from "./DefaultGasProvider";
import { LineaGasProvider } from "./LineaGasProvider";
import { IChainQuerier } from "../../../core/clients/blockchain/IChainQuerier";
import { GasFees, GasProviderConfig, IGasProvider, LineaGasFees } from "../../../core/clients/blockchain/IGasProvider";
import { Direction } from "../../../core/enums/MessageEnums";
import { BaseError } from "../../../core/errors/Base";
export class GasProvider implements IGasProvider<TransactionRequest> {
private defaultGasProvider: DefaultGasProvider;
private lineaGasProvider: LineaGasProvider;
/**
* Creates an instance of `GasProvider`.
*
* @param {IChainQuerier} chainQuerier - The chain querier for interacting with the blockchain.
* @param {GasProviderConfig} config - The configuration for the gas provider.
*/
constructor(
private chainQuerier: IChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly config: GasProviderConfig,
) {
this.defaultGasProvider = new DefaultGasProvider(this.chainQuerier, {
maxFeePerGas: config.maxFeePerGas,
gasEstimationPercentile: config.gasEstimationPercentile,
enforceMaxGasFee: config.enforceMaxGasFee,
});
this.lineaGasProvider = new LineaGasProvider(this.chainQuerier, {
maxFeePerGas: config.maxFeePerGas,
enforceMaxGasFee: config.enforceMaxGasFee,
});
}
/**
* Fetches gas fee estimates.
* Determines which gas provider to use based on the presence of transactionRequest.
*
* @param {TransactionRequest} [transactionRequest] - Optional transaction request to determine specific gas provider.
* @returns {Promise<GasFees | LineaGasFees>} A promise that resolves to an object containing gas fee estimates.
* @throws {BaseError} If transactionRequest is not provided when required.
*/
public async getGasFees(transactionRequest?: TransactionRequest): Promise<GasFees | LineaGasFees> {
if (this.config.direction === Direction.L1_TO_L2) {
if (this.config.enableLineaEstimateGas) {
if (!transactionRequest) {
throw new BaseError(
"You need to provide transaction request as param to call the getGasFees function on Linea.",
);
}
return this.lineaGasProvider.getGasFees(transactionRequest);
} else {
const fees = await this.defaultGasProvider.getGasFees();
const gasLimit = await this.chainQuerier.estimateGas({
...transactionRequest,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
maxFeePerGas: fees.maxFeePerGas,
});
return {
...fees,
gasLimit,
};
}
} else {
return this.defaultGasProvider.getGasFees();
}
}
/**
* Gets the maximum fee per gas as configured.
*
* @returns {bigint} The maximum fee per gas.
*/
public getMaxFeePerGas(): bigint {
if (this.config.direction === Direction.L1_TO_L2) {
return this.lineaGasProvider.getMaxFeePerGas();
}
return this.defaultGasProvider.getMaxFeePerGas();
}
}

View File

@@ -0,0 +1,125 @@
import { Block, JsonRpcProvider, TransactionReceipt, TransactionRequest, TransactionResponse } from "ethers";
import {
LineaEstimateGasResponse,
LineaGasFees,
ILineaGasProvider,
LineaGasProviderConfig,
} from "../../../core/clients/blockchain/IGasProvider";
import { IChainQuerier } from "../../../core/clients/blockchain/IChainQuerier";
const BASE_FEE_MULTIPLIER = 1.35;
const PRIORITY_FEE_MULTIPLIER = 1.05;
export class LineaGasProvider implements ILineaGasProvider<TransactionRequest> {
/**
* Creates an instance of `LineaGasProvider`.
*
* @param {IChainQuerier} chainQuerier - The chain querier for interacting with the blockchain.
* @param {LineaGasProviderConfig} config - The configuration for the Linea gas provider.
*/
constructor(
protected readonly chainQuerier: IChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly config: LineaGasProviderConfig,
) {}
/**
* Fetches gas fee estimates for a given transaction request.
*
* @param {TransactionRequest} transactionRequest - The transaction request to determine specific gas fees.
* @returns {Promise<LineaGasFees>} A promise that resolves to an object containing Linea gas fee estimates.
*/
public async getGasFees(transactionRequest: TransactionRequest): Promise<LineaGasFees> {
const gasFees = await this.getLineaGasFees(transactionRequest);
if (this.config.enforceMaxGasFee) {
return {
...gasFees,
maxPriorityFeePerGas: this.config.maxFeePerGas,
maxFeePerGas: this.config.maxFeePerGas,
};
}
return gasFees;
}
/**
* Fetches Linea gas fee estimates for a given transaction request.
*
* @private
* @param {TransactionRequest} transactionRequest - The transaction request to determine specific gas fees.
* @returns {Promise<LineaGasFees>} A promise that resolves to an object containing Linea gas fee estimates.
*/
private async getLineaGasFees(transactionRequest: TransactionRequest): Promise<LineaGasFees> {
const lineaGasFees = await this.fetchLineaResponse(transactionRequest);
const baseFee = this.getValueFromMultiplier(BigInt(lineaGasFees.baseFeePerGas), BASE_FEE_MULTIPLIER);
const maxPriorityFeePerGas = this.getValueFromMultiplier(
BigInt(lineaGasFees.priorityFeePerGas),
PRIORITY_FEE_MULTIPLIER,
);
const maxFeePerGas = this.computeMaxFeePerGas(baseFee, maxPriorityFeePerGas);
const gasLimit = BigInt(lineaGasFees.gasLimit);
return {
maxPriorityFeePerGas,
maxFeePerGas,
gasLimit,
};
}
/**
* Fetches the Linea gas fee response from the blockchain.
*
* @private
* @param {TransactionRequest} transactionRequest - The transaction request to determine specific gas fees.
* @returns {Promise<LineaEstimateGasResponse>} A promise that resolves to the Linea gas fee response.
*/
private async fetchLineaResponse(transactionRequest: TransactionRequest): Promise<LineaEstimateGasResponse> {
const params = {
from: transactionRequest.from,
to: transactionRequest.to,
value: transactionRequest.value?.toString(),
data: transactionRequest.data,
};
return this.chainQuerier.sendRequest("linea_estimateGas", [params]);
}
/**
* Calculates a value based on a multiplier.
*
* @private
* @param {bigint} value - The original value.
* @param {number} multiplier - The multiplier to apply.
* @returns {bigint} The calculated value.
*/
private getValueFromMultiplier(value: bigint, multiplier: number): bigint {
return (value * BigInt(multiplier * 100)) / 100n;
}
/**
* Computes the maximum fee per gas.
*
* @private
* @param {bigint} baseFee - The base fee per gas.
* @param {bigint} priorityFee - The priority fee per gas.
* @returns {bigint} The computed maximum fee per gas.
*/
private computeMaxFeePerGas(baseFee: bigint, priorityFee: bigint): bigint {
return baseFee + priorityFee;
}
/**
* Gets the maximum fee per gas as configured.
*
* @returns {bigint} The maximum fee per gas.
*/
public getMaxFeePerGas(): bigint {
return this.config.maxFeePerGas;
}
}

View File

@@ -0,0 +1,104 @@
import { describe, afterEach, jest, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { DefaultGasProvider } from "../DefaultGasProvider";
import { FeeEstimationError } from "../../../../core/errors/GasFeeErrors";
import { testL1NetworkConfig } from "../../../../utils/testing/constants";
import { ChainQuerier } from "../../ChainQuerier";
describe("DefaultGasProvider", () => {
let chainQuerierMock: MockProxy<ChainQuerier>;
let eip1559GasProvider: DefaultGasProvider;
beforeEach(() => {
chainQuerierMock = mock<ChainQuerier>();
eip1559GasProvider = new DefaultGasProvider(chainQuerierMock, {
maxFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas,
gasEstimationPercentile: testL1NetworkConfig.claiming.gasEstimationPercentile,
enforceMaxGasFee: false,
});
});
afterEach(() => {
mockClear(chainQuerierMock);
});
describe("getGasFees", () => {
it("should return fee from cache if currentBlockNumber == cacheIsValidForBlockNumber", async () => {
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(0);
const fees = await eip1559GasProvider.getGasFees();
expect(fees).toStrictEqual({
maxFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas,
maxPriorityFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas,
});
});
it("should throw an error 'FeeEstimationError' if maxPriorityFee is greater than maxFeePerGas", async () => {
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(1);
const sendSpy = jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce({
baseFeePerGas: ["0x3da8e7618", "0x3e1ba3b1b", "0x3dfd72b90", "0x3d64eee76", "0x3d4da2da0", "0x3ccbcac6b"],
gasUsedRatio: [0.5290747666666666, 0.49240453333333334, 0.4615576, 0.49407083333333335, 0.4669053],
oldestBlock: "0xfab8ac",
reward: [
["0x59682f00", "0x59682f00"],
["0x59682f00", "0x59682f00"],
["0x3b9aca00", "0x59682f00"],
["0x510b0870", "0x59682f00"],
["0x3b9aca00", "0x59682f00"],
],
});
await expect(eip1559GasProvider.getGasFees()).rejects.toThrow(FeeEstimationError);
expect(sendSpy).toHaveBeenCalledTimes(1);
});
it("should return maxFeePerGas and maxPriorityFeePerGas", async () => {
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(1);
const sendSpy = jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce({
baseFeePerGas: ["0x3da8e7618", "0x3e1ba3b1b", "0x3dfd72b90", "0x3d64eee76", "0x3d4da2da0", "0x3ccbcac6b"],
gasUsedRatio: [0.5290747666666666, 0.49240453333333334, 0.4615576, 0.49407083333333335, 0.4669053],
oldestBlock: "0xfab8ac",
reward: [
["0xe4e1c0", "0xe4e1c0"],
["0xe4e1c0", "0xe4e1c0"],
["0xe4e1c0", "0xe4e1c0"],
["0xcf7867", "0xe4e1c0"],
["0x5f5e100", "0xe4e1c0"],
],
});
const fees = await eip1559GasProvider.getGasFees();
expect(fees).toStrictEqual({
maxFeePerGas: BigInt(testL1NetworkConfig.claiming.maxFeePerGas!),
maxPriorityFeePerGas: 31_719_355n,
});
expect(sendSpy).toHaveBeenCalledTimes(1);
});
it("should return maxFeePerGas from config when maxFeePerGas and maxPriorityFeePerGas === 0", async () => {
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(1);
const sendSpy = jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce({
baseFeePerGas: ["0x0", "0x0", "0x0", "0x0", "0x0", "0x0"],
gasUsedRatio: [0, 0, 0, 0, 0],
oldestBlock: "0xfab8ac",
reward: [
["0x0", "0x0"],
["0x0", "0x0"],
["0x0", "0x0"],
["0x0", "0x0"],
["0x0", "0x0"],
],
});
const fees = await eip1559GasProvider.getGasFees();
expect(fees).toStrictEqual({
maxFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas!,
maxPriorityFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas!,
});
expect(sendSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,133 @@
import { describe, afterEach, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { ChainQuerier } from "../../ChainQuerier";
import { GasProvider } from "../GasProvider";
import { Direction } from "../../../../core/enums/MessageEnums";
import { DEFAULT_GAS_ESTIMATION_PERCENTILE, DEFAULT_MAX_FEE_PER_GAS } from "../../../../core/constants";
import { generateTransactionRequest } from "../../../../utils/testing/helpers";
import { toBeHex } from "ethers";
const testFeeHistory = {
baseFeePerGas: ["0x3da8e7618", "0x3e1ba3b1b", "0x3dfd72b90", "0x3d64eee76", "0x3d4da2da0", "0x3ccbcac6b"],
gasUsedRatio: [0.5290747666666666, 0.49240453333333334, 0.4615576, 0.49407083333333335, 0.4669053],
oldestBlock: "0xfab8ac",
reward: [
["0xe4e1c0", "0xe4e1c0"],
["0xe4e1c0", "0xe4e1c0"],
["0xe4e1c0", "0xe4e1c0"],
["0xcf7867", "0xe4e1c0"],
["0x5f5e100", "0xe4e1c0"],
],
};
describe("GasProvider", () => {
let chainQuerierMock: MockProxy<ChainQuerier>;
let gasProvider: GasProvider;
beforeEach(() => {
chainQuerierMock = mock<ChainQuerier>();
gasProvider = new GasProvider(chainQuerierMock, {
enableLineaEstimateGas: true,
direction: Direction.L1_TO_L2,
enforceMaxGasFee: false,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
});
});
afterEach(() => {
mockClear(chainQuerierMock);
jest.clearAllMocks();
});
describe("getGasFees", () => {
describe("L1 to L2", () => {
it("should throw an error if transactionRequest param is undefined and enableLineaEstimateGas is enabled", async () => {
await expect(gasProvider.getGasFees()).rejects.toThrow(
"You need to provide transaction request as param to call the getGasFees function on Linea.",
);
});
it("should use LineaGasProvider when enableLineaEstimateGas is enabled", async () => {
jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce({
gasLimit: "0x300000",
baseFeePerGas: "0x7",
priorityFeePerGas: toBeHex(DEFAULT_MAX_FEE_PER_GAS),
});
const gasFees = await gasProvider.getGasFees(generateTransactionRequest());
const expectedBaseFee = (BigInt("0x7") * BigInt(1.35 * 100)) / 100n;
const expectedPriorityFeePerGas = (DEFAULT_MAX_FEE_PER_GAS * BigInt(1.05 * 100)) / 100n;
const expectedMaxFeePerGas = expectedBaseFee + expectedPriorityFeePerGas;
expect(gasFees).toStrictEqual({
gasLimit: 3145728n,
maxFeePerGas: expectedMaxFeePerGas,
maxPriorityFeePerGas: expectedPriorityFeePerGas,
});
});
it("should use DefaultGasProvider when enableLineaEstimateGas is disabled", async () => {
gasProvider = new GasProvider(chainQuerierMock, {
enableLineaEstimateGas: false,
direction: Direction.L1_TO_L2,
enforceMaxGasFee: false,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
});
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(1);
const estimatedGasLimit = 50_000n;
jest.spyOn(chainQuerierMock, "estimateGas").mockResolvedValueOnce(estimatedGasLimit);
jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce(testFeeHistory);
const gasFees = await gasProvider.getGasFees();
expect(gasFees).toStrictEqual({
gasLimit: estimatedGasLimit,
maxFeePerGas: 32671357073n,
maxPriorityFeePerGas: 31719355n,
});
});
});
describe("L2 to L1", () => {
it("should use DefaultGasProvider", async () => {
gasProvider = new GasProvider(chainQuerierMock, {
enableLineaEstimateGas: false,
direction: Direction.L2_TO_L1,
enforceMaxGasFee: false,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
});
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(1);
jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce(testFeeHistory);
const gasFees = await gasProvider.getGasFees();
expect(gasFees).toStrictEqual({
maxFeePerGas: 32671357073n,
maxPriorityFeePerGas: 31719355n,
});
});
});
});
describe("getMaxFeePerGas", () => {
it("should use LineaGasProvider if direction == L1_TO_L2", () => {
expect(gasProvider.getMaxFeePerGas()).toStrictEqual(DEFAULT_MAX_FEE_PER_GAS);
});
it("should use DefaultGasProvider if direction == L2_TO_L1", () => {
gasProvider = new GasProvider(chainQuerierMock, {
enableLineaEstimateGas: false,
direction: Direction.L2_TO_L1,
enforceMaxGasFee: false,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
});
expect(gasProvider.getMaxFeePerGas()).toStrictEqual(DEFAULT_MAX_FEE_PER_GAS);
});
});
});

View File

@@ -0,0 +1,75 @@
import { describe, afterEach, jest, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { testL1NetworkConfig } from "../../../../utils/testing/constants";
import { LineaGasProvider } from "../LineaGasProvider";
import { ChainQuerier } from "../../ChainQuerier";
import { generateTransactionRequest } from "../../../../utils/testing/helpers";
import { toBeHex } from "ethers";
describe("LineaGasProvider", () => {
let chainQuerierMock: MockProxy<ChainQuerier>;
let lineaGasProvider: LineaGasProvider;
beforeEach(() => {
chainQuerierMock = mock<ChainQuerier>();
lineaGasProvider = new LineaGasProvider(chainQuerierMock, {
maxFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas,
enforceMaxGasFee: false,
});
});
afterEach(() => {
mockClear(chainQuerierMock);
});
describe("getGasFees", () => {
it("should return maxFeePerGas, maxPriorityFeePerGas from config when enforceMaxGasFee option is enabled", async () => {
lineaGasProvider = new LineaGasProvider(chainQuerierMock, {
maxFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas,
enforceMaxGasFee: true,
});
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(1);
const sendRequestSpy = jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce({
baseFeePerGas: "0x7",
priorityFeePerGas: toBeHex(testL1NetworkConfig.claiming.maxFeePerGas),
gasLimit: toBeHex(50_000n),
});
const transactionRequest = generateTransactionRequest();
const fees = await lineaGasProvider.getGasFees(transactionRequest);
expect(fees).toStrictEqual({
maxFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas,
maxPriorityFeePerGas: testL1NetworkConfig.claiming.maxFeePerGas,
gasLimit: 50_000n,
});
expect(sendRequestSpy).toHaveBeenCalledTimes(1);
});
it("should return maxFeePerGas, maxPriorityFeePerGas and gasLimit", async () => {
jest.spyOn(chainQuerierMock, "getCurrentBlockNumber").mockResolvedValueOnce(1);
const sendRequestSpy = jest.spyOn(chainQuerierMock, "sendRequest").mockResolvedValueOnce({
baseFeePerGas: "0x7",
priorityFeePerGas: toBeHex(testL1NetworkConfig.claiming.maxFeePerGas),
gasLimit: toBeHex(50_000n),
});
const transactionRequest = generateTransactionRequest();
const fees = await lineaGasProvider.getGasFees(transactionRequest);
const expectedBaseFee = (BigInt("0x7") * BigInt(1.35 * 100)) / 100n;
const expectedPriorityFeePerGas = (testL1NetworkConfig.claiming.maxFeePerGas * BigInt(1.05 * 100)) / 100n;
const expectedMaxFeePerGas = expectedBaseFee + expectedPriorityFeePerGas;
expect(fees).toStrictEqual({
maxFeePerGas: expectedMaxFeePerGas,
maxPriorityFeePerGas: expectedPriorityFeePerGas,
gasLimit: 50_000n,
});
expect(sendRequestSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,200 @@
import { JsonRpcProvider } from "ethers";
import { MessageSent, ServiceVersionMigrated } from "../../../core/types/Events";
import { L2MessageService, L2MessageService__factory } from "../typechain";
import {
IL2MessageServiceLogClient,
MessageSentEventFilters,
} from "../../../core/clients/blockchain/linea/IL2MessageServiceLogClient";
import { TypedContractEvent, TypedDeferredTopicFilter, TypedEventLog } from "../typechain/common";
import { MessageSentEvent, ServiceVersionMigratedEvent } from "../typechain/L2MessageService";
import { isUndefined } from "../../../core/utils/shared";
export class EthersL2MessageServiceLogClient implements IL2MessageServiceLogClient {
private l2MessageService: L2MessageService;
/**
* Constructs a new instance of the `EthersL2MessageServiceLogClient`.
*
* @param {JsonRpcProvider} provider - The JSON RPC provider for interacting with the Ethereum network.
* @param {string} contractAddress - The address of the L2 Message Service contract.
*/
constructor(provider: JsonRpcProvider, contractAddress: string) {
this.l2MessageService = L2MessageService__factory.connect(contractAddress, provider);
}
/**
* Fetches event logs from the L2 Message Service contract based on the provided filters and block range.
*
* This generic method queries the Ethereum blockchain for events emitted by the L2 Message Service contract that match the given criteria. It filters the events further based on the optional parameters for block range and log index, ensuring that only relevant events are returned.
*
* @template TCEevent - A type parameter extending `TypedContractEvent`, representing the specific event type to fetch.
* @param {TypedDeferredTopicFilter<TypedContractEvent>} eventFilter - The filter criteria used to select the events to be fetched. This includes the contract address, event signature, and any additional filter parameters.
* @param {number} [fromBlock=0] - The block number from which to start fetching events. Defaults to `0` if not specified.
* @param {string|number} [toBlock='latest'] - The block number until which to fetch events. Defaults to 'latest' if not specified.
* @param {number} [fromBlockLogIndex] - The log index within the `fromBlock` from which to start fetching events. This allows for more granular control over the event fetch start point within a block.
* @returns {Promise<TypedEventLog<TCEevent>[]>} A promise that resolves to an array of event logs of the specified type that match the filter criteria.
*/
private async getEvents<TCEevent extends TypedContractEvent>(
eventFilter: TypedDeferredTopicFilter<TypedContractEvent>,
fromBlock?: number,
toBlock?: string | number,
fromBlockLogIndex?: number,
): Promise<TypedEventLog<TCEevent>[]> {
const events = await this.l2MessageService.queryFilter(eventFilter, fromBlock, toBlock);
return events
.filter((event) => {
if (isUndefined(fromBlockLogIndex) || isUndefined(fromBlock)) {
return true;
}
if (event.blockNumber === fromBlock && event.index < fromBlockLogIndex) {
return false;
}
return true;
})
.filter((e) => e.removed === false);
}
/**
* Retrieves `MessageSent` events that match the given filters.
*
* @param {Object} params - The parameters for fetching events.
* @param {MessageSentEventFilters} [params.filters] - The messageSent event filters to apply.
* @param {number} [params.fromBlock=0] - The starting block number. Defaults to `0` if not specified.
* @param {string|number} [params.toBlock='latest'] - The ending block number. Defaults to `latest` if not specified.
* @param {number} [params.fromBlockLogIndex] - The log index to start from within the `fromBlock`.
* @returns {Promise<MessageSent[]>} A promise that resolves to an array of `MessageSent` events.
*/
public async getMessageSentEvents(params: {
filters?: MessageSentEventFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageSent[]> {
const { filters, fromBlock, toBlock, fromBlockLogIndex } = params;
const messageSentEventFilter = this.l2MessageService.filters.MessageSent(
filters?.from,
filters?.to,
undefined,
undefined,
undefined,
undefined,
filters?.messageHash,
);
return (
await this.getEvents<MessageSentEvent.Event>(messageSentEventFilter, fromBlock, toBlock, fromBlockLogIndex)
).map((event) => ({
messageSender: event.args._from,
destination: event.args._to,
fee: event.args._fee,
value: event.args._value,
messageNonce: event.args._nonce,
calldata: event.args._calldata,
messageHash: event.args._messageHash,
blockNumber: event.blockNumber,
logIndex: event.index,
contractAddress: event.address,
transactionHash: event.transactionHash,
}));
}
/**
* Retrieves `MessageSent` events by message hash.
*
* @param {Object} params - The parameters for fetching events by message hash.
* @param {string} params.messageHash - The hash of the message sent on L2.
* @param {number} [params.fromBlock=0] - The starting block number. Defaults to `0` if not specified.
* @param {string|number} [params.toBlock='latest'] - The ending block number. Defaults to `latest` if not specified.
* @param {number} [params.fromBlockLogIndex] - The log index to start from within the `fromBlock`.
* @returns {Promise<MessageSent[]>} A promise that resolves to an array of `MessageSent` events.
*/
public async getMessageSentEventsByMessageHash(params: {
messageHash: string;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageSent[]> {
const { messageHash, fromBlock, toBlock, fromBlockLogIndex } = params;
const messageSentEventFilter = this.l2MessageService.filters.MessageSent(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
messageHash,
);
return (
await this.getEvents<MessageSentEvent.Event>(messageSentEventFilter, fromBlock, toBlock, fromBlockLogIndex)
).map((event) => ({
messageSender: event.args._from,
destination: event.args._to,
fee: event.args._fee,
value: event.args._value,
messageNonce: event.args._nonce,
calldata: event.args._calldata,
messageHash: event.args._messageHash,
blockNumber: event.blockNumber,
logIndex: event.index,
contractAddress: event.address,
transactionHash: event.transactionHash,
}));
}
/**
* Retrieves `MessageSent` events within a specified block range.
*
* @param {number} fromBlock - The starting block number.
* @param {number} toBlock - The ending block number.
* @returns {Promise<MessageSent[]>} A promise that resolves to an array of `MessageSent` events.
*/
public async getMessageSentEventsByBlockRange(fromBlock: number, toBlock: number): Promise<MessageSent[]> {
const messageSentEventFilter = this.l2MessageService.filters.MessageSent();
return (await this.getEvents<MessageSentEvent.Event>(messageSentEventFilter, fromBlock, toBlock)).map((event) => ({
messageSender: event.args._from,
destination: event.args._to,
fee: event.args._fee,
value: event.args._value,
messageNonce: event.args._nonce,
calldata: event.args._calldata,
messageHash: event.args._messageHash,
blockNumber: event.blockNumber,
logIndex: event.index,
contractAddress: event.address,
transactionHash: event.transactionHash,
}));
}
/**
* Retrieves `ServiceVersionMigrated` events.
*
* @param {Object} [params] - The parameters for fetching the events.
* @param {number} [params.fromBlock=0] - The starting block number. Defaults to `0` if not specified.
* @param {string|number} [params.toBlock='latest'] - The ending block number. Defaults to `latest` if not specified.
* @param {number} [params.fromBlockLogIndex] - The log index to start from within the `fromBlock`.
* @returns {Promise<ServiceVersionMigrated[]>} A promise that resolves to an array of `ServiceVersionMigrated` events.
*/
public async getServiceVersionMigratedEvents(params?: {
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<ServiceVersionMigrated[]> {
const serviceVersionMigratedFilter = this.l2MessageService.filters.ServiceVersionMigrated(2);
return (
await this.getEvents<ServiceVersionMigratedEvent.Event>(
serviceVersionMigratedFilter,
params?.fromBlock,
params?.toBlock,
params?.fromBlockLogIndex,
)
).map((event) => ({
version: event.args.version,
blockNumber: event.blockNumber,
logIndex: event.index,
contractAddress: event.address,
transactionHash: event.transactionHash,
}));
}
}

View File

@@ -0,0 +1,66 @@
import { BlockTag, JsonRpcProvider, Signer, dataSlice, toNumber } from "ethers";
import { ChainQuerier } from "../ChainQuerier";
import { BlockExtraData } from "../../../core/clients/blockchain/linea/IL2ChainQuerier";
export class L2ChainQuerier extends ChainQuerier {
private blockExtraDataCache: BlockExtraData;
private cacheIsValidForBlockNumber: bigint;
/**
* Creates an instance of `L2ChainQuerier`.
*
* @param {JsonRpcProvider} provider - The JSON RPC provider for interacting with the Ethereum network.
* @param {Signer} [signer] - An optional Ethers.js signer object for signing transactions.
*/
constructor(provider: JsonRpcProvider, signer?: Signer) {
super(provider, signer);
}
/**
* Fetches and format extra data from a block.
*
* @param {BlockTag} blockNumber - The block number or tag to fetch extra data for.
* @returns {Promise<BlockExtraData | null>} A promise that resolves to an object containing the formatted block's extra data, or null if the block is not found.
*/
public async getBlockExtraData(blockNumber: BlockTag): Promise<BlockExtraData | null> {
if (typeof blockNumber === "number" && this.isCacheValid(blockNumber)) {
return this.blockExtraDataCache;
}
const block = await this.getBlock(blockNumber);
if (!block) {
return null;
}
const version = dataSlice(block.extraData, 0, 1);
const fixedCost = dataSlice(block.extraData, 1, 5);
const variableCost = dataSlice(block.extraData, 5, 9);
const ethGasPrice = dataSlice(block.extraData, 9, 13);
// original values are in Kwei and here we convert them back to wei
const extraData = {
version: toNumber(version),
fixedCost: toNumber(fixedCost) * 1000,
variableCost: toNumber(variableCost) * 1000,
ethGasPrice: toNumber(ethGasPrice) * 1000,
};
if (typeof blockNumber === "number") {
this.cacheIsValidForBlockNumber = BigInt(blockNumber);
this.blockExtraDataCache = extraData;
}
return extraData;
}
/**
* Checks if the cached block extra data is still valid based on the current block number.
*
* @private
* @param {number} currentBlockNumber - The current block number.
* @returns {boolean} True if the cache is valid, false otherwise.
*/
private isCacheValid(currentBlockNumber: number): boolean {
return this.cacheIsValidForBlockNumber >= BigInt(currentBlockNumber);
}
}

View File

@@ -0,0 +1,319 @@
import {
Overrides,
ContractTransactionResponse,
JsonRpcProvider,
TransactionReceipt,
TransactionRequest,
TransactionResponse,
Signer,
Block,
} from "ethers";
import { L2MessageService, L2MessageService__factory } from "../typechain";
import { GasEstimationError } from "../../../core/errors/GasFeeErrors";
import { MessageProps } from "../../../core/entities/Message";
import { OnChainMessageStatus } from "../../../core/enums/MessageEnums";
import { IL2MessageServiceClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceClient";
import { ZERO_ADDRESS } from "../../../core/constants";
import { BaseError } from "../../../core/errors/Base";
import { SDKMode } from "../../../sdk/config";
import { formatMessageStatus } from "../../../core/utils/message";
import { IGasProvider, LineaGasFees } from "../../../core/clients/blockchain/IGasProvider";
import { IMessageRetriever } from "../../../core/clients/blockchain/IMessageRetriever";
import { MessageSent } from "../../../core/types/Events";
import { IL2ChainQuerier } from "../../../core/clients/blockchain/linea/IL2ChainQuerier";
export class L2MessageServiceClient
implements
IL2MessageServiceClient<Overrides, TransactionReceipt, TransactionResponse, ContractTransactionResponse, Signer>
{
private readonly contract: L2MessageService;
/**
* Initializes a new instance of the `L2MessageServiceClient`.
*
* @param {IL2ChainQuerier} chainQuerier - The chain querier for interacting with the blockchain.
* @param {string} contractAddress - The address of the L2 message service contract.
* @param {IMessageRetriever<TransactionReceipt>} messageRetriever - An instance of a class implementing the `IMessageRetriever` interface for retrieving messages.
* @param {IGasProvider<TransactionRequest>} gasFeeProvider - An instance of a class implementing the `IGasProvider` interface for providing gas fee estimates.
* @param {SDKMode} mode - The mode in which the SDK is operating, e.g., `read-only` or `read-write`.
* @param {Signer} [signer] - An optional Ethers.js signer object for signing transactions.
*/
constructor(
private readonly chainQuerier: IL2ChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly contractAddress: string,
private readonly messageRetriever: IMessageRetriever<TransactionReceipt>,
private readonly gasFeeProvider: IGasProvider<TransactionRequest>,
private readonly mode: SDKMode,
private readonly signer?: Signer,
) {
this.contract = this.getContract(this.contractAddress, this.signer);
}
public getSigner(): Signer | undefined {
return this.signer;
}
public getContractAddress(): string {
return this.contractAddress;
}
/**
* Retrieves message information by message hash.
*
* @param {string} messageHash - The hash of the message sent on L2.
* @returns {Promise<MessageSent | null>} The message information or null if not found.
*/
public async getMessageByMessageHash(messageHash: string): Promise<MessageSent | null> {
return this.messageRetriever.getMessageByMessageHash(messageHash);
}
/**
* Retrieves messages information by the transaction hash.
*
* @param {string} transactionHash - The hash of the `sendMessage` transaction on L2.
* @returns {Promise<MessageSent[] | null>} An array of message information or null if not found.
*/
public async getMessagesByTransactionHash(transactionHash: string): Promise<MessageSent[] | null> {
return this.messageRetriever.getMessagesByTransactionHash(transactionHash);
}
/**
* Retrieves the transaction receipt by message hash.
*
* @param {string} messageHash - The hash of the message sent on L2.
* @returns {Promise<TransactionReceipt | null>} The `sendMessage` transaction receipt or null if not found.
*/
public async getTransactionReceiptByMessageHash(messageHash: string): Promise<TransactionReceipt | null> {
return this.messageRetriever.getTransactionReceiptByMessageHash(messageHash);
}
/**
* Retrieves the `L2MessageService` contract instance.
*
* @param {string} contractAddress - Address of the L2 contract.
* @param {Signer} [signer] - L2 ethers signer instance.
* @returns {L2MessageService} The `L2MessageService` contract instance.
* @private
*/
private getContract(contractAddress: string, signer?: Signer): L2MessageService {
if (this.mode === "read-only") {
return L2MessageService__factory.connect(contractAddress, this.chainQuerier.getProvider());
}
if (!signer) {
throw new BaseError("Please provide a signer.");
}
return L2MessageService__factory.connect(contractAddress, signer);
}
/**
* Retrieves the L1 message status on L2.
*
* @param {string} messageHash - The hash of the message sent on L1.
* @param {Overrides} [overrides={}] - Ethers call overrides. Defaults to `{}` if not specified.
* @returns {Promise<OnChainMessageStatus>} Message status (CLAIMED, CLAIMABLE, UNKNOWN).
*/
public async getMessageStatus(messageHash: string, overrides: Overrides = {}): Promise<OnChainMessageStatus> {
const status = await this.contract.inboxL1L2MessageStatus(messageHash, overrides);
return formatMessageStatus(status);
}
/**
* Estimates the gas required for the `claimMessage` transaction.
*
* @param {MessageProps & { feeRecipient?: string }} message - The message information object.
* @param {Overrides} [overrides={}] - Ethers payable overrides. Defaults to `{}` if not specified.
* @returns {Promise<bigint>} The `claimMessage` transaction gas estimation.
*/
public async estimateClaimGasFees(
message: MessageProps & { feeRecipient?: string },
overrides: Overrides = {},
): Promise<LineaGasFees> {
if (this.mode === "read-only") {
throw new BaseError("'EstimateClaimGasFees' function not callable using readOnly mode.");
}
try {
const transactionData = this.encodeClaimMessageTransactionData(message);
return (await this.gasFeeProvider.getGasFees({
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
from: await this.signer?.getAddress()!,
to: await this.contract.getAddress(),
value: 0n,
data: transactionData,
...overrides,
})) as LineaGasFees;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
throw new GasEstimationError(e, message);
}
}
/**
* Claims the message on L2.
*
* @param {MessageProps & { feeRecipient?: string }} message - The message information object.
* @param {Overrides} [overrides] - Ethers payable overrides. Defaults to `{}` if not specified.
* @returns {Promise<ContractTransactionResponse>} The claimMessage transaction info.
*/
public async claim(
message: MessageProps & { feeRecipient?: string },
overrides: Overrides = {},
): Promise<ContractTransactionResponse> {
if (this.mode === "read-only") {
throw new BaseError("'claim' function not callable using readOnly mode.");
}
const { messageSender, destination, fee, value, calldata, messageNonce, feeRecipient } = message;
const l2FeeRecipient = feeRecipient ?? ZERO_ADDRESS;
return await this.contract.claimMessage(
messageSender,
destination,
fee,
value,
l2FeeRecipient,
calldata,
messageNonce,
{
...overrides,
},
);
}
/**
* Retries a specific transaction with a higher fee.
*
* @param {string} transactionHash - The hash of the transaction.
* @param {number} [priceBumpPercent=10] - The percentage of price increase to retry the transaction. Defaults to `10` if not specified.
* @returns {Promise<TransactionResponse>} The transaction information.
*/
public async retryTransactionWithHigherFee(
transactionHash: string,
priceBumpPercent: number = 10,
): Promise<TransactionResponse> {
if (!Number.isInteger(priceBumpPercent)) {
throw new Error("'priceBumpPercent' must be an integer");
}
if (this.mode === "read-only") {
throw new BaseError("'retryTransactionWithHigherFee' function not callable using readOnly mode.");
}
const transaction = await this.chainQuerier.getTransaction(transactionHash);
if (!transaction) {
throw new BaseError(`Transaction with hash ${transactionHash} not found.`);
}
let maxPriorityFeePerGas;
let maxFeePerGas;
if (!transaction.maxPriorityFeePerGas || !transaction.maxFeePerGas) {
const txFees = await this.chainQuerier.getFees();
maxPriorityFeePerGas = txFees.maxPriorityFeePerGas;
maxFeePerGas = txFees.maxFeePerGas;
} else {
maxPriorityFeePerGas = (transaction.maxPriorityFeePerGas * (BigInt(priceBumpPercent) + 100n)) / 100n;
maxFeePerGas = (transaction.maxFeePerGas * (BigInt(priceBumpPercent) + 100n)) / 100n;
const maxFeePerGasFromConfig = this.gasFeeProvider.getMaxFeePerGas();
if (maxPriorityFeePerGas > maxFeePerGasFromConfig) {
maxPriorityFeePerGas = maxFeePerGasFromConfig;
}
if (maxFeePerGas > maxFeePerGasFromConfig) {
maxFeePerGas = maxFeePerGasFromConfig;
}
}
const updatedTransaction: TransactionRequest = {
to: transaction.to,
value: transaction.value,
data: transaction.data,
nonce: transaction.nonce,
gasLimit: transaction.gasLimit,
chainId: transaction.chainId,
type: 2,
maxPriorityFeePerGas,
maxFeePerGas,
};
const signedTransaction = await this.signer!.signTransaction(updatedTransaction);
return await this.chainQuerier.broadcastTransaction(signedTransaction);
}
/**
* Checks if the rate limit for sending a message has been exceeded based on the provided message fee and value.
*
* @param {bigint} _messageFee - The fee associated with the message.
* @param {bigint} _messageValue - The value being sent with the message.
* @returns {Promise<boolean>} A promise that resolves to `true` if the rate limit has been exceeded, otherwise `false`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async isRateLimitExceeded(_messageFee: bigint, _messageValue: bigint): Promise<boolean> {
return false;
}
/**
* Determines if a transaction failed due to exceeding the rate limit.
*
* @param {string} transactionHash - The hash of the transaction to check.
* @returns {Promise<boolean>} A promise that resolves to `true` if the transaction failed due to a rate limit exceedance, otherwise `false`.
*/
public async isRateLimitExceededError(transactionHash: string): Promise<boolean> {
try {
const tx = await this.chainQuerier.getTransaction(transactionHash);
const errorEncodedData = await this.chainQuerier.ethCall({
to: tx?.to,
from: tx?.from,
nonce: tx?.nonce,
gasLimit: tx?.gasLimit,
data: tx?.data,
value: tx?.value,
chainId: tx?.chainId,
accessList: tx?.accessList,
maxPriorityFeePerGas: tx?.maxPriorityFeePerGas,
maxFeePerGas: tx?.maxFeePerGas,
});
const error = this.contract.interface.parseError(errorEncodedData);
return error?.name === "RateLimitExceeded";
} catch (e) {
return false;
}
}
/**
* Encodes the transaction data for claiming a message.
*
* @param {MessageProps & { feeRecipient?: string }} message - The message properties including an optional fee recipient.
* @param {string} message.messageSender - The address of the message sender.
* @param {string} message.destination - The destination address of the message.
* @param {bigint} message.fee - The fee associated with the message.
* @param {bigint} message.value - The value associated with the message.
* @param {string} message.calldata - The calldata associated with the message.
* @param {bigint} message.messageNonce - The nonce of the message.
* @param {string} [message.feeRecipient] - The optional address of the fee recipient. Defaults to ZERO_ADDRESS if not provided.
* @returns {string} The encoded transaction data for claiming the message.
*/
public encodeClaimMessageTransactionData(message: MessageProps & { feeRecipient?: string }): string {
const { messageSender, destination, fee, value, calldata, messageNonce, feeRecipient } = message;
const l2FeeRecipient = feeRecipient ?? ZERO_ADDRESS;
return this.contract.interface.encodeFunctionData("claimMessage", [
messageSender,
destination,
fee,
value,
l2FeeRecipient,
calldata,
messageNonce,
]);
}
}

View File

@@ -0,0 +1,96 @@
import { Block, JsonRpcProvider, TransactionReceipt, TransactionRequest, TransactionResponse } from "ethers";
import { MessageSent } from "../../../core/types/Events";
import { MESSAGE_SENT_EVENT_SIGNATURE } from "../../../core/constants";
import { isNull } from "../../../core/utils/shared";
import { L2MessageService, L2MessageService__factory } from "../typechain";
import { IMessageRetriever } from "../../../core/clients/blockchain/IMessageRetriever";
import { IL2MessageServiceLogClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceLogClient";
import { IL2ChainQuerier } from "../../../core/clients/blockchain/linea/IL2ChainQuerier";
export class L2MessageServiceMessageRetriever implements IMessageRetriever<TransactionReceipt> {
private readonly contract: L2MessageService;
/**
* Creates an instance of `L2MessageServiceMessageRetriever`.
*
* @param {IL2ChainQuerier} chainQuerier - The chain querier for interacting with the blockchain.
* @param {IL2MessageServiceLogClient} l2MessageServiceLogClient - An instance of a class implementing the `IL2MessageServiceLogClient` interface for fetching events from the blockchain.
* @param {string} contractAddress - The address of the L2 message service contract.
*/
constructor(
private readonly chainQuerier: IL2ChainQuerier<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly l2MessageServiceLogClient: IL2MessageServiceLogClient,
private readonly contractAddress: string,
) {
this.contract = L2MessageService__factory.connect(contractAddress, this.chainQuerier.getProvider());
}
/**
* Retrieves message information by message hash.
*
* @param {string} messageHash - The hash of the message sent on L2.
* @returns {Promise<MessageSent | null>} The message information or null if not found.
*/
public async getMessageByMessageHash(messageHash: string): Promise<MessageSent | null> {
const [event] = await this.l2MessageServiceLogClient.getMessageSentEvents({
filters: { messageHash },
fromBlock: 0,
toBlock: "latest",
});
return event ?? null;
}
/**
* Retrieves messages information by the transaction hash.
*
* @param {string} transactionHash - The hash of the `sendMessage` transaction on L2.
* @returns {Promise<MessageSent[] | null>} An array of message information or null if not found.
*/
public async getMessagesByTransactionHash(transactionHash: string): Promise<MessageSent[] | null> {
const receipt = await this.chainQuerier.getTransactionReceipt(transactionHash);
if (!receipt) {
return null;
}
const messageSentEvents = await Promise.all(
receipt.logs
.filter((log) => log.address === this.contractAddress && log.topics[0] === MESSAGE_SENT_EVENT_SIGNATURE)
.map((log) => this.contract.interface.parseLog(log))
.filter((log) => !isNull(log))
.map((log) => this.getMessageByMessageHash(log!.args._messageHash)),
);
return messageSentEvents.filter((log) => !isNull(log)) as MessageSent[];
}
/**
* Retrieves the transaction receipt by message hash.
*
* @param {string} messageHash - The hash of the message sent on L2.
* @returns {Promise<TransactionReceipt | null>} The `sendMessage` transaction receipt or null if not found.
*/
public async getTransactionReceiptByMessageHash(messageHash: string): Promise<TransactionReceipt | null> {
const [event] = await this.l2MessageServiceLogClient.getMessageSentEvents({
filters: { messageHash },
fromBlock: 0,
toBlock: "latest",
});
if (!event) {
return null;
}
const receipt = await this.chainQuerier.getTransactionReceipt(event.transactionHash);
if (!receipt) {
return null;
}
return receipt;
}
}

View File

@@ -0,0 +1,98 @@
import { describe, afterEach, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { JsonRpcProvider } from "ethers";
import { EthersL2MessageServiceLogClient } from "../EthersL2MessageServiceLogClient";
import {
testL2NetworkConfig,
testMessageSentEvent,
testMessageSentEventLog,
testServiceVersionMigratedEventLog,
testServiceVersionMigratedEvent,
TEST_MESSAGE_HASH,
} from "../../../../utils/testing/constants";
import { L2MessageService, L2MessageService__factory } from "../../typechain";
import { mockProperty } from "../../../../utils/testing/helpers";
describe("TestEthersL2MessgaeServiceLogClient", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let l2MessgaeServiceMock: MockProxy<L2MessageService>;
let l2MessgaeServiceLogClient: EthersL2MessageServiceLogClient;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
l2MessgaeServiceMock = mock<L2MessageService>();
mockProperty(l2MessgaeServiceMock, "filters", {
...l2MessgaeServiceMock.filters,
MessageSent: jest.fn(),
ServiceVersionMigrated: jest.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
jest.spyOn(L2MessageService__factory, "connect").mockReturnValue(l2MessgaeServiceMock);
l2MessgaeServiceLogClient = new EthersL2MessageServiceLogClient(
providerMock,
testL2NetworkConfig.messageServiceContractAddress,
);
});
afterEach(() => {
mockClear(providerMock);
mockClear(l2MessgaeServiceMock);
});
describe("getMessageSentEvents", () => {
it("should return a MessageSentEvent", async () => {
jest.spyOn(l2MessgaeServiceMock, "queryFilter").mockResolvedValue([testMessageSentEventLog]);
const messageSentEvents = await l2MessgaeServiceLogClient.getMessageSentEvents({
fromBlock: 51,
fromBlockLogIndex: 1,
});
expect(messageSentEvents).toStrictEqual([testMessageSentEvent]);
});
it("should return empty MessageSentEvent as event index is less than fromBlockLogIndex", async () => {
jest.spyOn(l2MessgaeServiceMock, "queryFilter").mockResolvedValue([testMessageSentEventLog]);
const messageSentEvents = await l2MessgaeServiceLogClient.getMessageSentEvents({
fromBlock: 51,
fromBlockLogIndex: 10,
});
expect(messageSentEvents).toStrictEqual([]);
});
});
describe("getMessageSentEventsByMessageHash", () => {
it("should return a MessageSentEvent", async () => {
jest.spyOn(l2MessgaeServiceMock, "queryFilter").mockResolvedValue([testMessageSentEventLog]);
const messageSentEvents = await l2MessgaeServiceLogClient.getMessageSentEventsByMessageHash({
messageHash: TEST_MESSAGE_HASH,
});
expect(messageSentEvents).toStrictEqual([testMessageSentEvent]);
});
});
describe("getMessageSentEventsByBlockRange", () => {
it("should return a MessageSentEvent", async () => {
jest.spyOn(l2MessgaeServiceMock, "queryFilter").mockResolvedValue([testMessageSentEventLog]);
const messageSentEvents = await l2MessgaeServiceLogClient.getMessageSentEventsByBlockRange(51, 51);
expect(messageSentEvents).toStrictEqual([testMessageSentEvent]);
});
});
describe("getServiceVersionMigratedEvents", () => {
it("should return a ServiceVersionMigratedEvent", async () => {
jest.spyOn(l2MessgaeServiceMock, "queryFilter").mockResolvedValue([testServiceVersionMigratedEventLog]);
const serviceVersionMigratedEvents = await l2MessgaeServiceLogClient.getServiceVersionMigratedEvents({});
expect(serviceVersionMigratedEvents).toStrictEqual([testServiceVersionMigratedEvent]);
});
});
});

View File

@@ -0,0 +1,52 @@
import { Block, JsonRpcProvider, Wallet } from "ethers";
import { describe, afterEach, it, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { L2ChainQuerier } from "../L2ChainQuerier";
import { TEST_L1_SIGNER_PRIVATE_KEY } from "../../../../utils/testing/constants";
describe("L2ChainQuerier", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let chainQuerier: L2ChainQuerier;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
chainQuerier = new L2ChainQuerier(providerMock, new Wallet(TEST_L1_SIGNER_PRIVATE_KEY, providerMock));
});
afterEach(() => {
mockClear(providerMock);
});
describe("getBlockExtraData", () => {
it("should return block extraData", async () => {
const blockMocked: Block = {
baseFeePerGas: 7n,
difficulty: 2n,
extraData:
"0x0100989680015eb3c80000ea600000000000000000000000000000000000000024997ceb570c667b9c369d351b384ce97dcfe0dda90696fc3b007b8d7160672548a6716cc33ffe0e4004c555a0c7edd9ddc2545a630f2276a2964dcf856e6ab501",
gasLimit: 61000000n,
gasUsed: 138144n,
hash: "0xc53d0f6b65feabf0422bb897d3f5de2c32d57612f88eb4a366d8076a040a715a",
miner: "0x0000000000000000000000000000000000000000",
nonce: "0x0000000000000000",
number: 1635519,
parentHash: "0xecc7bd0b6d533b13fc65529e5da174062d93f8f426c32929914a375f00a19cc3",
receiptsRoot: "0x0cfd91942f25f029d50078225749dbd8601dd922713e783e4210568ed45b6cd3",
stateRoot: "0xad1346e81f574b511c917cd76c5b70f1d6852b871569bedd9ef02160746e3ffa",
timestamp: 1718030601,
transactions: [],
provider: providerMock,
parentBeaconBlockRoot: null,
blobGasUsed: null,
excessBlobGas: null,
} as unknown as Block;
jest.spyOn(providerMock, "getBlock").mockResolvedValue(blockMocked);
expect(await chainQuerier.getBlockExtraData("latest")).toStrictEqual({
version: 1,
fixedCost: 10000000000,
variableCost: 22983624000,
ethGasPrice: 60000000,
});
});
});
});

View File

@@ -0,0 +1,432 @@
import { describe, afterEach, it, expect, beforeEach } from "@jest/globals";
import { MockProxy, mock, mockClear, mockDeep } from "jest-mock-extended";
import { ContractTransactionResponse, FeeData, JsonRpcProvider, Wallet } from "ethers";
import {
TEST_MESSAGE_HASH,
TEST_CONTRACT_ADDRESS_1,
TEST_TRANSACTION_HASH,
TEST_ADDRESS_2,
TEST_ADDRESS_1,
} from "../../../../utils/testing/constants";
import { L2MessageService, L2MessageService__factory } from "../../typechain";
import {
generateL2MessageServiceClient,
generateMessage,
generateTransactionResponse,
mockProperty,
} from "../../../../utils/testing/helpers";
import { L2MessageServiceClient } from "../L2MessageServiceClient";
import { DEFAULT_MAX_FEE_PER_GAS, ZERO_ADDRESS } from "../../../../core/constants";
import { OnChainMessageStatus } from "../../../../core/enums/MessageEnums";
import { GasEstimationError } from "../../../../core/errors/GasFeeErrors";
import { BaseError } from "../../../../core/errors/Base";
import { LineaGasProvider } from "../../gas/LineaGasProvider";
describe("TestL2MessageServiceClient", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let walletMock: MockProxy<Wallet>;
let l2MessageServiceMock: MockProxy<L2MessageService>;
let l2MessageServiceClient: L2MessageServiceClient;
let gasFeeProvider: LineaGasProvider;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
walletMock = mock<Wallet>();
l2MessageServiceMock = mockDeep<L2MessageService>();
jest.spyOn(L2MessageService__factory, "connect").mockReturnValue(l2MessageServiceMock);
walletMock.getAddress.mockResolvedValue(TEST_ADDRESS_1);
l2MessageServiceMock.getAddress.mockResolvedValue(TEST_CONTRACT_ADDRESS_1);
const clients = generateL2MessageServiceClient(providerMock, TEST_CONTRACT_ADDRESS_1, "read-write", walletMock);
l2MessageServiceClient = clients.l2MessageServiceClient;
gasFeeProvider = clients.gasProvider;
});
afterEach(() => {
mockClear(providerMock);
mockClear(walletMock);
mockClear(l2MessageServiceMock);
jest.clearAllMocks();
});
describe("constructor", () => {
it("should throw an error when mode = 'read-write' and this.signer is undefined", async () => {
expect(() => generateL2MessageServiceClient(providerMock, TEST_CONTRACT_ADDRESS_1, "read-write")).toThrowError(
new BaseError("Please provide a signer."),
);
});
});
describe("getMessageStatus", () => {
it("should return UNKNOWN when on chain message status === 0", async () => {
jest.spyOn(l2MessageServiceMock, "inboxL1L2MessageStatus").mockResolvedValue(0n);
const messageStatus = await l2MessageServiceClient.getMessageStatus(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.UNKNOWN);
});
it("should return CLAIMABLE when on chain message status === 1", async () => {
jest.spyOn(l2MessageServiceMock, "inboxL1L2MessageStatus").mockResolvedValue(1n);
const messageStatus = await l2MessageServiceClient.getMessageStatus(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMABLE);
});
it("should return CLAIMED when on chain message status === 2", async () => {
jest.spyOn(l2MessageServiceMock, "inboxL1L2MessageStatus").mockResolvedValue(2n);
const messageStatus = await l2MessageServiceClient.getMessageStatus(TEST_MESSAGE_HASH);
expect(messageStatus).toStrictEqual(OnChainMessageStatus.CLAIMED);
});
});
describe("estimateClaimGasFees", () => {
it("should throw an error when mode = 'read-only'", async () => {
const l2MessageServiceClient = generateL2MessageServiceClient(
providerMock,
TEST_CONTRACT_ADDRESS_1,
"read-only",
walletMock,
).l2MessageServiceClient;
const message = generateMessage();
await expect(l2MessageServiceClient.estimateClaimGasFees(message)).rejects.toThrow(
new Error("'EstimateClaimGasFees' function not callable using readOnly mode."),
);
});
it("should throw a GasEstimationError when the gas estimation failed", async () => {
const message = generateMessage();
jest.spyOn(gasFeeProvider, "getGasFees").mockRejectedValue(new Error("Gas fees estimation failed").message);
await expect(l2MessageServiceClient.estimateClaimGasFees(message)).rejects.toThrow(
new GasEstimationError("Gas fees estimation failed", message),
);
});
it("should set feeRecipient === ZeroAddress when feeRecipient param is undefined", async () => {
const message = generateMessage();
const transactionData = L2MessageService__factory.createInterface().encodeFunctionData("claimMessage", [
message.messageSender,
message.destination,
message.fee,
message.value,
ZERO_ADDRESS,
message.calldata,
message.messageNonce,
]);
mockProperty(l2MessageServiceMock, "interface", {
encodeFunctionData: jest.fn().mockReturnValue(transactionData),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const gasFeesSpy = jest.spyOn(gasFeeProvider, "getGasFees").mockResolvedValue({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasLimit: 50_000n,
});
const estimatedGasFees = await l2MessageServiceClient.estimateClaimGasFees(message);
expect(estimatedGasFees).toStrictEqual({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasLimit: 50_000n,
});
expect(gasFeesSpy).toHaveBeenCalledTimes(1);
expect(gasFeesSpy).toHaveBeenCalledWith({
from: await walletMock.getAddress(),
to: TEST_CONTRACT_ADDRESS_1,
value: 0n,
data: transactionData,
});
});
it("should return estimated gas and fees for the claim message transaction", async () => {
const message = generateMessage();
const transactionData = L2MessageService__factory.createInterface().encodeFunctionData("claimMessage", [
message.messageSender,
message.destination,
message.fee,
message.value,
TEST_ADDRESS_2,
message.calldata,
message.messageNonce,
]);
mockProperty(l2MessageServiceMock, "interface", {
encodeFunctionData: jest.fn().mockReturnValue(transactionData),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const gasFeesSpy = jest.spyOn(gasFeeProvider, "getGasFees").mockResolvedValue({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasLimit: 50_000n,
});
const estimatedGasFees = await l2MessageServiceClient.estimateClaimGasFees({
...message,
feeRecipient: TEST_ADDRESS_2,
});
expect(estimatedGasFees).toStrictEqual({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasLimit: 50_000n,
});
expect(gasFeesSpy).toHaveBeenCalledTimes(1);
expect(gasFeesSpy).toHaveBeenCalledWith({
from: await walletMock.getAddress(),
to: TEST_CONTRACT_ADDRESS_1,
value: 0n,
data: transactionData,
});
});
});
describe("claim", () => {
it("should throw an error when mode = 'read-only'", async () => {
const l2MessageServiceClient = generateL2MessageServiceClient(
providerMock,
TEST_CONTRACT_ADDRESS_1,
"read-only",
walletMock,
).l2MessageServiceClient;
const message = generateMessage();
await expect(l2MessageServiceClient.claim(message)).rejects.toThrow(
new Error("'claim' function not callable using readOnly mode."),
);
});
it("should set feeRecipient === ZeroAddress when feeRecipient param is undefined", async () => {
const message = generateMessage();
const txResponse = generateTransactionResponse();
jest.spyOn(l2MessageServiceMock, "claimMessage").mockResolvedValue(txResponse as ContractTransactionResponse);
const claimMessageSpy = jest.spyOn(l2MessageServiceMock, "claimMessage");
await l2MessageServiceClient.claim(message);
expect(txResponse).toStrictEqual(txResponse);
expect(claimMessageSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledWith(
message.messageSender,
message.destination,
message.fee,
message.value,
ZERO_ADDRESS,
message.calldata,
message.messageNonce,
{},
);
});
it("should return executed claim message transaction", async () => {
const message = generateMessage();
const txResponse = generateTransactionResponse();
jest.spyOn(l2MessageServiceMock, "claimMessage").mockResolvedValue(txResponse as ContractTransactionResponse);
const claimMessageSpy = jest.spyOn(l2MessageServiceMock, "claimMessage");
await l2MessageServiceClient.claim({
...message,
feeRecipient: TEST_ADDRESS_2,
});
expect(txResponse).toStrictEqual(txResponse);
expect(claimMessageSpy).toHaveBeenCalledTimes(1);
expect(claimMessageSpy).toHaveBeenCalledWith(
message.messageSender,
message.destination,
message.fee,
message.value,
TEST_ADDRESS_2,
message.calldata,
message.messageNonce,
{},
);
});
});
describe("retryTransactionWithHigherFee", () => {
it("should throw an error when mode = 'read-only'", async () => {
const l2MessageServiceClient = generateL2MessageServiceClient(
providerMock,
TEST_CONTRACT_ADDRESS_1,
"read-only",
walletMock,
).l2MessageServiceClient;
await expect(l2MessageServiceClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH)).rejects.toThrow(
new BaseError("'retryTransactionWithHigherFee' function not callable using readOnly mode."),
);
});
it("should throw an error when priceBumpPercent is not an integer", async () => {
await expect(l2MessageServiceClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH, 1.1)).rejects.toThrow(
new BaseError("'priceBumpPercent' must be an integer"),
);
});
it("should throw an error when getTransaction return null", async () => {
jest.spyOn(providerMock, "getTransaction").mockResolvedValue(null);
await expect(l2MessageServiceClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH)).rejects.toThrow(
new BaseError(`Transaction with hash ${TEST_TRANSACTION_HASH} not found.`),
);
});
it("should retry the transaction with higher fees", async () => {
const transactionResponse = generateTransactionResponse();
const getTransactionSpy = jest.spyOn(providerMock, "getTransaction").mockResolvedValue(transactionResponse);
const signTransactionSpy = jest.spyOn(walletMock, "signTransaction").mockResolvedValue("");
const sendTransactionSpy = jest.spyOn(providerMock, "broadcastTransaction");
await l2MessageServiceClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH);
expect(getTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledWith({
to: transactionResponse.to,
value: transactionResponse.value,
data: transactionResponse.data,
nonce: transactionResponse.nonce,
gasLimit: transactionResponse.gasLimit,
chainId: transactionResponse.chainId,
type: 2,
maxPriorityFeePerGas: 55000000n,
maxFeePerGas: 110000000n,
});
expect(sendTransactionSpy).toHaveBeenCalledTimes(1);
});
it("should retry the transaction with higher fees and capped by the predefined maxFeePerGas", async () => {
const transactionResponse = generateTransactionResponse();
const getTransactionSpy = jest.spyOn(providerMock, "getTransaction").mockResolvedValue(transactionResponse);
const signTransactionSpy = jest.spyOn(walletMock, "signTransaction").mockResolvedValue("");
const sendTransactionSpy = jest.spyOn(providerMock, "broadcastTransaction");
const l2MessageServiceClient = generateL2MessageServiceClient(
providerMock,
TEST_CONTRACT_ADDRESS_1,
"read-write",
walletMock,
{ maxFeePerGas: 500000000n },
).l2MessageServiceClient;
await l2MessageServiceClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH, 1000);
expect(getTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledWith({
to: transactionResponse.to,
value: transactionResponse.value,
data: transactionResponse.data,
nonce: transactionResponse.nonce,
gasLimit: transactionResponse.gasLimit,
chainId: transactionResponse.chainId,
type: 2,
maxPriorityFeePerGas: 500000000n,
maxFeePerGas: 500000000n,
});
expect(sendTransactionSpy).toHaveBeenCalledTimes(1);
});
it("should retry the transaction with the predefined maxFeePerGas if enforceMaxGasFee is true", async () => {
const transactionResponse = generateTransactionResponse({
maxPriorityFeePerGas: undefined,
maxFeePerGas: undefined,
});
const getTransactionSpy = jest.spyOn(providerMock, "getTransaction").mockResolvedValue(transactionResponse);
const signTransactionSpy = jest.spyOn(walletMock, "signTransaction").mockResolvedValue("");
const sendTransactionSpy = jest.spyOn(providerMock, "broadcastTransaction");
const getFeeDataSpy = jest.spyOn(providerMock, "getFeeData").mockResolvedValueOnce({
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasPrice: 1n,
} as FeeData);
const clients = generateL2MessageServiceClient(providerMock, TEST_CONTRACT_ADDRESS_1, "read-write", walletMock, {
maxFeePerGas: 500000000n,
enforceMaxGasFee: true,
});
const l2MessageServiceClient = clients.l2MessageServiceClient;
const providerSendMockSpy = jest.spyOn(providerMock, "send").mockResolvedValue({
baseFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
priorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
gasLimit: 50_000n,
});
await l2MessageServiceClient.retryTransactionWithHigherFee(TEST_TRANSACTION_HASH, 1000);
expect(providerSendMockSpy).toHaveBeenCalledTimes(0);
expect(getTransactionSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledTimes(1);
expect(getFeeDataSpy).toHaveBeenCalledTimes(1);
expect(signTransactionSpy).toHaveBeenCalledWith({
to: transactionResponse.to,
value: transactionResponse.value,
data: transactionResponse.data,
nonce: transactionResponse.nonce,
gasLimit: transactionResponse.gasLimit,
chainId: transactionResponse.chainId,
type: 2,
maxPriorityFeePerGas: 100000000000n,
maxFeePerGas: 100000000000n,
});
expect(sendTransactionSpy).toHaveBeenCalledTimes(1);
});
});
describe("isRateLimitExceeded", () => {
it("should always return false", async () => {
const isRateLimitExceeded = await l2MessageServiceClient.isRateLimitExceeded(1000000000n, 1000000000n);
expect(isRateLimitExceeded).toBeFalsy();
});
});
describe("isRateLimitExceededError", () => {
it("should return false when something went wrong (http error etc)", async () => {
jest.spyOn(providerMock, "getTransaction").mockRejectedValueOnce({});
expect(
await l2MessageServiceClient.isRateLimitExceededError(
"0x825a7f1aa4453735597ddf7e9062413c906a7ad49bf17ff32c2cf42f41d438d9",
),
).toStrictEqual(false);
});
it("should return false when transaction revert reason is not RateLimitExceeded", async () => {
jest.spyOn(providerMock, "getTransaction").mockResolvedValueOnce(generateTransactionResponse());
jest.spyOn(providerMock, "call").mockResolvedValueOnce("0xa74c1c6d");
expect(
await l2MessageServiceClient.isRateLimitExceededError(
"0x825a7f1aa4453735597ddf7e9062413c906a7ad49bf17ff32c2cf42f41d438d9",
),
).toStrictEqual(false);
});
it("should return true when transaction revert reason is RateLimitExceeded", async () => {
mockProperty(l2MessageServiceMock, "interface", {
...l2MessageServiceMock.interface,
parseError: jest.fn().mockReturnValueOnce({ name: "RateLimitExceeded" }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
jest.spyOn(providerMock, "getTransaction").mockResolvedValueOnce(generateTransactionResponse());
jest.spyOn(providerMock, "call").mockResolvedValueOnce("0xa74c1c5f");
expect(
await l2MessageServiceClient.isRateLimitExceededError(
"0x825a7f1aa4453735597ddf7e9062413c906a7ad49bf17ff32c2cf42f41d438d9",
),
).toStrictEqual(true);
});
});
});

View File

@@ -0,0 +1,100 @@
import { describe, beforeEach } from "@jest/globals";
import { JsonRpcProvider, Wallet } from "ethers";
import { MockProxy, mock } from "jest-mock-extended";
import {
TEST_CONTRACT_ADDRESS_1,
TEST_MESSAGE_HASH,
TEST_TRANSACTION_HASH,
testMessageSentEvent,
} from "../../../../utils/testing/constants";
import { generateL2MessageServiceClient, generateTransactionReceipt } from "../../../../utils/testing/helpers";
import { L2MessageServiceMessageRetriever } from "../L2MessageServiceMessageRetriever";
import { EthersL2MessageServiceLogClient } from "../EthersL2MessageServiceLogClient";
describe("L2MessageServiceMessageRetriever", () => {
let providerMock: MockProxy<JsonRpcProvider>;
let walletMock: MockProxy<Wallet>;
let messageRetriever: L2MessageServiceMessageRetriever;
let l2MessageServiceLogClient: EthersL2MessageServiceLogClient;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
walletMock = mock<Wallet>();
const clients = generateL2MessageServiceClient(providerMock, TEST_CONTRACT_ADDRESS_1, "read-write", walletMock);
messageRetriever = clients.messageRetriever;
l2MessageServiceLogClient = clients.l2MessageServiceLogClient;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getMessageByMessageHash", () => {
it("should return a MessageSent", async () => {
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
const messageSentEvent = await messageRetriever.getMessageByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentEvent).toStrictEqual(testMessageSentEvent);
});
it("should return null if empty events returned", async () => {
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEvents").mockResolvedValue([]);
const messageSentEvent = await messageRetriever.getMessageByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentEvent).toStrictEqual(null);
});
});
describe("getMessagesByTransactionHash", () => {
it("should return null when message hash does not exist", async () => {
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(null);
const messageSentEvents = await messageRetriever.getMessagesByTransactionHash(TEST_TRANSACTION_HASH);
expect(messageSentEvents).toStrictEqual(null);
});
it("should return an array of messages when transaction hash exists and contains MessageSent events", async () => {
const transactionReceipt = generateTransactionReceipt();
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
const messageSentEvents = await messageRetriever.getMessagesByTransactionHash(TEST_MESSAGE_HASH);
expect(messageSentEvents).toStrictEqual([testMessageSentEvent]);
});
});
describe("getTransactionReceiptByMessageHash", () => {
it("should return null when message hash does not exist", async () => {
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEvents").mockResolvedValue([]);
const messageSentTxReceipt = await messageRetriever.getTransactionReceiptByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentTxReceipt).toStrictEqual(null);
});
it("should return null when transaction receipt does not exist", async () => {
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(null);
const messageSentTxReceipt = await messageRetriever.getTransactionReceiptByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentTxReceipt).toStrictEqual(null);
});
it("should return an array of messages when transaction hash exists and contains MessageSent events", async () => {
const transactionReceipt = generateTransactionReceipt();
jest.spyOn(l2MessageServiceLogClient, "getMessageSentEvents").mockResolvedValue([testMessageSentEvent]);
jest.spyOn(providerMock, "getTransactionReceipt").mockResolvedValue(transactionReceipt);
const messageSentTxReceipt = await messageRetriever.getTransactionReceiptByMessageHash(TEST_MESSAGE_HASH);
expect(messageSentTxReceipt).toStrictEqual(transactionReceipt);
});
});
});

View File

@@ -0,0 +1,16 @@
import { GasFees } from "./IGasProvider";
export interface IChainQuerier<TransactionReceipt, Block, TransactionRequest, TransactionResponse, JsonRpcProvider> {
getCurrentNonce(accountAddress?: string): Promise<number>;
getCurrentBlockNumber(): Promise<number>;
getTransactionReceipt(txHash: string): Promise<TransactionReceipt | null>;
getBlock(blockNumber: number | bigint | string): Promise<Block | null>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendRequest(methodName: string, params: any[]): Promise<any>;
estimateGas(transactionRequest: TransactionRequest): Promise<bigint>;
getProvider(): JsonRpcProvider;
getTransaction(transactionHash: string): Promise<TransactionResponse | null>;
broadcastTransaction(signedTx: string): Promise<TransactionResponse>;
ethCall(transactionRequest: TransactionRequest): Promise<string>;
getFees(): Promise<GasFees>;
}

View File

@@ -0,0 +1,56 @@
import { Direction } from "../../enums/MessageEnums";
export type GasFees = {
maxFeePerGas: bigint;
maxPriorityFeePerGas: bigint;
};
export type LineaGasFees = GasFees & {
gasLimit: bigint;
};
export type FeeHistory = {
oldestBlock: number;
reward: string[][];
baseFeePerGas: string[];
gasUsedRatio: number[];
};
export type LineaEstimateGasResponse = {
baseFeePerGas: string;
priorityFeePerGas: string;
gasLimit: string;
};
type BaseGasProviderConfig = {
maxFeePerGas: bigint;
enforceMaxGasFee: boolean;
};
export type DefaultGasProviderConfig = BaseGasProviderConfig & {
gasEstimationPercentile: number;
};
export type LineaGasProviderConfig = BaseGasProviderConfig;
export type GasProviderConfig = DefaultGasProviderConfig & {
direction: Direction;
enableLineaEstimateGas: boolean;
};
export interface IGasProvider<TransactionRequest> {
getGasFees(transactionRequest?: TransactionRequest): Promise<GasFees | LineaGasFees>;
getMaxFeePerGas(): bigint;
}
export interface IEthereumGasProvider<TransactionRequest> extends IGasProvider<TransactionRequest> {
getGasFees(): Promise<GasFees>;
}
export interface ILineaGasProvider<TransactionRequest> extends IGasProvider<TransactionRequest> {
getGasFees(transactionRequest: TransactionRequest): Promise<LineaGasFees>;
}
export function isLineaGasFees(fees: GasFees | LineaGasFees): fees is LineaGasFees {
return "gasLimit" in fees;
}

View File

@@ -0,0 +1,7 @@
import { MessageSent } from "../../types/Events";
export interface IMessageRetriever<TransactionReceipt> {
getMessageByMessageHash(messageHash: string): Promise<MessageSent | null>;
getMessagesByTransactionHash(transactionHash: string): Promise<MessageSent[] | null>;
getTransactionReceiptByMessageHash(messageHash: string): Promise<TransactionReceipt | null>;
}

View File

@@ -0,0 +1,27 @@
import { MessageProps } from "../../../entities/Message";
import { OnChainMessageStatus } from "../../../enums/MessageEnums";
import { IMessageServiceContract } from "../../../services/contracts/IMessageServiceContract";
import { MessageSent } from "../../../types/Events";
import { FinalizationMessagingInfo, Proof } from "./IMerkleTreeService";
export interface ILineaRollupClient<Overrides, TransactionReceipt, TransactionResponse, ContractTransactionResponse>
extends IMessageServiceContract<Overrides, TransactionReceipt, TransactionResponse, ContractTransactionResponse> {
getFinalizationMessagingInfo(transactionHash: string): Promise<FinalizationMessagingInfo>;
getL2MessageHashesInBlockRange(fromBlock: number, toBlock: number): Promise<string[]>;
getMessageSiblings(messageHash: string, messageHashes: string[], treeDepth: number): string[];
getMessageProof(messageHash: string): Promise<Proof>;
getMessageStatusUsingMessageHash(messageHash: string, overrides: Overrides): Promise<OnChainMessageStatus>;
getMessageStatusUsingMerkleTree(messageHash: string, overrides: Overrides): Promise<OnChainMessageStatus>;
estimateClaimGas(
message: (MessageSent | MessageProps) & { feeRecipient?: string },
overrides?: Overrides,
): Promise<bigint>;
estimateClaimWithoutProofGas(
message: (MessageSent | MessageProps) & { feeRecipient?: string },
overrides: Overrides,
): Promise<bigint>;
claimWithoutProof(
message: (MessageSent | MessageProps) & { feeRecipient?: string },
overrides: Overrides,
): Promise<ContractTransactionResponse>;
}

View File

@@ -0,0 +1,38 @@
import { L2MessagingBlockAnchored, MessageClaimed, MessageSent } from "../../../types/Events";
export type MessageSentEventFilters = {
from?: string;
to?: string;
messageHash?: string;
};
export type L2MessagingBlockAnchoredFilters = {
l2Block: bigint;
};
export type MessageClaimedFilters = {
messageHash: string;
};
export interface ILineaRollupLogClient {
getMessageSentEvents(params: {
filters?: MessageSentEventFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageSent[]>;
getL2MessagingBlockAnchoredEvents(params: {
filters?: L2MessagingBlockAnchoredFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<L2MessagingBlockAnchored[]>;
getMessageClaimedEvents(params: {
filters?: MessageClaimedFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageClaimed[]>;
}

View File

@@ -0,0 +1,23 @@
export type BlockRange = {
startingBlock: number;
endBlock: number;
};
export type FinalizationMessagingInfo = {
l2MessagingBlocksRange: BlockRange;
l2MerkleRoots: string[];
treeDepth: number;
};
export type Proof = {
proof: string[];
root: string;
leafIndex: number;
};
export interface IMerkleTreeService {
getMessageProof(messageHash: string): Promise<Proof>;
getFinalizationMessagingInfo(transactionHash: string): Promise<FinalizationMessagingInfo>;
getL2MessageHashesInBlockRange(fromBlock: number, toBlock: number): Promise<string[]>;
getMessageSiblings(messageHash: string, messageHashes: string[], treeDepth: number): string[];
}

View File

@@ -0,0 +1,13 @@
import { IChainQuerier } from "../IChainQuerier";
export type BlockExtraData = {
version: number;
fixedCost: number;
variableCost: number;
ethGasPrice: number;
};
export interface IL2ChainQuerier<TransactionReceipt, Block, TransactionRequest, TransactionResponse, JsonRpcProvider>
extends IChainQuerier<TransactionReceipt, Block, TransactionRequest, TransactionResponse, JsonRpcProvider> {
getBlockExtraData(blockNumber: number | bigint | string): Promise<BlockExtraData | null>;
}

View File

@@ -0,0 +1,20 @@
import { MessageSent } from "sdk/src/core/types/Events";
import { MessageProps } from "../../../../core/entities/Message";
import { IMessageServiceContract } from "../../../services/contracts/IMessageServiceContract";
import { LineaGasFees } from "../IGasProvider";
export interface IL2MessageServiceClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
Signer,
> extends IMessageServiceContract<Overrides, TransactionReceipt, TransactionResponse, ContractTransactionResponse> {
encodeClaimMessageTransactionData(message: MessageProps & { feeRecipient?: string }): string;
estimateClaimGasFees(
message: (MessageSent | MessageProps) & { feeRecipient?: string },
overrides?: Overrides,
): Promise<LineaGasFees>;
getSigner(): Signer | undefined;
getContractAddress(): string;
}

View File

@@ -0,0 +1,31 @@
import { MessageSent, ServiceVersionMigrated } from "../../../types/Events";
export type MessageSentEventFilters = {
from?: string;
to?: string;
messageHash?: string;
};
export interface IL2MessageServiceLogClient {
getMessageSentEvents(params: {
filters?: MessageSentEventFilters;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageSent[]>;
getMessageSentEventsByMessageHash(params: {
messageHash: string;
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<MessageSent[]>;
getMessageSentEventsByBlockRange(fromBlock: number, toBlock: number): Promise<MessageSent[]>;
getServiceVersionMigratedEvents(param?: {
fromBlock?: number;
toBlock?: string | number;
fromBlockLogIndex?: number;
}): Promise<ServiceVersionMigrated[]>;
}

View File

@@ -0,0 +1,4 @@
export const ZERO_HASH = "0x0000000000000000000000000000000000000000000000000000000000000000";
export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
export const MINIMUM_MARGIN = 1.2;

View File

@@ -0,0 +1,25 @@
export const DEFAULT_MESSAGE_SUBMISSION_TIMEOUT = 300000;
export const DEFAULT_LISTENER_INTERVAL = 4000;
export const DEFAULT_DB_CLEANER_ENABLED = false;
export const DEFAULT_DB_CLEANING_INTERVAL = 43200000;
export const DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE = 14;
export const DEFAULT_MAX_BLOCKS_TO_FETCH_LOGS = 1000;
export const DEFAULT_MAX_FETCH_MESSAGES_FROM_DB = 1000;
export const DEFAULT_MAX_NONCE_DIFF = 10000;
export const DEFAULT_MAX_FEE_PER_GAS = 100000000000n;
export const DEFAULT_ENFORCE_MAX_GAS_FEE = false;
export const DEFAULT_GAS_ESTIMATION_PERCENTILE = 20;
export const DEFAULT_GAS_LIMIT = 0;
export const DEFAULT_LISTENER_BLOCK_CONFIRMATIONS = 4;
export const DEFAULT_PROFIT_MARGIN = 1.0;
export const DEFAULT_MAX_NUMBER_OF_RETRIES = 100;
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_TX_RETRIES = 20;
export const DEFAULT_L2_MESSAGE_TREE_DEPTH = 5;
export const DEFAULT_INITIAL_FROM_BLOCK = -1;
export const PROFIT_MARGIN_MULTIPLIER = 100;

View File

@@ -0,0 +1,5 @@
export const MESSAGE_SENT_EVENT_SIGNATURE = "0xe856c2b8bd4eb0027ce32eeaf595c21b0b6b4644b326e5b7bd80a1cf8db72e6c";
export const L2_MESSAGING_BLOCK_ANCHORED_EVENT_SIGNATURE =
"0x3c116827db9db3a30c1a25db8b0ee4bab9d2b223560209cfd839601b621c726d";
export const L2_MERKLE_TREE_ADDED_EVENT_SIGNATURE =
"0x300e6f978eee6a4b0bba78dd8400dc64fd5652dbfc868a2258e16d0977be222b";

View File

@@ -0,0 +1,4 @@
export * from "./common";
export * from "./blockchain";
export * from "./events";
export * from "./message";

View File

@@ -0,0 +1,3 @@
export const MESSAGE_UNKNOWN_STATUS = 0;
export const MESSAGE_ANCHORED_STATUS = 1;
export const MESSAGE_CLAIMED_STATUS = 2;

View File

@@ -0,0 +1,147 @@
import { Direction, MessageStatus } from "../enums/MessageEnums";
export type MessageProps = {
id?: number;
messageSender: string;
destination: string;
fee: bigint;
value: bigint;
messageNonce: bigint;
calldata: string;
messageHash: string;
contractAddress: string;
sentBlockNumber: number;
direction: Direction;
status: MessageStatus;
claimTxCreationDate?: Date;
claimTxGasLimit?: number;
claimTxMaxFeePerGas?: bigint;
claimTxMaxPriorityFeePerGas?: bigint;
claimTxNonce?: number;
claimTxHash?: string;
claimNumberOfRetry: number;
claimLastRetriedAt?: Date;
claimGasEstimationThreshold?: number;
compressedTransactionSize?: number;
createdAt?: Date;
updatedAt?: Date;
};
export type MessageWithProofProps = MessageProps & {
proof: string[];
leafIndex: number;
merkleRoot: string;
};
type EditableMessageProps = Omit<
MessageProps,
| "id"
| "messageSender"
| "destination"
| "fee"
| "value"
| "messageNonce"
| "calldata"
| "messageHash"
| "contractAddress"
| "sentBlockNumber"
| "direction"
| "createdAt"
| "updatedAt"
>;
export class Message {
public id?: number;
public messageSender: string;
public destination: string;
public fee: bigint;
public value: bigint;
public messageNonce: bigint;
public calldata: string;
public messageHash: string;
public contractAddress: string;
public sentBlockNumber: number;
public direction: Direction;
public status: MessageStatus;
public claimTxCreationDate?: Date;
public claimTxGasLimit?: number;
public claimTxMaxFeePerGas?: bigint;
public claimTxMaxPriorityFeePerGas?: bigint;
public claimTxNonce?: number;
public claimTxHash?: string;
public claimNumberOfRetry: number;
public claimLastRetriedAt?: Date;
public claimGasEstimationThreshold?: number;
public compressedTransactionSize?: number;
public createdAt?: Date;
public updatedAt?: Date;
constructor(props: MessageProps) {
this.id = props.id;
this.messageSender = props.messageSender;
this.destination = props.destination;
this.fee = props.fee;
this.value = props.value;
this.messageNonce = props.messageNonce;
this.calldata = props.calldata;
this.messageHash = props.messageHash;
this.contractAddress = props.contractAddress;
this.sentBlockNumber = props.sentBlockNumber;
this.direction = props.direction;
this.status = props.status;
this.claimTxCreationDate = props.claimTxCreationDate;
this.claimTxGasLimit = props.claimTxGasLimit;
this.claimTxMaxFeePerGas = props.claimTxMaxFeePerGas;
this.claimTxMaxPriorityFeePerGas = props.claimTxMaxPriorityFeePerGas;
this.claimTxNonce = props.claimTxNonce;
this.claimTxHash = props.claimTxHash;
this.claimNumberOfRetry = props.claimNumberOfRetry;
this.claimLastRetriedAt = props.claimLastRetriedAt;
this.claimGasEstimationThreshold = props.claimGasEstimationThreshold;
this.compressedTransactionSize = props.compressedTransactionSize;
this.createdAt = props.createdAt;
this.updatedAt = props.updatedAt;
}
public hasZeroFee(): boolean {
return this.fee === 0n;
}
public edit(newMessage: Partial<EditableMessageProps>) {
if (newMessage.status) this.status = newMessage.status;
if (newMessage.claimTxCreationDate) this.claimTxCreationDate = newMessage.claimTxCreationDate;
if (newMessage.claimTxGasLimit) this.claimTxGasLimit = newMessage.claimTxGasLimit;
if (newMessage.claimTxMaxFeePerGas) this.claimTxMaxFeePerGas = newMessage.claimTxMaxFeePerGas;
if (newMessage.claimTxMaxPriorityFeePerGas)
this.claimTxMaxPriorityFeePerGas = newMessage.claimTxMaxPriorityFeePerGas;
if (newMessage.claimTxNonce) this.claimTxNonce = newMessage.claimTxNonce;
if (newMessage.claimTxHash) this.claimTxHash = newMessage.claimTxHash;
if (newMessage.claimNumberOfRetry) this.claimNumberOfRetry = newMessage.claimNumberOfRetry;
if (newMessage.claimLastRetriedAt) this.claimLastRetriedAt = newMessage.claimLastRetriedAt;
if (newMessage.claimGasEstimationThreshold)
this.claimGasEstimationThreshold = newMessage.claimGasEstimationThreshold;
if (newMessage.compressedTransactionSize) this.compressedTransactionSize = newMessage.compressedTransactionSize;
this.updatedAt = new Date();
}
public toString(): string {
return `Message(messageSender=${this.messageSender}, destination=${this.destination}, fee=${
this.fee
}, value=${this.value}, messageNonce=${this.messageNonce}, calldata=${this.calldata}, messageHash=${
this.messageHash
}, contractAddress=${this.contractAddress}, sentBlockNumber=${this.sentBlockNumber}, direction=${
this.direction
}, status=${this.status}, claimTxCreationDate=${this.claimTxCreationDate?.toISOString()}, claimTxGasLimit=${
this.claimTxGasLimit
}, claimTxMaxFeePerGas=${this.claimTxMaxFeePerGas}, claimTxMaxPriorityFeePerGas=${
this.claimTxMaxPriorityFeePerGas
}, claimTxNonce=${this.claimTxNonce}, claimTransactionHash=${this.claimTxHash}, claimNumberOfRetry=${
this.claimNumberOfRetry
}, claimLastRetriedAt=${this.claimLastRetriedAt?.toISOString()}, claimGasEstimationThreshold=${
this.claimGasEstimationThreshold
}, compressedTransactionSize=${
this.compressedTransactionSize
}, createdAt=${this.createdAt?.toISOString()}, updatedAt=${this.updatedAt?.toISOString()})`;
}
}

View File

@@ -0,0 +1,7 @@
import { Message, MessageProps } from "./Message";
export class MessageFactory {
public static createMessage(params: MessageProps): Message {
return new Message(params);
}
}

View File

@@ -0,0 +1,10 @@
export enum DatabaseErrorType {
Read = "read",
Insert = "insert",
Update = "update",
Delete = "delete",
}
export enum DatabaseRepoName {
MessageRepository = "MessageRepository",
}

View File

@@ -0,0 +1,23 @@
export enum Direction {
L1_TO_L2 = "L1_TO_L2",
L2_TO_L1 = "L2_TO_L1",
}
export enum MessageStatus {
SENT = "SENT",
TRANSACTION_SIZE_COMPUTED = "TRANSACTION_SIZE_COMPUTED",
ANCHORED = "ANCHORED",
PENDING = "PENDING",
CLAIMED_SUCCESS = "CLAIMED_SUCCESS",
CLAIMED_REVERTED = "CLAIMED_REVERTED",
NON_EXECUTABLE = "NON_EXECUTABLE",
ZERO_FEE = "ZERO_FEE",
FEE_UNDERPRICED = "FEE_UNDERPRICED",
EXCLUDED = "EXCLUDED",
}
export enum OnChainMessageStatus {
UNKNOWN = "UNKNOWN",
CLAIMABLE = "CLAIMABLE",
CLAIMED = "CLAIMED",
}

View File

@@ -0,0 +1,11 @@
export class BaseError extends Error {
reason?: BaseError | Error | string;
override name = "LineaSDKCoreError";
constructor(message?: string) {
super();
this.message = message || "An error occurred.";
Error.captureStackTrace(this, this.constructor);
}
}

View File

@@ -0,0 +1,13 @@
import { MessageProps } from "../entities/Message";
import { DatabaseErrorType, DatabaseRepoName } from "../enums/DatabaseEnums";
import { BaseError } from "./Base";
export class DatabaseAccessError<T extends MessageProps> extends BaseError {
override name = DatabaseAccessError.name;
public rejectedMessage?: T;
constructor(name: DatabaseRepoName, type: DatabaseErrorType, e: Error, rejectedMessage?: T) {
super(`${name}: ${type} - ${e.message}`);
this.rejectedMessage = rejectedMessage;
}
}

View File

@@ -0,0 +1,16 @@
import { MessageProps } from "../../core/entities/Message";
import { BaseError } from "./Base";
export class FeeEstimationError extends BaseError {
override name = FeeEstimationError.name;
}
export class GasEstimationError<T extends MessageProps> extends BaseError {
override name = GasEstimationError.name;
public rejectedMessage?: T;
constructor(message: string, rejectedMessage?: T) {
super(message);
this.rejectedMessage = rejectedMessage;
}
}

View File

@@ -0,0 +1,14 @@
import { describe, it } from "@jest/globals";
import { BaseError } from "../Base";
import { serialize } from "../../utils/serialize";
describe("BaseError", () => {
it("Should log error message when we only pass a short message", () => {
expect(serialize(new BaseError("An error message."))).toStrictEqual(
serialize({
name: "LineaSDKCoreError",
message: "An error message.",
}),
);
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it } from "@jest/globals";
import { DatabaseAccessError } from "../DatabaseErrors";
import { DatabaseErrorType, DatabaseRepoName } from "../../enums/DatabaseEnums";
import { MessageProps } from "../../entities/Message";
import { Direction, MessageStatus } from "../../enums/MessageEnums";
import { serialize } from "../../utils/serialize";
import { ZERO_ADDRESS, ZERO_HASH } from "../../constants";
describe("DatabaseAccessError", () => {
it("Should log error message and reason when we pass a short message and the error", () => {
const error = new DatabaseAccessError(
DatabaseRepoName.MessageRepository,
DatabaseErrorType.Read,
new Error("read database error."),
);
expect(serialize(error)).toStrictEqual(
serialize({
name: "DatabaseAccessError",
message: "MessageRepository: read - read database error.",
}),
);
});
it("Should log full error when we pass a short message, the error and the rejectedMessage", () => {
const rejectedMessage: MessageProps = {
messageHash: ZERO_HASH,
messageSender: ZERO_ADDRESS,
destination: ZERO_ADDRESS,
fee: 0n,
value: 0n,
messageNonce: 0n,
calldata: "0x",
contractAddress: ZERO_ADDRESS,
sentBlockNumber: 0,
direction: Direction.L1_TO_L2,
status: MessageStatus.SENT,
claimNumberOfRetry: 0,
};
const error = new DatabaseAccessError(
DatabaseRepoName.MessageRepository,
DatabaseErrorType.Read,
new Error("read database error."),
rejectedMessage,
);
expect(serialize(error)).toStrictEqual(
serialize({
name: "DatabaseAccessError",
message: "MessageRepository: read - read database error.",
rejectedMessage: {
messageHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
messageSender: "0x0000000000000000000000000000000000000000",
destination: "0x0000000000000000000000000000000000000000",
fee: 0n,
value: 0n,
messageNonce: 0n,
calldata: "0x",
contractAddress: "0x0000000000000000000000000000000000000000",
sentBlockNumber: 0,
direction: "L1_TO_L2",
status: "SENT",
claimNumberOfRetry: 0,
},
}),
);
});
});

View File

@@ -0,0 +1,61 @@
import { describe, it } from "@jest/globals";
import { FeeEstimationError, GasEstimationError } from "../GasFeeErrors";
import { serialize } from "../../utils/serialize";
import { Direction, MessageStatus } from "../../enums/MessageEnums";
import { MessageProps } from "../../entities/Message";
import { ZERO_ADDRESS, ZERO_HASH } from "../../constants";
describe("BaseError", () => {
describe("FeeEstimationError", () => {
it("Should log error message", () => {
expect(serialize(new FeeEstimationError("An error message."))).toStrictEqual(
serialize({
name: "FeeEstimationError",
message: "An error message.",
}),
);
});
});
describe("GasEstimationError", () => {
it("Should log error message", () => {
const rejectedMessage: MessageProps = {
messageHash: ZERO_HASH,
messageSender: ZERO_ADDRESS,
destination: ZERO_ADDRESS,
fee: 0n,
value: 0n,
messageNonce: 0n,
calldata: "0x",
contractAddress: ZERO_ADDRESS,
sentBlockNumber: 0,
direction: Direction.L1_TO_L2,
status: MessageStatus.SENT,
claimNumberOfRetry: 0,
};
const estimationError = new Error("estimation error");
expect(serialize(new GasEstimationError(estimationError.message, rejectedMessage))).toStrictEqual(
serialize({
name: "GasEstimationError",
message: "estimation error",
rejectedMessage: {
messageHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
messageSender: "0x0000000000000000000000000000000000000000",
destination: "0x0000000000000000000000000000000000000000",
fee: 0n,
value: 0n,
messageNonce: 0n,
calldata: "0x",
contractAddress: "0x0000000000000000000000000000000000000000",
sentBlockNumber: 0,
direction: "L1_TO_L2",
status: "SENT",
claimNumberOfRetry: 0,
},
}),
);
});
});
});

View File

@@ -0,0 +1,3 @@
export interface IDatabaseCleaner {
databaseCleanerRoutine(msBeforeNowToDelete: number): Promise<void>;
}

View File

@@ -0,0 +1,30 @@
import { Message } from "../entities/Message";
import { Direction, MessageStatus } from "../enums/MessageEnums";
export interface IMessageDBService<TransactionResponse> {
insertMessage(message: Message): Promise<void>;
saveMessages(messages: Message[]): Promise<void>;
updateMessage(message: Message): Promise<void>;
deleteMessages(msBeforeNowToDelete: number): Promise<number>;
getFirstPendingMessage(direction: Direction): Promise<Message | null>;
getLatestMessageSent(direction: Direction, contractAddress: string): Promise<Message | null>;
getNFirstMessagesSent(limit: number, contractAddress: string): Promise<Message[]>;
getNFirstMessagesByStatus(
status: MessageStatus,
direction: Direction,
limit: number,
contractAddress: string,
): Promise<Message[]>;
getMessageToClaim(
contractAddress: string,
gasEstimationMargin: number,
maxRetry: number,
retryDelay: number,
): Promise<Message | null>;
getLastClaimTxNonce(direction: Direction): Promise<number | null>;
updateMessageWithClaimTxAtomic(
message: Message,
nonce: number,
claimTxResponsePromise: Promise<TransactionResponse>,
): Promise<void>;
}

View File

@@ -0,0 +1,45 @@
import { Message } from "../entities/Message";
import { Direction, MessageStatus } from "../enums/MessageEnums";
export interface IMessageRepository<ContractTransactionResponse> {
insertMessage(message: Message): Promise<void>;
updateMessage(message: Message): Promise<void>;
updateMessageByTransactionHash(transactionHash: string, direction: Direction, message: Message): Promise<void>;
saveMessages(messages: Message[]): Promise<void>;
deleteMessages(msBeforeNowToDelete: number): Promise<number>;
getFirstMessageToClaimOnL1(
direction: Direction,
contractAddress: string,
currentGasPrice: bigint,
gasEstimationMargin: number,
maxRetry: number,
retryDelay: number,
): Promise<Message | null>;
getFirstMessageToClaimOnL2(
direction: Direction,
contractAddress: string,
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(
status: MessageStatus,
direction: Direction,
limit: number,
contractAddress: string,
): Promise<Message[]>;
getMessageSent(direction: Direction, contractAddress: string): Promise<Message | null>;
getLastClaimTxNonce(direction: Direction): Promise<number | null>;
getFirstPendingMessage(direction: Direction): Promise<Message | null>;
updateMessageWithClaimTxAtomic(
message: Message,
nonce: number,
claimTxResponsePromise: Promise<ContractTransactionResponse>,
): Promise<void>;
}

View File

@@ -0,0 +1,21 @@
import { Message } from "../entities/Message";
export interface ITransactionValidationService {
evaluateTransaction(
message: Message,
feeRecipient?: string,
): Promise<{
hasZeroFee: boolean;
isUnderPriced: boolean;
isRateLimitExceeded: boolean;
estimatedGasLimit: bigint | null;
threshold: number;
maxPriorityFeePerGas: bigint;
maxFeePerGas: bigint;
}>;
}
export type TransactionValidationServiceConfig = {
profitMargin: number;
maxClaimGasLimit: bigint;
};

View File

@@ -0,0 +1,22 @@
import { OnChainMessageStatus } from "../../../core/enums/MessageEnums";
import { MessageProps } from "../../entities/Message";
import { MessageSent } from "../../types/Events";
export interface IMessageServiceContract<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
> {
getMessageStatus(messageHash: string, overrides?: Overrides): Promise<OnChainMessageStatus>;
getMessageByMessageHash(messageHash: string): Promise<MessageSent | null>;
getMessagesByTransactionHash(transactionHash: string): Promise<MessageSent[] | null>;
getTransactionReceiptByMessageHash(messageHash: string): Promise<TransactionReceipt | null>;
claim(
message: (MessageSent | MessageProps) & { feeRecipient?: string },
overrides?: Overrides,
): Promise<ContractTransactionResponse>;
retryTransactionWithHigherFee(transactionHash: string, priceBumpPercent?: number): Promise<TransactionResponse>;
isRateLimitExceeded(messageFee: bigint, messageValue: bigint): Promise<boolean>;
isRateLimitExceededError(transactionHash: string): Promise<boolean>;
}

View File

@@ -0,0 +1,4 @@
export interface IPoller {
start(): Promise<void>;
stop(): void;
}

View File

@@ -0,0 +1,10 @@
import { Direction } from "../../enums/MessageEnums";
export interface IL2ClaimMessageTransactionSizeProcessor {
process(): Promise<void>;
}
export type L2ClaimMessageTransactionSizeProcessorConfig = {
direction: Direction;
originContractAddress: string;
};

View File

@@ -0,0 +1,6 @@
import { LineaGasFees } from "../../clients/blockchain/IGasProvider";
import { MessageProps } from "../../entities/Message";
export interface IL2ClaimTransactionSizeCalculator {
calculateTransactionSize(message: MessageProps & { feeRecipient?: string }, fees: LineaGasFees): Promise<number>;
}

View File

@@ -0,0 +1,8 @@
export interface IMessageAnchoringProcessor {
process(): Promise<void>;
}
export type MessageAnchoringProcessorConfig = {
maxFetchMessagesFromDb: number;
originContractAddress: string;
};

View File

@@ -0,0 +1,11 @@
import { Direction } from "../../enums/MessageEnums";
export interface IMessageClaimingPersister {
process(): Promise<void>;
}
export type MessageClaimingPersisterConfig = {
direction: Direction;
messageSubmissionTimeout: number;
maxTxRetries: number;
};

Some files were not shown because too many files have changed in this diff Show More