Feat/272 split sdk and postman (#381)

* feat: split postman and sdk

* fix: update postman client and sendMessage script

* fix: clean the sdk

* fix: update sdk dependencies

* fix: remove .env.sample file

* fix: remove testing helpers from the build

* fix: update gas provider in linea sdk and update postman client

* fix: update postman dependencies

* fix: update postman dockerfile and fix tests imports and types

* fix: remove unused code in the sdk + move typechain folder

* fix: remove unused code + fix imports in postman

* fix: pnpm lock file issue

* fix: import issue

* fix: case sensitive file issue

* fix: update sdk fees options and update exports

* fix: remove postman unused code and adjust imports and tests

* fix: update contracts abis + clean error parsing

* fix: update postman based on new SDk changes

* add readme + remove unused interface in postman

* fix: rename Base.ts file to BaseError.ts

* fix: rename Base.ts file to BaseError.ts in postman

* chore: update readme for the postman

* fix: rename maxFeePerGas to maxFeePerGasCap

* fix: update DefaultGasProvider fees check

* fix: default gas provider test issue

* fix: update main ci filter

* fix: issue in default gas provider
This commit is contained in:
Victorien Gauch
2024-12-09 12:12:45 +01:00
committed by GitHub
parent 4e93fa3a10
commit f58c12a455
217 changed files with 7157 additions and 7230 deletions

44
postman/.env.sample Normal file
View File

@@ -0,0 +1,44 @@
L1_RPC_URL=http://localhost:8445
L1_CONTRACT_ADDRESS=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9
# Only use the following PRIVATE KEY for testing
L1_SIGNER_PRIVATE_KEY=0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba
L1_LISTENER_INTERVAL=2000
# L1_LISTENER_INITIAL_FROM_BLOCK=0
L1_LISTENER_BLOCK_CONFIRMATION=1
L1_MAX_BLOCKS_TO_FETCH_LOGS=1000
L1_MAX_GAS_FEE_ENFORCED=false
L2_RPC_URL=http://localhost:8845
L2_CONTRACT_ADDRESS=0xe537D669CA013d86EBeF1D64e40fC74CADC91987
# Only use the following PRIVATE KEY for testing
L2_SIGNER_PRIVATE_KEY=0xfcf854e0a0bc6fd7e97d7050e61a362c915cecd6767a32267b22e8b7af572e58
L2_LISTENER_INTERVAL=2000
# 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_CAP=100000000000
GAS_ESTIMATION_PERCENTILE=50
PROFIT_MARGIN=0.0
MAX_NUMBER_OF_RETRIES=100
RETRY_DELAY_IN_SECONDS=30
MAX_CLAIM_GAS_LIMIT=2560000
MAX_TX_RETRIES=20
L1_L2_EOA_ENABLED=true
L1_L2_CALLDATA_ENABLED=true
L1_L2_AUTO_CLAIM_ENABLED=true
L2_L1_EOA_ENABLED=true
L2_L1_CALLDATA_ENABLED=true
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=false
DB_CLEANING_INTERVAL=10000
DB_DAYS_BEFORE_NOW_TO_DELETE=1
ENABLE_LINEA_ESTIMATE_GAS=false

3
postman/.eslintignore Normal file
View File

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

15
postman/.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
postman/.prettierignore Normal file
View File

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

3
postman/.prettierrc.js Normal file
View File

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

45
postman/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
FROM node:lts-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 ca-certificates bash curl make g++ \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /usr/src/app
ARG NATIVE_LIBS_RELEASE_TAG
ENV NATIVE_LIBS_RELEASE_TAG=${NATIVE_LIBS_RELEASE_TAG}
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./
COPY ./postman/package.json ./postman/package.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 pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
COPY ./postman ./postman
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=./postman --prod ./prod/postman
FROM node:lts-slim AS production
ENV NODE_ENV=production
WORKDIR /usr/src/app
USER node
COPY --from=builder /usr/src/app/prod/postman ./postman
CMD [ "node", "./postman/dist/scripts/runPostman.js" ]

13
postman/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.

115
postman/README.md Normal file
View File

@@ -0,0 +1,115 @@
# Linea Postman Service
The Linea Postman service is a component of the Linea blockchain infrastructure that facilitates cross-chain message delivery between Layer 1 (Ethereum) and Layer 2 (Linea).
## Overview
The Postman service monitors and processes messages between L1 and L2 chains, handling message submission, verification, and claiming. It operates as a Docker container and integrates with both L1 and L2 nodes.
It offers the following key features:
- Feature 1: Listening for message sent events on Ethereum and Linea
- 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.
## Configuration
### Environment Variables
#### L1 Configuration
- `L1_RPC_URL`: Ethereum node RPC endpoint
- `L1_CONTRACT_ADDRESS`: Address of the LineaRollup contract on L1
- `L1_SIGNER_PRIVATE_KEY`: Private key for L1 transactions
- `L1_LISTENER_INTERVAL`: Block listening interval (ms)
- `L1_LISTENER_INITIAL_FROM_BLOCK`: Starting block for event listening (optional)
- `L1_LISTENER_BLOCK_CONFIRMATION`: Required block confirmations
- `L1_MAX_BLOCKS_TO_FETCH_LOGS`: Maximum blocks to fetch in one request
- `L1_MAX_GAS_FEE_ENFORCED`: Enable/disable gas fee enforcement
#### L2 Configuration
- `L2_RPC_URL`: Linea node RPC endpoint
- `L2_CONTRACT_ADDRESS`: Address of the L2MessageService contract on L2
- `L2_SIGNER_PRIVATE_KEY`: Private key for L2 transactions
- `L2_LISTENER_INTERVAL`: Block listening interval (ms)
- `L2_LISTENER_INITIAL_FROM_BLOCK`: Starting block for event listening (optional)
- `L2_LISTENER_BLOCK_CONFIRMATION`: Required block confirmations
- `L2_MAX_BLOCKS_TO_FETCH_LOGS`: Maximum blocks to fetch in one request
- `L2_MAX_GAS_FEE_ENFORCED`: Enable/disable gas fee enforcement
- `L2_MESSAGE_TREE_DEPTH`: Depth of the message Merkle tree
#### Message Processing
- `MESSAGE_SUBMISSION_TIMEOUT`: Timeout for message submission (ms)
- `MAX_FETCH_MESSAGES_FROM_DB`: Maximum messages to fetch from database
- `MAX_NONCE_DIFF`: Maximum allowed nonce difference between the DB and the chain
- `MAX_FEE_PER_GAS_CAP`: Maximum gas fee cap
- `GAS_ESTIMATION_PERCENTILE`: Gas estimation percentile
- `PROFIT_MARGIN`: Profit margin for gas fees
- `MAX_NUMBER_OF_RETRIES`: Maximum retry attempts
- `RETRY_DELAY_IN_SECONDS`: Delay between retries
- `MAX_CLAIM_GAS_LIMIT`: Maximum gas limit for claim transactions
#### Feature Flags
- `L1_L2_EOA_ENABLED`: Enable L1->L2 EOA messages
- `L1_L2_CALLDATA_ENABLED`: Enable L1->L2 calldata messages
- `L1_L2_AUTO_CLAIM_ENABLED`: Enable auto-claiming for L1->L2 messages
- `L2_L1_EOA_ENABLED`: Enable L2->L1 EOA messages
- `L2_L1_CALLDATA_ENABLED`: Enable L2->L1 calldata messages
- `L2_L1_AUTO_CLAIM_ENABLED`: Enable auto-claiming for L2->L1 messages
- `ENABLE_LINEA_ESTIMATE_GAS`: Enable `linea_estimateGas`endpoint usage for L2 chain gas fees estimation
- `DB_CLEANER_ENABLED`: Enable DB cleaning to delete old claimed messages
#### DB cleaning
- `DB_CLEANING_INTERVAL`: DB cleaning polling interval (ms)
- `DB_DAYS_BEFORE_NOW_TO_DELETE`: Number of days to retain messages in the database before deletion. Messages older than this number of days will be automatically cleaned up if they are in a final state (CLAIMED_SUCCESS, CLAIMED_REVERTED, EXCLUDED, or ZERO_FEE)
#### Database Configuration
- `POSTGRES_HOST`: PostgreSQL host
- `POSTGRES_PORT`: PostgreSQL port
- `POSTGRES_USER`: Database user
- `POSTGRES_PASSWORD`: Database password
- `POSTGRES_DB`: Database name
## Development
### Running
#### Start the docker local stack
From the root folder, run the following command:
```bash
make fresh-start-all
```
Stop the postman docker container manually.
#### Run the postman locally:
From the postman folder run the following commands:
```bash
# Create a new .env file
cp .env.sample .env
# Run the postman
ts-node scripts/runPostman.ts
```
### Building
```bash
# Build the Postman service
pnpm run build
```
### Testing
```bash
# Run unit tests
pnpm run test
```
## License
This package is licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for more information.

25
postman/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/",
],
};

44
postman/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "@consensys/linea-postman",
"version": "1.0.0",
"author": "Consensys Software Inc.",
"license": "Apache-2.0",
"description": "",
"main": "dist/src/index.js",
"types": "dist/src/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 node_modules coverage tsconfig.build.tsbuildinfo",
"build": "tsc -p tsconfig.build.json",
"build:runSdk": "tsc ./scripts/runSdk.ts",
"test": "npx jest --bail --detectOpenHandles --forceExit",
"lint:fix": "pnpm run lint:ts:fix && pnpm run prettier:fix"
},
"dependencies": {
"@consensys/linea-native-libs": "workspace:*",
"@consensys/linea-sdk": "workspace:*",
"better-sqlite3": "11.6.0",
"class-validator": "0.14.1",
"dotenv": "16.4.5",
"ethers": "6.13.4",
"pg": "8.13.1",
"typeorm": "0.3.20",
"typeorm-naming-strategies": "4.1.0",
"winston": "3.17.0"
},
"devDependencies": {
"@jest/globals": "29.7.0",
"@types/jest": "29.5.14",
"@types/yargs": "17.0.33",
"jest": "29.7.0",
"jest-mock-extended": "3.0.5",
"ts-jest": "29.2.5",
"yargs": "17.7.2"
},
"files": [
"dist/**/*"
]
}

32
postman/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,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);
}

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,
maxFeePerGasCap: process.env.MAX_FEE_PER_GAS_CAP ? BigInt(process.env.MAX_FEE_PER_GAS_CAP) : 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,
maxFeePerGasCap: process.env.MAX_FEE_PER_GAS_CAP ? BigInt(process.env.MAX_FEE_PER_GAS_CAP) : 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);
});

View File

@@ -0,0 +1,191 @@
import { BytesLike, ContractTransactionReceipt, Overrides, Wallet, JsonRpcProvider } from "ethers";
import { config } from "dotenv";
import { L2MessageService, L2MessageService__factory, LineaRollup, LineaRollup__factory } from "@consensys/linea-sdk";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { SendMessageArgs } from "./types";
import { sanitizeAddress, sanitizePrivKey } from "./cli";
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: LineaRollup,
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: LineaRollup,
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 = LineaRollup__factory.connect(contractAddress, signer) as LineaRollup;
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 lineaRollup = LineaRollup__factory.connect(args.l1ContractAddress, l1Signer) as LineaRollup;
await sendMessages(lineaRollup, l1Signer, args.numberOfMessage, functionArgs, {
value: BigInt(args.value.toString()),
});
// Anchor messages hash on L2
if (!args.autoAnchoring) return;
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);
}
const l2Signer = new Wallet(args.l2PrivKey!, l2Provider);
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 { L2MessageService, L2MessageService__factory } from "@consensys/linea-sdk";
import { SendMessageArgs } from "./types";
import { sanitizeAddress, sanitizePrivKey } from "./cli";
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
postman/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,415 @@
import { DataSource } from "typeorm";
import { LineaSDK, Direction } from "@consensys/linea-sdk";
import { ILogger } from "../../../core/utils/logging/ILogger";
import { TypeOrmMessageRepository } from "../persistence/repositories/TypeOrmMessageRepository";
import { WinstonLogger } from "../../../utils/WinstonLogger";
import { IPoller } from "../../../core/services/pollers/IPoller";
import {
MessageAnchoringProcessor,
MessageClaimingProcessor,
MessageClaimingPersister,
MessageSentEventProcessor,
L2ClaimMessageTransactionSizeProcessor,
} from "../../../services/processors";
import { PostmanOptions } from "./config/config";
import { DB } from "../persistence/dataSource";
import {
MessageSentEventPoller,
MessageAnchoringPoller,
MessageClaimingPoller,
MessagePersistingPoller,
DatabaseCleaningPoller,
L2ClaimMessageTransactionSizePoller,
} from "../../../services/pollers";
import { DatabaseCleaner, LineaMessageDBService, EthereumMessageDBService } from "../../../services/persistence";
import { L2ClaimTransactionSizeCalculator } from "../../../services/L2ClaimTransactionSizeCalculator";
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 lineaSdk = new LineaSDK({
l1RpcUrlOrProvider: config.l1Config.rpcUrl,
l2RpcUrlOrProvider: config.l2Config.rpcUrl,
l1SignerPrivateKeyOrWallet: config.l1Config.claiming.signerPrivateKey,
l2SignerPrivateKeyOrWallet: config.l2Config.claiming.signerPrivateKey,
network: "custom",
mode: "read-write",
l1FeeEstimatorOptions: {
gasFeeEstimationPercentile: config.l1Config.claiming.gasEstimationPercentile,
maxFeePerGasCap: config.l1Config.claiming.maxFeePerGasCap,
enforceMaxGasFee: config.l1Config.claiming.isMaxGasFeeEnforced,
},
l2FeeEstimatorOptions: {
gasFeeEstimationPercentile: config.l2Config.claiming.gasEstimationPercentile,
maxFeePerGasCap: config.l2Config.claiming.maxFeePerGasCap,
enforceMaxGasFee: config.l2Config.claiming.isMaxGasFeeEnforced,
enableLineaEstimateGas: config.l2Config.enableLineaEstimateGas,
},
});
const l1Provider = lineaSdk.getL1Provider(config.l1Config.rpcUrl);
const l2Provider = lineaSdk.getL2Provider(config.l2Config.rpcUrl);
const l1Signer = lineaSdk.getL1Signer();
const l2Signer = lineaSdk.getL2Signer();
const lineaRollupClient = lineaSdk.getL1Contract(
config.l1Config.messageServiceContractAddress,
config.l2Config.messageServiceContractAddress,
);
const l2MessageServiceClient = lineaSdk.getL2Contract(config.l2Config.messageServiceContractAddress);
const lineaRollupLogClient = lineaSdk.getL1ContractEventLogClient(config.l1Config.messageServiceContractAddress);
const l2MessageServiceLogClient = lineaSdk.getL2ContractEventLogClient(
config.l2Config.messageServiceContractAddress,
);
const l1GasProvider = lineaSdk.getL1GasProvider();
this.db = DB.create(config.databaseOptions);
const messageRepository = new TypeOrmMessageRepository(this.db);
const lineaMessageDBService = new LineaMessageDBService(l2Provider, messageRepository);
const ethereumMessageDBService = new EthereumMessageDBService(l1GasProvider, messageRepository);
// L1 -> L2 flow
const l1MessageSentEventProcessor = new MessageSentEventProcessor(
lineaMessageDBService,
lineaRollupLogClient,
l1Provider,
{
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,
l1Provider,
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(
l2MessageServiceClient,
l2Provider,
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),
},
l2Provider,
l2MessageServiceClient,
);
const l2MessageClaimingProcessor = new MessageClaimingProcessor(
l2MessageServiceClient,
l2Signer,
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,
l2MessageServiceClient,
l2Provider,
{
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(l2MessageServiceClient);
const transactionSizeCompressor = new L2ClaimMessageTransactionSizeProcessor(
lineaMessageDBService,
l2MessageServiceClient,
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,
l2Provider,
{
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,
l2Provider,
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(
lineaRollupClient,
l1Provider,
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(lineaRollupClient, l1GasProvider, {
profitMargin: config.l1Config.claiming.profitMargin,
maxClaimGasLimit: BigInt(config.l1Config.claiming.maxClaimGasLimit),
});
const l1MessageClaimingProcessor = new MessageClaimingProcessor(
lineaRollupClient,
l1Signer,
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,
lineaRollupClient,
l1Provider,
{
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,
},
);
}
/**
* 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,202 @@
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,
maxFeePerGasCap: 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,
maxFeePerGasCap: 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: "0x",
},
},
l2Options: {
...postmanServiceClientOptions.l2Options,
claiming: {
...postmanServiceClientOptions.l2Options.claiming,
signerPrivateKey: "0x",
},
},
};
expect(() => new PostmanServiceClient(postmanServiceClientOptionsWithInvalidPrivateKey)).toThrow(
new Error("Something went wrong when trying to generate Wallet. Please check your private key."),
);
});
});
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_CAP,
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,
maxFeePerGasCap: DEFAULT_MAX_FEE_PER_GAS_CAP,
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,
maxFeePerGasCap: DEFAULT_MAX_FEE_PER_GAS_CAP,
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,
maxFeePerGasCap: DEFAULT_MAX_FEE_PER_GAS_CAP,
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,
maxFeePerGasCap: DEFAULT_MAX_FEE_PER_GAS_CAP,
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;
maxFeePerGasCap?: 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_CAP,
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,
maxFeePerGasCap: l1Options.claiming.maxFeePerGasCap ?? DEFAULT_MAX_FEE_PER_GAS_CAP,
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,
maxFeePerGasCap: l2Options.claiming.maxFeePerGasCap ?? DEFAULT_MAX_FEE_PER_GAS_CAP,
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,96 @@
import { IsDate, IsDecimal, IsEnum, IsNumber, IsString } from "class-validator";
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from "typeorm";
import { Direction } from "@consensys/linea-sdk";
import { MessageStatus } from "../../../../core/enums";
@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,80 @@
import { describe, it, expect } from "@jest/globals";
import { Direction } from "@consensys/linea-sdk";
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 { MessageStatus } from "../../../../../core/enums";
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 { ContractTransactionResponse } from "ethers";
import { Direction } from "@consensys/linea-sdk";
import { Message } from "../../../../core/entities/Message";
import { mapMessageEntityToMessage, mapMessageToMessageEntity } from "../mappers/messageMappers";
import { DatabaseErrorType, DatabaseRepoName, MessageStatus } from "../../../../core/enums";
import { DatabaseAccessError } from "../../../../core/errors";
import { MessageEntity } from "../entities/Message.entity";
import { subtractSeconds } from "../../../../core/utils/shared";
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,56 @@
import { Direction } from "@consensys/linea-sdk";
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 = {
maxFeePerGasCap: 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,16 @@
import { GasFees } from "./IGasProvider";
export interface IProvider<TransactionReceipt, Block, TransactionRequest, TransactionResponse, Provider> {
getTransactionCount(address: string, blockTag: string | number | bigint): Promise<number>;
getBlockNumber(): 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
send(methodName: string, params: Array<any> | Record<string, any>): Promise<any>;
estimateGas(transactionRequest: TransactionRequest): Promise<bigint>;
getTransaction(transactionHash: string): Promise<TransactionResponse | null>;
broadcastTransaction(signedTx: string): Promise<TransactionResponse>;
call(transactionRequest: TransactionRequest): Promise<string>;
getFees(): Promise<GasFees>;
get provider(): Provider;
}

View File

@@ -0,0 +1,37 @@
import { MessageProps } from "../../../entities/Message";
import { MessageSent, OnChainMessageStatus } from "@consensys/linea-sdk";
import { IMessageServiceContract } from "../../../services/contracts/IMessageServiceContract";
import { FinalizationMessagingInfo, Proof } from "./IMerkleTreeService";
export interface ILineaRollupClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription,
> extends IMessageServiceContract<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription
> {
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 "@consensys/linea-sdk";
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,27 @@
import { MessageSent } from "@consensys/linea-sdk";
import { MessageProps } from "../../../entities/Message";
import { IMessageServiceContract } from "../../../services/contracts/IMessageServiceContract";
import { LineaGasFees } from "../IGasProvider";
export interface IL2MessageServiceClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
Signer,
ErrorDescription,
> extends IMessageServiceContract<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription
> {
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 "@consensys/linea-sdk";
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,13 @@
import { IProvider } from "../IProvider";
export type BlockExtraData = {
version: number;
fixedCost: number;
variableCost: number;
ethGasPrice: number;
};
export interface ILineaProvider<TransactionReceipt, Block, TransactionRequest, TransactionResponse, Provider>
extends IProvider<TransactionReceipt, Block, TransactionRequest, TransactionResponse, Provider> {
getBlockExtraData(blockNumber: number | bigint | string): Promise<BlockExtraData | null>;
}

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_CAP = 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,2 @@
export * from "./common";
export * from "./blockchain";

View File

@@ -0,0 +1,148 @@
import { Direction } from "@consensys/linea-sdk";
import { MessageStatus } from "../enums";
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,12 @@
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",
}

View File

@@ -0,0 +1,2 @@
export { DatabaseErrorType, DatabaseRepoName } from "./DatabaseEnums";
export { MessageStatus } from "./MessageEnums";

View File

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

View File

@@ -0,0 +1,13 @@
import { BaseError } from "./BaseError";
import { MessageProps } from "../entities/Message";
import { DatabaseErrorType, DatabaseRepoName } from "../enums";
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,14 @@
import { describe, it } from "@jest/globals";
import { serialize } from "@consensys/linea-sdk";
import { BaseError } from "../BaseError";
describe("BaseError", () => {
it("Should log error message when we only pass a short message", () => {
expect(serialize(new BaseError("An error message."))).toStrictEqual(
serialize({
name: "PostmanCoreError",
message: "An error message.",
}),
);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from "@jest/globals";
import { Direction, serialize } from "@consensys/linea-sdk";
import { DatabaseAccessError } from "../DatabaseErrors";
import { DatabaseErrorType, DatabaseRepoName, MessageStatus } from "../../enums";
import { MessageProps } from "../../entities/Message";
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,2 @@
export { BaseError } from "./BaseError";
export { DatabaseAccessError } from "./DatabaseErrors";

View File

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

View File

@@ -0,0 +1,31 @@
import { Direction } from "@consensys/linea-sdk";
import { Message } from "../entities/Message";
import { MessageStatus } from "../enums";
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,46 @@
import { Direction } from "@consensys/linea-sdk";
import { Message } from "../entities/Message";
import { MessageStatus } from "../enums";
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,23 @@
import { OnChainMessageStatus, MessageSent } from "@consensys/linea-sdk";
import { MessageProps } from "../../entities/Message";
export interface IMessageServiceContract<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription,
> {
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>;
parseTransactionError(transactionHash: string): Promise<ErrorDescription | string>;
}

View File

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

View File

@@ -0,0 +1,10 @@
import { Direction } from "@consensys/linea-sdk";
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 "@consensys/linea-sdk";
export interface IMessageClaimingPersister {
process(): Promise<void>;
}
export type MessageClaimingPersisterConfig = {
direction: Direction;
messageSubmissionTimeout: number;
maxTxRetries: number;
};

View File

@@ -0,0 +1,16 @@
import { Direction } from "@consensys/linea-sdk";
export interface IMessageClaimingProcessor {
process(): Promise<void>;
}
export type MessageClaimingProcessorConfig = {
maxNonceDiff: number;
feeRecipientAddress?: string;
profitMargin: number;
maxNumberOfRetries: number;
retryDelayInSeconds: number;
maxClaimGasLimit: bigint;
direction: Direction;
originContractAddress: string;
};

View File

@@ -0,0 +1,15 @@
import { Direction } from "@consensys/linea-sdk";
export interface IMessageSentEventProcessor {
process(
fromBlock: number,
fromBlockLogIndex: number,
): Promise<{ nextFromBlock: number; nextFromBlockLogIndex: number }>;
}
export type MessageSentEventProcessorConfig = {
direction: Direction;
maxBlocksToFetchLogs: number;
blockConfirmation: number;
isEOAEnabled: boolean;
isCalldataEnabled: boolean;
};

View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from "@jest/globals";
import { subtractSeconds } from "../shared";
describe("Shared utils", () => {
describe("subtractSeconds", () => {
it("should substract X seconds to the current date", () => {
const currentDate = new Date("2024-04-08T00:12:10.000Z");
expect(subtractSeconds(currentDate, 10)).toStrictEqual(new Date("2024-04-08T00:12:00.000Z"));
});
});
});

View File

@@ -0,0 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface ILogger {
readonly name: string;
info(message: any, ...params: any[]): void;
error(message: any, ...params: any[]): void;
warn(message: any, ...params: any[]): void;
debug(message: any, ...params: any[]): void;
warnOrError(message: any, ...params: any[]): void;
}

View File

@@ -0,0 +1,12 @@
/**
* Subtracts a specified number of seconds from a given date.
*
* @param {Date} date - The original date.
* @param {number} seconds - The number of seconds to subtract from the date.
* @returns {Date} A new date object representing the time after subtracting the specified seconds.
*/
export const subtractSeconds = (date: Date, seconds: number): Date => {
const dateCopy = new Date(date);
dateCopy.setSeconds(date.getSeconds() - seconds);
return dateCopy;
};

View File

@@ -0,0 +1,146 @@
import {
ContractTransactionResponse,
ErrorDescription,
Overrides,
TransactionReceipt,
TransactionRequest,
TransactionResponse,
} from "ethers";
import { Message } from "../core/entities/Message";
import {
ITransactionValidationService,
TransactionValidationServiceConfig,
} from "../core/services/ITransactionValidationService";
import { PROFIT_MARGIN_MULTIPLIER } from "../core/constants";
import { ILineaRollupClient } from "../core/clients/blockchain/ethereum/ILineaRollupClient";
import { IEthereumGasProvider } from "../core/clients/blockchain/IGasProvider";
export class EthereumTransactionValidationService implements ITransactionValidationService {
/**
* Constructs a new instance of the `EthereumTransactionValidationService`.
*
* @param {ILineaRollupClient} lineaRollupClient - An instance of a class implementing the `ILineaRollupClient` interface, used to interact with the Linea Rollup client.
* @param {IEthereumGasProvider} gasProvider - An instance of a class implementing the `IEthereumGasProvider` interface, used to fetch gas fee estimates.
* @param {TransactionValidationServiceConfig} config - Configuration settings for the transaction validation service, including profit margin and maximum gas limit.
*/
constructor(
private readonly lineaRollupClient: ILineaRollupClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription
>,
private readonly gasProvider: IEthereumGasProvider<TransactionRequest>,
private readonly config: TransactionValidationServiceConfig,
) {}
/**
* Evaluates a transaction to determine its feasibility based on various factors such as gas estimation, profit margin, and rate limits.
*
* @param {Message} message - The message object to evaluate.
* @param {string} [feeRecipient] - The optional fee recipient address.
* @returns {Promise<{
* hasZeroFee: boolean;
* isUnderPriced: boolean;
* isRateLimitExceeded: boolean;
* estimatedGasLimit: bigint | null;
* threshold: number;
* maxPriorityFeePerGas: bigint;
* maxFeePerGas: bigint;
* }>} A promise that resolves to an object containing the evaluation results.
*/
public async evaluateTransaction(
message: Message,
feeRecipient?: string,
): Promise<{
hasZeroFee: boolean;
isUnderPriced: boolean;
isRateLimitExceeded: boolean;
estimatedGasLimit: bigint | null;
threshold: number;
maxPriorityFeePerGas: bigint;
maxFeePerGas: bigint;
}> {
const [gasLimit, { maxPriorityFeePerGas, maxFeePerGas }] = await Promise.all([
this.lineaRollupClient.estimateClaimGas({
...message,
feeRecipient,
}),
this.gasProvider.getGasFees(),
]);
const threshold = this.calculateGasEstimationThreshold(message.fee, gasLimit);
const estimatedGasLimit = this.getGasLimit(gasLimit);
const isUnderPriced = this.isUnderPriced(gasLimit, message.fee, maxFeePerGas);
const hasZeroFee = this.hasZeroFee(message);
const isRateLimitExceeded = await this.isRateLimitExceeded(message.fee, message.value);
return {
hasZeroFee,
isUnderPriced,
isRateLimitExceeded,
estimatedGasLimit,
threshold,
maxPriorityFeePerGas,
maxFeePerGas,
};
}
/**
* Determines if the transaction is underpriced based on the gas limit, message fee, and maximum fee per gas.
*
* @param {bigint} gasLimit - The gas limit for the transaction.
* @param {bigint} messageFee - The fee associated with the message.
* @param {bigint} maxFeePerGas - The maximum fee per gas for the transaction.
* @returns {boolean} `true` if the transaction is underpriced, `false` otherwise.
*/
private isUnderPriced(gasLimit: bigint, messageFee: bigint, maxFeePerGas: bigint): boolean {
const actualCost =
gasLimit * maxFeePerGas * BigInt(Math.floor(this.config.profitMargin * PROFIT_MARGIN_MULTIPLIER));
const maxFee = messageFee * BigInt(PROFIT_MARGIN_MULTIPLIER);
return actualCost > maxFee;
}
/**
* Determines if the message has zero fee.
*
* @param {Message} message - The message object to check.
* @returns {boolean} `true` if the message has zero fee, `false` otherwise.
*/
private hasZeroFee(message: Message): boolean {
return message.hasZeroFee() && this.config.profitMargin !== 0;
}
/**
* Calculates the gas estimation threshold based on the message fee and gas limit.
*
* @param {bigint} messageFee - The fee associated with the message.
* @param {bigint} gasLimit - The gas limit for the transaction.
* @returns {number} The calculated gas estimation threshold.
*/
private calculateGasEstimationThreshold(messageFee: bigint, gasLimit: bigint): number {
return parseFloat((messageFee / gasLimit).toString());
}
/**
* Determines the gas limit for the transaction, ensuring it does not exceed the maximum allowed gas limit.
*
* @param {bigint} gasLimit - The gas limit for the transaction.
* @returns {bigint | null} The gas limit if it is within the allowed range, `null` otherwise.
*/
private getGasLimit(gasLimit: bigint): bigint | null {
return gasLimit <= this.config.maxClaimGasLimit ? gasLimit : null;
}
/**
* Determines if the rate limit has been exceeded based on the message fee and value.
*
* @param {bigint} messageFee - The fee associated with the message.
* @param {bigint} messageValue - The value associated with the message.
* @returns {Promise<boolean>} A promise that resolves to `true` if the rate limit has been exceeded, `false` otherwise.
*/
private async isRateLimitExceeded(messageFee: bigint, messageValue: bigint): Promise<boolean> {
return this.lineaRollupClient.isRateLimitExceeded(messageFee, messageValue);
}
}

View File

@@ -0,0 +1,81 @@
import {
ContractTransactionResponse,
ErrorDescription,
ethers,
Overrides,
Signer,
Transaction,
TransactionReceipt,
TransactionResponse,
} from "ethers";
import { GoNativeCompressor } from "@consensys/linea-native-libs";
import { serialize } from "@consensys/linea-sdk";
import { BaseError } from "../core/errors";
import { MessageProps } from "../core/entities/Message";
import { IL2MessageServiceClient } from "../core/clients/blockchain/linea/IL2MessageServiceClient";
import { LineaGasFees } from "../core/clients/blockchain/IGasProvider";
import { IL2ClaimTransactionSizeCalculator } from "../core/services/processors/IL2ClaimTransactionSizeCalculator";
export class L2ClaimTransactionSizeCalculator implements IL2ClaimTransactionSizeCalculator {
private compressor: GoNativeCompressor;
/**
* Constructs a new instance of the `L2ClaimTransactionSizeCalculator`.
*
* @param {IL2MessageServiceClient} l2MessageServiceClient - An instance of a class implementing the `IL2MessageServiceClient` interface, used to interact with the L2 message service.
*/
constructor(
private readonly l2MessageServiceClient: IL2MessageServiceClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
Signer,
ErrorDescription
>,
) {
this.compressor = new GoNativeCompressor(800_000);
}
/**
* Calculates the transaction size for a given message.
*
* @param {MessageProps & { feeRecipient?: string }} message - The message properties including an optional fee recipient.
* @param {LineaGasFees} fees - The transaction gas fees.
* @returns {Promise<number>} A promise that resolves to the calculated transaction size.
* @throws {BaseError} If there is an error during the transaction size calculation.
*/
public async calculateTransactionSize(
message: MessageProps & { feeRecipient?: string },
fees: LineaGasFees,
): Promise<number> {
try {
const transactionData = this.l2MessageServiceClient.encodeClaimMessageTransactionData(message);
const signer = this.l2MessageServiceClient.getSigner();
const destinationAddress = this.l2MessageServiceClient.getContractAddress();
const { gasLimit, maxFeePerGas, maxPriorityFeePerGas } = fees;
if (!signer) {
throw new BaseError("Signer is undefined.");
}
const transaction = Transaction.from({
to: destinationAddress,
value: 0n,
data: transactionData,
maxPriorityFeePerGas,
maxFeePerGas,
gasLimit,
type: 2,
});
const signedTx = await signer.signTransaction(transaction);
const rlpEncodedTx = ethers.encodeRlp(signedTx);
const rlpEncodedTxInBytes = ethers.getBytes(rlpEncodedTx);
return this.compressor.getCompressedTxSize(rlpEncodedTxInBytes);
} catch (error) {
throw new BaseError(`Transaction size calculation error: ${serialize(error)}`);
}
}
}

View File

@@ -0,0 +1,170 @@
import {
Block,
ContractTransactionResponse,
ErrorDescription,
JsonRpcProvider,
Overrides,
Signer,
TransactionReceipt,
TransactionRequest,
TransactionResponse,
} from "ethers";
import { BaseError } from "../core/errors";
import { Message } from "../core/entities/Message";
import {
ITransactionValidationService,
TransactionValidationServiceConfig,
} from "../core/services/ITransactionValidationService";
import { MINIMUM_MARGIN, PROFIT_MARGIN_MULTIPLIER } from "../core/constants";
import { IL2MessageServiceClient } from "../core/clients/blockchain/linea/IL2MessageServiceClient";
import { ILineaProvider } from "../core/clients/blockchain/linea/ILineaProvider";
export class LineaTransactionValidationService implements ITransactionValidationService {
/**
* Constructs a new instance of the `LineaTransactionValidationService`.
*
* @param {TransactionValidationServiceConfig} config - Configuration settings for the transaction validation service, including profit margin and maximum gas limit.
* @param {ILineaProvider} provider - An instance of a class implementing the `ILineaProvider` interface, used to interact with the blockchain.
* @param {IL2MessageServiceClient} l2MessageServiceClient - An instance of a class implementing the `IL2MessageServiceClient` interface, used to interact with the L2 message service.
*/
constructor(
private readonly config: TransactionValidationServiceConfig,
private readonly provider: ILineaProvider<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly l2MessageServiceClient: IL2MessageServiceClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
Signer,
ErrorDescription
>,
) {}
/**
* Evaluates a transaction to determine its feasibility based on various factors such as gas estimation, profit margin, and rate limits.
*
* @param {Message} message - The message object to evaluate.
* @param {string} [feeRecipient] - The optional fee recipient address.
* @returns {Promise<{
* hasZeroFee: boolean;
* isUnderPriced: boolean;
* isRateLimitExceeded: boolean;
* estimatedGasLimit: bigint | null;
* threshold: number;
* maxPriorityFeePerGas: bigint;
* maxFeePerGas: bigint;
* }>} A promise that resolves to an object containing the evaluation results.
*/
public async evaluateTransaction(
message: Message,
feeRecipient?: string,
): Promise<{
hasZeroFee: boolean;
isUnderPriced: boolean;
isRateLimitExceeded: boolean;
estimatedGasLimit: bigint | null;
threshold: number;
maxPriorityFeePerGas: bigint;
maxFeePerGas: bigint;
}> {
const { gasLimit, maxPriorityFeePerGas, maxFeePerGas } = await this.l2MessageServiceClient.estimateClaimGasFees({
...message,
feeRecipient: feeRecipient,
});
const threshold = this.calculateGasEstimationThreshold(message.fee, gasLimit);
const estimatedGasLimit = this.getGasLimit(gasLimit);
const isUnderPriced = await this.isUnderPriced(gasLimit, message.fee, message.compressedTransactionSize!);
const hasZeroFee = this.hasZeroFee(message);
const isRateLimitExceeded = await this.isRateLimitExceeded(message.fee, message.value);
return {
hasZeroFee,
isUnderPriced,
isRateLimitExceeded,
estimatedGasLimit,
threshold,
maxPriorityFeePerGas,
maxFeePerGas,
};
}
/**
* Determines if the message has zero fee.
*
* @param {Message} message - The message object to check.
* @returns {boolean} `true` if the message has zero fee, `false` otherwise.
*/
private hasZeroFee(message: Message): boolean {
return message.hasZeroFee() && this.config.profitMargin !== 0;
}
/**
* Determines if the transaction is underpriced based on the gas limit, message fee, and maximum fee per gas.
*
* @param {bigint} gasLimit - The gas limit for the transaction.
* @param {bigint} messageFee - The fee associated with the message.
* @param {bigint} maxFeePerGas - The maximum fee per gas for the transaction.
* @returns {boolean} `true` if the transaction is underpriced, `false` otherwise.
*/
private async isUnderPriced(
gasLimit: bigint,
messageFee: bigint,
messageCompressedTransactionSize: number,
): Promise<boolean> {
const extraData = await this.provider.getBlockExtraData("latest");
if (!extraData) {
throw new BaseError("No extra data.");
}
const priorityFee =
(BigInt(MINIMUM_MARGIN * 10) *
((BigInt(extraData.variableCost) * BigInt(messageCompressedTransactionSize)) / gasLimit +
BigInt(extraData.fixedCost))) /
10n;
const actualCost = priorityFee * gasLimit * BigInt(Math.floor(this.config.profitMargin * PROFIT_MARGIN_MULTIPLIER));
const maxFee = messageFee * BigInt(PROFIT_MARGIN_MULTIPLIER);
return maxFee < actualCost;
}
/**
* Determines if the rate limit has been exceeded based on the message fee and value.
*
* @param {bigint} messageFee - The fee associated with the message.
* @param {bigint} messageValue - The value associated with the message.
* @returns {Promise<boolean>} A promise that resolves to `true` if the rate limit has been exceeded, `false` otherwise.
*/
private async isRateLimitExceeded(messageFee: bigint, messageValue: bigint): Promise<boolean> {
return this.l2MessageServiceClient.isRateLimitExceeded(messageFee, messageValue);
}
/**
* Calculates the gas estimation threshold based on the message fee and gas limit.
*
* @param {bigint} messageFee - The fee associated with the message.
* @param {bigint} gasLimit - The gas limit for the transaction.
* @returns {number} The calculated gas estimation threshold.
*/
private calculateGasEstimationThreshold(messageFee: bigint, gasLimit: bigint): number {
return parseFloat((messageFee / gasLimit).toString());
}
/**
* Determines the gas limit for the transaction, ensuring it does not exceed the maximum allowed gas limit.
*
* @param {bigint} gasLimit - The gas limit for the transaction.
* @returns {bigint | null} The gas limit if it is within the allowed range, `null` otherwise.
*/
private getGasLimit(gasLimit: bigint): bigint | null {
return gasLimit <= this.config.maxClaimGasLimit ? gasLimit : null;
}
}

View File

@@ -0,0 +1,31 @@
import { ContractTransactionResponse } from "ethers";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
import { IDatabaseCleaner } from "../../core/persistence/IDatabaseCleaner";
import { ILogger } from "../../core/utils/logging/ILogger";
export class DatabaseCleaner implements IDatabaseCleaner {
/**
* Constructs a new instance of the `DatabaseCleaner`.
*
* @param {IMessageDBService<ContractTransactionResponse>} databaseService - An instance of a MessageDBService that provides access to message storage and operations.
* @param {ILogger} logger - An instance of a logger for logging information and errors during the cleanup process.
*/
constructor(
private readonly databaseService: IMessageDBService<ContractTransactionResponse>,
private readonly logger: ILogger,
) {}
/**
* Executes the database cleanup routine to delete messages older than a specified duration.
*
* @param {number} msBeforeNowToDelete - The duration in milliseconds before the current time. Messages older than this duration will be deleted.
*/
public async databaseCleanerRoutine(msBeforeNowToDelete: number) {
try {
const affected = await this.databaseService.deleteMessages(msBeforeNowToDelete);
this.logger.info("Database cleanup result: deleted %s rows", affected);
} catch (e) {
this.logger.error(e);
}
}
}

View File

@@ -0,0 +1,68 @@
import { ContractTransactionResponse, TransactionRequest } from "ethers";
import { Direction } from "@consensys/linea-sdk";
import { Message } from "../../core/entities/Message";
import { MessageStatus } from "../../core/enums";
import { IMessageRepository } from "../../core/persistence/IMessageRepository";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
import { IGasProvider } from "../../core/clients/blockchain/IGasProvider";
import { MessageDBService } from "./MessageDBService";
export class EthereumMessageDBService
extends MessageDBService
implements IMessageDBService<ContractTransactionResponse>
{
/**
* Creates an instance of `EthereumMessageDBService`.
*
* @param {IGasProvider} gasProvider - The gas provider for fetching gas fee estimates.
* @param {IMessageRepository} messageRepository - The message repository for interacting with the message database.
*/
constructor(
private readonly gasProvider: IGasProvider<TransactionRequest>,
messageRepository: IMessageRepository<ContractTransactionResponse>,
) {
super(messageRepository);
}
/**
* Retrieves the first N messages with status SENT and direction L2_TO_L1.
*
* @param {number} limit - The maximum number of messages to retrieve.
* @param {string} contractAddress - The address of the contract to filter messages by.
* @returns {Promise<Message[]>} A promise that resolves to an array of messages.
*/
public async getNFirstMessagesSent(limit: number, contractAddress: string): Promise<Message[]> {
return this.messageRepository.getNFirstMessagesByStatus(
MessageStatus.SENT,
Direction.L2_TO_L1,
limit,
contractAddress,
);
}
/**
* Retrieves the first message to claim on L1.
*
* @param {string} contractAddress - The address of the contract to filter messages by.
* @param {number} gasEstimationMargin - The margin to apply to gas estimation.
* @param {number} maxRetry - The maximum number of retries for claiming the message.
* @param {number} retryDelay - The delay between retries in milliseconds.
* @returns {Promise<Message | null>} A promise that resolves to the message to claim, or null if no message is found.
*/
public async getMessageToClaim(
contractAddress: string,
gasEstimationMargin: number,
maxRetry: number,
retryDelay: number,
): Promise<Message | null> {
const { maxFeePerGas } = await this.gasProvider.getGasFees();
return this.messageRepository.getFirstMessageToClaimOnL1(
Direction.L2_TO_L1,
contractAddress,
maxFeePerGas,
gasEstimationMargin,
maxRetry,
retryDelay,
);
}
}

View File

@@ -0,0 +1,106 @@
import {
Block,
ContractTransactionResponse,
JsonRpcProvider,
TransactionReceipt,
TransactionRequest,
TransactionResponse,
} from "ethers";
import { Direction } from "@consensys/linea-sdk";
import { Message } from "../../core/entities/Message";
import { MessageStatus } from "../../core/enums";
import { IMessageRepository } from "../../core/persistence/IMessageRepository";
import { ILineaProvider } from "../../core/clients/blockchain/linea/ILineaProvider";
import { BaseError } from "../../core/errors";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
import { MessageDBService } from "./MessageDBService";
import { MINIMUM_MARGIN } from "../../core/constants";
export class LineaMessageDBService extends MessageDBService implements IMessageDBService<ContractTransactionResponse> {
/**
* Creates an instance of `LineaMessageDBService`.
*
* @param {ILineaProvider} provider - The provider for interacting with the blockchain.
* @param {IMessageRepository} messageRepository - The message repository for interacting with the message database.
*/
constructor(
private readonly provider: ILineaProvider<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
messageRepository: IMessageRepository<ContractTransactionResponse>,
) {
super(messageRepository);
}
/**
* Retrieves the first N messages with status SENT and direction L1_TO_L2.
*
* @param {number} limit - The maximum number of messages to retrieve.
* @param {string} contractAddress - The address of the contract to filter messages by.
* @returns {Promise<Message[]>} A promise that resolves to an array of messages.
*/
public async getNFirstMessagesSent(limit: number, contractAddress: string): Promise<Message[]> {
return this.messageRepository.getNFirstMessagesByStatus(
MessageStatus.SENT,
Direction.L1_TO_L2,
limit,
contractAddress,
);
}
/**
* Retrieves the first message to claim on L2.
*
* @param {string} contractAddress - The address of the contract to filter messages by.
* @param {number} _gasEstimationMargin - The margin to apply to gas estimation.
* @param {number} maxRetry - The maximum number of retries for claiming the message.
* @param {number} retryDelay - The delay between retries in milliseconds.
* @returns {Promise<Message | null>} A promise that resolves to the message to claim, or null if no message is found.
*/
public async getMessageToClaim(
contractAddress: string,
_gasEstimationMargin: number,
maxRetry: number,
retryDelay: number,
): Promise<Message | null> {
const feeEstimationOptions = await this.getClaimDBQueryFeeOptions();
return this.messageRepository.getFirstMessageToClaimOnL2(
Direction.L1_TO_L2,
contractAddress,
[MessageStatus.TRANSACTION_SIZE_COMPUTED, MessageStatus.FEE_UNDERPRICED],
maxRetry,
retryDelay,
feeEstimationOptions,
);
}
/**
* Retrieves fee estimation options for querying the database.
*
* @private
* @returns {Promise<{ minimumMargin: number; extraDataVariableCost: number; extraDataFixedCost: number }>} A promise that resolves to an object containing fee estimation options.
* @throws {BaseError} If no extra data is available.
*/
private async getClaimDBQueryFeeOptions(): Promise<{
minimumMargin: number;
extraDataVariableCost: number;
extraDataFixedCost: number;
}> {
const minimumMargin = MINIMUM_MARGIN;
const blockNumber = await this.provider.getBlockNumber();
const extraData = await this.provider.getBlockExtraData(blockNumber);
if (!extraData) {
throw new BaseError("no extra data.");
}
return {
minimumMargin,
extraDataVariableCost: extraData.variableCost,
extraDataFixedCost: extraData.fixedCost,
};
}
}

View File

@@ -0,0 +1,119 @@
import { ContractTransactionResponse } from "ethers";
import { Direction } from "@consensys/linea-sdk";
import { Message } from "../../core/entities/Message";
import { IMessageRepository } from "../../core/persistence/IMessageRepository";
import { MessageStatus } from "../../core/enums";
export abstract class MessageDBService {
/**
* Creates an instance of `MessageDBService`.
*
* @param {IMessageRepository} messageRepository - The message repository for interacting with the message database.
*/
constructor(protected readonly messageRepository: IMessageRepository<ContractTransactionResponse>) {}
/**
* Inserts a message into the database.
*
* @param {Message} message - The message to insert.
* @returns {Promise<void>} A promise that resolves when the message is inserted.
*/
public async insertMessage(message: Message): Promise<void> {
return this.messageRepository.insertMessage(message);
}
/**
* Saves multiple messages into the database.
*
* @param {Message[]} messages - The messages to save.
* @returns {Promise<void>} A promise that resolves when the messages are saved.
*/
public async saveMessages(messages: Message[]): Promise<void> {
return this.messageRepository.saveMessages(messages);
}
/**
* Updates a message in the database.
*
* @param {Message} message - The message to update.
* @returns {Promise<void>} A promise that resolves when the message is updated.
*/
public async updateMessage(message: Message): Promise<void> {
return this.messageRepository.updateMessage(message);
}
/**
* Deletes messages older than a specified time.
*
* @param {number} msBeforeNowToDelete - The time in milliseconds before now to delete messages.
* @returns {Promise<number>} A promise that resolves to the number of deleted messages.
*/
public async deleteMessages(msBeforeNowToDelete: number): Promise<number> {
return this.messageRepository.deleteMessages(msBeforeNowToDelete);
}
/**
* Retrieves the first pending message in a given direction.
*
* @param {Direction} direction - The direction to filter messages by.
* @returns {Promise<Message | null>} A promise that resolves to the first pending message, or null if no message is found.
*/
public async getFirstPendingMessage(direction: Direction): Promise<Message | null> {
return this.messageRepository.getFirstPendingMessage(direction);
}
/**
* Retrieves the latest message sent in a given direction and contract address.
*
* @param {Direction} direction - The direction to filter messages by.
* @param {string} contractAddress - The contract address to filter messages by.
* @returns {Promise<Message | null>} A promise that resolves to the latest message sent, or null if no message is found.
*/
public async getLatestMessageSent(direction: Direction, contractAddress: string): Promise<Message | null> {
return this.messageRepository.getLatestMessageSent(direction, contractAddress);
}
/**
* Retrieves the first N messages with a given status, direction, and contract address.
*
* @param {MessageStatus} status - The status to filter messages by.
* @param {Direction} direction - The direction to filter messages by.
* @param {number} limit - The maximum number of messages to retrieve.
* @param {string} contractAddress - The contract address to filter messages by.
* @returns {Promise<Message[]>} A promise that resolves to an array of messages.
*/
public async getNFirstMessagesByStatus(
status: MessageStatus,
direction: Direction,
limit: number,
contractAddress: string,
): Promise<Message[]> {
return this.messageRepository.getNFirstMessagesByStatus(status, direction, limit, contractAddress);
}
/**
* Retrieves the last claim transaction nonce in a given direction.
*
* @param {Direction} direction - The direction to filter messages by.
* @returns {Promise<number | null>} A promise that resolves to the last claim transaction nonce, or null if no nonce is found.
*/
public async getLastClaimTxNonce(direction: Direction): Promise<number | null> {
return this.messageRepository.getLastClaimTxNonce(direction);
}
/**
* Updates a message with a claim transaction atomically.
*
* @param {Message} message - The message to update.
* @param {number} nonce - The nonce to use for the claim transaction.
* @param {Promise<ContractTransactionResponse>} claimTxResponsePromise - The promise that resolves to the claim transaction response.
* @returns {Promise<void>} A promise that resolves when the message is updated.
*/
public async updateMessageWithClaimTxAtomic(
message: Message,
nonce: number,
claimTxResponsePromise: Promise<ContractTransactionResponse>,
): Promise<void> {
await this.messageRepository.updateMessageWithClaimTxAtomic(message, nonce, claimTxResponsePromise);
}
}

View File

@@ -0,0 +1,60 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import { ContractTransactionResponse } from "ethers";
import { DatabaseCleaner } from "../DatabaseCleaner";
import { ILogger } from "../../../core/utils/logging/ILogger";
import { DatabaseAccessError } from "../../../core/errors/DatabaseErrors";
import { DatabaseErrorType, DatabaseRepoName } from "../../../core/enums";
import { IMessageDBService } from "../../../core/persistence/IMessageDBService";
describe("TestDatabaseCleaner", () => {
let testDatabaseCleaner: DatabaseCleaner;
const messageRepositoryMock = mock<IMessageDBService<ContractTransactionResponse>>();
const loggerMock = mock<ILogger>();
beforeEach(() => {
testDatabaseCleaner = new DatabaseCleaner(messageRepositoryMock, loggerMock);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("databaseCleanerRoutine", () => {
it("Should log info if deleteMessages returns successfully", async () => {
const messageRepositorySpy = jest.spyOn(messageRepositoryMock, "deleteMessages").mockResolvedValue(10);
const loggerInfoSpy = jest.spyOn(loggerMock, "info");
await testDatabaseCleaner.databaseCleanerRoutine(10 * 24 * 3600 * 1000); // ms for 10 days
expect(messageRepositorySpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith("Database cleanup result: deleted %s rows", 10);
});
it("Should log error if deleteMessages returns error", async () => {
const messageRepositorySpy = jest
.spyOn(messageRepositoryMock, "deleteMessages")
.mockRejectedValue(
new DatabaseAccessError(
DatabaseRepoName.MessageRepository,
DatabaseErrorType.Delete,
new Error("Error for testing"),
),
);
const loggerErrorSpy = jest.spyOn(loggerMock, "error");
await testDatabaseCleaner.databaseCleanerRoutine(10 * 24 * 3600 * 1000); // ms for 10 days
expect(messageRepositorySpy).toHaveBeenCalledTimes(1);
expect(loggerErrorSpy).toHaveBeenCalledTimes(1);
expect(loggerErrorSpy).toHaveBeenCalledWith(
new DatabaseAccessError(
DatabaseRepoName.MessageRepository,
DatabaseErrorType.Delete,
new Error("Error for testing"),
),
);
});
});
});

View File

@@ -0,0 +1,4 @@
export { DatabaseCleaner } from "./DatabaseCleaner";
export { EthereumMessageDBService } from "./EthereumMessageDBService";
export { LineaMessageDBService } from "./LineaMessageDBService";
export { MessageDBService } from "./MessageDBService";

View File

@@ -0,0 +1,60 @@
import { wait } from "@consensys/linea-sdk";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IPoller } from "../../core/services/pollers/IPoller";
import { IDatabaseCleaner } from "../../core/persistence/IDatabaseCleaner";
import { DBCleanerConfig } from "../../application/postman/persistence/config/types";
export class DatabaseCleaningPoller implements IPoller {
private isPolling = false;
private enabled: boolean;
private pollingInterval: number;
private msBeforeNowToDelete: number;
/**
* Constructs a new instance of the `DatabaseCleaningPoller`.
*
* @param {IDatabaseCleaner} databaseCleaner - An instance of a class implementing the `IDatabaseCleaner` interface, responsible for executing the cleanup logic.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages related to the polling and cleanup process.
* @param {DBCleanerConfig} [config] - Optional configuration settings for the poller, including whether it is enabled, the polling interval, and the age threshold for data deletion.
*/
constructor(
private readonly databaseCleaner: IDatabaseCleaner,
private readonly logger: ILogger,
config: DBCleanerConfig,
) {
this.enabled = config.enabled;
this.pollingInterval = config.cleaningInterval;
this.msBeforeNowToDelete = config.daysBeforeNowToDelete * 24 * 60 * 60 * 1000;
}
/**
* Starts the polling process, triggering periodic database cleanup operations based on the configured interval and deletion threshold.
*/
public async start() {
if (!this.enabled) {
this.logger.warn("%s is disabled", this.logger.name);
return;
}
if (this.isPolling) {
this.logger.warn("%s has already started.", this.logger.name);
return;
}
this.logger.info("Starting %s...", this.logger.name);
this.isPolling = true;
while (this.isPolling) {
await this.databaseCleaner.databaseCleanerRoutine(this.msBeforeNowToDelete);
await wait(this.pollingInterval);
}
}
/**
* Stops the polling process, halting any further database cleanup operations.
*/
public stop() {
this.logger.info("Stopping %s...", this.logger.name);
this.isPolling = false;
this.logger.info("%s stopped.", this.logger.name);
}
}

View File

@@ -0,0 +1,56 @@
import { Direction, wait } from "@consensys/linea-sdk";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IPoller } from "../../core/services/pollers/IPoller";
import { L2ClaimMessageTransactionSizeProcessor } from "../processors/L2ClaimMessageTransactionSizeProcessor";
type L2ClaimMessageTransactionSizePollerConfig = {
pollingInterval: number;
};
export class L2ClaimMessageTransactionSizePoller implements IPoller {
private isPolling = false;
/**
* Creates an instance of `L2ClaimMessageTransactionSizePoller`.
*
* @param {L2ClaimMessageTransactionSizeProcessor} transactionSizeProcessor - The processor for handling L2 claim message transaction sizes.
* @param {L2ClaimMessageTransactionSizePollerConfig} config - The configuration for the poller.
* @param {ILogger} logger - The logger for logging information and warnings.
*/
constructor(
private readonly transactionSizeProcessor: L2ClaimMessageTransactionSizeProcessor,
private config: L2ClaimMessageTransactionSizePollerConfig,
private readonly logger: ILogger,
) {}
/**
* Starts the poller.
* Logs a warning if the poller is already running.
* Continuously processes transaction sizes and waits for the specified polling interval.
*
* @returns {Promise<void>} A promise that resolves when the poller is started.
*/
public async start(): Promise<void> {
if (this.isPolling) {
this.logger.warn("%s has already started.", this.logger.name);
return;
}
this.logger.info("Starting %s %s...", Direction.L1_TO_L2, this.logger.name);
this.isPolling = true;
while (this.isPolling) {
await this.transactionSizeProcessor.process();
await wait(this.config.pollingInterval);
}
}
/**
* Stops the poller.
* Logs information about the stopping process.
*/
public stop() {
this.logger.info("Stopping %s %s...", Direction.L1_TO_L2, this.logger.name);
this.isPolling = false;
this.logger.info("%s %s stopped.", Direction.L1_TO_L2, this.logger.name);
}
}

View File

@@ -0,0 +1,56 @@
import { Direction, wait } from "@consensys/linea-sdk";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IPoller } from "../../core/services/pollers/IPoller";
import { IMessageAnchoringProcessor } from "../../core/services/processors/IMessageAnchoringProcessor";
type MessageAnchoringPollerConfig = {
direction: Direction;
pollingInterval: number;
};
export class MessageAnchoringPoller implements IPoller {
private isPolling = false;
/**
* Constructs a new instance of the `MessageAnchoringPoller`.
*
* @param {IMessageAnchoringProcessor} anchoringProcessor - An instance of a class implementing the `IMessageAnchoringProcessor` interface, responsible for the message anchoring logic.
* @param {MessageAnchoringPollerConfig} config - Configuration settings for the poller, including the direction of message flow and the polling interval.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages related to the polling process.
*/
constructor(
private readonly anchoringProcessor: IMessageAnchoringProcessor,
private config: MessageAnchoringPollerConfig,
private readonly logger: ILogger,
) {}
/**
* Starts the polling process, triggering periodic execution of the message anchoring logic.
* Logs a warning if the poller is already running.
*
* @returns {Promise<void>} A promise that resolves when the poller is started.
*/
public async start(): Promise<void> {
if (this.isPolling) {
this.logger.warn("%s has already started.", this.logger.name);
return;
}
this.logger.info("Starting %s %s...", this.config.direction, this.logger.name);
this.isPolling = true;
while (this.isPolling) {
await this.anchoringProcessor.process();
await wait(this.config.pollingInterval);
}
}
/**
* Stops the polling process, halting any further execution of the message anchoring logic.
* Logs information about the stopping process.
*/
public stop() {
this.logger.info("Stopping %s %s...", this.config.direction, this.logger.name);
this.isPolling = false;
this.logger.info("%s %s stopped.", this.config.direction, this.logger.name);
}
}

View File

@@ -0,0 +1,56 @@
import { Direction, wait } from "@consensys/linea-sdk";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IPoller } from "../../core/services/pollers/IPoller";
import { IMessageClaimingProcessor } from "../../core/services/processors/IMessageClaimingProcessor";
type MessageClaimingPollerConfig = {
direction: Direction;
pollingInterval: number;
};
export class MessageClaimingPoller implements IPoller {
private isPolling = false;
/**
* Constructs a new instance of the `MessageClaimingPoller`.
*
* @param {IMessageClaimingProcessor} claimingProcessor - An instance of a class implementing the `IMessageClaimingProcessor` interface, responsible for the message claiming logic.
* @param {MessageClaimingPollerConfig} config - Configuration settings for the poller, including the direction of message flow and the polling interval.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages related to the polling process.
*/
constructor(
private readonly claimingProcessor: IMessageClaimingProcessor,
private config: MessageClaimingPollerConfig,
private readonly logger: ILogger,
) {}
/**
* Starts the polling process, triggering periodic execution of the message claiming logic.
* Logs a warning if the poller is already running.
*
* @returns {Promise<void>} A promise that resolves when the poller is started.
*/
public async start(): Promise<void> {
if (this.isPolling) {
this.logger.warn("%s has already started.", this.logger.name);
return;
}
this.logger.info("Starting %s %s...", this.config.direction, this.logger.name);
this.isPolling = true;
while (this.isPolling) {
await this.claimingProcessor.process();
await wait(this.config.pollingInterval);
}
}
/**
* Stops the polling process, halting any further execution of the message claiming logic.
* Logs information about the stopping process.
*/
public stop() {
this.logger.info("Stopping %s %s...", this.config.direction, this.logger.name);
this.isPolling = false;
this.logger.info("%s %s stopped.", this.config.direction, this.logger.name);
}
}

View File

@@ -0,0 +1,56 @@
import { Direction, wait } from "@consensys/linea-sdk";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IPoller } from "../../core/services/pollers/IPoller";
import { IMessageClaimingPersister } from "../../core/services/processors/IMessageClaimingPersister";
type MessagePersistingPollerConfig = {
direction: Direction;
pollingInterval: number;
};
export class MessagePersistingPoller implements IPoller {
private isPolling = false;
/**
* Constructs a new instance of the `MessagePersistingPoller`.
*
* @param {IMessageClaimingPersister} claimingPersister - An instance of a class implementing the `IMessageClaimingPersister` interface, responsible for updating and persisting claimed messages.
* @param {MessagePersistingPollerConfig} config - Configuration settings for the poller, including the direction of message flow and the polling interval.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages related to the polling process.
*/
constructor(
private readonly claimingPersister: IMessageClaimingPersister,
private config: MessagePersistingPollerConfig,
private readonly logger: ILogger,
) {}
/**
* Starts the polling process, triggering periodic execution of the message persistence logic.
* Logs a warning if the poller is already running.
*
* @returns {Promise<void>} A promise that resolves when the poller is started.
*/
public async start(): Promise<void> {
if (this.isPolling) {
this.logger.warn("%s has already started.", this.logger.name);
return;
}
this.logger.info("Starting %s %s...", this.config.direction, this.logger.name);
this.isPolling = true;
while (this.isPolling) {
await this.claimingPersister.process();
await wait(this.config.pollingInterval);
}
}
/**
* Stops the polling process, halting any further execution of the message persistence logic.
* Logs information about the stopping process.
*/
public stop() {
this.logger.info("Stopping %s %s...", this.config.direction, this.logger.name);
this.isPolling = false;
this.logger.info("%s %s stopped.", this.config.direction, this.logger.name);
}
}

View File

@@ -0,0 +1,173 @@
import {
Block,
ContractTransactionResponse,
JsonRpcProvider,
TransactionReceipt,
TransactionRequest,
TransactionResponse,
} from "ethers";
import { Direction, wait } from "@consensys/linea-sdk";
import { ILogger } from "../../core/utils/logging/ILogger";
import { DEFAULT_INITIAL_FROM_BLOCK } from "../../core/constants";
import { IMessageSentEventProcessor } from "../../core/services/processors/IMessageSentEventProcessor";
import { Message } from "../../core/entities/Message";
import { DatabaseAccessError } from "../../core/errors/DatabaseErrors";
import { IProvider } from "../../core/clients/blockchain/IProvider";
import { IPoller } from "../../core/services/pollers/IPoller";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
type MessageSentEventPollerConfig = {
direction: Direction;
pollingInterval: number;
initialFromBlock: number;
originContractAddress: string;
};
export class MessageSentEventPoller implements IPoller {
private isPolling = false;
/**
* Constructs a new instance of the `MessageSentEventPoller`.
*
* @param {IMessageSentEventProcessor} eventProcessor - An instance of a class implementing the `IMessageSentEventProcessor` interface, responsible for processing message sent events.
* @param {IProvider} provider - An instance of a class implementing the `IProvider` interface, used to query blockchain data.
* @param {IMessageDBService} databaseService - An instance of a class implementing the `IMessageDBService` interface, used for storing and retrieving message data.
* @param {MessageSentEventPollerConfig} config - Configuration settings for the poller, including the direction of message flow, the polling interval, and the initial block number to start listening from.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages related to the polling process.
*/
constructor(
private readonly eventProcessor: IMessageSentEventProcessor,
private readonly provider: IProvider<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly databaseService: IMessageDBService<ContractTransactionResponse>,
private readonly config: MessageSentEventPollerConfig,
private readonly logger: ILogger,
) {}
/**
* Starts the polling process, initiating the continuous listening and processing of message sent events.
* Logs a warning if the poller is already running.
*
* @returns {Promise<void>} A promise that resolves when the poller is started.
*/
public async start(): Promise<void> {
if (this.isPolling) {
this.logger.warn("%s has already started.", this.logger.name);
return;
}
this.logger.info("Starting %s %s...", this.config.direction, this.logger.name);
this.isPolling = true;
this.startProcessingEvents();
}
/**
* Stops the polling process, halting any further listening and processing of message sent events.
* Logs information about the stopping process.
*/
public stop() {
this.logger.info("Stopping %s %s...", this.config.direction, this.logger.name);
this.isPolling = false;
this.logger.info("%s %s stopped.", this.config.direction, this.logger.name);
}
/**
* Initiates the event processing loop, fetching and processing message sent events starting from a determined block number.
*
* In case of errors during the initial block number retrieval or event processing, it attempts to restart the process after waiting for the specified polling interval.
*/
private async startProcessingEvents(): Promise<void> {
try {
const { fromBlock, fromBlockLogIndex } = await this.getInitialFromBlock();
this.processEvents(fromBlock, fromBlockLogIndex);
} catch (e) {
this.logger.error(e);
await wait(this.config.pollingInterval);
this.startProcessingEvents();
}
}
/**
* Processes message sent events starting from a specific block number and log index.
*
* This method continuously fetches and processes events, updating the starting point for the next fetch based on the processed events. In case of database access errors, it attempts to recover by restarting from the last successfully processed block number and log index.
*
* @param {number} fromBlock - The block number to start fetching events from.
* @param {number} fromBlockLogIndex - The log index within the starting block to begin processing events from.
*/
private async processEvents(fromBlock: number, fromBlockLogIndex: number): Promise<void> {
if (!this.isPolling) return;
try {
const { nextFromBlock, nextFromBlockLogIndex } = await this.eventProcessor.process(fromBlock, fromBlockLogIndex);
fromBlock = nextFromBlock;
fromBlockLogIndex = nextFromBlockLogIndex;
} catch (e) {
if (e instanceof DatabaseAccessError) {
fromBlock = (e.rejectedMessage as Message & { logIndex: number }).sentBlockNumber;
fromBlockLogIndex = (e.rejectedMessage as Message & { logIndex: number }).logIndex;
this.logger.warn(
"Something went wrong with database access. Restarting fromBlockNum=%s and fromLogIndex=%s and errorMessage=%s",
fromBlock,
fromBlockLogIndex,
e.message,
);
} else {
this.logger.warnOrError(e);
}
} finally {
await wait(this.config.pollingInterval);
this.processEvents(fromBlock, fromBlockLogIndex);
}
}
/**
* Determines the initial block number and log index to start fetching message sent events from.
*
* This method considers the latest message sent block number, the configured initial block number, and the current block number to determine the most appropriate starting point for event processing.
*
* @returns {Promise<{ fromBlock: number; fromBlockLogIndex: number }>} An object containing the determined starting block number and log index.
*/
private async getInitialFromBlock(): Promise<{ fromBlock: number; fromBlockLogIndex: number }> {
let fromBlock = await this.provider.getBlockNumber();
const fromBlockLogIndex = 0;
const latestMessageSentBlockNumber = await this.getLatestMessageSentBlockNumber(this.config.direction);
if (latestMessageSentBlockNumber) {
fromBlock = latestMessageSentBlockNumber;
}
if (this.config.initialFromBlock > DEFAULT_INITIAL_FROM_BLOCK) {
fromBlock = this.config.initialFromBlock;
}
return { fromBlock, fromBlockLogIndex };
}
/**
* Retrieves the block number of the latest message sent event processed by the application, based on the specified direction and contract address.
*
* @param {Direction} direction - The direction of message flow to consider when retrieving the latest message sent event.
* @returns {Promise<number | null>} The block number of the latest message sent event, or null if no such event has been processed.
*/
private async getLatestMessageSentBlockNumber(direction: Direction): Promise<number | null> {
const lastMessageSent = await this.databaseService.getLatestMessageSent(
direction,
this.config.originContractAddress,
);
if (!lastMessageSent) {
return null;
}
return lastMessageSent.sentBlockNumber;
}
}

View File

@@ -0,0 +1,82 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import { DatabaseCleaningPoller } from "../DatabaseCleaningPoller";
import { IDatabaseCleaner } from "../../../core/persistence/IDatabaseCleaner";
import { TestLogger } from "../../../utils/testing/helpers";
import { DEFAULT_DB_CLEANING_INTERVAL, DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE } from "../../../core/constants";
describe("TestDatabaseCleaningPoller", () => {
let testDatabaseCleaningPoller: DatabaseCleaningPoller;
const databaseCleanerMock = mock<IDatabaseCleaner>();
const logger = new TestLogger(DatabaseCleaningPoller.name);
beforeEach(() => {});
afterEach(() => {
jest.resetAllMocks();
});
describe("start", () => {
it("Should return log as warning if not enabled", async () => {
testDatabaseCleaningPoller = new DatabaseCleaningPoller(databaseCleanerMock, logger, {
enabled: false,
cleaningInterval: DEFAULT_DB_CLEANING_INTERVAL,
daysBeforeNowToDelete: DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
});
const loggerWarnSpy = jest.spyOn(logger, "warn");
await testDatabaseCleaningPoller.start();
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith("%s is disabled", DatabaseCleaningPoller.name);
});
it("Should return and log as warning if it has been started", async () => {
testDatabaseCleaningPoller = new DatabaseCleaningPoller(databaseCleanerMock, logger, {
enabled: true,
cleaningInterval: DEFAULT_DB_CLEANING_INTERVAL,
daysBeforeNowToDelete: DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
});
const loggerWarnSpy = jest.spyOn(logger, "warn");
testDatabaseCleaningPoller.start();
await testDatabaseCleaningPoller.start();
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith("%s has already started.", DatabaseCleaningPoller.name);
});
it("Should call databaseCleanerRoutine and log as info if it started successfully", async () => {
testDatabaseCleaningPoller = new DatabaseCleaningPoller(databaseCleanerMock, logger, {
enabled: true,
cleaningInterval: DEFAULT_DB_CLEANING_INTERVAL,
daysBeforeNowToDelete: DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
});
const databaseCleanerMockSpy = jest.spyOn(databaseCleanerMock, "databaseCleanerRoutine");
const loggerInfoSpy = jest.spyOn(logger, "info");
testDatabaseCleaningPoller.start();
expect(databaseCleanerMockSpy).toHaveBeenCalled();
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith("Starting %s...", DatabaseCleaningPoller.name);
});
});
describe("stop", () => {
it("Should return and log as info if it stopped successfully", async () => {
testDatabaseCleaningPoller = new DatabaseCleaningPoller(databaseCleanerMock, logger, {
enabled: true,
cleaningInterval: DEFAULT_DB_CLEANING_INTERVAL,
daysBeforeNowToDelete: DEFAULT_DB_DAYS_BEFORE_NOW_TO_DELETE,
});
const loggerInfoSpy = jest.spyOn(logger, "info");
testDatabaseCleaningPoller.stop();
expect(loggerInfoSpy).toHaveBeenCalledTimes(2);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(1, "Stopping %s...", DatabaseCleaningPoller.name);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(2, "%s stopped.", DatabaseCleaningPoller.name);
});
});
});

View File

@@ -0,0 +1,89 @@
import { describe, it, beforeEach } from "@jest/globals";
import { MockProxy, mock } from "jest-mock-extended";
import { Direction } from "@consensys/linea-sdk";
import { TestLogger } from "../../../utils/testing/helpers";
import { testL2NetworkConfig } from "../../../utils/testing/constants";
import { IPoller } from "../../../core/services/pollers/IPoller";
import { L2ClaimMessageTransactionSizePoller } from "../L2ClaimMessageTransactionSizePoller";
import { L2ClaimMessageTransactionSizeProcessor } from "../../processors/L2ClaimMessageTransactionSizeProcessor";
describe("L2ClaimMessageTransactionSizePoller", () => {
let testClaimMessageTransactionSizePoller: IPoller;
let transactionSizeProcessor: MockProxy<L2ClaimMessageTransactionSizeProcessor>;
const logger = new TestLogger(L2ClaimMessageTransactionSizePoller.name);
beforeEach(() => {
transactionSizeProcessor = mock<L2ClaimMessageTransactionSizeProcessor>();
testClaimMessageTransactionSizePoller = new L2ClaimMessageTransactionSizePoller(
transactionSizeProcessor,
{
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("start", () => {
it("Should return and log as warning if it has been started", async () => {
const loggerWarnSpy = jest.spyOn(logger, "warn");
testClaimMessageTransactionSizePoller.start();
await testClaimMessageTransactionSizePoller.start();
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith("%s has already started.", L2ClaimMessageTransactionSizePoller.name);
});
it("Should call process and log as info if it started successfully", async () => {
const transactionSizeProcessorMockSpy = jest
.spyOn(transactionSizeProcessor, "process")
.mockImplementation(jest.fn());
const loggerInfoSpy = jest.spyOn(logger, "info");
testClaimMessageTransactionSizePoller.start();
expect(transactionSizeProcessorMockSpy).toHaveBeenCalled();
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith(
"Starting %s %s...",
Direction.L1_TO_L2,
L2ClaimMessageTransactionSizePoller.name,
);
testClaimMessageTransactionSizePoller.stop();
});
});
describe("stop", () => {
it("Should return and log as info if it stopped successfully", async () => {
const loggerInfoSpy = jest.spyOn(logger, "info");
testClaimMessageTransactionSizePoller = new L2ClaimMessageTransactionSizePoller(
transactionSizeProcessor,
{
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
testClaimMessageTransactionSizePoller.stop();
expect(loggerInfoSpy).toHaveBeenCalledTimes(2);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
1,
"Stopping %s %s...",
Direction.L1_TO_L2,
L2ClaimMessageTransactionSizePoller.name,
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
2,
"%s %s stopped.",
Direction.L1_TO_L2,
L2ClaimMessageTransactionSizePoller.name,
);
});
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import { Direction } from "@consensys/linea-sdk";
import { MessageAnchoringPoller } from "../MessageAnchoringPoller";
import { TestLogger } from "../../../utils/testing/helpers";
import { IMessageAnchoringProcessor } from "../../../core/services/processors/IMessageAnchoringProcessor";
import { testL2NetworkConfig } from "../../../utils/testing/constants";
import { IPoller } from "../../../core/services/pollers/IPoller";
describe("TestMessageAnchoringPoller", () => {
let testAnchoringPoller: IPoller;
const anchoringProcessorMock = mock<IMessageAnchoringProcessor>();
const logger = new TestLogger(MessageAnchoringPoller.name);
beforeEach(() => {
testAnchoringPoller = new MessageAnchoringPoller(
anchoringProcessorMock,
{
direction: Direction.L1_TO_L2,
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("start", () => {
it("Should return and log as warning if it has been started", async () => {
const loggerWarnSpy = jest.spyOn(logger, "warn");
testAnchoringPoller.start();
await testAnchoringPoller.start();
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith("%s has already started.", MessageAnchoringPoller.name);
});
it("Should call getAndUpdateAnchoredMessageStatus and log as info if it started successfully", async () => {
const anchoringProcessorMockSpy = jest.spyOn(anchoringProcessorMock, "process");
const loggerInfoSpy = jest.spyOn(logger, "info");
testAnchoringPoller.start();
expect(anchoringProcessorMockSpy).toHaveBeenCalled();
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith("Starting %s %s...", Direction.L1_TO_L2, MessageAnchoringPoller.name);
});
});
describe("stop", () => {
it("Should return and log as info if it stopped successfully", async () => {
const loggerInfoSpy = jest.spyOn(logger, "info");
testAnchoringPoller = new MessageAnchoringPoller(
anchoringProcessorMock,
{
direction: Direction.L1_TO_L2,
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
testAnchoringPoller.stop();
expect(loggerInfoSpy).toHaveBeenCalledTimes(2);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
1,
"Stopping %s %s...",
Direction.L1_TO_L2,
MessageAnchoringPoller.name,
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
2,
"%s %s stopped.",
Direction.L1_TO_L2,
MessageAnchoringPoller.name,
);
});
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import { Direction } from "@consensys/linea-sdk";
import { MessageClaimingPoller } from "../MessageClaimingPoller";
import { TestLogger } from "../../../utils/testing/helpers";
import { IMessageClaimingProcessor } from "../../../core/services/processors/IMessageClaimingProcessor";
import { testL2NetworkConfig } from "../../../utils/testing/constants";
import { IPoller } from "../../../core/services/pollers/IPoller";
describe("TestMessageClaimingPoller", () => {
let testClaimingPoller: IPoller;
const claimingProcessorMock = mock<IMessageClaimingProcessor>();
const logger = new TestLogger(MessageClaimingPoller.name);
beforeEach(() => {
testClaimingPoller = new MessageClaimingPoller(
claimingProcessorMock,
{
direction: Direction.L1_TO_L2,
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("start", () => {
it("Should return and log as warning if it has been started", async () => {
const loggerWarnSpy = jest.spyOn(logger, "warn");
testClaimingPoller.start();
await testClaimingPoller.start();
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith("%s has already started.", MessageClaimingPoller.name);
});
it("Should call getAndClaimAnchoredMessage and log as info if it started successfully", async () => {
const claimingProcessorMockSpy = jest.spyOn(claimingProcessorMock, "process");
const loggerInfoSpy = jest.spyOn(logger, "info");
testClaimingPoller.start();
expect(claimingProcessorMockSpy).toHaveBeenCalled();
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith("Starting %s %s...", Direction.L1_TO_L2, MessageClaimingPoller.name);
});
});
describe("stop", () => {
it("Should return and log as info if it stopped successfully", async () => {
const loggerInfoSpy = jest.spyOn(logger, "info");
testClaimingPoller = new MessageClaimingPoller(
claimingProcessorMock,
{
direction: Direction.L1_TO_L2,
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
testClaimingPoller.stop();
expect(loggerInfoSpy).toHaveBeenCalledTimes(2);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
1,
"Stopping %s %s...",
Direction.L1_TO_L2,
MessageClaimingPoller.name,
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
2,
"%s %s stopped.",
Direction.L1_TO_L2,
MessageClaimingPoller.name,
);
});
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import { Direction } from "@consensys/linea-sdk";
import { MessagePersistingPoller } from "../MessagePersistingPoller";
import { TestLogger } from "../../../utils/testing/helpers";
import { IMessageClaimingPersister } from "../../../core/services/processors/IMessageClaimingPersister";
import { testL2NetworkConfig } from "../../../utils/testing/constants";
import { IPoller } from "../../../core/services/pollers/IPoller";
describe("TestMessagePersistingPoller", () => {
let testPersistingPoller: IPoller;
const claimingPersisterMock = mock<IMessageClaimingPersister>();
const logger = new TestLogger(MessagePersistingPoller.name);
beforeEach(() => {
testPersistingPoller = new MessagePersistingPoller(
claimingPersisterMock,
{
direction: Direction.L1_TO_L2,
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("start", () => {
it("Should return and log as warning if it has been started", async () => {
const loggerWarnSpy = jest.spyOn(logger, "warn");
testPersistingPoller.start();
await testPersistingPoller.start();
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith("%s has already started.", MessagePersistingPoller.name);
});
it("Should call updateAndPersistPendingMessage and log as info if it started successfully", async () => {
const claimingPersisterMockSpy = jest.spyOn(claimingPersisterMock, "process");
const loggerInfoSpy = jest.spyOn(logger, "info");
testPersistingPoller.start();
expect(claimingPersisterMockSpy).toHaveBeenCalled();
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith("Starting %s %s...", Direction.L1_TO_L2, MessagePersistingPoller.name);
});
});
describe("stop", () => {
it("Should return and log as info if it stopped successfully", async () => {
const loggerInfoSpy = jest.spyOn(logger, "info");
testPersistingPoller = new MessagePersistingPoller(
claimingPersisterMock,
{
direction: Direction.L1_TO_L2,
pollingInterval: testL2NetworkConfig.listener.pollingInterval,
},
logger,
);
testPersistingPoller.stop();
expect(loggerInfoSpy).toHaveBeenCalledTimes(2);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
1,
"Stopping %s %s...",
Direction.L1_TO_L2,
MessagePersistingPoller.name,
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
2,
"%s %s stopped.",
Direction.L1_TO_L2,
MessagePersistingPoller.name,
);
});
});
});

View File

@@ -0,0 +1,228 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import { Block, TransactionReceipt, TransactionRequest, TransactionResponse } from "ethers";
import { Provider, DefaultGasProvider, Direction, wait } from "@consensys/linea-sdk";
import { TestLogger } from "../../../utils/testing/helpers";
import { rejectedMessageProps, testL1NetworkConfig, testMessage } from "../../../utils/testing/constants";
import { IPoller } from "../../../core/services/pollers/IPoller";
import { MessageSentEventPoller } from "../MessageSentEventPoller";
import { IMessageSentEventProcessor } from "../../../core/services/processors/IMessageSentEventProcessor";
import { IProvider } from "../../../core/clients/blockchain/IProvider";
import { IMessageRepository } from "../../../core/persistence/IMessageRepository";
import { DatabaseAccessError } from "../../../core/errors";
import { DatabaseErrorType, DatabaseRepoName } from "../../../core/enums";
import { EthereumMessageDBService } from "../../persistence/EthereumMessageDBService";
import {
DEFAULT_GAS_ESTIMATION_PERCENTILE,
DEFAULT_INITIAL_FROM_BLOCK,
DEFAULT_LISTENER_INTERVAL,
DEFAULT_MAX_FEE_PER_GAS_CAP,
} from "../../../core/constants";
describe("TestMessageSentEventPoller", () => {
let testMessageSentEventPoller: IPoller;
let databaseService: EthereumMessageDBService;
const eventProcessorMock = mock<IMessageSentEventProcessor>();
const provider = mock<IProvider<TransactionReceipt, Block, TransactionRequest, TransactionResponse, Provider>>();
const logger = new TestLogger(MessageSentEventPoller.name);
beforeEach(() => {
const gasProvider = new DefaultGasProvider(provider, {
maxFeePerGasCap: DEFAULT_MAX_FEE_PER_GAS_CAP,
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
enforceMaxGasFee: false,
});
databaseService = new EthereumMessageDBService(gasProvider, mock<IMessageRepository<unknown>>());
testMessageSentEventPoller = new MessageSentEventPoller(
eventProcessorMock,
provider,
databaseService,
{
direction: Direction.L1_TO_L2,
pollingInterval: DEFAULT_LISTENER_INTERVAL,
initialFromBlock: DEFAULT_INITIAL_FROM_BLOCK,
originContractAddress: testL1NetworkConfig.messageServiceContractAddress,
},
logger,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("start", () => {
it("Should return and log as warning if it has been started", async () => {
const loggerWarnSpy = jest.spyOn(logger, "warn");
jest.spyOn(provider, "getBlockNumber").mockResolvedValue(10);
jest.spyOn(databaseService, "getLatestMessageSent").mockResolvedValue(null);
jest.spyOn(eventProcessorMock, "process").mockResolvedValue({
nextFromBlock: 20,
nextFromBlockLogIndex: 0,
});
testMessageSentEventPoller.start();
await wait(500);
await testMessageSentEventPoller.start();
expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith("%s has already started.", MessageSentEventPoller.name);
testMessageSentEventPoller.stop();
});
it("Should call process and log as info if it started successfully", async () => {
const l1QuerierMockSpy = jest.spyOn(provider, "getBlockNumber").mockResolvedValue(10);
const messageRepositoryMockSpy = jest
.spyOn(databaseService, "getLatestMessageSent")
.mockResolvedValue(testMessage);
const eventProcessorMockSpy = jest.spyOn(eventProcessorMock, "process").mockResolvedValue({
nextFromBlock: 20,
nextFromBlockLogIndex: 0,
});
const loggerInfoSpy = jest.spyOn(logger, "info");
testMessageSentEventPoller.start();
await wait(500);
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith("Starting %s %s...", Direction.L1_TO_L2, MessageSentEventPoller.name);
expect(l1QuerierMockSpy).toHaveBeenCalledTimes(1);
expect(messageRepositoryMockSpy).toHaveBeenCalledTimes(1);
expect(messageRepositoryMockSpy).toHaveBeenCalledWith(
Direction.L1_TO_L2,
testL1NetworkConfig.messageServiceContractAddress,
);
expect(eventProcessorMockSpy).toHaveBeenCalled();
expect(eventProcessorMockSpy).toHaveBeenCalledWith(10, 0);
testMessageSentEventPoller.stop();
});
it("Should log as warning if getCurrentBlockNumber throws error", async () => {
const error = new Error("Other error for testing");
const l1QuerierMockSpy = jest.spyOn(provider, "getBlockNumber").mockRejectedValue(error);
const loggerErrorSpy = jest.spyOn(logger, "error");
await testMessageSentEventPoller.start();
await wait(500);
expect(loggerErrorSpy).toHaveBeenCalled();
expect(loggerErrorSpy).toHaveBeenCalledWith(error);
expect(l1QuerierMockSpy).toHaveBeenCalled();
testMessageSentEventPoller.stop();
});
it("Should log as warning if process throws DatabaseAccessError", async () => {
const l1QuerierMockSpy = jest.spyOn(provider, "getBlockNumber").mockResolvedValue(10);
const messageRepositoryMockSpy = jest
.spyOn(databaseService, "getLatestMessageSent")
.mockResolvedValue(testMessage);
const eventProcessorMockSpy = jest
.spyOn(eventProcessorMock, "process")
.mockRejectedValue(
new DatabaseAccessError(
DatabaseRepoName.MessageRepository,
DatabaseErrorType.Read,
new Error("read database error."),
{ ...rejectedMessageProps, logIndex: 1 },
),
);
const loggerWarnSpy = jest.spyOn(logger, "warn");
await testMessageSentEventPoller.start();
await wait(500);
expect(loggerWarnSpy).toHaveBeenCalled();
expect(loggerWarnSpy).toHaveBeenCalledWith(
"Something went wrong with database access. Restarting fromBlockNum=%s and fromLogIndex=%s and errorMessage=%s",
0,
1,
new DatabaseAccessError(
DatabaseRepoName.MessageRepository,
DatabaseErrorType.Read,
new Error("read database error."),
).message,
);
expect(l1QuerierMockSpy).toHaveBeenCalled();
expect(messageRepositoryMockSpy).toHaveBeenCalled();
expect(messageRepositoryMockSpy).toHaveBeenCalledWith(
Direction.L1_TO_L2,
testL1NetworkConfig.messageServiceContractAddress,
);
expect(eventProcessorMockSpy).toHaveBeenCalled();
expect(eventProcessorMockSpy).toHaveBeenCalledWith(10, 0);
testMessageSentEventPoller.stop();
});
it("Should log as warning or error if process throws Error", async () => {
const l1QuerierMockSpy = jest.spyOn(provider, "getBlockNumber").mockResolvedValue(10);
const messageRepositoryMockSpy = jest
.spyOn(databaseService, "getLatestMessageSent")
.mockResolvedValue(testMessage);
const error = new Error("Other error for testing");
const eventProcessorMockSpy = jest.spyOn(eventProcessorMock, "process").mockRejectedValue(error);
const loggerWarnOrErrorSpy = jest.spyOn(logger, "warnOrError");
await testMessageSentEventPoller.start();
await wait(500);
expect(loggerWarnOrErrorSpy).toHaveBeenCalled();
expect(loggerWarnOrErrorSpy).toHaveBeenCalledWith(error);
expect(l1QuerierMockSpy).toHaveBeenCalled();
expect(messageRepositoryMockSpy).toHaveBeenCalled();
expect(messageRepositoryMockSpy).toHaveBeenCalledWith(
Direction.L1_TO_L2,
testL1NetworkConfig.messageServiceContractAddress,
);
expect(eventProcessorMockSpy).toHaveBeenCalled();
expect(eventProcessorMockSpy).toHaveBeenCalledWith(10, 0);
testMessageSentEventPoller.stop();
});
});
describe("stop", () => {
it("Should return and log as info if it stopped successfully", async () => {
const loggerInfoSpy = jest.spyOn(logger, "info");
testMessageSentEventPoller = new MessageSentEventPoller(
eventProcessorMock,
provider,
databaseService,
{
direction: Direction.L1_TO_L2,
pollingInterval: DEFAULT_LISTENER_INTERVAL,
initialFromBlock: DEFAULT_INITIAL_FROM_BLOCK,
originContractAddress: testL1NetworkConfig.messageServiceContractAddress,
},
logger,
);
testMessageSentEventPoller.start();
testMessageSentEventPoller.stop();
expect(loggerInfoSpy).toHaveBeenCalledTimes(3);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
1,
"Starting %s %s...",
Direction.L1_TO_L2,
MessageSentEventPoller.name,
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
2,
"Stopping %s %s...",
Direction.L1_TO_L2,
MessageSentEventPoller.name,
);
expect(loggerInfoSpy).toHaveBeenNthCalledWith(
3,
"%s %s stopped.",
Direction.L1_TO_L2,
MessageSentEventPoller.name,
);
});
});
});

View File

@@ -0,0 +1,6 @@
export { DatabaseCleaningPoller } from "./DatabaseCleaningPoller";
export { L2ClaimMessageTransactionSizePoller } from "./L2ClaimMessageTransactionSizePoller";
export { MessageAnchoringPoller } from "./MessageAnchoringPoller";
export { MessageClaimingPoller } from "./MessageClaimingPoller";
export { MessagePersistingPoller } from "./MessagePersistingPoller";
export { MessageSentEventPoller } from "./MessageSentEventPoller";

View File

@@ -0,0 +1,92 @@
import {
ContractTransactionResponse,
ErrorDescription,
Overrides,
Signer,
TransactionReceipt,
TransactionResponse,
} from "ethers";
import { MessageStatus } from "../../core/enums";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
import { IL2MessageServiceClient } from "../../core/clients/blockchain/linea/IL2MessageServiceClient";
import {
IL2ClaimMessageTransactionSizeProcessor,
L2ClaimMessageTransactionSizeProcessorConfig,
} from "../../core/services/processors/IL2ClaimMessageTransactionSizeProcessor";
import { IL2ClaimTransactionSizeCalculator } from "../../core/services/processors/IL2ClaimTransactionSizeCalculator";
export class L2ClaimMessageTransactionSizeProcessor implements IL2ClaimMessageTransactionSizeProcessor {
/**
* Constructs a new instance of the `L2ClaimMessageTransactionSizeProcessor`.
*
* @param {IMessageDBService} databaseService - The database service for interacting with message data.
* @param {IL2MessageServiceClient} l2MessageServiceClient - The L2 message service client for estimating gas fees.
* @param {IL2ClaimTransactionSizeCalculator} transactionSizeCalculator - The calculator for determining the transaction size.
* @param {L2ClaimMessageTransactionSizeProcessorConfig} config - Configuration settings for the processor, including the direction and origin contract address.
* @param {ILogger} logger - The logger for logging information and errors.
*/
constructor(
private readonly databaseService: IMessageDBService<ContractTransactionResponse>,
private readonly l2MessageServiceClient: IL2MessageServiceClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
Signer,
ErrorDescription
>,
private readonly transactionSizeCalculator: IL2ClaimTransactionSizeCalculator,
private readonly config: L2ClaimMessageTransactionSizeProcessorConfig,
private readonly logger: ILogger,
) {}
/**
* Processes the transaction size and gas limit for L2 claim messages.
* Fetches the first anchored message, calculates its transaction size and gas limit, updates the message status, and logs the information.
*
* @returns {Promise<void>} A promise that resolves when the processing is complete.
*/
public async process(): Promise<void> {
try {
const messages = await this.databaseService.getNFirstMessagesByStatus(
MessageStatus.ANCHORED,
this.config.direction,
1,
this.config.originContractAddress,
);
if (messages.length === 0) {
return;
}
const message = messages[0];
const { gasLimit, maxPriorityFeePerGas, maxFeePerGas } =
await this.l2MessageServiceClient.estimateClaimGasFees(message);
const transactionSize = await this.transactionSizeCalculator.calculateTransactionSize(message, {
maxPriorityFeePerGas,
maxFeePerGas,
gasLimit,
});
message.edit({
claimTxGasLimit: Number(gasLimit),
compressedTransactionSize: transactionSize,
status: MessageStatus.TRANSACTION_SIZE_COMPUTED,
});
await this.databaseService.updateMessage(message);
this.logger.info(
"Message transaction size and gas limit have been computed: messageHash=%s transactionSize=%s gasLimit=%s",
message.messageHash,
transactionSize,
gasLimit,
);
} catch (e) {
this.logger.error(e);
}
}
}

View File

@@ -0,0 +1,99 @@
import {
Overrides,
TransactionResponse,
ContractTransactionResponse,
TransactionReceipt,
Block,
TransactionRequest,
JsonRpcProvider,
ErrorDescription,
} from "ethers";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import {
IMessageAnchoringProcessor,
MessageAnchoringProcessorConfig,
} from "../../core/services/processors/IMessageAnchoringProcessor";
import { IProvider } from "../../core/clients/blockchain/IProvider";
import { MessageStatus } from "../../core/enums";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IMessageServiceContract } from "../../core/services/contracts/IMessageServiceContract";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
export class MessageAnchoringProcessor implements IMessageAnchoringProcessor {
private readonly maxFetchMessagesFromDb: number;
/**
* Constructs a new instance of the `MessageAnchoringProcessor`.
*
* @param {IMessageServiceContract<Overrides, TransactionReceipt, TransactionResponse, ContractTransactionResponse>} contractClient - An instance of a class implementing the `IMessageServiceContract` interface, used to interact with the blockchain contract.
* @param {IProvider<TransactionReceipt, Block, TransactionRequest, TransactionResponse, JsonRpcProvider>} provider - An instance of a class implementing the `IProvider` interface, used to query blockchain data.
* @param {IMessageDBService<ContractTransactionResponse>} databaseService - An instance of a class implementing the `IMessageDBService` interface, used for storing and retrieving message data.
* @param {MessageAnchoringProcessorConfig} config - Configuration settings for the processor, including the maximum number of messages to fetch from the database for processing.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages.
*/
constructor(
private readonly contractClient: IMessageServiceContract<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription
>,
private readonly provider: IProvider<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly databaseService: IMessageDBService<ContractTransactionResponse>,
private readonly config: MessageAnchoringProcessorConfig,
private readonly logger: ILogger,
) {
this.maxFetchMessagesFromDb = Math.max(config.maxFetchMessagesFromDb, 0);
}
/**
* Fetches a set number of messages from the database and updates their status based on the latest anchoring information from the blockchain.
*
* @returns {Promise<void>} A promise that resolves when the processing is complete.
*/
public async process() {
try {
const messages = await this.databaseService.getNFirstMessagesSent(
this.maxFetchMessagesFromDb,
this.config.originContractAddress,
);
if (messages.length === this.maxFetchMessagesFromDb) {
this.logger.warn(`Limit of messages sent to listen reached (%s).`, this.maxFetchMessagesFromDb);
}
if (messages.length === 0) {
return;
}
const latestBlockNumber = await this.provider.getBlockNumber();
for (const message of messages) {
const messageStatus = await this.contractClient.getMessageStatus(message.messageHash, {
blockTag: latestBlockNumber,
});
if (messageStatus === OnChainMessageStatus.CLAIMABLE) {
message.edit({ status: MessageStatus.ANCHORED });
this.logger.info("Message has been anchored: messageHash=%s", message.messageHash);
}
if (messageStatus === OnChainMessageStatus.CLAIMED) {
message.edit({ status: MessageStatus.CLAIMED_SUCCESS });
this.logger.info("Message has already been claimed: messageHash=%s", message.messageHash);
}
}
await this.databaseService.saveMessages(messages);
} catch (e) {
this.logger.error(e);
}
}
}

View File

@@ -0,0 +1,230 @@
import {
Overrides,
TransactionResponse,
ContractTransactionResponse,
TransactionReceipt,
TransactionRequest,
Block,
JsonRpcProvider,
ErrorDescription,
} from "ethers";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import { BaseError } from "../../core/errors";
import { MessageStatus } from "../../core/enums";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IMessageServiceContract } from "../../core/services/contracts/IMessageServiceContract";
import { IProvider } from "../../core/clients/blockchain/IProvider";
import { Message } from "../../core/entities/Message";
import {
IMessageClaimingPersister,
MessageClaimingPersisterConfig,
} from "../../core/services/processors/IMessageClaimingPersister";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
export class MessageClaimingPersister implements IMessageClaimingPersister {
private messageBeingRetry: { message: Message | null; retries: number };
/**
* Initializes a new instance of the `MessageClaimingPersister`.
*
* @param {IMessageDBService} databaseService - An instance of a class implementing the `IMessageDBService` interface, used for storing and retrieving message data.
* @param {IMessageServiceContract} messageServiceContract - An instance of a class implementing the `IMessageServiceContract` interface, used to interact with the blockchain contract.
* @param {IProvider} provider - An instance of a class implementing the `IProvider` interface, used to query blockchain data.
* @param {MessageClaimingPersisterConfig} config - Configuration for network-specific settings, including transaction submission timeout and maximum transaction retries.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages.
*/
constructor(
private readonly databaseService: IMessageDBService<ContractTransactionResponse>,
private readonly messageServiceContract: IMessageServiceContract<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription
>,
private readonly provider: IProvider<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly config: MessageClaimingPersisterConfig,
private readonly logger: ILogger,
) {
this.messageBeingRetry = { message: null, retries: 0 };
}
/**
* Determines whether a message has exceeded the configured submission timeout.
*
* This method checks if the time elapsed since the last update of the message exceeds the submission timeout threshold. This is useful for identifying messages that may require action due to prolonged processing times, such as retrying the transaction with a higher fee.
*
* @param {Message} message - The message object to check for submission timeout exceedance.
* @returns {boolean} `true` if the message has exceeded the submission timeout, `false` otherwise.
*/
private isMessageExceededSubmissionTimeout(message: Message): boolean {
return (
!!message.updatedAt && new Date().getTime() - message.updatedAt.getTime() > this.config.messageSubmissionTimeout
);
}
/**
* Processes the first pending message, updating its status based on the transaction receipt. If the transaction has not been mined or has failed, it attempts to retry the transaction with a higher fee.
*
* @returns {Promise<void>} A promise that resolves when the processing is complete.
*/
public async process(): Promise<void> {
let firstPendingMessage: Message | null = null;
try {
firstPendingMessage = await this.databaseService.getFirstPendingMessage(this.config.direction);
if (!firstPendingMessage?.claimTxHash) {
return;
}
const receipt = await this.provider.getTransactionReceipt(firstPendingMessage.claimTxHash);
if (!receipt) {
if (this.isMessageExceededSubmissionTimeout(firstPendingMessage)) {
this.logger.warn("Retrying to claim message: messageHash=%s", firstPendingMessage.messageHash);
if (
!this.messageBeingRetry.message ||
this.messageBeingRetry.message.messageHash !== firstPendingMessage.messageHash
) {
this.messageBeingRetry = { message: firstPendingMessage, retries: 0 };
}
const transactionReceipt = await this.retryTransaction(
firstPendingMessage.claimTxHash,
firstPendingMessage.messageHash,
);
if (transactionReceipt) {
this.logger.warn(
"Retried claim message transaction succeed: messageHash=%s transactionHash=%s",
firstPendingMessage.messageHash,
transactionReceipt.hash,
);
}
}
return;
}
await this.updateReceiptStatus(firstPendingMessage, receipt);
} catch (e) {
this.logger.error(e);
}
}
/**
* Attempts to retry a transaction with a higher fee if the original transaction has not been successfully processed.
*
* @param {string} transactionHash - The hash of the original transaction to retry.
* @param {string} messageHash - The hash of the message associated with the transaction.
* @returns {Promise<TransactionReceipt | null>} The receipt of the retried transaction, or null if the retry was unsuccessful.
*/
private async retryTransaction(transactionHash: string, messageHash: string): Promise<TransactionReceipt | null> {
try {
const messageStatus = await this.messageServiceContract.getMessageStatus(messageHash, {
blockTag: "latest",
});
if (messageStatus === OnChainMessageStatus.CLAIMED) {
const receipt = await this.provider.getTransactionReceipt(transactionHash);
if (!receipt) {
this.logger.warn(
"Calling retryTransaction again as message was claimed but transaction receipt is not available yet: messageHash=%s transactionHash=%s",
messageHash,
transactionHash,
);
}
return receipt;
}
this.messageBeingRetry.retries++;
this.logger.warn(
"Retry to claim message: numberOfRetries=%s messageInfo=%s",
this.messageBeingRetry.retries.toString(),
this.messageBeingRetry.message?.toString(),
);
const tx = await this.messageServiceContract.retryTransactionWithHigherFee(transactionHash);
const receipt = await tx.wait();
if (!receipt) {
throw new BaseError(
`RetryTransaction: Transaction receipt not found after retry transaction. transactionHash=${tx.hash}`,
);
}
this.messageBeingRetry.message?.edit({
claimTxGasLimit: parseInt(tx.gasLimit.toString()),
claimTxMaxFeePerGas: tx.maxFeePerGas ?? undefined,
claimTxMaxPriorityFeePerGas: tx.maxPriorityFeePerGas ?? undefined,
claimTxHash: tx.hash,
claimNumberOfRetry: this.messageBeingRetry.retries,
claimLastRetriedAt: new Date(),
claimTxNonce: tx.nonce,
});
await this.databaseService.updateMessage(this.messageBeingRetry.message!);
return receipt;
} catch (e) {
this.logger.error(
"Transaction retry failed: messageHash=%s error=%s",
this.messageBeingRetry.message?.messageHash,
e,
);
if (this.messageBeingRetry.retries > this.config.maxTxRetries) {
this.logger.error(
"Max number of retries exceeded. Manual intervention is needed as soon as possible: messageInfo=%s",
this.messageBeingRetry.message?.toString(),
);
}
return null;
}
}
/**
* Updates the status of a message based on the outcome of its claim transaction.
*
* @param {Message} message - The message object to update.
* @param {TransactionReceipt} receipt - The receipt of the claim transaction.
*/
private async updateReceiptStatus(message: Message, receipt: TransactionReceipt): Promise<void> {
if (receipt.status === 0) {
const isRateLimitExceeded = await this.messageServiceContract.isRateLimitExceededError(receipt.hash);
if (isRateLimitExceeded) {
message.edit({
status: MessageStatus.SENT,
//claimGasEstimationThreshold: undefined,
});
await this.databaseService.updateMessage(message);
this.logger.warn(
"Claim transaction has been reverted with RateLimitExceeded error. Claiming will be retry later: messageHash=%s transactionHash=%s",
message.messageHash,
receipt.hash,
);
return;
}
message.edit({ status: MessageStatus.CLAIMED_REVERTED });
await this.databaseService.updateMessage(message);
this.logger.warn(
"Message claim transaction has been REVERTED: messageHash=%s transactionHash=%s",
message.messageHash,
receipt.hash,
);
return;
}
message.edit({ status: MessageStatus.CLAIMED_SUCCESS });
await this.databaseService.updateMessage(message);
this.logger.info(
"Message has been SUCCESSFULLY claimed: messageHash=%s transactionHash=%s",
message.messageHash,
receipt.hash,
);
}
}

View File

@@ -0,0 +1,279 @@
import { ErrorParser } from "../../utils/ErrorParser";
import {
Overrides,
TransactionResponse,
ContractTransactionResponse,
EthersError,
TransactionReceipt,
Signer,
ErrorDescription,
} from "ethers";
import { OnChainMessageStatus } from "@consensys/linea-sdk";
import { MessageStatus } from "../../core/enums";
import {
IMessageClaimingProcessor,
MessageClaimingProcessorConfig,
} from "../../core/services/processors/IMessageClaimingProcessor";
import { ILogger } from "../../core/utils/logging/ILogger";
import { IMessageServiceContract } from "../../core/services/contracts/IMessageServiceContract";
import { Message } from "../../core/entities/Message";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
import { ITransactionValidationService } from "../../core/services/ITransactionValidationService";
export class MessageClaimingProcessor implements IMessageClaimingProcessor {
private readonly maxNonceDiff: number;
/**
* Initializes a new instance of the `MessageClaimingProcessor`.
*
* @param {IMessageServiceContract} messageServiceContract - An instance of a class implementing the `IMessageServiceContract` interface, used to interact with the blockchain contract.
* @param {Signer} signer - An instance of a class implementing the `Signer` interface, used to query blockchain data.
* @param {IMessageDBService} databaseService - An instance of a class implementing the `IMessageDBService` interface, used for storing and retrieving message data.
* @param {ITransactionValidationService} transactionValidationService - An instance of a class implementing the `ITransactionValidationService` interface, used for validating transactions.
* @param {MessageClaimingProcessorConfig} config - Configuration for network-specific settings, including transaction submission timeout, maximum transaction retries, and gas limit.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages.
*/
constructor(
private readonly messageServiceContract: IMessageServiceContract<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription
>,
private readonly signer: Signer,
private readonly databaseService: IMessageDBService<TransactionResponse>,
private readonly transactionValidationService: ITransactionValidationService,
private readonly config: MessageClaimingProcessorConfig,
private readonly logger: ILogger,
) {
this.maxNonceDiff = Math.max(config.maxNonceDiff, 0);
}
/**
* Identifies the next message eligible for claiming and attempts to execute the claim transaction. It considers various factors such as gas estimation, profit margin, and rate limits to decide whether to proceed with the claim.
*
* @returns {Promise<void>} A promise that resolves when the processing is complete.
*/
public async process(): Promise<void> {
let nextMessageToClaim: Message | null = null;
try {
const nonce = await this.getNonce();
if (!nonce && nonce !== 0) {
this.logger.error("Nonce returned from getNonce is an invalid value (e.g. null or undefined)");
return;
}
nextMessageToClaim = await this.databaseService.getMessageToClaim(
this.config.originContractAddress,
this.config.profitMargin,
this.config.maxNumberOfRetries,
this.config.retryDelayInSeconds,
);
if (!nextMessageToClaim) {
return;
}
const messageStatus = await this.messageServiceContract.getMessageStatus(nextMessageToClaim.messageHash);
if (messageStatus === OnChainMessageStatus.CLAIMED) {
this.logger.info("Found already claimed message: messageHash=%s", nextMessageToClaim.messageHash);
nextMessageToClaim.edit({ status: MessageStatus.CLAIMED_SUCCESS });
await this.databaseService.updateMessage(nextMessageToClaim);
return;
}
const { hasZeroFee, isUnderPriced, isRateLimitExceeded, estimatedGasLimit, threshold, ...claimTxFees } =
await this.transactionValidationService.evaluateTransaction(
nextMessageToClaim,
this.config.feeRecipientAddress,
);
if (await this.handleZeroFee(hasZeroFee, nextMessageToClaim)) return;
if (await this.handleNonExecutable(nextMessageToClaim, estimatedGasLimit)) return;
nextMessageToClaim.edit({ claimGasEstimationThreshold: threshold });
await this.databaseService.updateMessage(nextMessageToClaim);
if (await this.handleUnderpriced(nextMessageToClaim, isUnderPriced, estimatedGasLimit, claimTxFees.maxFeePerGas))
return;
if (this.handleRateLimitExceeded(nextMessageToClaim, isRateLimitExceeded)) return;
await this.executeClaimTransaction(
nextMessageToClaim,
nonce,
estimatedGasLimit!,
claimTxFees.maxPriorityFeePerGas,
claimTxFees.maxFeePerGas,
);
} catch (e) {
await this.handleProcessingError(e, nextMessageToClaim);
}
}
/**
* Retrieves the current nonce for the claiming transactions, ensuring it is within an acceptable range compared to the last recorded nonce.
*
* @returns {Promise<number | null>} The nonce to use for the next transaction, or null if the nonce difference exceeds the configured maximum.
*/
private async getNonce(): Promise<number | null> {
const lastTxNonce = await this.databaseService.getLastClaimTxNonce(this.config.direction);
let nonce = await this.signer.getNonce();
if (lastTxNonce) {
if (lastTxNonce - nonce > this.maxNonceDiff) {
this.logger.warn(
"Last recorded nonce in db is higher than the latest nonce from blockchain and exceeds the diff limit, paused the claim message process now: nonceInDb=%s nonceOnChain=%s maxAllowedNonceDiff=%s",
lastTxNonce,
nonce,
this.maxNonceDiff,
);
return null;
}
nonce = Math.max(nonce, lastTxNonce + 1);
}
return nonce;
}
/**
* Executes the claim transaction for a message, updating the message repository with transaction details.
*
* @param {Message} message - The message object to claim.
* @param {number} nonce - The nonce to use for the transaction.
* @param {bigint} gasLimit - The gas limit for the transaction.
* @param {bigint} maxPriorityFeePerGas - The maximum priority fee per gas for the transaction.
* @param {bigint} maxFeePerGas - The maximum fee per gas for the transaction.
* @returns {Promise<void>} A promise that resolves when the transaction is executed.
*/
private async executeClaimTransaction(
message: Message,
nonce: number,
gasLimit: bigint,
maxPriorityFeePerGas: bigint,
maxFeePerGas: bigint,
): Promise<void> {
const claimTxResponsePromise = this.messageServiceContract.claim(
{
...message,
feeRecipient: this.config.feeRecipientAddress,
},
{ nonce, gasLimit, maxPriorityFeePerGas, maxFeePerGas },
);
await this.databaseService.updateMessageWithClaimTxAtomic(message, nonce, claimTxResponsePromise);
}
/**
* Handles messages with zero fee, updating their status and logging a warning.
*
* @param {boolean} hasZeroFee - Indicates whether the message has zero fee.
* @param {Message} message - The message object to handle.
* @returns {Promise<boolean>} A promise that resolves to `true` if the message has zero fee, `false` otherwise.
*/
private async handleZeroFee(hasZeroFee: boolean, message: Message): Promise<boolean> {
if (hasZeroFee) {
this.logger.warn(
"Found message with zero fee. This message will not be processed: messageHash=%s",
message.messageHash,
);
message.edit({ status: MessageStatus.ZERO_FEE });
await this.databaseService.updateMessage(message);
return true;
}
return false;
}
/**
* Handles non-executable messages, updating their status and logging a warning.
*
* @param {Message} message - The message object to handle.
* @param {bigint | null} estimatedGasLimit - The estimated gas limit for the transaction.
* @returns {Promise<boolean>} A promise that resolves to `true` if the message is non-executable, `false` otherwise.
*/
private async handleNonExecutable(message: Message, estimatedGasLimit: bigint | null): Promise<boolean> {
if (!estimatedGasLimit) {
this.logger.warn(
"Estimated gas limit is higher than the max allowed gas limit for this message: messageHash=%s messageInfo=%s estimatedGasLimit=%s maxAllowedGasLimit=%s",
message.messageHash,
message.toString(),
// TODO: fix this
estimatedGasLimit?.toString(),
this.config.maxClaimGasLimit.toString(),
);
message.edit({ status: MessageStatus.NON_EXECUTABLE });
await this.databaseService.updateMessage(message);
return true;
}
return false;
}
/**
* Handles underpriced messages, updating their status and logging a warning.
*
* @param {Message} message - The message object to handle.
* @param {boolean} isUnderPriced - Indicates whether the message is underpriced.
* @param {bigint | null} estimatedGasLimit - The estimated gas limit for the transaction.
* @param {bigint} maxFeePerGas - The maximum fee per gas for the transaction.
* @returns {Promise<boolean>} A promise that resolves to `true` if the message is underpriced, `false` otherwise.
*/
private async handleUnderpriced(
message: Message,
isUnderPriced: boolean,
estimatedGasLimit: bigint | null,
maxFeePerGas: bigint,
): Promise<boolean> {
if (isUnderPriced) {
this.logger.warn(
"Fee underpriced found in this message: messageHash=%s messageInfo=%s transactionGasLimit=%s maxFeePerGas=%s",
message.messageHash,
message.toString(),
estimatedGasLimit?.toString(),
maxFeePerGas.toString(),
);
message.edit({ status: MessageStatus.FEE_UNDERPRICED });
await this.databaseService.updateMessage(message);
return true;
}
return false;
}
/**
* Handles messages that have exceeded the rate limit, logging a warning.
*
* @param {Message} message - The message object to handle.
* @param {boolean} isRateLimitExceeded - Indicates whether the rate limit has been exceeded.
* @returns {boolean} `true` if the rate limit has been exceeded, `false` otherwise.
*/
private handleRateLimitExceeded(message: Message, isRateLimitExceeded: boolean): boolean {
if (isRateLimitExceeded) {
this.logger.warn(
"Rate limit exceeded for this message. It will be reprocessed later: messageHash=%s",
message.messageHash,
);
return true;
}
return false;
}
/**
* Handles errors that occur during the processing of messages, updating their status if necessary and logging the error.
*
* @param {unknown} e - The error that occurred.
* @param {Message | null} message - The message object being processed when the error occurred.
* @returns {Promise<void>} A promise that resolves when the error has been handled.
*/
private async handleProcessingError(e: unknown, message: Message | null): Promise<void> {
const parsedError = ErrorParser.parseErrorWithMitigation(e as EthersError);
if (parsedError?.mitigation && !parsedError.mitigation.shouldRetry && message) {
message.edit({ status: MessageStatus.NON_EXECUTABLE });
await this.databaseService.updateMessage(message);
}
this.logger.warnOrError(e, {
parsedError,
});
}
}

View File

@@ -0,0 +1,133 @@
import {
Block,
ContractTransactionResponse,
JsonRpcProvider,
TransactionReceipt,
TransactionRequest,
TransactionResponse,
} from "ethers";
import { serialize, isEmptyBytes } from "@consensys/linea-sdk";
import { ILineaRollupLogClient } from "../../core/clients/blockchain/ethereum/ILineaRollupLogClient";
import { IProvider } from "../../core/clients/blockchain/IProvider";
import { MessageFactory } from "../../core/entities/MessageFactory";
import { ILogger } from "../../core/utils/logging/ILogger";
import { MessageStatus } from "../../core/enums";
import { IL2MessageServiceLogClient } from "../../core/clients/blockchain/linea/IL2MessageServiceLogClient";
import {
IMessageSentEventProcessor,
MessageSentEventProcessorConfig,
} from "../../core/services/processors/IMessageSentEventProcessor";
import { IMessageDBService } from "../../core/persistence/IMessageDBService";
export class MessageSentEventProcessor implements IMessageSentEventProcessor {
private readonly maxBlocksToFetchLogs: number;
/**
* Initializes a new instance of the `MessageSentEventProcessor`.
*
* @param {IMessageDBService} databaseService - An instance of a class implementing the `IMessageDBService` interface, used for storing and retrieving message data.
* @param {ILineaRollupLogClient | IL2MessageServiceLogClient} logClient - An instance of a class implementing the `ILineaRollupLogClient` or the `IL2MessageServiceLogClient` interface for fetching message sent events from the blockchain.
* @param {IProvider} provider - An instance of a class implementing the `IProvider` interface, used to query blockchain data.
* @param {MessageSentEventProcessorConfig} config - Configuration for network-specific settings, including listener parameters and feature flags.
* @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages.
*/
constructor(
private readonly databaseService: IMessageDBService<ContractTransactionResponse>,
private readonly logClient: ILineaRollupLogClient | IL2MessageServiceLogClient,
private readonly provider: IProvider<
TransactionReceipt,
Block,
TransactionRequest,
TransactionResponse,
JsonRpcProvider
>,
private readonly config: MessageSentEventProcessorConfig,
private readonly logger: ILogger,
) {
this.maxBlocksToFetchLogs = Math.max(config.maxBlocksToFetchLogs, 0);
}
/**
* Calculates the starting block number for fetching events, ensuring it is within the valid range.
*
* @param {number} fromBlockNumber - The proposed starting block number.
* @param {number} toBlockNumber - The ending block number for the query range.
* @returns {number} The adjusted starting block number.
*/
private calculateFromBlockNumber(fromBlockNumber: number, toBlockNumber: number): number {
if (fromBlockNumber > toBlockNumber) {
return toBlockNumber;
}
return Math.max(fromBlockNumber, 0);
}
/**
* Fetches `MessageSent` events from the blockchain within a specified block range and stores them in the database.
*
* @param {number} fromBlock - The starting block number for fetching events.
* @param {number} fromBlockLogIndex - The log index within the starting block to begin processing events from.
* @returns {Promise<{ nextFromBlock: number; nextFromBlockLogIndex: number }>} The block number and log index to start fetching events from in the next iteration.
*/
public async process(
fromBlock: number,
fromBlockLogIndex: number,
): Promise<{ nextFromBlock: number; nextFromBlockLogIndex: number }> {
const latestBlockNumber = Math.max((await this.provider.getBlockNumber()) - this.config.blockConfirmation, 0);
const toBlock = Math.min(latestBlockNumber, fromBlock + this.maxBlocksToFetchLogs);
fromBlock = this.calculateFromBlockNumber(fromBlock, toBlock);
this.logger.info("Getting events fromBlock=%s toBlock=%s", fromBlock, toBlock);
const events = await this.logClient.getMessageSentEvents({
fromBlock,
toBlock,
fromBlockLogIndex,
});
this.logger.info("Number of fetched MessageSent events: %s", events.length);
for (const event of events) {
const shouldBeProcessed = this.shouldProcessMessage(event.calldata, event.messageHash);
const messageStatusToInsert = shouldBeProcessed ? MessageStatus.SENT : MessageStatus.EXCLUDED;
const message = MessageFactory.createMessage({
...event,
sentBlockNumber: event.blockNumber,
direction: this.config.direction,
status: messageStatusToInsert,
claimNumberOfRetry: 0,
});
await this.databaseService.insertMessage(message);
}
this.logger.info(`Messages hashes found: messageHashes=%s`, serialize(events.map((event) => event.messageHash)));
return { nextFromBlock: toBlock + 1, nextFromBlockLogIndex: 0 };
}
/**
* Determines whether a message should be processed based on its calldata and the configuration.
*
* @param {string} messageCalldata - The calldata of the message.
* @param {string} messageHash - The hash of the message.
* @returns {boolean} `true` if the message should be processed, `false` otherwise.
*/
private shouldProcessMessage(messageCalldata: string, messageHash: string): boolean {
if (isEmptyBytes(messageCalldata)) {
if (this.config.isEOAEnabled) {
return true;
}
} else {
if (this.config.isCalldataEnabled) {
return true;
}
}
this.logger.debug(
"Message has been excluded because target address is not an EOA or calldata is not empty: messageHash=%s",
messageHash,
);
return false;
}
}

View File

@@ -0,0 +1,166 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import {
ContractTransactionResponse,
ErrorDescription,
Overrides,
TransactionReceipt,
TransactionResponse,
Wallet,
} from "ethers";
import { DefaultGasProvider, LineaProvider, Provider, testingHelpers } from "@consensys/linea-sdk";
import {
DEFAULT_MAX_FEE_PER_GAS,
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
TEST_L1_SIGNER_PRIVATE_KEY,
testMessage,
} from "../../../utils/testing/constants";
import {
DEFAULT_GAS_ESTIMATION_PERCENTILE,
DEFAULT_MAX_CLAIM_GAS_LIMIT,
DEFAULT_MAX_FEE_PER_GAS_CAP,
DEFAULT_PROFIT_MARGIN,
} from "../../../core/constants";
import { EthereumTransactionValidationService } from "../../EthereumTransactionValidationService";
import { ILineaRollupClient } from "../../../core/clients/blockchain/ethereum/ILineaRollupClient";
describe("EthereumTransactionValidationService", () => {
let lineaTransactionValidationService: EthereumTransactionValidationService;
let gasProvider: DefaultGasProvider;
let lineaRollupClient: ILineaRollupClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
ErrorDescription
>;
beforeEach(() => {
const clients = testingHelpers.generateLineaRollupClient(
mock<Provider>(),
mock<LineaProvider>(),
TEST_CONTRACT_ADDRESS_1,
TEST_CONTRACT_ADDRESS_2,
"read-write",
new Wallet(TEST_L1_SIGNER_PRIVATE_KEY),
{
gasEstimationPercentile: DEFAULT_GAS_ESTIMATION_PERCENTILE,
maxFeePerGasCap: DEFAULT_MAX_FEE_PER_GAS_CAP,
enforceMaxGasFee: false,
},
);
lineaRollupClient = clients.lineaRollupClient;
gasProvider = clients.gasProvider;
lineaTransactionValidationService = new EthereumTransactionValidationService(lineaRollupClient, gasProvider, {
profitMargin: DEFAULT_PROFIT_MARGIN,
maxClaimGasLimit: DEFAULT_MAX_CLAIM_GAS_LIMIT,
});
jest.spyOn(gasProvider, "getGasFees").mockResolvedValueOnce({
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
});
afterEach(() => {
jest.resetAllMocks();
});
describe("evaluateTransaction", () => {
it("Should return transaction evaluation criteria with hasZeroFee = true", async () => {
const estimatedGasLimit = 50_000n;
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(false);
testMessage.fee = 0n;
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
expect(criteria).toStrictEqual({
estimatedGasLimit: estimatedGasLimit,
hasZeroFee: true,
isRateLimitExceeded: false,
isUnderPriced: true,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
threshold: 0,
});
});
it("Should return transaction evaluation criteria with isUnderPriced = true", async () => {
const estimatedGasLimit = 50_000n;
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(false);
testMessage.fee = 1n;
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
expect(criteria).toStrictEqual({
estimatedGasLimit: estimatedGasLimit,
hasZeroFee: false,
isRateLimitExceeded: false,
isUnderPriced: true,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
threshold: 0,
});
});
it("Should return transaction evaluation criteria with estimatedGasLimit = null", async () => {
const estimatedGasLimit = DEFAULT_MAX_CLAIM_GAS_LIMIT + 1n;
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(false);
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
expect(criteria).toStrictEqual({
estimatedGasLimit: null,
hasZeroFee: false,
isRateLimitExceeded: false,
isUnderPriced: true,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
threshold: 0,
});
});
it("Should return transaction evaluation criteria with isRateLimitExceeded = true", async () => {
const estimatedGasLimit = DEFAULT_MAX_CLAIM_GAS_LIMIT + 1n;
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(true);
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
expect(criteria).toStrictEqual({
estimatedGasLimit: null,
hasZeroFee: false,
isRateLimitExceeded: true,
isUnderPriced: true,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
threshold: 0,
});
});
it("Should return transaction evaluation criteria for a valid message", async () => {
const estimatedGasLimit = 50_000n;
jest.spyOn(lineaRollupClient, "estimateClaimGas").mockResolvedValueOnce(estimatedGasLimit);
jest.spyOn(lineaRollupClient, "isRateLimitExceeded").mockResolvedValueOnce(false);
testMessage.fee = 100000000000000000000n;
const criteria = await lineaTransactionValidationService.evaluateTransaction(testMessage);
expect(criteria).toStrictEqual({
estimatedGasLimit: estimatedGasLimit,
hasZeroFee: false,
isRateLimitExceeded: false,
isUnderPriced: false,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
threshold: 2000000000000000,
});
});
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import {
ContractTransactionResponse,
ErrorDescription,
Overrides,
Signer,
TransactionReceipt,
TransactionResponse,
} from "ethers";
import { Direction } from "@consensys/linea-sdk";
import { TestLogger } from "../../../utils/testing/helpers";
import { MessageStatus } from "../../../core/enums";
import { testL1NetworkConfig, testMessage, DEFAULT_MAX_FEE_PER_GAS } from "../../../utils/testing/constants";
import { EthereumMessageDBService } from "../../persistence/EthereumMessageDBService";
import { L2ClaimMessageTransactionSizeProcessor } from "../L2ClaimMessageTransactionSizeProcessor";
import { L2ClaimTransactionSizeCalculator } from "../../L2ClaimTransactionSizeCalculator";
import { IL2MessageServiceClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceClient";
describe("L2ClaimMessageTransactionSizeProcessor", () => {
let transactionSizeProcessor: L2ClaimMessageTransactionSizeProcessor;
let transactionSizeCalculator: L2ClaimTransactionSizeCalculator;
const databaseService = mock<EthereumMessageDBService>();
const l2ContractClientMock =
mock<
IL2MessageServiceClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
Signer,
ErrorDescription
>
>();
const logger = new TestLogger(L2ClaimMessageTransactionSizeProcessor.name);
beforeEach(() => {
transactionSizeCalculator = new L2ClaimTransactionSizeCalculator(l2ContractClientMock);
transactionSizeProcessor = new L2ClaimMessageTransactionSizeProcessor(
databaseService,
l2ContractClientMock,
transactionSizeCalculator,
{
direction: Direction.L1_TO_L2,
originContractAddress: testL1NetworkConfig.messageServiceContractAddress,
},
logger,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("process", () => {
it("Should return if getNFirstMessagesByStatus returns empty list", async () => {
const transactionSizeCalculatorSpy = jest.spyOn(transactionSizeCalculator, "calculateTransactionSize");
jest.spyOn(databaseService, "getNFirstMessagesByStatus").mockResolvedValue([]);
await transactionSizeProcessor.process();
expect(transactionSizeCalculatorSpy).toHaveBeenCalledTimes(0);
});
it("Should log as error when calculateTransactionSize failed", async () => {
const testGasLimit = 50_000n;
const loggerErrorSpy = jest.spyOn(logger, "error");
jest.spyOn(databaseService, "getNFirstMessagesByStatus").mockResolvedValue([testMessage]);
jest.spyOn(l2ContractClientMock, "estimateClaimGasFees").mockResolvedValue({
gasLimit: testGasLimit,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
jest
.spyOn(transactionSizeCalculator, "calculateTransactionSize")
.mockRejectedValueOnce(new Error("calculation failed."));
await transactionSizeProcessor.process();
expect(loggerErrorSpy).toHaveBeenCalledTimes(1);
expect(loggerErrorSpy).toHaveBeenCalledWith(new Error("calculation failed."));
});
it("Should log as info and call updateMessage if the transaction size calculation succeed", async () => {
const testGasLimit = 50_000n;
const testTransactionSize = 100;
const loggerInfoSpy = jest.spyOn(logger, "info");
jest.spyOn(databaseService, "getNFirstMessagesByStatus").mockResolvedValue([testMessage]);
jest.spyOn(databaseService, "updateMessage").mockImplementationOnce(jest.fn());
jest.spyOn(l2ContractClientMock, "estimateClaimGasFees").mockResolvedValue({
gasLimit: testGasLimit,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
jest.spyOn(transactionSizeCalculator, "calculateTransactionSize").mockResolvedValue(testTransactionSize);
const testMessageEditSpy = jest.spyOn(testMessage, "edit");
const databaseServiceMockUpdateSpy = jest.spyOn(databaseService, "updateMessage");
await transactionSizeProcessor.process();
expect(loggerInfoSpy).toHaveBeenCalledTimes(1);
expect(loggerInfoSpy).toHaveBeenCalledWith(
"Message transaction size and gas limit have been computed: messageHash=%s transactionSize=%s gasLimit=%s",
testMessage.messageHash,
testTransactionSize,
testGasLimit,
);
expect(testMessageEditSpy).toHaveBeenCalledTimes(1);
expect(testMessageEditSpy).toHaveBeenCalledWith({
claimTxGasLimit: Number(testGasLimit),
compressedTransactionSize: testTransactionSize,
status: MessageStatus.TRANSACTION_SIZE_COMPUTED,
});
expect(databaseServiceMockUpdateSpy).toHaveBeenCalledTimes(1);
expect(databaseServiceMockUpdateSpy).toHaveBeenCalledWith(testMessage);
});
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, beforeEach } from "@jest/globals";
import { mock } from "jest-mock-extended";
import {
ContractTransactionResponse,
ErrorDescription,
Overrides,
Signer,
TransactionReceipt,
TransactionResponse,
Wallet,
} from "ethers";
import { LineaProvider, serialize, testingHelpers } from "@consensys/linea-sdk";
import {
DEFAULT_MAX_FEE_PER_GAS,
TEST_CONTRACT_ADDRESS_2,
TEST_L2_SIGNER_PRIVATE_KEY,
testMessage,
} from "../../../utils/testing/constants";
import { EthereumMessageDBService } from "../../persistence/EthereumMessageDBService";
import { L2ClaimTransactionSizeCalculator } from "../../L2ClaimTransactionSizeCalculator";
import { DEFAULT_MAX_FEE_PER_GAS_CAP } from "../../../core/constants";
import { BaseError } from "../../../core/errors";
import { IL2MessageServiceClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceClient";
describe("L2ClaimTransactionSizeCalculator", () => {
let transactionSizeCalculator: L2ClaimTransactionSizeCalculator;
const databaseService = mock<EthereumMessageDBService>();
let l2ContractClient: IL2MessageServiceClient<
Overrides,
TransactionReceipt,
TransactionResponse,
ContractTransactionResponse,
Signer,
ErrorDescription
>;
beforeEach(() => {
const clients = testingHelpers.generateL2MessageServiceClient(
mock<LineaProvider>(),
TEST_CONTRACT_ADDRESS_2,
"read-only",
undefined,
{
maxFeePerGasCap: DEFAULT_MAX_FEE_PER_GAS_CAP,
enforceMaxGasFee: false,
},
);
l2ContractClient = clients.l2MessageServiceClient;
transactionSizeCalculator = new L2ClaimTransactionSizeCalculator(l2ContractClient);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("process", () => {
it("Should throw an error if signer is undefined", async () => {
jest.spyOn(databaseService, "getNFirstMessagesByStatus").mockResolvedValue([]);
await expect(
transactionSizeCalculator.calculateTransactionSize(testMessage, {
gasLimit: 50_000n,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
}),
).rejects.toThrow(
new BaseError(`Transaction size calculation error: ${serialize(new BaseError("Signer is undefined."))}`),
);
});
it("Should return transaction size", async () => {
jest.spyOn(l2ContractClient, "getSigner").mockReturnValueOnce(new Wallet(TEST_L2_SIGNER_PRIVATE_KEY));
const transactionSize = await transactionSizeCalculator.calculateTransactionSize(testMessage, {
gasLimit: 50_000n,
maxPriorityFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
maxFeePerGas: DEFAULT_MAX_FEE_PER_GAS,
});
expect(transactionSize).toStrictEqual(77);
});
});
});

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