chore: Initial commit

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

3
operations/.dockerignore Normal file
View File

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

3
operations/.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
dist
node_modules
src/synctx/

16
operations/.eslintrc.js Normal file
View File

@@ -0,0 +1,16 @@
module.exports = {
env: {
commonjs: true,
mocha: false,
es2021: false,
jest: true,
},
extends: ["../.eslintrc.js"],
plugins: ["prettier"],
parserOptions: {
sourceType: "module",
},
rules: {
"prettier/prettier": "error",
},
};

View File

@@ -0,0 +1,3 @@
dist
node_modules
src/synctx/

View File

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

59
operations/Dockerfile Normal file
View File

@@ -0,0 +1,59 @@
# syntax=docker/dockerfile:1.2
FROM node:18-slim AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /usr/src/app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./
COPY ./operations/package.json ./operations/package.json
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prefer-offline && npm install -g typescript
COPY . .
RUN rm -rf src/synctx && pnpm run -F operations build
FROM node:18-slim as builder-synctx
ARG TARGETPLATFORM
WORKDIR /opt/synctx
COPY ./operations/src/synctx .
RUN apt-get update \
&& apt-get install --no-install-recommends -y git xz-utils perl \
&& OCLIF_TARGET=$(echo ${TARGETPLATFORM} | sed 's#/#-#;s#amd64#x64#') \
&& yarn global add oclif && yarn && yarn run build && yarn install --production --ignore-scripts --prefer-offline \
&& git init \
&& git config user.email "sre@consensys.net" \
&& git config user.name "cs-sre" \
&& git commit --allow-empty -m "dummy commit" \
&& oclif pack tarballs --targets="${OCLIF_TARGET}" \
&& tar -xvf dist/synctx-*.tar.gz
FROM node:18-slim as release
ENV NODE_ENV production
ENV PATH="${PATH}:/opt/synctx/bin"
WORKDIR /usr/src/app
# Install pnpm
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod --frozen-lockfile --prefer-offline
COPY --chown=node:node --from=builder /usr/src/app/operations/dist ./dist
COPY --chown=node:node --from=builder-synctx /opt/synctx/synctx /opt/synctx/
USER node:node
ENTRYPOINT ["node"]

View File

@@ -0,0 +1,49 @@
FROM node:18-alpine AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install -g typescript && npm ci
COPY . .
RUN rm -rf src/synctx && npm run build
FROM node:18-alpine as builder-synctx
ARG TARGETPLATFORM
WORKDIR /opt/synctx
COPY src/synctx .
RUN apk add --no-cache git perl-utils xz \
&& OCLIF_TARGET=$(echo ${TARGETPLATFORM} | sed 's#/#-#;s#amd64#x64#') \
&& yarn global add oclif && yarn && yarn run build \
&& yarn install --production --ignore-scripts --prefer-offline \
&& git init \
&& git config user.email "sre@consensys.net" \
&& git config user.name "cs-sre" \
&& git commit --allow-empty -m "dummy commit" \
&& oclif pack tarballs --targets="${OCLIF_TARGET}" \
&& tar -xvf dist/synctx-*.tar.gz
FROM node:18-alpine as release
ENV NODE_ENV production
ENV PATH="${PATH}:/opt/synctx/bin"
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci && npm cache clean --force \
&& apk add --no-cache curl jq bash gcompat glibc
COPY --chown=node:node --from=builder /usr/src/app/dist ./dist
COPY --chown=node:node --from=builder-synctx /opt/synctx/synctx /opt/synctx/
USER node:node
ENTRYPOINT ["node"]

12
operations/jest.config.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
testRegex: "test.ts$",
verbose: true,
collectCoverage: true,
collectCoverageFrom: ["src/**/*.ts"],
coverageReporters: ["html", "lcov", "text"],
testPathIgnorePatterns: ["src/common/index.ts", "src/ethTransfer/index.ts", "src/synctx/src/index.ts"],
coveragePathIgnorePatterns: ["src/common/index.ts", "src/ethTransfer/index.ts", "src/synctx/src/index.ts"],
};

31
operations/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "operations",
"version": "1.0.0",
"description": "Operations scripts",
"author": "Consensys Software Inc.",
"license": "Apache-2.0",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"prettier": "prettier -c '**/*.{js,ts}'",
"prettier:fix": "prettier -w '**/*.{js,ts}'",
"lint:ts": "npx eslint '**/*.{js,ts}'",
"lint:ts:fix": "npx eslint --fix '**/*.{js,ts}'",
"test": "npx jest --bail --detectOpenHandles --forceExit",
"lint:fix": "npm run lint:ts:fix && npm run prettier:fix",
"clean": "rimraf node_modules"
},
"dependencies": {
"axios": "^1.6.0",
"ethers": "^6.8.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.7",
"@types/yargs": "^17.0.29",
"jest": "^29.7.0",
"jest-mock-extended": "^3.0.5",
"ts-jest": "^29.1.1",
"rimraf": "^3.0.2"
}
}

View File

@@ -0,0 +1,152 @@
import { describe, jest, it, expect, afterAll } from "@jest/globals";
import axios from "axios";
import { JsonRpcProvider, Signature, TransactionLike, TransactionResponse, ethers } from "ethers";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { estimateTransactionGas, executeTransaction, getWeb3SignerSignature } from "../transactions";
jest.mock("axios");
const transaction: TransactionLike = {
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
value: 1n,
};
describe("Transactions", () => {
let providerMock: MockProxy<JsonRpcProvider>;
beforeAll(() => {
providerMock = mock<JsonRpcProvider>();
});
afterAll(() => {
mockClear(providerMock);
});
describe("getWeb3SignerSignature", () => {
const web3SignerUrl = "http://localhost:9000";
const web3SignerPublicKey = ethers.hexlify(ethers.randomBytes(64));
it("should throw an error when the axios request failed", async () => {
jest.spyOn(axios, "post").mockRejectedValueOnce(new Error("http error"));
await expect(getWeb3SignerSignature(web3SignerUrl, web3SignerPublicKey, transaction)).rejects.toThrowError(
`Web3SignerError: ${JSON.stringify("http error")}`,
);
});
it("should return the signature", async () => {
jest.spyOn(axios, "post").mockResolvedValueOnce({ data: "0xaaaaaa" });
expect(await getWeb3SignerSignature(web3SignerUrl, web3SignerPublicKey, transaction)).toStrictEqual("0xaaaaaa");
});
});
describe("estimateTransactionGas", () => {
it("should throw an error when the transaction gas estimation failed", async () => {
jest.spyOn(providerMock, "estimateGas").mockRejectedValueOnce(new Error("estimated gas error"));
await expect(estimateTransactionGas(providerMock, transaction)).rejects.toThrow(
`GasEstimationError: ${JSON.stringify("estimated gas error")}`,
);
});
it("should return estimated transaction gas limit", async () => {
jest.spyOn(providerMock, "estimateGas").mockResolvedValueOnce(100_000n);
expect(await estimateTransactionGas(providerMock, transaction)).toStrictEqual(100_000n);
});
});
describe("executeTransaction", () => {
it("should throw an error when the transaction sent failed", async () => {
jest.spyOn(providerMock, "broadcastTransaction").mockRejectedValueOnce(new Error("broadcast transaction error"));
await expect(
executeTransaction(providerMock, {
from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
value: ethers.parseEther("1").toString(),
gasPrice: "1606627962",
maxPriorityFeePerGas: "1000000000",
maxFeePerGas: "2213255924",
gasLimit: "21001",
nonce: 3,
data: "0x",
chainId: 31337,
signature: Signature.from({
r: "0xac6fbe2571f913aa5e88596b50e6a9ab01833e94187b9cf4b0cc86e7fccb6ca8",
s: "0x405936c6e570b8e877ac391124d04ac7bff11a49c6b50b78bc057442d9f98262",
v: 1,
}),
}),
).rejects.toThrowError(`TransactionError: ${JSON.stringify("broadcast transaction error")}`);
});
it("should successfully execute the transaction", async () => {
const expectedTransactionReceipt = {
blockHash: "0xcd224ee1fc35433bb96dbc81f3c2a0dc67d97f84ef41d9826b02b039bc3da055",
blockNumber: 4,
confirmations: 1,
contractAddress: null,
cumulativeGasUsed: "21000",
effectiveGasPrice: "1606627962",
from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
gasUsed: "21000",
logs: [],
logsBloom:
"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
status: 1,
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
transactionHash: "0xcc0a7710f2bdaf8a1b34d1d62d0e5f65dab5c89e61d5cfab76a3e6f1fdc745dc",
transactionIndex: 0,
type: 2,
};
jest.spyOn(providerMock, "broadcastTransaction").mockResolvedValueOnce({
hash: "0x81a954827d5ed7b1693f1bc844fd99895e9c4ac9f47ff12f280cd9b5b7a200e5",
type: 2,
accessList: [],
blockHash: "0x33c47baf1c92aa0472fc2bc071acf7d5c1336f3f298d1074593ba87e9565518a",
blockNumber: 4,
index: 0,
from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
gasPrice: 1606627962n,
maxPriorityFeePerGas: 1000000000n,
maxFeePerGas: 2213255924n,
gasLimit: 21001n,
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
value: 1n,
nonce: 3,
data: "0x",
signature: Signature.from({
r: "0x65623e79d4875e745c0f390b05bae72786ef8920087c93b6be22a199932970b4",
s: "0x5969afc485f0d32debe16ac290b812d41e2538dac32f345bb5174f49f35789bf",
v: 1,
}),
chainId: 31337n,
wait: jest.fn().mockImplementationOnce(() => expectedTransactionReceipt),
} as unknown as TransactionResponse);
expect(
await executeTransaction(providerMock, {
hash: "0x81a954827d5ed7b1693f1bc844fd99895e9c4ac9f47ff12f280cd9b5b7a200e5",
type: 2,
accessList: [],
from: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
gasPrice: 1606627962n,
maxPriorityFeePerGas: 1000000000n,
maxFeePerGas: 2213255924n,
gasLimit: 21001n,
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
value: 1n,
nonce: 3,
data: "0x",
signature: {
r: "0x65623e79d4875e745c0f390b05bae72786ef8920087c93b6be22a199932970b4",
s: "0x5969afc485f0d32debe16ac290b812d41e2538dac32f345bb5174f49f35789bf",
v: 1,
},
chainId: 31337,
}),
).toStrictEqual(expectedTransactionReceipt);
});
});
});

View File

@@ -0,0 +1,92 @@
import { describe, afterEach, jest, it, expect, beforeEach } from "@jest/globals";
import { JsonRpcProvider } from "ethers";
import { MockProxy, mock, mockClear } from "jest-mock-extended";
import { get1559Fees } from "../utils";
describe("Utils", () => {
let providerMock: MockProxy<JsonRpcProvider>;
const MAX_FEE_PER_GAS_FROM_CONFIG = 100_000_000n;
const GAS_ESTIMATION_PERCENTILE = 15;
beforeEach(() => {
providerMock = mock<JsonRpcProvider>();
});
afterEach(() => {
mockClear(providerMock);
});
describe("get1559Fees", () => {
it("should throw an error if maxPriorityFee is greater than maxFeePerGas", async () => {
jest.spyOn(providerMock, "getBlockNumber").mockResolvedValueOnce(1);
const sendSpy = jest.spyOn(providerMock, "send").mockResolvedValueOnce({
baseFeePerGas: ["0x3da8e7618", "0x3e1ba3b1b", "0x3dfd72b90", "0x3d64eee76", "0x3d4da2da0", "0x3ccbcac6b"],
gasUsedRatio: [0.5290747666666666, 0.49240453333333334, 0.4615576, 0.49407083333333335, 0.4669053],
oldestBlock: "0xfab8ac",
reward: [
["0x59682f00", "0x59682f00"],
["0x59682f00", "0x59682f00"],
["0x3b9aca00", "0x59682f00"],
["0x510b0870", "0x59682f00"],
["0x3b9aca00", "0x59682f00"],
],
});
await expect(
get1559Fees(providerMock, MAX_FEE_PER_GAS_FROM_CONFIG, GAS_ESTIMATION_PERCENTILE),
).rejects.toThrowError(
`Estimated miner tip of ${1_271_935_510} exceeds configured max fee per gas of ${MAX_FEE_PER_GAS_FROM_CONFIG.toString()}.`,
);
expect(sendSpy).toHaveBeenCalledTimes(1);
});
it("should return maxFeePerGas and maxPriorityFeePerGas", async () => {
jest.spyOn(providerMock, "getBlockNumber").mockResolvedValueOnce(1);
const sendSpy = jest.spyOn(providerMock, "send").mockResolvedValueOnce({
baseFeePerGas: ["0x3da8e7618", "0x3e1ba3b1b", "0x3dfd72b90", "0x3d64eee76", "0x3d4da2da0", "0x3ccbcac6b"],
gasUsedRatio: [0.5290747666666666, 0.49240453333333334, 0.4615576, 0.49407083333333335, 0.4669053],
oldestBlock: "0xfab8ac",
reward: [
["0xe4e1c0", "0xe4e1c0"],
["0xe4e1c0", "0xe4e1c0"],
["0xe4e1c0", "0xe4e1c0"],
["0xcf7867", "0xe4e1c0"],
["0x5f5e100", "0xe4e1c0"],
],
});
const fees = await get1559Fees(providerMock, MAX_FEE_PER_GAS_FROM_CONFIG, GAS_ESTIMATION_PERCENTILE);
expect(fees).toStrictEqual({
maxFeePerGas: MAX_FEE_PER_GAS_FROM_CONFIG,
maxPriorityFeePerGas: 31_719_355n,
});
expect(sendSpy).toHaveBeenCalledTimes(1);
});
it("should return maxFeePerGas from config when maxFeePerGas and maxPriorityFeePerGas === 0", async () => {
jest.spyOn(providerMock, "getBlockNumber").mockResolvedValueOnce(1);
const sendSpy = jest.spyOn(providerMock, "send").mockResolvedValueOnce({
baseFeePerGas: ["0x0", "0x0", "0x0", "0x0", "0x0", "0x0"],
gasUsedRatio: [0, 0, 0, 0, 0],
oldestBlock: "0xfab8ac",
reward: [
["0x0", "0x0"],
["0x0", "0x0"],
["0x0", "0x0"],
["0x0", "0x0"],
["0x0", "0x0"],
],
});
const fees = await get1559Fees(providerMock, MAX_FEE_PER_GAS_FROM_CONFIG, GAS_ESTIMATION_PERCENTILE);
expect(fees).toStrictEqual({
maxFeePerGas: MAX_FEE_PER_GAS_FROM_CONFIG,
});
expect(sendSpy).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,3 @@
export { Fees, FeeHistory } from "./types";
export { get1559Fees } from "./utils";
export { getWeb3SignerSignature, estimateTransactionGas, executeTransaction } from "./transactions";

View File

@@ -0,0 +1,43 @@
import axios from "axios";
import { ethers, TransactionLike } from "ethers";
export async function getWeb3SignerSignature(
web3SignerUrl: string,
web3SignerPublicKey: string,
transaction: TransactionLike,
): Promise<string> {
try {
const { data } = await axios.post(`${web3SignerUrl}/api/v1/eth1/sign/${web3SignerPublicKey}`, {
data: ethers.Transaction.from(transaction).unsignedSerialized,
});
return data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
throw new Error(`Web3SignerError: ${JSON.stringify(error.message)}`);
}
}
export async function estimateTransactionGas(
provider: ethers.JsonRpcProvider,
transaction: ethers.TransactionRequest,
): Promise<bigint> {
try {
return await provider.estimateGas(transaction);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
throw new Error(`GasEstimationError: ${JSON.stringify(error.message)}`);
}
}
export async function executeTransaction(
provider: ethers.JsonRpcProvider,
transaction: TransactionLike,
): Promise<ethers.TransactionReceipt | null> {
try {
const tx = await provider.broadcastTransaction(ethers.Transaction.from(transaction).serialized);
return await tx.wait();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
throw new Error(`TransactionError: ${JSON.stringify(error.message)}`);
}
}

View File

@@ -0,0 +1,11 @@
export type Fees = {
maxFeePerGas: bigint;
maxPriorityFeePerGas?: bigint;
};
export type FeeHistory = {
oldestBlock: number;
reward: string[][];
baseFeePerGas: string[];
gasUsedRatio: number[];
};

View File

@@ -0,0 +1,32 @@
import { ethers } from "ethers";
import { FeeHistory, Fees } from "./types";
export async function get1559Fees(
provider: ethers.JsonRpcProvider,
maxFeePerGasFromConfig: bigint,
percentile: number,
): Promise<Fees> {
const { reward, baseFeePerGas }: FeeHistory = await provider.send("eth_feeHistory", ["0x4", "latest", [percentile]]);
const maxPriorityFeePerGas =
reward.reduce((acc: bigint, currentValue: string[]) => acc + BigInt(currentValue[0]), 0n) / BigInt(reward.length);
if (maxPriorityFeePerGas && maxPriorityFeePerGas > maxFeePerGasFromConfig) {
throw new Error(
`Estimated miner tip of ${maxPriorityFeePerGas} exceeds configured max fee per gas of ${maxFeePerGasFromConfig}.`,
);
}
const maxFeePerGas = BigInt(baseFeePerGas[baseFeePerGas.length - 1]) * 2n + maxPriorityFeePerGas;
if (maxFeePerGas > 0n && maxPriorityFeePerGas > 0n) {
return {
maxPriorityFeePerGas,
maxFeePerGas: maxFeePerGas > maxFeePerGasFromConfig ? maxFeePerGasFromConfig : maxFeePerGas,
};
}
return {
maxFeePerGas: maxFeePerGasFromConfig,
};
}

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from "@jest/globals";
import { ethers } from "ethers";
import { isValidUrl, sanitizeAddress, sanitizeETHThreshold, sanitizeHexString, sanitizeUrl } from "../cli";
describe("CLI", () => {
describe("sanitizeAddress", () => {
it("should throw an error when the input is not a valid Ethereum address", () => {
const invalidAddress = "0x0a0e";
expect(() => sanitizeAddress("Address")(invalidAddress)).toThrow(`Address is not a valid Ethereum address.`);
});
it("should return the input when it is a valid Ethereum address", () => {
const address = ethers.hexlify(ethers.randomBytes(20));
expect(sanitizeAddress("Address")(address)).toStrictEqual(address);
});
});
describe("isValidUrl", () => {
it("should return false when the url is not valid", () => {
const invalidUrl = "www.test.com";
expect(isValidUrl(invalidUrl, ["http:", "https:"])).toBeFalsy();
});
it("should return false when the url protocol is not allowed", () => {
const invalidUrl = "tcp://test.com";
expect(isValidUrl(invalidUrl, ["http:", "https:"])).toBeFalsy();
});
it("should return true when the url valid", () => {
const url = "http://test.com";
expect(isValidUrl(url, ["http:", "https:"])).toBeTruthy();
});
});
describe("sanitizeUrl", () => {
it("should throw an error when the input is not a valid url", () => {
const invalidUrl = "www.test.com";
expect(() => sanitizeUrl("Url", ["http:", "https:"])(invalidUrl)).toThrow(
`Url, with value: ${invalidUrl} is not a valid URL`,
);
});
it("should return the input when it is a valid url", () => {
const url = "http://test.com";
expect(sanitizeUrl("Url", ["http:", "https:"])(url)).toStrictEqual(url);
});
});
describe("sanitizeHexString", () => {
it("should throw an error when the input is not a hex string", () => {
const invalidHexString = "0a1f";
const expectedLength = 2;
expect(() => sanitizeHexString("HexString", expectedLength)(invalidHexString)).toThrow(
`HexString must be hexadecimal string of length ${expectedLength}.`,
);
});
it("should throw an error when the input length is not equal to the expected length", () => {
const hexString = "0x0a1f";
const expectedLength = 4;
expect(() => sanitizeHexString("HexString", expectedLength)(hexString)).toThrow(
`HexString must be hexadecimal string of length ${expectedLength}.`,
);
});
it("should return the input when it is a hex string", () => {
const hexString = "0x0a1f";
const expectedLength = 2;
expect(sanitizeHexString("HexString", expectedLength)(hexString)).toStrictEqual(hexString);
});
});
describe("sanitizeETHThreshold", () => {
it("should throw an error when the input threshold is less than 1 ETH", () => {
const invalidThreshold = "0.5";
expect(() => sanitizeETHThreshold()(invalidThreshold)).toThrow("Threshold must be higher than 1 ETH");
});
it("should return the input when it is higher than 1 ETH", () => {
const threshold = "2";
expect(sanitizeETHThreshold()(threshold)).toStrictEqual(threshold);
});
});
});

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "@jest/globals";
import { ethers } from "ethers";
import { calculateRewards } from "../utils";
describe("Utils", () => {
describe("calculateRewards", () => {
it("should return 0 when the balance is less than 1 ETH", () => {
const balance = ethers.parseEther("0.5");
expect(calculateRewards(balance)).toStrictEqual(0n);
});
it("should return Math.floor(balance - 1 ETH) when balance > Number.MAX_SAFE_INTEGER (2^53 - 1)", () => {
const balance = ethers.parseEther("9999999999999999999999999999999");
expect(calculateRewards(balance)).toStrictEqual(ethers.parseEther("9999999999999999999999999999998"));
});
it("should return Math.floor(balance - 1 ETH) when balance < Number.MAX_SAFE_INTEGER (2^53 - 1)", () => {
const balance = ethers.parseEther("101.55");
expect(calculateRewards(balance)).toStrictEqual(ethers.parseEther("100"));
});
});
});

View File

@@ -0,0 +1,48 @@
import { ethers } from "ethers";
function sanitizeAddress(argName: string) {
return (input: string) => {
if (!ethers.isAddress(input)) {
throw new Error(`${argName} is not a valid Ethereum address.`);
}
return input;
};
}
function isValidUrl(input: string, allowedProtocols: string[]): boolean {
try {
const url = new URL(input);
return allowedProtocols.includes(url.protocol);
} catch (e) {
return false;
}
}
function sanitizeUrl(argName: string, allowedProtocols: string[]) {
return (input: string) => {
if (!isValidUrl(input, allowedProtocols)) {
throw new Error(`${argName}, with value: ${input} is not a valid URL`);
}
return input;
};
}
function sanitizeHexString(argName: string, expectedLength: number) {
return (input: string) => {
if (!ethers.isHexString(input, expectedLength)) {
throw new Error(`${argName} must be hexadecimal string of length ${expectedLength}.`);
}
return input;
};
}
function sanitizeETHThreshold() {
return (input: string) => {
if (parseInt(input) <= 1) {
throw new Error("Threshold must be higher than 1 ETH");
}
return input;
};
}
export { sanitizeAddress, sanitizeHexString, sanitizeETHThreshold, sanitizeUrl, isValidUrl };

View File

@@ -0,0 +1,177 @@
import { ethers, TransactionLike } from "ethers";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { sanitizeAddress, sanitizeHexString, sanitizeUrl, sanitizeETHThreshold } from "./cli";
import { estimateTransactionGas, executeTransaction, get1559Fees, getWeb3SignerSignature } from "../common";
import { Config } from "./types";
import { calculateRewards } from "./utils";
const WEB3_SIGNER_PUBLIC_KEY_LENGTH = 64;
const DEFAULT_MAX_FEE_PER_GAS = "100000000000";
const DEFAULT_GAS_ESTIMATION_PERCENTILE = "10";
const argv = yargs(hideBin(process.argv))
.option("sender-address", {
describe: "Sender address",
type: "string",
demandOption: true,
coerce: sanitizeAddress("sender-address"),
})
.option("destination-address", {
describe: "Destination address",
type: "string",
demandOption: true,
coerce: sanitizeAddress("destination-address"),
})
.option("threshold", {
describe: "Balance threshold of Validator address",
type: "string",
demandOption: true,
coerce: sanitizeETHThreshold(),
})
.option("blockchain-rpc-url", {
describe: "Blockchain rpc url",
type: "string",
demandOption: true,
coerce: sanitizeUrl("blockchain-rpc-url", ["http:", "https:"]),
})
.option("web3-signer-url", {
describe: "Web3 Signer URL",
type: "string",
demandOption: true,
coerce: sanitizeUrl("web3-signer-url", ["http:", "https:"]),
})
.option("web3-signer-public-key", {
describe: "Web3 Signer Public Key",
type: "string",
demandOption: true,
coerce: sanitizeHexString("web3-signer-public-key", WEB3_SIGNER_PUBLIC_KEY_LENGTH),
})
.option("dry-run", {
describe: "Dry run flag",
type: "string",
demandOption: false,
})
.option("max-fee-per-gas", {
describe: "MaxFeePerGas in wei",
type: "string",
default: DEFAULT_MAX_FEE_PER_GAS,
demandOption: false,
})
.option("gas-estimation-percentile", {
describe: "Gas estimation percentile",
type: "string",
default: DEFAULT_GAS_ESTIMATION_PERCENTILE,
demandOption: false,
})
.parseSync();
function getConfig(args: typeof argv): Config {
const destinationAddress: string = args.destinationAddress;
const senderAddress = args.senderAddress;
const threshold = args.threshold;
const blockchainRpcUrl = args.blockchainRpcUrl;
const web3SignerUrl = args.web3SignerUrl;
const web3SignerPublicKey = args.web3SignerPublicKey;
const dryRunCheck = args.dryRun !== "false";
const maxFeePerGasArg = args.maxFeePerGas;
const gasEstimationPercentileArg = args.gasEstimationPercentile;
return {
destinationAddress,
senderAddress,
threshold,
blockchainRpcUrl,
web3SignerUrl,
web3SignerPublicKey,
maxFeePerGas: BigInt(maxFeePerGasArg),
gasEstimationPercentile: parseInt(gasEstimationPercentileArg),
dryRun: dryRunCheck,
};
}
const main = async (args: typeof argv) => {
const {
destinationAddress,
senderAddress,
threshold,
blockchainRpcUrl,
web3SignerUrl,
web3SignerPublicKey,
maxFeePerGas,
gasEstimationPercentile,
dryRun,
} = getConfig(args);
const provider = new ethers.JsonRpcProvider(blockchainRpcUrl);
const [{ chainId }, senderBalance, fees, nonce] = await Promise.all([
provider.getNetwork(),
provider.getBalance(senderAddress),
get1559Fees(provider, maxFeePerGas, gasEstimationPercentile),
provider.getTransactionCount(senderAddress),
]);
if (senderBalance <= ethers.parseEther(threshold)) {
console.log(`Sender balance (${ethers.formatEther(senderBalance)} ETH) is less than threshold. No action needed.`);
return;
}
const rewards = calculateRewards(senderBalance);
if (rewards == 0n) {
console.log(`No rewards to send.`);
return;
}
const transactionRequest: TransactionLike = {
to: destinationAddress,
value: rewards,
type: 2,
chainId,
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
nonce: nonce,
};
const transactionGasLimit = await estimateTransactionGas(provider, {
...transactionRequest,
from: senderAddress,
} as ethers.TransactionRequest);
const transaction: TransactionLike = {
...transactionRequest,
gasLimit: transactionGasLimit,
};
const signature = await getWeb3SignerSignature(web3SignerUrl, web3SignerPublicKey, transaction);
if (dryRun) {
console.log("Dryrun enabled: Skipping transaction submission to blockchain.");
console.log(`Here is the expected rewards: ${ethers.formatEther(rewards)} ETH`);
return;
}
const receipt = await executeTransaction(provider, { ...transaction, signature });
if (!receipt) {
throw new Error(`Transaction receipt not found for this transaction ${JSON.stringify(transaction)}`);
}
if (receipt.status == 0) {
throw new Error(`Transaction reverted. Receipt: ${JSON.stringify(receipt)}`);
}
console.log(
`Transaction succeed. Rewards sent: ${ethers.formatEther(rewards)} ETH. Receipt: ${JSON.stringify(receipt)}`,
);
console.log(`Rewards sent: ${ethers.formatEther(rewards)} ETH`);
};
main(argv)
.then(() => process.exit(0))
.catch((error) => {
console.error("The ETH transfer script failed with the following error:", error);
process.exit(1);
});

View File

@@ -0,0 +1,11 @@
export type Config = {
senderAddress: string;
destinationAddress: string;
threshold: string;
blockchainRpcUrl: string;
web3SignerUrl: string;
web3SignerPublicKey: string;
maxFeePerGas: bigint;
gasEstimationPercentile: number;
dryRun: boolean;
};

View File

@@ -0,0 +1,13 @@
import { ethers } from "ethers";
export function calculateRewards(balance: bigint): bigint {
const oneEth = ethers.parseEther("1");
if (balance < oneEth) {
return 0n;
}
const quotient = (balance - oneEth) / oneEth;
const flooredBalance = quotient * oneEth;
return flooredBalance;
}

View File

@@ -0,0 +1,87 @@
synctx
=================
Generated by [oclif](https://oclif.io)
Sync pending transactions from source to target node. Supports both Besu and Geth clients.
#### How Does It Work
The tool grabs pending transactions in the txpool for both the source and target node. Then, it compares the pending transactions in both pools to determine which transactions we need to sync, as some transactions are already persisted in the target node. The comparison is performmed on the transaction hash. To perform the actual sync, the tool will batch send transactions in groups of 10 (controlled by the --concurrency flag) to help with performance. The batch transactions are sent asynchronously, but the controlling concurrent loop is synchronous.
# Install
1. Download and extract the zip on the release page. Each release contains multiple distributions. Find the distribution that works for your machine type.
2. When unzipping the release contents, a `synctx` folder is extracted which contains the command line entrypoint inside the `bin` directory.
3. Move the extracted content to a path of your choosing, such as `/usr/local/synctx`
4. Edit your `PATH` variable to include synctx, `PATH="$PATH:/usr/local/synctx/bin` make sure to persist this change in your `.profile` | `.bash_profile` | `.zsh_profile`
5. Run `synctx --help` to confirm that the install has completed successfully.
#### Docker
As an alternative to downloading the distribution, you can also run the tool via Docker. This builds the distribution inside the image and exposes `synctx` as an entrypoint. From there, you may simply run `docker run synctx:latest --help`
Building the image can be done with `docker build . -t synctx:latest`
# How To Sync
To sync transactions between nodes, first make sure you have port-forwarded to the necessary Kubernetes services or pods.
`kubectl port-forward {service|pod}/{service_name|pod_name}`
[How to port-forward in K8s](https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/)
Then provide the source and target nodes, where the source node is the node pending transactions will be synced from.
`synctx -s http://localhost:8500 -t http://localhost:8501`
To perform a dry run and verify if the corrent nodes are targetted, include the `--dry-run` flag.
`synctx -s http://localhost:8500 -t http://localhost:8501 --dry-run`
# Ethstats
[Mainnet](https://ethstats.linea.build/)
[Testnet](https://ethstats.goerli.linea.build/)
# Local Development
It is highly recommended to read the official docs of `oclif` as it is used to generate the CLI.
To run the tool against your local changes (without constantly building it), run `./bin/dev.js`
# Commands
## `synctx`
```
USAGE
$ synctx -s <value> -t <value> [--dry-run] [--local] [-c <value>]
CONFIG FLAGS
-c, --concurrency=<value> [default: 10] number of concurrent batch requests
--dry-run enable dry run
--local enable local run, provide only forwarded ports
NODE FLAGS
-s, --source=<value> (required) source node to sync from
-t, --target=<value> (required) target node to sync to
EXAMPLES
$ synctx --source=8500 --target=8501 --local
$ synctx --source=http://geth-archive-1:8545 --target=http://geth-validator-1:8545 --concurrency=10
```
_See code: [dist/index.ts](https://github.com/scripts/synctx/blob/v0.0.0/dist/index.ts)_
## `synctx help [COMMANDS]`
Display help for synctx.
```
USAGE
$ synctx help [COMMANDS] [-n]
ARGUMENTS
COMMANDS Command to show help for.
FLAGS
-n, --nested-commands Include all nested commands in the output.
DESCRIPTION
Display help for synctx.
```
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v5.2.20/src/commands/help.ts)_
<!-- commandsstop -->

View File

@@ -0,0 +1,3 @@
@echo off
node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %*

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env -S node --loader ts-node/esm --no-warnings=ExperimentalWarning
// eslint-disable-next-line node/shebang
async function main() {
const { execute } = await import("@oclif/core");
await execute({ development: true, dir: import.meta.url });
}
await main();

View File

@@ -0,0 +1,3 @@
@echo off
node "%~dp0\run" %*

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env node
async function main() {
const { execute } = await import("@oclif/core");
await execute({ dir: import.meta.url });
}
await main();

15787
operations/src/synctx/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
{
"author": "cs-sre",
"bin": {
"synctx": "./bin/run.js"
},
"dependencies": {
"@oclif/core": "^3",
"@oclif/plugin-help": "^5",
"@oclif/plugin-plugins": "^3.7.0",
"ethers": "^5.7.2"
},
"description": "Sync pending transactions from source to target node",
"devDependencies": {
"@oclif/prettier-config": "^0.2.1",
"@oclif/test": "^3",
"@types/chai": "^4",
"@types/mocha": "^10",
"@types/node": "^18",
"chai": "^4",
"eslint": "^8",
"eslint-config-oclif": "^5",
"eslint-config-oclif-typescript": "^3",
"eslint-config-prettier": "^9.0.0",
"mocha": "^10",
"nodemon": "^3.0.1",
"oclif": "^4.0.3",
"shx": "^0.3.4",
"ts-node": "^10.9.1",
"typescript": "^5"
},
"peerDependencies": {
"ethers": "^5.7.2"
},
"engines": {
"node": ">=18.0.0"
},
"files": [
"/bin",
"/dist",
"/npm-shrinkwrap.json",
"/oclif.manifest.json"
],
"homepage": "https://github.com/scripts/synctx",
"license": "MIT",
"main": "",
"name": "synctx",
"oclif": {
"bin": "synctx",
"dirname": "synctx",
"commands": "./dist",
"default": ".",
"plugins": [
"@oclif/plugin-help",
"@oclif/plugin-plugins"
],
"topicSeparator": " ",
"topics": {
"hello": {
"description": "Say hello to the world and others"
}
}
},
"repository": "scripts/synctx",
"scripts": {
"build": "rm -rf dist && tsc -b",
"lint": "eslint . --ext .ts",
"postpack": "rm -f oclif.manifest.json",
"posttest": "npm run lint",
"prepack": "npm run build && oclif manifest && oclif readme",
"test": "mocha --forbid-only --full-trace \"test/**/*.test.ts\"",
"version": "oclif readme && git add README.md"
},
"version": "0.0.0",
"bugs": "https://github.com/scripts/synctx/issues",
"keywords": [
"oclif"
],
"types": "dist/index.d.ts",
"exports": "./lib/index.js",
"type": "module"
}

View File

@@ -0,0 +1,305 @@
import { Command } from "@oclif/core";
import { BigNumber, UnsignedTransaction, ethers } from "ethers";
import { Flags } from "@oclif/core";
import { readFileSync } from "fs";
type Txpool = {
pending: object;
queued: object;
};
//TODO: add pagination for besu?
const clientApi: { [key: string]: { api: string; params: Array<any> } } = {
geth: { api: "txpool_content", params: [] },
besu: { api: "txpool_besuPendingTransactions", params: [2000] },
};
const isValidNodeTarget = (sourceNode: string, targetNode: string) => {
try {
/* eslint-disable no-new */
if (sourceNode) {
new URL(sourceNode);
}
new URL(targetNode);
return true;
} catch {}
return false;
};
const isLocalPort = (value: string) => {
try {
const port = Number(value);
return port >= 1 && port <= 65535;
} catch {}
return false;
};
const parsePendingTransactions = (pool: Txpool) => {
return (Object.values(pool.pending).map((el: any) => Object.values(el)) as unknown as any[]).flat();
};
const getPendingTransactions = (sourcePool: any, targetPool: any) => {
// calculate diff between source and target
const targetPendingTransactions = new Set();
targetPool.forEach((tx: any) => {
targetPendingTransactions.add(tx.hash);
});
return sourcePool.filter((tx: any) => !targetPendingTransactions.has(tx.hash));
};
const getClientType = async (nodeProvider: ethers.providers.JsonRpcProvider) => {
// Fetch client type of Besu or Geth
const res: string = await nodeProvider.send("web3_clientVersion", []);
const clientType = res.slice(0, 4).toLowerCase();
if (!["geth", "besu"].includes(clientType)) {
throw new Error(`Invalid node client type, must be either geth or besu`);
}
return clientType;
};
export default class Sync extends Command {
static examples = [
"<%= config.bin %> <%= command.id %> --source=8500 --target=8501 --local",
"<%= config.bin %> <%= command.id %> --source=http://geth-archive-1:8545 --target=http://geth-validator-1:8545 --concurrency=10",
];
static flags = {
source: Flags.string({
char: "s",
description: "source node to sync from, mutually exclusive with file flag",
helpGroup: "Node",
multiple: false,
required: true,
default: "",
env: "SYNCTX_SOURCE",
}),
target: Flags.string({
char: "t",
description: "target node to sync to",
helpGroup: "Node",
multiple: false,
required: true,
env: "SYNCTX_TARGET",
}),
"dry-run": Flags.boolean({
description: "enable dry run",
helpGroup: "Config",
required: false,
default: false,
env: "SYNCTX_DRYRUN",
}),
local: Flags.boolean({
description: "enable local run, provide only forwarded ports",
helpGroup: "Config",
required: false,
default: false,
env: "SYNCTX_LOCAL",
}),
concurrency: Flags.integer({
char: "c",
description: "number of concurrent batch requests",
helpGroup: "Config",
multiple: false,
required: false,
default: 10,
env: "SYNCTX_CONCURRENCY",
}),
file: Flags.string({
char: "f",
description: "local txs file to read from, mutually exclusive with source flag",
helpGroup: "Config",
multiple: false,
requiredOrDefaulted: true,
default: "",
env: "SYNCTX_FILE",
}),
};
public async run(): Promise<void> {
const { flags } = await this.parse(Sync);
let sourceNode: string = flags.source;
let targetNode: string = flags.target;
const filePath: string = flags.file;
let pendingTransactionsToSync: any[] = [];
const concurrentCount = flags.concurrency as number;
if ((filePath === "" && sourceNode === "") || (filePath !== "" && sourceNode !== "")) {
this.error(
"Invalid flag values are supplied, source and file are mutually exclusive and at least one needs to be specified",
);
}
if (flags.local) {
sourceNode = sourceNode && isLocalPort(sourceNode) ? `http://localhost:${sourceNode}` : sourceNode;
targetNode = isLocalPort(targetNode) ? `http://localhost:${targetNode}` : targetNode;
}
if (!isValidNodeTarget(sourceNode, targetNode)) {
this.error("Invalid nodes supplied to source and/or target, must be valid URL");
}
const sourceProvider = sourceNode ? new ethers.providers.JsonRpcProvider(sourceNode) : undefined;
const targetProvider = new ethers.providers.JsonRpcProvider(targetNode);
const sourceClientType = sourceProvider ? await getClientType(sourceProvider) : undefined;
const targetClientType = await getClientType(targetProvider);
if (sourceNode) {
this.log(`Source ${sourceClientType} node: ${sourceNode}`);
} else {
this.log(`Skip checking source node type as txs file is supplied`);
}
this.log(`Target ${targetClientType} node: ${targetNode}`);
let sourceTransactionPool: Txpool = { pending: {}, queued: {} };
let targetTransactionPool: Txpool = { pending: {}, queued: {} };
try {
if (sourceProvider && sourceClientType) {
this.log(`Fetching pending txs from txpool`);
sourceTransactionPool = await sourceProvider.send(
clientApi[sourceClientType].api,
clientApi[sourceClientType].params,
);
targetTransactionPool = await targetProvider.send(
clientApi[targetClientType].api,
clientApi[targetClientType].params,
);
} else {
this.log(`Skip fetching txs from source node as txs file is supplied`);
}
} catch (err) {
this.error(`Invalid rpc provider(s) - ${err}`);
}
if (
(sourceClientType === "geth" && Object.keys(sourceTransactionPool.pending).length === 0) ||
(sourceClientType === "besu" && Object.keys(sourceTransactionPool).length === 0)
) {
this.log("No pending transactions found on source node");
return;
}
const sourcePendingTransactions: any =
sourceClientType === "geth" ? parsePendingTransactions(sourceTransactionPool) : sourceTransactionPool;
const targetPendingTransactions: any =
targetClientType === "geth" ? parsePendingTransactions(targetTransactionPool) : targetTransactionPool;
if (sourceNode) {
this.log(`Source pending transactions: ${sourcePendingTransactions.length}`);
this.log(`Target pending transactions: ${targetPendingTransactions.length}`);
}
pendingTransactionsToSync = sourceNode
? getPendingTransactions(sourcePendingTransactions, targetPendingTransactions)
: JSON.parse(readFileSync(filePath, "utf-8"));
if (pendingTransactionsToSync.length === 0) {
if (sourceNode) {
this.log(`Delta between source and target pending transactions is 0.`);
} else {
this.log(`No txs found from file ${filePath}`);
}
return;
}
this.log(`Pending transactions to process: ${pendingTransactionsToSync.length}`);
// track errors serializing transactions
let errorSerialization = 0;
const transactions: Array<string> = [];
for (const tx of pendingTransactionsToSync) {
const transaction: UnsignedTransaction = {
to: tx.to,
nonce: Number.parseInt(tx.nonce.toString()),
gasLimit: BigNumber.from(tx.gas),
...(Number(tx.type) === 2
? {
gasPrice: BigNumber.from(tx.maxFeePerGas),
maxFeePerGas: BigNumber.from(tx.maxFeePerGas),
maxPriorityFeePerGas: BigNumber.from(tx.maxPriorityFeePerGas),
}
: { gasPrice: BigNumber.from(tx.gasPrice) }),
data: tx.input || "0x",
value: BigNumber.from(tx.value),
...(tx.chainId && Number(tx.type) !== 0 ? { chainId: Number.parseInt(tx.chainId.toString()) } : {}),
...(Number(tx.type) === 1 || Number(tx.type) === 2 ? { accessList: tx.accessList, type: Number(tx.type) } : {}),
};
const rawTx = ethers.utils.serializeTransaction(
transaction,
ethers.utils.splitSignature({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
v: Number.parseInt(tx.v!.toString()),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
r: tx.r!,
s: tx.s,
}),
);
if (ethers.utils.keccak256(rawTx) !== tx.hash) {
errorSerialization++;
this.warn(`Failed to serialize transaction: ${tx.hash}`);
}
transactions.push(rawTx);
}
const totalBatchesToProcess = Math.ceil(transactions.length / concurrentCount);
this.log(`Total serialization errors: ${errorSerialization}`);
let totalSuccess = 0;
let success = 0;
let errors = 0;
let totalErrors = 0;
if (flags["dry-run"]) {
this.log(`Total batches to process: ${totalBatchesToProcess}`);
return;
}
for (let i = 0; i < totalBatchesToProcess; i++) {
const batchIndex = concurrentCount * i;
const transactionBatch = transactions.slice(batchIndex, batchIndex + concurrentCount);
if (transactionBatch.length === 0) {
break;
}
this.log(`Processing batch: ${i + 1} of ${totalBatchesToProcess}, size ${transactionBatch.length}`);
const transactionPromises = transactionBatch.map((transactionReq) => {
return targetProvider.sendTransaction(transactionReq);
});
const results = await Promise.allSettled(transactionPromises);
success += results.filter((result: PromiseSettledResult<unknown>) => result.status === "fulfilled").length;
const resultErrors = results.filter(
(result: PromiseSettledResult<unknown>): result is PromiseRejectedResult => result.status === "rejected",
);
errors += resultErrors.length;
resultErrors.forEach((result) => {
this.log(`${result.reason.message.toString()}`);
});
totalSuccess += success;
totalErrors += errors;
this.log(
`
Total count: ${transactionBatch.length + batchIndex} - Success: ${success} - Errors: ${errors} - Total Success: ${totalSuccess} - Total Errors: ${totalErrors}
`.trim(),
);
success = 0;
errors = 0;
}
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"declaration": true,
"module": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "es2022",
"moduleResolution": "node16"
},
"include": ["./src/*"],
"ts-node": {
"esm": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts"],
}

29
operations/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "es2021",
"module": "commonjs",
"declaration": true,
"rootDir": "./src",
"outDir": "dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"strictPropertyInitialization": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
},
"include": [
"src/common/**/*.ts",
"src/common/**/*.d.ts",
"src/common/**/*.js",
"src/ethTransfer/**/*.ts",
"src/ethTransfer/**/*.d.ts",
"src/ethTransfer/**/*.js"
]
}