mirror of
https://github.com/vacp2p/linea-monorepo.git
synced 2026-01-09 04:08:01 -05:00
Feat/30 refactor operational packages (#116)
* refactor Operational packages * fix: refactor operations package to use oclif * adjusting Dockerfile * adjusting github action * Update all-tools.yml Signed-off-by: Andrei A. <andrei.alexandru@consensys.net> * adjusting github action --------- Signed-off-by: Andrei A. <andrei.alexandru@consensys.net> Co-authored-by: VGau <victorien.gauch@consensys.net>
This commit is contained in:
29
.github/workflows/all-tools.yml
vendored
29
.github/workflows/all-tools.yml
vendored
@@ -5,22 +5,21 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- 'operations/**'
|
- 'operations/'
|
||||||
|
- '.github/workflows/all-tools.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- 'operations/**'
|
- 'operations/'
|
||||||
|
- '.github/workflows/all-tools.yml'
|
||||||
env:
|
|
||||||
DOCKER_IMAGE_NAME: consensys/linea-alltools
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
runs-on: besu-arm64
|
runs-on: besu-arm64
|
||||||
name: Filter commit changes
|
name: Filter commit changes
|
||||||
outputs:
|
outputs:
|
||||||
all-tools: ${{ steps.filter.outputs.all-tools }}
|
all-tools: ${{ steps.filter.outputs['all-tools'] }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -43,7 +42,7 @@ jobs:
|
|||||||
runs-on: besu-arm64
|
runs-on: besu-arm64
|
||||||
name: Check image tags exist
|
name: Check image tags exist
|
||||||
needs: [ changes, store_image_name_and_tags ]
|
needs: [ changes, store_image_name_and_tags ]
|
||||||
if: ${{ needs.changes.outputs.all-tools == 'false' }}
|
if: ${{ needs.changes.outputs['all-tools'] == 'false' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -52,7 +51,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
last_commit_tag: ${{ needs.store_image_name_and_tags.outputs.last_commit_tag }}
|
last_commit_tag: ${{ needs.store_image_name_and_tags.outputs.last_commit_tag }}
|
||||||
common_ancestor_tag: ${{ needs.store_image_name_and_tags.outputs.common_ancestor_tag }}
|
common_ancestor_tag: ${{ needs.store_image_name_and_tags.outputs.common_ancestor_tag }}
|
||||||
image_name: ${{ env.DOCKER_IMAGE_NAME }}
|
image_name: consensys/linea-alltools
|
||||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
docker_password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ jobs:
|
|||||||
runs-on: besu-arm64
|
runs-on: besu-arm64
|
||||||
name: All tools tag only
|
name: All tools tag only
|
||||||
needs: [ changes, store_image_name_and_tags, check_image_tags_exist ]
|
needs: [ changes, store_image_name_and_tags, check_image_tags_exist ]
|
||||||
if: ${{ github.event_name != 'pull_request' && needs.changes.outputs.all-tools == 'false' }}
|
if: ${{ github.event_name != 'pull_request' && needs.changes.outputs['all-tools'] == 'false' }}
|
||||||
outputs:
|
outputs:
|
||||||
image_tagged: ${{ steps.image_tag_push.outputs.image_tagged }}
|
image_tagged: ${{ steps.image_tag_push.outputs.image_tagged }}
|
||||||
steps:
|
steps:
|
||||||
@@ -75,7 +74,7 @@ jobs:
|
|||||||
common_ancestor_tag: ${{ needs.store_image_name_and_tags.outputs.common_ancestor_tag }}
|
common_ancestor_tag: ${{ needs.store_image_name_and_tags.outputs.common_ancestor_tag }}
|
||||||
develop_tag: ${{ needs.store_image_name_and_tags.outputs.develop_tag }}
|
develop_tag: ${{ needs.store_image_name_and_tags.outputs.develop_tag }}
|
||||||
untested_tag_suffix: ${{ needs.store_image_name_and_tags.outputs.untested_tag_suffix }}
|
untested_tag_suffix: ${{ needs.store_image_name_and_tags.outputs.untested_tag_suffix }}
|
||||||
image_name: ${{ env.DOCKER_IMAGE_NAME }}
|
image_name: consensys/linea-alltools
|
||||||
last_commit_tag_exists: ${{ needs.check_image_tags_exist.outputs.last_commit_tag_exists }}
|
last_commit_tag_exists: ${{ needs.check_image_tags_exist.outputs.last_commit_tag_exists }}
|
||||||
common_ancestor_commit_tag_exists: ${{ needs.check_image_tags_exist.outputs.common_ancestor_commit_tag_exists }}
|
common_ancestor_commit_tag_exists: ${{ needs.check_image_tags_exist.outputs.common_ancestor_commit_tag_exists }}
|
||||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@@ -83,12 +82,12 @@ jobs:
|
|||||||
|
|
||||||
build-and-publish:
|
build-and-publish:
|
||||||
needs: [ changes, store_image_name_and_tags, all-tools-tag-only ]
|
needs: [ changes, store_image_name_and_tags, all-tools-tag-only ]
|
||||||
if: ${{ always() && (needs.changes.outputs.all-tools == 'true' || needs.all-tools-tag-only.result != 'success' || needs.all-tools-tag-only.outputs.image_tagged != 'true') }}
|
if: ${{ always() && (needs.changes.outputs['all-tools'] == 'true' || needs.all-tools-tag-only.result != 'success' || needs.all-tools-tag-only.outputs.image_tagged != 'true') }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
env:
|
env:
|
||||||
COMMIT_TAG: ${{ needs.store_image_name_and_tags.outputs.commit_tag }}
|
COMMIT_TAG: ${{ needs.store_image_name_and_tags.outputs.commit_tag }}
|
||||||
DEVELOP_TAG: ${{ needs.store_image_name_and_tags.outputs.develop_tag }}
|
DEVELOP_TAG: ${{ needs.store_image_name_and_tags.outputs.develop_tag }}
|
||||||
IMAGE_NAME: ${{ env.DOCKER_IMAGE_NAME }}
|
IMAGE_NAME: consensys/linea-alltools
|
||||||
name: All tools build and push
|
name: All tools build and push
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -116,15 +115,13 @@ jobs:
|
|||||||
- name: Show the "version" build argument
|
- name: Show the "version" build argument
|
||||||
run: |
|
run: |
|
||||||
echo "We inject the commit tag in the docker image ${{ env.COMMIT_TAG }}"
|
echo "We inject the commit tag in the docker image ${{ env.COMMIT_TAG }}"
|
||||||
echo COMMIT_TAG=${{ env.COMMIT_TAG }} >> $GITHUB_ENV
|
echo COMMIT_TAG=${{ env.COMMIT_TAG }} >> GITHUB_ENV
|
||||||
- name: Build and push all tools image
|
- name: Build and push all tools image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./operations/Dockerfile
|
file: ./operations/Dockerfile
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64 # Note: Build amd64 image only
|
||||||
# Note: Build amd64 image only
|
|
||||||
# platforms: linux/amd64,linux/arm64
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.IMAGE_NAME }}:${{ env.COMMIT_TAG }}
|
${{ env.IMAGE_NAME }}:${{ env.COMMIT_TAG }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,7 +24,7 @@
|
|||||||
.run/**.run.xml
|
.run/**.run.xml
|
||||||
.envrc
|
.envrc
|
||||||
bin/
|
bin/
|
||||||
!operations/src/synctx/bin/
|
!operations/bin/
|
||||||
target/
|
target/
|
||||||
tmp/
|
tmp/
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
src/synctx/
|
coverage
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
src/synctx/
|
coverage
|
||||||
@@ -1,59 +1,31 @@
|
|||||||
# syntax=docker/dockerfile:1.2
|
# syntax=docker/dockerfile:1.2
|
||||||
FROM node:18-slim AS builder
|
FROM node:20-slim AS base
|
||||||
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./
|
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 operations/ ./operations/
|
||||||
|
|
||||||
COPY . .
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
|
||||||
|
RUN pnpm run -F operations build
|
||||||
|
RUN pnpm deploy --filter=./operations --prod ./prod
|
||||||
|
|
||||||
RUN rm -rf src/synctx && pnpm run -F operations build
|
FROM node:20-slim AS release
|
||||||
|
|
||||||
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 NODE_ENV=production
|
||||||
ENV PATH="${PATH}:/opt/synctx/bin"
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# Install pnpm
|
USER node
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY --from=builder /usr/src/app/prod ./
|
||||||
|
|
||||||
RUN pnpm install --prod --frozen-lockfile --prefer-offline
|
ENTRYPOINT ["./bin/run.js"]
|
||||||
|
|
||||||
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"]
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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"]
|
|
||||||
6
operations/bin/dev.js
Executable file
6
operations/bin/dev.js
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env -S node --loader ts-node/esm --disable-warning=ExperimentalWarning
|
||||||
|
|
||||||
|
// eslint-disable-next-line n/shebang
|
||||||
|
import {execute} from '@oclif/core'
|
||||||
|
|
||||||
|
await execute({development: true, dir: import.meta.url})
|
||||||
5
operations/bin/run.js
Executable file
5
operations/bin/run.js
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import {execute} from '@oclif/core'
|
||||||
|
|
||||||
|
await execute({dir: import.meta.url})
|
||||||
18
operations/jest.config.cjs
Normal file
18
operations/jest.config.cjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest/presets/default-esm",
|
||||||
|
testEnvironment: "node",
|
||||||
|
testRegex: "test.ts$",
|
||||||
|
transform: {
|
||||||
|
"^.+\\.ts$": ["ts-jest", { useESM: true }],
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
collectCoverage: true,
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^(\\.{1,2}/.*)\\.js$": "$1",
|
||||||
|
},
|
||||||
|
extensionsToTreatAsEsm: [".ts"],
|
||||||
|
collectCoverageFrom: ["src/**/*.ts"],
|
||||||
|
coverageReporters: ["html", "lcov", "text"],
|
||||||
|
testPathIgnorePatterns: [],
|
||||||
|
coveragePathIgnorePatterns: [],
|
||||||
|
};
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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"],
|
|
||||||
};
|
|
||||||
@@ -5,27 +5,56 @@
|
|||||||
"author": "Consensys Software Inc.",
|
"author": "Consensys Software Inc.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "shx rm -rf dist && tsc -p tsconfig.build.json",
|
||||||
"prettier": "prettier -c '**/*.{js,ts}'",
|
"prettier": "prettier -c '**/*.{js,ts}'",
|
||||||
"prettier:fix": "prettier -w '**/*.{js,ts}'",
|
"prettier:fix": "prettier -w '**/*.{js,ts}'",
|
||||||
"lint:ts": "npx eslint '**/*.{js,ts}'",
|
"lint": "eslint . --ext .ts",
|
||||||
"lint:ts:fix": "npx eslint --fix '**/*.{js,ts}'",
|
"lint:fix": "eslint . --ext .ts --fix",
|
||||||
"test": "npx jest --bail --detectOpenHandles --forceExit",
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest --bail --detectOpenHandles --forceExit",
|
||||||
"lint:fix": "npm run lint:ts:fix && npm run prettier:fix",
|
"clean": "rimraf node_modules dist coverage",
|
||||||
"clean": "rimraf node_modules"
|
"postpack": "shx rm -f oclif.manifest.json",
|
||||||
|
"posttest": "pnpm run lint",
|
||||||
|
"prepack": "oclif manifest && oclif readme",
|
||||||
|
"version": "oclif readme && git add README.md"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"@oclif/core": "4.0.23",
|
||||||
"ethers": "^6.8.1",
|
"@oclif/plugin-help": "6.2.13",
|
||||||
"yargs": "^17.7.2"
|
"@oclif/plugin-plugins": "5.4.10",
|
||||||
|
"axios": "1.7.7",
|
||||||
|
"ethers": "6.13.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@jest/globals": "^29.7.0",
|
"@jest/globals": "29.7.0",
|
||||||
"@types/jest": "^29.5.7",
|
"@oclif/test": "4.0.9",
|
||||||
"@types/yargs": "^17.0.29",
|
"@types/jest": "29.5.13",
|
||||||
"jest": "^29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-mock-extended": "^3.0.5",
|
"jest-mock-extended": "3.0.5",
|
||||||
"ts-jest": "^29.1.1",
|
"shx": "0.3.4",
|
||||||
"rimraf": "^3.0.2"
|
"ts-jest": "29.2.5"
|
||||||
}
|
},
|
||||||
}
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"operations": "./bin/run.js"
|
||||||
|
},
|
||||||
|
"oclif": {
|
||||||
|
"bin": "operations",
|
||||||
|
"dirname": "operations",
|
||||||
|
"commands": "./dist/commands",
|
||||||
|
"plugins": [
|
||||||
|
"@oclif/plugin-help",
|
||||||
|
"@oclif/plugin-plugins"
|
||||||
|
],
|
||||||
|
"topicSeparator": " "
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"/bin",
|
||||||
|
"/dist",
|
||||||
|
"/oclif.manifest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
191
operations/src/commands/eth-transfer.ts
Normal file
191
operations/src/commands/eth-transfer.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { ethers, TransactionLike } from "ethers";
|
||||||
|
import { Command, Flags } from "@oclif/core";
|
||||||
|
import { validateEthereumAddress, validateHexString, validateUrl, get1559Fees } from "../utils/common/index.js";
|
||||||
|
import {
|
||||||
|
validateETHThreshold,
|
||||||
|
calculateRewards,
|
||||||
|
validateConfig,
|
||||||
|
estimateTransactionGas,
|
||||||
|
executeTransaction,
|
||||||
|
getWeb3SignerSignature,
|
||||||
|
DEFAULT_GAS_ESTIMATION_PERCENTILE,
|
||||||
|
DEFAULT_MAX_FEE_PER_GAS,
|
||||||
|
WEB3_SIGNER_PUBLIC_KEY_LENGTH,
|
||||||
|
} from "../utils/eth-transfer/index.js";
|
||||||
|
|
||||||
|
export default class EthTransfer extends Command {
|
||||||
|
static examples = [
|
||||||
|
// Example 1: Basic usage with required flags
|
||||||
|
`<%= config.bin %> <%= command.id %>
|
||||||
|
--sender-address=0xYourSenderAddress
|
||||||
|
--destination-address=0xYourDestinationAddress
|
||||||
|
--threshold=10
|
||||||
|
--blockchain-rpc-url=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
||||||
|
--web3-signer-url=http://localhost:8545
|
||||||
|
--web3-signer-public-key=0xYourWeb3SignerPublicKey`,
|
||||||
|
|
||||||
|
// Example 2: Including optional flags with custom values
|
||||||
|
`<%= config.bin %> <%= command.id %>
|
||||||
|
--sender-address=0xYourSenderAddress
|
||||||
|
--destination-address=0xYourDestinationAddress
|
||||||
|
--threshold=5
|
||||||
|
--blockchain-rpc-url=https://mainnet.infura.io/v3/YOUR-PROJECT-ID
|
||||||
|
--web3-signer-url=http://localhost:8545
|
||||||
|
--web3-signer-public-key=0xYourWeb3SignerPublicKey
|
||||||
|
--max-fee-per-gas=150000000000
|
||||||
|
--gas-estimation-percentile=20`,
|
||||||
|
|
||||||
|
// Example 3: Using the dry-run flag to simulate the transaction
|
||||||
|
`<%= config.bin %> <%= command.id %>
|
||||||
|
--sender-address=0xYourSenderAddress
|
||||||
|
--destination-address=0xYourDestinationAddress
|
||||||
|
--threshold=1.5
|
||||||
|
--blockchain-rpc-url=http://127.0.0.1:8545
|
||||||
|
--web3-signer-url=http://127.0.0.1:8546
|
||||||
|
--web3-signer-public-key=0xYourWeb3SignerPublicKey
|
||||||
|
--dry-run`,
|
||||||
|
];
|
||||||
|
|
||||||
|
static flags = {
|
||||||
|
"sender-address": Flags.string({
|
||||||
|
char: "s",
|
||||||
|
description: "Sender address",
|
||||||
|
required: true,
|
||||||
|
parse: async (input) => validateEthereumAddress("sender-address", input),
|
||||||
|
env: "ETH_TRANSFER_SENDER_ADDRESS",
|
||||||
|
}),
|
||||||
|
"destination-address": Flags.string({
|
||||||
|
char: "d",
|
||||||
|
description: "Destination address",
|
||||||
|
required: true,
|
||||||
|
parse: async (input) => validateEthereumAddress("destination-address", input),
|
||||||
|
env: "ETH_TRANSFER_DESTINATION_ADDRESS",
|
||||||
|
}),
|
||||||
|
threshold: Flags.string({
|
||||||
|
char: "t",
|
||||||
|
description: "Balance threshold of Validator address",
|
||||||
|
required: true,
|
||||||
|
parse: async (input) => validateETHThreshold(input),
|
||||||
|
env: "ETH_TRANSFER_THRESHOLD",
|
||||||
|
}),
|
||||||
|
"blockchain-rpc-url": Flags.string({
|
||||||
|
description: "Blockchain RPC URL",
|
||||||
|
required: true,
|
||||||
|
parse: async (input) => validateUrl("blockchain-rpc-url", input, ["http:", "https:"]),
|
||||||
|
env: "ETH_TRANSFER_BLOCKCHAIN_RPC_URL",
|
||||||
|
}),
|
||||||
|
"web3-signer-url": Flags.string({
|
||||||
|
description: "Web3 Signer URL",
|
||||||
|
required: true,
|
||||||
|
parse: async (input) => validateUrl("web3-signer-url", input, ["http:", "https:"]),
|
||||||
|
env: "ETH_TRANSFER_WEB3_SIGNER_URL",
|
||||||
|
}),
|
||||||
|
"web3-signer-public-key": Flags.string({
|
||||||
|
description: "Web3 Signer Public Key",
|
||||||
|
required: true,
|
||||||
|
parse: async (input) => validateHexString("web3-signer-public-key", input, WEB3_SIGNER_PUBLIC_KEY_LENGTH),
|
||||||
|
env: "ETH_TRANSFER_WEB3_SIGNER_PUBLIC_KEY",
|
||||||
|
}),
|
||||||
|
"dry-run": Flags.boolean({
|
||||||
|
description: "Dry run flag",
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
env: "ETH_TRANSFER_DRY_RUN",
|
||||||
|
}),
|
||||||
|
"max-fee-per-gas": Flags.string({
|
||||||
|
description: "MaxFeePerGas in wei",
|
||||||
|
required: false,
|
||||||
|
default: DEFAULT_MAX_FEE_PER_GAS,
|
||||||
|
env: "ETH_TRANSFER_MAX_FEE_PER_GAS",
|
||||||
|
}),
|
||||||
|
"gas-estimation-percentile": Flags.integer({
|
||||||
|
description: "Gas estimation percentile (0-100)",
|
||||||
|
required: false,
|
||||||
|
default: DEFAULT_GAS_ESTIMATION_PERCENTILE,
|
||||||
|
env: "ETH_TRANSFER_GAS_ESTIMATION_PERCENTILE",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
public async run(): Promise<void> {
|
||||||
|
const { flags } = await this.parse(EthTransfer);
|
||||||
|
|
||||||
|
const {
|
||||||
|
senderAddress,
|
||||||
|
destinationAddress,
|
||||||
|
threshold,
|
||||||
|
blockchainRpcUrl,
|
||||||
|
web3SignerUrl,
|
||||||
|
web3SignerPublicKey,
|
||||||
|
maxFeePerGas,
|
||||||
|
gasEstimationPercentile,
|
||||||
|
dryRun,
|
||||||
|
} = validateConfig(flags);
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
this.log(`Sender balance (${ethers.formatEther(senderBalance)} ETH) is less than threshold. No action needed.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewards = calculateRewards(senderBalance);
|
||||||
|
|
||||||
|
if (rewards === 0n) {
|
||||||
|
this.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) {
|
||||||
|
this.log("Dry run enabled: Skipping transaction submission to blockchain.");
|
||||||
|
this.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)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log(
|
||||||
|
`Transaction succeeded. Rewards sent: ${ethers.formatEther(rewards)} ETH. Receipt: ${JSON.stringify(receipt)}`,
|
||||||
|
);
|
||||||
|
this.log(`Rewards sent: ${ethers.formatEther(rewards)} ETH`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,68 +1,23 @@
|
|||||||
import { Command } from "@oclif/core";
|
import { Command, Flags } from "@oclif/core";
|
||||||
import { BigNumber, UnsignedTransaction, ethers } from "ethers";
|
import { ethers } from "ethers";
|
||||||
import { Flags } from "@oclif/core";
|
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
|
import {
|
||||||
|
getPendingTransactions,
|
||||||
|
isLocalPort,
|
||||||
|
isValidNodeTarget,
|
||||||
|
parsePendingTransactions,
|
||||||
|
Transaction,
|
||||||
|
Txpool,
|
||||||
|
ClientApi,
|
||||||
|
getClientType,
|
||||||
|
} from "../utils/synctx/index.js";
|
||||||
|
|
||||||
type Txpool = {
|
const clientApi: ClientApi = {
|
||||||
pending: object;
|
|
||||||
queued: object;
|
|
||||||
};
|
|
||||||
|
|
||||||
//TODO: add pagination for besu?
|
|
||||||
const clientApi: { [key: string]: { api: string; params: Array<any> } } = {
|
|
||||||
geth: { api: "txpool_content", params: [] },
|
geth: { api: "txpool_content", params: [] },
|
||||||
besu: { api: "txpool_besuPendingTransactions", params: [2000] },
|
besu: { api: "txpool_besuPendingTransactions", params: [2000] },
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidNodeTarget = (sourceNode: string, targetNode: string) => {
|
export default class Synctx extends Command {
|
||||||
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 = [
|
static examples = [
|
||||||
"<%= config.bin %> <%= command.id %> --source=8500 --target=8501 --local",
|
"<%= 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",
|
"<%= config.bin %> <%= command.id %> --source=http://geth-archive-1:8545 --target=http://geth-validator-1:8545 --concurrency=10",
|
||||||
@@ -74,7 +29,7 @@ export default class Sync extends Command {
|
|||||||
description: "source node to sync from, mutually exclusive with file flag",
|
description: "source node to sync from, mutually exclusive with file flag",
|
||||||
helpGroup: "Node",
|
helpGroup: "Node",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
required: true,
|
required: false,
|
||||||
default: "",
|
default: "",
|
||||||
env: "SYNCTX_SOURCE",
|
env: "SYNCTX_SOURCE",
|
||||||
}),
|
}),
|
||||||
@@ -114,23 +69,24 @@ export default class Sync extends Command {
|
|||||||
description: "local txs file to read from, mutually exclusive with source flag",
|
description: "local txs file to read from, mutually exclusive with source flag",
|
||||||
helpGroup: "Config",
|
helpGroup: "Config",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
requiredOrDefaulted: true,
|
required: false,
|
||||||
default: "",
|
default: "",
|
||||||
env: "SYNCTX_FILE",
|
env: "SYNCTX_FILE",
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
public async run(): Promise<void> {
|
|
||||||
const { flags } = await this.parse(Sync);
|
|
||||||
|
|
||||||
let sourceNode: string = flags.source;
|
public async run(): Promise<void> {
|
||||||
|
const { flags } = await this.parse(Synctx);
|
||||||
|
|
||||||
|
let sourceNode: string = flags.source ?? "";
|
||||||
let targetNode: string = flags.target;
|
let targetNode: string = flags.target;
|
||||||
const filePath: string = flags.file;
|
const filePath: string = flags.file ?? "";
|
||||||
let pendingTransactionsToSync: any[] = [];
|
let pendingTransactionsToSync: Transaction[] = [];
|
||||||
const concurrentCount = flags.concurrency as number;
|
const concurrentCount = flags.concurrency as number;
|
||||||
|
|
||||||
if ((filePath === "" && sourceNode === "") || (filePath !== "" && sourceNode !== "")) {
|
if ((filePath === "" && sourceNode === "") || (filePath !== "" && sourceNode !== "")) {
|
||||||
this.error(
|
this.error(
|
||||||
"Invalid flag values are supplied, source and file are mutually exclusive and at least one needs to be specified",
|
"Invalid flag values are supplied; source and file are mutually exclusive, and at least one needs to be specified",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,11 +96,11 @@ export default class Sync extends Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidNodeTarget(sourceNode, targetNode)) {
|
if (!isValidNodeTarget(sourceNode, targetNode)) {
|
||||||
this.error("Invalid nodes supplied to source and/or target, must be valid URL");
|
this.error("Invalid nodes supplied to source and/or target; must be valid URLs");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceProvider = sourceNode ? new ethers.providers.JsonRpcProvider(sourceNode) : undefined;
|
const sourceProvider = sourceNode ? new ethers.JsonRpcProvider(sourceNode) : undefined;
|
||||||
const targetProvider = new ethers.providers.JsonRpcProvider(targetNode);
|
const targetProvider = new ethers.JsonRpcProvider(targetNode);
|
||||||
|
|
||||||
const sourceClientType = sourceProvider ? await getClientType(sourceProvider) : undefined;
|
const sourceClientType = sourceProvider ? await getClientType(sourceProvider) : undefined;
|
||||||
const targetClientType = await getClientType(targetProvider);
|
const targetClientType = await getClientType(targetProvider);
|
||||||
@@ -173,7 +129,7 @@ export default class Sync extends Command {
|
|||||||
this.log(`Skip fetching txs from source node as txs file is supplied`);
|
this.log(`Skip fetching txs from source node as txs file is supplied`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error(`Invalid rpc provider(s) - ${err}`);
|
this.error(`Invalid RPC provider(s) - ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -184,10 +140,14 @@ export default class Sync extends Command {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePendingTransactions: any =
|
const sourcePendingTransactions: Transaction[] =
|
||||||
sourceClientType === "geth" ? parsePendingTransactions(sourceTransactionPool) : sourceTransactionPool;
|
sourceClientType === "geth"
|
||||||
const targetPendingTransactions: any =
|
? parsePendingTransactions(sourceTransactionPool)
|
||||||
targetClientType === "geth" ? parsePendingTransactions(targetTransactionPool) : targetTransactionPool;
|
: (sourceTransactionPool as unknown as Transaction[]);
|
||||||
|
const targetPendingTransactions: Transaction[] =
|
||||||
|
targetClientType === "geth"
|
||||||
|
? parsePendingTransactions(targetTransactionPool)
|
||||||
|
: (targetTransactionPool as unknown as Transaction[]);
|
||||||
|
|
||||||
if (sourceNode) {
|
if (sourceNode) {
|
||||||
this.log(`Source pending transactions: ${sourcePendingTransactions.length}`);
|
this.log(`Source pending transactions: ${sourcePendingTransactions.length}`);
|
||||||
@@ -202,51 +162,52 @@ export default class Sync extends Command {
|
|||||||
if (sourceNode) {
|
if (sourceNode) {
|
||||||
this.log(`Delta between source and target pending transactions is 0.`);
|
this.log(`Delta between source and target pending transactions is 0.`);
|
||||||
} else {
|
} else {
|
||||||
this.log(`No txs found from file ${filePath}`);
|
this.log(`No txs found in file ${filePath}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.log(`Pending transactions to process: ${pendingTransactionsToSync.length}`);
|
this.log(`Pending transactions to process: ${pendingTransactionsToSync.length}`);
|
||||||
|
|
||||||
// track errors serializing transactions
|
// Track errors during serialization
|
||||||
let errorSerialization = 0;
|
let errorSerialization = 0;
|
||||||
const transactions: Array<string> = [];
|
const transactions: Array<string> = [];
|
||||||
|
|
||||||
|
// Asynchronous loop to handle address resolution
|
||||||
for (const tx of pendingTransactionsToSync) {
|
for (const tx of pendingTransactionsToSync) {
|
||||||
const transaction: UnsignedTransaction = {
|
// Resolve the 'to' address if necessary
|
||||||
to: tx.to,
|
const toAddress = tx.to ? await ethers.resolveAddress(tx.to) : undefined;
|
||||||
nonce: Number.parseInt(tx.nonce.toString()),
|
|
||||||
gasLimit: BigNumber.from(tx.gas),
|
const transaction: ethers.TransactionLike<string> = {
|
||||||
|
to: toAddress,
|
||||||
|
nonce: Number(tx.nonce),
|
||||||
|
gasLimit: BigInt(tx.gas),
|
||||||
...(Number(tx.type) === 2
|
...(Number(tx.type) === 2
|
||||||
? {
|
? {
|
||||||
gasPrice: BigNumber.from(tx.maxFeePerGas),
|
maxFeePerGas: BigInt(tx.maxFeePerGas!),
|
||||||
maxFeePerGas: BigNumber.from(tx.maxFeePerGas),
|
maxPriorityFeePerGas: BigInt(tx.maxPriorityFeePerGas!),
|
||||||
maxPriorityFeePerGas: BigNumber.from(tx.maxPriorityFeePerGas),
|
|
||||||
}
|
}
|
||||||
: { gasPrice: BigNumber.from(tx.gasPrice) }),
|
: { gasPrice: BigInt(tx.gasPrice!) }),
|
||||||
data: tx.input || "0x",
|
data: tx.input || "0x",
|
||||||
value: BigNumber.from(tx.value),
|
value: BigInt(tx.value),
|
||||||
...(tx.chainId && Number(tx.type) !== 0 ? { chainId: Number.parseInt(tx.chainId.toString()) } : {}),
|
...(tx.chainId && Number(tx.type) !== 0 ? { chainId: Number(tx.chainId) } : {}),
|
||||||
...(Number(tx.type) === 1 || Number(tx.type) === 2 ? { accessList: tx.accessList, type: Number(tx.type) } : {}),
|
...(Number(tx.type) === 1 || Number(tx.type) === 2
|
||||||
|
? { accessList: tx.accessList as ethers.AccessListish, type: Number(tx.type) }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawTx = ethers.utils.serializeTransaction(
|
try {
|
||||||
transaction,
|
const rawTx = ethers.Transaction.from(transaction).serialized;
|
||||||
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) {
|
if (ethers.keccak256(rawTx) !== tx.hash) {
|
||||||
|
errorSerialization++;
|
||||||
|
this.warn(`Failed to serialize transaction: ${tx.hash}`);
|
||||||
|
}
|
||||||
|
transactions.push(rawTx);
|
||||||
|
} catch (error) {
|
||||||
errorSerialization++;
|
errorSerialization++;
|
||||||
this.warn(`Failed to serialize transaction: ${tx.hash}`);
|
this.warn(`Error serializing transaction ${tx.hash}: ${(error as Error).message}`);
|
||||||
}
|
}
|
||||||
transactions.push(rawTx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBatchesToProcess = Math.ceil(transactions.length / concurrentCount);
|
const totalBatchesToProcess = Math.ceil(transactions.length / concurrentCount);
|
||||||
@@ -273,20 +234,18 @@ export default class Sync extends Command {
|
|||||||
|
|
||||||
this.log(`Processing batch: ${i + 1} of ${totalBatchesToProcess}, size ${transactionBatch.length}`);
|
this.log(`Processing batch: ${i + 1} of ${totalBatchesToProcess}, size ${transactionBatch.length}`);
|
||||||
|
|
||||||
const transactionPromises = transactionBatch.map((transactionReq) => {
|
const transactionPromises = transactionBatch.map((rawTransaction) => {
|
||||||
return targetProvider.sendTransaction(transactionReq);
|
return targetProvider.broadcastTransaction(rawTransaction);
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.allSettled(transactionPromises);
|
const results = await Promise.allSettled(transactionPromises);
|
||||||
success += results.filter((result: PromiseSettledResult<unknown>) => result.status === "fulfilled").length;
|
success = results.filter((result) => result.status === "fulfilled").length;
|
||||||
|
|
||||||
const resultErrors = results.filter(
|
const resultErrors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||||
(result: PromiseSettledResult<unknown>): result is PromiseRejectedResult => result.status === "rejected",
|
errors = resultErrors.length;
|
||||||
);
|
|
||||||
errors += resultErrors.length;
|
|
||||||
|
|
||||||
resultErrors.forEach((result) => {
|
resultErrors.forEach((result) => {
|
||||||
this.log(`${result.reason.message.toString()}`);
|
this.log(`Error broadcasting transaction: ${(result.reason as Error).message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
totalSuccess += success;
|
totalSuccess += success;
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { Fees, FeeHistory } from "./types";
|
|
||||||
export { get1559Fees } from "./utils";
|
|
||||||
export { getWeb3SignerSignature, estimateTransactionGas, executeTransaction } from "./transactions";
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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 };
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export type Config = {
|
|
||||||
senderAddress: string;
|
|
||||||
destinationAddress: string;
|
|
||||||
threshold: string;
|
|
||||||
blockchainRpcUrl: string;
|
|
||||||
web3SignerUrl: string;
|
|
||||||
web3SignerPublicKey: string;
|
|
||||||
maxFeePerGas: bigint;
|
|
||||||
gasEstimationPercentile: number;
|
|
||||||
dryRun: boolean;
|
|
||||||
};
|
|
||||||
1
operations/src/index.ts
Normal file
1
operations/src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { run } from "@oclif/core";
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/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();
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/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
15787
operations/src/synctx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,81 +0,0 @@
|
|||||||
{
|
|
||||||
"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": ">=20"
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -1,7 +1,7 @@
|
|||||||
import { describe, afterEach, jest, it, expect, beforeEach } from "@jest/globals";
|
import { describe, afterEach, jest, it, expect, beforeEach } from "@jest/globals";
|
||||||
import { JsonRpcProvider } from "ethers";
|
import { JsonRpcProvider } from "ethers";
|
||||||
import { MockProxy, mock, mockClear } from "jest-mock-extended";
|
import { MockProxy, mock, mockClear } from "jest-mock-extended";
|
||||||
import { get1559Fees } from "../utils";
|
import { get1559Fees } from "../fees.js";
|
||||||
|
|
||||||
describe("Utils", () => {
|
describe("Utils", () => {
|
||||||
let providerMock: MockProxy<JsonRpcProvider>;
|
let providerMock: MockProxy<JsonRpcProvider>;
|
||||||
@@ -1,56 +1,58 @@
|
|||||||
import { describe, it, expect } from "@jest/globals";
|
import { describe, it, expect } from "@jest/globals";
|
||||||
import { ethers } from "ethers";
|
import { ethers } from "ethers";
|
||||||
import { isValidUrl, sanitizeAddress, sanitizeETHThreshold, sanitizeHexString, sanitizeUrl } from "../cli";
|
import { isValidProtocolUrl, validateEthereumAddress, validateHexString, validateUrl } from "../validation.js";
|
||||||
|
|
||||||
describe("CLI", () => {
|
describe("Validation utils", () => {
|
||||||
describe("sanitizeAddress", () => {
|
describe("validateEthereumAddress", () => {
|
||||||
it("should throw an error when the input is not a valid Ethereum address", () => {
|
it("should throw an error when the input is not a valid Ethereum address", () => {
|
||||||
const invalidAddress = "0x0a0e";
|
const invalidAddress = "0x0a0e";
|
||||||
expect(() => sanitizeAddress("Address")(invalidAddress)).toThrow(`Address is not a valid Ethereum address.`);
|
expect(() => validateEthereumAddress("Address", invalidAddress)).toThrow(
|
||||||
|
`Address is not a valid Ethereum address.`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return the input when it is a valid Ethereum address", () => {
|
it("should return the input when it is a valid Ethereum address", () => {
|
||||||
const address = ethers.hexlify(ethers.randomBytes(20));
|
const address = ethers.hexlify(ethers.randomBytes(20));
|
||||||
expect(sanitizeAddress("Address")(address)).toStrictEqual(address);
|
expect(validateEthereumAddress("Address", address)).toStrictEqual(address);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("isValidUrl", () => {
|
describe("isValidProtocolUrl", () => {
|
||||||
it("should return false when the url is not valid", () => {
|
it("should return false when the url is not valid", () => {
|
||||||
const invalidUrl = "www.test.com";
|
const invalidUrl = "www.test.com";
|
||||||
expect(isValidUrl(invalidUrl, ["http:", "https:"])).toBeFalsy();
|
expect(isValidProtocolUrl(invalidUrl, ["http:", "https:"])).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false when the url protocol is not allowed", () => {
|
it("should return false when the url protocol is not allowed", () => {
|
||||||
const invalidUrl = "tcp://test.com";
|
const invalidUrl = "tcp://test.com";
|
||||||
expect(isValidUrl(invalidUrl, ["http:", "https:"])).toBeFalsy();
|
expect(isValidProtocolUrl(invalidUrl, ["http:", "https:"])).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return true when the url valid", () => {
|
it("should return true when the url valid", () => {
|
||||||
const url = "http://test.com";
|
const url = "http://test.com";
|
||||||
expect(isValidUrl(url, ["http:", "https:"])).toBeTruthy();
|
expect(isValidProtocolUrl(url, ["http:", "https:"])).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitizeUrl", () => {
|
describe("validateUrl", () => {
|
||||||
it("should throw an error when the input is not a valid url", () => {
|
it("should throw an error when the input is not a valid url", () => {
|
||||||
const invalidUrl = "www.test.com";
|
const invalidUrl = "www.test.com";
|
||||||
expect(() => sanitizeUrl("Url", ["http:", "https:"])(invalidUrl)).toThrow(
|
expect(() => validateUrl("Url", invalidUrl, ["http:", "https:"])).toThrow(
|
||||||
`Url, with value: ${invalidUrl} is not a valid URL`,
|
`Url, with value: ${invalidUrl} is not a valid URL`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return the input when it is a valid url", () => {
|
it("should return the input when it is a valid url", () => {
|
||||||
const url = "http://test.com";
|
const url = "http://test.com";
|
||||||
expect(sanitizeUrl("Url", ["http:", "https:"])(url)).toStrictEqual(url);
|
expect(validateUrl("Url", url, ["http:", "https:"])).toStrictEqual(url);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitizeHexString", () => {
|
describe("validateHexString", () => {
|
||||||
it("should throw an error when the input is not a hex string", () => {
|
it("should throw an error when the input is not a hex string", () => {
|
||||||
const invalidHexString = "0a1f";
|
const invalidHexString = "0a1f";
|
||||||
const expectedLength = 2;
|
const expectedLength = 2;
|
||||||
expect(() => sanitizeHexString("HexString", expectedLength)(invalidHexString)).toThrow(
|
expect(() => validateHexString("HexString", invalidHexString, expectedLength)).toThrow(
|
||||||
`HexString must be hexadecimal string of length ${expectedLength}.`,
|
`HexString must be hexadecimal string of length ${expectedLength}.`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -58,7 +60,7 @@ describe("CLI", () => {
|
|||||||
it("should throw an error when the input length is not equal to the expected length", () => {
|
it("should throw an error when the input length is not equal to the expected length", () => {
|
||||||
const hexString = "0x0a1f";
|
const hexString = "0x0a1f";
|
||||||
const expectedLength = 4;
|
const expectedLength = 4;
|
||||||
expect(() => sanitizeHexString("HexString", expectedLength)(hexString)).toThrow(
|
expect(() => validateHexString("HexString", hexString, expectedLength)).toThrow(
|
||||||
`HexString must be hexadecimal string of length ${expectedLength}.`,
|
`HexString must be hexadecimal string of length ${expectedLength}.`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -66,19 +68,7 @@ describe("CLI", () => {
|
|||||||
it("should return the input when it is a hex string", () => {
|
it("should return the input when it is a hex string", () => {
|
||||||
const hexString = "0x0a1f";
|
const hexString = "0x0a1f";
|
||||||
const expectedLength = 2;
|
const expectedLength = 2;
|
||||||
expect(sanitizeHexString("HexString", expectedLength)(hexString)).toStrictEqual(hexString);
|
expect(validateHexString("HexString", hexString, expectedLength)).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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ethers } from "ethers";
|
import { ethers } from "ethers";
|
||||||
import { FeeHistory, Fees } from "./types";
|
import { FeeHistory, Fees } from "./types.js";
|
||||||
|
|
||||||
export async function get1559Fees(
|
export async function get1559Fees(
|
||||||
provider: ethers.JsonRpcProvider,
|
provider: ethers.JsonRpcProvider,
|
||||||
3
operations/src/utils/common/index.ts
Normal file
3
operations/src/utils/common/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./fees.js";
|
||||||
|
export * from "./types.js";
|
||||||
|
export * from "./validation.js";
|
||||||
31
operations/src/utils/common/validation.ts
Normal file
31
operations/src/utils/common/validation.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ethers } from "ethers";
|
||||||
|
|
||||||
|
export function validateEthereumAddress(argName: string, input: string) {
|
||||||
|
if (!ethers.isAddress(input)) {
|
||||||
|
throw new Error(`${argName} is not a valid Ethereum address.`);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidProtocolUrl(input: string, allowedProtocols: string[]): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(input);
|
||||||
|
return allowedProtocols.includes(url.protocol);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUrl(argName: string, input: string, allowedProtocols: string[]) {
|
||||||
|
if (!isValidProtocolUrl(input, allowedProtocols)) {
|
||||||
|
throw new Error(`${argName}, with value: ${input} is not a valid URL`);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateHexString(argName: string, input: string, expectedLength: number) {
|
||||||
|
if (!ethers.isHexString(input, expectedLength)) {
|
||||||
|
throw new Error(`${argName} must be hexadecimal string of length ${expectedLength}.`);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "@jest/globals";
|
import { describe, it, expect } from "@jest/globals";
|
||||||
import { ethers } from "ethers";
|
import { ethers } from "ethers";
|
||||||
import { calculateRewards } from "../utils";
|
import { calculateRewards } from "../rewards";
|
||||||
|
|
||||||
describe("Utils", () => {
|
describe("Utils", () => {
|
||||||
describe("calculateRewards", () => {
|
describe("calculateRewards", () => {
|
||||||
@@ -2,7 +2,7 @@ import { describe, jest, it, expect, afterAll } from "@jest/globals";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { JsonRpcProvider, Signature, TransactionLike, TransactionResponse, ethers } from "ethers";
|
import { JsonRpcProvider, Signature, TransactionLike, TransactionResponse, ethers } from "ethers";
|
||||||
import { MockProxy, mock, mockClear } from "jest-mock-extended";
|
import { MockProxy, mock, mockClear } from "jest-mock-extended";
|
||||||
import { estimateTransactionGas, executeTransaction, getWeb3SignerSignature } from "../transactions";
|
import { estimateTransactionGas, executeTransaction, getWeb3SignerSignature } from "../transactions.js";
|
||||||
|
|
||||||
jest.mock("axios");
|
jest.mock("axios");
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from "@jest/globals";
|
||||||
|
import { validateETHThreshold } from "../validation.js";
|
||||||
|
|
||||||
|
describe("Validation utils", () => {
|
||||||
|
describe("validateETHThreshold", () => {
|
||||||
|
it("should throw an error when the input threshold is less than 1 ETH", () => {
|
||||||
|
const invalidThreshold = "0.5";
|
||||||
|
expect(() => validateETHThreshold(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(validateETHThreshold(threshold)).toStrictEqual(threshold);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
operations/src/utils/eth-transfer/config.ts
Normal file
70
operations/src/utils/eth-transfer/config.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { FlagOutput } from "@oclif/core/lib/interfaces/parser";
|
||||||
|
|
||||||
|
export type Config = {
|
||||||
|
senderAddress: string;
|
||||||
|
destinationAddress: string;
|
||||||
|
threshold: string;
|
||||||
|
blockchainRpcUrl: string;
|
||||||
|
web3SignerUrl: string;
|
||||||
|
web3SignerPublicKey: string;
|
||||||
|
maxFeePerGas: bigint;
|
||||||
|
gasEstimationPercentile: number;
|
||||||
|
dryRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateConfig(flags: FlagOutput): Config {
|
||||||
|
const {
|
||||||
|
senderAddress,
|
||||||
|
destinationAddress,
|
||||||
|
threshold,
|
||||||
|
blockchainRpcUrl,
|
||||||
|
web3SignerUrl,
|
||||||
|
web3SignerPublicKey,
|
||||||
|
dryRun,
|
||||||
|
maxFeePerGas: maxFeePerGasArg,
|
||||||
|
gasEstimationPercentile,
|
||||||
|
} = flags;
|
||||||
|
|
||||||
|
const requiredFlags = [
|
||||||
|
"senderAddress",
|
||||||
|
"destinationAddress",
|
||||||
|
"threshold",
|
||||||
|
"blockchainRpcUrl",
|
||||||
|
"web3SignerUrl",
|
||||||
|
"web3SignerPublicKey",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const flagName of requiredFlags) {
|
||||||
|
if (!flags[flagName]) {
|
||||||
|
throw new Error(`Missing required flag: ${flagName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxFeePerGas: bigint;
|
||||||
|
try {
|
||||||
|
maxFeePerGas = BigInt(maxFeePerGasArg);
|
||||||
|
if (maxFeePerGas <= 0n) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid value for --max-fee-per-gas: ${maxFeePerGasArg}. Must be a positive integer in wei.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gasEstimationPercentile < 0 || gasEstimationPercentile > 100) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid value for --gas-estimation-percentile: ${gasEstimationPercentile}. Must be an integer between 0 and 100.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
senderAddress,
|
||||||
|
destinationAddress,
|
||||||
|
threshold,
|
||||||
|
blockchainRpcUrl,
|
||||||
|
web3SignerUrl,
|
||||||
|
web3SignerPublicKey,
|
||||||
|
maxFeePerGas,
|
||||||
|
gasEstimationPercentile,
|
||||||
|
dryRun,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
operations/src/utils/eth-transfer/constants.ts
Normal file
3
operations/src/utils/eth-transfer/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const WEB3_SIGNER_PUBLIC_KEY_LENGTH = 64;
|
||||||
|
export const DEFAULT_MAX_FEE_PER_GAS = "100000000000";
|
||||||
|
export const DEFAULT_GAS_ESTIMATION_PERCENTILE = 10;
|
||||||
5
operations/src/utils/eth-transfer/index.ts
Normal file
5
operations/src/utils/eth-transfer/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./config.js";
|
||||||
|
export * from "./constants.js";
|
||||||
|
export * from "./rewards.js";
|
||||||
|
export * from "./transactions.js";
|
||||||
|
export * from "./validation.js";
|
||||||
6
operations/src/utils/eth-transfer/validation.ts
Normal file
6
operations/src/utils/eth-transfer/validation.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function validateETHThreshold(input: string) {
|
||||||
|
if (parseInt(input) <= 1) {
|
||||||
|
throw new Error("Threshold must be higher than 1 ETH");
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
17
operations/src/utils/synctx/client.ts
Normal file
17
operations/src/utils/synctx/client.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ethers } from "ethers";
|
||||||
|
|
||||||
|
export type ClientApi = {
|
||||||
|
[key: string]: {
|
||||||
|
api: string;
|
||||||
|
params: Array<unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getClientType = async (nodeProvider: ethers.JsonRpcProvider): Promise<string> => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
4
operations/src/utils/synctx/index.ts
Normal file
4
operations/src/utils/synctx/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./types.js";
|
||||||
|
export * from "./validation.js";
|
||||||
|
export * from "./transactions.js";
|
||||||
|
export * from "./client.js";
|
||||||
13
operations/src/utils/synctx/transactions.ts
Normal file
13
operations/src/utils/synctx/transactions.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Transaction, Txpool } from "./types.js";
|
||||||
|
|
||||||
|
export const parsePendingTransactions = (pool: Txpool): Transaction[] => {
|
||||||
|
const pendingAddresses = Object.values(pool.pending);
|
||||||
|
const transactionsByNonce = pendingAddresses.map((txsByNonce) => Object.values(txsByNonce));
|
||||||
|
const transactions = transactionsByNonce.flat();
|
||||||
|
return transactions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPendingTransactions = (sourcePool: Transaction[], targetPool: Transaction[]): Transaction[] => {
|
||||||
|
const targetPendingTransactions = new Set(targetPool.map((tx) => tx.hash));
|
||||||
|
return sourcePool.filter((tx) => !targetPendingTransactions.has(tx.hash));
|
||||||
|
};
|
||||||
25
operations/src/utils/synctx/types.ts
Normal file
25
operations/src/utils/synctx/types.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ethers } from "ethers";
|
||||||
|
|
||||||
|
export type Transaction = {
|
||||||
|
hash: string;
|
||||||
|
nonce: number;
|
||||||
|
gas: string;
|
||||||
|
maxFeePerGas?: string;
|
||||||
|
maxPriorityFeePerGas?: string;
|
||||||
|
gasPrice?: string;
|
||||||
|
input?: string;
|
||||||
|
value: string;
|
||||||
|
chainId?: string;
|
||||||
|
accessList?: ethers.AccessListish | null;
|
||||||
|
type: string | number;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Txpool = {
|
||||||
|
pending: {
|
||||||
|
[address: string]: {
|
||||||
|
[nonce: string]: Transaction;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
queued: object;
|
||||||
|
};
|
||||||
16
operations/src/utils/synctx/validation.ts
Normal file
16
operations/src/utils/synctx/validation.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export const isValidNodeTarget = (sourceNode: string, targetNode: string): boolean => {
|
||||||
|
try {
|
||||||
|
if (sourceNode) {
|
||||||
|
new URL(sourceNode);
|
||||||
|
}
|
||||||
|
new URL(targetNode);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isLocalPort = (value: string): boolean => {
|
||||||
|
const port = Number(value);
|
||||||
|
return !isNaN(port) && port >= 1 && port <= 65535;
|
||||||
|
};
|
||||||
@@ -1,29 +1,16 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"extends": "../tsconfig.json",
|
||||||
"target": "es2021",
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"declaration": true,
|
||||||
"declaration": true,
|
"module": "ES2022",
|
||||||
"rootDir": "./src",
|
"outDir": "dist",
|
||||||
"outDir": "dist",
|
"rootDir": "src",
|
||||||
"esModuleInterop": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"target": "es2022",
|
||||||
"strict": true,
|
"moduleResolution": "Node",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": true,
|
"esModuleInterop": true,
|
||||||
"resolveJsonModule": true,
|
},
|
||||||
"noImplicitThis": true,
|
"include": ["./src/**/*"],
|
||||||
"noImplicitAny": true,
|
"exclude": ["node_modules", "dist"]
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
5124
pnpm-lock.yaml
generated
5124
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,5 +4,4 @@ packages:
|
|||||||
- 'sdk/**'
|
- 'sdk/**'
|
||||||
- 'operations/**'
|
- 'operations/**'
|
||||||
- 'bridge-ui/**'
|
- 'bridge-ui/**'
|
||||||
- '!operations/src/synctx/**'
|
|
||||||
- 'ts-libs/**'
|
- 'ts-libs/**'
|
||||||
|
|||||||
Reference in New Issue
Block a user