mirror of
https://github.com/vacp2p/linea-monorepo.git
synced 2026-01-09 04:08:01 -05:00
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:
3
operations/.dockerignore
Normal file
3
operations/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
3
operations/.eslintignore
Normal file
3
operations/.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
src/synctx/
|
||||
16
operations/.eslintrc.js
Normal file
16
operations/.eslintrc.js
Normal 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",
|
||||
},
|
||||
};
|
||||
3
operations/.prettierignore
Normal file
3
operations/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
src/synctx/
|
||||
3
operations/.prettierrc.js
Normal file
3
operations/.prettierrc.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
...require("../.prettierrc.js"),
|
||||
};
|
||||
59
operations/Dockerfile
Normal file
59
operations/Dockerfile
Normal 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"]
|
||||
49
operations/Dockerfile.alpine
Normal file
49
operations/Dockerfile.alpine
Normal 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
12
operations/jest.config.js
Normal 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
31
operations/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
152
operations/src/common/__tests__/transactions.test.ts
Normal file
152
operations/src/common/__tests__/transactions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
92
operations/src/common/__tests__/utils.test.ts
Normal file
92
operations/src/common/__tests__/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
operations/src/common/index.ts
Normal file
3
operations/src/common/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Fees, FeeHistory } from "./types";
|
||||
export { get1559Fees } from "./utils";
|
||||
export { getWeb3SignerSignature, estimateTransactionGas, executeTransaction } from "./transactions";
|
||||
43
operations/src/common/transactions.ts
Normal file
43
operations/src/common/transactions.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
11
operations/src/common/types.ts
Normal file
11
operations/src/common/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type Fees = {
|
||||
maxFeePerGas: bigint;
|
||||
maxPriorityFeePerGas?: bigint;
|
||||
};
|
||||
|
||||
export type FeeHistory = {
|
||||
oldestBlock: number;
|
||||
reward: string[][];
|
||||
baseFeePerGas: string[];
|
||||
gasUsedRatio: number[];
|
||||
};
|
||||
32
operations/src/common/utils.ts
Normal file
32
operations/src/common/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
84
operations/src/ethTransfer/__tests__/cli.test.ts
Normal file
84
operations/src/ethTransfer/__tests__/cli.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
22
operations/src/ethTransfer/__tests__/utils.test.ts
Normal file
22
operations/src/ethTransfer/__tests__/utils.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
48
operations/src/ethTransfer/cli.ts
Normal file
48
operations/src/ethTransfer/cli.ts
Normal 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 };
|
||||
177
operations/src/ethTransfer/index.ts
Normal file
177
operations/src/ethTransfer/index.ts
Normal 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);
|
||||
});
|
||||
11
operations/src/ethTransfer/types.ts
Normal file
11
operations/src/ethTransfer/types.ts
Normal 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;
|
||||
};
|
||||
13
operations/src/ethTransfer/utils.ts
Normal file
13
operations/src/ethTransfer/utils.ts
Normal 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;
|
||||
}
|
||||
87
operations/src/synctx/README.md
Normal file
87
operations/src/synctx/README.md
Normal 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 -->
|
||||
3
operations/src/synctx/bin/dev.cmd
Normal file
3
operations/src/synctx/bin/dev.cmd
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
|
||||
node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %*
|
||||
9
operations/src/synctx/bin/dev.js
Executable file
9
operations/src/synctx/bin/dev.js
Executable 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();
|
||||
3
operations/src/synctx/bin/run.cmd
Normal file
3
operations/src/synctx/bin/run.cmd
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
|
||||
node "%~dp0\run" %*
|
||||
8
operations/src/synctx/bin/run.js
Executable file
8
operations/src/synctx/bin/run.js
Executable 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
15787
operations/src/synctx/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
81
operations/src/synctx/package.json
Normal file
81
operations/src/synctx/package.json
Normal 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"
|
||||
}
|
||||
305
operations/src/synctx/src/index.ts
Normal file
305
operations/src/synctx/src/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
operations/src/synctx/tsconfig.json
Normal file
15
operations/src/synctx/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
7027
operations/src/synctx/yarn.lock
Normal file
7027
operations/src/synctx/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
4
operations/tsconfig.build.json
Normal file
4
operations/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.ts"],
|
||||
}
|
||||
29
operations/tsconfig.json
Normal file
29
operations/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user