diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index d5737429cf..db9d1a2c1b 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -21,6 +21,9 @@ on: coordinator_changed: required: true type: string + native_yield_automation_service_changed: + required: true + type: string postman_changed: required: true type: string @@ -36,6 +39,9 @@ on: coordinator_image_tagged: required: true type: string + native_yield_automation_service_image_tagged: + required: true + type: string prover_image_tagged: required: true type: string @@ -67,6 +73,16 @@ jobs: push_image: ${{ inputs.push_image }} secrets: inherit + native_yield_automation_service: + uses: ./.github/workflows/native-yield-automation-service-build-and-publish.yml + if: ${{ always() && (inputs.native_yield_automation_service_changed == 'true' || inputs.native_yield_automation_service_image_tagged != 'true') }} + with: + commit_tag: ${{ inputs.commit_tag }} + develop_tag: ${{ inputs.develop_tag }} + image_name: consensys/linea-native-yield-automation-service + push_image: ${{ inputs.push_image }} + secrets: inherit + prover: uses: ./.github/workflows/prover-build-and-publish.yml if: ${{ always() && (inputs.prover_changed == 'true' || inputs.prover_image_tagged != 'true') }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fea1fc752d..4ef73d3173 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -29,6 +29,7 @@ jobs: contracts-excluding-local-deployment-artifacts-count: ${{ steps.exclusion-filter.outputs.contracts-excluding-local-deployment-artifacts_count }} smart-contracts: ${{ steps.filter.outputs.smart-contracts }} linea-sequencer-plugin: ${{ steps.filter.outputs.linea-sequencer-plugin }} + native-yield-automation-service: ${{ steps.filter.outputs.native-yield-automation-service }} steps: - name: Checkout uses: actions/checkout@v4 @@ -120,6 +121,10 @@ jobs: linea-besu-package: - 'linea-besu-package/versions.env' - 'linea-besu-package/scripts/assemble-packages.sh' + native-yield-automation-service: + - 'native-yield-operations/automation-service/**' + - 'ts-libs/linea-shared-utils/**' + - '.github/workflows/native-yield-automation-service-*.yml' - name: Filter out commit changes uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 #v3.0.2 @@ -152,6 +157,7 @@ jobs: postman_changed: ${{ needs.filter-commit-changes.outputs.postman }} prover_changed: ${{ needs.filter-commit-changes.outputs.prover }} transaction_exclusion_api_changed: ${{ needs.filter-commit-changes.outputs.transaction-exclusion-api }} + native_yield_automation_service_changed: ${{ needs.filter-commit-changes.outputs.native-yield-automation-service }} secrets: inherit code-analysis: @@ -170,6 +176,7 @@ jobs: smart_contracts_changed: ${{ needs.filter-commit-changes.outputs.smart-contracts }} staterecovery_changed: false # disable until we have time to address it ${{ needs.filter-commit-changes.outputs.staterecovery }} transaction_exclusion_api_changed: ${{ needs.filter-commit-changes.outputs.transaction-exclusion-api }} + native_yield_automation_service_changed: ${{ needs.filter-commit-changes.outputs.native-yield-automation-service }} secrets: inherit get-has-changes-requiring-e2e-testing: @@ -209,11 +216,13 @@ jobs: postman_changed: ${{ needs.filter-commit-changes.outputs.postman }} prover_changed: ${{ needs.filter-commit-changes.outputs.prover }} transaction_exclusion_api_changed: ${{ needs.filter-commit-changes.outputs.transaction-exclusion-api }} + native_yield_automation_service_changed: ${{ needs.filter-commit-changes.outputs.native-yield-automation-service }} build_linea_besu_package: ${{ needs.filter-commit-changes.outputs.linea-besu-package }} coordinator_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_coordinator }} postman_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_postman }} prover_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_prover }} transaction_exclusion_api_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_transaction_exclusion_api }} + native_yield_automation_service_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_native_yield_automation_service }} secrets: inherit run-e2e-tests: @@ -249,11 +258,13 @@ jobs: postman_changed: ${{ needs.filter-commit-changes.outputs.postman }} prover_changed: ${{ needs.filter-commit-changes.outputs.prover }} transaction_exclusion_api_changed: ${{ needs.filter-commit-changes.outputs.transaction-exclusion-api }} + native_yield_automation_service_changed: ${{ needs.filter-commit-changes.outputs.native-yield-automation-service }} build_linea_besu_package: "false" # dockerhub image build and push will be on linea-besu-package-release when push to main coordinator_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_coordinator }} postman_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_postman }} prover_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_prover }} transaction_exclusion_api_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_transaction_exclusion_api }} + native_yield_automation_service_image_tagged: ${{ needs.check-and-tag-images.outputs.image_tagged_native_yield_automation_service }} secrets: inherit cleanup-deployments: diff --git a/.github/workflows/native-yield-automation-service-build-and-publish.yml b/.github/workflows/native-yield-automation-service-build-and-publish.yml new file mode 100644 index 0000000000..165701ab76 --- /dev/null +++ b/.github/workflows/native-yield-automation-service-build-and-publish.yml @@ -0,0 +1,130 @@ +name: native-yield-automation-service-build + +permissions: + contents: read + actions: read + packages: write + +on: + workflow_call: + inputs: + commit_tag: + required: true + type: string + develop_tag: + required: true + type: string + image_name: + required: true + type: string + push_image: + required: false + type: boolean + default: false + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + workflow_dispatch: + inputs: + commit_tag: + description: 'Image tag' + required: true + type: string + develop_tag: + description: 'Image tag will be "develop" if target branch is main' + required: true + type: choice + options: + - develop + default: 'develop' + image_name: + description: 'Image name' + required: true + type: string + default: 'consensys/linea-native-yield-automation-service' + push_image: + description: 'Toggle whether to push image to docker registry' + required: false + type: boolean + default: true + +concurrency: + group: native-yield-automation-service-build-and-publish-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + build-and-publish: + # ~1 min saved vs small + runs-on: gha-runner-scale-set-ubuntu-22.04-amd64-med + name: native-yield-automation-service + env: + COMMIT_TAG: ${{ inputs.commit_tag }} + DEVELOP_TAG: ${{ inputs.develop_tag }} + IMAGE_NAME: ${{ inputs.image_name }} + PUSH_IMAGE: ${{ inputs.push_image }} + TAGS: ${{ inputs.image_name }}:${{ inputs.commit_tag }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + steps: + - name: Set develop tag if main branch + if: ${{ github.ref == 'refs/heads/main' }} + run: | + echo "TAGS=${{ env.IMAGE_NAME }}:${{ env.COMMIT_TAG }},${{ env.IMAGE_NAME }}:${{ env.DEVELOP_TAG }}" >> $GITHUB_ENV + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + - name: Login to Docker Hub + if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }} + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 #v3.6.0 + with: + platforms: 'arm64,arm' + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 #v3.11.1 + - name: Show the "version" build argument + run: | + echo "We inject the commit tag in the docker image ${{ env.COMMIT_TAG }}" + echo COMMIT_TAG=${{ env.COMMIT_TAG }} >> $GITHUB_ENV + - name: Build native-yield-automation-service image for testing + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0 + if: ${{ env.PUSH_IMAGE == 'false' }} + with: + context: ./ + file: ./native-yield-operations/automation-service/Dockerfile + platforms: linux/amd64 + load: true + push: false + tags: ${{ env.IMAGE_NAME }}:${{ env.COMMIT_TAG }} + - name: Save Docker image as artifact + if: ${{ env.PUSH_IMAGE == 'false' }} + run: | + docker save ${{ env.IMAGE_NAME }}:${{ env.COMMIT_TAG }} | gzip > linea-native-yield-automation-service-docker-image.tar.gz + shell: bash + - name: Upload Docker image artifact + if: ${{ env.PUSH_IMAGE == 'false' }} + uses: actions/upload-artifact@v4 + with: + name: linea-native-yield-automation-service + path: linea-native-yield-automation-service-docker-image.tar.gz + - name: Build and push native-yield-automation-service image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 #v6.18.0 + if: ${{ env.PUSH_IMAGE == 'true' || github.event_name == 'workflow_dispatch' }} + with: + context: ./ + file: ./native-yield-operations/automation-service/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ env.TAGS }} + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}:buildcache-amd64,platform=linux/amd64 + type=registry,ref=${{ env.IMAGE_NAME }}:buildcache-arm64,platform=linux/arm64 + cache-to: | + type=registry,ref=${{ env.IMAGE_NAME }}:buildcache-amd64,mode=max,platform=linux/amd64 + type=registry,ref=${{ env.IMAGE_NAME }}:buildcache-arm64,mode=max,platform=linux/arm64 diff --git a/.github/workflows/native-yield-automation-service-testing.yml b/.github/workflows/native-yield-automation-service-testing.yml new file mode 100644 index 0000000000..3ecc47936a --- /dev/null +++ b/.github/workflows/native-yield-automation-service-testing.yml @@ -0,0 +1,33 @@ +name: native-yield-automation-service-testing + +permissions: + contents: read + actions: read + +on: + workflow_call: + +concurrency: + group: native-yield-automation-service-testing-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + run-tests: + runs-on: gha-runner-scale-set-ubuntu-22.04-amd64-med + name: native-yield-automation-service-testing + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup nodejs environment + uses: ./.github/actions/setup-nodejs + + - name: Build + run: | + pnpm --filter @consensys/linea-shared-utils build + pnpm --filter @consensys/linea-native-yield-automation-service build + + - name: Run tests and generate coverage report + run: | + pnpm --filter @consensys/linea-shared-utils test + pnpm --filter @consensys/linea-native-yield-automation-service test diff --git a/.github/workflows/postman-testing.yml b/.github/workflows/postman-testing.yml index 18a717c9bb..f0f1cbb209 100644 --- a/.github/workflows/postman-testing.yml +++ b/.github/workflows/postman-testing.yml @@ -27,6 +27,7 @@ jobs: NATIVE_LIBS_RELEASE_TAG: blob-libs-v1.2.0 run: | pnpm run -F ./ts-libs/linea-native-libs build; + pnpm run -F linea-shared-utils build; pnpm run -F ./sdk/sdk-ethers build; pnpm run -F ./postman test; pnpm run -F ./sdk/sdk-ethers test; diff --git a/.github/workflows/reuse-check-images-tags-and-push.yml b/.github/workflows/reuse-check-images-tags-and-push.yml index 2567cc4e2c..3921b05a37 100644 --- a/.github/workflows/reuse-check-images-tags-and-push.yml +++ b/.github/workflows/reuse-check-images-tags-and-push.yml @@ -23,6 +23,9 @@ on: transaction_exclusion_api_changed: required: true type: string + native_yield_automation_service_changed: + required: true + type: string outputs: commit_tag: value: ${{ jobs.check_image_tags_exist.outputs.commit_tag }} @@ -36,6 +39,8 @@ on: value: ${{ jobs.image_tag_push.outputs.image_tagged_prover }} image_tagged_postman: value: ${{ jobs.image_tag_push.outputs.image_tagged_postman }} + image_tagged_native_yield_automation_service: + value: ${{ jobs.image_tag_push.outputs.image_tagged_native_yield_automation_service }} image_tagged_transaction_exclusion_api: value: ${{ jobs.image_tag_push.outputs.image_tagged_transaction_exclusion_api }} secrets: @@ -57,6 +62,7 @@ jobs: EVENT_BEFORE: ${{ github.event.before }} outputs: last_commit_tag_exists_coordinator: ${{ steps.check_image_tags_exist_coordinator.outputs.last_commit_tag_exists }} + last_commit_tag_exists_native_yield_automation_service: ${{ steps.check_image_tags_exist_native_yield_automation_service.outputs.last_commit_tag_exists }} last_commit_tag_exists_postman: ${{ steps.check_image_tags_exist_postman.outputs.last_commit_tag_exists }} last_commit_tag_exists_prover: ${{ steps.check_image_tags_exist_prover.outputs.last_commit_tag_exists }} last_commit_tag_exists_transaction_exclusion_api: ${{ steps.check_image_tags_exist_transaction_exclusion_api.outputs.last_commit_tag_exists }} @@ -102,6 +108,14 @@ jobs: last_commit_tag: ${{ steps.compute-version-tags.outputs.LAST_COMMIT_TAG }} image_name: consensys/linea-postman + - name: Check image tags exist for native-yield-automation-service + uses: ./.github/actions/check-image-tags-exist + if: ${{ inputs.native_yield_automation_service_changed == 'false' }} + id: check_image_tags_exist_native_yield_automation_service + with: + last_commit_tag: ${{ steps.compute-version-tags.outputs.LAST_COMMIT_TAG }} + image_name: consensys/linea-native-yield-automation-service + - name: Check image tags exist for prover uses: ./.github/actions/check-image-tags-exist if: ${{ inputs.prover_changed == 'false' }} @@ -125,6 +139,7 @@ jobs: needs: [ check_image_tags_exist ] outputs: image_tagged_coordinator: ${{ steps.image_tag_push_coordinator.outputs.image_tagged }} + image_tagged_native_yield_automation_service: ${{ steps.image_tag_push_native_yield_automation_service.outputs.image_tagged }} image_tagged_prover: ${{ steps.image_tag_push_prover.outputs.image_tagged }} image_tagged_postman: ${{ steps.image_tag_push_postman.outputs.image_tagged }} image_tagged_transaction_exclusion_api: ${{ steps.image_tag_push_transaction_exclusion_api.outputs.image_tagged }} @@ -158,6 +173,19 @@ jobs: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Tag and push native-yield-automation-service image + id: image_tag_push_native_yield_automation_service + uses: ./.github/actions/image-tag-and-push + if: ${{ inputs.native_yield_automation_service_changed == 'false' }} + with: + commit_tag: ${{ needs.check_image_tags_exist.outputs.commit_tag }} + last_commit_tag: ${{ needs.check_image_tags_exist.outputs.last_commit_tag }} + develop_tag: ${{ needs.check_image_tags_exist.outputs.develop_tag }} + image_name: consensys/linea-native-yield-automation-service + last_commit_tag_exists: ${{ needs.check_image_tags_exist.outputs.last_commit_tag_exists_native_yield_automation_service }} + docker_username: ${{ secrets.DOCKERHUB_USERNAME }} + docker_password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Tag and push prover image id: image_tag_push_prover uses: ./.github/actions/image-tag-and-push diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 080d1b8724..9a34a0d1f4 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -16,6 +16,9 @@ on: staterecovery_changed: required: true type: string + native_yield_automation_service_changed: + required: true + type: string postman_changed: required: true type: string @@ -47,6 +50,11 @@ jobs: if: ${{ inputs.prover_changed == 'true' }} secrets: inherit + native-yield-automation-service: + uses: ./.github/workflows/native-yield-automation-service-testing.yml + if: ${{ inputs.native_yield_automation_service_changed == 'true' }} + secrets: inherit + postman: uses: ./.github/workflows/postman-testing.yml if: ${{ inputs.postman_changed == 'true' }} @@ -79,7 +87,7 @@ jobs: # If all jobs are skipped, the workflow will still succeed. always_succeed: runs-on: ubuntu-24.04 - if: ${{ inputs.coordinator_changed == 'false' && inputs.prover_changed == 'false' && inputs.postman_changed == 'false' && inputs.transaction_exclusion_api_changed == 'false' }} + if: ${{ inputs.coordinator_changed == 'false' && inputs.prover_changed == 'false' && inputs.native_yield_automation_service_changed == 'false' && inputs.postman_changed == 'false' && inputs.transaction_exclusion_api_changed == 'false' && inputs.staterecovery_changed == 'false' && inputs.smart_contracts_changed == 'false' && inputs.linea_sequencer_changed == 'false' }} steps: - name: Ensure Workflow Success run: echo "All jobs were skipped, but workflow succeeds." diff --git a/.gitignore b/.gitignore index adac324f1f..500eac1445 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ build/ cache_forge/ coverage/ dist/ +ipfs-cache/ node_modules/ out/ playwright-report/ diff --git a/.npmrc b/.npmrc index 5dc4d03908..2657a2e025 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ auto-install-peers = true enable-pre-post-scripts = true +inject-workspace-packages = false # Breaks linea-native-yield-automation-service local build \ No newline at end of file diff --git a/bridge-ui/package.json b/bridge-ui/package.json index e56ddd5da1..7cfa8e236a 100644 --- a/bridge-ui/package.json +++ b/bridge-ui/package.json @@ -56,7 +56,7 @@ "sharp": "0.33.5", "viem": "catalog:", "wagmi": "2.16.9", - "zod": "3.24.2", + "zod": "catalog:", "zustand": "4.5.4" }, "devDependencies": { diff --git a/native-yield-operations/automation-service/.env.sample b/native-yield-operations/automation-service/.env.sample new file mode 100644 index 0000000000..77f56f1258 --- /dev/null +++ b/native-yield-operations/automation-service/.env.sample @@ -0,0 +1,48 @@ +## For configuration schema, see `native-yield-operations/automation-service/src/application/main/config/config.schema.ts`` + +# --- RPC and API Endpoints --- +CHAIN_ID=1 +L1_RPC_URL=https://mainnet.infura.io/v3/YOUR_PROJECT_ID +BEACON_CHAIN_RPC_URL=https://beaconcha.in/api +STAKING_GRAPHQL_URL=https://staking.api/graphql +IPFS_BASE_URL=https://ipfs.io/ipfs/ + +# --- Consensys Staking OAuth2 --- +CONSENSYS_STAKING_OAUTH2_TOKEN_ENDPOINT=https://auth.consensys.net/oauth/token +CONSENSYS_STAKING_OAUTH2_CLIENT_ID=your-client-id +CONSENSYS_STAKING_OAUTH2_CLIENT_SECRET=your-client-secret +CONSENSYS_STAKING_OAUTH2_AUDIENCE=your-audience + +# --- L1 Contract Addresses --- +LINEA_ROLLUP_ADDRESS=0x0000000000000000000000000000000000000000 +LAZY_ORACLE_ADDRESS=0x0000000000000000000000000000000000000000 +VAULT_HUB_ADDRESS=0x0000000000000000000000000000000000000000 +YIELD_MANAGER_ADDRESS=0x0000000000000000000000000000000000000000 +LIDO_YIELD_PROVIDER_ADDRESS=0x0000000000000000000000000000000000000000 + +# --- L2 Contract Addresses --- +L2_YIELD_RECIPIENT=0x0000000000000000000000000000000000000000 + +# --- Timing Intervals (milliseconds) --- +TRIGGER_EVENT_POLL_INTERVAL_MS=3_600_000 +TRIGGER_MAX_INACTION_MS=86_400_000 +CONTRACT_READ_RETRY_TIME_MS=10_000 + +# --- Rebalance Tolerance --- +REBALANCE_TOLERANCE_BPS=100 # Between 1 and 10000 + +# --- Unstake Parameters --- +MAX_VALIDATOR_WITHDRAWAL_REQUESTS_PER_TRANSACTION=10 +MIN_WITHDRAWAL_THRESHOLD_ETH=1 + +# --- Web3Signer --- +WEB3SIGNER_URL=https://web3signer.yourdomain.com +WEB3SIGNER_PUBLIC_KEY=0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +WEB3SIGNER_KEYSTORE_PATH=./secrets/keystore.p12 +WEB3SIGNER_KEYSTORE_PASSPHRASE=your-keystore-pass +WEB3SIGNER_TRUSTSTORE_PATH=./secrets/truststore.p12 +WEB3SIGNER_TRUSTSTORE_PASSPHRASE=your-truststore-pass +WEB3SIGNER_TLS_ENABLED=true + +# --- API Configuration --- +API_PORT=3000 \ No newline at end of file diff --git a/native-yield-operations/automation-service/.eslintignore b/native-yield-operations/automation-service/.eslintignore new file mode 100644 index 0000000000..0e177d4fc9 --- /dev/null +++ b/native-yield-operations/automation-service/.eslintignore @@ -0,0 +1,3 @@ +dist +node_modules +typechain \ No newline at end of file diff --git a/native-yield-operations/automation-service/.eslintrc.cjs b/native-yield-operations/automation-service/.eslintrc.cjs new file mode 100644 index 0000000000..302bd2a817 --- /dev/null +++ b/native-yield-operations/automation-service/.eslintrc.cjs @@ -0,0 +1,15 @@ +module.exports = { + extends: "../../.eslintrc.js", + env: { + commonjs: true, + es2021: true, + node: true, + jest: true, + }, + parserOptions: { + sourceType: "module", + }, + rules: { + "prettier/prettier": "error", + }, +}; diff --git a/native-yield-operations/automation-service/.prettierignore b/native-yield-operations/automation-service/.prettierignore new file mode 100644 index 0000000000..0e177d4fc9 --- /dev/null +++ b/native-yield-operations/automation-service/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +typechain \ No newline at end of file diff --git a/native-yield-operations/automation-service/.prettierrc.cjs b/native-yield-operations/automation-service/.prettierrc.cjs new file mode 100644 index 0000000000..e6454e14f7 --- /dev/null +++ b/native-yield-operations/automation-service/.prettierrc.cjs @@ -0,0 +1,3 @@ +module.exports = { + ...require('../../.prettierrc.js'), +}; diff --git a/native-yield-operations/automation-service/Dockerfile b/native-yield-operations/automation-service/Dockerfile new file mode 100644 index 0000000000..34ac24977d --- /dev/null +++ b/native-yield-operations/automation-service/Dockerfile @@ -0,0 +1,49 @@ +FROM node:lts-slim AS base + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +# Temp fix for the corepack issue described in https://github.com/pnpm/pnpm/issues/9029 +RUN npm i -g corepack@latest + +RUN corepack enable + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates bash curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +FROM base AS builder + +WORKDIR /usr/src/app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ + +COPY ./native-yield-operations/automation-service/package.json ./native-yield-operations/automation-service/package.json +COPY ./ts-libs/linea-shared-utils/package.json ./ts-libs/linea-shared-utils/package.json + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prefer-offline --ignore-scripts + +COPY ./native-yield-operations/automation-service ./native-yield-operations/automation-service +COPY ts-libs/linea-shared-utils ./ts-libs/linea-shared-utils + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm run build \ + && pnpm deploy --legacy --filter=./native-yield-operations/automation-service --prod ./prod/native-yield-operations/automation-service + +FROM node:lts-slim AS production + +ENV NODE_ENV=production + +WORKDIR /usr/src/app + +# Create ipfs-cache directory with proper permissions before switching to node user +# The @lidofinance/lsv-cli library requires this directory for caching IPFS data +RUN mkdir -p /usr/src/app/ipfs-cache && \ + chown -R node:node /usr/src/app/ipfs-cache && \ + chmod 755 /usr/src/app/ipfs-cache + +USER node + +COPY --from=builder /usr/src/app/prod/native-yield-operations/automation-service ./native-yield-operations/automation-service + +CMD [ "node", "./native-yield-operations/automation-service/dist/run.js" ] diff --git a/native-yield-operations/automation-service/README.md b/native-yield-operations/automation-service/README.md new file mode 100644 index 0000000000..8c60a3883f --- /dev/null +++ b/native-yield-operations/automation-service/README.md @@ -0,0 +1,82 @@ +# Linea Native Yield Automation Service + +## Overview + +The Linea Native Yield Automation Service automates native yield operations by continuously monitoring the YieldManager contract's ossification state and executing mode-specific processors. + +The service is triggered through an event-driven mechanism that watches for `VaultsReportDataUpdated` events from the LazyOracle contract. The `waitForVaultsReportDataUpdatedEvent()` function implements a race condition between event detection and a configurable maximum wait duration — whichever occurs first triggers the operation cycle, ensuring timely execution even when events are absent. + +Operation modes are selected dynamically based on the yield provider's state: +- `YIELD_REPORTING_MODE` for normal operations +- `OSSIFICATION_PENDING_MODE` during transition +- `OSSIFICATION_COMPLETE_MODE` once ossified. + +## Codebase Architecture + +The codebase follows a **Layered Architecture with Dependency Inversion**, incorporating concepts from Hexagonal Architecture (Ports and Adapters) and Domain-Driven Design: + +- **`core/`** - Domain layer containing interfaces (ports), entities, enums, and ABIs. This layer has no dependencies on other internal layers. +- **`services/`** - Application layer containing business logic that orchestrates operations using interfaces from `core/`. +- **`clients/`** - Infrastructure layer containing adapter implementations of interfaces defined in `core/`. +- **`application/`** - Composition layer that wires dependencies and bootstraps the service. + +Dependencies flow inward: `application` → `services/clients` → `core`. This ensures business logic remains independent of infrastructure concerns, making the codebase testable and maintainable. + +## Folder Structure + +``` +automation-service/ +├── src/ +│ ├── application/ # Application bootstrap and configuration +│ │ ├── main/ # Service entry point and config loading +│ │ └── metrics/ # Metrics service and updaters +│ ├── clients/ # External service clients +│ │ ├── contracts/ # Smart contract clients (LazyOracle, YieldManager, VaultHub, etc.) +│ ├── core/ # Interfaces +│ │ ├── abis/ # Contract ABIs +│ │ ├── clients/ # Client interfaces +│ │ ├── entities/ # Domain entities and data models +│ │ ├── enums/ # Enums +│ │ ├── metrics/ # Metrics interfaces and types +│ │ └── services/ # Service interfaces +│ ├── services/ # Business logic services +│ │ ├── operation-mode-processors/ # Mode-specific processors +│ │ └── OperationModeSelector.ts # Mode selection orchestrator +│ └── utils/ # Utility functions +└── scripts/ # Testing and utility scripts +``` + +## Configuration + +See the [configuration schema file](./src/application/main/config/config.schema.ts) + +## Development + +### Running + +#### Start the docker local stack + +TODO - Planning to write E2E tests with mock Lido contracts once this branch + Native Yield contracts are together + +#### Run the automation service locally: + +1. Create `.env` as per `.env.sample` and the [configuration schema](./src/application/main/config/config.schema.ts) + +2. `pnpm --filter @consensys/linea-native-yield-automation-service exec tsx run.ts` + +### Build + +```bash +# Dependency on pnpm --filter @consensys/linea-shared-utils build +pnpm --filter @consensys/linea-native-yield-automation-service build +``` + +### Unit Test + +```bash +pnpm --filter @consensys/linea-shared-utils test +``` + +## License + +This package is licensed under the [Apache 2.0](../../LICENSE-APACHE) and the [MIT](../../LICENSE-MIT) licenses. diff --git a/native-yield-operations/automation-service/jest.config.js b/native-yield-operations/automation-service/jest.config.js new file mode 100644 index 0000000000..fe99c75f2d --- /dev/null +++ b/native-yield-operations/automation-service/jest.config.js @@ -0,0 +1,31 @@ +// jest.config.mjs +/** @type {import('jest').Config} */ +export default { + preset: "ts-jest/presets/default-esm", + testEnvironment: "node", + rootDir: ".", + + // Prefer testMatch over a bare regex + testMatch: ["**/__tests__/**/*.test.ts"], + + verbose: true, + collectCoverage: true, + collectCoverageFrom: ["src/**/*.ts"], + coverageReporters: ["html", "lcov", "text"], + testPathIgnorePatterns: ["src/run.ts", "src/utils/createApolloClient.ts", "src/core"], + coveragePathIgnorePatterns: ["src/run.ts", "src/utils/createApolloClient.ts", "src/core"], + + // Tell Jest that .ts files are ESM and have ts-jest emit ESM + extensionsToTreatAsEsm: [".ts"], + transform: { + "^.+\\.tsx?$": ["ts-jest", { useESM: true, tsconfig: "tsconfig.jest.json" }], + }, + + // If your source imports have ".js" suffix (ESM style), rewrite for TS + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + + // Only add node_modules here if you hit ESM-only deps later + transformIgnorePatterns: ["/node_modules/(?!(@lidofinance/lsv-cli|some-esm-only-lib)/)"], +}; diff --git a/native-yield-operations/automation-service/package.json b/native-yield-operations/automation-service/package.json new file mode 100644 index 0000000000..bcf6b1b9a5 --- /dev/null +++ b/native-yield-operations/automation-service/package.json @@ -0,0 +1,41 @@ +{ + "name": "@consensys/linea-native-yield-automation-service", + "version": "1.0.0", + "author": "Consensys Software Inc.", + "license": "(MIT OR Apache-2.0)", + "description": "", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "scripts": { + "lint:ts": "npx eslint '**/*.ts'", + "lint:ts:fix": "npx eslint --fix '**/*.ts'", + "prettier": "prettier -c '**/*.ts'", + "prettier:fix": "prettier -w '**/*.ts'", + "clean": "rimraf dist node_modules coverage tsconfig.build.tsbuildinfo", + "build": "tsc -p tsconfig.build.json", + "build:runSdk": "tsc ./scripts/runSdk.ts", + "test": "npx jest --bail --detectOpenHandles --forceExit", + "lint:fix": "pnpm run lint:ts:fix && pnpm run prettier:fix" + }, + "dependencies": { + "@apollo/client": "4.0.7", + "@consensys/linea-shared-utils": "workspace:*", + "@lidofinance/lsv-cli": "1.0.0-alpha.62", + "dotenv": "catalog:", + "neverthrow": "catalog:", + "winston": "catalog:", + "viem": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@jest/globals": "catalog:", + "@types/jest": "catalog:", + "jest": "catalog:", + "jest-mock-extended": "catalog:", + "ts-jest": "catalog:" + }, + "files": [ + "dist/**/*" + ], + "type": "module" +} diff --git a/native-yield-operations/automation-service/run.ts b/native-yield-operations/automation-service/run.ts new file mode 100644 index 0000000000..4b49abd741 --- /dev/null +++ b/native-yield-operations/automation-service/run.ts @@ -0,0 +1,28 @@ +import * as dotenv from "dotenv"; +import { loadConfigFromEnv } from "./src/application/main/config/loadConfigFromEnv.js"; +import { NativeYieldAutomationServiceBootstrap } from "./src/application/main/NativeYieldAutomationServiceBootstrap.js"; + +dotenv.config(); + +async function main() { + const options = loadConfigFromEnv(); + const application = new NativeYieldAutomationServiceBootstrap({ + ...options, + }); + application.startAllServices(); +} + +main() + .then() + .catch((error) => { + console.error("", error); + process.exit(1); + }); + +process.on("SIGINT", () => { + process.exit(0); +}); + +process.on("SIGTERM", () => { + process.exit(0); +}); diff --git a/native-yield-operations/automation-service/scripts/test-consensys-staking-graphql-client.ts b/native-yield-operations/automation-service/scripts/test-consensys-staking-graphql-client.ts new file mode 100644 index 0000000000..b9fa9daea9 --- /dev/null +++ b/native-yield-operations/automation-service/scripts/test-consensys-staking-graphql-client.ts @@ -0,0 +1,81 @@ +/** + * Manual integration runner for ConsensysStakingApiClient. + * + * Example usage: + * GRAPHQL_ENDPOINT=https://example/graphql \ + * TOKEN_URL=... \ + * CLIENT_ID=... \ + * CLIENT_SECRET=... \ + * AUDIENCE=... \ + * BEACON_NODE_RPC_URL=https://example/beacon \ + * pnpm --filter @consensys/linea-native-yield-automation-service exec tsx scripts/test-consensys-staking-graphql-client.ts + */ + +import { + BeaconNodeApiClient, + WinstonLogger, + OAuth2TokenClient, + ExponentialBackoffRetryService, +} from "@consensys/linea-shared-utils"; +import { ConsensysStakingApiClient } from "../src/clients/ConsensysStakingApiClient.js"; +import { createApolloClient } from "../src/utils/createApolloClient.js"; + +// private readonly apolloClient: ApolloClient, + +async function main() { + const requiredEnvVars = [ + "GRAPHQL_ENDPOINT", + "BEACON_NODE_RPC_URL", + "TOKEN_URL", + "CLIENT_ID", + "CLIENT_SECRET", + "AUDIENCE", + ]; + const missing = requiredEnvVars.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.error(`Missing required env vars: ${missing.join(", ")}`); + process.exitCode = 1; + return; + } + + const retryService = new ExponentialBackoffRetryService(new WinstonLogger(ExponentialBackoffRetryService.name)); + const beaconClient = new BeaconNodeApiClient( + new WinstonLogger("BeaconNodeApiClient.integration"), + retryService, + process.env.BEACON_NODE_RPC_URL!, + ); + + const tokenClient = new OAuth2TokenClient( + new WinstonLogger("OAuth2TokenClient.integration"), + retryService, + process.env.TOKEN_URL!, + process.env.CLIENT_ID!, + process.env.CLIENT_SECRET!, + process.env.AUDIENCE!, + ); + + const apolloClient = createApolloClient(tokenClient, process.env.GRAPHQL_ENDPOINT!); + const consensysStakingClient = new ConsensysStakingApiClient( + new WinstonLogger("ConsensysStakingApiClient.integration"), + retryService, + apolloClient, + beaconClient, + ); + + try { + const validators = await consensysStakingClient.getActiveValidatorsWithPendingWithdrawals(); + if (validators === undefined) { + console.error("Failed getActiveValidatorsWithPendingWithdrawals"); + throw "Failed getActiveValidatorsWithPendingWithdrawals"; + } + console.log(`Fetched ${validators.length} validators with pending withdrawals.`); + console.log(validators); + const totalPendingWei = consensysStakingClient.getTotalPendingPartialWithdrawalsWei(validators); + console.log(`Total pending partial withdrawals (wei): ${totalPendingWei.toString()}`); + } catch (err) { + console.error("ConsensysStakingApiClient integration script failed:", err); + process.exitCode = 1; + } +} + +main(); diff --git a/native-yield-operations/automation-service/scripts/test-lazy-oracle-contract-client.ts b/native-yield-operations/automation-service/scripts/test-lazy-oracle-contract-client.ts new file mode 100644 index 0000000000..b8600ef1ae --- /dev/null +++ b/native-yield-operations/automation-service/scripts/test-lazy-oracle-contract-client.ts @@ -0,0 +1,70 @@ +/** + * Manual integration runner for LidoAccountingReportClient. + * + * Example usage: + * RPC_URL=https://mainnet.infura.io/v3/YOUR_KEY \ + * PRIVATE_KEY=0xabc123... \ + * LAZY_ORACLE_ADDRESS=0x... \ + * LIDO_VAULT_ADDRESS=0x... \ + * pnpm --filter @consensys/linea-native-yield-automation-service exec tsx scripts/test-lazy-oracle-contract-client.ts + * + * Optional flags: + * POLL_INTERVAL_MS=60000 \ + * WAIT_TIMEOUT_MS=300000 \ + */ + +import { + ViemBlockchainClientAdapter, + ViemWalletSignerClientAdapter, + WinstonLogger, +} from "@consensys/linea-shared-utils"; +import { LazyOracleContractClient } from "../src/clients/contracts/LazyOracleContractClient.js"; +import { Address, Hex } from "viem"; +import { hoodi } from "viem/chains"; + +async function main() { + const requiredEnvVars = ["RPC_URL", "PRIVATE_KEY", "LAZY_ORACLE_ADDRESS"]; + + const missing = requiredEnvVars.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.error(`Missing required env vars: ${missing.join(", ")}`); + process.exitCode = 1; + return; + } + + const rpcUrl = process.env.RPC_URL as string; + const privateKey = process.env.PRIVATE_KEY as Hex; + const lazyOracleAddress = process.env.LAZY_ORACLE_ADDRESS as Address; + const pollIntervalMs = Number.parseInt(process.env.POLL_INTERVAL_MS ?? "60000", 10); + const waitTimeoutMs = Number.parseInt(process.env.WAIT_TIMEOUT_MS ?? "300000", 10); + + const signer = new ViemWalletSignerClientAdapter( + new WinstonLogger("ViemWalletSignerClientAdapter.integration"), + rpcUrl, + privateKey, + hoodi, + ); + const contractClientLibrary = new ViemBlockchainClientAdapter( + new WinstonLogger("ViemBlockchainClientAdapter.integration"), + rpcUrl, + hoodi, + signer, + ); + + const lazyOracleClient = new LazyOracleContractClient( + new WinstonLogger("LazyOracleContractClient.integration"), + contractClientLibrary, + lazyOracleAddress, + pollIntervalMs, + waitTimeoutMs, + ); + + try { + await lazyOracleClient.waitForVaultsReportDataUpdatedEvent(); + } catch (err) { + console.error("LazyOracleContractClient integration script failed:", err); + process.exitCode = 1; + } +} + +void main(); diff --git a/native-yield-operations/automation-service/scripts/test-lido-accounting-report-client.ts b/native-yield-operations/automation-service/scripts/test-lido-accounting-report-client.ts new file mode 100644 index 0000000000..a92f74d2ef --- /dev/null +++ b/native-yield-operations/automation-service/scripts/test-lido-accounting-report-client.ts @@ -0,0 +1,103 @@ +/** + * Manual integration runner for LidoAccountingReportClient. + * + * Example usage: + * RPC_URL=https://mainnet.infura.io/v3/YOUR_KEY \ + * PRIVATE_KEY=0xabc123... \ + * LAZY_ORACLE_ADDRESS=0x... \ + * LIDO_VAULT_ADDRESS=0x... \ + * IPFS_BASE_URL=https://gateway.ipfs.io/ipfs \ + * pnpm --filter @consensys/linea-native-yield-automation-service exec tsx scripts/test-lido-accounting-report-client.ts + * + * Optional flags: + * POLL_INTERVAL_MS=60000 \ + * SKIP_SIMULATION=true \ + * SUBMIT_LATEST_REPORT=true \ + */ + +import { + ExponentialBackoffRetryService, + ViemBlockchainClientAdapter, + ViemWalletSignerClientAdapter, + WinstonLogger, +} from "@consensys/linea-shared-utils"; +import { LidoAccountingReportClient } from "../src/clients/LidoAccountingReportClient.js"; +import { LazyOracleContractClient } from "../src/clients/contracts/LazyOracleContractClient.js"; +import { Address, Hex } from "viem"; +import { hoodi } from "viem/chains"; + +async function main() { + const requiredEnvVars = ["RPC_URL", "PRIVATE_KEY", "LAZY_ORACLE_ADDRESS", "LIDO_VAULT_ADDRESS", "IPFS_BASE_URL"]; + + const missing = requiredEnvVars.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.error(`Missing required env vars: ${missing.join(", ")}`); + process.exitCode = 1; + return; + } + + const rpcUrl = process.env.RPC_URL as string; + const privateKey = process.env.PRIVATE_KEY as Hex; + const lazyOracleAddress = process.env.LAZY_ORACLE_ADDRESS as Address; + const lidoVaultAddress = process.env.LIDO_VAULT_ADDRESS as Address; + const ipfsGatewayUrl = process.env.IPFS_BASE_URL as string; + const pollIntervalMs = Number.parseInt(process.env.POLL_INTERVAL_MS ?? "60000", 10); + + const signer = new ViemWalletSignerClientAdapter( + new WinstonLogger("ViemWalletSignerClientAdapter.integration", { level: "debug" }), + rpcUrl, + privateKey, + hoodi, + ); + const contractClientLibrary = new ViemBlockchainClientAdapter( + new WinstonLogger("ViemBlockchainClientAdapter.integration", { level: "debug" }), + rpcUrl, + hoodi, + signer, + ); + + const lazyOracleClient = new LazyOracleContractClient( + new WinstonLogger("LazyOracleContractClient.integration", { level: "debug" }), + contractClientLibrary, + lazyOracleAddress, + pollIntervalMs, + 300_000, + ); + + const retryService = new ExponentialBackoffRetryService( + new WinstonLogger(ExponentialBackoffRetryService.name, { level: "debug" }), + ); + const lidoAccountingClient = new LidoAccountingReportClient( + new WinstonLogger("LidoAccountingReportClient.integration", { level: "debug" }), + retryService, + lazyOracleClient, + ipfsGatewayUrl, + ); + + try { + console.log("Fetching latest vault report parameters..."); + const params = await lidoAccountingClient.getLatestSubmitVaultReportParams(lidoVaultAddress); + console.log("Latest updateVaultData params:"); + console.log({ + ...params, + totalValue: params.totalValue.toString(), + cumulativeLidoFees: params.cumulativeLidoFees.toString(), + liabilityShares: params.liabilityShares.toString(), + maxLiabilityShares: params.maxLiabilityShares.toString(), + slashingReserve: params.slashingReserve.toString(), + }); + + if (process.env.SUBMIT_LATEST_REPORT === "true") { + console.log("Submitting latest vault report..."); + await lidoAccountingClient.submitLatestVaultReport(lidoVaultAddress); + console.log("Submission transaction sent ✔️"); + } else { + console.log("Submission skipped. Set SUBMIT_LATEST_REPORT=true to send the transaction."); + } + } catch (err) { + console.error("LidoAccountingReportClient integration script failed:", err); + process.exitCode = 1; + } +} + +void main(); diff --git a/native-yield-operations/automation-service/src/application/main/NativeYieldAutomationServiceBootstrap.ts b/native-yield-operations/automation-service/src/application/main/NativeYieldAutomationServiceBootstrap.ts new file mode 100644 index 0000000000..f5a9530085 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/NativeYieldAutomationServiceBootstrap.ts @@ -0,0 +1,284 @@ +import { + ExponentialBackoffRetryService, + ExpressApiApplication, + IApplication, + ILogger, + IMetricsService, + IRetryService, + WinstonLogger, +} from "@consensys/linea-shared-utils"; +import { NativeYieldAutomationServiceBootstrapConfig } from "./config/config.js"; +import { IOperationModeSelector } from "../../core/services/operation-mode/IOperationModeSelector.js"; +import { OperationModeSelector } from "../../services/OperationModeSelector.js"; +import { + IBlockchainClient, + ViemBlockchainClientAdapter, + Web3SignerClientAdapter, + IContractSignerClient, + IOAuth2TokenClient, + IBeaconNodeAPIClient, + BeaconNodeApiClient, + OAuth2TokenClient, +} from "@consensys/linea-shared-utils"; +import { Chain, PublicClient, TransactionReceipt } from "viem"; +import { YieldManagerContractClient } from "../../clients/contracts/YieldManagerContractClient.js"; +import { IYieldManager } from "../../core/clients/contracts/IYieldManager.js"; +import { YieldReportingProcessor } from "../../services/operation-mode-processors/YieldReportingProcessor.js"; +import { LazyOracleContractClient } from "../../clients/contracts/LazyOracleContractClient.js"; +import { ILazyOracle } from "../../core/clients/contracts/ILazyOracle.js"; +import { ApolloClient } from "@apollo/client"; +import { ILineaRollupYieldExtension } from "../../core/clients/contracts/ILineaRollupYieldExtension.js"; +import { LineaRollupYieldExtensionContractClient } from "../../clients/contracts/LineaRollupYieldExtensionContractClient.js"; +import { IOperationModeProcessor } from "../../core/services/operation-mode/IOperationModeProcessor.js"; +import { ILidoAccountingReportClient } from "../../core/clients/ILidoAccountingReportClient.js"; +import { IBeaconChainStakingClient } from "../../core/clients/IBeaconChainStakingClient.js"; +import { IValidatorDataClient } from "../../core/clients/IValidatorDataClient.js"; +import { ConsensysStakingApiClient } from "../../clients/ConsensysStakingApiClient.js"; +import { LidoAccountingReportClient } from "../../clients/LidoAccountingReportClient.js"; +import { BeaconChainStakingClient } from "../../clients/BeaconChainStakingClient.js"; +import { OssificationCompleteProcessor } from "../../services/operation-mode-processors/OssificationCompleteProcessor.js"; +import { OssificationPendingProcessor } from "../../services/operation-mode-processors/OssificationPendingProcessor.js"; +import { mainnet, hoodi } from "viem/chains"; +import { createApolloClient } from "../../utils/createApolloClient.js"; +import { LineaNativeYieldAutomationServiceMetrics } from "../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; +import { NativeYieldAutomationMetricsService } from "../metrics/NativeYieldAutomationMetricsService.js"; +import { NativeYieldAutomationMetricsUpdater } from "../metrics/NativeYieldAutomationMetricsUpdater.js"; +import { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import { IVaultHub } from "../../core/clients/contracts/IVaultHub.js"; +import { VaultHubContractClient } from "../../clients/contracts/VaultHubContractClient.js"; +import { IOperationModeMetricsRecorder } from "../../core/metrics/IOperationModeMetricsRecorder.js"; +import { OperationModeMetricsRecorder } from "../metrics/OperationModeMetricsRecorder.js"; + +/** + * Bootstrap class for the Native Yield Automation Service. + * Initializes and configures all service dependencies including observability (logging and metrics), + * blockchain clients, contract clients, API clients, and operation mode processors. + * Manages the lifecycle of all services (start/stop) and provides access to configuration. + */ +export class NativeYieldAutomationServiceBootstrap { + private readonly config: NativeYieldAutomationServiceBootstrapConfig; + private readonly logger: ILogger; + private readonly metricsService: IMetricsService; + private readonly metricsUpdater: INativeYieldAutomationMetricsUpdater; + private readonly api: IApplication; + + private viemBlockchainClientAdapter: IBlockchainClient; + private web3SignerClient: IContractSignerClient; + private yieldManagerContractClient: IYieldManager; + private lazyOracleContractClient: ILazyOracle; + private vaultHubContractClient: IVaultHub; + private lineaRollupYieldExtensionContractClient: ILineaRollupYieldExtension; + + private exponentialBackoffRetryService: IRetryService; + private beaconNodeApiClient: IBeaconNodeAPIClient; + private oAuth2TokenClient: IOAuth2TokenClient; + private apolloClient: ApolloClient; + private beaconChainStakingClient: IBeaconChainStakingClient; + private lidoAccountingReportClient: ILidoAccountingReportClient; + private consensysStakingGraphQLClient: IValidatorDataClient; + + private readonly operationModeMetricsRecorder: IOperationModeMetricsRecorder; + private operationModeSelector: IOperationModeSelector; + private yieldReportingOperationModeProcessor: IOperationModeProcessor; + private ossificationPendingOperationModeProcessor: IOperationModeProcessor; + private ossificationCompleteOperationModeProcessor: IOperationModeProcessor; + + /** + * Creates a new NativeYieldAutomationServiceBootstrap instance. + * Initializes all service dependencies in the following order: + * 1. Observability - logging and metrics + * 2. Clients - blockchain, contract, and API clients + * 3. Processor Services - operation mode processors and selector + * + * @param {NativeYieldAutomationServiceBootstrapConfig} config - Configuration object containing all service settings. + */ + constructor(config: NativeYieldAutomationServiceBootstrapConfig) { + this.config = config; + + // Observability - logging and metrics + this.logger = new WinstonLogger(NativeYieldAutomationServiceBootstrap.name, config.loggerOptions); + this.metricsService = new NativeYieldAutomationMetricsService(); + this.metricsUpdater = new NativeYieldAutomationMetricsUpdater(this.metricsService); + this.api = new ExpressApiApplication( + this.config.apiPort, + this.metricsService, + new WinstonLogger(ExpressApiApplication.name), + ); + + // Clients + this.web3SignerClient = new Web3SignerClientAdapter( + new WinstonLogger(Web3SignerClientAdapter.name, config.loggerOptions), + config.web3signer.url, + config.web3signer.publicKey, + config.web3signer.keystore.path, + config.web3signer.keystore.passphrase, + config.web3signer.truststore.path, + config.web3signer.truststore.passphrase, + ); + + const getChain = (chainId: number): Chain => { + switch (chainId) { + case mainnet.id: + return mainnet; + case hoodi.id: + return hoodi; + default: + throw new Error(`Unsupported chain ID: ${chainId}`); + } + }; + this.viemBlockchainClientAdapter = new ViemBlockchainClientAdapter( + new WinstonLogger(ViemBlockchainClientAdapter.name, config.loggerOptions), + config.dataSources.l1RpcUrl, + getChain(config.dataSources.chainId), + this.web3SignerClient, + ); + this.yieldManagerContractClient = new YieldManagerContractClient( + new WinstonLogger(YieldManagerContractClient.name, config.loggerOptions), + this.viemBlockchainClientAdapter, + config.contractAddresses.yieldManagerAddress, + config.rebalanceToleranceBps, + config.minWithdrawalThresholdEth, + ); + this.lazyOracleContractClient = new LazyOracleContractClient( + new WinstonLogger(LazyOracleContractClient.name, config.loggerOptions), + this.viemBlockchainClientAdapter, + config.contractAddresses.lazyOracleAddress, + config.timing.trigger.pollIntervalMs, + config.timing.trigger.maxInactionMs, + ); + this.vaultHubContractClient = new VaultHubContractClient( + this.viemBlockchainClientAdapter, + config.contractAddresses.vaultHubAddress, + ); + this.lineaRollupYieldExtensionContractClient = new LineaRollupYieldExtensionContractClient( + new WinstonLogger(LineaRollupYieldExtensionContractClient.name, config.loggerOptions), + this.viemBlockchainClientAdapter, + config.contractAddresses.lineaRollupContractAddress, + ); + + this.exponentialBackoffRetryService = new ExponentialBackoffRetryService( + new WinstonLogger(ExponentialBackoffRetryService.name, config.loggerOptions), + ); + this.beaconNodeApiClient = new BeaconNodeApiClient( + new WinstonLogger(BeaconNodeApiClient.name, config.loggerOptions), + this.exponentialBackoffRetryService, + config.dataSources.beaconChainRpcUrl, + ); + this.oAuth2TokenClient = new OAuth2TokenClient( + new WinstonLogger(OAuth2TokenClient.name, config.loggerOptions), + this.exponentialBackoffRetryService, + config.consensysStakingOAuth2.tokenEndpoint, + config.consensysStakingOAuth2.clientId, + config.consensysStakingOAuth2.clientSecret, + config.consensysStakingOAuth2.audience, + ); + this.apolloClient = createApolloClient(this.oAuth2TokenClient, config.dataSources.stakingGraphQLUrl); + this.consensysStakingGraphQLClient = new ConsensysStakingApiClient( + new WinstonLogger(ConsensysStakingApiClient.name, config.loggerOptions), + this.exponentialBackoffRetryService, + this.apolloClient, + this.beaconNodeApiClient, + ); + this.lidoAccountingReportClient = new LidoAccountingReportClient( + new WinstonLogger(LidoAccountingReportClient.name, config.loggerOptions), + this.exponentialBackoffRetryService, + this.lazyOracleContractClient, + config.dataSources.ipfsBaseUrl, + ); + this.beaconChainStakingClient = new BeaconChainStakingClient( + new WinstonLogger(BeaconChainStakingClient.name, config.loggerOptions), + this.metricsUpdater, + this.consensysStakingGraphQLClient, + config.maxValidatorWithdrawalRequestsPerTransaction, + this.yieldManagerContractClient, + this.config.contractAddresses.lidoYieldProviderAddress, + ); + + // Processor Services + this.operationModeMetricsRecorder = new OperationModeMetricsRecorder( + new WinstonLogger(OperationModeMetricsRecorder.name, config.loggerOptions), + this.metricsUpdater, + this.yieldManagerContractClient, + this.vaultHubContractClient, + ); + + this.yieldReportingOperationModeProcessor = new YieldReportingProcessor( + new WinstonLogger(YieldReportingProcessor.name, config.loggerOptions), + this.metricsUpdater, + this.operationModeMetricsRecorder, + this.yieldManagerContractClient, + this.lazyOracleContractClient, + this.lineaRollupYieldExtensionContractClient, + this.lidoAccountingReportClient, + this.beaconChainStakingClient, + config.contractAddresses.lidoYieldProviderAddress, + config.contractAddresses.l2YieldRecipientAddress, + config.shouldSubmitVaultReport, + ); + + this.ossificationPendingOperationModeProcessor = new OssificationPendingProcessor( + new WinstonLogger(OssificationPendingProcessor.name, config.loggerOptions), + this.metricsUpdater, + this.operationModeMetricsRecorder, + this.yieldManagerContractClient, + this.lazyOracleContractClient, + this.lidoAccountingReportClient, + this.beaconChainStakingClient, + config.contractAddresses.lidoYieldProviderAddress, + config.shouldSubmitVaultReport, + ); + + this.ossificationCompleteOperationModeProcessor = new OssificationCompleteProcessor( + new WinstonLogger(OssificationCompleteProcessor.name, config.loggerOptions), + this.metricsUpdater, + this.operationModeMetricsRecorder, + this.yieldManagerContractClient, + this.beaconChainStakingClient, + config.timing.trigger.maxInactionMs, + config.contractAddresses.lidoYieldProviderAddress, + ); + + this.operationModeSelector = new OperationModeSelector( + new WinstonLogger(OperationModeSelector.name, config.loggerOptions), + this.metricsUpdater, + this.yieldManagerContractClient, + this.yieldReportingOperationModeProcessor, + this.ossificationPendingOperationModeProcessor, + this.ossificationCompleteOperationModeProcessor, + config.contractAddresses.lidoYieldProviderAddress, + config.timing.contractReadRetryTimeMs, + ); + } + + /** + * Starts all services. + * Purposely refrains from awaiting .start() methods so they don't become blocking calls. + * Starts the metrics API server and the operation mode selector. + */ + public startAllServices(): void { + this.api.start(); + this.logger.info("Metrics API server started"); + this.operationModeSelector.start(); + this.logger.info("Native yield automation service started"); + } + + /** + * Stops all services gracefully. + * Stops the metrics API server and the operation mode selector. + */ + public stopAllServices(): void { + this.api.stop(); + this.logger.info("Metrics API server stopped"); + this.operationModeSelector.stop(); + this.logger.info("Native yield automation service stopped"); + } + + /** + * Gets the bootstrap configuration. + * + * @returns {NativeYieldAutomationServiceBootstrapConfig} The configuration object used to initialize this bootstrap instance. + */ + public getConfig(): NativeYieldAutomationServiceBootstrapConfig { + return this.config; + } +} diff --git a/native-yield-operations/automation-service/src/application/main/__tests__/NativeYieldAutomationServiceBootstrap.test.ts b/native-yield-operations/automation-service/src/application/main/__tests__/NativeYieldAutomationServiceBootstrap.test.ts new file mode 100644 index 0000000000..10e89e0e83 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/__tests__/NativeYieldAutomationServiceBootstrap.test.ts @@ -0,0 +1,287 @@ +import { jest } from "@jest/globals"; + +const mockExpressApiApplication = jest.fn().mockImplementation(() => ({ + start: jest.fn(), + stop: jest.fn(), +})); +const mockOperationModeSelector = jest.fn().mockImplementation(() => ({ + start: jest.fn(), + stop: jest.fn(), +})); +const mockWinstonLogger = jest.fn().mockImplementation(() => ({ + info: jest.fn(), +})); +const mockViemBlockchainClientAdapter = jest.fn().mockImplementation(() => ({})); +const mockWeb3SignerClientAdapter = jest.fn().mockImplementation(() => ({})); + +jest.mock("@consensys/linea-shared-utils", () => ({ + ExponentialBackoffRetryService: jest.fn().mockImplementation(() => ({})), + ExpressApiApplication: mockExpressApiApplication, + WinstonLogger: mockWinstonLogger, + ViemBlockchainClientAdapter: mockViemBlockchainClientAdapter, + Web3SignerClientAdapter: mockWeb3SignerClientAdapter, + BeaconNodeApiClient: jest.fn().mockImplementation(() => ({})), + OAuth2TokenClient: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock( + "../../../services/OperationModeSelector.js", + () => ({ + OperationModeSelector: mockOperationModeSelector, + }), + { virtual: true }, +); + +jest.mock( + "../../../services/operation-mode-processors/YieldReportingProcessor.js", + () => ({ + YieldReportingProcessor: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../../services/operation-mode-processors/OssificationPendingProcessor.js", + () => ({ + OssificationPendingProcessor: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../../services/operation-mode-processors/OssificationCompleteProcessor.js", + () => ({ + OssificationCompleteProcessor: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); + +jest.mock( + "../../../clients/contracts/YieldManagerContractClient.js", + () => ({ + YieldManagerContractClient: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../../clients/contracts/LazyOracleContractClient.js", + () => ({ + LazyOracleContractClient: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../../clients/contracts/VaultHubContractClient.js", + () => ({ + VaultHubContractClient: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../../clients/contracts/LineaRollupYieldExtensionContractClient.js", + () => ({ + LineaRollupYieldExtensionContractClient: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); + +jest.mock( + "../../../clients/ConsensysStakingApiClient.js", + () => ({ + ConsensysStakingApiClient: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../../clients/LidoAccountingReportClient.js", + () => ({ + LidoAccountingReportClient: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../../clients/BeaconChainStakingClient.js", + () => ({ + BeaconChainStakingClient: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); + +jest.mock( + "../../metrics/NativeYieldAutomationMetricsService.js", + () => ({ + NativeYieldAutomationMetricsService: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../metrics/NativeYieldAutomationMetricsUpdater.js", + () => ({ + NativeYieldAutomationMetricsUpdater: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); +jest.mock( + "../../metrics/OperationModeMetricsRecorder.js", + () => ({ + OperationModeMetricsRecorder: jest.fn().mockImplementation(() => ({})), + }), + { virtual: true }, +); + +jest.mock( + "../../../utils/createApolloClient.js", + () => ({ + createApolloClient: jest.fn().mockReturnValue({}), + }), + { virtual: true }, +); + +jest.mock("viem/chains", () => ({ + mainnet: { id: 1 }, + hoodi: { id: 2 }, +})); + +let NativeYieldAutomationServiceBootstrap: any; + +beforeAll(async () => { + ({ NativeYieldAutomationServiceBootstrap } = await import("../NativeYieldAutomationServiceBootstrap.js")); +}); + +const createBootstrapConfig = () => ({ + dataSources: { + chainId: 1, + l1RpcUrl: "https://rpc.example.com", + beaconChainRpcUrl: "https://beacon.example.com", + stakingGraphQLUrl: "https://staking.example.com/graphql", + ipfsBaseUrl: "https://ipfs.example.com", + }, + consensysStakingOAuth2: { + tokenEndpoint: "https://auth.example.com/token", + clientId: "client-id", + clientSecret: "client-secret", + audience: "audience", + }, + contractAddresses: { + lineaRollupContractAddress: "0x1111111111111111111111111111111111111111", + lazyOracleAddress: "0x2222222222222222222222222222222222222222", + vaultHubAddress: "0x3333333333333333333333333333333333333333", + yieldManagerAddress: "0x4444444444444444444444444444444444444444", + lidoYieldProviderAddress: "0x5555555555555555555555555555555555555555", + l2YieldRecipientAddress: "0x7777777777777777777777777777777777777777", + }, + apiPort: 3000, + timing: { + trigger: { + pollIntervalMs: 1000, + maxInactionMs: 5000, + }, + contractReadRetryTimeMs: 250, + }, + rebalanceToleranceBps: 500, + maxValidatorWithdrawalRequestsPerTransaction: 16, + minWithdrawalThresholdEth: 42n, + web3signer: { + url: "https://web3signer.example.com", + publicKey: + "0x02aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + keystore: { + path: "/keystore", + passphrase: "keystore-pass", + }, + truststore: { + path: "/truststore", + passphrase: "truststore-pass", + }, + tlsEnabled: true, + }, + loggerOptions: { + level: "info", + transports: [], + }, +}); + +describe("NativeYieldAutomationServiceBootstrap", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("starts services and logs startup messages", () => { + const config = createBootstrapConfig(); + + const bootstrap = new NativeYieldAutomationServiceBootstrap(config); + bootstrap.startAllServices(); + + const apiInstance = mockExpressApiApplication.mock.results[0]?.value as { + start: jest.Mock; + stop: jest.Mock; + }; + const operationModeSelectorInstance = mockOperationModeSelector.mock.results[0]?.value as { + start: jest.Mock; + stop: jest.Mock; + }; + const loggerInstance = mockWinstonLogger.mock.results[0]?.value as { + info: jest.Mock; + }; + + expect(apiInstance.start).toHaveBeenCalledTimes(1); + expect(operationModeSelectorInstance.start).toHaveBeenCalledTimes(1); + expect(loggerInstance.info).toHaveBeenCalledWith("Metrics API server started"); + expect(loggerInstance.info).toHaveBeenCalledWith("Native yield automation service started"); + }); + + it("stops services and logs shutdown messages", () => { + const config = createBootstrapConfig(); + + const bootstrap = new NativeYieldAutomationServiceBootstrap(config); + bootstrap.stopAllServices(); + + const apiInstance = mockExpressApiApplication.mock.results[0]?.value as { + start: jest.Mock; + stop: jest.Mock; + }; + const operationModeSelectorInstance = mockOperationModeSelector.mock.results[0]?.value as { + start: jest.Mock; + stop: jest.Mock; + }; + const loggerInstance = mockWinstonLogger.mock.results[0]?.value as { + info: jest.Mock; + }; + + expect(apiInstance.stop).toHaveBeenCalledTimes(1); + expect(operationModeSelectorInstance.stop).toHaveBeenCalledTimes(1); + expect(loggerInstance.info).toHaveBeenCalledWith("Metrics API server stopped"); + expect(loggerInstance.info).toHaveBeenCalledWith("Native yield automation service stopped"); + }); + + it("exposes the bootstrap configuration", () => { + const config = createBootstrapConfig(); + + const bootstrap = new NativeYieldAutomationServiceBootstrap(config); + + expect(bootstrap.getConfig()).toBe(config); + }); + + it("creates blockchain client using hoodi chain when configured", () => { + const config = createBootstrapConfig(); + config.dataSources.chainId = 2; + + new NativeYieldAutomationServiceBootstrap(config); + + const { hoodi } = jest.requireMock("viem/chains") as { hoodi: { id: number } }; + expect(mockViemBlockchainClientAdapter).toHaveBeenCalledWith( + expect.anything(), + config.dataSources.l1RpcUrl, + hoodi, + expect.anything(), + ); + }); + + it("throws when configured with an unsupported chain id", () => { + const unsupportedChainId = 999; + const config = createBootstrapConfig(); + config.dataSources.chainId = unsupportedChainId; + + expect(() => new NativeYieldAutomationServiceBootstrap(config)).toThrow( + `Unsupported chain ID: ${unsupportedChainId}`, + ); + }); +}); diff --git a/native-yield-operations/automation-service/src/application/main/config/__tests__/config.schema.test.ts b/native-yield-operations/automation-service/src/application/main/config/__tests__/config.schema.test.ts new file mode 100644 index 0000000000..30f170683e --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/config/__tests__/config.schema.test.ts @@ -0,0 +1,82 @@ +import { configSchema } from "../config.schema.js"; +import { getAddress } from "viem"; + +const createValidEnv = () => ({ + CHAIN_ID: "11155111", + L1_RPC_URL: "https://rpc.linea.build", + BEACON_CHAIN_RPC_URL: "https://beacon.linea.build", + STAKING_GRAPHQL_URL: "https://staking.linea.build/graphql", + IPFS_BASE_URL: "https://ipfs.linea.build", + CONSENSYS_STAKING_OAUTH2_TOKEN_ENDPOINT: "https://auth.linea.build/token", + CONSENSYS_STAKING_OAUTH2_CLIENT_ID: "client-id", + CONSENSYS_STAKING_OAUTH2_CLIENT_SECRET: "client-secret", + CONSENSYS_STAKING_OAUTH2_AUDIENCE: "audience", + LINEA_ROLLUP_ADDRESS: "0x1111111111111111111111111111111111111111", + LAZY_ORACLE_ADDRESS: "0x2222222222222222222222222222222222222222", + VAULT_HUB_ADDRESS: "0x3333333333333333333333333333333333333333", + YIELD_MANAGER_ADDRESS: "0x4444444444444444444444444444444444444444", + LIDO_YIELD_PROVIDER_ADDRESS: "0x5555555555555555555555555555555555555555", + L2_YIELD_RECIPIENT: "0x7777777777777777777777777777777777777777", + TRIGGER_EVENT_POLL_INTERVAL_MS: "1000", + TRIGGER_MAX_INACTION_MS: "5000", + CONTRACT_READ_RETRY_TIME_MS: "250", + REBALANCE_TOLERANCE_BPS: "500", + MAX_VALIDATOR_WITHDRAWAL_REQUESTS_PER_TRANSACTION: "16", + MIN_WITHDRAWAL_THRESHOLD_ETH: "42", + WEB3SIGNER_URL: "https://web3signer.linea.build", + WEB3SIGNER_PUBLIC_KEY: `0x${"a".repeat(128)}`, + WEB3SIGNER_KEYSTORE_PATH: "/path/to/keystore", + WEB3SIGNER_KEYSTORE_PASSPHRASE: "keystore-pass", + WEB3SIGNER_TRUSTSTORE_PATH: "/path/to/truststore", + WEB3SIGNER_TRUSTSTORE_PASSPHRASE: "truststore-pass", + WEB3SIGNER_TLS_ENABLED: "true", + API_PORT: "3000", + SHOULD_SUBMIT_VAULT_REPORT: "true", +}); + +describe("configSchema", () => { + it("parses and normalizes valid environment variables", () => { + const env = createValidEnv(); + + const parsed = configSchema.parse(env); + + expect(parsed.CHAIN_ID).toBe(11155111); + expect(parsed.TRIGGER_EVENT_POLL_INTERVAL_MS).toBe(1000); + expect(parsed.API_PORT).toBe(3000); + expect(parsed.MIN_WITHDRAWAL_THRESHOLD_ETH).toBe(42n); + expect(parsed.WEB3SIGNER_TLS_ENABLED).toBe(true); + expect(parsed.SHOULD_SUBMIT_VAULT_REPORT).toBe(true); + expect(parsed.LINEA_ROLLUP_ADDRESS).toBe(getAddress(env.LINEA_ROLLUP_ADDRESS)); + expect(parsed.LAZY_ORACLE_ADDRESS).toBe(getAddress(env.LAZY_ORACLE_ADDRESS)); + expect(parsed.VAULT_HUB_ADDRESS).toBe(getAddress(env.VAULT_HUB_ADDRESS)); + expect(parsed.L2_YIELD_RECIPIENT).toBe(getAddress(env.L2_YIELD_RECIPIENT)); + }); + + it("rejects invalid Ethereum addresses", () => { + const env = { + ...createValidEnv(), + LINEA_ROLLUP_ADDRESS: "0xinvalid", + }; + + const result = configSchema.safeParse(env); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join(".") === "LINEA_ROLLUP_ADDRESS")).toBe(true); + } + }); + + it("rejects invalid Web3Signer public key values", () => { + const env = { + ...createValidEnv(), + WEB3SIGNER_PUBLIC_KEY: "0x1234", + }; + + const result = configSchema.safeParse(env); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join(".") === "WEB3SIGNER_PUBLIC_KEY")).toBe(true); + } + }); +}); diff --git a/native-yield-operations/automation-service/src/application/main/config/__tests__/config.test.ts b/native-yield-operations/automation-service/src/application/main/config/__tests__/config.test.ts new file mode 100644 index 0000000000..1afc797b42 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/config/__tests__/config.test.ts @@ -0,0 +1,96 @@ +import { toClientConfig } from "../config.js"; +import { configSchema } from "../config.schema.js"; +import { transports } from "winston"; + +const createValidEnv = () => ({ + CHAIN_ID: "11155111", + L1_RPC_URL: "https://rpc.linea.build", + BEACON_CHAIN_RPC_URL: "https://beacon.linea.build", + STAKING_GRAPHQL_URL: "https://staking.linea.build/graphql", + IPFS_BASE_URL: "https://ipfs.linea.build", + CONSENSYS_STAKING_OAUTH2_TOKEN_ENDPOINT: "https://auth.linea.build/token", + CONSENSYS_STAKING_OAUTH2_CLIENT_ID: "client-id", + CONSENSYS_STAKING_OAUTH2_CLIENT_SECRET: "client-secret", + CONSENSYS_STAKING_OAUTH2_AUDIENCE: "audience", + LINEA_ROLLUP_ADDRESS: "0x1111111111111111111111111111111111111111", + LAZY_ORACLE_ADDRESS: "0x2222222222222222222222222222222222222222", + VAULT_HUB_ADDRESS: "0x3333333333333333333333333333333333333333", + YIELD_MANAGER_ADDRESS: "0x4444444444444444444444444444444444444444", + LIDO_YIELD_PROVIDER_ADDRESS: "0x5555555555555555555555555555555555555555", + L2_YIELD_RECIPIENT: "0x7777777777777777777777777777777777777777", + TRIGGER_EVENT_POLL_INTERVAL_MS: "1000", + TRIGGER_MAX_INACTION_MS: "5000", + CONTRACT_READ_RETRY_TIME_MS: "250", + REBALANCE_TOLERANCE_BPS: "500", + MAX_VALIDATOR_WITHDRAWAL_REQUESTS_PER_TRANSACTION: "16", + MIN_WITHDRAWAL_THRESHOLD_ETH: "42", + WEB3SIGNER_URL: "https://web3signer.linea.build", + WEB3SIGNER_PUBLIC_KEY: `0x${"b".repeat(128)}`, + WEB3SIGNER_KEYSTORE_PATH: "/path/to/keystore", + WEB3SIGNER_KEYSTORE_PASSPHRASE: "keystore-pass", + WEB3SIGNER_TRUSTSTORE_PATH: "/path/to/truststore", + WEB3SIGNER_TRUSTSTORE_PASSPHRASE: "truststore-pass", + WEB3SIGNER_TLS_ENABLED: "true", + API_PORT: "3000", + SHOULD_SUBMIT_VAULT_REPORT: "true", +}); + +describe("toClientConfig", () => { + it("maps a validated environment into bootstrap config", () => { + const env = configSchema.parse(createValidEnv()); + + const config = toClientConfig(env); + + expect(config).toMatchObject({ + dataSources: { + chainId: env.CHAIN_ID, + l1RpcUrl: env.L1_RPC_URL, + beaconChainRpcUrl: env.BEACON_CHAIN_RPC_URL, + stakingGraphQLUrl: env.STAKING_GRAPHQL_URL, + ipfsBaseUrl: env.IPFS_BASE_URL, + }, + consensysStakingOAuth2: { + tokenEndpoint: env.CONSENSYS_STAKING_OAUTH2_TOKEN_ENDPOINT, + clientId: env.CONSENSYS_STAKING_OAUTH2_CLIENT_ID, + clientSecret: env.CONSENSYS_STAKING_OAUTH2_CLIENT_SECRET, + audience: env.CONSENSYS_STAKING_OAUTH2_AUDIENCE, + }, + contractAddresses: { + lineaRollupContractAddress: env.LINEA_ROLLUP_ADDRESS, + lazyOracleAddress: env.LAZY_ORACLE_ADDRESS, + vaultHubAddress: env.VAULT_HUB_ADDRESS, + yieldManagerAddress: env.YIELD_MANAGER_ADDRESS, + lidoYieldProviderAddress: env.LIDO_YIELD_PROVIDER_ADDRESS, + l2YieldRecipientAddress: env.L2_YIELD_RECIPIENT, + }, + apiPort: env.API_PORT, + timing: { + trigger: { + pollIntervalMs: env.TRIGGER_EVENT_POLL_INTERVAL_MS, + maxInactionMs: env.TRIGGER_MAX_INACTION_MS, + }, + contractReadRetryTimeMs: env.CONTRACT_READ_RETRY_TIME_MS, + }, + rebalanceToleranceBps: env.REBALANCE_TOLERANCE_BPS, + maxValidatorWithdrawalRequestsPerTransaction: env.MAX_VALIDATOR_WITHDRAWAL_REQUESTS_PER_TRANSACTION, + minWithdrawalThresholdEth: env.MIN_WITHDRAWAL_THRESHOLD_ETH, + shouldSubmitVaultReport: env.SHOULD_SUBMIT_VAULT_REPORT, + web3signer: { + url: env.WEB3SIGNER_URL, + publicKey: env.WEB3SIGNER_PUBLIC_KEY, + keystore: { + path: env.WEB3SIGNER_KEYSTORE_PATH, + passphrase: env.WEB3SIGNER_KEYSTORE_PASSPHRASE, + }, + truststore: { + path: env.WEB3SIGNER_TRUSTSTORE_PATH, + passphrase: env.WEB3SIGNER_TRUSTSTORE_PASSPHRASE, + }, + tlsEnabled: env.WEB3SIGNER_TLS_ENABLED, + }, + }); + expect(config.loggerOptions.level).toBe("info"); + expect(config.loggerOptions.transports).toHaveLength(1); + expect(config.loggerOptions.transports[0]).toBeInstanceOf(transports.Console); + }); +}); diff --git a/native-yield-operations/automation-service/src/application/main/config/__tests__/loadConfigFromEnv.test.ts b/native-yield-operations/automation-service/src/application/main/config/__tests__/loadConfigFromEnv.test.ts new file mode 100644 index 0000000000..938c84e3dc --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/config/__tests__/loadConfigFromEnv.test.ts @@ -0,0 +1,90 @@ +import { jest } from "@jest/globals"; +import { loadConfigFromEnv } from "../loadConfigFromEnv.js"; +import { configSchema } from "../config.schema.js"; +import * as configModule from "../config.js"; + +const createValidEnv = () => ({ + CHAIN_ID: "11155111", + L1_RPC_URL: "https://rpc.linea.build", + BEACON_CHAIN_RPC_URL: "https://beacon.linea.build", + STAKING_GRAPHQL_URL: "https://staking.linea.build/graphql", + IPFS_BASE_URL: "https://ipfs.linea.build", + CONSENSYS_STAKING_OAUTH2_TOKEN_ENDPOINT: "https://auth.linea.build/token", + CONSENSYS_STAKING_OAUTH2_CLIENT_ID: "client-id", + CONSENSYS_STAKING_OAUTH2_CLIENT_SECRET: "client-secret", + CONSENSYS_STAKING_OAUTH2_AUDIENCE: "audience", + LINEA_ROLLUP_ADDRESS: "0x1111111111111111111111111111111111111111", + LAZY_ORACLE_ADDRESS: "0x2222222222222222222222222222222222222222", + VAULT_HUB_ADDRESS: "0x3333333333333333333333333333333333333333", + YIELD_MANAGER_ADDRESS: "0x4444444444444444444444444444444444444444", + LIDO_YIELD_PROVIDER_ADDRESS: "0x5555555555555555555555555555555555555555", + L2_YIELD_RECIPIENT: "0x7777777777777777777777777777777777777777", + TRIGGER_EVENT_POLL_INTERVAL_MS: "1000", + TRIGGER_MAX_INACTION_MS: "5000", + CONTRACT_READ_RETRY_TIME_MS: "250", + REBALANCE_TOLERANCE_BPS: "500", + MAX_VALIDATOR_WITHDRAWAL_REQUESTS_PER_TRANSACTION: "16", + MIN_WITHDRAWAL_THRESHOLD_ETH: "42", + WEB3SIGNER_URL: "https://web3signer.linea.build", + WEB3SIGNER_PUBLIC_KEY: `0x${"c".repeat(128)}`, + WEB3SIGNER_KEYSTORE_PATH: "/path/to/keystore", + WEB3SIGNER_KEYSTORE_PASSPHRASE: "keystore-pass", + WEB3SIGNER_TRUSTSTORE_PATH: "/path/to/truststore", + WEB3SIGNER_TRUSTSTORE_PASSPHRASE: "truststore-pass", + WEB3SIGNER_TLS_ENABLED: "true", + API_PORT: "3000", + SHOULD_SUBMIT_VAULT_REPORT: "true", +}); + +describe("loadConfigFromEnv", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns the bootstrap config when the environment is valid", () => { + const env = createValidEnv(); + const expectedConfig = { sentinel: "value" } as unknown as ReturnType; + const toClientConfigSpy = jest.spyOn(configModule, "toClientConfig").mockReturnValue(expectedConfig); + + const result = loadConfigFromEnv(env); + + expect(result).toBe(expectedConfig); + expect(toClientConfigSpy).toHaveBeenCalledTimes(1); + expect(toClientConfigSpy).toHaveBeenCalledWith(configSchema.parse(env)); + }); + + it("falls back to process.env when no environment object is provided", () => { + const env = createValidEnv(); + const expectedConfig = { sentinel: "process-env" } as unknown as ReturnType; + const toClientConfigSpy = jest.spyOn(configModule, "toClientConfig").mockReturnValue(expectedConfig); + const originalEnv = process.env; + process.env = { ...env } as unknown as NodeJS.ProcessEnv; + + try { + const result = loadConfigFromEnv(); + + expect(result).toBe(expectedConfig); + expect(toClientConfigSpy).toHaveBeenCalledWith(configSchema.parse(env)); + } finally { + process.env = originalEnv; + } + }); + + it("logs errors and exits the process when validation fails", () => { + const env = { + ...createValidEnv(), + API_PORT: "80", // below minimum of 1024 + }; + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = jest.spyOn(process, "exit").mockImplementation(((code?: number | undefined) => { + throw new Error(`process.exit: ${code}`); + }) as never); + const toClientConfigSpy = jest.spyOn(configModule, "toClientConfig"); + + expect(() => loadConfigFromEnv(env)).toThrow("process.exit: 1"); + expect(consoleSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid configuration")); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(toClientConfigSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/native-yield-operations/automation-service/src/application/main/config/config.schema.ts b/native-yield-operations/automation-service/src/application/main/config/config.schema.ts new file mode 100644 index 0000000000..302a9b90bf --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/config/config.schema.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { isAddress, getAddress, isHex } from "viem"; + +/** Reusable EVM address schema: validates then normalizes to checksummed form */ +const Address = z + .string() + .refine((v) => isAddress(v), { message: "Invalid Ethereum address" }) + .transform((v) => getAddress(v)); // checksum/normalize + +const Hex = z.string().refine((v) => isHex(v), { message: "Invalid Hex" }); + +export const configSchema = z + .object({ + /** Ethereum chain ID for the L1 network (e.g., 1 for mainnet, 560048 for hoodi). + * Currently supports mainnet.id and hoodi.id - other chain IDs will throw an error during initialization. + */ + CHAIN_ID: z.coerce.number().int().positive(), + // RPC endpoint URL for the L1 Ethereum blockchain. Must be a valid HTTP/HTTPS URL. + L1_RPC_URL: z.string().url(), + // Beacon chain API endpoint URL for Ethereum 2.0 consensus layer. + // See API documentation - https://ethereum.github.io/beacon-APIs/ + BEACON_CHAIN_RPC_URL: z.string().url(), + // GraphQL endpoint URL for Consensys Staking API. Expected to require OAuth2 token. + STAKING_GRAPHQL_URL: z.string().url(), + // IPFS gateway base URL for retrieving Lido StakingVault report data. + // Report CIDs are recorded on-chain in the LazyOracle.sol contract + IPFS_BASE_URL: z.string().url(), + // OAuth2 token endpoint URL for Consensys Staking API authentication. + CONSENSYS_STAKING_OAUTH2_TOKEN_ENDPOINT: z.string().url(), + // OAuth2 client ID for Consensys Staking API authentication. + CONSENSYS_STAKING_OAUTH2_CLIENT_ID: z.string().min(1), + /** OAuth2 client secret for Consensys Staking API authentication. + * Must be kept secure - used with CONSENSYS_STAKING_OAUTH2_CLIENT_ID to authenticate + * and obtain access tokens for GraphQL API requests. + */ + CONSENSYS_STAKING_OAUTH2_CLIENT_SECRET: z.string().min(1), + /** OAuth2 audience claim for Consensys Staking API authentication. + * Specifies the intended recipient of the access token. Used together with client ID + * and secret to obtain properly scoped access tokens. + */ + CONSENSYS_STAKING_OAUTH2_AUDIENCE: z.string().min(1), + // Address of the Linea Rollup contract. + LINEA_ROLLUP_ADDRESS: Address, + // Address of the Lido LazyOracle contract. + LAZY_ORACLE_ADDRESS: Address, + // Address of the Lido VaultHub contract. + VAULT_HUB_ADDRESS: Address, + // Address of the Linea YieldManager contract. + YIELD_MANAGER_ADDRESS: Address, + // Address of the LidoStVaultYieldProvider contract. + LIDO_YIELD_PROVIDER_ADDRESS: Address, + // L2 address that receives yield distributions. + L2_YIELD_RECIPIENT: Address, + // Polling interval in milliseconds for watching blockchain events. + TRIGGER_EVENT_POLL_INTERVAL_MS: z.coerce.number().int().positive(), + // Maximum idle duration (in milliseconds) before automatically executing pending operations. + TRIGGER_MAX_INACTION_MS: z.coerce.number().int().positive(), + // Whether to submit the vault accounting report. Can set to false if we expect other actors to submit. + SHOULD_SUBMIT_VAULT_REPORT: z.coerce.boolean(), + // Retry delay in milliseconds between contract read attempts after failures. + CONTRACT_READ_RETRY_TIME_MS: z.coerce.number().int().positive(), + /** Rebalance tolerance in basis points (1 bps = 0.01%, max 10000 bps = 100%). + * Used to calculate tolerance band for rebalancing decisions. + * The tolerance band is calculated as: `(totalSystemBalance * REBALANCE_TOLERANCE_BPS) / 10000`. + * Rebalancing occurs only when the L1 Message Service balance deviates from the effective + * target withdrawal reserve by more than this tolerance band (either above or below). + * Prevents unnecessary rebalancing operations for small fluctuations. + */ + REBALANCE_TOLERANCE_BPS: z.coerce.number().int().positive().max(10000), + // Maximum number of validator withdrawal requests that will be batched in a single transaction. + MAX_VALIDATOR_WITHDRAWAL_REQUESTS_PER_TRANSACTION: z.coerce.number().int().positive(), + /** + * The available withdrawal balance must exceed this amount before any withdrawal operation proceeds. + * This prevents gas-inefficient transactions for very small amounts. + */ + MIN_WITHDRAWAL_THRESHOLD_ETH: z + .union([z.string(), z.number(), z.bigint()]) + .transform((val) => BigInt(val)) + .refine((v) => v >= 0n, { message: "Must be nonnegative" }), + /** Web3Signer service URL for transaction signing. + * The service signs transactions using the key specified by WEB3SIGNER_PUBLIC_KEY. + * Must be a valid HTTPS (not HTTP) URL. + */ + WEB3SIGNER_URL: z.string().url(), + /** Secp256k1 public key (uncompressed, 64 bytes) for Web3Signer transaction signing. + * Used by Web3SignerClientAdapter to identify which key to use for signing transactions. + * Format: 64 hex characters (128 hex digits) representing the uncompressed public key + * without the 0x04 prefix. Optional 0x prefix is accepted. Example: "a1b2c3..." or "0xa1b2c3...". + * This corresponds to the signing key stored in the Web3Signer keystore. + */ + WEB3SIGNER_PUBLIC_KEY: Hex.refine( + (v) => /^(?:0x)?[a-fA-F0-9]{128}$/.test(v), // uncompressed pubkey (64 bytes, without ...04-prefix, optional 0x prefix) + "Expected secp256k1 public key (uncompressed, without 0x04 prefix).", + ), + /** File path to the Web3Signer keystore file. + * Keystore = Who am I? + * Contains the client’s private key and certificate used to authenticate itself + * to the Web3Signer server during mutual TLS (mTLS) connections. + */ + WEB3SIGNER_KEYSTORE_PATH: z.string().min(1), + // Passphrase to decrypt the Web3Signer keystore file. + WEB3SIGNER_KEYSTORE_PASSPHRASE: z.string().min(1), + /** Path to the Web3Signer truststore file. + * Truststore = Who do I trust? + * Contains trusted CA certificates for verifying the Web3Signer server's TLS certificate. + */ + WEB3SIGNER_TRUSTSTORE_PATH: z.string().min(1), + // Passphrase to access the Web3Signer truststore file. + WEB3SIGNER_TRUSTSTORE_PASSPHRASE: z.string().min(1), + // Note: Doesn't currently do anything. Implementation currently only supports HTTPS anyway. + WEB3SIGNER_TLS_ENABLED: z.coerce.boolean(), + /** Port number for the metrics API HTTP server. + * Used to expose metrics endpoints for monitoring and observability. + * Must be between 1024 and 49000 (inclusive) to avoid system ports and common application ports. + */ + API_PORT: z.coerce.number().int().min(1024).max(49000), + }) + .strip(); + +export type FlattenedConfigSchema = z.infer; diff --git a/native-yield-operations/automation-service/src/application/main/config/config.ts b/native-yield-operations/automation-service/src/application/main/config/config.ts new file mode 100644 index 0000000000..56eeb8d133 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/config/config.ts @@ -0,0 +1,83 @@ +import { Hex } from "viem"; +import { FlattenedConfigSchema } from "./config.schema.js"; +import { transports } from "winston"; + +/** + * Converts a flattened configuration schema to a client configuration object. + * Transforms environment variables into a structured configuration with nested objects + * for data sources, OAuth2 settings, contract addresses, timing settings, and Web3Signer configuration. + * + * @param {FlattenedConfigSchema} env - The flattened configuration schema containing environment variables. + * @returns {Object} A client configuration object with the following structure: + * - dataSources: Chain ID, RPC URLs, GraphQL URL, and IPFS base URL + * - consensysStakingOAuth2: OAuth2 token endpoint and credentials + * - contractAddresses: All contract addresses used by the service + * - apiPort: Port for the metrics API server + * - timing: Poll intervals and retry timing configuration + * - rebalanceToleranceBps: Rebalance tolerance in basis points + * - maxValidatorWithdrawalRequestsPerTransaction: Maximum withdrawal requests per transaction + * - minWithdrawalThresholdEth: Minimum withdrawal threshold in ETH + * - shouldSubmitVaultReport: Whether to submit the vault accounting report + * - web3signer: Web3Signer URL, public key (address or secp pubkey compressed/uncompressed), keystore, truststore, and TLS settings + * - loggerOptions: Winston logger configuration with console transport + */ +export const toClientConfig = (env: FlattenedConfigSchema) => ({ + dataSources: { + chainId: env.CHAIN_ID, + l1RpcUrl: env.L1_RPC_URL, + beaconChainRpcUrl: env.BEACON_CHAIN_RPC_URL, + stakingGraphQLUrl: env.STAKING_GRAPHQL_URL, + ipfsBaseUrl: env.IPFS_BASE_URL, + }, + consensysStakingOAuth2: { + tokenEndpoint: env.CONSENSYS_STAKING_OAUTH2_TOKEN_ENDPOINT, + clientId: env.CONSENSYS_STAKING_OAUTH2_CLIENT_ID, + clientSecret: env.CONSENSYS_STAKING_OAUTH2_CLIENT_SECRET, + audience: env.CONSENSYS_STAKING_OAUTH2_AUDIENCE, + }, + contractAddresses: { + lineaRollupContractAddress: env.LINEA_ROLLUP_ADDRESS, + lazyOracleAddress: env.LAZY_ORACLE_ADDRESS, + vaultHubAddress: env.VAULT_HUB_ADDRESS, + yieldManagerAddress: env.YIELD_MANAGER_ADDRESS, + lidoYieldProviderAddress: env.LIDO_YIELD_PROVIDER_ADDRESS, + l2YieldRecipientAddress: env.L2_YIELD_RECIPIENT, + }, + apiPort: env.API_PORT, + timing: { + trigger: { + // How often we poll for the trigger event + pollIntervalMs: env.TRIGGER_EVENT_POLL_INTERVAL_MS, + // Max tolerated time for inaction if trigger event polling doesn't find the trigger event + maxInactionMs: env.TRIGGER_MAX_INACTION_MS, + }, + contractReadRetryTimeMs: env.CONTRACT_READ_RETRY_TIME_MS, + }, + rebalanceToleranceBps: env.REBALANCE_TOLERANCE_BPS, + maxValidatorWithdrawalRequestsPerTransaction: env.MAX_VALIDATOR_WITHDRAWAL_REQUESTS_PER_TRANSACTION, + minWithdrawalThresholdEth: env.MIN_WITHDRAWAL_THRESHOLD_ETH, + shouldSubmitVaultReport: env.SHOULD_SUBMIT_VAULT_REPORT, + web3signer: { + url: env.WEB3SIGNER_URL, + publicKey: env.WEB3SIGNER_PUBLIC_KEY as Hex, // address or secp pubkey (compressed/uncompressed) + keystore: { + path: env.WEB3SIGNER_KEYSTORE_PATH, + passphrase: env.WEB3SIGNER_KEYSTORE_PASSPHRASE, + }, + truststore: { + path: env.WEB3SIGNER_TRUSTSTORE_PATH, + passphrase: env.WEB3SIGNER_TRUSTSTORE_PASSPHRASE, + }, + tlsEnabled: env.WEB3SIGNER_TLS_ENABLED, + }, + loggerOptions: { + level: "info", + transports: [new transports.Console()], + }, +}); + +/** + * Type representing the bootstrap configuration for the Native Yield Automation Service. + * Derived from the return type of toClientConfig function. + */ +export type NativeYieldAutomationServiceBootstrapConfig = ReturnType; diff --git a/native-yield-operations/automation-service/src/application/main/config/loadConfigFromEnv.ts b/native-yield-operations/automation-service/src/application/main/config/loadConfigFromEnv.ts new file mode 100644 index 0000000000..9bd4f901aa --- /dev/null +++ b/native-yield-operations/automation-service/src/application/main/config/loadConfigFromEnv.ts @@ -0,0 +1,22 @@ +import { configSchema } from "./config.schema.js"; +import { toClientConfig } from "./config.js"; + +/** + * Loads and validates configuration from environment variables. + * Parses the environment object using the config schema, validates it, and converts it to client configuration. + * If validation fails, outputs pretty-ish error output for CI/boot logs and exits the process with code 1. + * + * @param {NodeJS.ProcessEnv} [envObj=process.env] - The environment object to parse. Defaults to process.env. + * @returns {ReturnType} The validated and converted client configuration object. + * @throws {never} Exits the process with code 1 if configuration validation fails. + */ +export function loadConfigFromEnv(envObj: NodeJS.ProcessEnv = process.env) { + const parsed = configSchema.safeParse(envObj); + if (!parsed.success) { + // pretty-ish error output for CI/boot logs + console.error("❌ Invalid configuration:"); + console.error(JSON.stringify(parsed.error.format(), null, 2)); + process.exit(1); + } + return toClientConfig(parsed.data); +} diff --git a/native-yield-operations/automation-service/src/application/metrics/NativeYieldAutomationMetricsService.ts b/native-yield-operations/automation-service/src/application/metrics/NativeYieldAutomationMetricsService.ts new file mode 100644 index 0000000000..5dcab9c860 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/metrics/NativeYieldAutomationMetricsService.ts @@ -0,0 +1,18 @@ +import { SingletonMetricsService } from "@consensys/linea-shared-utils"; +import { LineaNativeYieldAutomationServiceMetrics } from "../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; + +/** + * Metrics service for the Native Yield Automation Service. + * Extends SingletonMetricsService to provide singleton access to metrics collection + * with service-specific default labels. + */ +export class NativeYieldAutomationMetricsService extends SingletonMetricsService { + /** + * Creates a new NativeYieldAutomationMetricsService instance. + * + * @param {Record} [defaultLabels={ app: "native-yield-automation-service" }] - Default labels to apply to all metrics collected by this service. + */ + constructor(defaultLabels: Record = { app: "native-yield-automation-service" }) { + super(defaultLabels); + } +} diff --git a/native-yield-operations/automation-service/src/application/metrics/NativeYieldAutomationMetricsUpdater.ts b/native-yield-operations/automation-service/src/application/metrics/NativeYieldAutomationMetricsUpdater.ts new file mode 100644 index 0000000000..f982a73fe2 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/metrics/NativeYieldAutomationMetricsUpdater.ts @@ -0,0 +1,304 @@ +import { IMetricsService } from "@consensys/linea-shared-utils"; +import { + LineaNativeYieldAutomationServiceMetrics, + OperationTrigger, +} from "../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; +import { RebalanceDirection } from "../../core/entities/RebalanceRequirement.js"; +import { Address, Hex } from "viem"; +import { OperationMode } from "../../core/enums/OperationModeEnums.js"; +import { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; + +// Buckets range up to 20 minutes to account for long-running modes. +const OPERATION_MODE_DURATION_BUCKETS = [1, 5, 10, 30, 60, 120, 180, 300, 600, 900, 1200]; + +/** + * Focused on defining the specific metrics, and methods for updating them. + * Handles creation and updates of all metrics for the Native Yield Automation Service, + * including rebalances, validator operations, vault reporting, fees, and operation mode tracking. + */ +export class NativeYieldAutomationMetricsUpdater implements INativeYieldAutomationMetricsUpdater { + /** + * Creates a new NativeYieldAutomationMetricsUpdater instance. + * Initializes all metrics (counters, gauges, and histograms) used by the service. + * + * @param {IMetricsService} metricsService - The metrics service used to create and update metrics. + */ + constructor(private readonly metricsService: IMetricsService) { + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.RebalanceAmountTotal, + "Total rebalance amount between L1MessageService and YieldProvider", + ["direction", "type"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.ValidatorPartialUnstakeAmountTotal, + "Total amount partially unstaked per validator", + ["validator_pubkey"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.ValidatorExitTotal, + "Total validator exits initiated by automation", + ["validator_pubkey"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.LidoVaultAccountingReportSubmittedTotal, + "Accounting reports submitted to Lido per vault", + ["vault_address"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.ReportYieldTotal, + "Yield reports submitted to YieldManager per vault", + ["vault_address"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.ReportYieldAmountTotal, + "Total yield amount reported per vault", + ["vault_address"], + ); + + this.metricsService.createGauge( + LineaNativeYieldAutomationServiceMetrics.CurrentNegativeYieldLastReport, + "Outstanding negative yield as of the latest report", + ["vault_address"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.NodeOperatorFeesPaidTotal, + "Node operator fees paid by automation per vault", + ["vault_address"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.LiabilitiesPaidTotal, + "Liabilities paid by automation per vault", + ["vault_address"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.LidoFeesPaidTotal, + "Lido fees paid by automation per vault", + ["vault_address"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.OperationModeTriggerTotal, + "Operation mode triggers grouped by mode and triggers", + ["mode", "trigger"], + ); + + this.metricsService.createCounter( + LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionTotal, + "Operation mode executions grouped by mode", + ["mode"], + ); + + this.metricsService.createHistogram( + LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionDurationSeconds, + OPERATION_MODE_DURATION_BUCKETS, + "Operation mode execution duration in seconds", + ["mode"], + ); + } + + /** + * Records a rebalance operation amount. + * Increments the rebalance amount counter for the specified direction. + * + * @param {RebalanceDirection.STAKE | RebalanceDirection.UNSTAKE} direction - The direction of the rebalance (STAKE or UNSTAKE). + * @param {number} amountGwei - The rebalance amount in gwei. Must be greater than 0 to be recorded. + */ + public recordRebalance(direction: RebalanceDirection.STAKE | RebalanceDirection.UNSTAKE, amountGwei: number): void { + if (amountGwei <= 0) return; + this.metricsService.incrementCounter( + LineaNativeYieldAutomationServiceMetrics.RebalanceAmountTotal, + { direction }, + amountGwei, + ); + } + + /** + * Adds to the total amount partially unstaked for a specific validator. + * + * @param {Hex} validatorPubkey - The validator's public key in hex format. + * @param {number} amountGwei - The partial unstake amount in gwei. Must be greater than 0 to be recorded. + */ + public addValidatorPartialUnstakeAmount(validatorPubkey: Hex, amountGwei: number): void { + if (amountGwei <= 0) return; + this.metricsService.incrementCounter( + LineaNativeYieldAutomationServiceMetrics.ValidatorPartialUnstakeAmountTotal, + { validator_pubkey: validatorPubkey }, + amountGwei, + ); + } + + /** + * Increments the counter for validator exits initiated by automation. + * + * @param {Hex} validatorPubkey - The validator's public key in hex format. + * @param {number} [count=1] - The number of exits to record. Must be greater than 0 to be recorded. + */ + public incrementValidatorExit(validatorPubkey: Hex, count: number = 1): void { + if (count <= 0) return; + this.metricsService.incrementCounter( + LineaNativeYieldAutomationServiceMetrics.ValidatorExitTotal, + { validator_pubkey: validatorPubkey }, + count, + ); + } + + /** + * Increments the counter for accounting reports submitted to Lido for a specific vault. + * + * @param {Address} vaultAddress - The address of the vault. + */ + public incrementLidoVaultAccountingReport(vaultAddress: Address): void { + this.metricsService.incrementCounter( + LineaNativeYieldAutomationServiceMetrics.LidoVaultAccountingReportSubmittedTotal, + { vault_address: vaultAddress }, + ); + } + + /** + * Increments the counter for yield reports submitted to YieldManager for a specific vault. + * + * @param {Address} vaultAddress - The address of the vault. + */ + public incrementReportYield(vaultAddress: Address): void { + this.metricsService.incrementCounter(LineaNativeYieldAutomationServiceMetrics.ReportYieldTotal, { + vault_address: vaultAddress, + }); + } + + /** + * Adds to the total yield amount reported for a specific vault. + * + * @param {Address} vaultAddress - The address of the vault. + * @param {number} amountGwei - The yield amount in gwei. Must be greater than 0 to be recorded. + */ + public addReportedYieldAmount(vaultAddress: Address, amountGwei: number): void { + if (amountGwei <= 0) return; + this.metricsService.incrementCounter( + LineaNativeYieldAutomationServiceMetrics.ReportYieldAmountTotal, + { vault_address: vaultAddress }, + amountGwei, + ); + } + + /** + * Sets the current outstanding negative yield as of the latest report for a specific vault. + * + * @param {Address} vaultAddress - The address of the vault. + * @param {number} negativeYield - The negative yield amount. Must be non-negative to be recorded. + * @returns {Promise} A promise that resolves when the gauge is set. + */ + public async setCurrentNegativeYieldLastReport(vaultAddress: Address, negativeYield: number): Promise { + if (negativeYield < 0) return; + this.metricsService.setGauge( + LineaNativeYieldAutomationServiceMetrics.CurrentNegativeYieldLastReport, + { vault_address: vaultAddress }, + negativeYield, + ); + } + + /** + * Adds to the total node operator fees paid by automation for a specific vault. + * + * @param {Address} vaultAddress - The address of the vault. + * @param {number} amountGwei - The fees amount in gwei. Must be greater than 0 to be recorded. + */ + public addNodeOperatorFeesPaid(vaultAddress: Address, amountGwei: number): void { + this._incrementVaultAmountCounter( + LineaNativeYieldAutomationServiceMetrics.NodeOperatorFeesPaidTotal, + vaultAddress, + amountGwei, + ); + } + + /** + * Adds to the total liabilities paid by automation for a specific vault. + * + * @param {Address} vaultAddress - The address of the vault. + * @param {number} amountGwei - The liabilities amount in gwei. Must be greater than 0 to be recorded. + */ + public addLiabilitiesPaid(vaultAddress: Address, amountGwei: number): void { + this._incrementVaultAmountCounter( + LineaNativeYieldAutomationServiceMetrics.LiabilitiesPaidTotal, + vaultAddress, + amountGwei, + ); + } + + /** + * Adds to the total Lido fees paid by automation for a specific vault. + * + * @param {Address} vaultAddress - The address of the vault. + * @param {number} amountGwei - The fees amount in gwei. Must be greater than 0 to be recorded. + */ + public addLidoFeesPaid(vaultAddress: Address, amountGwei: number): void { + this._incrementVaultAmountCounter( + LineaNativeYieldAutomationServiceMetrics.LidoFeesPaidTotal, + vaultAddress, + amountGwei, + ); + } + + /** + * Increments the counter for operation mode triggers, grouped by mode and trigger type. + * + * @param {OperationMode} mode - The operation mode that was triggered. + * @param {OperationTrigger} trigger - The trigger that caused the mode to be activated. + */ + public incrementOperationModeTrigger(mode: OperationMode, trigger: OperationTrigger): void { + this.metricsService.incrementCounter(LineaNativeYieldAutomationServiceMetrics.OperationModeTriggerTotal, { + mode, + trigger, + }); + } + + /** + * Increments the counter for operation mode executions, grouped by mode. + * + * @param {OperationMode} mode - The operation mode that was executed. + */ + public incrementOperationModeExecution(mode: OperationMode): void { + this.metricsService.incrementCounter(LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionTotal, { + mode, + }); + } + + /** + * Records the execution duration of an operation mode in a histogram. + * Uses buckets that range up to 20 minutes to account for long-running modes. + * + * @param {OperationMode} mode - The operation mode that was executed. + * @param {number} durationSeconds - The duration in seconds. Must be non-negative to be recorded. + */ + public recordOperationModeDuration(mode: OperationMode, durationSeconds: number): void { + if (durationSeconds < 0) return; + this.metricsService.addValueToHistogram( + LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionDurationSeconds, + durationSeconds, + { mode }, + ); + } + + /** + * Internal helper method to increment a vault-specific amount counter. + * + * @param {LineaNativeYieldAutomationServiceMetrics} metric - The metric to increment. + * @param {Address} vaultAddress - The address of the vault. + * @param {number} amountGwei - The amount in gwei. Must be greater than 0 to be recorded. + */ + private _incrementVaultAmountCounter( + metric: LineaNativeYieldAutomationServiceMetrics, + vaultAddress: Address, + amountGwei: number, + ): void { + if (amountGwei <= 0) return; + this.metricsService.incrementCounter(metric, { vault_address: vaultAddress }, amountGwei); + } +} diff --git a/native-yield-operations/automation-service/src/application/metrics/OperationModeMetricsRecorder.ts b/native-yield-operations/automation-service/src/application/metrics/OperationModeMetricsRecorder.ts new file mode 100644 index 0000000000..a3a0820960 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/metrics/OperationModeMetricsRecorder.ts @@ -0,0 +1,171 @@ +// Take operation results and record the relevant figures into metrics + +import { Address, TransactionReceipt } from "viem"; +import { Result } from "neverthrow"; +import { IOperationModeMetricsRecorder } from "../../core/metrics/IOperationModeMetricsRecorder.js"; +import { IYieldManager } from "../../core/clients/contracts/IYieldManager.js"; +import { ILogger, weiToGweiNumber } from "@consensys/linea-shared-utils"; +import { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import { IVaultHub } from "../../core/clients/contracts/IVaultHub.js"; +import { getNodeOperatorFeesPaidFromTxReceipt } from "../../clients/contracts/getNodeOperatorFeesPaidFromTxReceipt.js"; +import { RebalanceDirection } from "../../core/entities/RebalanceRequirement.js"; + +/** + * Take operation results and record the relevant figures into metrics. + * Extracts transaction data from operation results and updates metrics for various operation modes, + * including progress ossification, yield reporting, safe withdrawals, and fund transfers. + */ +export class OperationModeMetricsRecorder implements IOperationModeMetricsRecorder { + /** + * Creates a new OperationModeMetricsRecorder instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {INativeYieldAutomationMetricsUpdater} metricsUpdater - Service for updating metrics. + * @param {IYieldManager} yieldManagerClient - Client for interacting with YieldManager contracts. + * @param {IVaultHub} vaultHubClient - Client for interacting with VaultHub contracts. + */ + constructor( + private readonly logger: ILogger, + private readonly metricsUpdater: INativeYieldAutomationMetricsUpdater, + private readonly yieldManagerClient: IYieldManager, + private readonly vaultHubClient: IVaultHub, + ) { + void this.logger; + } + + /** + * Records metrics for progress ossification operations. + * Extracts node operator fees, Lido fees, and liability payments from the transaction receipt + * and updates the corresponding metrics for the vault. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {Result} txReceiptResult - The transaction receipt result (may be an error or undefined). + * @returns {Promise} A promise that resolves when metrics are recorded (or silently returns if receipt is missing or error). + */ + async recordProgressOssificationMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise { + if (txReceiptResult.isErr()) return; + const receipt = txReceiptResult.value; + if (!receipt) return; + + const [vault, dashboard] = await Promise.all([ + this.yieldManagerClient.getLidoStakingVaultAddress(yieldProvider), + this.yieldManagerClient.getLidoDashboardAddress(yieldProvider), + ]); + + const nodeOperatorFeesDisbursed = getNodeOperatorFeesPaidFromTxReceipt(receipt, dashboard); + if (nodeOperatorFeesDisbursed != 0n) { + this.metricsUpdater.addNodeOperatorFeesPaid(vault, weiToGweiNumber(nodeOperatorFeesDisbursed)); + } + + const lidoFeePayment = this.vaultHubClient.getLidoFeePaymentFromTxReceipt(receipt); + if (lidoFeePayment != 0n) { + this.metricsUpdater.addLidoFeesPaid(vault, weiToGweiNumber(lidoFeePayment)); + } + + const liabilityPayment = this.vaultHubClient.getLiabilityPaymentFromTxReceipt(receipt); + if (liabilityPayment != 0n) { + this.metricsUpdater.addLiabilitiesPaid(vault, weiToGweiNumber(liabilityPayment)); + } + } + + /** + * Records metrics for yield reporting operations. + * Extracts yield report data, fees, and liability payments from the transaction receipt + * and updates metrics including reported yield amount, negative yield, and fee payments. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {Result} txReceiptResult - The transaction receipt result (may be an error or undefined). + * @returns {Promise} A promise that resolves when metrics are recorded (or silently returns if receipt is missing, error, or yield report not found). + */ + async recordReportYieldMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise { + if (txReceiptResult.isErr()) return; + const receipt = txReceiptResult.value; + if (!receipt) return; + + const yieldReport = this.yieldManagerClient.getYieldReportFromTxReceipt(receipt); + if (yieldReport === undefined) return; + + const [vault, dashboard] = await Promise.all([ + this.yieldManagerClient.getLidoStakingVaultAddress(yieldReport.yieldProvider), + this.yieldManagerClient.getLidoDashboardAddress(yieldReport.yieldProvider), + ]); + + this.metricsUpdater.incrementReportYield(vault); + this.metricsUpdater.addReportedYieldAmount(vault, weiToGweiNumber(yieldReport.yieldAmount)); + this.metricsUpdater.setCurrentNegativeYieldLastReport(vault, weiToGweiNumber(yieldReport.outstandingNegativeYield)); + + const nodeOperatorFeesDisbursed = getNodeOperatorFeesPaidFromTxReceipt(receipt, dashboard); + if (nodeOperatorFeesDisbursed != 0n) { + this.metricsUpdater.addNodeOperatorFeesPaid(vault, weiToGweiNumber(nodeOperatorFeesDisbursed)); + } + + const lidoFeePayment = this.vaultHubClient.getLidoFeePaymentFromTxReceipt(receipt); + if (lidoFeePayment != 0n) { + this.metricsUpdater.addLidoFeesPaid(vault, weiToGweiNumber(lidoFeePayment)); + } + + const liabilityPayment = this.vaultHubClient.getLiabilityPaymentFromTxReceipt(receipt); + if (liabilityPayment != 0n) { + this.metricsUpdater.addLiabilitiesPaid(vault, weiToGweiNumber(liabilityPayment)); + } + } + + /** + * Records metrics for safe withdrawal operations. + * Extracts withdrawal event data and liability payments from the transaction receipt + * and updates rebalance metrics (UNSTAKE direction) and liability payment metrics. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {Result} txReceiptResult - The transaction receipt result (may be an error or undefined). + * @returns {Promise} A promise that resolves when metrics are recorded (or silently returns if receipt is missing, error, or withdrawal event not found). + */ + async recordSafeWithdrawalMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise { + if (txReceiptResult.isErr()) return; + const receipt = txReceiptResult.value; + if (!receipt) return; + + const event = this.yieldManagerClient.getWithdrawalEventFromTxReceipt(receipt); + if (!event) return; + const { reserveIncrementAmount } = event; + + this.metricsUpdater.recordRebalance(RebalanceDirection.UNSTAKE, weiToGweiNumber(reserveIncrementAmount)); + + const vault = await this.yieldManagerClient.getLidoStakingVaultAddress(yieldProvider); + const liabilityPayment = this.vaultHubClient.getLiabilityPaymentFromTxReceipt(receipt); + if (liabilityPayment != 0n) { + this.metricsUpdater.addLiabilitiesPaid(vault, weiToGweiNumber(liabilityPayment)); + } + } + + /** + * Records metrics for fund transfer operations. + * Extracts liability payments from the transaction receipt and updates the corresponding metrics. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {Result} txReceiptResult - The transaction receipt result (may be an error or undefined). + * @returns {Promise} A promise that resolves when metrics are recorded (or silently returns if receipt is missing or error). + */ + async recordTransferFundsMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise { + if (txReceiptResult.isErr()) return; + const receipt = txReceiptResult.value; + if (!receipt) return; + + const vault = await this.yieldManagerClient.getLidoStakingVaultAddress(yieldProvider); + const liabilityPayment = this.vaultHubClient.getLiabilityPaymentFromTxReceipt(receipt); + if (liabilityPayment != 0n) { + this.metricsUpdater.addLiabilitiesPaid(vault, weiToGweiNumber(liabilityPayment)); + } + } +} diff --git a/native-yield-operations/automation-service/src/application/metrics/__tests__/NativeYieldAutomationMetricsService.test.ts b/native-yield-operations/automation-service/src/application/metrics/__tests__/NativeYieldAutomationMetricsService.test.ts new file mode 100644 index 0000000000..7d24c752c4 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/metrics/__tests__/NativeYieldAutomationMetricsService.test.ts @@ -0,0 +1,27 @@ +import { NativeYieldAutomationMetricsService } from "../NativeYieldAutomationMetricsService.js"; +import { LineaNativeYieldAutomationServiceMetrics } from "../../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; + +describe("NativeYieldAutomationMetricsService", () => { + const TEST_METRIC = LineaNativeYieldAutomationServiceMetrics.ReportYieldTotal; + + it("applies default labels when none are provided", async () => { + const service = new NativeYieldAutomationMetricsService(); + const counter = service.createCounter(TEST_METRIC, "test counter"); + + counter.inc(); + + const metricsOutput = await service.getRegistry().metrics(); + expect(metricsOutput).toContain('app="native-yield-automation-service"'); + }); + + it("allows overriding default labels through constructor parameter", async () => { + const service = new NativeYieldAutomationMetricsService({ service: "custom" }); + const counter = service.createCounter(TEST_METRIC, "custom label counter"); + + counter.inc(); + + const metricsOutput = await service.getRegistry().metrics(); + expect(metricsOutput).toContain('service="custom"'); + expect(metricsOutput).not.toContain('app="native-yield-automation-service"'); + }); +}); diff --git a/native-yield-operations/automation-service/src/application/metrics/__tests__/NativeYieldAutomationMetricsUpdater.test.ts b/native-yield-operations/automation-service/src/application/metrics/__tests__/NativeYieldAutomationMetricsUpdater.test.ts new file mode 100644 index 0000000000..fd41a8f3f1 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/metrics/__tests__/NativeYieldAutomationMetricsUpdater.test.ts @@ -0,0 +1,350 @@ +import { jest } from "@jest/globals"; +import { IMetricsService } from "@consensys/linea-shared-utils"; +import { NativeYieldAutomationMetricsUpdater } from "../NativeYieldAutomationMetricsUpdater.js"; +import { + LineaNativeYieldAutomationServiceMetrics, + OperationTrigger, +} from "../../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; +import { RebalanceDirection } from "../../../core/entities/RebalanceRequirement.js"; +import { OperationMode } from "../../../core/enums/OperationModeEnums.js"; +import { Address, Hex } from "viem"; + +const createMetricsServiceMock = (): jest.Mocked> => + ({ + getRegistry: jest.fn(), + createCounter: jest.fn(), + createGauge: jest.fn(), + incrementCounter: jest.fn(), + setGauge: jest.fn(), + incrementGauge: jest.fn(), + decrementGauge: jest.fn(), + getGaugeValue: jest.fn(), + getCounterValue: jest.fn(), + createHistogram: jest.fn(), + addValueToHistogram: jest.fn(), + getHistogramMetricsValues: jest.fn(), + }) as unknown as jest.Mocked>; + +describe("NativeYieldAutomationMetricsUpdater", () => { + const validatorPubkey = "0xvalidator" as Hex; + const vaultAddress = "0xvault" as Address; + + it("registers all metrics on construction", () => { + const metricsService = createMetricsServiceMock(); + + // Constructing should immediately register all metrics + new NativeYieldAutomationMetricsUpdater(metricsService); + + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.RebalanceAmountTotal, + "Total rebalance amount between L1MessageService and YieldProvider", + ["direction", "type"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ValidatorPartialUnstakeAmountTotal, + "Total amount partially unstaked per validator", + ["validator_pubkey"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ValidatorExitTotal, + "Total validator exits initiated by automation", + ["validator_pubkey"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.LidoVaultAccountingReportSubmittedTotal, + "Accounting reports submitted to Lido per vault", + ["vault_address"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ReportYieldTotal, + "Yield reports submitted to YieldManager per vault", + ["vault_address"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ReportYieldAmountTotal, + "Total yield amount reported per vault", + ["vault_address"], + ); + expect(metricsService.createGauge).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.CurrentNegativeYieldLastReport, + "Outstanding negative yield as of the latest report", + ["vault_address"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.NodeOperatorFeesPaidTotal, + "Node operator fees paid by automation per vault", + ["vault_address"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.LiabilitiesPaidTotal, + "Liabilities paid by automation per vault", + ["vault_address"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.LidoFeesPaidTotal, + "Lido fees paid by automation per vault", + ["vault_address"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.OperationModeTriggerTotal, + "Operation mode triggers grouped by mode and triggers", + ["mode", "trigger"], + ); + expect(metricsService.createCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionTotal, + "Operation mode executions grouped by mode", + ["mode"], + ); + expect(metricsService.createHistogram).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionDurationSeconds, + [1, 5, 10, 30, 60, 120, 180, 300, 600, 900, 1200], + "Operation mode execution duration in seconds", + ["mode"], + ); + }); + + describe("recordRebalance", () => { + it("increments counter when amount is positive", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.recordRebalance(RebalanceDirection.STAKE, 42); + + expect(metricsService.incrementCounter).toHaveBeenCalledTimes(1); + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.RebalanceAmountTotal, + { direction: RebalanceDirection.STAKE }, + 42, + ); + }); + + it("does not increment when amount is non-positive", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.recordRebalance(RebalanceDirection.UNSTAKE, 0); + updater.recordRebalance(RebalanceDirection.UNSTAKE, -10); + + expect(metricsService.incrementCounter).not.toHaveBeenCalled(); + }); + }); + + describe("addValidatorPartialUnstakeAmount", () => { + it("increments counter when amount is positive", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.addValidatorPartialUnstakeAmount(validatorPubkey, 100); + + expect(metricsService.incrementCounter).toHaveBeenCalledTimes(1); + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ValidatorPartialUnstakeAmountTotal, + { validator_pubkey: validatorPubkey }, + 100, + ); + }); + + it("does not increment when amount is non-positive", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.addValidatorPartialUnstakeAmount(validatorPubkey, 0); + + expect(metricsService.incrementCounter).not.toHaveBeenCalled(); + }); + }); + + describe("incrementValidatorExit", () => { + it("defaults count to 1", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.incrementValidatorExit(validatorPubkey); + + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ValidatorExitTotal, + { validator_pubkey: validatorPubkey }, + 1, + ); + }); + + it("does not increment when count is non-positive", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.incrementValidatorExit(validatorPubkey, 0); + + expect(metricsService.incrementCounter).not.toHaveBeenCalled(); + }); + }); + + it("increments accounting and report counters", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.incrementLidoVaultAccountingReport(vaultAddress); + updater.incrementReportYield(vaultAddress); + + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.LidoVaultAccountingReportSubmittedTotal, + { vault_address: vaultAddress }, + ); + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ReportYieldTotal, + { vault_address: vaultAddress }, + ); + }); + + describe("addReportedYieldAmount", () => { + it("increments counter for positive amount", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.addReportedYieldAmount(vaultAddress, 500); + + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.ReportYieldAmountTotal, + { vault_address: vaultAddress }, + 500, + ); + }); + + it("does not increment for non-positive amount", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.addReportedYieldAmount(vaultAddress, 0); + + expect(metricsService.incrementCounter).not.toHaveBeenCalled(); + }); + }); + + describe("setCurrentNegativeYieldLastReport", () => { + it("sets gauge when value is non-negative", async () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + await updater.setCurrentNegativeYieldLastReport(vaultAddress, 123); + + expect(metricsService.setGauge).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.CurrentNegativeYieldLastReport, + { vault_address: vaultAddress }, + 123, + ); + }); + + it("does not set gauge when value is negative", async () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + await updater.setCurrentNegativeYieldLastReport(vaultAddress, -1); + + expect(metricsService.setGauge).not.toHaveBeenCalled(); + }); + }); + + describe("vault payout counters", () => { + const cases: Array<{ + metric: LineaNativeYieldAutomationServiceMetrics; + invoke: (updater: NativeYieldAutomationMetricsUpdater, address: Address, amount: number) => void; + }> = [ + { + metric: LineaNativeYieldAutomationServiceMetrics.NodeOperatorFeesPaidTotal, + invoke: (updater, address, amount) => updater.addNodeOperatorFeesPaid(address, amount), + }, + { + metric: LineaNativeYieldAutomationServiceMetrics.LiabilitiesPaidTotal, + invoke: (updater, address, amount) => updater.addLiabilitiesPaid(address, amount), + }, + { + metric: LineaNativeYieldAutomationServiceMetrics.LidoFeesPaidTotal, + invoke: (updater, address, amount) => updater.addLidoFeesPaid(address, amount), + }, + ]; + + cases.forEach(({ metric, invoke }) => { + it(`increments ${metric} when amount is positive`, () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + invoke(updater, vaultAddress, 321); + + expect(metricsService.incrementCounter).toHaveBeenCalledWith(metric, { vault_address: vaultAddress }, 321); + }); + + it(`does not increment ${metric} when amount is non-positive`, () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + invoke(updater, vaultAddress, 0); + + expect(metricsService.incrementCounter).not.toHaveBeenCalled(); + }); + }); + }); + + it("increments operation mode trigger counter", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.incrementOperationModeTrigger(OperationMode.YIELD_REPORTING_MODE, OperationTrigger.TIMEOUT); + + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.OperationModeTriggerTotal, + { mode: OperationMode.YIELD_REPORTING_MODE, trigger: OperationTrigger.TIMEOUT }, + ); + }); + + it("increments operation mode execution counter", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.incrementOperationModeExecution(OperationMode.OSSIFICATION_PENDING_MODE); + + expect(metricsService.incrementCounter).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionTotal, + { mode: OperationMode.OSSIFICATION_PENDING_MODE }, + ); + }); + + describe("recordOperationModeDuration", () => { + it("records duration when value is non-negative", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.recordOperationModeDuration(OperationMode.OSSIFICATION_COMPLETE_MODE, 0); + + expect(metricsService.addValueToHistogram).toHaveBeenCalledWith( + LineaNativeYieldAutomationServiceMetrics.OperationModeExecutionDurationSeconds, + 0, + { mode: OperationMode.OSSIFICATION_COMPLETE_MODE }, + ); + }); + + it("does not record when duration is negative", () => { + const metricsService = createMetricsServiceMock(); + const updater = new NativeYieldAutomationMetricsUpdater(metricsService); + jest.clearAllMocks(); + + updater.recordOperationModeDuration(OperationMode.YIELD_REPORTING_MODE, -1); + + expect(metricsService.addValueToHistogram).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/native-yield-operations/automation-service/src/application/metrics/__tests__/OperationModeMetricsRecorder.test.ts b/native-yield-operations/automation-service/src/application/metrics/__tests__/OperationModeMetricsRecorder.test.ts new file mode 100644 index 0000000000..b72b1a7e79 --- /dev/null +++ b/native-yield-operations/automation-service/src/application/metrics/__tests__/OperationModeMetricsRecorder.test.ts @@ -0,0 +1,282 @@ +import { jest } from "@jest/globals"; +import { ok, err } from "neverthrow"; +import { OperationModeMetricsRecorder } from "../OperationModeMetricsRecorder.js"; +import type { TransactionReceipt, Address } from "viem"; +import type { ILogger } from "@consensys/linea-shared-utils"; +import type { INativeYieldAutomationMetricsUpdater } from "../../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import type { IYieldManager } from "../../../core/clients/contracts/IYieldManager.js"; +import type { IVaultHub } from "../../../core/clients/contracts/IVaultHub.js"; +import { RebalanceDirection } from "../../../core/entities/RebalanceRequirement.js"; +import * as NodeOperatorModule from "../../../clients/contracts/getNodeOperatorFeesPaidFromTxReceipt.js"; + +const ONE_GWEI = 1_000_000_000n; +const toWei = (gwei: number): bigint => BigInt(gwei) * ONE_GWEI; + +const createLoggerMock = (): ILogger => ({ + name: "test-logger", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), +}); + +const createMetricsUpdaterMock = (): jest.Mocked => + ({ + recordRebalance: jest.fn(), + recordOperationModeDuration: jest.fn(), + incrementReportYield: jest.fn(), + addReportedYieldAmount: jest.fn(), + setCurrentNegativeYieldLastReport: jest.fn(async () => undefined), + addNodeOperatorFeesPaid: jest.fn(), + addLiabilitiesPaid: jest.fn(), + addLidoFeesPaid: jest.fn(), + incrementLidoVaultAccountingReport: jest.fn(), + incrementOperationModeExecution: jest.fn(), + incrementOperationModeTrigger: jest.fn(), + addValidatorPartialUnstakeAmount: jest.fn(), + incrementValidatorExit: jest.fn(), + }) as unknown as jest.Mocked; + +const createYieldManagerMock = () => + ({ + getLidoStakingVaultAddress: jest.fn(), + getLidoDashboardAddress: jest.fn(), + getYieldReportFromTxReceipt: jest.fn(), + getWithdrawalEventFromTxReceipt: jest.fn(), + }) as unknown as jest.Mocked>; + +const createVaultHubMock = () => + ({ + getLiabilityPaymentFromTxReceipt: jest.fn(), + getLidoFeePaymentFromTxReceipt: jest.fn(), + }) as unknown as jest.Mocked>; + +const getNodeOperatorFeesPaidFromTxReceiptMock = jest.spyOn(NodeOperatorModule, "getNodeOperatorFeesPaidFromTxReceipt"); + +describe("OperationModeMetricsRecorder", () => { + const yieldProvider = "0xyieldprovider" as Address; + const alternateYieldProvider = "0xalternate" as Address; + const vaultAddress = "0xvault" as Address; + const dashboardAddress = "0xdashboard" as Address; + const receipt = {} as TransactionReceipt; + + beforeEach(() => { + jest.clearAllMocks(); + getNodeOperatorFeesPaidFromTxReceiptMock.mockClear(); + }); + + const setupRecorder = () => { + const logger = createLoggerMock(); + const metricsUpdater = createMetricsUpdaterMock(); + const yieldManagerClient = createYieldManagerMock(); + const vaultHubClient = createVaultHubMock(); + + const recorder = new OperationModeMetricsRecorder(logger, metricsUpdater, yieldManagerClient, vaultHubClient); + + return { recorder, metricsUpdater, yieldManagerClient, vaultHubClient }; + }; + + describe("recordProgressOssificationMetrics", () => { + it("does nothing when receipt is an error", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + await recorder.recordProgressOssificationMetrics(yieldProvider, err(new Error("boom"))); + + expect(yieldManagerClient.getLidoStakingVaultAddress).not.toHaveBeenCalled(); + expect(metricsUpdater.addNodeOperatorFeesPaid).not.toHaveBeenCalled(); + }); + + it("does nothing when receipt is undefined", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + await recorder.recordProgressOssificationMetrics( + yieldProvider, + ok(undefined), + ); + + expect(yieldManagerClient.getLidoStakingVaultAddress).not.toHaveBeenCalled(); + expect(metricsUpdater.addNodeOperatorFeesPaid).not.toHaveBeenCalled(); + }); + + it("records node operator, lido fee, and liability metrics when values are non-zero", async () => { + const { recorder, metricsUpdater, yieldManagerClient, vaultHubClient } = setupRecorder(); + + yieldManagerClient.getLidoStakingVaultAddress.mockResolvedValueOnce(vaultAddress); + yieldManagerClient.getLidoDashboardAddress.mockResolvedValueOnce(dashboardAddress); + getNodeOperatorFeesPaidFromTxReceiptMock.mockReturnValueOnce(toWei(5)); + vaultHubClient.getLidoFeePaymentFromTxReceipt.mockReturnValueOnce(toWei(3)); + vaultHubClient.getLiabilityPaymentFromTxReceipt.mockReturnValueOnce(toWei(7)); + + await recorder.recordProgressOssificationMetrics( + yieldProvider, + ok(receipt), + ); + + expect(getNodeOperatorFeesPaidFromTxReceiptMock).toHaveBeenCalledWith(receipt, dashboardAddress); + expect(metricsUpdater.addNodeOperatorFeesPaid).toHaveBeenCalledWith(vaultAddress, 5); + expect(metricsUpdater.addLidoFeesPaid).toHaveBeenCalledWith(vaultAddress, 3); + expect(metricsUpdater.addLiabilitiesPaid).toHaveBeenCalledWith(vaultAddress, 7); + }); + + it("skips metric updates when all extracted values are zero", async () => { + const { recorder, metricsUpdater, yieldManagerClient, vaultHubClient } = setupRecorder(); + + yieldManagerClient.getLidoStakingVaultAddress.mockResolvedValueOnce(vaultAddress); + yieldManagerClient.getLidoDashboardAddress.mockResolvedValueOnce(dashboardAddress); + getNodeOperatorFeesPaidFromTxReceiptMock.mockReturnValueOnce(0n); + vaultHubClient.getLidoFeePaymentFromTxReceipt.mockReturnValueOnce(0n); + vaultHubClient.getLiabilityPaymentFromTxReceipt.mockReturnValueOnce(0n); + + await recorder.recordProgressOssificationMetrics( + yieldProvider, + ok(receipt), + ); + + expect(metricsUpdater.addNodeOperatorFeesPaid).not.toHaveBeenCalled(); + expect(metricsUpdater.addLidoFeesPaid).not.toHaveBeenCalled(); + expect(metricsUpdater.addLiabilitiesPaid).not.toHaveBeenCalled(); + }); + }); + + describe("recordReportYieldMetrics", () => { + it("does nothing when receipt result is error", async () => { + const { recorder, metricsUpdater } = setupRecorder(); + + await recorder.recordReportYieldMetrics(yieldProvider, err(new Error("boom"))); + + expect(metricsUpdater.incrementReportYield).not.toHaveBeenCalled(); + }); + + it("does nothing when receipt is undefined", async () => { + const { recorder, metricsUpdater } = setupRecorder(); + + await recorder.recordReportYieldMetrics(yieldProvider, ok(undefined)); + + expect(metricsUpdater.incrementReportYield).not.toHaveBeenCalled(); + }); + + it("does nothing when yield report cannot be parsed", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + yieldManagerClient.getYieldReportFromTxReceipt.mockReturnValueOnce(undefined); + + await recorder.recordReportYieldMetrics(yieldProvider, ok(receipt)); + + expect(metricsUpdater.incrementReportYield).not.toHaveBeenCalled(); + expect(yieldManagerClient.getLidoStakingVaultAddress).not.toHaveBeenCalled(); + }); + + it("records yield metrics and payouts when report is available", async () => { + const { recorder, metricsUpdater, yieldManagerClient, vaultHubClient } = setupRecorder(); + + yieldManagerClient.getYieldReportFromTxReceipt.mockReturnValueOnce({ + yieldAmount: toWei(11), + outstandingNegativeYield: toWei(2), + yieldProvider: alternateYieldProvider, + }); + yieldManagerClient.getLidoStakingVaultAddress.mockResolvedValueOnce(vaultAddress); + yieldManagerClient.getLidoDashboardAddress.mockResolvedValueOnce(dashboardAddress); + getNodeOperatorFeesPaidFromTxReceiptMock.mockReturnValueOnce(toWei(4)); + vaultHubClient.getLidoFeePaymentFromTxReceipt.mockReturnValueOnce(toWei(6)); + vaultHubClient.getLiabilityPaymentFromTxReceipt.mockReturnValueOnce(toWei(8)); + + await recorder.recordReportYieldMetrics(yieldProvider, ok(receipt)); + + expect(yieldManagerClient.getLidoStakingVaultAddress).toHaveBeenCalledWith(alternateYieldProvider); + expect(metricsUpdater.incrementReportYield).toHaveBeenCalledWith(vaultAddress); + expect(metricsUpdater.addReportedYieldAmount).toHaveBeenCalledWith(vaultAddress, 11); + expect(metricsUpdater.setCurrentNegativeYieldLastReport).toHaveBeenCalledWith(vaultAddress, 2); + expect(metricsUpdater.addNodeOperatorFeesPaid).toHaveBeenCalledWith(vaultAddress, 4); + expect(metricsUpdater.addLidoFeesPaid).toHaveBeenCalledWith(vaultAddress, 6); + expect(metricsUpdater.addLiabilitiesPaid).toHaveBeenCalledWith(vaultAddress, 8); + }); + }); + + describe("recordSafeWithdrawalMetrics", () => { + it("does nothing when receipt result is error", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + await recorder.recordSafeWithdrawalMetrics(yieldProvider, err(new Error("boom"))); + + expect(metricsUpdater.recordRebalance).not.toHaveBeenCalled(); + expect(yieldManagerClient.getWithdrawalEventFromTxReceipt).not.toHaveBeenCalled(); + }); + + it("does nothing when receipt is undefined", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + await recorder.recordSafeWithdrawalMetrics(yieldProvider, ok(undefined)); + + expect(metricsUpdater.recordRebalance).not.toHaveBeenCalled(); + expect(yieldManagerClient.getWithdrawalEventFromTxReceipt).not.toHaveBeenCalled(); + }); + + it("records rebalance and liabilities when withdrawal event is present", async () => { + const { recorder, metricsUpdater, yieldManagerClient, vaultHubClient } = setupRecorder(); + + yieldManagerClient.getWithdrawalEventFromTxReceipt.mockReturnValueOnce({ + reserveIncrementAmount: toWei(9), + yieldProvider, + }); + yieldManagerClient.getLidoStakingVaultAddress.mockResolvedValueOnce(vaultAddress); + vaultHubClient.getLiabilityPaymentFromTxReceipt.mockReturnValueOnce(toWei(5)); + + await recorder.recordSafeWithdrawalMetrics(yieldProvider, ok(receipt)); + + expect(metricsUpdater.recordRebalance).toHaveBeenCalledWith(RebalanceDirection.UNSTAKE, 9); + expect(metricsUpdater.addLiabilitiesPaid).toHaveBeenCalledWith(vaultAddress, 5); + }); + + it("does nothing when no withdrawal event is found", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + yieldManagerClient.getWithdrawalEventFromTxReceipt.mockReturnValueOnce(undefined); + + await recorder.recordSafeWithdrawalMetrics(yieldProvider, ok(receipt)); + + expect(metricsUpdater.recordRebalance).not.toHaveBeenCalled(); + }); + }); + + describe("recordTransferFundsMetrics", () => { + it("does nothing when receipt result is error", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + await recorder.recordTransferFundsMetrics(yieldProvider, err(new Error("boom"))); + + expect(metricsUpdater.addLiabilitiesPaid).not.toHaveBeenCalled(); + expect(yieldManagerClient.getLidoStakingVaultAddress).not.toHaveBeenCalled(); + }); + + it("does nothing when receipt is undefined", async () => { + const { recorder, metricsUpdater, yieldManagerClient } = setupRecorder(); + + await recorder.recordTransferFundsMetrics(yieldProvider, ok(undefined)); + + expect(metricsUpdater.addLiabilitiesPaid).not.toHaveBeenCalled(); + expect(yieldManagerClient.getLidoStakingVaultAddress).not.toHaveBeenCalled(); + }); + + it("records liabilities when payment exists", async () => { + const { recorder, metricsUpdater, yieldManagerClient, vaultHubClient } = setupRecorder(); + + yieldManagerClient.getLidoStakingVaultAddress.mockResolvedValueOnce(vaultAddress); + vaultHubClient.getLiabilityPaymentFromTxReceipt.mockReturnValueOnce(toWei(12)); + + await recorder.recordTransferFundsMetrics(yieldProvider, ok(receipt)); + + expect(metricsUpdater.addLiabilitiesPaid).toHaveBeenCalledWith(vaultAddress, 12); + }); + + it("skips liabilities when payment is zero", async () => { + const { recorder, metricsUpdater, yieldManagerClient, vaultHubClient } = setupRecorder(); + + yieldManagerClient.getLidoStakingVaultAddress.mockResolvedValueOnce(vaultAddress); + vaultHubClient.getLiabilityPaymentFromTxReceipt.mockReturnValueOnce(0n); + + await recorder.recordTransferFundsMetrics(yieldProvider, ok(receipt)); + + expect(metricsUpdater.addLiabilitiesPaid).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/BeaconChainStakingClient.ts b/native-yield-operations/automation-service/src/clients/BeaconChainStakingClient.ts new file mode 100644 index 0000000000..08f09583f0 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/BeaconChainStakingClient.ts @@ -0,0 +1,180 @@ +import { ILogger, min, ONE_GWEI, safeSub } from "@consensys/linea-shared-utils"; +import { IBeaconChainStakingClient } from "../core/clients/IBeaconChainStakingClient.js"; +import { IValidatorDataClient } from "../core/clients/IValidatorDataClient.js"; +import { ValidatorBalanceWithPendingWithdrawal } from "../core/entities/ValidatorBalance.js"; +import { WithdrawalRequests } from "../core/entities/LidoStakingVaultWithdrawalParams.js"; +import { Address, maxUint256, stringToHex, TransactionReceipt } from "viem"; +import { IYieldManager } from "../core/clients/contracts/IYieldManager.js"; +import { INativeYieldAutomationMetricsUpdater } from "../core/metrics/INativeYieldAutomationMetricsUpdater.js"; + +/** + * Client for managing beacon chain staking operations including withdrawal requests and validator exits. + * Handles partial withdrawal requests up to a configured maximum per transaction and tracks metrics. + */ +export class BeaconChainStakingClient implements IBeaconChainStakingClient { + /** + * Creates a new BeaconChainStakingClient instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {INativeYieldAutomationMetricsUpdater} metricsUpdater - Service for updating metrics. + * @param {IValidatorDataClient} validatorDataClient - Client for retrieving validator data. + * @param {number} maxValidatorWithdrawalRequestsPerTransaction - Maximum number of withdrawal requests allowed per transaction. + * @param {IYieldManager} yieldManagerContractClient - Client for interacting with YieldManager contracts. + * @param {Address} yieldProvider - The yield provider address. + */ + constructor( + private readonly logger: ILogger, + private readonly metricsUpdater: INativeYieldAutomationMetricsUpdater, + private readonly validatorDataClient: IValidatorDataClient, + private readonly maxValidatorWithdrawalRequestsPerTransaction: number, + private readonly yieldManagerContractClient: IYieldManager, + private readonly yieldProvider: Address, + ) {} + + /** + * Submits withdrawal requests to fulfill a specific amount. + * Calculates the remaining withdrawal amount needed after accounting for existing pending partial withdrawals, + * then submits partial withdrawal requests for validators to meet the target amount. + * + * @param {bigint} amountWei - The target withdrawal amount in wei. + * @returns {Promise} A promise that resolves when withdrawal requests are submitted (or silently returns if validator list is unavailable). + */ + async submitWithdrawalRequestsToFulfilAmount(amountWei: bigint): Promise { + this.logger.debug( + `submitWithdrawalRequestsToFulfilAmount started: amountWei=${amountWei.toString()}; validatorLimit=${this.maxValidatorWithdrawalRequestsPerTransaction}`, + ); + const sortedValidatorList = await this.validatorDataClient.getActiveValidatorsWithPendingWithdrawals(); + if (sortedValidatorList === undefined) { + this.logger.error( + "submitWithdrawalRequestsToFulfilAmount failed to get sortedValidatorList with pending withdrawals", + ); + return; + } + const totalPendingPartialWithdrawalsWei = + this.validatorDataClient.getTotalPendingPartialWithdrawalsWei(sortedValidatorList); + const remainingWithdrawalAmountWei = safeSub(amountWei, totalPendingPartialWithdrawalsWei); + if (remainingWithdrawalAmountWei === 0n) return; + await this._submitPartialWithdrawalRequests(sortedValidatorList, remainingWithdrawalAmountWei); + } + + /** + * Submits the maximum available withdrawal requests. + * First submits partial withdrawal requests for validators with withdrawable amounts, + * then submits validator exit requests for validators with no withdrawable amount remaining. + * + * @returns {Promise} A promise that resolves when all withdrawal requests are submitted (or silently returns if validator list is unavailable). + */ + async submitMaxAvailableWithdrawalRequests(): Promise { + this.logger.debug(`submitMaxAvailableWithdrawalRequests started`); + const sortedValidatorList = await this.validatorDataClient.getActiveValidatorsWithPendingWithdrawals(); + if (sortedValidatorList === undefined) { + this.logger.error( + "submitMaxAvailableWithdrawalRequests failed to get sortedValidatorList with pending withdrawals", + ); + return; + } + const remainingWithdrawals = await this._submitPartialWithdrawalRequests(sortedValidatorList, maxUint256); + await this._submitValidatorExits(sortedValidatorList, remainingWithdrawals); + } + + /** + * Submits partial withdrawal requests for validators up to the specified amount or transaction limit. + * Returns the number of withdrawal requests remaining (remaining shots) after this submission. + * Processes validators in order, withdrawing up to their withdrawable amount until the target amount is reached + * or the maximum validators per transaction limit is hit. Does unstake operation and instruments metrics after transaction success. + * + * @param {ValidatorBalanceWithPendingWithdrawal[]} sortedValidatorList - List of validators sorted by priority with pending withdrawals. + * @param {bigint} amountWei - The target withdrawal amount in wei (use maxUint256 for maximum available). + * @returns {Promise} The number of withdrawal requests remaining (remaining shots) after this submission. + */ + private async _submitPartialWithdrawalRequests( + sortedValidatorList: ValidatorBalanceWithPendingWithdrawal[], + amountWei: bigint, + ): Promise { + this.logger.debug(`_submitPartialWithdrawalRequests started amountWei=${amountWei}`, { sortedValidatorList }); + const withdrawalRequests: WithdrawalRequests = { + pubkeys: [], + amountsGwei: [], + }; + if (sortedValidatorList.length === 0) return this.maxValidatorWithdrawalRequestsPerTransaction; + let totalWithdrawalRequestAmountWei = 0n; + + for (const v of sortedValidatorList) { + if (withdrawalRequests.pubkeys.length >= this.maxValidatorWithdrawalRequestsPerTransaction) break; + if (totalWithdrawalRequestAmountWei >= amountWei) break; + + const remainingWei = amountWei - totalWithdrawalRequestAmountWei; + const withdrawableWei = v.withdrawableAmount * ONE_GWEI; + const amountToWithdrawWei = min(withdrawableWei, remainingWei); + const amountToWithdrawGwei = amountToWithdrawWei / ONE_GWEI; + + if (amountToWithdrawGwei > 0n) { + withdrawalRequests.pubkeys.push(stringToHex(v.publicKey)); + withdrawalRequests.amountsGwei.push(amountToWithdrawGwei); + totalWithdrawalRequestAmountWei += amountToWithdrawWei; + } + } + + // Do unstake + if (totalWithdrawalRequestAmountWei === 0n || withdrawalRequests.amountsGwei.length === 0) { + return this.maxValidatorWithdrawalRequestsPerTransaction; + } + await this.yieldManagerContractClient.unstake(this.yieldProvider, withdrawalRequests); + + // Instrument metrics after tx success + for (let i = 0; i < withdrawalRequests.pubkeys.length; i++) { + const pubkey = withdrawalRequests.pubkeys[i]; + const amountGwei = withdrawalRequests.amountsGwei[i]; + this.metricsUpdater.addValidatorPartialUnstakeAmount(pubkey, Number(amountGwei)); + } + + // Return # of remaining shots (withdrawal requests remaining) + const remainingWithdrawals = this.maxValidatorWithdrawalRequestsPerTransaction - withdrawalRequests.pubkeys.length; + this.logger.debug(`_submitPartialWithdrawalRequests remainingWithdrawal=${remainingWithdrawals}`); + return remainingWithdrawals; + } + + /** + * Submits validator exit requests for validators with no withdrawable amount remaining. + * Processes validators that have 0 withdrawable amount, submitting them for exit using 0 amount as a signal for validator exit. + * Respects the remaining withdrawal slots available. Does unstake operation and instruments metrics after transaction success. + * + * @param {ValidatorBalanceWithPendingWithdrawal[]} sortedValidatorList - List of validators sorted by priority with pending withdrawals. + * @param {number} remainingWithdrawals - The number of remaining withdrawal request slots available. + * @returns {Promise} A promise that resolves when validator exit requests are submitted (or silently returns if no slots available or no validators to exit). + */ + private async _submitValidatorExits( + sortedValidatorList: ValidatorBalanceWithPendingWithdrawal[], + remainingWithdrawals: number, + ): Promise { + this.logger.debug(`_submitValidatorExits started remainingWithdrawals=${remainingWithdrawals}`, { + sortedValidatorList, + }); + if (remainingWithdrawals === 0 || sortedValidatorList.length === 0) return; + const withdrawalRequests: WithdrawalRequests = { + pubkeys: [], + amountsGwei: [], + }; + + for (const v of sortedValidatorList) { + if (withdrawalRequests.pubkeys.length >= remainingWithdrawals) break; + if (withdrawalRequests.pubkeys.length >= this.maxValidatorWithdrawalRequestsPerTransaction) break; + + if (v.withdrawableAmount === 0n) { + withdrawalRequests.pubkeys.push(stringToHex(v.publicKey)); + // 0 amount -> signal for validator exit + withdrawalRequests.amountsGwei.push(0n); + } + } + + if (withdrawalRequests.amountsGwei.length === 0) return; + // Do unstake + await this.yieldManagerContractClient.unstake(this.yieldProvider, withdrawalRequests); + + // Instrument metrics after tx success + for (let i = 0; i < withdrawalRequests.pubkeys.length; i++) { + const pubkey = withdrawalRequests.pubkeys[i]; + this.metricsUpdater.incrementValidatorExit(pubkey); + } + } +} diff --git a/native-yield-operations/automation-service/src/clients/ConsensysStakingApiClient.ts b/native-yield-operations/automation-service/src/clients/ConsensysStakingApiClient.ts new file mode 100644 index 0000000000..e80b84774e --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/ConsensysStakingApiClient.ts @@ -0,0 +1,111 @@ +import { ApolloClient } from "@apollo/client"; +import { IBeaconNodeAPIClient, ILogger, IRetryService, ONE_GWEI, safeSub } from "@consensys/linea-shared-utils"; +import { IValidatorDataClient } from "../core/clients/IValidatorDataClient.js"; +import { ALL_VALIDATORS_BY_LARGEST_BALANCE_QUERY } from "../core/entities/graphql/ActiveValidatorsByLargestBalance.js"; +import { ValidatorBalance, ValidatorBalanceWithPendingWithdrawal } from "../core/entities/ValidatorBalance.js"; + +/** + * Client for retrieving validator data from Consensys Staking API via GraphQL. + * Fetches active validators and combines them with pending withdrawal data from the beacon chain. + */ +export class ConsensysStakingApiClient implements IValidatorDataClient { + /** + * Creates a new ConsensysStakingApiClient instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {IRetryService} retryService - Service for retrying failed operations. + * @param {ApolloClient} apolloClient - Apollo GraphQL client for querying Consensys Staking API. + * @param {IBeaconNodeAPIClient} beaconNodeApiClient - Client for retrieving pending partial withdrawals from beacon chain. + */ + constructor( + private readonly logger: ILogger, + private readonly retryService: IRetryService, + private readonly apolloClient: ApolloClient, + private readonly beaconNodeApiClient: IBeaconNodeAPIClient, + ) {} + + /** + * Retrieves all active validators from the Consensys Staking GraphQL API. + * Uses retry logic to handle transient failures. + * + * @returns {Promise} Array of active validators, or undefined if the query fails or returns no data. + */ + async getActiveValidators(): Promise { + const { data, error } = await this.retryService.retry(() => + this.apolloClient.query({ query: ALL_VALIDATORS_BY_LARGEST_BALANCE_QUERY }), + ); + if (error) { + this.logger.error("getActiveValidators error:", { error }); + return undefined; + } + if (!data) { + this.logger.error("getActiveValidators data undefined"); + return undefined; + } + const resp = data?.allValidators.nodes; + this.logger.debug("getActiveValidators succeded", { resp }); + return resp; + } + + /** + * Retrieves active validators with pending withdrawal information. + * Returns sorted in descending order of withdrawableValue (largest withdrawableAmount first). + * Performs the following steps: + * 1️⃣ Aggregate duplicate pending withdrawals by validator index + * 2️⃣ Join with validators and compute total pending amount + * ✅ Sort descending (largest withdrawableAmount first) + * + * @returns {Promise} Array of validators with pending withdrawal data, sorted descending by withdrawableAmount, or undefined if data retrieval fails. + */ + async getActiveValidatorsWithPendingWithdrawals(): Promise { + const [allValidators, pendingWithdrawalsQueue] = await Promise.all([ + this.getActiveValidators(), + this.beaconNodeApiClient.getPendingPartialWithdrawals(), + ]); + if (allValidators === undefined || pendingWithdrawalsQueue === undefined) return undefined; + + // 1️⃣ Aggregate duplicate pending withdrawals by validator index + const pendingByValidator = new Map(); + for (const w of pendingWithdrawalsQueue) { + const current = pendingByValidator.get(w.validator_index) ?? 0n; + pendingByValidator.set(w.validator_index, current + w.amount); + } + + // 2️⃣ Join with validators and compute total pending amount + const joined = allValidators.map((v) => { + const pendingAmount = pendingByValidator.get(Number(v.validatorIndex)) ?? 0n; + + return { + balance: v.balance, + effectiveBalance: v.effectiveBalance, + publicKey: v.publicKey, + validatorIndex: v.validatorIndex, + pendingWithdrawalAmount: pendingAmount, + // N.B We expect amounts from GraphQL API and Beacon Chain RPC URL to be in gwei units, not wei. + withdrawableAmount: safeSub(safeSub(v.balance, pendingAmount), ONE_GWEI * 32n), + }; + }); + + // ✅ Sort descending (largest withdrawableAmount first) + joined.sort((a, b) => + a.withdrawableAmount > b.withdrawableAmount ? -1 : a.withdrawableAmount < b.withdrawableAmount ? 1 : 0, + ); + + this.logger.debug("getActiveValidatorsWithPendingWithdrawals return val", { joined }); + return joined; + } + + /** + * Calculates the total pending partial withdrawals across all validators in wei. + * Should be static, but bit tricky to use static and interface together. + * + * @param {ValidatorBalanceWithPendingWithdrawal[]} validatorList - List of validators with pending withdrawal information. + * @returns {bigint} Total pending partial withdrawals amount in wei. + */ + getTotalPendingPartialWithdrawalsWei(validatorList: ValidatorBalanceWithPendingWithdrawal[]): bigint { + const totalGwei = validatorList.reduce((acc, v) => acc + v.pendingWithdrawalAmount, 0n); + const totalWei = totalGwei * ONE_GWEI; + this.logger.debug(`getTotalPendingPartialWithdrawalsWei totalWei=${totalWei}`); + return totalWei; + } +} diff --git a/native-yield-operations/automation-service/src/clients/LidoAccountingReportClient.ts b/native-yield-operations/automation-service/src/clients/LidoAccountingReportClient.ts new file mode 100644 index 0000000000..0969ebdab4 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/LidoAccountingReportClient.ts @@ -0,0 +1,93 @@ +import { Address, TransactionReceipt } from "viem"; +import { ILidoAccountingReportClient } from "../core/clients/ILidoAccountingReportClient.js"; +import { ILazyOracle, UpdateVaultDataParams } from "../core/clients/contracts/ILazyOracle.js"; +import { getReportProofByVault } from "@lidofinance/lsv-cli/dist/utils/report/report-proof.js"; +import { ILogger, IRetryService, bigintReplacer } from "@consensys/linea-shared-utils"; + +/** + * Client for submitting Lido accounting reports to the LazyOracle contract. + * Retrieves report data from IPFS, caches vault report parameters, and provides methods + * for submitting vault accounting reports. + */ +export class LidoAccountingReportClient implements ILidoAccountingReportClient { + private vaultReportByAddress = new Map(); + + /** + * Creates a new LidoAccountingReportClient instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {IRetryService} retryService - Service for retrying failed operations. + * @param {ILazyOracle} lazyOracleContractClient - Client for interacting with LazyOracle contracts. + * @param {string} ipfsGatewayUrl - IPFS gateway URL for retrieving report data. + */ + constructor( + private readonly logger: ILogger, + private readonly retryService: IRetryService, + private readonly lazyOracleContractClient: ILazyOracle, + private readonly ipfsGatewayUrl: string, + ) {} + + /** + * Retrieves the latest vault report parameters for submission. + * Fetches the latest report CID from the LazyOracle, retrieves the report proof from IPFS, + * constructs the vault data parameters, and caches them for future use. + * + * @param {Address} vault - The vault address to get report parameters for. + * @returns {Promise} The vault data parameters including totalValue, cumulativeLidoFees, liabilityShares, maxLiabilityShares, slashingReserve, and proof. + */ + async getLatestSubmitVaultReportParams(vault: Address): Promise { + const latestReportData = await this.lazyOracleContractClient.latestReportData(); + const reportProof = await this.retryService.retry(() => + getReportProofByVault({ + vault, + cid: latestReportData.reportCid, + gateway: this.ipfsGatewayUrl, + }), + ); + + const params: UpdateVaultDataParams = { + vault, + totalValue: BigInt(reportProof.data.totalValueWei), + cumulativeLidoFees: BigInt(reportProof.data.fee), + liabilityShares: BigInt(reportProof.data.liabilityShares), + maxLiabilityShares: BigInt(reportProof.data.maxLiabilityShares), + slashingReserve: BigInt(reportProof.data.slashingReserve), + proof: reportProof.proof, + }; + + this.vaultReportByAddress.set(vault, params); + + this.logger.info( + `getLatestSubmitVaultReportParams for vault=${vault} latestSubmitVaultReportParams=${JSON.stringify(params, bigintReplacer, 2)}`, + ); + return params; + } + + /** + * Submits the latest vault report to the LazyOracle contract. + * Uses latest known result of this.getLatestSubmitVaultReportParams(). + * + * @param {Address} vault - The vault address to submit the report for. + * @returns {Promise} A promise that resolves when the vault report is submitted. + */ + async submitLatestVaultReport(vault: Address): Promise { + const params = await this._getLatestSubmitVaultReportParams(vault); + await this.lazyOracleContractClient.updateVaultData(params); + } + + /** + * Gets the latest vault report parameters, using cached values if available. + * If no cached value exists for the vault, fetches fresh parameters using getLatestSubmitVaultReportParams. + * + * @param {Address} vault - The vault address to get report parameters for. + * @returns {Promise} The vault data parameters, either from cache or freshly fetched. + */ + private async _getLatestSubmitVaultReportParams(vault: Address): Promise { + const cachedVaultReport = this.vaultReportByAddress.get(vault); + if (cachedVaultReport === undefined) { + return this.getLatestSubmitVaultReportParams(vault); + } else { + return cachedVaultReport; + } + } +} diff --git a/native-yield-operations/automation-service/src/clients/__tests__/BeaconChainStakingClient.test.ts b/native-yield-operations/automation-service/src/clients/__tests__/BeaconChainStakingClient.test.ts new file mode 100644 index 0000000000..51a35f52b7 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/__tests__/BeaconChainStakingClient.test.ts @@ -0,0 +1,370 @@ +import { jest } from "@jest/globals"; +import { BeaconChainStakingClient } from "../BeaconChainStakingClient.js"; +import type { ILogger } from "@consensys/linea-shared-utils"; +import type { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import type { IValidatorDataClient } from "../../core/clients/IValidatorDataClient.js"; +import type { ValidatorBalance, ValidatorBalanceWithPendingWithdrawal } from "../../core/entities/ValidatorBalance.js"; +import type { IYieldManager } from "../../core/clients/contracts/IYieldManager.js"; +import type { Address, TransactionReceipt } from "viem"; +import { stringToHex } from "viem"; +import { ONE_GWEI } from "@consensys/linea-shared-utils"; +import type { WithdrawalRequests } from "../../core/entities/LidoStakingVaultWithdrawalParams.js"; + +const YIELD_PROVIDER = "0xyieldprovider" as Address; + +const createLoggerMock = (): ILogger => ({ + name: "test-logger", + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +}); + +const createMetricsUpdaterMock = () => { + const addValidatorPartialUnstakeAmount = jest.fn(); + const incrementValidatorExit = jest.fn(); + + const metricsUpdater: INativeYieldAutomationMetricsUpdater = { + recordRebalance: jest.fn(), + addValidatorPartialUnstakeAmount, + incrementValidatorExit, + incrementLidoVaultAccountingReport: jest.fn(), + incrementReportYield: jest.fn(), + addReportedYieldAmount: jest.fn(), + setCurrentNegativeYieldLastReport: jest.fn(async () => undefined), + addNodeOperatorFeesPaid: jest.fn(), + addLiabilitiesPaid: jest.fn(), + addLidoFeesPaid: jest.fn(), + incrementOperationModeTrigger: jest.fn(), + incrementOperationModeExecution: jest.fn(), + recordOperationModeDuration: jest.fn(), + }; + + return { metricsUpdater, addValidatorPartialUnstakeAmount, incrementValidatorExit }; +}; + +const createValidatorDataClientMock = () => { + const getActiveValidators = jest.fn<() => Promise>(); + const getActiveValidatorsWithPendingWithdrawals = + jest.fn<() => Promise>(); + const getTotalPendingPartialWithdrawalsWei = jest + .fn<(validatorList: ValidatorBalanceWithPendingWithdrawal[]) => bigint>() + .mockReturnValue(0n); + + const client: IValidatorDataClient = { + getActiveValidators, + getActiveValidatorsWithPendingWithdrawals, + getTotalPendingPartialWithdrawalsWei, + }; + + return { + client, + getActiveValidators, + getActiveValidatorsWithPendingWithdrawals, + getTotalPendingPartialWithdrawalsWei, + }; +}; + +const createYieldManagerMock = () => { + const unstakeMock = jest.fn(async (_: Address, __: WithdrawalRequests) => ({}) as TransactionReceipt); + const mock = { + unstake: unstakeMock, + } as unknown as IYieldManager; + return { mock, unstakeMock }; +}; + +const createValidator = ( + overrides: Partial & Pick, +): ValidatorBalanceWithPendingWithdrawal => ({ + balance: 32n, + effectiveBalance: 32n, + pendingWithdrawalAmount: 0n, + withdrawableAmount: 0n, + validatorIndex: 0n, + ...overrides, +}); + +describe("BeaconChainStakingClient", () => { + const setupClient = (maxValidatorsPerTx = 3) => { + const logger = createLoggerMock(); + const { metricsUpdater, addValidatorPartialUnstakeAmount, incrementValidatorExit } = createMetricsUpdaterMock(); + const { + client: validatorDataClient, + getActiveValidatorsWithPendingWithdrawals, + getTotalPendingPartialWithdrawalsWei, + } = createValidatorDataClientMock(); + const { mock: yieldManagerContractClient, unstakeMock } = createYieldManagerMock(); + + const client = new BeaconChainStakingClient( + logger, + metricsUpdater, + validatorDataClient, + maxValidatorsPerTx, + yieldManagerContractClient, + YIELD_PROVIDER, + ); + + return { + client, + logger, + metricsUpdater, + validatorDataClient, + unstakeMock, + mocks: { + addValidatorPartialUnstakeAmount, + incrementValidatorExit, + getActiveValidatorsWithPendingWithdrawals, + getTotalPendingPartialWithdrawalsWei, + }, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("submitWithdrawalRequestsToFulfilAmount", () => { + it("logs an error when validator data is unavailable", async () => { + const { client, logger, unstakeMock, mocks } = setupClient(); + mocks.getActiveValidatorsWithPendingWithdrawals.mockResolvedValueOnce(undefined); + + await client.submitWithdrawalRequestsToFulfilAmount(10n); + + expect(logger.error).toHaveBeenCalledWith( + "submitWithdrawalRequestsToFulfilAmount failed to get sortedValidatorList with pending withdrawals", + ); + expect(mocks.getTotalPendingPartialWithdrawalsWei).not.toHaveBeenCalled(); + expect(unstakeMock).not.toHaveBeenCalled(); + }); + + it("skips submission when pending withdrawals already cover the amount", async () => { + const { client, unstakeMock, mocks } = setupClient(); + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 3n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 1n }), + ]; + mocks.getActiveValidatorsWithPendingWithdrawals.mockResolvedValueOnce(validators); + const amountWei = 4n * ONE_GWEI; + mocks.getTotalPendingPartialWithdrawalsWei.mockReturnValueOnce(amountWei); + + await client.submitWithdrawalRequestsToFulfilAmount(amountWei); + + expect(mocks.getTotalPendingPartialWithdrawalsWei).toHaveBeenCalledWith(validators); + expect(unstakeMock).not.toHaveBeenCalled(); + expect(mocks.addValidatorPartialUnstakeAmount).not.toHaveBeenCalled(); + }); + + it("submits partial withdrawal requests up to the configured limit and records metrics", async () => { + const maxValidatorsPerTx = 2; + const { client, unstakeMock, mocks } = setupClient(maxValidatorsPerTx); + + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 2n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 5n }), + createValidator({ publicKey: "validator-3", withdrawableAmount: 3n }), + ]; + mocks.getActiveValidatorsWithPendingWithdrawals.mockResolvedValueOnce(validators); + mocks.getTotalPendingPartialWithdrawalsWei.mockReturnValueOnce(0n); + const amountWei = 3n * ONE_GWEI; + + await client.submitWithdrawalRequestsToFulfilAmount(amountWei); + + expect(mocks.getTotalPendingPartialWithdrawalsWei).toHaveBeenCalledWith(validators); + expect(unstakeMock).toHaveBeenCalledTimes(1); + + const [, withdrawalRequests] = unstakeMock.mock.calls[0]; + expect(withdrawalRequests.pubkeys).toEqual([stringToHex("validator-1"), stringToHex("validator-2")]); + expect(withdrawalRequests.amountsGwei).toEqual([2n, 1n]); + + expect(mocks.addValidatorPartialUnstakeAmount).toHaveBeenNthCalledWith(1, stringToHex("validator-1"), 2); + expect(mocks.addValidatorPartialUnstakeAmount).toHaveBeenNthCalledWith(2, stringToHex("validator-2"), 1); + }); + }); + + describe("_submitPartialWithdrawalRequests (private)", () => { + it("returns max validator slots and skips unstake when the validator list is empty", async () => { + const maxValidatorsPerTx = 3; + const { client, unstakeMock } = setupClient(maxValidatorsPerTx); + + const remaining = await ( + client as unknown as { + _submitPartialWithdrawalRequests( + list: ValidatorBalanceWithPendingWithdrawal[], + amountWei: bigint, + ): Promise; + } + )._submitPartialWithdrawalRequests([], 1n * ONE_GWEI); + + expect(remaining).toBe(maxValidatorsPerTx); + expect(unstakeMock).not.toHaveBeenCalled(); + }); + + it("returns max validator slots when no validator has withdrawable balance", async () => { + const maxValidatorsPerTx = 3; + const { client, unstakeMock, mocks } = setupClient(maxValidatorsPerTx); + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 0n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 0n }), + ]; + + const remaining = await ( + client as unknown as { + _submitPartialWithdrawalRequests( + list: ValidatorBalanceWithPendingWithdrawal[], + amountWei: bigint, + ): Promise; + } + )._submitPartialWithdrawalRequests(validators, 5n * ONE_GWEI); + + expect(remaining).toBe(maxValidatorsPerTx); + expect(unstakeMock).not.toHaveBeenCalled(); + expect(mocks.addValidatorPartialUnstakeAmount).not.toHaveBeenCalled(); + }); + + it("stops building requests once the required amount is met even if the validator limit is not reached", async () => { + const maxValidatorsPerTx = 3; + const { client, unstakeMock, mocks } = setupClient(maxValidatorsPerTx); + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 5n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 5n }), + ]; + + const remaining = await ( + client as unknown as { + _submitPartialWithdrawalRequests( + list: ValidatorBalanceWithPendingWithdrawal[], + amountWei: bigint, + ): Promise; + } + )._submitPartialWithdrawalRequests(validators, 1n * ONE_GWEI); + + expect(remaining).toBe(maxValidatorsPerTx - 1); + expect(unstakeMock).toHaveBeenCalledTimes(1); + const [, requests] = unstakeMock.mock.calls[0]; + expect(requests.pubkeys).toEqual([stringToHex("validator-1")]); + expect(requests.amountsGwei).toEqual([1n]); + expect(mocks.addValidatorPartialUnstakeAmount).toHaveBeenCalledTimes(1); + expect(mocks.addValidatorPartialUnstakeAmount).toHaveBeenCalledWith(stringToHex("validator-1"), 1); + }); + }); + + describe("submitMaxAvailableWithdrawalRequests", () => { + it("logs an error when validator data is unavailable", async () => { + const { client, logger, unstakeMock, mocks } = setupClient(); + mocks.getActiveValidatorsWithPendingWithdrawals.mockResolvedValueOnce(undefined); + + await client.submitMaxAvailableWithdrawalRequests(); + + expect(logger.error).toHaveBeenCalledWith( + "submitMaxAvailableWithdrawalRequests failed to get sortedValidatorList with pending withdrawals", + ); + expect(unstakeMock).not.toHaveBeenCalled(); + }); + + it("submits partial withdrawals and validator exits using remaining slots", async () => { + const maxValidatorsPerTx = 3; + const { client, unstakeMock, mocks } = setupClient(maxValidatorsPerTx); + + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 2n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 3n }), + createValidator({ publicKey: "validator-3", withdrawableAmount: 0n }), + createValidator({ publicKey: "validator-4", withdrawableAmount: 0n }), + ]; + mocks.getActiveValidatorsWithPendingWithdrawals.mockResolvedValueOnce(validators); + + await client.submitMaxAvailableWithdrawalRequests(); + + expect(unstakeMock).toHaveBeenCalledTimes(2); + + const [, partialRequests] = unstakeMock.mock.calls[0]; + expect(partialRequests.pubkeys).toEqual([stringToHex("validator-1"), stringToHex("validator-2")]); + expect(partialRequests.amountsGwei).toEqual([2n, 3n]); + + const [, exitRequests] = unstakeMock.mock.calls[1]; + expect(exitRequests.pubkeys).toEqual([stringToHex("validator-3")]); + expect(exitRequests.amountsGwei).toEqual([0n]); + + expect(mocks.addValidatorPartialUnstakeAmount).toHaveBeenCalledTimes(2); + expect(mocks.addValidatorPartialUnstakeAmount).toHaveBeenNthCalledWith(1, stringToHex("validator-1"), 2); + expect(mocks.addValidatorPartialUnstakeAmount).toHaveBeenNthCalledWith(2, stringToHex("validator-2"), 3); + + expect(mocks.incrementValidatorExit).toHaveBeenCalledTimes(1); + expect(mocks.incrementValidatorExit).toHaveBeenCalledWith(stringToHex("validator-3")); + }); + }); + + describe("_submitValidatorExits (private)", () => { + it("returns immediately when no withdrawal slots remain", async () => { + const { client, unstakeMock, mocks } = setupClient(); + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 0n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 0n }), + ]; + + await ( + client as unknown as { + _submitValidatorExits( + list: ValidatorBalanceWithPendingWithdrawal[], + remainingWithdrawals: number, + ): Promise; + } + )._submitValidatorExits(validators, 0); + + expect(unstakeMock).not.toHaveBeenCalled(); + expect(mocks.incrementValidatorExit).not.toHaveBeenCalled(); + }); + + it("returns without unstaking when no validators qualify for exits", async () => { + const { client, unstakeMock, mocks } = setupClient(); + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 1n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 2n }), + ]; + + await ( + client as unknown as { + _submitValidatorExits( + list: ValidatorBalanceWithPendingWithdrawal[], + remainingWithdrawals: number, + ): Promise; + } + )._submitValidatorExits(validators, 2); + + expect(unstakeMock).not.toHaveBeenCalled(); + expect(mocks.incrementValidatorExit).not.toHaveBeenCalled(); + }); + + it("stops adding exits when reaching the maximum per-transaction limit even with remaining capacity", async () => { + const maxValidatorsPerTx = 3; + const { client, unstakeMock, mocks } = setupClient(maxValidatorsPerTx); + const validators = [ + createValidator({ publicKey: "validator-1", withdrawableAmount: 0n }), + createValidator({ publicKey: "validator-2", withdrawableAmount: 0n }), + createValidator({ publicKey: "validator-3", withdrawableAmount: 0n }), + createValidator({ publicKey: "validator-4", withdrawableAmount: 0n }), + ]; + + await ( + client as unknown as { + _submitValidatorExits( + list: ValidatorBalanceWithPendingWithdrawal[], + remainingWithdrawals: number, + ): Promise; + } + )._submitValidatorExits(validators, maxValidatorsPerTx + 1); + + expect(unstakeMock).toHaveBeenCalledTimes(1); + const [, requests] = unstakeMock.mock.calls[0]; + expect(requests.pubkeys).toEqual([ + stringToHex("validator-1"), + stringToHex("validator-2"), + stringToHex("validator-3"), + ]); + expect(mocks.incrementValidatorExit).toHaveBeenCalledTimes(3); + expect(mocks.incrementValidatorExit).toHaveBeenNthCalledWith(1, stringToHex("validator-1")); + expect(mocks.incrementValidatorExit).toHaveBeenNthCalledWith(2, stringToHex("validator-2")); + expect(mocks.incrementValidatorExit).toHaveBeenNthCalledWith(3, stringToHex("validator-3")); + }); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/__tests__/ConsensysStakingApiClient.test.ts b/native-yield-operations/automation-service/src/clients/__tests__/ConsensysStakingApiClient.test.ts new file mode 100644 index 0000000000..99b324304b --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/__tests__/ConsensysStakingApiClient.test.ts @@ -0,0 +1,305 @@ +import { jest } from "@jest/globals"; +import { ConsensysStakingApiClient } from "../ConsensysStakingApiClient.js"; +import { + IBeaconNodeAPIClient, + ILogger, + IRetryService, + ONE_GWEI, + PendingPartialWithdrawal, + safeSub, +} from "@consensys/linea-shared-utils"; +import type { ApolloClient } from "@apollo/client"; +import { ALL_VALIDATORS_BY_LARGEST_BALANCE_QUERY } from "../../core/entities/graphql/ActiveValidatorsByLargestBalance.js"; +import type { ValidatorBalance, ValidatorBalanceWithPendingWithdrawal } from "../../core/entities/ValidatorBalance.js"; + +const createLoggerMock = (): jest.Mocked => ({ + name: "test-logger", + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +}); + +const createClient = () => { + const logger = createLoggerMock(); + const retryMock = jest.fn(async (fn: () => Promise, _timeoutMs?: number) => fn()); + const retryService = { retry: retryMock } as unknown as jest.Mocked; + + const apolloQueryMock = jest.fn() as jest.MockedFunction< + (params: { query: unknown }) => Promise<{ data?: unknown; error?: unknown }> + >; + const apolloClient = { query: apolloQueryMock } as unknown as ApolloClient; + + const pendingWithdrawalsMock = jest.fn() as jest.MockedFunction; + const beaconNodeApiClient = { + getPendingPartialWithdrawals: pendingWithdrawalsMock, + } as unknown as jest.Mocked; + + const client = new ConsensysStakingApiClient(logger, retryService, apolloClient, beaconNodeApiClient); + + return { + client, + logger, + retryMock, + apolloQueryMock, + pendingWithdrawalsMock, + }; +}; + +describe("ConsensysStakingApiClient", () => { + describe("getActiveValidators", () => { + it("logs and returns undefined when the query returns an error", async () => { + const { client, logger, retryMock } = createClient(); + const queryError = new Error("graphql failure"); + + retryMock.mockImplementationOnce(async (_fn, _timeout) => ({ + data: undefined, + error: queryError, + })); + + const result = await client.getActiveValidators(); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith("getActiveValidators error:", { error: queryError }); + }); + + it("logs and returns undefined when the query response lacks data", async () => { + const { client, logger, retryMock } = createClient(); + + retryMock.mockImplementationOnce(async (_fn, _timeout) => ({ + data: undefined, + error: undefined, + })); + + const result = await client.getActiveValidators(); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith("getActiveValidators data undefined"); + }); + + it("returns the validator list and logs success when the query succeeds", async () => { + const { client, logger, retryMock, apolloQueryMock } = createClient(); + const validators: ValidatorBalance[] = [ + { balance: 32n, effectiveBalance: 32n, publicKey: "validator-1", validatorIndex: 1n }, + ]; + + apolloQueryMock.mockResolvedValue({ + data: { allValidators: { nodes: validators } }, + error: undefined, + }); + + const result = await client.getActiveValidators(); + + expect(result).toEqual(validators); + expect(retryMock).toHaveBeenCalledTimes(1); + expect(apolloQueryMock).toHaveBeenCalledWith({ query: ALL_VALIDATORS_BY_LARGEST_BALANCE_QUERY }); + expect(logger.debug).toHaveBeenCalledWith("getActiveValidators succeded", { resp: validators }); + }); + }); + + describe("getActiveValidatorsWithPendingWithdrawals", () => { + it("returns undefined when active validator data is unavailable", async () => { + const { client, pendingWithdrawalsMock } = createClient(); + const getActiveValidatorsSpy = jest.spyOn(client, "getActiveValidators").mockResolvedValueOnce(undefined); + pendingWithdrawalsMock.mockResolvedValueOnce([]); + + const result = await client.getActiveValidatorsWithPendingWithdrawals(); + + expect(result).toBeUndefined(); + expect(pendingWithdrawalsMock).toHaveBeenCalledTimes(1); + getActiveValidatorsSpy.mockRestore(); + }); + + it("returns undefined when pending withdrawals cannot be fetched", async () => { + const { client, pendingWithdrawalsMock } = createClient(); + const validators: ValidatorBalance[] = [ + { balance: 32n, effectiveBalance: 32n, publicKey: "validator-1", validatorIndex: 1n }, + ]; + const getActiveValidatorsSpy = jest.spyOn(client, "getActiveValidators").mockResolvedValueOnce(validators); + pendingWithdrawalsMock.mockResolvedValueOnce(undefined); + + const result = await client.getActiveValidatorsWithPendingWithdrawals(); + + expect(result).toBeUndefined(); + expect(getActiveValidatorsSpy).toHaveBeenCalledTimes(1); + getActiveValidatorsSpy.mockRestore(); + }); + + it("aggregates pending withdrawals, computes withdrawable amounts, and sorts descending", async () => { + const { client, logger, pendingWithdrawalsMock } = createClient(); + + const validatorA: ValidatorBalance = { + balance: 40n * ONE_GWEI, + effectiveBalance: 32n * ONE_GWEI, + publicKey: "validator-a", + validatorIndex: 1n, + }; + + const validatorB: ValidatorBalance = { + balance: 34n * ONE_GWEI, + effectiveBalance: 32n * ONE_GWEI, + publicKey: "validator-b", + validatorIndex: 2n, + }; + + const getActiveValidatorsSpy = jest + .spyOn(client, "getActiveValidators") + .mockResolvedValueOnce([validatorB, validatorA]); + + const pendingWithdrawals: PendingPartialWithdrawal[] = [ + { validator_index: 1, amount: 2n * ONE_GWEI, withdrawable_epoch: 0 }, + { validator_index: 1, amount: 3n * ONE_GWEI, withdrawable_epoch: 1 }, + { validator_index: 2, amount: 1n * ONE_GWEI, withdrawable_epoch: 0 }, + ]; + pendingWithdrawalsMock.mockResolvedValueOnce(pendingWithdrawals); + + const result = await client.getActiveValidatorsWithPendingWithdrawals(); + + const expectedValidatorA: ValidatorBalanceWithPendingWithdrawal = { + ...validatorA, + pendingWithdrawalAmount: 5n * ONE_GWEI, + withdrawableAmount: safeSub(safeSub(validatorA.balance, 5n * ONE_GWEI), ONE_GWEI * 32n), + }; + const expectedValidatorB: ValidatorBalanceWithPendingWithdrawal = { + ...validatorB, + pendingWithdrawalAmount: 1n * ONE_GWEI, + withdrawableAmount: safeSub(safeSub(validatorB.balance, 1n * ONE_GWEI), ONE_GWEI * 32n), + }; + + expect(result).toEqual([expectedValidatorA, expectedValidatorB]); + expect(logger.debug).toHaveBeenCalledWith("getActiveValidatorsWithPendingWithdrawals return val", { + joined: [expectedValidatorA, expectedValidatorB], + }); + + getActiveValidatorsSpy.mockRestore(); + }); + + it("keeps already sorted validators when the first entry has the largest withdrawable amount", async () => { + const { client, pendingWithdrawalsMock } = createClient(); + + const validatorHigh: ValidatorBalance = { + balance: 45n * ONE_GWEI, + effectiveBalance: 32n * ONE_GWEI, + publicKey: "validator-high", + validatorIndex: 10n, + }; + + const validatorLow: ValidatorBalance = { + balance: 40n * ONE_GWEI, + effectiveBalance: 32n * ONE_GWEI, + publicKey: "validator-low", + validatorIndex: 11n, + }; + + const getActiveValidatorsSpy = jest + .spyOn(client, "getActiveValidators") + .mockResolvedValueOnce([validatorHigh, validatorLow]); + + pendingWithdrawalsMock.mockResolvedValueOnce([ + { + validator_index: Number(validatorHigh.validatorIndex), + amount: 2n * ONE_GWEI, + withdrawable_epoch: 0, + }, + { + validator_index: Number(validatorLow.validatorIndex), + amount: 6n * ONE_GWEI, + withdrawable_epoch: 0, + }, + ]); + + const result = await client.getActiveValidatorsWithPendingWithdrawals(); + + const expectedHigh: ValidatorBalanceWithPendingWithdrawal = { + ...validatorHigh, + pendingWithdrawalAmount: 2n * ONE_GWEI, + withdrawableAmount: safeSub(safeSub(validatorHigh.balance, 2n * ONE_GWEI), ONE_GWEI * 32n), + }; + const expectedLow: ValidatorBalanceWithPendingWithdrawal = { + ...validatorLow, + pendingWithdrawalAmount: 6n * ONE_GWEI, + withdrawableAmount: safeSub(safeSub(validatorLow.balance, 6n * ONE_GWEI), ONE_GWEI * 32n), + }; + + expect(result).toEqual([expectedHigh, expectedLow]); + + getActiveValidatorsSpy.mockRestore(); + }); + + it("handles validators with equal withdrawable amounts", async () => { + const { client, pendingWithdrawalsMock } = createClient(); + + const validatorEqualA: ValidatorBalance = { + balance: 36n * ONE_GWEI, + effectiveBalance: 32n * ONE_GWEI, + publicKey: "validator-equal-a", + validatorIndex: 20n, + }; + const validatorEqualB: ValidatorBalance = { + balance: 32n * ONE_GWEI, + effectiveBalance: 32n * ONE_GWEI, + publicKey: "validator-equal-b", + validatorIndex: 21n, + }; + + const getActiveValidatorsSpy = jest + .spyOn(client, "getActiveValidators") + .mockResolvedValueOnce([validatorEqualA, validatorEqualB]); + + pendingWithdrawalsMock.mockResolvedValueOnce([ + { + validator_index: Number(validatorEqualA.validatorIndex), + amount: 4n * ONE_GWEI, + withdrawable_epoch: 0, + }, + ]); + + const result = await client.getActiveValidatorsWithPendingWithdrawals(); + + const expectedEqualA: ValidatorBalanceWithPendingWithdrawal = { + ...validatorEqualA, + pendingWithdrawalAmount: 4n * ONE_GWEI, + withdrawableAmount: safeSub(safeSub(validatorEqualA.balance, 4n * ONE_GWEI), ONE_GWEI * 32n), + }; + + const expectedEqualB: ValidatorBalanceWithPendingWithdrawal = { + ...validatorEqualB, + pendingWithdrawalAmount: 0n, + withdrawableAmount: safeSub(safeSub(validatorEqualB.balance, 0n), ONE_GWEI * 32n), + }; + + expect(result).toEqual(expect.arrayContaining([expectedEqualA, expectedEqualB])); + + getActiveValidatorsSpy.mockRestore(); + }); + }); + + describe("getTotalPendingPartialWithdrawalsWei", () => { + it("returns the total pending withdrawals converted to wei and logs it", () => { + const { client, logger } = createClient(); + const validators: ValidatorBalanceWithPendingWithdrawal[] = [ + { + balance: 32n, + effectiveBalance: 32n, + publicKey: "validator-1", + validatorIndex: 1n, + pendingWithdrawalAmount: 3n, + withdrawableAmount: 0n, + }, + { + balance: 32n, + effectiveBalance: 32n, + publicKey: "validator-2", + validatorIndex: 2n, + pendingWithdrawalAmount: 1n, + withdrawableAmount: 0n, + }, + ]; + + const totalWei = client.getTotalPendingPartialWithdrawalsWei(validators); + + expect(totalWei).toBe(4n * ONE_GWEI); + expect(logger.debug).toHaveBeenCalledWith("getTotalPendingPartialWithdrawalsWei totalWei=4000000000"); + }); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/__tests__/LidoAccountingReportClient.test.ts b/native-yield-operations/automation-service/src/clients/__tests__/LidoAccountingReportClient.test.ts new file mode 100644 index 0000000000..32377be8d9 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/__tests__/LidoAccountingReportClient.test.ts @@ -0,0 +1,136 @@ +import { jest } from "@jest/globals"; +import type { ILogger, IRetryService } from "@consensys/linea-shared-utils"; +import type { ILazyOracle, UpdateVaultDataParams } from "../../core/clients/contracts/ILazyOracle.js"; +import type { Address, Hex, TransactionReceipt } from "viem"; +import { LidoAccountingReportClient } from "../LidoAccountingReportClient.js"; + +jest.mock("@lidofinance/lsv-cli/dist/utils/report/report-proof.js", () => ({ + getReportProofByVault: jest.fn(), +})); + +import { getReportProofByVault } from "@lidofinance/lsv-cli/dist/utils/report/report-proof.js"; + +const mockedGetReportProofByVault = getReportProofByVault as jest.MockedFunction; + +type LazyOracleMock = jest.Mocked>; + +describe("LidoAccountingReportClient", () => { + const vault = "0x1111111111111111111111111111111111111111" as Address; + const ipfsGatewayUrl = "https://ipfs.example.com"; + + let logger: jest.Mocked; + let retryService: jest.Mocked; + let retryMock: jest.Mock; + let lazyOracle: LazyOracleMock; + let client: LidoAccountingReportClient; + + const reportData = { + timestamp: 1n, + refSlot: 2n, + treeRoot: "0x1234" as Hex, + reportCid: "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }; + + const reportProof = { + data: { + totalValueWei: "1000", + fee: "200", + liabilityShares: "300", + maxLiabilityShares: "400", + slashingReserve: "500", + }, + proof: ["0xabc"] as Hex[], + } as const; + + const createLoggerMock = (): jest.Mocked => + ({ + name: "test-logger", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }) as unknown as jest.Mocked; + + const createRetryServiceMock = (): jest.Mocked => { + const mock = jest.fn((fn: () => Promise) => fn()); + retryMock = mock as unknown as jest.Mock; + return { + retry: mock as unknown as IRetryService["retry"], + } as unknown as jest.Mocked; + }; + + const createLazyOracleMock = (): LazyOracleMock => + ({ + latestReportData: jest.fn(), + updateVaultData: jest.fn(), + }) as unknown as LazyOracleMock; + + beforeEach(() => { + jest.clearAllMocks(); + logger = createLoggerMock(); + retryService = createRetryServiceMock(); + lazyOracle = createLazyOracleMock(); + lazyOracle.latestReportData.mockResolvedValue(reportData); + mockedGetReportProofByVault.mockResolvedValue(reportProof as any); + + client = new LidoAccountingReportClient(logger, retryService, lazyOracle, ipfsGatewayUrl); + }); + + const expectReportParams = (params: UpdateVaultDataParams) => { + expect(params).toEqual({ + vault, + totalValue: 1000n, + cumulativeLidoFees: 200n, + liabilityShares: 300n, + maxLiabilityShares: 400n, + slashingReserve: 500n, + proof: reportProof.proof, + }); + }; + + it("fetches the latest report, converts values to bigint, logs, and caches the params", async () => { + const params = await client.getLatestSubmitVaultReportParams(vault); + + expectReportParams(params); + expect(retryService.retry).toHaveBeenCalledTimes(1); + expect(mockedGetReportProofByVault).toHaveBeenCalledWith({ + vault, + cid: reportData.reportCid, + gateway: ipfsGatewayUrl, + }); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining(`getLatestSubmitVaultReportParams for vault=${vault}`), + ); + }); + + it("submits the latest vault report using cached params", async () => { + const params = await client.getLatestSubmitVaultReportParams(vault); + lazyOracle.latestReportData.mockClear(); + retryMock.mockClear(); + + await client.submitLatestVaultReport(vault); + + expect(lazyOracle.updateVaultData).toHaveBeenCalledWith(params); + expect(lazyOracle.latestReportData).not.toHaveBeenCalled(); + }); + + it("submits the latest vault report fetching params when cache is empty", async () => { + lazyOracle.updateVaultData.mockResolvedValue(undefined as unknown as TransactionReceipt); + + await client.submitLatestVaultReport(vault); + + expect(lazyOracle.latestReportData).toHaveBeenCalledTimes(1); + expect(mockedGetReportProofByVault).toHaveBeenCalledTimes(1); + expect(lazyOracle.updateVaultData).toHaveBeenCalledWith( + expect.objectContaining({ + vault, + totalValue: 1000n, + cumulativeLidoFees: 200n, + liabilityShares: 300n, + maxLiabilityShares: 400n, + slashingReserve: 500n, + proof: reportProof.proof, + }), + ); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/contracts/LazyOracleContractClient.ts b/native-yield-operations/automation-service/src/clients/contracts/LazyOracleContractClient.ts new file mode 100644 index 0000000000..aa478b810e --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/LazyOracleContractClient.ts @@ -0,0 +1,214 @@ +import { IBlockchainClient, ILogger } from "@consensys/linea-shared-utils"; +import { + Address, + encodeFunctionData, + getContract, + GetContractReturnType, + InvalidInputRpcError, + PublicClient, + TransactionReceipt, + WatchContractEventReturnType, +} from "viem"; +import { LazyOracleABI } from "../../core/abis/LazyOracle.js"; +import { + ILazyOracle, + UpdateVaultDataParams, + LazyOracleReportData, + WaitForVaultReportDataEventResult, + VaultReportResult, +} from "../../core/clients/contracts/ILazyOracle.js"; +import { OperationTrigger } from "../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; + +/** + * Client for interacting with LazyOracle smart contracts. + * Provides methods for reading report data, updating vault data, simulating transactions, + * and waiting for VaultsReportDataUpdated events with timeout handling. + */ +export class LazyOracleContractClient implements ILazyOracle { + private readonly contract: GetContractReturnType; + /** + * Creates a new LazyOracleContractClient instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {IBlockchainClient} contractClientLibrary - Blockchain client for sending transactions. + * @param {Address} contractAddress - The address of the LazyOracle contract. + * @param {number} pollIntervalMs - Polling interval in milliseconds for event watching. + * @param {number} eventWatchTimeoutMs - Timeout in milliseconds for waiting for events. + */ + constructor( + private readonly logger: ILogger, + private readonly contractClientLibrary: IBlockchainClient, + private readonly contractAddress: Address, + private readonly pollIntervalMs: number, + private readonly eventWatchTimeoutMs: number, + ) { + this.contract = getContract({ + abi: LazyOracleABI, + address: contractAddress, + client: contractClientLibrary.getBlockchainClient(), + }); + } + + /** + * Gets the address of the LazyOracle contract. + * + * @returns {Address} The contract address. + */ + getAddress(): Address { + return this.contractAddress; + } + + /** + * Gets the viem contract instance. + * + * @returns {GetContractReturnType} The contract instance. + */ + getContract(): GetContractReturnType { + return this.contract; + } + + /** + * Retrieves the latest report data from the LazyOracle contract. + * + * @returns {Promise} The latest report data containing timestamp, refSlot, treeRoot, and reportCid. + */ + async latestReportData(): Promise { + const [timestamp, refSlot, treeRoot, reportCid] = await this.contract.read.latestReportData(); + const returnVal = { + timestamp, + refSlot, + treeRoot, + reportCid, + }; + this.logger.debug("latestReportData", { returnVal }); + return returnVal; + } + + /** + * Updates vault data in the LazyOracle contract by submitting a transaction. + * Encodes the function call and sends a signed transaction via the blockchain client. + * + * @param {UpdateVaultDataParams} params - The vault data parameters including vault address, totalValue, cumulativeLidoFees, liabilityShares, maxLiabilityShares, slashingReserve, and proof. + * @returns {Promise} The transaction receipt if successful. + */ + async updateVaultData(params: UpdateVaultDataParams): Promise { + const { vault, totalValue, cumulativeLidoFees, liabilityShares, maxLiabilityShares, slashingReserve, proof } = + params; + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "updateVaultData", + args: [vault, totalValue, cumulativeLidoFees, liabilityShares, maxLiabilityShares, slashingReserve, proof], + }); + this.logger.debug(`updateVaultData started`, { params }); + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info(`updateVaultData succeeded, txHash=${txReceipt.transactionHash}`, { params }); + return txReceipt; + } + + /** + * Waits for a VaultsReportDataUpdated event from the LazyOracle contract. + * Creates placeholder Promise resolve fn. Creates Promise that we will return. + * Sets placeholder resolve fn, to resolve fn here - decouples Promise creation from resolve fn creation. + * Creates placeholders for unwatch fns. Starts timeout and event watching. + * Filters out removed logs (could be due to reorg) and logs that don't fit our interface. + * On success, cleans up and returns. Tolerates errors - we don't want to interrupt L1MessageService<->YieldProvider rebalancing. + * + * @returns {Promise} The event result containing the operation trigger and report data, or TIMEOUT if the event is not detected within the timeout period. + */ + async waitForVaultsReportDataUpdatedEvent(): Promise { + // Create placeholder Promise resolve fn + let resolvePromise: (value: WaitForVaultReportDataEventResult) => void; + // Create Promise that we will return + // Set placeholder resolve fn, to resolve fn here - decouple Promise creation from resolve fn creation + const waitForEvent = new Promise((resolve) => { + resolvePromise = resolve; + }); + + // Create placeholders for unwatch fns + let unwatchEvent: WatchContractEventReturnType | undefined; + let unwatchTimeout: (() => void) | undefined; + + const cleanup = () => { + if (unwatchTimeout) { + unwatchTimeout(); + unwatchTimeout = undefined; + } + if (unwatchEvent) { + unwatchEvent(); + unwatchEvent = undefined; + } + }; + + // Start timeout + this.logger.info(`waitForVaultsReportDataUpdatedEvent started with timeout=${this.eventWatchTimeoutMs}ms`); + const timeoutId = setTimeout(() => { + cleanup(); + this.logger.info(`waitForVaultsReportDataUpdatedEvent timed out after timeout=${this.eventWatchTimeoutMs}ms`); + resolvePromise({ result: OperationTrigger.TIMEOUT }); + }, this.eventWatchTimeoutMs); + unwatchTimeout = () => clearTimeout(timeoutId); + + // Start event watching + unwatchEvent = this.contractClientLibrary.getBlockchainClient().watchContractEvent({ + address: this.contractAddress, + abi: this.contract.abi, + eventName: "VaultsReportDataUpdated", + pollingInterval: this.pollIntervalMs, + onLogs: (logs) => { + // Filter out removed logs - could be due to reorg + const nonRemovedLogs = logs.filter((log) => !log.removed); + if (nonRemovedLogs.length === 0) { + this.logger.warn("waitForVaultsReportDataUpdatedEvent: Dropped VaultsReportDataUpdated event"); + return; + } + + if (nonRemovedLogs.length !== logs.length) { + this.logger.debug("waitForVaultsReportDataUpdatedEvent: Ignored removed reorg logs", { logs }); + } + + // Filter out logs that don't fit our interface + const firstEvent = nonRemovedLogs[0]; + if ( + firstEvent.args?.timestamp === undefined || + firstEvent.args?.refSlot === undefined || + firstEvent.args?.root === undefined || + firstEvent.args?.cid === undefined + ) { + return; + } + + // Success -> cleanup and return + cleanup(); + const result: VaultReportResult = { + result: OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT, + txHash: firstEvent.transactionHash, + report: { + timestamp: firstEvent.args?.timestamp, + refSlot: firstEvent.args?.refSlot, + treeRoot: firstEvent.args?.root, + reportCid: firstEvent.args?.cid, + }, + }; + this.logger.info("waitForVaultsReportDataUpdatedEvent detected", { + result, + }); + resolvePromise(result); + }, + onError: (error) => { + // This means a filter has expired and Viem will handle renewing it - https://github.com/wevm/viem/blob/003b231361f223487aa3e6a67a1e5258e8fe758b/src/actions/public/watchContractEvent.ts#L260-L265 + // A filter is a RPC-provider managed subscription for blockchain change notifications - https://chainstack.readme.io/reference/ethereum-filters-rpc-methods + if (error instanceof InvalidInputRpcError) { + this.logger.warn("waitForVaultsReportDataUpdatedEvent: Filter expired, will be recreated by Viem framework", { + error, + }); + return; + } + + // Tolerate errors, we don't want to interrupt L1MessageService<->YieldProvider rebalancing + this.logger.error("waitForVaultsReportDataUpdatedEvent error", { error }); + }, + }); + + return await waitForEvent; + } +} diff --git a/native-yield-operations/automation-service/src/clients/contracts/LineaRollupYieldExtensionContractClient.ts b/native-yield-operations/automation-service/src/clients/contracts/LineaRollupYieldExtensionContractClient.ts new file mode 100644 index 0000000000..4f3e05fef4 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/LineaRollupYieldExtensionContractClient.ts @@ -0,0 +1,78 @@ +import { IBlockchainClient, ILogger } from "@consensys/linea-shared-utils"; +import { + Address, + encodeFunctionData, + getContract, + GetContractReturnType, + PublicClient, + TransactionReceipt, +} from "viem"; +import { LineaRollupYieldExtensionABI } from "../../core/abis/LineaRollupYieldExtension.js"; +import { ILineaRollupYieldExtension } from "../../core/clients/contracts/ILineaRollupYieldExtension.js"; + +/** + * Client for interacting with LineaRollupYieldExtension smart contracts. + * Provides methods for transferring funds for native yield operations on the Linea rollup. + */ +export class LineaRollupYieldExtensionContractClient implements ILineaRollupYieldExtension { + private readonly contract: GetContractReturnType; + + /** + * Creates a new LineaRollupYieldExtensionContractClient instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {IBlockchainClient} contractClientLibrary - Blockchain client for sending transactions. + * @param {Address} contractAddress - The address of the LineaRollupYieldExtension contract. + */ + constructor( + private readonly logger: ILogger, + private readonly contractClientLibrary: IBlockchainClient, + private readonly contractAddress: Address, + ) { + this.contract = getContract({ + abi: LineaRollupYieldExtensionABI, + address: contractAddress, + client: contractClientLibrary.getBlockchainClient(), + }); + } + + /** + * Gets the address of the LineaRollupYieldExtension contract. + * + * @returns {Address} The contract address. + */ + getAddress(): Address { + return this.contractAddress; + } + + /** + * Gets the viem contract instance. + * + * @returns {GetContractReturnType} The contract instance. + */ + getContract(): GetContractReturnType { + return this.contract; + } + + /** + * Transfers funds for native yield operations on the Linea rollup. + * Encodes the function call and sends a signed transaction via the blockchain client. + * + * @param {bigint} amount - The amount to transfer in wei. + * @returns {Promise} The transaction receipt if successful. + */ + async transferFundsForNativeYield(amount: bigint): Promise { + this.logger.debug(`transferFundsForNativeYield started, amount=${amount.toString()}`); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "transferFundsForNativeYield", + args: [amount], + }); + + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info( + `transferFundsForNativeYield succeeded, amount=${amount.toString()}, txHash=${txReceipt.transactionHash}`, + ); + return txReceipt; + } +} diff --git a/native-yield-operations/automation-service/src/clients/contracts/VaultHubContractClient.ts b/native-yield-operations/automation-service/src/clients/contracts/VaultHubContractClient.ts new file mode 100644 index 0000000000..20be305cfa --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/VaultHubContractClient.ts @@ -0,0 +1,87 @@ +import { IBlockchainClient } from "@consensys/linea-shared-utils"; +import { Address, getContract, GetContractReturnType, parseEventLogs, PublicClient, TransactionReceipt } from "viem"; +import { IVaultHub } from "../../core/clients/contracts/IVaultHub.js"; +import { VaultHubABI } from "../../core/abis/VaultHub.js"; + +/** + * Client for interacting with VaultHub smart contracts. + * Provides methods for extracting payment information from transaction receipts by decoding contract events. + */ +export class VaultHubContractClient implements IVaultHub { + private readonly contract: GetContractReturnType; + + /** + * Creates a new VaultHubContractClient instance. + * + * @param {IBlockchainClient} contractClientLibrary - Blockchain client for reading contract data. + * @param {Address} contractAddress - The address of the VaultHub contract. + */ + constructor( + private readonly contractClientLibrary: IBlockchainClient, + private readonly contractAddress: Address, + ) { + this.contract = getContract({ + abi: VaultHubABI, + address: contractAddress, + client: this.contractClientLibrary.getBlockchainClient(), + }); + } + + /** + * Gets the address of the VaultHub contract. + * + * @returns {Address} The contract address. + */ + getAddress(): Address { + return this.contractAddress; + } + + /** + * Gets the viem contract instance. + * + * @returns {GetContractReturnType} The contract instance. + */ + getContract(): GetContractReturnType { + return this.contract; + } + + /** + * Extracts the liability payment amount from a transaction receipt by decoding VaultRebalanced events. + * Only decodes logs emitted by this contract. Skips unrelated logs (from the same contract or different ABIs). + * If event not found, returns 0n. + * + * @param {TransactionReceipt} txReceipt - The transaction receipt to search for VaultRebalanced events. + * @returns {bigint} The etherWithdrawn amount from the VaultRebalanced event, or 0n if the event is not found. + */ + getLiabilityPaymentFromTxReceipt(txReceipt: TransactionReceipt): bigint { + const logs = parseEventLogs({ + abi: this.contract.abi, + eventName: "VaultRebalanced", + logs: txReceipt.logs, + }); + + const etherWithdrawn = + logs.find((log) => log.address.toLowerCase() === this.contractAddress.toLowerCase())?.args.etherWithdrawn ?? 0n; + return etherWithdrawn; + } + + /** + * Extracts the Lido fee payment amount from a transaction receipt by decoding LidoFeesSettled events. + * Only decodes logs emitted by this contract. Skips unrelated logs (from the same contract or different ABIs). + * If event not found, returns 0n. + * + * @param {TransactionReceipt} txReceipt - The transaction receipt to search for LidoFeesSettled events. + * @returns {bigint} The transferred amount from the LidoFeesSettled event, or 0n if the event is not found. + */ + getLidoFeePaymentFromTxReceipt(txReceipt: TransactionReceipt): bigint { + const logs = parseEventLogs({ + abi: this.contract.abi, + eventName: "LidoFeesSettled", + logs: txReceipt.logs, + }); + + const transferred = + logs.find((log) => log.address.toLowerCase() === this.contractAddress.toLowerCase())?.args.transferred ?? 0n; + return transferred; + } +} diff --git a/native-yield-operations/automation-service/src/clients/contracts/YieldManagerContractClient.ts b/native-yield-operations/automation-service/src/clients/contracts/YieldManagerContractClient.ts new file mode 100644 index 0000000000..6430d0e85a --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/YieldManagerContractClient.ts @@ -0,0 +1,581 @@ +import { IBlockchainClient, ILogger } from "@consensys/linea-shared-utils"; +import { + Address, + encodeAbiParameters, + encodeFunctionData, + getContract, + GetContractReturnType, + Hex, + parseEventLogs, + PublicClient, + TransactionReceipt, +} from "viem"; +import { + LidoStakingVaultWithdrawalParams, + WithdrawalRequests, +} from "../../core/entities/LidoStakingVaultWithdrawalParams.js"; +import { RebalanceRequirement, RebalanceDirection } from "../../core/entities/RebalanceRequirement.js"; + +import { YieldManagerABI } from "../../core/abis/YieldManager.js"; +import { IYieldManager, YieldProviderData } from "../../core/clients/contracts/IYieldManager.js"; +import { ONE_ETHER } from "@consensys/linea-shared-utils"; +import { YieldReport } from "../../core/entities/YieldReport.js"; +import { StakingVaultABI } from "../../core/abis/StakingVault.js"; +import { WithdrawalEvent } from "../../core/entities/WithdrawalEvent.js"; + +/** + * Client for interacting with YieldManager smart contracts. + * Provides comprehensive methods for managing yield providers, staking operations, withdrawals, + * rebalancing, ossification, and extracting event data from transaction receipts. + */ +export class YieldManagerContractClient implements IYieldManager { + private readonly contract: GetContractReturnType; + + /** + * Creates a new YieldManagerContractClient instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {IBlockchainClient} contractClientLibrary - Blockchain client for sending transactions and reading contract data. + * @param {Address} contractAddress - The address of the YieldManager contract. + * @param {number} rebalanceToleranceBps - Rebalance tolerance in basis points (for determining when rebalancing is required). + * @param {bigint} minWithdrawalThresholdEth - Minimum withdrawal threshold in ETH (for threshold-based withdrawal operations). + */ + constructor( + private readonly logger: ILogger, + private readonly contractClientLibrary: IBlockchainClient, + private readonly contractAddress: Address, + private readonly rebalanceToleranceBps: number, + private readonly minWithdrawalThresholdEth: bigint, + ) { + this.contract = getContract({ + abi: YieldManagerABI, + address: contractAddress, + client: this.contractClientLibrary.getBlockchainClient(), + }); + } + + /** + * Gets the address of the YieldManager contract. + * + * @returns {Address} The contract address. + */ + getAddress(): Address { + return this.contractAddress; + } + + /** + * Gets the viem contract instance. + * + * @returns {GetContractReturnType} The contract instance. + */ + getContract(): GetContractReturnType { + return this.contract; + } + + /** + * Gets the L1 Message Service address from the YieldManager contract. + * + * @returns {Promise
} The L1 Message Service address. + */ + async L1_MESSAGE_SERVICE(): Promise
{ + return this.contract.read.L1_MESSAGE_SERVICE(); + } + + /** + * Gets the total system balance from the YieldManager contract. + * + * @returns {Promise} The total system balance in wei. + */ + async getTotalSystemBalance(): Promise { + return this.contract.read.getTotalSystemBalance(); + } + + /** + * Gets the effective target withdrawal reserve from the YieldManager contract. + * + * @returns {Promise} The effective target withdrawal reserve in wei. + */ + async getEffectiveTargetWithdrawalReserve(): Promise { + return this.contract.read.getEffectiveTargetWithdrawalReserve(); + } + + /** + * Gets the target reserve deficit from the YieldManager contract. + * + * @returns {Promise} The target reserve deficit in wei. + */ + async getTargetReserveDeficit(): Promise { + return this.contract.read.getTargetReserveDeficit(); + } + + /** + * Checks if staking is paused for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address to check. + * @returns {Promise} True if staking is paused, false otherwise. + */ + async isStakingPaused(yieldProvider: Address): Promise { + return this.contract.read.isStakingPaused([yieldProvider]); + } + + /** + * Checks if ossification has been initiated for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address to check. + * @returns {Promise} True if ossification is initiated, false otherwise. + */ + async isOssificationInitiated(yieldProvider: Address): Promise { + return this.contract.read.isOssificationInitiated([yieldProvider]); + } + + /** + * Checks if a yield provider is ossified. + * + * @param {Address} yieldProvider - The yield provider address to check. + * @returns {Promise} True if the yield provider is ossified, false otherwise. + */ + async isOssified(yieldProvider: Address): Promise { + return this.contract.read.isOssified([yieldProvider]); + } + + /** + * Gets the withdrawable value for a yield provider using simulation. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The withdrawable value in wei. + */ + async withdrawableValue(yieldProvider: Address): Promise { + const { result } = await this.contract.simulate.withdrawableValue([yieldProvider]); + return result; + } + + /** + * Gets yield provider data from the YieldManager contract. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The yield provider data including entrypoints and configuration. + */ + async getYieldProviderData(yieldProvider: Address): Promise { + return this.contract.read.getYieldProviderData([yieldProvider]); + } + + /** + * Funds a yield provider by sending a transaction to the YieldManager contract. + * + * @param {Address} yieldProvider - The yield provider address to fund. + * @param {bigint} amount - The amount to fund in wei. + * @returns {Promise} The transaction receipt if successful. + */ + async fundYieldProvider(yieldProvider: Address, amount: bigint): Promise { + this.logger.debug(`fundYieldProvider started, yieldProvider=${yieldProvider}, amount=${amount.toString()}`); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "fundYieldProvider", + args: [yieldProvider, amount], + }); + + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info( + `fundYieldProvider succeeded, yieldProvider=${yieldProvider}, amount=${amount.toString()}, txHash=${txReceipt.transactionHash}`, + ); + return txReceipt; + } + + /** + * Reports yield for a yield provider by sending a transaction to the YieldManager contract. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {Address} l2YieldRecipient - The L2 yield recipient address. + * @returns {Promise} The transaction receipt if successful. + */ + async reportYield(yieldProvider: Address, l2YieldRecipient: Address): Promise { + this.logger.debug(`reportYield started, yieldProvider=${yieldProvider}, l2YieldRecipient=${l2YieldRecipient}`); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "reportYield", + args: [yieldProvider, l2YieldRecipient], + }); + + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info( + `reportYield succeeded, yieldProvider=${yieldProvider}, l2YieldRecipient=${l2YieldRecipient}, txHash=${txReceipt.transactionHash}`, + ); + return txReceipt; + } + + /** + * Unstakes funds from a yield provider by submitting withdrawal requests. + * Encodes Lido withdrawal parameters, computes validator withdrawal fees, and sends a signed transaction. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {WithdrawalRequests} withdrawalParams - The withdrawal parameters including validator pubkeys and amounts. + * @returns {Promise} The transaction receipt if successful. + */ + async unstake(yieldProvider: Address, withdrawalParams: WithdrawalRequests): Promise { + this.logger.debug(`unstake started, yieldProvider=${yieldProvider}`, { withdrawalParams }); + const encodedWithdrawalParams = this._encodeLidoWithdrawalParams({ + ...withdrawalParams, + refundRecipient: this.contractAddress, + }); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "unstake", + args: [yieldProvider, encodedWithdrawalParams], + }); + + const validatorWithdrawalFee = await this._getValidatorWithdrawalFee(yieldProvider, withdrawalParams); + const txReceipt = await this.contractClientLibrary.sendSignedTransaction( + this.contractAddress, + calldata, + validatorWithdrawalFee, + ); + this.logger.info(`unstake succeeded, yieldProvider=${yieldProvider}, txHash=${txReceipt.transactionHash}`, { + withdrawalParams, + }); + return txReceipt; + } + + /** + * Encodes Lido staking vault withdrawal parameters into ABI-encoded format. + * + * @param {LidoStakingVaultWithdrawalParams} params - The withdrawal parameters including pubkeys, amounts, and refund recipient. + * @returns {Hex} The ABI-encoded withdrawal parameters. + */ + private _encodeLidoWithdrawalParams(params: LidoStakingVaultWithdrawalParams): Hex { + return encodeAbiParameters( + [ + { + type: "tuple", + components: [ + { name: "pubkeys", type: "bytes[]" }, + { name: "amounts", type: "uint64[]" }, + { name: "refundRecipient", type: "address" }, + ], + }, + ], + [ + { + pubkeys: params.pubkeys, + amounts: params.amountsGwei, + refundRecipient: params.refundRecipient, + }, + ], + ); + } + + /** + * Computes EIP7002 Withdrawal Fee for beacon chain unstaking. + * Retrieves the validator withdrawal fee from the staking vault contract based on the number of validators. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {WithdrawalRequests} withdrawalParams - The withdrawal parameters containing validator pubkeys. + * @returns {Promise} The validator withdrawal fee in wei. + */ + private async _getValidatorWithdrawalFee( + yieldProvider: Address, + withdrawalParams: WithdrawalRequests, + ): Promise { + const vault = await this.getLidoStakingVaultAddress(yieldProvider); + const validatorWithdrawalFee = await this.contractClientLibrary.getBlockchainClient().readContract({ + address: vault, + abi: StakingVaultABI, + functionName: "calculateValidatorWithdrawalFee", + args: [BigInt(withdrawalParams.pubkeys.length)], + }); + return validatorWithdrawalFee; + } + + /** + * Safely adds funds to the withdrawal reserve for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {bigint} amount - The amount to add to the withdrawal reserve in wei. + * @returns {Promise} The transaction receipt if successful. + */ + async safeAddToWithdrawalReserve(yieldProvider: Address, amount: bigint): Promise { + this.logger.debug( + `safeAddToWithdrawalReserve started, yieldProvider=${yieldProvider}, amount=${amount.toString()}`, + ); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "safeAddToWithdrawalReserve", + args: [yieldProvider, amount], + }); + + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info( + `safeAddToWithdrawalReserve succeeded, yieldProvider=${yieldProvider}, amount=${amount.toString()}, txHash=${txReceipt.transactionHash}`, + ); + return txReceipt; + } + + /** + * Pauses staking for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The transaction receipt if successful. + */ + async pauseStaking(yieldProvider: Address): Promise { + this.logger.debug(`pauseStaking started, yieldProvider=${yieldProvider}`); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "pauseStaking", + args: [yieldProvider], + }); + + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info(`pauseStaking succeeded, yieldProvider=${yieldProvider}, txHash=${txReceipt.transactionHash}`); + return txReceipt; + } + + /** + * Unpauses staking for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The transaction receipt if successful. + */ + async unpauseStaking(yieldProvider: Address): Promise { + this.logger.debug(`unpauseStaking started, yieldProvider=${yieldProvider}`); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "unpauseStaking", + args: [yieldProvider], + }); + + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info(`unpauseStaking succeeded, yieldProvider=${yieldProvider}, txHash=${txReceipt.transactionHash}`); + return txReceipt; + } + + /** + * Progresses pending ossification for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The transaction receipt if successful. + */ + async progressPendingOssification(yieldProvider: Address): Promise { + this.logger.debug(`progressPendingOssification started, yieldProvider=${yieldProvider}`); + const calldata = encodeFunctionData({ + abi: this.contract.abi, + functionName: "progressPendingOssification", + args: [yieldProvider], + }); + + const txReceipt = await this.contractClientLibrary.sendSignedTransaction(this.contractAddress, calldata); + this.logger.info( + `progressPendingOssification succeeded, yieldProvider=${yieldProvider}, txHash=${txReceipt.transactionHash}`, + ); + return txReceipt; + } + + /** + * Gets rebalance requirements by comparing L1 Message Service balance with effective target withdrawal reserve. + * Determines if rebalancing is needed (in deficit or in surplus) and calculates the required rebalance amount. + * Returns NONE direction with 0 amount if rebalancing is not required. + * + * @returns {Promise} The rebalance requirement containing direction (NONE, STAKE, or UNSTAKE) and amount. + */ + async getRebalanceRequirements(): Promise { + const l1MessageServiceAddress = await this.L1_MESSAGE_SERVICE(); + const [l1MessageServiceBalance, totalSystemBalance, effectiveTargetWithdrawalReserve] = await Promise.all([ + this.contractClientLibrary.getBalance(l1MessageServiceAddress), + this.getTotalSystemBalance(), + this.getEffectiveTargetWithdrawalReserve(), + ]); + const isRebalanceRequired = this._isRebalanceRequired( + totalSystemBalance, + l1MessageServiceBalance, + effectiveTargetWithdrawalReserve, + ); + if (!isRebalanceRequired) { + return { + rebalanceDirection: RebalanceDirection.NONE, + rebalanceAmount: 0n, + }; + } + // In deficit + if (l1MessageServiceBalance < effectiveTargetWithdrawalReserve) { + return { + rebalanceDirection: RebalanceDirection.UNSTAKE, + rebalanceAmount: effectiveTargetWithdrawalReserve - l1MessageServiceBalance, + }; + // In surplus + } else { + return { + rebalanceDirection: RebalanceDirection.STAKE, + rebalanceAmount: l1MessageServiceBalance - effectiveTargetWithdrawalReserve, + }; + } + } + + /** + * Determines if rebalancing is required based on tolerance band calculations. + * Checks if the L1 Message Service balance is below tolerance band or above tolerance band + * compared to the effective target withdrawal reserve. + * + * @param {bigint} totalSystemBalance - The total system balance. + * @param {bigint} l1MessageServiceBalance - The L1 Message Service balance. + * @param {bigint} effectiveTargetWithdrawalReserve - The effective target withdrawal reserve. + * @returns {boolean} True if rebalancing is required, false otherwise. + */ + private _isRebalanceRequired( + totalSystemBalance: bigint, + l1MessageServiceBalance: bigint, + effectiveTargetWithdrawalReserve: bigint, + ): boolean { + const toleranceBand = (totalSystemBalance * BigInt(this.rebalanceToleranceBps)) / 10000n; + // Below tolerance band + if (l1MessageServiceBalance < effectiveTargetWithdrawalReserve - toleranceBand) { + return true; + } + // Above tolerance band + if (l1MessageServiceBalance > effectiveTargetWithdrawalReserve + toleranceBand) { + return true; + } + return false; + } + + /** + * Gets the Lido staking vault address (ossified entrypoint) for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise
} The Lido staking vault address. + */ + async getLidoStakingVaultAddress(yieldProvider: Address): Promise
{ + const yieldProviderData = await this.getYieldProviderData(yieldProvider); + return yieldProviderData.ossifiedEntrypoint; + } + + /** + * Gets the Lido dashboard address (primary entrypoint) for a yield provider. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise
} The Lido dashboard address. + */ + async getLidoDashboardAddress(yieldProvider: Address): Promise
{ + const yieldProviderData = await this.getYieldProviderData(yieldProvider); + return yieldProviderData.primaryEntrypoint; + } + + /** + * Pauses staking for a yield provider only if it's not already paused. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The transaction receipt if staking was paused, undefined if already paused. + */ + async pauseStakingIfNotAlready(yieldProvider: Address): Promise { + if (!(await this.isStakingPaused(yieldProvider))) { + const txReceipt = await this.pauseStaking(yieldProvider); + return txReceipt; + } + this.logger.info(`Already paused staking for yieldProvider=${yieldProvider}`); + return undefined; + } + + /** + * Unpauses staking for a yield provider only if it's currently paused. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The transaction receipt if staking was unpaused, undefined if already unpaused. + */ + async unpauseStakingIfNotAlready(yieldProvider: Address): Promise { + if (await this.isStakingPaused(yieldProvider)) { + const txReceipt = await this.unpauseStaking(yieldProvider); + return txReceipt; + } + this.logger.info(`Already resumed staking for yieldProvider=${yieldProvider}`); + return undefined; + } + + /** + * Gets the available unstaking rebalance balance for a yield provider. + * Calculates the sum of YieldManager balance and yield provider withdrawable balance. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The available unstaking rebalance balance in wei. + */ + async getAvailableUnstakingRebalanceBalance(yieldProvider: Address): Promise { + const [yieldManagerBalance, yieldProviderWithdrawableBalance] = await Promise.all([ + this.contractClientLibrary.getBalance(this.contractAddress), + this.withdrawableValue(yieldProvider), + ]); + return yieldManagerBalance + yieldProviderWithdrawableBalance; + } + + /** + * Safely adds funds to withdrawal reserve only if the available withdrawal balance is above the minimum threshold. + * + * @param {Address} yieldProvider - The yield provider address. + * @param {bigint} amount - The amount to add to the withdrawal reserve in wei. + * @returns {Promise} The transaction receipt if successful, undefined if below threshold. + */ + async safeAddToWithdrawalReserveIfAboveThreshold( + yieldProvider: Address, + amount: bigint, + ): Promise { + const availableWithdrawalBalance = await this.getAvailableUnstakingRebalanceBalance(yieldProvider); + if (availableWithdrawalBalance < this.minWithdrawalThresholdEth * ONE_ETHER) return undefined; + return await this.safeAddToWithdrawalReserve(yieldProvider, amount); + } + + /** + * Safely adds the maximum available withdrawal balance to the withdrawal reserve. + * Only proceeds if the available withdrawal balance is above the minimum threshold. + * + * @param {Address} yieldProvider - The yield provider address. + * @returns {Promise} The transaction receipt if successful, undefined if below threshold. + */ + async safeMaxAddToWithdrawalReserve(yieldProvider: Address): Promise { + const availableWithdrawalBalance = await this.getAvailableUnstakingRebalanceBalance(yieldProvider); + if (availableWithdrawalBalance < this.minWithdrawalThresholdEth * ONE_ETHER) return undefined; + return await this.safeAddToWithdrawalReserve(yieldProvider, availableWithdrawalBalance); + } + + /** + * Extracts withdrawal event data from a transaction receipt by decoding WithdrawalReserveAugmented events. + * Only decodes logs emitted by this contract. Skips unrelated logs (from the same contract or different ABIs). + * If event not found, returns undefined. + * + * @param {TransactionReceipt} txReceipt - The transaction receipt to search for WithdrawalReserveAugmented events. + * @returns {WithdrawalEvent | undefined} The withdrawal event containing reserveIncrementAmount and yieldProvider, or undefined if not found. + */ + getWithdrawalEventFromTxReceipt(txReceipt: TransactionReceipt): WithdrawalEvent | undefined { + const logs = parseEventLogs({ + abi: this.contract.abi, + eventName: "WithdrawalReserveAugmented", + logs: txReceipt.logs, + }); + + const event = logs.find((log) => log.address.toLowerCase() === this.contractAddress.toLowerCase()); + if (!event) return undefined; + + const { reserveIncrementAmount, yieldProvider } = event.args; + return { reserveIncrementAmount, yieldProvider }; + } + + /** + * Extracts yield report data from a transaction receipt by decoding NativeYieldReported events. + * Only decodes logs emitted by this contract. Skips unrelated logs (from the same contract or different ABIs). + * If event not found, returns undefined. + * + * @param {TransactionReceipt} txReceipt - The transaction receipt to search for NativeYieldReported events. + * @returns {YieldReport | undefined} The yield report containing yieldAmount, outstandingNegativeYield, and yieldProvider, or undefined if not found. + */ + getYieldReportFromTxReceipt(txReceipt: TransactionReceipt): YieldReport | undefined { + const logs = parseEventLogs({ + abi: this.contract.abi, + eventName: "NativeYieldReported", + logs: txReceipt.logs, + }); + + const event = logs.find((log) => log.address.toLowerCase() === this.contractAddress.toLowerCase()); + if (!event) return undefined; + + const { yieldAmount, outstandingNegativeYield, yieldProvider } = event.args; + return { + yieldAmount, + outstandingNegativeYield, + yieldProvider, + }; + } +} diff --git a/native-yield-operations/automation-service/src/clients/contracts/__tests__/LazyOracleContractClient.test.ts b/native-yield-operations/automation-service/src/clients/contracts/__tests__/LazyOracleContractClient.test.ts new file mode 100644 index 0000000000..b499a4ef25 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/__tests__/LazyOracleContractClient.test.ts @@ -0,0 +1,352 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import type { ILogger, IBlockchainClient } from "@consensys/linea-shared-utils"; +import type { Address, Hex, PublicClient, TransactionReceipt } from "viem"; +import { InvalidInputRpcError } from "viem"; +import { LazyOracleABI } from "../../../core/abis/LazyOracle.js"; +import { OperationTrigger } from "../../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + getContract: jest.fn(), + encodeFunctionData: jest.fn(), + }; +}); + +import { encodeFunctionData, getContract } from "viem"; + +const mockedGetContract = getContract as jest.MockedFunction; +const mockedEncodeFunctionData = encodeFunctionData as jest.MockedFunction; + +let LazyOracleContractClient: typeof import("../LazyOracleContractClient.js").LazyOracleContractClient; + +beforeAll(async () => { + ({ LazyOracleContractClient } = await import("../LazyOracleContractClient.js")); +}); + +describe("LazyOracleContractClient", () => { + const contractAddress = "0x1111111111111111111111111111111111111111" as Address; + const pollIntervalMs = 5_000; + const eventWatchTimeoutMs = 30_000; + + let logger: MockProxy; + let blockchainClient: MockProxy>; + let watchContractEvent: jest.Mock; + let stopWatching: jest.Mock; + let publicClient: PublicClient; + let contractStub: any; + + const createClient = () => + new LazyOracleContractClient(logger, blockchainClient, contractAddress, pollIntervalMs, eventWatchTimeoutMs); + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + logger = mock(); + blockchainClient = mock>(); + stopWatching = jest.fn(); + watchContractEvent = jest.fn().mockReturnValue(stopWatching); + publicClient = { watchContractEvent } as unknown as PublicClient; + blockchainClient.getBlockchainClient.mockReturnValue(publicClient); + contractStub = { + abi: LazyOracleABI, + read: { + latestReportData: jest.fn(), + }, + simulate: { + updateVaultData: jest.fn(), + }, + }; + mockedGetContract.mockReturnValue(contractStub); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("initializes the viem contract and exposes getters", () => { + const client = createClient(); + + expect(mockedGetContract).toHaveBeenCalledWith({ + abi: LazyOracleABI, + address: contractAddress, + client: publicClient, + }); + expect(client.getAddress()).toBe(contractAddress); + expect(client.getContract()).toBe(contractStub); + }); + + it("returns latest report data with normalized structure", async () => { + const client = createClient(); + const latest = [123n, 456n, "0xabc" as Hex, "cid"] as const; + contractStub.read.latestReportData.mockResolvedValueOnce(latest); + + const report = await client.latestReportData(); + + expect(report).toEqual({ + timestamp: latest[0], + refSlot: latest[1], + treeRoot: latest[2], + reportCid: latest[3], + }); + expect(logger.debug).toHaveBeenCalledWith("latestReportData", { + returnVal: report, + }); + }); + + it("encodes calldata and relays updateVaultData to the blockchain client", async () => { + const client = createClient(); + const calldata = "0xdeadbeef" as Hex; + const receipt = { transactionHash: "0xhash" } as unknown as TransactionReceipt; + const params = { + vault: "0x2222222222222222222222222222222222222222" as Address, + totalValue: 1n, + cumulativeLidoFees: 2n, + liabilityShares: 3n, + maxLiabilityShares: 4n, + slashingReserve: 5n, + proof: ["0x01"] as Hex[], + }; + + mockedEncodeFunctionData.mockReturnValueOnce(calldata); + blockchainClient.sendSignedTransaction.mockResolvedValueOnce(receipt); + + const result = await client.updateVaultData(params); + + expect(result).toBe(receipt); + expect(logger.debug).toHaveBeenCalledWith("updateVaultData started", { params }); + expect(mockedEncodeFunctionData).toHaveBeenCalledWith({ + abi: contractStub.abi, + functionName: "updateVaultData", + args: [ + params.vault, + params.totalValue, + params.cumulativeLidoFees, + params.liabilityShares, + params.maxLiabilityShares, + params.slashingReserve, + params.proof, + ], + }); + expect(blockchainClient.sendSignedTransaction).toHaveBeenCalledWith(contractAddress, calldata); + expect(logger.info).toHaveBeenCalledWith("updateVaultData succeeded, txHash=0xhash", { params }); + }); + + it("resolves with VaultReportResult when VaultsReportDataUpdated event arrives", async () => { + const client = createClient(); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + const promise = client.waitForVaultsReportDataUpdatedEvent(); + + expect(watchContractEvent).toHaveBeenCalledWith({ + address: contractAddress, + abi: contractStub.abi, + eventName: "VaultsReportDataUpdated", + pollingInterval: pollIntervalMs, + onLogs: expect.any(Function), + onError: expect.any(Function), + }); + + const watchArgs = watchContractEvent.mock.calls[0][0]; + expect(stopWatching).not.toHaveBeenCalled(); + const log = { + removed: false, + args: { + timestamp: 123n, + refSlot: 456n, + root: "0xabc" as Hex, + cid: "cid", + }, + transactionHash: "0xhash" as Hex, + }; + + watchArgs.onLogs?.([log as any]); + await expect(promise).resolves.toEqual({ + result: OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT, + txHash: log.transactionHash, + report: { + timestamp: log.args.timestamp, + refSlot: log.args.refSlot, + treeRoot: log.args.root, + reportCid: log.args.cid, + }, + }); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + expect(stopWatching).toHaveBeenCalledTimes(1); + clearTimeoutSpy.mockRestore(); + expect(logger.info).toHaveBeenCalledWith( + "waitForVaultsReportDataUpdatedEvent detected", + expect.objectContaining({ + result: expect.objectContaining({ + result: OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT, + }), + }), + ); + }); + + it("resolves with timeout result when no event is observed", async () => { + const client = createClient(); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + const promise = client.waitForVaultsReportDataUpdatedEvent(); + + expect(logger.info).toHaveBeenCalledWith( + `waitForVaultsReportDataUpdatedEvent started with timeout=${eventWatchTimeoutMs}ms`, + ); + + expect(stopWatching).not.toHaveBeenCalled(); + jest.advanceTimersByTime(eventWatchTimeoutMs); + await expect(promise).resolves.toEqual({ result: OperationTrigger.TIMEOUT }); + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(stopWatching).toHaveBeenCalledTimes(1); + clearTimeoutSpy.mockRestore(); + expect(logger.info).toHaveBeenCalledWith( + `waitForVaultsReportDataUpdatedEvent timed out after timeout=${eventWatchTimeoutMs}ms`, + ); + }); + + it("logs errors emitted by the watcher and continues waiting", async () => { + const client = createClient(); + const promise = client.waitForVaultsReportDataUpdatedEvent(); + const watchArgs = watchContractEvent.mock.calls[0][0]; + const failure = new Error("boom"); + + watchArgs.onError?.(failure); + + expect(logger.error).toHaveBeenCalledWith("waitForVaultsReportDataUpdatedEvent error", { error: failure }); + + jest.advanceTimersByTime(eventWatchTimeoutMs); + await expect(promise).resolves.toEqual({ result: OperationTrigger.TIMEOUT }); + expect(stopWatching).toHaveBeenCalledTimes(1); + }); + + it("warns and continues waiting when InvalidInputRpcError is emitted (filter expired)", async () => { + const client = createClient(); + const promise = client.waitForVaultsReportDataUpdatedEvent(); + const watchArgs = watchContractEvent.mock.calls[0][0]; + const invalidInputError = new InvalidInputRpcError(new Error("Filter expired")); + + watchArgs.onError?.(invalidInputError); + + expect(logger.warn).toHaveBeenCalledWith( + "waitForVaultsReportDataUpdatedEvent: Filter expired, will be recreated by Viem framework", + { error: invalidInputError }, + ); + expect(logger.error).not.toHaveBeenCalledWith("waitForVaultsReportDataUpdatedEvent error", expect.anything()); + + jest.advanceTimersByTime(eventWatchTimeoutMs); + await expect(promise).resolves.toEqual({ result: OperationTrigger.TIMEOUT }); + expect(stopWatching).toHaveBeenCalledTimes(1); + }); + + it("warns when all received logs are removed before resolving later events", async () => { + const client = createClient(); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + const promise = client.waitForVaultsReportDataUpdatedEvent(); + const watchArgs = watchContractEvent.mock.calls[0][0]; + + watchArgs.onLogs?.([{ removed: true }] as any); + + expect(logger.warn).toHaveBeenCalledWith( + "waitForVaultsReportDataUpdatedEvent: Dropped VaultsReportDataUpdated event", + ); + expect(stopWatching).not.toHaveBeenCalled(); + + const log = { + removed: false, + args: { + timestamp: 123n, + refSlot: 456n, + root: "0xbeef" as Hex, + cid: "cid", + }, + transactionHash: "0xhash" as Hex, + }; + + watchArgs.onLogs?.([log as any]); + + await expect(promise).resolves.toEqual({ + result: OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT, + txHash: log.transactionHash, + report: { + timestamp: log.args.timestamp, + refSlot: log.args.refSlot, + treeRoot: log.args.root, + reportCid: log.args.cid, + }, + }); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + expect(stopWatching).toHaveBeenCalledTimes(1); + clearTimeoutSpy.mockRestore(); + }); + + it("logs debug details when reorg logs are filtered out", async () => { + const client = createClient(); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + const promise = client.waitForVaultsReportDataUpdatedEvent(); + const watchArgs = watchContractEvent.mock.calls[0][0]; + + const logs = [ + { removed: true, args: {}, transactionHash: "0xdead" }, + { + removed: false, + args: { + timestamp: 1n, + refSlot: 2n, + root: "0xroot" as Hex, + cid: "cid", + }, + transactionHash: "0xlive" as Hex, + }, + ]; + + watchArgs.onLogs?.(logs as any); + + expect(logger.debug).toHaveBeenCalledWith("waitForVaultsReportDataUpdatedEvent: Ignored removed reorg logs", { + logs, + }); + + await expect(promise).resolves.toEqual({ + result: OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT, + txHash: logs[1].transactionHash as Hex, + report: { + timestamp: logs[1].args.timestamp, + refSlot: logs[1].args.refSlot, + treeRoot: logs[1].args.root, + reportCid: logs[1].args.cid, + }, + }); + expect(clearTimeoutSpy).toHaveBeenCalledTimes(1); + expect(stopWatching).toHaveBeenCalledTimes(1); + clearTimeoutSpy.mockRestore(); + }); + + it("ignores logs that lack the expected arguments", async () => { + const client = createClient(); + const clearTimeoutSpy = jest.spyOn(global, "clearTimeout"); + const promise = client.waitForVaultsReportDataUpdatedEvent(); + const watchArgs = watchContractEvent.mock.calls[0][0]; + + const incompleteLog = { + removed: false, + args: { + timestamp: 123n, + refSlot: undefined, + root: "0xroot" as Hex, + cid: "cid", + }, + transactionHash: "0xincomplete" as Hex, + }; + + watchArgs.onLogs?.([incompleteLog as any]); + + expect(stopWatching).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalledWith("waitForVaultsReportDataUpdatedEvent detected", expect.anything()); + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(eventWatchTimeoutMs); + await expect(promise).resolves.toEqual({ result: OperationTrigger.TIMEOUT }); + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(stopWatching).toHaveBeenCalledTimes(1); + clearTimeoutSpy.mockRestore(); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/contracts/__tests__/LineaRollupYieldExtensionContractClient.test.ts b/native-yield-operations/automation-service/src/clients/contracts/__tests__/LineaRollupYieldExtensionContractClient.test.ts new file mode 100644 index 0000000000..a4da06ba67 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/__tests__/LineaRollupYieldExtensionContractClient.test.ts @@ -0,0 +1,83 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import type { ILogger, IBlockchainClient } from "@consensys/linea-shared-utils"; +import type { PublicClient, TransactionReceipt, Address, Hex } from "viem"; +import { LineaRollupYieldExtensionABI } from "../../../core/abis/LineaRollupYieldExtension.js"; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + getContract: jest.fn(), + encodeFunctionData: jest.fn(), + }; +}); + +import { getContract, encodeFunctionData } from "viem"; + +const mockedGetContract = getContract as jest.MockedFunction; +const mockedEncodeFunctionData = encodeFunctionData as jest.MockedFunction; + +let LineaRollupYieldExtensionContractClient: typeof import("../LineaRollupYieldExtensionContractClient.js").LineaRollupYieldExtensionContractClient; + +beforeAll(async () => { + ({ LineaRollupYieldExtensionContractClient } = await import("../LineaRollupYieldExtensionContractClient.js")); +}); + +describe("LineaRollupYieldExtensionContractClient", () => { + const contractAddress = "0x1111111111111111111111111111111111111111" as Address; + + let logger: MockProxy; + let blockchainClient: MockProxy>; + let publicClient: PublicClient; + const contractStub = { abi: LineaRollupYieldExtensionABI } as any; + + beforeEach(() => { + jest.clearAllMocks(); + logger = mock(); + blockchainClient = mock>(); + publicClient = {} as PublicClient; + blockchainClient.getBlockchainClient.mockReturnValue(publicClient); + mockedGetContract.mockReturnValue(contractStub); + }); + + const createClient = () => new LineaRollupYieldExtensionContractClient(logger, blockchainClient, contractAddress); + + it("initializes viem contract with provided address and client", () => { + const client = createClient(); + + expect(mockedGetContract).toHaveBeenCalledWith({ + abi: LineaRollupYieldExtensionABI, + address: contractAddress, + client: publicClient, + }); + expect(client.getContract()).toBe(contractStub); + }); + + it("exposes the configured contract address", () => { + const client = createClient(); + + expect(client.getAddress()).toBe(contractAddress); + }); + + it("encodes calldata and relays transferFundsForNativeYield to the blockchain client", async () => { + const client = createClient(); + const amount = 123n; + const calldata = "0xdeadbeef" as Hex; + const txReceipt = { transactionHash: "0xhash" } as unknown as TransactionReceipt; + + mockedEncodeFunctionData.mockReturnValue(calldata); + blockchainClient.sendSignedTransaction.mockResolvedValue(txReceipt); + + const receipt = await client.transferFundsForNativeYield(amount); + + expect(receipt).toBe(txReceipt); + expect(logger.debug).toHaveBeenCalledWith("transferFundsForNativeYield started, amount=123"); + expect(mockedEncodeFunctionData).toHaveBeenCalledWith({ + abi: contractStub.abi, + functionName: "transferFundsForNativeYield", + args: [amount], + }); + expect(blockchainClient.sendSignedTransaction).toHaveBeenCalledWith(contractAddress, calldata); + expect(logger.info).toHaveBeenCalledWith("transferFundsForNativeYield succeeded, amount=123, txHash=0xhash"); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/contracts/__tests__/VaultHubContractClient.test.ts b/native-yield-operations/automation-service/src/clients/contracts/__tests__/VaultHubContractClient.test.ts new file mode 100644 index 0000000000..1faa248b3a --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/__tests__/VaultHubContractClient.test.ts @@ -0,0 +1,167 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import type { IBlockchainClient } from "@consensys/linea-shared-utils"; +import type { PublicClient, TransactionReceipt, Address } from "viem"; +import { VaultHubABI } from "../../../core/abis/VaultHub.js"; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + getContract: jest.fn(), + parseEventLogs: jest.fn(), + }; +}); + +import { getContract, parseEventLogs } from "viem"; + +const mockedGetContract = getContract as jest.MockedFunction; +const mockedParseEventLogs = parseEventLogs as jest.MockedFunction; + +let VaultHubContractClient: typeof import("../VaultHubContractClient.js").VaultHubContractClient; + +beforeAll(async () => { + ({ VaultHubContractClient } = await import("../VaultHubContractClient.js")); +}); + +describe("VaultHubContractClient", () => { + const contractAddress = "0x1111111111111111111111111111111111111111" as Address; + + let blockchainClient: MockProxy>; + let publicClient: PublicClient; + const viemContractStub = { abi: VaultHubABI } as any; + + beforeEach(() => { + jest.clearAllMocks(); + blockchainClient = mock>(); + publicClient = {} as PublicClient; + blockchainClient.getBlockchainClient.mockReturnValue(publicClient); + mockedGetContract.mockReturnValue(viemContractStub); + }); + + const createClient = () => new VaultHubContractClient(blockchainClient, contractAddress); + + const buildReceipt = (logs: Array<{ address: string; data: string; topics: string[] }>): TransactionReceipt => + ({ + logs, + }) as unknown as TransactionReceipt; + + it("initializes the viem contract and exposes it through getters", () => { + const client = createClient(); + + expect(mockedGetContract).toHaveBeenCalledWith({ + abi: VaultHubABI, + address: contractAddress, + client: publicClient, + }); + expect(client.getAddress()).toBe(contractAddress); + expect(client.getContract()).toBe(viemContractStub); + }); + + it("returns liability payment when VaultRebalanced event is present", () => { + const client = createClient(); + const receipt = buildReceipt([ + { + address: "0x0000000000000000000000000000000000000000", + data: "0x", + topics: [], + }, + { + address: contractAddress, + data: "0xdata", + topics: ["0xtopic"], + }, + ]); + + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "VaultRebalanced", + args: { etherWithdrawn: 123n }, + address: contractAddress, + } as any, + ]); + + const amount = client.getLiabilityPaymentFromTxReceipt(receipt); + + expect(amount).toBe(123n); + expect(mockedParseEventLogs).toHaveBeenCalledWith({ + abi: viemContractStub.abi, + eventName: "VaultRebalanced", + logs: receipt.logs, + }); + }); + + it("ignores logs that fail to decode and returns zero when no VaultRebalanced event", () => { + const client = createClient(); + const receipt = buildReceipt([ + { + address: contractAddress.toUpperCase(), + data: "0xdead", + topics: [], + }, + ]); + + mockedParseEventLogs.mockReturnValueOnce([]); + + const amount = client.getLiabilityPaymentFromTxReceipt(receipt); + + expect(amount).toBe(0n); + expect(mockedParseEventLogs).toHaveBeenCalledTimes(1); + }); + + it("returns lido fee payment when LidoFeesSettled event is present", () => { + const client = createClient(); + const receipt = buildReceipt([ + { + address: contractAddress, + data: "0xfeed", + topics: ["0x01"], + }, + ]); + + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "LidoFeesSettled", + args: { transferred: 456n }, + address: contractAddress, + } as any, + ]); + + const amount = client.getLidoFeePaymentFromTxReceipt(receipt); + + expect(amount).toBe(456n); + expect(mockedParseEventLogs).toHaveBeenCalledWith({ + abi: viemContractStub.abi, + eventName: "LidoFeesSettled", + logs: receipt.logs, + }); + }); + + it("returns zero lido fee when logs belong to other contracts or events", () => { + const client = createClient(); + const receipt = buildReceipt([ + { + address: "0x2222222222222222222222222222222222222222", + data: "0xaaa", + topics: [], + }, + { + address: contractAddress, + data: "0xbb", + topics: [], + }, + ]); + + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "LidoFeesSettled", + args: { transferred: 456n }, + address: "0x2222222222222222222222222222222222222222", + } as any, + ]); + + const amount = client.getLidoFeePaymentFromTxReceipt(receipt); + + expect(amount).toBe(0n); + expect(mockedParseEventLogs).toHaveBeenCalledTimes(1); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/contracts/__tests__/YieldManagerContractClient.test.ts b/native-yield-operations/automation-service/src/clients/contracts/__tests__/YieldManagerContractClient.test.ts new file mode 100644 index 0000000000..bd6308ee49 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/__tests__/YieldManagerContractClient.test.ts @@ -0,0 +1,569 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import type { ILogger, IBlockchainClient } from "@consensys/linea-shared-utils"; +import type { Address, Hex, PublicClient, TransactionReceipt } from "viem"; +import { YieldManagerABI } from "../../../core/abis/YieldManager.js"; +import { StakingVaultABI } from "../../../core/abis/StakingVault.js"; +import { RebalanceDirection } from "../../../core/entities/RebalanceRequirement.js"; +import type { WithdrawalRequests } from "../../../core/entities/LidoStakingVaultWithdrawalParams.js"; +import { ONE_ETHER } from "@consensys/linea-shared-utils"; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + getContract: jest.fn(), + encodeFunctionData: jest.fn(), + parseEventLogs: jest.fn(), + encodeAbiParameters: jest.fn(), + }; +}); + +import { getContract, encodeFunctionData, parseEventLogs, encodeAbiParameters } from "viem"; + +const mockedGetContract = getContract as jest.MockedFunction; +const mockedEncodeFunctionData = encodeFunctionData as jest.MockedFunction; +const mockedParseEventLogs = parseEventLogs as jest.MockedFunction; +const mockedEncodeAbiParameters = encodeAbiParameters as jest.MockedFunction; + +let YieldManagerContractClient: typeof import("../YieldManagerContractClient.js").YieldManagerContractClient; + +beforeAll(async () => { + ({ YieldManagerContractClient } = await import("../YieldManagerContractClient.js")); +}); + +describe("YieldManagerContractClient", () => { + const contractAddress = "0xcccccccccccccccccccccccccccccccccccccccc" as Address; + const yieldProvider = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Address; + const l2Recipient = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as Address; + const l1MessageServiceAddress = "0x9999999999999999999999999999999999999999" as Address; + const stakingVaultAddress = "0x8888888888888888888888888888888888888888" as Address; + + let logger: MockProxy; + let blockchainClient: MockProxy>; + let publicClient: { readContract: jest.Mock } & Record; + let contractStub: { + abi: typeof YieldManagerABI; + read: Record; + simulate: Record; + }; + + const defaultYieldProviderData = { + yieldProviderVendor: 0, + isStakingPaused: false, + isOssificationInitiated: false, + isOssified: false, + primaryEntrypoint: l2Recipient, + ossifiedEntrypoint: stakingVaultAddress, + yieldProviderIndex: 0n, + userFunds: 0n, + yieldReportedCumulative: 0n, + lstLiabilityPrincipal: 0n, + }; + + const buildReceipt = (logs: Array<{ address: string; data: string; topics: string[] }>): TransactionReceipt => + ({ + logs, + }) as unknown as TransactionReceipt; + + const createClient = ({ + rebalanceToleranceBps = 100, + minWithdrawalThresholdEth = 0n, + }: { + rebalanceToleranceBps?: number; + minWithdrawalThresholdEth?: bigint; + } = {}) => + new YieldManagerContractClient( + logger, + blockchainClient, + contractAddress, + rebalanceToleranceBps, + minWithdrawalThresholdEth, + ); + + beforeEach(() => { + jest.clearAllMocks(); + logger = mock(); + blockchainClient = mock>(); + publicClient = { + readContract: jest.fn(), + }; + + blockchainClient.getBlockchainClient.mockReturnValue(publicClient as unknown as PublicClient); + + contractStub = { + abi: YieldManagerABI, + read: { + L1_MESSAGE_SERVICE: jest.fn(), + getTotalSystemBalance: jest.fn(), + getEffectiveTargetWithdrawalReserve: jest.fn(), + getTargetReserveDeficit: jest.fn(), + isStakingPaused: jest.fn(), + isOssificationInitiated: jest.fn(), + isOssified: jest.fn(), + getYieldProviderData: jest.fn().mockResolvedValue(defaultYieldProviderData), + }, + simulate: { + withdrawableValue: jest.fn(), + }, + }; + + mockedGetContract.mockReturnValue(contractStub as any); + blockchainClient.getBalance.mockResolvedValue(0n); + blockchainClient.sendSignedTransaction.mockResolvedValue({ + transactionHash: "0xreceipt", + } as unknown as TransactionReceipt); + contractStub.read.L1_MESSAGE_SERVICE.mockResolvedValue(l1MessageServiceAddress); + contractStub.read.getTotalSystemBalance.mockResolvedValue(0n); + contractStub.read.getEffectiveTargetWithdrawalReserve.mockResolvedValue(0n); + contractStub.read.getTargetReserveDeficit.mockResolvedValue(0n); + contractStub.read.isStakingPaused.mockResolvedValue(false); + contractStub.read.isOssificationInitiated.mockResolvedValue(false); + contractStub.read.isOssified.mockResolvedValue(false); + contractStub.simulate.withdrawableValue.mockResolvedValue({ result: 0n }); + }); + + it("initializes the viem contract and exposes address & contract accessors", () => { + const client = createClient(); + + expect(mockedGetContract).toHaveBeenCalledWith({ + abi: YieldManagerABI, + address: contractAddress, + client: publicClient, + }); + expect(client.getAddress()).toBe(contractAddress); + expect(client.getContract()).toBe(contractStub); + }); + + it("delegates simple reads to the viem contract", async () => { + const stakingPaused = true; + const ossificationInitiated = true; + const ossified = true; + contractStub.read.getTotalSystemBalance.mockResolvedValueOnce(123n); + contractStub.read.getEffectiveTargetWithdrawalReserve.mockResolvedValueOnce(456n); + contractStub.read.getTargetReserveDeficit.mockResolvedValueOnce(789n); + contractStub.read.isStakingPaused.mockResolvedValueOnce(stakingPaused); + contractStub.read.isOssificationInitiated.mockResolvedValueOnce(ossificationInitiated); + contractStub.read.isOssified.mockResolvedValueOnce(ossified); + + const client = createClient(); + + await expect(client.getTotalSystemBalance()).resolves.toBe(123n); + await expect(client.getEffectiveTargetWithdrawalReserve()).resolves.toBe(456n); + await expect(client.getTargetReserveDeficit()).resolves.toBe(789n); + await expect(client.L1_MESSAGE_SERVICE()).resolves.toBe(l1MessageServiceAddress); + await expect(client.isStakingPaused(yieldProvider)).resolves.toBe(stakingPaused); + await expect(client.isOssificationInitiated(yieldProvider)).resolves.toBe(ossificationInitiated); + await expect(client.isOssified(yieldProvider)).resolves.toBe(ossified); + + expect(contractStub.read.getTotalSystemBalance).toHaveBeenCalledTimes(1); + expect(contractStub.read.getEffectiveTargetWithdrawalReserve).toHaveBeenCalledTimes(1); + expect(contractStub.read.getTargetReserveDeficit).toHaveBeenCalledTimes(1); + expect(contractStub.read.L1_MESSAGE_SERVICE).toHaveBeenCalledTimes(1); + expect(contractStub.read.isStakingPaused).toHaveBeenCalledWith([yieldProvider]); + expect(contractStub.read.isOssificationInitiated).toHaveBeenCalledWith([yieldProvider]); + expect(contractStub.read.isOssified).toHaveBeenCalledWith([yieldProvider]); + }); + + it("reads withdrawableValue via simulate and returns the result", async () => { + const withdrawable = 42n; + contractStub.simulate.withdrawableValue.mockResolvedValueOnce({ result: withdrawable }); + + const client = createClient(); + const result = await client.withdrawableValue(yieldProvider); + + expect(contractStub.simulate.withdrawableValue).toHaveBeenCalledWith([yieldProvider]); + expect(result).toBe(withdrawable); + }); + + it("encodes calldata and sends fundYieldProvider transactions", async () => { + const amount = 100n; + const calldata = "0xcalldata" as Hex; + const txReceipt = { transactionHash: "0xhash" } as unknown as TransactionReceipt; + mockedEncodeFunctionData.mockReturnValueOnce(calldata); + blockchainClient.sendSignedTransaction.mockResolvedValueOnce(txReceipt); + + const client = createClient(); + const receipt = await client.fundYieldProvider(yieldProvider, amount); + + expect(logger.debug).toHaveBeenCalledWith( + `fundYieldProvider started, yieldProvider=${yieldProvider}, amount=${amount.toString()}`, + ); + expect(mockedEncodeFunctionData).toHaveBeenCalledWith({ + abi: contractStub.abi, + functionName: "fundYieldProvider", + args: [yieldProvider, amount], + }); + expect(blockchainClient.sendSignedTransaction).toHaveBeenCalledWith(contractAddress, calldata); + expect(logger.info).toHaveBeenCalledWith( + `fundYieldProvider succeeded, yieldProvider=${yieldProvider}, amount=${amount.toString()}, txHash=${txReceipt.transactionHash}`, + ); + expect(receipt).toBe(txReceipt); + }); + + it("reports yield with encoded calldata", async () => { + const calldata = "0xreport" as Hex; + const txReceipt = { transactionHash: "0xreporthash" } as unknown as TransactionReceipt; + mockedEncodeFunctionData.mockReturnValueOnce(calldata); + blockchainClient.sendSignedTransaction.mockResolvedValueOnce(txReceipt); + + const client = createClient(); + const receipt = await client.reportYield(yieldProvider, l2Recipient); + + expect(logger.debug).toHaveBeenCalledWith( + `reportYield started, yieldProvider=${yieldProvider}, l2YieldRecipient=${l2Recipient}`, + ); + expect(mockedEncodeFunctionData).toHaveBeenCalledWith({ + abi: contractStub.abi, + functionName: "reportYield", + args: [yieldProvider, l2Recipient], + }); + expect(blockchainClient.sendSignedTransaction).toHaveBeenCalledWith(contractAddress, calldata); + expect(logger.info).toHaveBeenCalledWith( + `reportYield succeeded, yieldProvider=${yieldProvider}, l2YieldRecipient=${l2Recipient}, txHash=${txReceipt.transactionHash}`, + ); + expect(receipt).toBe(txReceipt); + }); + + it("unstakes with encoded withdrawal params and pays validator fee", async () => { + const withdrawalParams: WithdrawalRequests = { + pubkeys: ["0x01"] as Hex[], + amountsGwei: [32n], + }; + const encodedWithdrawalParams = "0xencoded" as Hex; + const calldata = "0xunstake" as Hex; + const fee = 123n; + const txReceipt = { transactionHash: "0xunstakehash" } as unknown as TransactionReceipt; + + mockedEncodeAbiParameters.mockReturnValueOnce(encodedWithdrawalParams); + mockedEncodeFunctionData.mockReturnValueOnce(calldata); + blockchainClient.sendSignedTransaction.mockResolvedValueOnce(txReceipt); + (publicClient.readContract as jest.Mock).mockResolvedValueOnce(fee); + + const client = createClient(); + const receipt = await client.unstake(yieldProvider, withdrawalParams); + + expect(logger.debug).toHaveBeenCalledWith(`unstake started, yieldProvider=${yieldProvider}`, { + withdrawalParams, + }); + expect(mockedEncodeAbiParameters).toHaveBeenCalledWith( + [ + { + type: "tuple", + components: [ + { name: "pubkeys", type: "bytes[]" }, + { name: "amounts", type: "uint64[]" }, + { name: "refundRecipient", type: "address" }, + ], + }, + ], + [ + { + pubkeys: withdrawalParams.pubkeys, + amounts: withdrawalParams.amountsGwei, + refundRecipient: contractAddress, + }, + ], + ); + expect(publicClient.readContract).toHaveBeenCalledWith({ + address: stakingVaultAddress, + abi: StakingVaultABI, + functionName: "calculateValidatorWithdrawalFee", + args: [BigInt(withdrawalParams.pubkeys.length)], + }); + expect(mockedEncodeFunctionData).toHaveBeenCalledWith({ + abi: contractStub.abi, + functionName: "unstake", + args: [yieldProvider, encodedWithdrawalParams], + }); + expect(blockchainClient.sendSignedTransaction).toHaveBeenCalledWith(contractAddress, calldata, fee); + expect(logger.info).toHaveBeenCalledWith( + `unstake succeeded, yieldProvider=${yieldProvider}, txHash=${txReceipt.transactionHash}`, + { withdrawalParams }, + ); + expect(receipt).toBe(txReceipt); + }); + + it("adds to withdrawal reserve and logs success", async () => { + const amount = 55n; + const calldata = "0xreserve" as Hex; + const txReceipt = { transactionHash: "0xreservehash" } as unknown as TransactionReceipt; + mockedEncodeFunctionData.mockReturnValueOnce(calldata); + blockchainClient.sendSignedTransaction.mockResolvedValueOnce(txReceipt); + + const client = createClient(); + const receipt = await client.safeAddToWithdrawalReserve(yieldProvider, amount); + + expect(mockedEncodeFunctionData).toHaveBeenCalledWith({ + abi: contractStub.abi, + functionName: "safeAddToWithdrawalReserve", + args: [yieldProvider, amount], + }); + expect(blockchainClient.sendSignedTransaction).toHaveBeenCalledWith(contractAddress, calldata); + expect(logger.info).toHaveBeenCalledWith( + `safeAddToWithdrawalReserve succeeded, yieldProvider=${yieldProvider}, amount=${amount.toString()}, txHash=${txReceipt.transactionHash}`, + ); + expect(receipt).toBe(txReceipt); + }); + + it("pauses and unpauses staking through encoded calls", async () => { + const pauseCalldata = "0xpause" as Hex; + const unpauseCalldata = "0xunpause" as Hex; + const txReceipt = { transactionHash: "0xhash" } as unknown as TransactionReceipt; + + mockedEncodeFunctionData.mockReturnValueOnce(pauseCalldata).mockReturnValueOnce(unpauseCalldata); + blockchainClient.sendSignedTransaction.mockResolvedValue(txReceipt); + + const client = createClient(); + + await client.pauseStaking(yieldProvider); + expect(mockedEncodeFunctionData).toHaveBeenNthCalledWith(1, { + abi: contractStub.abi, + functionName: "pauseStaking", + args: [yieldProvider], + }); + expect(logger.info).toHaveBeenCalledWith( + `pauseStaking succeeded, yieldProvider=${yieldProvider}, txHash=${txReceipt.transactionHash}`, + ); + + await client.unpauseStaking(yieldProvider); + expect(mockedEncodeFunctionData).toHaveBeenNthCalledWith(2, { + abi: contractStub.abi, + functionName: "unpauseStaking", + args: [yieldProvider], + }); + expect(logger.info).toHaveBeenCalledWith( + `unpauseStaking succeeded, yieldProvider=${yieldProvider}, txHash=${txReceipt.transactionHash}`, + ); + }); + + it("progresses pending ossification", async () => { + const calldata = "0xprogress" as Hex; + mockedEncodeFunctionData.mockReturnValueOnce(calldata); + + const client = createClient(); + await client.progressPendingOssification(yieldProvider); + + expect(mockedEncodeFunctionData).toHaveBeenCalledWith({ + abi: contractStub.abi, + functionName: "progressPendingOssification", + args: [yieldProvider], + }); + expect(blockchainClient.sendSignedTransaction).toHaveBeenCalledWith(contractAddress, calldata); + }); + + it("evaluates rebalance requirements with tolerance band", async () => { + const totalSystemBalance = 1_000_000n; + const effectiveTarget = 500_000n; + contractStub.read.getTotalSystemBalance.mockResolvedValue(totalSystemBalance); + contractStub.read.getEffectiveTargetWithdrawalReserve.mockResolvedValue(effectiveTarget); + + const client = createClient({ rebalanceToleranceBps: 100 }); + + // Within tolerance band => no rebalance + blockchainClient.getBalance.mockResolvedValueOnce(effectiveTarget + 5_000n); + await expect(client.getRebalanceRequirements()).resolves.toEqual({ + rebalanceDirection: RebalanceDirection.NONE, + rebalanceAmount: 0n, + }); + + // Deficit => UNSTAKE + blockchainClient.getBalance.mockResolvedValueOnce(effectiveTarget - 20_000n); + await expect(client.getRebalanceRequirements()).resolves.toEqual({ + rebalanceDirection: RebalanceDirection.UNSTAKE, + rebalanceAmount: 20_000n, + }); + + // Surplus => STAKE + blockchainClient.getBalance.mockResolvedValueOnce(effectiveTarget + 30_000n); + await expect(client.getRebalanceRequirements()).resolves.toEqual({ + rebalanceDirection: RebalanceDirection.STAKE, + rebalanceAmount: 30_000n, + }); + }); + + it("returns staking vault and dashboard addresses from yield provider data", async () => { + contractStub.read.getYieldProviderData.mockResolvedValueOnce({ + ...defaultYieldProviderData, + primaryEntrypoint: l2Recipient, + ossifiedEntrypoint: stakingVaultAddress, + }); + + const client = createClient(); + + await expect(client.getLidoStakingVaultAddress(yieldProvider)).resolves.toBe(stakingVaultAddress); + await expect(client.getLidoDashboardAddress(yieldProvider)).resolves.toBe(l2Recipient); + }); + + it("pauses staking only when not already paused", async () => { + const txReceipt = { transactionHash: "0xpause" } as unknown as TransactionReceipt; + const client = createClient(); + const pauseSpy = jest.spyOn(client, "pauseStaking").mockResolvedValue(txReceipt); + + contractStub.read.isStakingPaused.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + await expect(client.pauseStakingIfNotAlready(yieldProvider)).resolves.toBe(txReceipt); + await expect(client.pauseStakingIfNotAlready(yieldProvider)).resolves.toBeUndefined(); + expect(logger.info).toHaveBeenCalledWith(`Already paused staking for yieldProvider=${yieldProvider}`); + expect(pauseSpy).toHaveBeenCalledTimes(1); + }); + + it("unpauses staking only when currently paused", async () => { + const txReceipt = { transactionHash: "0xunpause" } as unknown as TransactionReceipt; + const client = createClient(); + const unpauseSpy = jest.spyOn(client, "unpauseStaking").mockResolvedValue(txReceipt); + + contractStub.read.isStakingPaused.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + + await expect(client.unpauseStakingIfNotAlready(yieldProvider)).resolves.toBe(txReceipt); + await expect(client.unpauseStakingIfNotAlready(yieldProvider)).resolves.toBeUndefined(); + expect(logger.info).toHaveBeenCalledWith(`Already resumed staking for yieldProvider=${yieldProvider}`); + expect(unpauseSpy).toHaveBeenCalledTimes(1); + }); + + it("computes available unstaking rebalance balance", async () => { + blockchainClient.getBalance.mockResolvedValueOnce(1_000n); + contractStub.simulate.withdrawableValue.mockResolvedValueOnce({ result: 500n }); + + const client = createClient(); + await expect(client.getAvailableUnstakingRebalanceBalance(yieldProvider)).resolves.toBe(1_500n); + + expect(blockchainClient.getBalance).toHaveBeenCalledWith(contractAddress); + expect(contractStub.simulate.withdrawableValue).toHaveBeenCalledWith([yieldProvider]); + }); + + it("adds to withdrawal reserve only when above threshold", async () => { + const client = createClient({ minWithdrawalThresholdEth: 1n }); + const addSpy = jest.spyOn(client, "safeAddToWithdrawalReserve").mockResolvedValue(undefined as any); + jest + .spyOn(client, "getAvailableUnstakingRebalanceBalance") + .mockResolvedValueOnce(ONE_ETHER - 1n) + .mockResolvedValueOnce(ONE_ETHER + 100n); + + await expect(client.safeAddToWithdrawalReserveIfAboveThreshold(yieldProvider, 5n)).resolves.toBeUndefined(); + expect(addSpy).not.toHaveBeenCalled(); + + await client.safeAddToWithdrawalReserveIfAboveThreshold(yieldProvider, 7n); + expect(addSpy).toHaveBeenCalledWith(yieldProvider, 7n); + }); + + it("adds the full available balance when calling safeMaxAddToWithdrawalReserve", async () => { + const client = createClient({ minWithdrawalThresholdEth: 1n }); + const addSpy = jest.spyOn(client, "safeAddToWithdrawalReserve").mockResolvedValue(undefined as any); + const available = ONE_ETHER + 50n; + jest.spyOn(client, "getAvailableUnstakingRebalanceBalance").mockResolvedValue(available); + + await client.safeMaxAddToWithdrawalReserve(yieldProvider); + + expect(addSpy).toHaveBeenCalledWith(yieldProvider, available); + }); + + it("skips safeMaxAddToWithdrawalReserve when below the threshold", async () => { + const client = createClient({ minWithdrawalThresholdEth: 2n }); + const addSpy = jest.spyOn(client, "safeAddToWithdrawalReserve").mockResolvedValue(undefined as any); + jest.spyOn(client, "getAvailableUnstakingRebalanceBalance").mockResolvedValue(2n * ONE_ETHER - 1n); + + await expect(client.safeMaxAddToWithdrawalReserve(yieldProvider)).resolves.toBeUndefined(); + expect(addSpy).not.toHaveBeenCalled(); + }); + + it("extracts withdrawal events from receipts emitted by the contract", () => { + const client = createClient(); + const log = { address: contractAddress, data: "0xdata", topics: ["0x01"] }; + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "WithdrawalReserveAugmented", + args: { reserveIncrementAmount: 10n, yieldProvider }, + address: contractAddress, + } as any, + ]); + + const event = client.getWithdrawalEventFromTxReceipt(buildReceipt([log])); + + expect(event).toEqual({ reserveIncrementAmount: 10n, yieldProvider }); + expect(mockedParseEventLogs).toHaveBeenCalledWith({ + abi: contractStub.abi, + eventName: "WithdrawalReserveAugmented", + logs: [log], + }); + }); + + it("returns undefined when withdrawal events are absent or decoding fails", () => { + const client = createClient(); + mockedParseEventLogs.mockReturnValueOnce([]); + + const event = client.getWithdrawalEventFromTxReceipt( + buildReceipt([{ address: contractAddress.toUpperCase(), data: "0x", topics: [] }]), + ); + + expect(event).toBeUndefined(); + expect(mockedParseEventLogs).toHaveBeenCalledTimes(1); + }); + + it("ignores withdrawal events from other contracts", () => { + const client = createClient(); + const foreignLog = { address: "0x1234567890123456789012345678901234567890", data: "0x", topics: [] }; + + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "WithdrawalReserveAugmented", + args: { reserveIncrementAmount: 10n, yieldProvider }, + address: "0x1234567890123456789012345678901234567890", + } as any, + ]); + + const event = client.getWithdrawalEventFromTxReceipt(buildReceipt([foreignLog])); + + expect(event).toBeUndefined(); + expect(mockedParseEventLogs).toHaveBeenCalledTimes(1); + }); + + it("extracts yield reports from receipts emitted by the contract", () => { + const client = createClient(); + const log = { address: contractAddress, data: "0xfeed", topics: ["0x1111"] }; + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "NativeYieldReported", + args: { yieldAmount: 12n, outstandingNegativeYield: 5n, yieldProvider }, + address: contractAddress, + } as any, + ]); + + const report = client.getYieldReportFromTxReceipt(buildReceipt([log])); + + expect(report).toEqual({ yieldAmount: 12n, outstandingNegativeYield: 5n, yieldProvider }); + expect(mockedParseEventLogs).toHaveBeenCalledWith({ + abi: contractStub.abi, + eventName: "NativeYieldReported", + logs: [log], + }); + }); + + it("returns undefined when yield report events are absent", () => { + const client = createClient(); + mockedParseEventLogs.mockReturnValueOnce([]); + + const report = client.getYieldReportFromTxReceipt( + buildReceipt([{ address: contractAddress, data: "0x0", topics: [] }]), + ); + + expect(report).toBeUndefined(); + }); + + it("ignores yield report logs from other contracts", () => { + const client = createClient(); + const foreignLog = { address: "0x1234567890123456789012345678901234567890", data: "0x", topics: [] }; + + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "NativeYieldReported", + args: { yieldAmount: 12n, outstandingNegativeYield: 5n, yieldProvider }, + address: "0x1234567890123456789012345678901234567890", + } as any, + ]); + + const report = client.getYieldReportFromTxReceipt(buildReceipt([foreignLog])); + + expect(report).toBeUndefined(); + expect(mockedParseEventLogs).toHaveBeenCalledTimes(1); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/contracts/__tests__/getNodeOperatorFeesPaidFromTxReceipt.test.ts b/native-yield-operations/automation-service/src/clients/contracts/__tests__/getNodeOperatorFeesPaidFromTxReceipt.test.ts new file mode 100644 index 0000000000..a268924303 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/__tests__/getNodeOperatorFeesPaidFromTxReceipt.test.ts @@ -0,0 +1,105 @@ +import type { Address, TransactionReceipt } from "viem"; +import { DashboardABI } from "../../../core/abis/Dashboard.js"; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + parseEventLogs: jest.fn(), + }; +}); + +import { parseEventLogs } from "viem"; + +const mockedParseEventLogs = parseEventLogs as jest.MockedFunction; + +let getNodeOperatorFeesPaidFromTxReceipt: typeof import("../getNodeOperatorFeesPaidFromTxReceipt.js").getNodeOperatorFeesPaidFromTxReceipt; + +beforeAll(async () => { + ({ getNodeOperatorFeesPaidFromTxReceipt } = await import("../getNodeOperatorFeesPaidFromTxReceipt.js")); +}); + +describe("getNodeOperatorFeesPaidFromTxReceipt", () => { + const dashboardAddress = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Address; + + const buildReceipt = (logs: Array<{ address: string; data: string; topics: string[] }>): TransactionReceipt => + ({ + logs, + }) as unknown as TransactionReceipt; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns the fee from a FeeDisbursed event emitted by the dashboard", () => { + const receipt = buildReceipt([ + { + address: dashboardAddress, + data: "0xfee", + topics: ["0x01"], + }, + ]); + + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "FeeDisbursed", + args: { fee: 123n }, + address: dashboardAddress, + } as any, + ]); + + const fee = getNodeOperatorFeesPaidFromTxReceipt(receipt, dashboardAddress); + + expect(fee).toBe(123n); + expect(mockedParseEventLogs).toHaveBeenCalledWith({ + abi: DashboardABI, + eventName: "FeeDisbursed", + logs: receipt.logs, + }); + }); + + it("returns zero when decoding fails for the dashboard log", () => { + const receipt = buildReceipt([ + { + address: dashboardAddress.toUpperCase(), + data: "0xdead", + topics: [], + }, + ]); + + mockedParseEventLogs.mockReturnValueOnce([]); + + const fee = getNodeOperatorFeesPaidFromTxReceipt(receipt, dashboardAddress); + + expect(fee).toBe(0n); + expect(mockedParseEventLogs).toHaveBeenCalledTimes(1); + }); + + it("ignores logs from other contracts or events and returns zero", () => { + const receipt = buildReceipt([ + { + address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + data: "0x00", + topics: [], + }, + { + address: dashboardAddress, + data: "0x01", + topics: [], + }, + ]); + + mockedParseEventLogs.mockReturnValueOnce([ + { + eventName: "FeeDisbursed", + args: { fee: 456n }, + address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + } as any, + ]); + + const fee = getNodeOperatorFeesPaidFromTxReceipt(receipt, dashboardAddress); + + expect(fee).toBe(0n); + expect(mockedParseEventLogs).toHaveBeenCalledTimes(1); + }); +}); diff --git a/native-yield-operations/automation-service/src/clients/contracts/getNodeOperatorFeesPaidFromTxReceipt.ts b/native-yield-operations/automation-service/src/clients/contracts/getNodeOperatorFeesPaidFromTxReceipt.ts new file mode 100644 index 0000000000..c8789a3766 --- /dev/null +++ b/native-yield-operations/automation-service/src/clients/contracts/getNodeOperatorFeesPaidFromTxReceipt.ts @@ -0,0 +1,27 @@ +import { Address, parseEventLogs, TransactionReceipt } from "viem"; +import { DashboardABI } from "../../core/abis/Dashboard.js"; + +// Functions that would be in a DashboardClient if we had one +// But DashboardClient cannot have a fixed address - we can have multiple Dashboard.sol contracts + +/** + * Extracts the node operator fee amount from a transaction receipt by decoding FeeDisbursed events. + * Functions that would be in a DashboardClient if we had one, but DashboardClient cannot have a fixed address + * since we can have multiple Dashboard.sol contracts. + * Only decodes logs emitted by the specified dashboard contract. Skips unrelated logs (from the same contract or different ABIs). + * If event not found, returns 0n. + * + * @param {TransactionReceipt} txReceipt - The transaction receipt to search for FeeDisbursed events. + * @param {Address} dashboardAddress - The address of the Dashboard contract to filter logs by. + * @returns {bigint} The fee amount from the FeeDisbursed event, or 0n if the event is not found. + */ +export function getNodeOperatorFeesPaidFromTxReceipt(txReceipt: TransactionReceipt, dashboardAddress: Address): bigint { + const logs = parseEventLogs({ + abi: DashboardABI, + eventName: "FeeDisbursed", + logs: txReceipt.logs, + }); + + const fee = logs.find((log) => log.address.toLowerCase() === dashboardAddress.toLowerCase())?.args.fee ?? 0n; + return fee ?? 0n; +} diff --git a/native-yield-operations/automation-service/src/core/abis/Dashboard.ts b/native-yield-operations/automation-service/src/core/abis/Dashboard.ts new file mode 100644 index 0000000000..fd3db06e49 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/abis/Dashboard.ts @@ -0,0 +1,11 @@ +export const DashboardABI = [ + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "sender", type: "address" }, + { indexed: false, internalType: "uint256", name: "fee", type: "uint256" }, + ], + name: "FeeDisbursed", + type: "event", + }, +] as const; diff --git a/native-yield-operations/automation-service/src/core/abis/LazyOracle.ts b/native-yield-operations/automation-service/src/core/abis/LazyOracle.ts new file mode 100644 index 0000000000..7f2a8bd80a --- /dev/null +++ b/native-yield-operations/automation-service/src/core/abis/LazyOracle.ts @@ -0,0 +1,136 @@ +export const LazyOracleABI = [ + { + inputs: [ + { + internalType: "address", + name: "_vault", + type: "address", + }, + { + internalType: "uint256", + name: "_totalValue", + type: "uint256", + }, + { + internalType: "uint256", + name: "_cumulativeLidoFees", + type: "uint256", + }, + { + internalType: "uint256", + name: "_liabilityShares", + type: "uint256", + }, + { + internalType: "uint256", + name: "_maxLiabilityShares", + type: "uint256", + }, + { + internalType: "uint256", + name: "_slashingReserve", + type: "uint256", + }, + { + internalType: "bytes32[]", + name: "_proof", + type: "bytes32[]", + }, + ], + name: "updateVaultData", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "timestamp", + type: "uint256", + }, + { + indexed: true, + internalType: "uint256", + name: "refSlot", + type: "uint256", + }, + { + indexed: true, + internalType: "bytes32", + name: "root", + type: "bytes32", + }, + { + indexed: false, + internalType: "string", + name: "cid", + type: "string", + }, + ], + name: "VaultsReportDataUpdated", + type: "event", + }, + { + inputs: [], + name: "latestReportData", + outputs: [ + { internalType: "uint256", name: "timestamp", type: "uint256" }, + { internalType: "uint256", name: "refSlot", type: "uint256" }, + { internalType: "bytes32", name: "treeRoot", type: "bytes32" }, + { internalType: "string", name: "reportCid", type: "string" }, + ], + stateMutability: "view", + type: "function", + }, + { inputs: [], name: "InvalidProof", type: "error" }, + { + inputs: [], + name: "VaultReportIsFreshEnough", + type: "error", + }, + { + inputs: [], + name: "UnderflowInTotalValueCalculation", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "feeIncrease", + type: "uint256", + }, + { + internalType: "uint256", + name: "maxFeeIncrease", + type: "uint256", + }, + ], + name: "CumulativeLidoFeesTooLarge", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "reportingFees", + type: "uint256", + }, + { + internalType: "uint256", + name: "previousFees", + type: "uint256", + }, + ], + name: "CumulativeLidoFeesTooLow", + type: "error", + }, + { + inputs: [], + name: "InvalidMaxLiabilityShares", + type: "error", + }, +] as const; diff --git a/native-yield-operations/automation-service/src/core/abis/LineaRollupYieldExtension.ts b/native-yield-operations/automation-service/src/core/abis/LineaRollupYieldExtension.ts new file mode 100644 index 0000000000..fc523ab901 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/abis/LineaRollupYieldExtension.ts @@ -0,0 +1,15 @@ +export const LineaRollupYieldExtensionABI = [ + { + inputs: [ + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "transferFundsForNativeYield", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; diff --git a/native-yield-operations/automation-service/src/core/abis/StakingVault.ts b/native-yield-operations/automation-service/src/core/abis/StakingVault.ts new file mode 100644 index 0000000000..f80f77537a --- /dev/null +++ b/native-yield-operations/automation-service/src/core/abis/StakingVault.ts @@ -0,0 +1,9 @@ +export const StakingVaultABI = [ + { + inputs: [{ internalType: "uint256", name: "_numberOfKeys", type: "uint256" }], + name: "calculateValidatorWithdrawalFee", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/native-yield-operations/automation-service/src/core/abis/VaultHub.ts b/native-yield-operations/automation-service/src/core/abis/VaultHub.ts new file mode 100644 index 0000000000..fed4b82b85 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/abis/VaultHub.ts @@ -0,0 +1,23 @@ +export const VaultHubABI = [ + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "vault", type: "address" }, + { indexed: false, internalType: "uint256", name: "transferred", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "cumulativeLidoFees", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "settledLidoFees", type: "uint256" }, + ], + name: "LidoFeesSettled", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "vault", type: "address" }, + { indexed: false, internalType: "uint256", name: "sharesBurned", type: "uint256" }, + { indexed: false, internalType: "uint256", name: "etherWithdrawn", type: "uint256" }, + ], + name: "VaultRebalanced", + type: "event", + }, +] as const; diff --git a/native-yield-operations/automation-service/src/core/abis/YieldManager.ts b/native-yield-operations/automation-service/src/core/abis/YieldManager.ts new file mode 100644 index 0000000000..59b3d25ea1 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/abis/YieldManager.ts @@ -0,0 +1,2313 @@ +export const YieldManagerABI = [ + { + inputs: [ + { + internalType: "address", + name: "_l1MessageService", + type: "address", + }, + ], + stateMutability: "nonpayable", + type: "constructor", + }, + { + inputs: [], + name: "AlreadyOssified", + type: "error", + }, + { + inputs: [], + name: "BpsMoreThan10000", + type: "error", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "role1", + type: "bytes32", + }, + { + internalType: "bytes32", + name: "role2", + type: "bytes32", + }, + ], + name: "CallerMissingRole", + type: "error", + }, + { + inputs: [], + name: "DelegateCallFailed", + type: "error", + }, + { + inputs: [], + name: "InsufficientWithdrawalReserve", + type: "error", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + ], + name: "IsNotPaused", + type: "error", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + ], + name: "IsPaused", + type: "error", + }, + { + inputs: [], + name: "L2YieldRecipientAlreadyAdded", + type: "error", + }, + { + inputs: [], + name: "LSTWithdrawalExceedsYieldProviderFunds", + type: "error", + }, + { + inputs: [], + name: "LSTWithdrawalNotAllowed", + type: "error", + }, + { + inputs: [], + name: "NoAvailableFundsToReplenishWithdrawalReserve", + type: "error", + }, + { + inputs: [], + name: "OssificationNotInitiated", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "expiryEnd", + type: "uint256", + }, + ], + name: "PauseNotExpired", + type: "error", + }, + { + inputs: [], + name: "PauseTypeNotUsed", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "cooldownEnd", + type: "uint256", + }, + ], + name: "PauseUnavailableDueToCooldown", + type: "error", + }, + { + inputs: [], + name: "PermissionlessUnstakeRequestPlusAvailableFundsExceedsTargetDeficit", + type: "error", + }, + { + inputs: [], + name: "RolesNotDifferent", + type: "error", + }, + { + inputs: [], + name: "SenderNotL1MessageService", + type: "error", + }, + { + inputs: [], + name: "StakingAlreadyPaused", + type: "error", + }, + { + inputs: [], + name: "StakingAlreadyUnpaused", + type: "error", + }, + { + inputs: [], + name: "TargetReserveAmountMustBeAboveMinimum", + type: "error", + }, + { + inputs: [], + name: "TargetReservePercentageMustBeAboveMinimum", + type: "error", + }, + { + inputs: [], + name: "UnknownL2YieldRecipient", + type: "error", + }, + { + inputs: [], + name: "UnknownYieldProvider", + type: "error", + }, + { + inputs: [], + name: "UnpauseStakingForbiddenDuringPendingOssification", + type: "error", + }, + { + inputs: [], + name: "UnpauseStakingForbiddenWithCurrentLSTLiability", + type: "error", + }, + { + inputs: [], + name: "WithdrawalReserveNotInDeficit", + type: "error", + }, + { + inputs: [], + name: "YieldProviderAlreadyAdded", + type: "error", + }, + { + inputs: [], + name: "YieldProviderHasRemainingFunds", + type: "error", + }, + { + inputs: [], + name: "YieldProviderHasRemainingNegativeYield", + type: "error", + }, + { + inputs: [ + { + internalType: "uint256", + name: "index", + type: "uint256", + }, + { + internalType: "uint256", + name: "count", + type: "uint256", + }, + ], + name: "YieldProviderIndexOutOfBounds", + type: "error", + }, + { + inputs: [], + name: "ZeroAddressNotAllowed", + type: "error", + }, + { + inputs: [], + name: "ZeroHashNotAllowed", + type: "error", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "DonationProcessed", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint8", + name: "version", + type: "uint8", + }, + ], + name: "Initialized", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "address", + name: "l2YieldRecipient", + type: "address", + }, + ], + name: "L2YieldRecipientAdded", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "address", + name: "l2YieldRecipient", + type: "address", + }, + ], + name: "L2YieldRecipientRemoved", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "recipient", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "LSTMinted", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "l2YieldRecipient", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "yieldAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "outstandingNegativeYield", + type: "uint256", + }, + ], + name: "NativeYieldReported", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + { + indexed: true, + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + ], + name: "PauseTypeRoleSet", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + { + indexed: true, + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + indexed: true, + internalType: "bytes32", + name: "previousRole", + type: "bytes32", + }, + ], + name: "PauseTypeRoleUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "address", + name: "messageSender", + type: "address", + }, + { + indexed: true, + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + ], + name: "Paused", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + ], + name: "ReserveFundsReceived", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + indexed: true, + internalType: "bytes32", + name: "previousAdminRole", + type: "bytes32", + }, + { + indexed: true, + internalType: "bytes32", + name: "newAdminRole", + type: "bytes32", + }, + ], + name: "RoleAdminChanged", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + ], + name: "RoleGranted", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "sender", + type: "address", + }, + ], + name: "RoleRevoked", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "enum IPauseManager.PauseType", + name: "unPauseType", + type: "uint8", + }, + { + indexed: true, + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + ], + name: "UnPauseTypeRoleSet", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "enum IPauseManager.PauseType", + name: "unPauseType", + type: "uint8", + }, + { + indexed: true, + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + indexed: true, + internalType: "bytes32", + name: "previousRole", + type: "bytes32", + }, + ], + name: "UnPauseTypeRoleUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "address", + name: "messageSender", + type: "address", + }, + { + indexed: true, + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + ], + name: "UnPaused", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + ], + name: "UnPausedDueToExpiry", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "requestedAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "reserveIncrementAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "fromYieldManager", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "fromYieldProvider", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "lstPrincipalPaid", + type: "uint256", + }, + ], + name: "WithdrawalReserveAugmented", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: false, + internalType: "uint256", + name: "oldMinimumWithdrawalReservePercentageBps", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "newMinimumWithdrawalReservePercentageBps", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "oldMinimumWithdrawalReserveAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "newMinimumWithdrawalReserveAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "oldTargetWithdrawalReservePercentageBps", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "newTargetWithdrawalReservePercentageBps", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "oldTargetWithdrawalReserveAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "newTargetWithdrawalReserveAmount", + type: "uint256", + }, + ], + name: "WithdrawalReserveParametersSet", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "targetDeficit", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "reserveIncrementAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "fromYieldManager", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "fromYieldProvider", + type: "uint256", + }, + ], + name: "WithdrawalReserveReplenished", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: true, + internalType: "enum YieldManagerStorageLayout.YieldProviderVendor", + name: "yieldProviderVendor", + type: "uint8", + }, + { + indexed: false, + internalType: "address", + name: "primaryEntrypoint", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "ossifiedEntrypoint", + type: "address", + }, + ], + name: "YieldProviderAdded", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "userFundsIncrement", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "lstPrincipalRepaid", + type: "uint256", + }, + ], + name: "YieldProviderFunded", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + ], + name: "YieldProviderOssificationInitiated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: false, + internalType: "enum IYieldProvider.ProgressOssificationResult", + name: "progressOssificationResult", + type: "uint8", + }, + ], + name: "YieldProviderOssificationProcessed", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: false, + internalType: "bool", + name: "emergencyRemoval", + type: "bool", + }, + ], + name: "YieldProviderRemoved", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + ], + name: "YieldProviderStakingPaused", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + ], + name: "YieldProviderStakingUnpaused", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "yieldProvider", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "amountRequested", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "amountWithdrawn", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "reserveIncrementAmount", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "lstPrincipalPaid", + type: "uint256", + }, + ], + name: "YieldProviderWithdrawal", + type: "event", + }, + { + inputs: [], + name: "COOLDOWN_DURATION", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "DEFAULT_ADMIN_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "L1_MESSAGE_SERVICE", + outputs: [ + { + internalType: "address", + name: "", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "OSSIFICATION_INITIATOR_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "OSSIFICATION_PROCESSOR_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "PAUSE_ALL_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "PAUSE_DURATION", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "PAUSE_NATIVE_YIELD_DONATION_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "PAUSE_NATIVE_YIELD_PERMISSIONLESS_ACTIONS_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "PAUSE_NATIVE_YIELD_REPORTING_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "PAUSE_NATIVE_YIELD_STAKING_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "PAUSE_NATIVE_YIELD_UNSTAKING_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "SECURITY_COUNCIL_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "SET_L2_YIELD_RECIPIENT_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "SET_YIELD_PROVIDER_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "STAKING_PAUSE_CONTROLLER_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "UNPAUSE_ALL_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "UNPAUSE_NATIVE_YIELD_DONATION_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "UNPAUSE_NATIVE_YIELD_PERMISSIONLESS_ACTIONS_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "UNPAUSE_NATIVE_YIELD_REPORTING_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "UNPAUSE_NATIVE_YIELD_STAKING_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "UNPAUSE_NATIVE_YIELD_UNSTAKING_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "WITHDRAWAL_RESERVE_SETTER_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "YIELD_PROVIDER_STAKING_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "YIELD_PROVIDER_UNSTAKER_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "YIELD_REPORTER_ROLE", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_l2YieldRecipient", + type: "address", + }, + ], + name: "addL2YieldRecipient", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "addToWithdrawalReserve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "safeAddToWithdrawalReserve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + components: [ + { + internalType: "enum YieldManagerStorageLayout.YieldProviderVendor", + name: "yieldProviderVendor", + type: "uint8", + }, + { + internalType: "address", + name: "primaryEntrypoint", + type: "address", + }, + { + internalType: "address", + name: "ossifiedEntrypoint", + type: "address", + }, + ], + internalType: "struct YieldManagerStorageLayout.YieldProviderRegistration", + name: "_registration", + type: "tuple", + }, + ], + name: "addYieldProvider", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "emergencyRemoveYieldProvider", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "fundYieldProvider", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "getEffectiveMinimumWithdrawalReserve", + outputs: [ + { + internalType: "uint256", + name: "minimumWithdrawalReserve", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getEffectiveTargetWithdrawalReserve", + outputs: [ + { + internalType: "uint256", + name: "targetWithdrawalReserve", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getMinimumReserveDeficit", + outputs: [ + { + internalType: "uint256", + name: "minimumReserveDeficit", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + ], + name: "getRoleAdmin", + outputs: [ + { + internalType: "bytes32", + name: "", + type: "bytes32", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getTargetReserveDeficit", + outputs: [ + { + internalType: "uint256", + name: "targetReserveDeficit", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "getTotalSystemBalance", + outputs: [ + { + internalType: "uint256", + name: "totalSystemBalance", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "getYieldProviderData", + outputs: [ + { + components: [ + { + internalType: "enum YieldManagerStorageLayout.YieldProviderVendor", + name: "yieldProviderVendor", + type: "uint8", + }, + { + internalType: "bool", + name: "isStakingPaused", + type: "bool", + }, + { + internalType: "bool", + name: "isOssificationInitiated", + type: "bool", + }, + { + internalType: "bool", + name: "isOssified", + type: "bool", + }, + { + internalType: "address", + name: "primaryEntrypoint", + type: "address", + }, + { + internalType: "address", + name: "ossifiedEntrypoint", + type: "address", + }, + { + internalType: "uint96", + name: "yieldProviderIndex", + type: "uint96", + }, + { + internalType: "uint256", + name: "userFunds", + type: "uint256", + }, + { + internalType: "uint256", + name: "yieldReportedCumulative", + type: "uint256", + }, + { + internalType: "uint256", + name: "lstLiabilityPrincipal", + type: "uint256", + }, + ], + internalType: "struct YieldManagerStorageLayout.YieldProviderStorage", + name: "yieldProviderData", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "grantRole", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "hasRole", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + components: [ + { + components: [ + { + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + ], + internalType: "struct IPauseManager.PauseTypeRole[]", + name: "pauseTypeRoles", + type: "tuple[]", + }, + { + components: [ + { + internalType: "enum IPauseManager.PauseType", + name: "pauseType", + type: "uint8", + }, + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + ], + internalType: "struct IPauseManager.PauseTypeRole[]", + name: "unpauseTypeRoles", + type: "tuple[]", + }, + { + components: [ + { + internalType: "address", + name: "addressWithRole", + type: "address", + }, + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + ], + internalType: "struct IPermissionsManager.RoleAddress[]", + name: "roleAddresses", + type: "tuple[]", + }, + { + internalType: "address[]", + name: "initialL2YieldRecipients", + type: "address[]", + }, + { + internalType: "address", + name: "defaultAdmin", + type: "address", + }, + { + internalType: "uint16", + name: "initialMinimumWithdrawalReservePercentageBps", + type: "uint16", + }, + { + internalType: "uint16", + name: "initialTargetWithdrawalReservePercentageBps", + type: "uint16", + }, + { + internalType: "uint256", + name: "initialMinimumWithdrawalReserveAmount", + type: "uint256", + }, + { + internalType: "uint256", + name: "initialTargetWithdrawalReserveAmount", + type: "uint256", + }, + ], + internalType: "struct IYieldManager.YieldManagerInitializationData", + name: "_initializationData", + type: "tuple", + }, + ], + name: "initialize", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "initiateOssification", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_l2YieldRecipient", + type: "address", + }, + ], + name: "isL2YieldRecipientKnown", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "isOssificationInitiated", + outputs: [ + { + internalType: "bool", + name: "isInitiated", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "isOssified", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "_pauseType", + type: "uint8", + }, + ], + name: "isPaused", + outputs: [ + { + internalType: "bool", + name: "pauseTypeIsPaused", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "isStakingPaused", + outputs: [ + { + internalType: "bool", + name: "isPaused", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "isWithdrawalReserveBelowMinimum", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "isYieldProviderKnown", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "minimumWithdrawalReserveAmount", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "minimumWithdrawalReservePercentageBps", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "_pauseType", + type: "uint8", + }, + ], + name: "pauseByType", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "pauseExpiryTimestamp", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "pauseStaking", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "pendingPermissionlessUnstake", + outputs: [ + { + internalType: "uint256", + name: "pendingUnstake", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "progressPendingOssification", + outputs: [ + { + internalType: "enum IYieldProvider.ProgressOssificationResult", + name: "progressOssificationResult", + type: "uint8", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "receiveFundsFromReserve", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_l2YieldRecipient", + type: "address", + }, + ], + name: "removeL2YieldRecipient", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "removeYieldProvider", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "renounceRole", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "replenishWithdrawalReserve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "address", + name: "_l2YieldRecipient", + type: "address", + }, + ], + name: "reportYield", + outputs: [ + { + internalType: "uint256", + name: "newReportedYield", + type: "uint256", + }, + { + internalType: "uint256", + name: "outstandingNegativeYield", + type: "uint256", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes32", + name: "role", + type: "bytes32", + }, + { + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "revokeRole", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + components: [ + { + internalType: "uint16", + name: "minimumWithdrawalReservePercentageBps", + type: "uint16", + }, + { + internalType: "uint16", + name: "targetWithdrawalReservePercentageBps", + type: "uint16", + }, + { + internalType: "uint256", + name: "minimumWithdrawalReserveAmount", + type: "uint256", + }, + { + internalType: "uint256", + name: "targetWithdrawalReserveAmount", + type: "uint256", + }, + ], + internalType: "struct IYieldManager.UpdateReserveParametersConfig", + name: "_params", + type: "tuple", + }, + ], + name: "setWithdrawalReserveParameters", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "bytes4", + name: "interfaceId", + type: "bytes4", + }, + ], + name: "supportsInterface", + outputs: [ + { + internalType: "bool", + name: "", + type: "bool", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "targetWithdrawalReserveAmount", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "targetWithdrawalReservePercentageBps", + outputs: [ + { + internalType: "uint256", + name: "", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "transferFundsToReserve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "_pauseType", + type: "uint8", + }, + ], + name: "unPauseByExpiredType", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "_pauseType", + type: "uint8", + }, + ], + name: "unPauseByType", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "unpauseStaking", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "bytes", + name: "_withdrawalParams", + type: "bytes", + }, + ], + name: "unstake", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "bytes", + name: "_withdrawalParams", + type: "bytes", + }, + { + internalType: "bytes", + name: "_withdrawalParamsProof", + type: "bytes", + }, + ], + name: "unstakePermissionless", + outputs: [ + { + internalType: "uint256", + name: "maxUnstakeAmount", + type: "uint256", + }, + ], + stateMutability: "payable", + type: "function", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "_pauseType", + type: "uint8", + }, + { + internalType: "bytes32", + name: "_newRole", + type: "bytes32", + }, + ], + name: "updatePauseTypeRole", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "enum IPauseManager.PauseType", + name: "_pauseType", + type: "uint8", + }, + { + internalType: "bytes32", + name: "_newRole", + type: "bytes32", + }, + ], + name: "updateUnpauseTypeRole", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "userFunds", + outputs: [ + { + internalType: "uint256", + name: "funds", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "userFundsInYieldProvidersTotal", + outputs: [ + { + internalType: "uint256", + name: "totalUserFunds", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + ], + name: "withdrawFromYieldProvider", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + { + internalType: "uint256", + name: "_amount", + type: "uint256", + }, + { + internalType: "address", + name: "_recipient", + type: "address", + }, + ], + name: "withdrawLST", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "address", + name: "_yieldProvider", + type: "address", + }, + ], + name: "withdrawableValue", + outputs: [ + { + internalType: "uint256", + name: "withdrawableAmount", + type: "uint256", + }, + ], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { + internalType: "uint256", + name: "_index", + type: "uint256", + }, + ], + name: "yieldProviderByIndex", + outputs: [ + { + internalType: "address", + name: "yieldProvider", + type: "address", + }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "yieldProviderCount", + outputs: [ + { + internalType: "uint256", + name: "count", + type: "uint256", + }, + ], + stateMutability: "view", + type: "function", + }, + { + stateMutability: "payable", + type: "receive", + }, +] as const; diff --git a/native-yield-operations/automation-service/src/core/clients/IBeaconChainStakingClient.ts b/native-yield-operations/automation-service/src/core/clients/IBeaconChainStakingClient.ts new file mode 100644 index 0000000000..cd23bc4e72 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/clients/IBeaconChainStakingClient.ts @@ -0,0 +1,4 @@ +export interface IBeaconChainStakingClient { + submitWithdrawalRequestsToFulfilAmount(amountWei: bigint): Promise; + submitMaxAvailableWithdrawalRequests(): Promise; +} diff --git a/native-yield-operations/automation-service/src/core/clients/ILidoAccountingReportClient.ts b/native-yield-operations/automation-service/src/core/clients/ILidoAccountingReportClient.ts new file mode 100644 index 0000000000..ed8529dbcc --- /dev/null +++ b/native-yield-operations/automation-service/src/core/clients/ILidoAccountingReportClient.ts @@ -0,0 +1,7 @@ +import { Address } from "viem"; +import { UpdateVaultDataParams } from "./contracts/ILazyOracle.js"; + +export interface ILidoAccountingReportClient { + getLatestSubmitVaultReportParams(vault: Address): Promise; + submitLatestVaultReport(vault: Address): Promise; +} diff --git a/native-yield-operations/automation-service/src/core/clients/IValidatorDataClient.ts b/native-yield-operations/automation-service/src/core/clients/IValidatorDataClient.ts new file mode 100644 index 0000000000..6e38af715b --- /dev/null +++ b/native-yield-operations/automation-service/src/core/clients/IValidatorDataClient.ts @@ -0,0 +1,7 @@ +import { ValidatorBalance, ValidatorBalanceWithPendingWithdrawal } from "../entities/ValidatorBalance.js"; + +export interface IValidatorDataClient { + getActiveValidators(): Promise; + getActiveValidatorsWithPendingWithdrawals(): Promise; + getTotalPendingPartialWithdrawalsWei(validatorList: ValidatorBalanceWithPendingWithdrawal[]): bigint; +} diff --git a/native-yield-operations/automation-service/src/core/clients/contracts/ILazyOracle.ts b/native-yield-operations/automation-service/src/core/clients/contracts/ILazyOracle.ts new file mode 100644 index 0000000000..3b43bfe731 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/clients/contracts/ILazyOracle.ts @@ -0,0 +1,37 @@ +import { Address, Hex } from "viem"; +import { IBaseContractClient } from "@consensys/linea-shared-utils"; +import { OperationTrigger } from "../../metrics/LineaNativeYieldAutomationServiceMetrics.js"; + +export interface ILazyOracle extends IBaseContractClient { + updateVaultData(params: UpdateVaultDataParams): Promise; + latestReportData(): Promise; + waitForVaultsReportDataUpdatedEvent(): Promise; +} + +export interface UpdateVaultDataParams { + vault: Address; + totalValue: bigint; + cumulativeLidoFees: bigint; + liabilityShares: bigint; + maxLiabilityShares: bigint; + slashingReserve: bigint; + proof: Hex[]; +} + +export type WaitForVaultReportDataEventResult = VaultReportResult | TimeoutResult; +export interface VaultReportResult { + result: OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT; + report: LazyOracleReportData; + txHash: Hex; +} + +export interface TimeoutResult { + result: OperationTrigger.TIMEOUT; +} + +export interface LazyOracleReportData { + timestamp: bigint; + refSlot: bigint; + treeRoot: Hex; + reportCid: string; +} diff --git a/native-yield-operations/automation-service/src/core/clients/contracts/ILineaRollupYieldExtension.ts b/native-yield-operations/automation-service/src/core/clients/contracts/ILineaRollupYieldExtension.ts new file mode 100644 index 0000000000..527ab75e3a --- /dev/null +++ b/native-yield-operations/automation-service/src/core/clients/contracts/ILineaRollupYieldExtension.ts @@ -0,0 +1,5 @@ +import { IBaseContractClient } from "@consensys/linea-shared-utils"; + +export interface ILineaRollupYieldExtension extends IBaseContractClient { + transferFundsForNativeYield(amount: bigint): Promise; +} diff --git a/native-yield-operations/automation-service/src/core/clients/contracts/IVaultHub.ts b/native-yield-operations/automation-service/src/core/clients/contracts/IVaultHub.ts new file mode 100644 index 0000000000..e28f5cf5ba --- /dev/null +++ b/native-yield-operations/automation-service/src/core/clients/contracts/IVaultHub.ts @@ -0,0 +1,6 @@ +import { IBaseContractClient } from "@consensys/linea-shared-utils"; + +export interface IVaultHub extends IBaseContractClient { + getLiabilityPaymentFromTxReceipt(txReceipt: TTransactionReceipt): bigint; + getLidoFeePaymentFromTxReceipt(txReceipt: TTransactionReceipt): bigint; +} diff --git a/native-yield-operations/automation-service/src/core/clients/contracts/IYieldManager.ts b/native-yield-operations/automation-service/src/core/clients/contracts/IYieldManager.ts new file mode 100644 index 0000000000..a66e7e52e9 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/clients/contracts/IYieldManager.ts @@ -0,0 +1,54 @@ +import { Address } from "viem"; +import { WithdrawalRequests } from "../../entities/LidoStakingVaultWithdrawalParams.js"; +import { RebalanceRequirement } from "../../entities/RebalanceRequirement.js"; +import { IBaseContractClient } from "@consensys/linea-shared-utils"; +import { YieldReport } from "../../entities/YieldReport.js"; +import { WithdrawalEvent } from "../../entities/WithdrawalEvent.js"; + +export interface IYieldManager extends IBaseContractClient { + // View calls + L1_MESSAGE_SERVICE(): Promise
; + getTotalSystemBalance(): Promise; + getEffectiveTargetWithdrawalReserve(): Promise; + getTargetReserveDeficit(): Promise; + isStakingPaused(yieldProvider: Address): Promise; + isOssificationInitiated(yieldProvider: Address): Promise; + isOssified(yieldProvider: Address): Promise; + withdrawableValue(yieldProvider: Address): Promise; + getYieldProviderData(yieldProvider: Address): Promise; + // Mutator calls + fundYieldProvider(yieldProvider: Address, amount: bigint): Promise; + reportYield(yieldProvider: Address, l2YieldRecipient: Address): Promise; + unstake(yieldProvider: Address, withdrawalParams: WithdrawalRequests): Promise; + safeAddToWithdrawalReserve(yieldProvider: Address, amount: bigint): Promise; + pauseStaking(yieldProvider: Address): Promise; + unpauseStaking(yieldProvider: Address): Promise; + progressPendingOssification(yieldProvider: Address): Promise; + // Utility methods + getRebalanceRequirements(): Promise; + getLidoStakingVaultAddress(yieldProvider: Address): Promise
; + getLidoDashboardAddress(yieldProvider: Address): Promise
; + pauseStakingIfNotAlready(yieldProvider: Address): Promise; + unpauseStakingIfNotAlready(yieldProvider: Address): Promise; + getAvailableUnstakingRebalanceBalance(yieldProvider: Address): Promise; + safeAddToWithdrawalReserveIfAboveThreshold( + yieldProvider: Address, + amount: bigint, + ): Promise; + safeMaxAddToWithdrawalReserve(yieldProvider: Address): Promise; + getWithdrawalEventFromTxReceipt(txReceipt: TTransactionReceipt): WithdrawalEvent | undefined; + getYieldReportFromTxReceipt(txReceipt: TTransactionReceipt): YieldReport | undefined; +} + +export interface YieldProviderData { + yieldProviderVendor: number; // enum uint8 + isStakingPaused: boolean; + isOssificationInitiated: boolean; + isOssified: boolean; + primaryEntrypoint: Address; + ossifiedEntrypoint: Address; + yieldProviderIndex: bigint; // uint96 + userFunds: bigint; // uint256 + yieldReportedCumulative: bigint; // uint256 + lstLiabilityPrincipal: bigint; // uint256 +} diff --git a/native-yield-operations/automation-service/src/core/entities/LidoStakingVaultWithdrawalParams.ts b/native-yield-operations/automation-service/src/core/entities/LidoStakingVaultWithdrawalParams.ts new file mode 100644 index 0000000000..6a4715cfe9 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/entities/LidoStakingVaultWithdrawalParams.ts @@ -0,0 +1,10 @@ +import { Address, Hex } from "viem"; + +export interface WithdrawalRequests { + pubkeys: Hex[]; + amountsGwei: bigint[]; +} + +export interface LidoStakingVaultWithdrawalParams extends WithdrawalRequests { + refundRecipient: Address; +} diff --git a/native-yield-operations/automation-service/src/core/entities/RebalanceRequirement.ts b/native-yield-operations/automation-service/src/core/entities/RebalanceRequirement.ts new file mode 100644 index 0000000000..45a3f01a31 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/entities/RebalanceRequirement.ts @@ -0,0 +1,10 @@ +export interface RebalanceRequirement { + rebalanceDirection: RebalanceDirection; + rebalanceAmount: bigint; +} + +export enum RebalanceDirection { + NONE = "NONE", + STAKE = "STAKE", // Reserve excess + UNSTAKE = "UNSTAKE", // Reserve deficit +} diff --git a/native-yield-operations/automation-service/src/core/entities/ValidatorBalance.ts b/native-yield-operations/automation-service/src/core/entities/ValidatorBalance.ts new file mode 100644 index 0000000000..93539499a7 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/entities/ValidatorBalance.ts @@ -0,0 +1,12 @@ +// N.B. ALL AMOUNTS HERE IN GWEI +export interface ValidatorBalance { + balance: bigint; + effectiveBalance: bigint; + publicKey: string; + validatorIndex: bigint; +} + +export interface ValidatorBalanceWithPendingWithdrawal extends ValidatorBalance { + pendingWithdrawalAmount: bigint; + withdrawableAmount: bigint; +} diff --git a/native-yield-operations/automation-service/src/core/entities/WithdrawalEvent.ts b/native-yield-operations/automation-service/src/core/entities/WithdrawalEvent.ts new file mode 100644 index 0000000000..c08c8a3f58 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/entities/WithdrawalEvent.ts @@ -0,0 +1,6 @@ +import { Address } from "viem"; + +export interface WithdrawalEvent { + reserveIncrementAmount: bigint; + yieldProvider: Address; +} diff --git a/native-yield-operations/automation-service/src/core/entities/YieldReport.ts b/native-yield-operations/automation-service/src/core/entities/YieldReport.ts new file mode 100644 index 0000000000..3189b94fe1 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/entities/YieldReport.ts @@ -0,0 +1,7 @@ +import { Address } from "viem"; + +export interface YieldReport { + yieldAmount: bigint; + outstandingNegativeYield: bigint; + yieldProvider: Address; +} diff --git a/native-yield-operations/automation-service/src/core/entities/graphql/ActiveValidatorsByLargestBalance.ts b/native-yield-operations/automation-service/src/core/entities/graphql/ActiveValidatorsByLargestBalance.ts new file mode 100644 index 0000000000..832cb6d43c --- /dev/null +++ b/native-yield-operations/automation-service/src/core/entities/graphql/ActiveValidatorsByLargestBalance.ts @@ -0,0 +1,31 @@ +import { gql, TypedDocumentNode } from "@apollo/client"; +import { ValidatorBalance } from "../ValidatorBalance.js"; +// https://www.apollographql.com/docs/react/data/typescript#using-operation-types + +/** Result shape returned by the server */ +type ActiveValidatorsByLargestBalanceQuery = { + allValidators: { + nodes: Array; + }; +}; + +/** Variables you pass in */ +type ActiveValidatorsByLargestBalanceQueryVariables = { + first?: number; // optional since we set a default in the query +}; + +export const ALL_VALIDATORS_BY_LARGEST_BALANCE_QUERY: TypedDocumentNode< + ActiveValidatorsByLargestBalanceQuery, + ActiveValidatorsByLargestBalanceQueryVariables +> = gql` + query AllValidatorsByLargestBalanceQuery($first: Int = 100) { + allValidators(condition: { state: ACTIVE }, orderBy: EFFECTIVE_BALANCE_DESC, first: $first) { + nodes { + balance + effectiveBalance + publicKey + validatorIndex + } + } + } +`; diff --git a/native-yield-operations/automation-service/src/core/entities/index.ts b/native-yield-operations/automation-service/src/core/entities/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/native-yield-operations/automation-service/src/core/enums/OperationModeEnums.ts b/native-yield-operations/automation-service/src/core/enums/OperationModeEnums.ts new file mode 100644 index 0000000000..a36352ba7f --- /dev/null +++ b/native-yield-operations/automation-service/src/core/enums/OperationModeEnums.ts @@ -0,0 +1,5 @@ +export enum OperationMode { + YIELD_REPORTING_MODE = "YIELD_REPORTING_MODE", + OSSIFICATION_PENDING_MODE = "OSSIFICATION_PENDING_MODE", + OSSIFICATION_COMPLETE_MODE = "OSSIFICATION_COMPLETE_MODE", +} diff --git a/native-yield-operations/automation-service/src/core/metrics/INativeYieldAutomationMetricsUpdater.ts b/native-yield-operations/automation-service/src/core/metrics/INativeYieldAutomationMetricsUpdater.ts new file mode 100644 index 0000000000..bd498e85a3 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/metrics/INativeYieldAutomationMetricsUpdater.ts @@ -0,0 +1,32 @@ +import { Address, Hex } from "viem"; +import { RebalanceDirection } from "../entities/RebalanceRequirement.js"; +import { OperationMode } from "../enums/OperationModeEnums.js"; +import { OperationTrigger } from "./LineaNativeYieldAutomationServiceMetrics.js"; + +export interface INativeYieldAutomationMetricsUpdater { + recordRebalance(direction: RebalanceDirection.STAKE | RebalanceDirection.UNSTAKE, amountGwei: number): void; + + addValidatorPartialUnstakeAmount(validatorPubkey: Hex, amountGwei: number): void; + + incrementValidatorExit(validatorPubkey: Hex, count?: number): void; + + incrementLidoVaultAccountingReport(vaultAddress: Address): void; + + incrementReportYield(vaultAddress: Address): void; + + addReportedYieldAmount(vaultAddress: Address, amountGwei: number): void; + + setCurrentNegativeYieldLastReport(vaultAddress: Address, negativeYield: number): Promise; + + addNodeOperatorFeesPaid(vaultAddress: Address, amountGwei: number): void; + + addLiabilitiesPaid(vaultAddress: Address, amountGwei: number): void; + + addLidoFeesPaid(vaultAddress: Address, amountGwei: number): void; + + incrementOperationModeTrigger(mode: OperationMode, trigger: OperationTrigger): void; + + incrementOperationModeExecution(mode: OperationMode): void; + + recordOperationModeDuration(mode: OperationMode, durationSeconds: number): void; +} diff --git a/native-yield-operations/automation-service/src/core/metrics/IOperationModeMetricsRecorder.ts b/native-yield-operations/automation-service/src/core/metrics/IOperationModeMetricsRecorder.ts new file mode 100644 index 0000000000..c6acd8ee00 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/metrics/IOperationModeMetricsRecorder.ts @@ -0,0 +1,24 @@ +import { Address, TransactionReceipt } from "viem"; +import { Result } from "neverthrow"; + +export interface IOperationModeMetricsRecorder { + recordProgressOssificationMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise; + + recordReportYieldMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise; + + recordSafeWithdrawalMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise; + + recordTransferFundsMetrics( + yieldProvider: Address, + txReceiptResult: Result, + ): Promise; +} diff --git a/native-yield-operations/automation-service/src/core/metrics/LineaNativeYieldAutomationServiceMetrics.ts b/native-yield-operations/automation-service/src/core/metrics/LineaNativeYieldAutomationServiceMetrics.ts new file mode 100644 index 0000000000..c7e5620274 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/metrics/LineaNativeYieldAutomationServiceMetrics.ts @@ -0,0 +1,67 @@ +// NB - All amounts rounded down in gwei. Due to limitation that PromQL does not support bigint. + +export enum LineaNativeYieldAutomationServiceMetrics { + // Counter that increments each time a rebalance between L1MessageService and YieldProvider is performed + // Labels: + // i.) direction = STAKE | UNSTAKE + // ii.) type = INITIAL | POST_REPORT + RebalanceAmountTotal = "linea_native_yield_automation_service_rebalance_amount_total", + + // Counter that increments each time a partial beacon chain withdrawal is made + // Single label - validator_pubkey + ValidatorPartialUnstakeAmountTotal = "linea_native_yield_automation_service_validator_partial_unstake_amount_total", + + // Counter that increments each time a validator exit is made + // Single label - validator_pubkey + ValidatorExitTotal = "linea_native_yield_automation_service_validator_exit_total", + + // Counter that increment each time a vault accounting report is submitted + // Single label `vault_address` + LidoVaultAccountingReportSubmittedTotal = "linea_native_yield_automation_service_lido_vault_accounting_report_submitted_total", + + // Counter that increment each time YieldManager.reportYield is called + // Single label `vault_address` + ReportYieldTotal = "linea_native_yield_automation_service_report_yield_total", + + // Counter that increments by the yield amount reported + // Single label `vault_address` + ReportYieldAmountTotal = "linea_native_yield_automation_service_report_yield_amount_total", + + // Gauge representing outstanding negative yield as of the last yield report + // Single label `vault_address` + CurrentNegativeYieldLastReport = "linea_native_yield_automation_service_current_negative_yield", + + // Counter that increments by the node operator fees paid + // Single label `vault_address` + // N.B. Only accounts for payments by the automation service, but external actors can also trigger payment + NodeOperatorFeesPaidTotal = "linea_native_yield_automation_service_node_operator_fees_paid_total", + + // Counter that increments by the node operator fees paid + // Single label `vault_address` + // N.B. Only accounts for payments by the automation service, but external actors can also trigger payment + LiabilitiesPaidTotal = "linea_native_yield_automation_service_liabilities_paid_total", + + // Counter that increments by Lido fees paid + // Single label `vault_address` + // N.B. Only accounts for payments by the automation service, but external actors can also trigger payment + LidoFeesPaidTotal = "linea_native_yield_automation_service_lido_fees_paid_total", + + // Counter that increments each time an operation mode is triggered. + // Labels: + // i.) `mode` + // i.) `operation_trigger` - VaultsReportDataUpdated_event vs timeout + OperationModeTriggerTotal = "linea_native_yield_automation_service_operation_mode_trigger_total", + + // Counter that increments each time an operation mode completes execution. + // Single label `mode` + OperationModeExecutionTotal = "linea_native_yield_automation_service_operation_mode_execution_total", + + // Histogram that tracks time for each operation mode run. + // Single label `mode` + OperationModeExecutionDurationSeconds = "linea_native_yield_automation_service_operation_mode_execution_duration_seconds", +} + +export enum OperationTrigger { + VAULTS_REPORT_DATA_UPDATED_EVENT = "VAULTS_REPORT_DATA_UPDATED_EVENT", + TIMEOUT = "TIMEOUT", +} diff --git a/native-yield-operations/automation-service/src/core/services/operation-mode/IOperationModeProcessor.ts b/native-yield-operations/automation-service/src/core/services/operation-mode/IOperationModeProcessor.ts new file mode 100644 index 0000000000..cd22471ff5 --- /dev/null +++ b/native-yield-operations/automation-service/src/core/services/operation-mode/IOperationModeProcessor.ts @@ -0,0 +1,3 @@ +export interface IOperationModeProcessor { + process(): Promise; +} diff --git a/native-yield-operations/automation-service/src/core/services/operation-mode/IOperationModeSelector.ts b/native-yield-operations/automation-service/src/core/services/operation-mode/IOperationModeSelector.ts new file mode 100644 index 0000000000..6820cf60ae --- /dev/null +++ b/native-yield-operations/automation-service/src/core/services/operation-mode/IOperationModeSelector.ts @@ -0,0 +1,4 @@ +export interface IOperationModeSelector { + start(): Promise; + stop(): void; +} diff --git a/native-yield-operations/automation-service/src/services/OperationModeSelector.ts b/native-yield-operations/automation-service/src/services/OperationModeSelector.ts new file mode 100644 index 0000000000..a04b423f98 --- /dev/null +++ b/native-yield-operations/automation-service/src/services/OperationModeSelector.ts @@ -0,0 +1,112 @@ +import { ILogger, wait } from "@consensys/linea-shared-utils"; +import { IYieldManager } from "../core/clients/contracts/IYieldManager.js"; +import { Address, TransactionReceipt } from "viem"; +import { IOperationModeSelector } from "../core/services/operation-mode/IOperationModeSelector.js"; +import { IOperationModeProcessor } from "../core/services/operation-mode/IOperationModeProcessor.js"; +import { INativeYieldAutomationMetricsUpdater } from "../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import { OperationMode } from "../core/enums/OperationModeEnums.js"; + +/** + * Selects and executes the appropriate operation mode based on the yield provider's ossification state. + * Continuously polls the YieldManager contract to determine the current state and routes execution + * to the corresponding operation mode processor. Handles errors with retry logic. + */ +export class OperationModeSelector implements IOperationModeSelector { + private isRunning = false; + + /** + * Creates a new OperationModeSelector instance. + * + * @param {ILogger} logger - Logger instance for logging operation mode selection and execution. + * @param {INativeYieldAutomationMetricsUpdater} metricsUpdater - Service for updating operation mode metrics. + * @param {IYieldManager} yieldManagerContractClient - Client for reading yield provider state from YieldManager contract. + * @param {IOperationModeProcessor} yieldReportingOperationModeProcessor - Processor for YIELD_REPORTING_MODE operations. + * @param {IOperationModeProcessor} ossificationPendingOperationModeProcessor - Processor for OSSIFICATION_PENDING_MODE operations. + * @param {IOperationModeProcessor} ossificationCompleteOperationModeProcessor - Processor for OSSIFICATION_COMPLETE_MODE operations. + * @param {Address} yieldProvider - The yield provider address to monitor and process. + * @param {number} contractReadRetryTimeMs - Delay in milliseconds before retrying after a contract read error. + */ + constructor( + private readonly logger: ILogger, + private readonly metricsUpdater: INativeYieldAutomationMetricsUpdater, + private readonly yieldManagerContractClient: IYieldManager, + private readonly yieldReportingOperationModeProcessor: IOperationModeProcessor, + private readonly ossificationPendingOperationModeProcessor: IOperationModeProcessor, + private readonly ossificationCompleteOperationModeProcessor: IOperationModeProcessor, + private readonly yieldProvider: Address, + private readonly contractReadRetryTimeMs: number, + ) { + } + + /** + * Starts the operation mode selection loop. + * Sets the running flag and begins polling for operation mode selection. + * If already running, returns immediately without starting a new loop. + * + * @returns {Promise} A promise that resolves when the loop starts (but does not resolve until the loop stops). + */ + public async start(): Promise { + if (this.isRunning) { + return; + } + + this.isRunning = true; + this.logger.info(`Starting selectOperationModeLoop`); + await this.selectOperationModeLoop(); + } + + /** + * Stops the operation mode selection loop. + * Sets the running flag to false, which causes the loop to exit on its next iteration. + * If not running, returns immediately. + */ + public stop(): void { + if (!this.isRunning) { + return; + } + + this.isRunning = false; + this.logger.info(`Stopped selectOperationModeLoop`); + } + + /** + * Main loop that continuously selects and executes operation modes based on yield provider state. + * Polls the YieldManager contract to check ossification status and routes execution accordingly: + * - If ossified: executes OSSIFICATION_COMPLETE_MODE processor + * - Else if ossification initiated: executes OSSIFICATION_PENDING_MODE processor + * - Otherwise: executes YIELD_REPORTING_MODE processor + * Records metrics for each execution and handles errors with retry logic using contractReadRetryTimeMs delay. + * + * @returns {Promise} A promise that resolves when the loop exits (when isRunning becomes false). + */ + private async selectOperationModeLoop(): Promise { + while (this.isRunning) { + try { + const [isOssificationInitiated, isOssified] = await Promise.all([ + this.yieldManagerContractClient.isOssificationInitiated(this.yieldProvider), + this.yieldManagerContractClient.isOssified(this.yieldProvider), + ]); + + if (isOssified) { + this.logger.info("Selected OSSIFICATION_COMPLETE_MODE"); + await this.ossificationCompleteOperationModeProcessor.process(); + this.logger.info("Completed OSSIFICATION_COMPLETE_MODE"); + this.metricsUpdater.incrementOperationModeExecution(OperationMode.OSSIFICATION_COMPLETE_MODE); + } else if (isOssificationInitiated) { + this.logger.info("Selected OSSIFICATION_PENDING_MODE"); + await this.ossificationPendingOperationModeProcessor.process(); + this.logger.info("Completed OSSIFICATION_PENDING_MODE"); + this.metricsUpdater.incrementOperationModeExecution(OperationMode.OSSIFICATION_PENDING_MODE); + } else { + this.logger.info("Selected YIELD_REPORTING_MODE"); + await this.yieldReportingOperationModeProcessor.process(); + this.logger.info("Completed YIELD_REPORTING_MODE"); + this.metricsUpdater.incrementOperationModeExecution(OperationMode.YIELD_REPORTING_MODE); + } + } catch (error) { + this.logger.error(`selectOperationModeLoop error, retrying in ${this.contractReadRetryTimeMs}ms`, { error }); + await wait(this.contractReadRetryTimeMs); + } + } + } +} diff --git a/native-yield-operations/automation-service/src/services/__tests__/OperationModeSelector.test.ts b/native-yield-operations/automation-service/src/services/__tests__/OperationModeSelector.test.ts new file mode 100644 index 0000000000..b04546f230 --- /dev/null +++ b/native-yield-operations/automation-service/src/services/__tests__/OperationModeSelector.test.ts @@ -0,0 +1,157 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import type { ILogger } from "@consensys/linea-shared-utils"; +import type { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import type { IYieldManager } from "../../core/clients/contracts/IYieldManager.js"; +import type { TransactionReceipt, Address } from "viem"; +import type { IOperationModeProcessor } from "../../core/services/operation-mode/IOperationModeProcessor.js"; +import { OperationMode } from "../../core/enums/OperationModeEnums.js"; + +jest.mock("@consensys/linea-shared-utils", () => { + const actual = jest.requireActual("@consensys/linea-shared-utils"); + return { + ...actual, + wait: jest.fn(), + }; +}); + +import { wait } from "@consensys/linea-shared-utils"; +import { OperationModeSelector } from "../OperationModeSelector.js"; + +describe("OperationModeSelector", () => { + const yieldProvider = "0x1111111111111111111111111111111111111111" as Address; + + let logger: MockProxy; + let metricsUpdater: MockProxy; + let yieldManager: MockProxy>; + let yieldReportingProcessor: MockProxy; + let ossificationPendingProcessor: MockProxy; + let ossificationCompleteProcessor: MockProxy; + let waitMock: jest.MockedFunction; + + const contractReadRetryTimeMs = 123; + + const createSelector = (retryTime = contractReadRetryTimeMs) => + new OperationModeSelector( + logger, + metricsUpdater, + yieldManager, + yieldReportingProcessor, + ossificationPendingProcessor, + ossificationCompleteProcessor, + yieldProvider, + retryTime, + ); + + beforeEach(() => { + jest.clearAllMocks(); + logger = mock(); + metricsUpdater = mock(); + yieldManager = mock>(); + yieldReportingProcessor = mock(); + ossificationPendingProcessor = mock(); + ossificationCompleteProcessor = mock(); + + waitMock = wait as jest.MockedFunction; + waitMock.mockResolvedValue(undefined); + }); + + it("runs yield reporting mode when neither ossification flag is set", async () => { + yieldManager.isOssificationInitiated.mockResolvedValueOnce(false); + yieldManager.isOssified.mockResolvedValueOnce(false); + + const selector = createSelector(); + yieldReportingProcessor.process.mockImplementation(async () => { + selector.stop(); + }); + + await selector.start(); + + expect(yieldReportingProcessor.process).toHaveBeenCalledTimes(1); + expect(ossificationPendingProcessor.process).not.toHaveBeenCalled(); + expect(ossificationCompleteProcessor.process).not.toHaveBeenCalled(); + expect(metricsUpdater.incrementOperationModeExecution).toHaveBeenCalledWith(OperationMode.YIELD_REPORTING_MODE); + expect(waitMock).not.toHaveBeenCalled(); + }); + + it("runs ossification pending mode when ossification is initiated", async () => { + yieldManager.isOssificationInitiated.mockResolvedValueOnce(true); + yieldManager.isOssified.mockResolvedValueOnce(false); + + const selector = createSelector(); + ossificationPendingProcessor.process.mockImplementation(async () => { + selector.stop(); + }); + + await selector.start(); + + expect(ossificationPendingProcessor.process).toHaveBeenCalledTimes(1); + expect(yieldReportingProcessor.process).not.toHaveBeenCalled(); + expect(ossificationCompleteProcessor.process).not.toHaveBeenCalled(); + expect(metricsUpdater.incrementOperationModeExecution).toHaveBeenCalledWith( + OperationMode.OSSIFICATION_PENDING_MODE, + ); + }); + + it("prefers ossification complete mode when ossified", async () => { + yieldManager.isOssificationInitiated.mockResolvedValueOnce(true); + yieldManager.isOssified.mockResolvedValueOnce(true); + + const selector = createSelector(); + ossificationCompleteProcessor.process.mockImplementation(async () => { + selector.stop(); + }); + + await selector.start(); + + expect(ossificationCompleteProcessor.process).toHaveBeenCalledTimes(1); + expect(yieldReportingProcessor.process).not.toHaveBeenCalled(); + expect(ossificationPendingProcessor.process).not.toHaveBeenCalled(); + expect(metricsUpdater.incrementOperationModeExecution).toHaveBeenCalledWith( + OperationMode.OSSIFICATION_COMPLETE_MODE, + ); + }); + + it("is a no-op when start is invoked while already running", async () => { + yieldManager.isOssificationInitiated.mockResolvedValue(false); + yieldManager.isOssified.mockResolvedValue(false); + + const selector = createSelector(); + yieldReportingProcessor.process.mockImplementation(async () => { + selector.stop(); + }); + + await Promise.all([selector.start(), selector.start()]); + + expect(yieldReportingProcessor.process).toHaveBeenCalledTimes(1); + }); + + it("does nothing when stop is called before start", () => { + const selector = createSelector(); + + selector.stop(); + + expect(logger.info).not.toHaveBeenCalled(); + }); + + it("logs errors, waits, and retries before succeeding", async () => { + const error = new Error("boom"); + const retryTime = 321; + + yieldManager.isOssificationInitiated.mockRejectedValueOnce(error); + yieldManager.isOssificationInitiated.mockResolvedValueOnce(false); + yieldManager.isOssified.mockRejectedValueOnce(error); + yieldManager.isOssified.mockResolvedValueOnce(false); + + const selector = createSelector(retryTime); + yieldReportingProcessor.process.mockImplementation(async () => { + selector.stop(); + }); + + await selector.start(); + + expect(logger.error).toHaveBeenCalledWith(`selectOperationModeLoop error, retrying in ${retryTime}ms`, { error }); + expect(waitMock).toHaveBeenCalledWith(retryTime); + expect(yieldReportingProcessor.process).toHaveBeenCalledTimes(1); + expect(metricsUpdater.incrementOperationModeExecution).toHaveBeenCalledWith(OperationMode.YIELD_REPORTING_MODE); + }); +}); diff --git a/native-yield-operations/automation-service/src/services/operation-mode-processors/OssificationCompleteProcessor.ts b/native-yield-operations/automation-service/src/services/operation-mode-processors/OssificationCompleteProcessor.ts new file mode 100644 index 0000000000..a1fee9d551 --- /dev/null +++ b/native-yield-operations/automation-service/src/services/operation-mode-processors/OssificationCompleteProcessor.ts @@ -0,0 +1,82 @@ +import { Address, TransactionReceipt } from "viem"; +import { ILogger, attempt, msToSeconds, wait } from "@consensys/linea-shared-utils"; +import { IYieldManager } from "../../core/clients/contracts/IYieldManager.js"; +import { IOperationModeProcessor } from "../../core/services/operation-mode/IOperationModeProcessor.js"; +import { IBeaconChainStakingClient } from "../../core/clients/IBeaconChainStakingClient.js"; +import { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import { OperationMode } from "../../core/enums/OperationModeEnums.js"; +import { OperationTrigger } from "../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; +import { IOperationModeMetricsRecorder } from "../../core/metrics/IOperationModeMetricsRecorder.js"; + +/** + * Processor for OSSIFICATION_COMPLETE_MODE operations. + * Handles ossification complete state by performing max withdrawal from yield provider + * and max unstake from beacon chain after a configurable delay period. + */ +export class OssificationCompleteProcessor implements IOperationModeProcessor { + /** + * Creates a new OssificationCompleteProcessor instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {INativeYieldAutomationMetricsUpdater} metricsUpdater - Service for updating operation mode metrics. + * @param {IOperationModeMetricsRecorder} operationModeMetricsRecorder - Service for recording operation mode metrics from transaction receipts. + * @param {IYieldManager} yieldManagerContractClient - Client for interacting with YieldManager contracts. + * @param {IBeaconChainStakingClient} beaconChainStakingClient - Client for managing beacon chain staking operations. + * @param {number} maxInactionMs - Maximum inaction delay in milliseconds before executing actions (timeout-based trigger). + * @param {Address} yieldProvider - The yield provider address to process. + */ + constructor( + private readonly logger: ILogger, + private readonly metricsUpdater: INativeYieldAutomationMetricsUpdater, + private readonly operationModeMetricsRecorder: IOperationModeMetricsRecorder, + private readonly yieldManagerContractClient: IYieldManager, + private readonly beaconChainStakingClient: IBeaconChainStakingClient, + private readonly maxInactionMs: number, + private readonly yieldProvider: Address, + ) {} + + /** + * Executes one processing cycle: + * - Waits for the configured max inaction delay period. + * - Records operation mode trigger metrics with TIMEOUT trigger. + * - Runs the main processing logic (`_process()`). + * - Records operation mode execution duration metrics. + * + * @returns {Promise} A promise that resolves when the processing cycle completes. + */ + public async process(): Promise { + this.logger.info(`Waiting ${this.maxInactionMs}ms before executing actions`); + await wait(this.maxInactionMs); + + this.metricsUpdater.incrementOperationModeTrigger( + OperationMode.OSSIFICATION_COMPLETE_MODE, + OperationTrigger.TIMEOUT, + ); + const startedAt = performance.now(); + await this._process(); + const durationMs = performance.now() - startedAt; + this.metricsUpdater.recordOperationModeDuration(OperationMode.OSSIFICATION_COMPLETE_MODE, msToSeconds(durationMs)); + } + + /** + * Main processing logic for ossification complete mode: + * 1. Max withdraw - Performs maximum safe withdrawal from yield provider to withdrawal reserve + * 2. Max unstake - Submits maximum available withdrawal requests from beacon chain + * + * @returns {Promise} A promise that resolves when processing completes. + */ + private async _process(): Promise { + // Max withdraw + this.logger.info("_process - Performing max withdrawal from YieldProvider"); + const withdrawalResult = await attempt( + this.logger, + () => this.yieldManagerContractClient.safeMaxAddToWithdrawalReserve(this.yieldProvider), + "_process - safeMaxAddToWithdrawalReserve failed (tolerated)", + ); + await this.operationModeMetricsRecorder.recordSafeWithdrawalMetrics(this.yieldProvider, withdrawalResult); + + // Max unstake + this.logger.info("_process - Performing max unstake from beacon chain"); + await this.beaconChainStakingClient.submitMaxAvailableWithdrawalRequests(); + } +} diff --git a/native-yield-operations/automation-service/src/services/operation-mode-processors/OssificationPendingProcessor.ts b/native-yield-operations/automation-service/src/services/operation-mode-processors/OssificationPendingProcessor.ts new file mode 100644 index 0000000000..01afd27550 --- /dev/null +++ b/native-yield-operations/automation-service/src/services/operation-mode-processors/OssificationPendingProcessor.ts @@ -0,0 +1,121 @@ +import { Address, TransactionReceipt } from "viem"; +import { IYieldManager } from "../../core/clients/contracts/IYieldManager.js"; +import { IOperationModeProcessor } from "../../core/services/operation-mode/IOperationModeProcessor.js"; +import { ILogger, attempt, msToSeconds } from "@consensys/linea-shared-utils"; +import { ILazyOracle } from "../../core/clients/contracts/ILazyOracle.js"; +import { ILidoAccountingReportClient } from "../../core/clients/ILidoAccountingReportClient.js"; +import { IBeaconChainStakingClient } from "../../core/clients/IBeaconChainStakingClient.js"; +import { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import { OperationMode } from "../../core/enums/OperationModeEnums.js"; +import { IOperationModeMetricsRecorder } from "../../core/metrics/IOperationModeMetricsRecorder.js"; + +/** + * Processor for OSSIFICATION_PENDING_MODE operations. + * Handles ossification pending state by performing max unstake, submitting vault reports, + * progressing pending ossification, and performing max withdrawals if ossification completes. + */ +export class OssificationPendingProcessor implements IOperationModeProcessor { + /** + * Creates a new OssificationPendingProcessor instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {INativeYieldAutomationMetricsUpdater} metricsUpdater - Service for updating operation mode metrics. + * @param {IOperationModeMetricsRecorder} operationModeMetricsRecorder - Service for recording operation mode metrics from transaction receipts. + * @param {IYieldManager} yieldManagerContractClient - Client for interacting with YieldManager contracts. + * @param {ILazyOracle} lazyOracleContractClient - Client for waiting on LazyOracle events. + * @param {ILidoAccountingReportClient} lidoAccountingReportClient - Client for submitting Lido accounting reports. + * @param {IBeaconChainStakingClient} beaconChainStakingClient - Client for managing beacon chain staking operations. + * @param {Address} yieldProvider - The yield provider address to process. + * @param {boolean} shouldSubmitVaultReport - Whether to submit the vault accounting report. Can be set to false if other actors are expected to submit. + */ + constructor( + private readonly logger: ILogger, + private readonly metricsUpdater: INativeYieldAutomationMetricsUpdater, + private readonly operationModeMetricsRecorder: IOperationModeMetricsRecorder, + private readonly yieldManagerContractClient: IYieldManager, + private readonly lazyOracleContractClient: ILazyOracle, + private readonly lidoAccountingReportClient: ILidoAccountingReportClient, + private readonly beaconChainStakingClient: IBeaconChainStakingClient, + private readonly yieldProvider: Address, + private readonly shouldSubmitVaultReport: boolean, + ) {} + + /** + * Executes one processing cycle: + * - Waits for the next `VaultsReportDataUpdated` event **or** a timeout, whichever happens first. + * - Once triggered, runs the main processing logic (`_process()`). + * - Always cleans up the event watcher afterward. + * Records operation mode trigger metrics and execution duration metrics. + * + * @returns {Promise} A promise that resolves when the processing cycle completes. + */ + public async process(): Promise { + const triggerEvent = await this.lazyOracleContractClient.waitForVaultsReportDataUpdatedEvent(); + this.metricsUpdater.incrementOperationModeTrigger(OperationMode.OSSIFICATION_PENDING_MODE, triggerEvent.result); + const startedAt = performance.now(); + await this._process(); + const durationMs = performance.now() - startedAt; + this.metricsUpdater.recordOperationModeDuration(OperationMode.OSSIFICATION_PENDING_MODE, msToSeconds(durationMs)); + } + + /** + * Main processing loop: + * 1. Max unstake - Submit maximum available withdrawal requests from beacon chain + * 2. Submit vault report - Fetch and submit latest vault report + * 3. Process Pending Ossification - Progress pending ossification (stops if failed) + * 4. Max withdraw if ossified - Perform max safe withdrawal if ossification completed + * + * @returns {Promise} A promise that resolves when processing completes (or early returns if ossification fails). + */ + private async _process(): Promise { + // Max unstake + this.logger.info("_process - performing max unstake from beacon chain"); + await attempt( + this.logger, + () => this.beaconChainStakingClient.submitMaxAvailableWithdrawalRequests(), + "submitMaxAvailableWithdrawalRequests failed (tolerated)", + ); + + // Submit vault report if available and enabled + if (this.shouldSubmitVaultReport) { + this.logger.info("_process - Fetching latest vault report"); + const vault = await this.yieldManagerContractClient.getLidoStakingVaultAddress(this.yieldProvider); + await this.lidoAccountingReportClient.getLatestSubmitVaultReportParams(vault); + this.logger.info("_process - Submitting latest vault report"); + const vaultReportResult = await attempt( + this.logger, + () => this.lidoAccountingReportClient.submitLatestVaultReport(vault), + "submitLatestVaultReport failed (tolerated)", + ); + if (vaultReportResult.isOk()) { + this.logger.info("_process - Successfully submitted latest vault report"); + this.metricsUpdater.incrementLidoVaultAccountingReport(vault); + } + } else { + this.logger.info("_process - Skipping vault report submission (SHOULD_SUBMIT_VAULT_REPORT=false)"); + } + + // Process Pending Ossification + + const ossificationResult = await attempt( + this.logger, + () => this.yieldManagerContractClient.progressPendingOssification(this.yieldProvider), + "_process - progressPendingOssification failed", + ); + // Stop if failed. + if (ossificationResult.isErr()) return; + + await this.operationModeMetricsRecorder.recordProgressOssificationMetrics(this.yieldProvider, ossificationResult); + this.logger.info("_process - Ossification completed, performing max safe withdrawal"); + + // Max withdraw if ossified + if (await this.yieldManagerContractClient.isOssified(this.yieldProvider)) { + const withdrawalResult = await attempt( + this.logger, + () => this.yieldManagerContractClient.safeMaxAddToWithdrawalReserve(this.yieldProvider), + "_process - safeMaxAddToWithdrawalReserve failed", + ); + await this.operationModeMetricsRecorder.recordSafeWithdrawalMetrics(this.yieldProvider, withdrawalResult); + } + } +} diff --git a/native-yield-operations/automation-service/src/services/operation-mode-processors/YieldReportingProcessor.ts b/native-yield-operations/automation-service/src/services/operation-mode-processors/YieldReportingProcessor.ts new file mode 100644 index 0000000000..ba8fb59bfc --- /dev/null +++ b/native-yield-operations/automation-service/src/services/operation-mode-processors/YieldReportingProcessor.ts @@ -0,0 +1,286 @@ +import { Address, TransactionReceipt } from "viem"; +import { IYieldManager } from "../../core/clients/contracts/IYieldManager.js"; +import { IOperationModeProcessor } from "../../core/services/operation-mode/IOperationModeProcessor.js"; +import { bigintReplacer, ILogger, attempt, msToSeconds, weiToGweiNumber } from "@consensys/linea-shared-utils"; +import { ILazyOracle } from "../../core/clients/contracts/ILazyOracle.js"; +import { ILidoAccountingReportClient } from "../../core/clients/ILidoAccountingReportClient.js"; +import { RebalanceDirection, RebalanceRequirement } from "../../core/entities/RebalanceRequirement.js"; +import { ILineaRollupYieldExtension } from "../../core/clients/contracts/ILineaRollupYieldExtension.js"; +import { IBeaconChainStakingClient } from "../../core/clients/IBeaconChainStakingClient.js"; +import { INativeYieldAutomationMetricsUpdater } from "../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import { OperationMode } from "../../core/enums/OperationModeEnums.js"; +import { IOperationModeMetricsRecorder } from "../../core/metrics/IOperationModeMetricsRecorder.js"; + +/** + * Processor for YIELD_REPORTING_MODE operations. + * Handles native yield reporting cycles including rebalancing, vault report submission, and beacon chain withdrawals. + */ +export class YieldReportingProcessor implements IOperationModeProcessor { + private vault: Address; + + /** + * Creates a new YieldReportingProcessor instance. + * + * @param {ILogger} logger - Logger instance for logging operations. + * @param {INativeYieldAutomationMetricsUpdater} metricsUpdater - Service for updating operation mode metrics. + * @param {IOperationModeMetricsRecorder} operationModeMetricsRecorder - Service for recording operation mode metrics from transaction receipts. + * @param {IYieldManager} yieldManagerContractClient - Client for interacting with YieldManager contracts. + * @param {ILazyOracle} lazyOracleContractClient - Client for waiting on LazyOracle events. + * @param {ILineaRollupYieldExtension} lineaRollupYieldExtensionClient - Client for interacting with LineaRollupYieldExtension contracts. + * @param {ILidoAccountingReportClient} lidoAccountingReportClient - Client for submitting Lido accounting reports. + * @param {IBeaconChainStakingClient} beaconChainStakingClient - Client for managing beacon chain staking operations. + * @param {Address} yieldProvider - The yield provider address to process. + * @param {Address} l2YieldRecipient - The L2 yield recipient address for yield reporting. + * @param {boolean} shouldSubmitVaultReport - Whether to submit the vault accounting report. Can be set to false if other actors are expected to submit. + */ + constructor( + private readonly logger: ILogger, + private readonly metricsUpdater: INativeYieldAutomationMetricsUpdater, + private readonly operationModeMetricsRecorder: IOperationModeMetricsRecorder, + private readonly yieldManagerContractClient: IYieldManager, + private readonly lazyOracleContractClient: ILazyOracle, + private readonly lineaRollupYieldExtensionClient: ILineaRollupYieldExtension, + private readonly lidoAccountingReportClient: ILidoAccountingReportClient, + private readonly beaconChainStakingClient: IBeaconChainStakingClient, + private readonly yieldProvider: Address, + private readonly l2YieldRecipient: Address, + private readonly shouldSubmitVaultReport: boolean, + ) {} + + /** + * Executes one processing cycle: + * - Waits for the next `VaultsReportDataUpdated` event **or** a timeout, whichever happens first. + * - Once triggered, runs the main processing logic (`_process()`). + * - Always cleans up the event watcher afterward. + * Records operation mode trigger metrics and execution duration metrics. + * + * @returns {Promise} A promise that resolves when the processing cycle completes. + */ + public async process(): Promise { + const triggerEvent = await this.lazyOracleContractClient.waitForVaultsReportDataUpdatedEvent(); + this.metricsUpdater.incrementOperationModeTrigger(OperationMode.YIELD_REPORTING_MODE, triggerEvent.result); + const startedAt = performance.now(); + await this._process(); + const durationMs = performance.now() - startedAt; + this.metricsUpdater.recordOperationModeDuration(OperationMode.YIELD_REPORTING_MODE, msToSeconds(durationMs)); + } + + /** + * Orchestrates a single Native Yield reporting cycle. + * + * High-level flow + * 1) Read current state: + * - Fetch latest Lido report params + * - Determine rebalance direction/amount (stake vs. unstake vs. no-op) + * 2) Front-running / safety: + * - If we're in DEFICIT (need to UNSTAKE), pause staking up-front to + * prevent new deposits worsening the shortfall while we act + * 3) Primary action: + * - Perform the primary rebalance and (if possible) submit the report + * 4) Mid-cycle drift fix: + * - If we *started* needing STAKE but, during processing, external flows + * (e.g., bridge withdrawals) flipped us into DEFICIT, perform an + * *amendment* UNSTAKE to restore reserve targets + * 5) Resume normal operations: + * - If we started in EXCESS (STAKE) and did *not* end in DEFICIT, unpause staking + * 6) Beacon chain withdrawals: + * - Defer actual validator withdrawals to the very end since fulfillment + * extends beyond this method’s synchronous runtime + * + * Invariants and intent: + * - Never leave staking enabled while in a known deficit + * - Prefer to submit the accounting report in the same cycle as the primary rebalance + * - Make at most one amendment pass to handle mid-cycle state changes + * + * Side effects: + * - May pause/unpause staking + * - May submit Lido vault report + * - May stake/unstake on the vault + * - May queue beacon-chain withdrawals + */ + private async _process(): Promise { + // Fetch initial data + this.vault = await this.yieldManagerContractClient.getLidoStakingVaultAddress(this.yieldProvider); + const [initialRebalanceRequirements] = await Promise.all([ + this.yieldManagerContractClient.getRebalanceRequirements(), + this.shouldSubmitVaultReport + ? this.lidoAccountingReportClient.getLatestSubmitVaultReportParams(this.vault) + : Promise.resolve(), + ]); + this.logger.info( + `_process - Initial data fetch: initialRebalanceRequirements=${JSON.stringify(initialRebalanceRequirements, bigintReplacer, 2)}`, + ); + + // If we begin in DEFICIT, freeze beacon chain deposits to prevent further exacerbation + if (initialRebalanceRequirements.rebalanceDirection === RebalanceDirection.UNSTAKE) { + await attempt( + this.logger, + () => this.yieldManagerContractClient.pauseStakingIfNotAlready(this.yieldProvider), + "_process - pause staking failed (tolerated)", + ); + } + + // Do primary rebalance +/- report submission + await this._handleRebalance(initialRebalanceRequirements); + + const postReportRebalanceRequirements = await this.yieldManagerContractClient.getRebalanceRequirements(); + this.logger.info( + `_process - Post rebalance data fetch: postReportRebalanceRequirements=${JSON.stringify(postReportRebalanceRequirements, bigintReplacer, 2)}`, + ); + + // Mid-cycle drift check: + // If we *started* with EXCESS (STAKE) but external flows flipped us to DEFICIT, + // immediately correct with a targeted UNSTAKE amendment. + if (initialRebalanceRequirements.rebalanceDirection === RebalanceDirection.STAKE) { + if (postReportRebalanceRequirements.rebalanceDirection === RebalanceDirection.UNSTAKE) { + await this._handleUnstakingRebalance(postReportRebalanceRequirements.rebalanceAmount, false); + } else { + await attempt( + this.logger, + () => this.yieldManagerContractClient.unpauseStakingIfNotAlready(this.yieldProvider), + "_process - unpause staking failed (tolerated)", + ); + } + } + + // Beacon-chain withdrawals are last: + // These have fulfillment latency beyond this method; queue them after local state is stable. + const beaconChainWithdrawalRequirements = await this.yieldManagerContractClient.getRebalanceRequirements(); + this.logger.info( + `_process - Beacon chain withdrawal data fetch: beaconChainWithdrawalRequirements=${JSON.stringify(beaconChainWithdrawalRequirements, bigintReplacer, 2)}`, + ); + if (beaconChainWithdrawalRequirements.rebalanceDirection === RebalanceDirection.UNSTAKE) { + await this.beaconChainStakingClient.submitWithdrawalRequestsToFulfilAmount( + beaconChainWithdrawalRequirements.rebalanceAmount, + ); + } + } + + /** + * Handles rebalancing operations based on rebalance requirements. + * Routes to appropriate handler based on rebalance direction: + * - NONE: Simple submit report + * - STAKE: Handles staking rebalance (surplus) + * - UNSTAKE: Handles unstaking rebalance (deficit) + * + * @param {RebalanceRequirement} rebalanceRequirements - The rebalance requirements containing direction and amount. + * @returns {Promise} A promise that resolves when rebalancing is handled. + */ + private async _handleRebalance(rebalanceRequirements: RebalanceRequirement): Promise { + if (rebalanceRequirements.rebalanceDirection === RebalanceDirection.NONE) { + // No-op + this.logger.info("_handleRebalance - no rebalance pathway, calling _handleSubmitLatestVaultReport"); + await this._handleSubmitLatestVaultReport(); + return; + } else if (rebalanceRequirements.rebalanceDirection === RebalanceDirection.STAKE) { + await this._handleStakingRebalance(rebalanceRequirements.rebalanceAmount); + } else { + await this._handleUnstakingRebalance(rebalanceRequirements.rebalanceAmount, true); + } + } + + /** + * Handles staking rebalance operations when there is a reserve surplus. + * Rebalance first - tolerate failures because fresh vault report should not be blocked. + * Only do YieldManager->YieldProvider, if L1MessageService->YieldManager succeeded. + * Submit report last. + * + * Assumptions: + * - i.) We count rebalance once funds have been moved away from the L1MessageService + * - ii.) Only the initial rebalance will call this fn + * + * @param {bigint} rebalanceAmount - The amount to rebalance in wei. + * @returns {Promise} A promise that resolves when staking rebalance is handled. + */ + // Surplus + private async _handleStakingRebalance(rebalanceAmount: bigint): Promise { + this.logger.info(`_handleStakingRebalance - reserve surplus, rebalanceAmount=${rebalanceAmount}`); + // Rebalance first - tolerate failures because fresh vault report should not be blocked + const transferFundsForNativeYieldResult = await attempt( + this.logger, + () => this.lineaRollupYieldExtensionClient.transferFundsForNativeYield(rebalanceAmount), + "_handleStakingRebalance - transferFundsForNativeYield failed (tolerated)", + ); + // Only do YieldManager->YieldProvider, if L1MessageService->YieldManager succeeded + if (transferFundsForNativeYieldResult.isOk()) { + // Assumptions + // i.) We count rebalance once funds have been moved away from the L1MessageService + // ii.) Only the initial rebalance will call this fn + this.metricsUpdater.recordRebalance(RebalanceDirection.STAKE, weiToGweiNumber(rebalanceAmount)); + const transferFundsResult = await attempt( + this.logger, + () => this.yieldManagerContractClient.fundYieldProvider(this.yieldProvider, rebalanceAmount), + "_handleStakingRebalance - fundYieldProvider failed (tolerated)", + ); + await this.operationModeMetricsRecorder.recordTransferFundsMetrics(this.yieldProvider, transferFundsResult); + } + + // Submit report last + this.logger.info("_handleStakingRebalance calling _handleSubmitLatestVaultReport"); + await this._handleSubmitLatestVaultReport(); + } + + /** + * Handles unstaking rebalance operations when there is a reserve deficit. + * Submit report first (if yield should be reported), then perform rebalance. + * + * @param {bigint} rebalanceAmount - The amount to rebalance in wei. + * @param {boolean} shouldReportYield - Whether to report yield before rebalancing. If true, submits vault report before rebalancing. + * @returns {Promise} A promise that resolves when unstaking rebalance is handled. + */ + // Deficit + private async _handleUnstakingRebalance(rebalanceAmount: bigint, shouldReportYield: boolean): Promise { + if (shouldReportYield) { + // Submit report first + this.logger.info("_handleUnstakingRebalance calling _handleSubmitLatestVaultReport"); + await this._handleSubmitLatestVaultReport(); + } + + this.logger.info(`_handleUnstakingRebalance - reserve deficit, rebalanceAmount=${rebalanceAmount}`); + // Then perform rebalance + const withdrawalResult = await attempt( + this.logger, + () => + this.yieldManagerContractClient.safeAddToWithdrawalReserveIfAboveThreshold(this.yieldProvider, rebalanceAmount), + "_handleUnstakingRebalance - safeAddToWithdrawalReserveIfAboveThreshold failed (tolerated)", + ); + await this.operationModeMetricsRecorder.recordSafeWithdrawalMetrics(this.yieldProvider, withdrawalResult); + } + + /** + * @notice Submits the latest vault report (if enabled) and then reports yield to the yield manager. + * @dev Uses `tryResult` to safely handle failures without throwing. + * - If submitting the vault report fails, the execution will continue to report yield. + * A key assumption is that it is safe to submit multiple yield reports for the same vault report. + * @dev We tolerate report submission errors because they should not block rebalances + * @dev If shouldSubmitVaultReport is false, skips vault report submission but still reports yield. + * @returns {Promise} A promise that resolves when both operations are attempted (regardless of success/failure). + */ + private async _handleSubmitLatestVaultReport() { + // First call: submit vault report (if enabled) + if (this.shouldSubmitVaultReport) { + const vaultResult = await attempt( + this.logger, + () => this.lidoAccountingReportClient.submitLatestVaultReport(this.vault), + "_handleSubmitLatestVaultReport: submitLatestVaultReport failed", + ); + if (vaultResult.isOk()) { + this.logger.info("_handleSubmitLatestVaultReport: vault report succeeded"); + this.metricsUpdater.incrementLidoVaultAccountingReport(this.vault); + } + } else { + this.logger.info("_handleSubmitLatestVaultReport: skipping vault report submission (SHOULD_SUBMIT_VAULT_REPORT=false)"); + } + + // Second call: report yield + const yieldResult = await attempt( + this.logger, + () => this.yieldManagerContractClient.reportYield(this.yieldProvider, this.l2YieldRecipient), + "_handleSubmitLatestVaultReport - reportYield failed", + ); + if (yieldResult.isOk()) { + this.logger.info("_handleSubmitLatestVaultReport: yield report succeeded"); + await this.operationModeMetricsRecorder.recordReportYieldMetrics(this.yieldProvider, yieldResult); + } + } +} diff --git a/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/OssificationCompleteProcessor.test.ts b/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/OssificationCompleteProcessor.test.ts new file mode 100644 index 0000000000..5f8f862e38 --- /dev/null +++ b/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/OssificationCompleteProcessor.test.ts @@ -0,0 +1,126 @@ +import { jest } from "@jest/globals"; +import type { ILogger } from "@consensys/linea-shared-utils"; +import type { INativeYieldAutomationMetricsUpdater } from "../../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import type { IOperationModeMetricsRecorder } from "../../../core/metrics/IOperationModeMetricsRecorder.js"; +import type { IYieldManager } from "../../../core/clients/contracts/IYieldManager.js"; +import type { TransactionReceipt, Address } from "viem"; +import type { IBeaconChainStakingClient } from "../../../core/clients/IBeaconChainStakingClient.js"; +import { OperationMode } from "../../../core/enums/OperationModeEnums.js"; +import { OperationTrigger } from "../../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; +import { OssificationCompleteProcessor } from "../OssificationCompleteProcessor.js"; +import { ResultAsync } from "neverthrow"; + +jest.mock("@consensys/linea-shared-utils", () => { + const actual = jest.requireActual("@consensys/linea-shared-utils") as typeof import("@consensys/linea-shared-utils"); + return { + ...actual, + wait: jest.fn(), + attempt: jest.fn(), + msToSeconds: jest.fn(), + }; +}); + +import { wait, attempt, msToSeconds } from "@consensys/linea-shared-utils"; + +describe("OssificationCompleteProcessor", () => { + const yieldProvider = "0x1111111111111111111111111111111111111111" as Address; + const maxInactionMs = 5_000; + + let logger: jest.Mocked; + let metricsUpdater: jest.Mocked; + let metricsRecorder: jest.Mocked; + let yieldManager: jest.Mocked>; + let beaconClient: jest.Mocked; + + const waitMock = wait as jest.MockedFunction; + const attemptMock = attempt as jest.MockedFunction; + const msToSecondsMock = msToSeconds as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + logger = { + name: "test", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as jest.Mocked; + metricsUpdater = { + incrementOperationModeTrigger: jest.fn(), + recordOperationModeDuration: jest.fn(), + } as unknown as jest.Mocked; + + metricsRecorder = { + recordSafeWithdrawalMetrics: jest.fn(), + } as unknown as jest.Mocked; + + yieldManager = { + safeMaxAddToWithdrawalReserve: jest.fn(), + } as unknown as jest.Mocked>; + + beaconClient = { + submitMaxAvailableWithdrawalRequests: jest.fn(), + } as unknown as jest.Mocked; + + waitMock.mockResolvedValue(undefined); + msToSecondsMock.mockImplementation((ms: number) => ms / 1_000); + yieldManager.safeMaxAddToWithdrawalReserve.mockResolvedValue(undefined as unknown as TransactionReceipt); + attemptMock.mockImplementation(((logger: ILogger, fn: () => unknown | Promise) => + ResultAsync.fromPromise( + Promise.resolve().then(() => fn()), + (error) => error as Error, + )) as typeof attempt); + }); + + const createProcessor = () => + new OssificationCompleteProcessor( + logger, + metricsUpdater, + metricsRecorder, + yieldManager, + beaconClient, + maxInactionMs, + yieldProvider, + ); + + it("waits, processes withdrawals, submits exits, and records metrics", async () => { + const performanceSpy = jest.spyOn(performance, "now").mockReturnValueOnce(100).mockReturnValueOnce(340); + + const processor = createProcessor(); + await processor.process(); + + expect(logger.info).toHaveBeenCalledWith(`Waiting ${maxInactionMs}ms before executing actions`); + expect(waitMock).toHaveBeenCalledWith(maxInactionMs); + expect(attemptMock).toHaveBeenCalledWith( + logger, + expect.any(Function), + "_process - safeMaxAddToWithdrawalReserve failed (tolerated)", + ); + expect(yieldManager.safeMaxAddToWithdrawalReserve).toHaveBeenCalledWith(yieldProvider); + const recordedResult = metricsRecorder.recordSafeWithdrawalMetrics.mock.calls[0]?.[1]; + expect(recordedResult?.isOk()).toBe(true); + expect(beaconClient.submitMaxAvailableWithdrawalRequests).toHaveBeenCalledTimes(1); + expect(metricsUpdater.incrementOperationModeTrigger).toHaveBeenCalledWith( + OperationMode.OSSIFICATION_COMPLETE_MODE, + OperationTrigger.TIMEOUT, + ); + expect(msToSecondsMock).toHaveBeenCalledWith(240); + expect(metricsUpdater.recordOperationModeDuration).toHaveBeenCalledWith( + OperationMode.OSSIFICATION_COMPLETE_MODE, + 0.24, + ); + performanceSpy.mockRestore(); + }); + + it("records failed withdrawal attempts while continuing processing", async () => { + const failure = new Error("boom"); + yieldManager.safeMaxAddToWithdrawalReserve.mockRejectedValue(failure); + + const processor = createProcessor(); + await processor.process(); + + const recordedResult = metricsRecorder.recordSafeWithdrawalMetrics.mock.calls[0]?.[1]; + expect(recordedResult?.isErr()).toBe(true); + expect(beaconClient.submitMaxAvailableWithdrawalRequests).toHaveBeenCalledTimes(1); + }); +}); diff --git a/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/OssificationPendingProcessor.test.ts b/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/OssificationPendingProcessor.test.ts new file mode 100644 index 0000000000..60695dfe9d --- /dev/null +++ b/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/OssificationPendingProcessor.test.ts @@ -0,0 +1,228 @@ +import { jest } from "@jest/globals"; +import type { ILogger } from "@consensys/linea-shared-utils"; +import type { INativeYieldAutomationMetricsUpdater } from "../../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import type { IOperationModeMetricsRecorder } from "../../../core/metrics/IOperationModeMetricsRecorder.js"; +import type { IYieldManager } from "../../../core/clients/contracts/IYieldManager.js"; +import type { ILazyOracle } from "../../../core/clients/contracts/ILazyOracle.js"; +import type { ILidoAccountingReportClient } from "../../../core/clients/ILidoAccountingReportClient.js"; +import type { IBeaconChainStakingClient } from "../../../core/clients/IBeaconChainStakingClient.js"; +import type { TransactionReceipt, Address } from "viem"; +import { OperationMode } from "../../../core/enums/OperationModeEnums.js"; +import { OperationTrigger } from "../../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; +import { OssificationPendingProcessor } from "../OssificationPendingProcessor.js"; +import { ResultAsync } from "neverthrow"; + +jest.mock("@consensys/linea-shared-utils", () => { + const actual = jest.requireActual("@consensys/linea-shared-utils") as typeof import("@consensys/linea-shared-utils"); + return { + ...actual, + attempt: jest.fn(), + msToSeconds: jest.fn(), + }; +}); + +import { attempt, msToSeconds } from "@consensys/linea-shared-utils"; + +describe("OssificationPendingProcessor", () => { + const yieldProvider = "0x1111111111111111111111111111111111111111" as Address; + const vaultAddress = "0x2222222222222222222222222222222222222222" as Address; + + let logger: jest.Mocked; + let metricsUpdater: jest.Mocked; + let metricsRecorder: jest.Mocked; + let yieldManager: jest.Mocked>; + let lazyOracle: jest.Mocked>; + let lidoReportClient: jest.Mocked; + let beaconClient: jest.Mocked; + const attemptMock = attempt as jest.MockedFunction; + const msToSecondsMock = msToSeconds as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + logger = { + name: "test", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as jest.Mocked; + + metricsUpdater = { + incrementOperationModeTrigger: jest.fn(), + recordOperationModeDuration: jest.fn(), + incrementLidoVaultAccountingReport: jest.fn(), + recordRebalance: jest.fn(), + addNodeOperatorFeesPaid: jest.fn(), + addLiabilitiesPaid: jest.fn(), + addLidoFeesPaid: jest.fn(), + incrementOperationModeExecution: jest.fn(), + addValidatorPartialUnstakeAmount: jest.fn(), + incrementValidatorExit: jest.fn(), + incrementReportYield: jest.fn(), + addReportedYieldAmount: jest.fn(), + setCurrentNegativeYieldLastReport: jest.fn(), + } as unknown as jest.Mocked; + + metricsRecorder = { + recordSafeWithdrawalMetrics: jest.fn(), + recordProgressOssificationMetrics: jest.fn(), + recordReportYieldMetrics: jest.fn(), + recordTransferFundsMetrics: jest.fn(), + } as unknown as jest.Mocked; + + yieldManager = { + progressPendingOssification: jest.fn(), + safeMaxAddToWithdrawalReserve: jest.fn(), + isOssified: jest.fn(), + getLidoStakingVaultAddress: jest.fn(), + } as unknown as jest.Mocked>; + + lazyOracle = { + waitForVaultsReportDataUpdatedEvent: jest.fn(), + } as unknown as jest.Mocked>; + + lidoReportClient = { + getLatestSubmitVaultReportParams: jest.fn(), + submitLatestVaultReport: jest.fn(), + } as unknown as jest.Mocked; + + beaconClient = { + submitMaxAvailableWithdrawalRequests: jest.fn(), + } as unknown as jest.Mocked; + + lazyOracle.waitForVaultsReportDataUpdatedEvent.mockResolvedValue({ + result: OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT, + report: { + timestamp: 0n, + refSlot: 0n, + treeRoot: "0x" as `0x${string}`, + reportCid: "cid", + }, + txHash: "0xhash" as `0x${string}`, + }); + yieldManager.getLidoStakingVaultAddress.mockResolvedValue(vaultAddress); + lidoReportClient.getLatestSubmitVaultReportParams.mockResolvedValue({ + vault: vaultAddress, + totalValue: 0n, + cumulativeLidoFees: 0n, + liabilityShares: 0n, + maxLiabilityShares: 0n, + slashingReserve: 0n, + proof: [], + }); + lidoReportClient.submitLatestVaultReport.mockResolvedValue(undefined); + yieldManager.progressPendingOssification.mockResolvedValue({ + transactionHash: "0xhash", + } as unknown as TransactionReceipt); + yieldManager.isOssified.mockResolvedValue(false); + yieldManager.safeMaxAddToWithdrawalReserve.mockResolvedValue(undefined as unknown as TransactionReceipt); + msToSecondsMock.mockImplementation((ms: number) => ms / 1_000); + attemptMock.mockImplementation(((logger: ILogger, fn: () => unknown | Promise) => + ResultAsync.fromPromise( + Promise.resolve().then(() => fn()), + (error) => error as Error, + )) as typeof attempt); + }); + + const createProcessor = (shouldSubmitVaultReport: boolean = true) => + new OssificationPendingProcessor( + logger, + metricsUpdater, + metricsRecorder, + yieldManager, + lazyOracle, + lidoReportClient, + beaconClient, + yieldProvider, + shouldSubmitVaultReport, + ); + + it("processes trigger events, submits reports, and progresses ossification", async () => { + const performanceSpy = jest.spyOn(performance, "now").mockReturnValueOnce(1000).mockReturnValueOnce(1600); + + const processor = createProcessor(); + + await processor.process(); + + expect(lazyOracle.waitForVaultsReportDataUpdatedEvent).toHaveBeenCalledTimes(1); + expect(metricsUpdater.incrementOperationModeTrigger).toHaveBeenCalledWith( + OperationMode.OSSIFICATION_PENDING_MODE, + OperationTrigger.VAULTS_REPORT_DATA_UPDATED_EVENT, + ); + expect(beaconClient.submitMaxAvailableWithdrawalRequests).toHaveBeenCalledTimes(1); + expect(yieldManager.getLidoStakingVaultAddress).toHaveBeenCalledWith(yieldProvider); + expect(lidoReportClient.getLatestSubmitVaultReportParams).toHaveBeenCalledWith(vaultAddress); + expect(lidoReportClient.submitLatestVaultReport).toHaveBeenCalledWith(vaultAddress); + expect(metricsUpdater.incrementLidoVaultAccountingReport).toHaveBeenCalledWith(vaultAddress); + expect(yieldManager.progressPendingOssification).toHaveBeenCalledWith(yieldProvider); + expect(metricsRecorder.recordProgressOssificationMetrics).toHaveBeenCalledWith(yieldProvider, expect.anything()); + expect(yieldManager.isOssified).toHaveBeenCalledWith(yieldProvider); + expect(metricsRecorder.recordSafeWithdrawalMetrics).not.toHaveBeenCalled(); + expect(msToSeconds).toHaveBeenCalledWith(600); + expect(metricsUpdater.recordOperationModeDuration).toHaveBeenCalledWith( + OperationMode.OSSIFICATION_PENDING_MODE, + 0.6, + ); + performanceSpy.mockRestore(); + }); + + it("always attempts to submit vault report regardless of outcome", async () => { + lidoReportClient.submitLatestVaultReport.mockRejectedValueOnce(new Error("submission failed")); + + const processor = createProcessor(); + await processor.process(); + + expect(lidoReportClient.submitLatestVaultReport).toHaveBeenCalledWith(vaultAddress); + expect(metricsUpdater.incrementLidoVaultAccountingReport).not.toHaveBeenCalled(); + expect(yieldManager.progressPendingOssification).toHaveBeenCalled(); + }); + + it("returns early when progressPendingOssification fails", async () => { + yieldManager.progressPendingOssification.mockRejectedValue(new Error("progress failed")); + + const processor = createProcessor(); + await processor.process(); + + expect(metricsRecorder.recordProgressOssificationMetrics).not.toHaveBeenCalled(); + expect(yieldManager.safeMaxAddToWithdrawalReserve).not.toHaveBeenCalled(); + }); + + it("performs safe withdrawal when ossification completes", async () => { + yieldManager.isOssified.mockResolvedValue(true); + + const processor = createProcessor(); + await processor.process(); + + expect(metricsRecorder.recordSafeWithdrawalMetrics).toHaveBeenCalledWith(yieldProvider, expect.anything()); + }); + + it("tolerates failure of submitMaxAvailableWithdrawalRequests", async () => { + const failure = new Error("unstake failed"); + beaconClient.submitMaxAvailableWithdrawalRequests.mockRejectedValueOnce(failure); + yieldManager.isOssified.mockResolvedValueOnce(true); + + const processor = createProcessor(); + await processor.process(); + + expect(beaconClient.submitMaxAvailableWithdrawalRequests).toHaveBeenCalledTimes(1); + expect(lidoReportClient.getLatestSubmitVaultReportParams).toHaveBeenCalledWith(vaultAddress); + expect(metricsRecorder.recordProgressOssificationMetrics).toHaveBeenCalledWith(yieldProvider, expect.anything()); + expect(metricsRecorder.recordSafeWithdrawalMetrics).toHaveBeenCalledWith(yieldProvider, expect.anything()); + }); + + it("skips vault report submission when shouldSubmitVaultReport is false", async () => { + const performanceSpy = jest.spyOn(performance, "now").mockReturnValueOnce(1000).mockReturnValueOnce(1600); + + const processor = createProcessor(false); + await processor.process(); + + expect(lidoReportClient.getLatestSubmitVaultReportParams).not.toHaveBeenCalled(); + expect(lidoReportClient.submitLatestVaultReport).not.toHaveBeenCalled(); + expect(metricsUpdater.incrementLidoVaultAccountingReport).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith("_process - Skipping vault report submission (SHOULD_SUBMIT_VAULT_REPORT=false)"); + expect(yieldManager.progressPendingOssification).toHaveBeenCalledWith(yieldProvider); + expect(metricsRecorder.recordProgressOssificationMetrics).toHaveBeenCalledWith(yieldProvider, expect.anything()); + + performanceSpy.mockRestore(); + }); +}); diff --git a/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/YieldReportingProcessor.test.ts b/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/YieldReportingProcessor.test.ts new file mode 100644 index 0000000000..9d40433dda --- /dev/null +++ b/native-yield-operations/automation-service/src/services/operation-mode-processors/__tests__/YieldReportingProcessor.test.ts @@ -0,0 +1,531 @@ +import { jest } from "@jest/globals"; +import { ResultAsync } from "neverthrow"; +import type { ILogger } from "@consensys/linea-shared-utils"; +import type { INativeYieldAutomationMetricsUpdater } from "../../../core/metrics/INativeYieldAutomationMetricsUpdater.js"; +import type { IOperationModeMetricsRecorder } from "../../../core/metrics/IOperationModeMetricsRecorder.js"; +import type { IYieldManager } from "../../../core/clients/contracts/IYieldManager.js"; +import type { ILazyOracle } from "../../../core/clients/contracts/ILazyOracle.js"; +import type { ILidoAccountingReportClient } from "../../../core/clients/ILidoAccountingReportClient.js"; +import type { ILineaRollupYieldExtension } from "../../../core/clients/contracts/ILineaRollupYieldExtension.js"; +import type { IBeaconChainStakingClient } from "../../../core/clients/IBeaconChainStakingClient.js"; +import type { Address, TransactionReceipt, Hex } from "viem"; +import { OperationTrigger } from "../../../core/metrics/LineaNativeYieldAutomationServiceMetrics.js"; +import { OperationMode } from "../../../core/enums/OperationModeEnums.js"; +import { RebalanceDirection } from "../../../core/entities/RebalanceRequirement.js"; +import { YieldReportingProcessor } from "../YieldReportingProcessor.js"; +import type { UpdateVaultDataParams } from "../../../core/clients/contracts/ILazyOracle.js"; + +jest.mock("@consensys/linea-shared-utils", () => { + const actual = jest.requireActual("@consensys/linea-shared-utils") as typeof import("@consensys/linea-shared-utils"); + return { + ...actual, + attempt: jest.fn(), + msToSeconds: jest.fn(), + weiToGweiNumber: jest.fn(), + }; +}); + +import { attempt, msToSeconds, weiToGweiNumber } from "@consensys/linea-shared-utils"; + +describe("YieldReportingProcessor", () => { + const yieldProvider = "0x1111111111111111111111111111111111111111" as Address; + const l2Recipient = "0x2222222222222222222222222222222222222222" as Address; + const vaultAddress = "0x3333333333333333333333333333333333333333" as Address; + const stakeAmount = 10n; + const submitParams: UpdateVaultDataParams = { + vault: vaultAddress, + totalValue: 0n, + cumulativeLidoFees: 0n, + liabilityShares: 0n, + maxLiabilityShares: 0n, + slashingReserve: 0n, + proof: [] as Hex[], + }; + + let logger: jest.Mocked; + let metricsUpdater: jest.Mocked; + let metricsRecorder: jest.Mocked; + let yieldManager: jest.Mocked>; + let lazyOracle: jest.Mocked>; + let lidoReportClient: jest.Mocked; + let yieldExtension: jest.Mocked>; + let beaconClient: jest.Mocked; + const attemptMock = attempt as jest.MockedFunction; + const msToSecondsMock = msToSeconds as jest.MockedFunction; + const weiToGweiNumberMock = weiToGweiNumber as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + + logger = { + name: "logger", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + } as unknown as jest.Mocked; + + metricsUpdater = { + incrementOperationModeTrigger: jest.fn(), + recordOperationModeDuration: jest.fn(), + incrementLidoVaultAccountingReport: jest.fn(), + recordRebalance: jest.fn(), + } as unknown as jest.Mocked; + + metricsRecorder = { + recordTransferFundsMetrics: jest.fn(), + recordSafeWithdrawalMetrics: jest.fn(), + recordReportYieldMetrics: jest.fn(), + } as unknown as jest.Mocked; + + yieldManager = { + getLidoStakingVaultAddress: jest.fn(), + getRebalanceRequirements: jest.fn(), + pauseStakingIfNotAlready: jest.fn(), + unpauseStakingIfNotAlready: jest.fn(), + fundYieldProvider: jest.fn(), + safeAddToWithdrawalReserveIfAboveThreshold: jest.fn(), + reportYield: jest.fn(), + } as unknown as jest.Mocked>; + + lazyOracle = { + waitForVaultsReportDataUpdatedEvent: jest.fn(), + } as unknown as jest.Mocked>; + + lidoReportClient = { + getLatestSubmitVaultReportParams: jest.fn(), + submitLatestVaultReport: jest.fn(), + } as unknown as jest.Mocked; + + yieldExtension = { + transferFundsForNativeYield: jest.fn(), + } as unknown as jest.Mocked>; + + beaconClient = { + submitWithdrawalRequestsToFulfilAmount: jest.fn(), + } as unknown as jest.Mocked; + + lazyOracle.waitForVaultsReportDataUpdatedEvent.mockResolvedValue({ + result: OperationTrigger.TIMEOUT, + }); + yieldManager.getLidoStakingVaultAddress.mockResolvedValue(vaultAddress); + lidoReportClient.getLatestSubmitVaultReportParams.mockResolvedValue(submitParams); + lidoReportClient.submitLatestVaultReport.mockResolvedValue(undefined); + yieldManager.reportYield.mockResolvedValue({ transactionHash: "0xyield" } as unknown as TransactionReceipt); + yieldExtension.transferFundsForNativeYield.mockResolvedValue({ + transactionHash: "0xtransfer", + } as unknown as TransactionReceipt); + yieldManager.fundYieldProvider.mockResolvedValue({ transactionHash: "0xfund" } as unknown as TransactionReceipt); + yieldManager.safeAddToWithdrawalReserveIfAboveThreshold.mockResolvedValue( + undefined as unknown as TransactionReceipt, + ); + + attemptMock.mockImplementation(((loggerArg: ILogger, fn: () => unknown | Promise) => + ResultAsync.fromPromise((async () => fn())(), (error) => error as Error)) as typeof attempt); + msToSecondsMock.mockImplementation((ms: number) => ms / 1_000); + weiToGweiNumberMock.mockImplementation((value: bigint) => Number(value)); + }); + + const createProcessor = (shouldSubmitVaultReport: boolean = true) => + new YieldReportingProcessor( + logger, + metricsUpdater, + metricsRecorder, + yieldManager, + lazyOracle, + yieldExtension, + lidoReportClient, + beaconClient, + yieldProvider, + l2Recipient, + shouldSubmitVaultReport, + ); + + it("_process - processes staking surplus flow and records metrics", async () => { + yieldManager.getRebalanceRequirements + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.STAKE, rebalanceAmount: stakeAmount }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.UNSTAKE, rebalanceAmount: 5n }); + + const performanceSpy = jest.spyOn(performance, "now").mockReturnValueOnce(100).mockReturnValueOnce(250); + + const processor = createProcessor(); + await processor.process(); + + expect(lazyOracle.waitForVaultsReportDataUpdatedEvent).toHaveBeenCalledTimes(1); + expect(metricsUpdater.incrementOperationModeTrigger).toHaveBeenCalledWith( + OperationMode.YIELD_REPORTING_MODE, + OperationTrigger.TIMEOUT, + ); + expect(yieldManager.pauseStakingIfNotAlready).not.toHaveBeenCalled(); + expect(yieldExtension.transferFundsForNativeYield).toHaveBeenCalledWith(stakeAmount); + expect(metricsUpdater.recordRebalance).toHaveBeenCalledWith(RebalanceDirection.STAKE, Number(stakeAmount)); + expect(metricsRecorder.recordTransferFundsMetrics).toHaveBeenCalledTimes(1); + const transferResult = metricsRecorder.recordTransferFundsMetrics.mock.calls[0][1]; + expect(transferResult.isOk()).toBe(true); + expect(lidoReportClient.submitLatestVaultReport).toHaveBeenCalledWith(vaultAddress); + expect(metricsUpdater.incrementLidoVaultAccountingReport).toHaveBeenCalledWith(vaultAddress); + expect(metricsRecorder.recordReportYieldMetrics).toHaveBeenCalledTimes(1); + expect(yieldManager.unpauseStakingIfNotAlready).toHaveBeenCalledWith(yieldProvider); + expect(beaconClient.submitWithdrawalRequestsToFulfilAmount).toHaveBeenCalledWith(5n); + expect(msToSeconds).toHaveBeenCalledWith(150); + expect(metricsUpdater.recordOperationModeDuration).toHaveBeenCalledWith(OperationMode.YIELD_REPORTING_MODE, 0.15); + + performanceSpy.mockRestore(); + }); + + it("_process - pauses staking when starting in deficit and skips unpause", async () => { + yieldManager.getRebalanceRequirements + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.UNSTAKE, rebalanceAmount: 8n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }); + + const performanceSpy = jest.spyOn(performance, "now").mockReturnValueOnce(200).mockReturnValueOnce(260); + + const processor = createProcessor(); + await processor.process(); + + expect(yieldManager.pauseStakingIfNotAlready).toHaveBeenCalledWith(yieldProvider); + expect(metricsRecorder.recordSafeWithdrawalMetrics).toHaveBeenCalledTimes(1); + expect(yieldManager.unpauseStakingIfNotAlready).not.toHaveBeenCalled(); + expect(beaconClient.submitWithdrawalRequestsToFulfilAmount).not.toHaveBeenCalled(); + + performanceSpy.mockRestore(); + }); + + it("_process - skips vault report submission when shouldSubmitVaultReport is false", async () => { + yieldManager.getRebalanceRequirements + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }); + + const performanceSpy = jest.spyOn(performance, "now").mockReturnValueOnce(100).mockReturnValueOnce(200); + + const processor = createProcessor(false); + await processor.process(); + + expect(lidoReportClient.getLatestSubmitVaultReportParams).not.toHaveBeenCalled(); + expect(lidoReportClient.submitLatestVaultReport).not.toHaveBeenCalled(); + expect(metricsUpdater.incrementLidoVaultAccountingReport).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + "_handleSubmitLatestVaultReport: skipping vault report submission (SHOULD_SUBMIT_VAULT_REPORT=false)", + ); + expect(yieldManager.reportYield).toHaveBeenCalledWith(yieldProvider, l2Recipient); + + performanceSpy.mockRestore(); + }); + + it("_process - performs an amendment unstake when stake flow flips to deficit mid-cycle", async () => { + yieldManager.getRebalanceRequirements + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.STAKE, rebalanceAmount: 4n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.UNSTAKE, rebalanceAmount: 6n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }); + + const performanceSpy = jest.spyOn(performance, "now").mockReturnValueOnce(50).mockReturnValueOnce(200); + const processor = createProcessor(); + const amendmentSpy = jest.spyOn( + processor as unknown as { _handleUnstakingRebalance(amount: bigint, shouldReportYield: boolean): Promise }, + "_handleUnstakingRebalance", + ); + + await processor.process(); + + expect(amendmentSpy).toHaveBeenCalledWith(6n, false); + expect(yieldManager.unpauseStakingIfNotAlready).not.toHaveBeenCalled(); + + performanceSpy.mockRestore(); + amendmentSpy.mockRestore(); + }); + + it("_process - if start in excess, and no amendment unstake is required, will perform unpause staking", async () => { + yieldManager.getRebalanceRequirements + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.STAKE, rebalanceAmount: 3n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }); + + const processor = createProcessor(); + const amendmentSpy = jest.spyOn( + processor as unknown as { _handleUnstakingRebalance(amount: bigint, success: boolean): Promise }, + "_handleUnstakingRebalance", + ); + + await processor.process(); + + expect(amendmentSpy).not.toHaveBeenCalled(); + expect(yieldManager.unpauseStakingIfNotAlready).toHaveBeenCalledWith(yieldProvider); + + amendmentSpy.mockRestore(); + }); + + it("_process - does not perform ending beacon chain withdrawal if there is no ending deficit", async () => { + yieldManager.getRebalanceRequirements + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }) + .mockResolvedValueOnce({ rebalanceDirection: RebalanceDirection.STAKE, rebalanceAmount: 2n }); + + const processor = createProcessor(); + await processor.process(); + + expect(beaconClient.submitWithdrawalRequestsToFulfilAmount).not.toHaveBeenCalled(); + }); + + it("_handleRebalance submits report when no rebalance is needed", async () => { + const processor = createProcessor(); + const submitSpy = jest + .spyOn( + processor as unknown as { _handleSubmitLatestVaultReport(): Promise }, + "_handleSubmitLatestVaultReport", + ) + .mockResolvedValue(undefined); + + await ( + processor as unknown as { + _handleRebalance(req: unknown): Promise; + } + )._handleRebalance({ rebalanceDirection: RebalanceDirection.NONE, rebalanceAmount: 0n }); + + expect(submitSpy).toHaveBeenCalledTimes(1); + }); + + it("_handleRebalance routes excess directions to _handleStakingRebalance", async () => { + const processor = createProcessor(); + const stakingSpy = jest + .spyOn( + processor as unknown as { _handleStakingRebalance(amount: bigint): Promise }, + "_handleStakingRebalance", + ) + .mockResolvedValue(undefined); + + await ( + processor as unknown as { + _handleRebalance(req: unknown): Promise; + } + )._handleRebalance({ rebalanceDirection: RebalanceDirection.STAKE, rebalanceAmount: 42n }); + + expect(stakingSpy).toHaveBeenCalledWith(42n); + }); + + it("_handleRebalance routes deficit directions to _handleUnstakingRebalance", async () => { + const processor = createProcessor(); + const unstakeSpy = jest + .spyOn( + processor as unknown as { _handleUnstakingRebalance(amount: bigint, shouldReportYield: boolean): Promise }, + "_handleUnstakingRebalance", + ) + .mockResolvedValue(undefined); + + await ( + processor as unknown as { + _handleRebalance(req: unknown): Promise; + } + )._handleRebalance({ rebalanceDirection: RebalanceDirection.UNSTAKE, rebalanceAmount: 17n }); + + expect(unstakeSpy).toHaveBeenCalledWith(17n, true); + }); + + it("_handleStakingRebalance successfully calls rebalance functions and reports yield", async () => { + const processor = createProcessor(); + const submitSpy = jest + .spyOn( + processor as unknown as { _handleSubmitLatestVaultReport(): Promise }, + "_handleSubmitLatestVaultReport", + ) + .mockResolvedValue(undefined); + + await ( + processor as unknown as { + _handleStakingRebalance(amount: bigint): Promise; + } + )._handleStakingRebalance(18n); + + expect(yieldExtension.transferFundsForNativeYield).toHaveBeenCalledWith(18n); + expect(metricsUpdater.recordRebalance).toHaveBeenCalledWith(RebalanceDirection.STAKE, Number(18n)); + expect(yieldManager.fundYieldProvider).toHaveBeenCalledWith(yieldProvider, 18n); + expect(metricsRecorder.recordTransferFundsMetrics).toHaveBeenCalledTimes(1); + const transferResult = metricsRecorder.recordTransferFundsMetrics.mock.calls[0][1]; + expect(transferResult.isOk()).toBe(true); + expect(submitSpy).toHaveBeenCalledTimes(1); + submitSpy.mockRestore(); + }); + + it("_handleStakingRebalance tolerates failure of fundYieldProvider", async () => { + yieldManager.fundYieldProvider.mockRejectedValueOnce(new Error("fund fail")); + + const processor = createProcessor(); + const submitSpy = jest + .spyOn( + processor as unknown as { _handleSubmitLatestVaultReport(): Promise }, + "_handleSubmitLatestVaultReport", + ) + .mockResolvedValue(undefined); + + await ( + processor as unknown as { + _handleStakingRebalance(amount: bigint): Promise; + } + )._handleStakingRebalance(11n); + + expect(yieldExtension.transferFundsForNativeYield).toHaveBeenCalledWith(11n); + expect(metricsUpdater.recordRebalance).toHaveBeenCalledWith(RebalanceDirection.STAKE, Number(11n)); + expect(metricsRecorder.recordTransferFundsMetrics).toHaveBeenCalledTimes(1); + const result = metricsRecorder.recordTransferFundsMetrics.mock.calls[0][1]; + expect(result.isErr()).toBe(true); + expect(submitSpy).toHaveBeenCalledTimes(1); + submitSpy.mockRestore(); + }); + + it("_handleStakingRebalance tolerates failure of transferFundsForNativeYieldResult, but will skip fundYieldProvider and metrics update", async () => { + yieldExtension.transferFundsForNativeYield.mockRejectedValueOnce(new Error("transfer fail")); + + const processor = createProcessor(); + const submitSpy = jest + .spyOn( + processor as unknown as { _handleSubmitLatestVaultReport(): Promise }, + "_handleSubmitLatestVaultReport", + ) + .mockResolvedValue(undefined); + + await ( + processor as unknown as { + _handleStakingRebalance(amount: bigint): Promise; + } + )._handleStakingRebalance(9n); + + expect(metricsUpdater.recordRebalance).not.toHaveBeenCalled(); + expect(yieldManager.fundYieldProvider).not.toHaveBeenCalled(); + expect(metricsRecorder.recordTransferFundsMetrics).not.toHaveBeenCalled(); + expect(submitSpy).toHaveBeenCalledTimes(1); + submitSpy.mockRestore(); + }); + + it("_handleUnstakingRebalance submits report before withdrawing when shouldReportYield is true", async () => { + const processor = createProcessor(); + const submitSpy = jest + .spyOn( + processor as unknown as { _handleSubmitLatestVaultReport(): Promise }, + "_handleSubmitLatestVaultReport", + ) + .mockResolvedValue(undefined); + + await ( + processor as unknown as { + _handleUnstakingRebalance(amount: bigint, shouldReportYield: boolean): Promise; + } + )._handleUnstakingRebalance(15n, true); + + expect(submitSpy).toHaveBeenCalledTimes(1); + expect(metricsRecorder.recordSafeWithdrawalMetrics).toHaveBeenCalledTimes(1); + }); + + it("_handleUnstakingRebalance skips report submission when shouldReportYield is false", async () => { + const processor = createProcessor(); + const submitSpy = jest.spyOn( + processor as unknown as { _handleSubmitLatestVaultReport(): Promise }, + "_handleSubmitLatestVaultReport", + ); + + await ( + processor as unknown as { + _handleUnstakingRebalance(amount: bigint, shouldReportYield: boolean): Promise; + } + )._handleUnstakingRebalance(7n, false); + + expect(submitSpy).not.toHaveBeenCalled(); + expect(metricsRecorder.recordSafeWithdrawalMetrics).toHaveBeenCalledTimes(1); + }); + + it("_handleUnstakingRebalance tolerates failure of safeAddToWithdrawalReserveIfAboveThreshold", async () => { + const error = new Error("withdrawal failed"); + yieldManager.safeAddToWithdrawalReserveIfAboveThreshold.mockRejectedValueOnce(error); + const processor = createProcessor(); + + await ( + processor as unknown as { + _handleUnstakingRebalance(amount: bigint, shouldReportYield: boolean): Promise; + } + )._handleUnstakingRebalance(20n, false); + + expect(metricsRecorder.recordSafeWithdrawalMetrics).toHaveBeenCalledTimes(1); + const result = metricsRecorder.recordSafeWithdrawalMetrics.mock.calls[0][1]; + expect(result.isErr()).toBe(true); + }); + + it("_handleSubmitLatestVaultReport continues to report yield when vault submission fails", async () => { + lidoReportClient.submitLatestVaultReport.mockRejectedValueOnce(new Error("vault fail")); + + const processor = createProcessor(); + (processor as unknown as { vault: Address }).vault = vaultAddress; + + await ( + processor as unknown as { + _handleSubmitLatestVaultReport(): Promise; + } + )._handleSubmitLatestVaultReport(); + + expect(metricsUpdater.incrementLidoVaultAccountingReport).not.toHaveBeenCalled(); + expect(yieldManager.reportYield).toHaveBeenCalledWith(yieldProvider, l2Recipient); + expect(metricsRecorder.recordReportYieldMetrics).toHaveBeenCalledWith( + yieldProvider, + expect.objectContaining({ isOk: expect.any(Function) }), + ); + }); + + it("_handleSubmitLatestVaultReport continues execution when yield reporting fails", async () => { + yieldManager.reportYield.mockRejectedValueOnce(new Error("yield fail")); + + const processor = createProcessor(); + (processor as unknown as { vault: Address }).vault = vaultAddress; + + await ( + processor as unknown as { + _handleSubmitLatestVaultReport(): Promise; + } + )._handleSubmitLatestVaultReport(); + + expect(metricsUpdater.incrementLidoVaultAccountingReport).toHaveBeenCalledWith(vaultAddress); + expect(logger.info).toHaveBeenCalledWith("_handleSubmitLatestVaultReport: vault report succeeded"); + expect(metricsRecorder.recordReportYieldMetrics).not.toHaveBeenCalled(); + }); + + it("_handleSubmitLatestVaultReport logs success when both steps succeed", async () => { + const processor = createProcessor(); + (processor as unknown as { vault: Address }).vault = vaultAddress; + + await ( + processor as unknown as { + _handleSubmitLatestVaultReport(): Promise; + } + )._handleSubmitLatestVaultReport(); + + expect(metricsUpdater.incrementLidoVaultAccountingReport).toHaveBeenCalledWith(vaultAddress); + expect(logger.info).toHaveBeenCalledWith("_handleSubmitLatestVaultReport: vault report succeeded"); + expect(metricsRecorder.recordReportYieldMetrics).toHaveBeenCalledWith( + yieldProvider, + expect.objectContaining({ isOk: expect.any(Function) }), + ); + expect(logger.info).toHaveBeenCalledWith("_handleSubmitLatestVaultReport: yield report succeeded"); + }); + + it("_handleSubmitLatestVaultReport skips vault report submission when shouldSubmitVaultReport is false", async () => { + const processor = createProcessor(false); + (processor as unknown as { vault: Address }).vault = vaultAddress; + + await ( + processor as unknown as { + _handleSubmitLatestVaultReport(): Promise; + } + )._handleSubmitLatestVaultReport(); + + expect(lidoReportClient.submitLatestVaultReport).not.toHaveBeenCalled(); + expect(metricsUpdater.incrementLidoVaultAccountingReport).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + "_handleSubmitLatestVaultReport: skipping vault report submission (SHOULD_SUBMIT_VAULT_REPORT=false)", + ); + expect(yieldManager.reportYield).toHaveBeenCalledWith(yieldProvider, l2Recipient); + expect(metricsRecorder.recordReportYieldMetrics).toHaveBeenCalledWith( + yieldProvider, + expect.objectContaining({ isOk: expect.any(Function) }), + ); + expect(logger.info).toHaveBeenCalledWith("_handleSubmitLatestVaultReport: yield report succeeded"); + }); +}); diff --git a/native-yield-operations/automation-service/src/utils/createApolloClient.ts b/native-yield-operations/automation-service/src/utils/createApolloClient.ts new file mode 100644 index 0000000000..f06ac6138d --- /dev/null +++ b/native-yield-operations/automation-service/src/utils/createApolloClient.ts @@ -0,0 +1,38 @@ +import { ApolloClient, HttpLink, InMemoryCache, from } from "@apollo/client"; +import { SetContextLink } from "@apollo/client/link/context"; +import { IOAuth2TokenClient } from "@consensys/linea-shared-utils"; + +/** + * Creates an Apollo Client instance configured with OAuth2 authentication. + * Sets up an HTTP link for GraphQL requests and an authentication link that injects + * bearer tokens into request headers. The authentication link runs before the HTTP link + * to ensure all requests are authenticated. + * + * @param {IOAuth2TokenClient} oAuth2TokenClient - The OAuth2 token client used to obtain bearer tokens for authentication. + * @param {string} graphqlUri - The GraphQL endpoint URI. + * @returns {ApolloClient} A configured Apollo Client instance with OAuth2 authentication and in-memory cache. + */ +// Could move to linea-shared-utils one day, if there is another project using @apollo/client +export function createApolloClient(oAuth2TokenClient: IOAuth2TokenClient, graphqlUri: string): ApolloClient { + // --- create the base HTTP transport + const httpLink = new HttpLink({ + uri: graphqlUri, + }); + + const asyncAuthLink = new SetContextLink(async (prevContext, operation) => { + void operation; + const bearerToken = await oAuth2TokenClient.getBearerToken(); + return { + headers: { + ...prevContext.headers, + authorization: bearerToken, + }, + }; + }); + // --- combine links so authLink runs before httpLink + const client = new ApolloClient({ + link: from([asyncAuthLink, httpLink]), + cache: new InMemoryCache(), + }); + return client; +} diff --git a/native-yield-operations/automation-service/tsconfig.build.json b/native-yield-operations/automation-service/tsconfig.build.json new file mode 100644 index 0000000000..e3946fa696 --- /dev/null +++ b/native-yield-operations/automation-service/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "noEmit": false, + "outDir": "dist", + "rootDir": ".", + "sourceMap": true + }, + "include": ["./src/**/*.ts", "**/run.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/native-yield-operations/automation-service/tsconfig.jest.json b/native-yield-operations/automation-service/tsconfig.jest.json new file mode 100644 index 0000000000..25700fd979 --- /dev/null +++ b/native-yield-operations/automation-service/tsconfig.jest.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": false, + "sourceMap": true + }, + "include": ["src", "**/*.test.ts"] +} \ No newline at end of file diff --git a/native-yield-operations/automation-service/tsconfig.json b/native-yield-operations/automation-service/tsconfig.json new file mode 100644 index 0000000000..0f6238adaa --- /dev/null +++ b/native-yield-operations/automation-service/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "exactOptionalPropertyTypes": false + }, + "references": [ + { + "path": "../../ts-libs/linea-shared-utils" + }, + { + "path": "../../sdk/sdk-ethers" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cac82e026..fea1e823de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,21 +18,36 @@ catalogs: '@types/yargs': specifier: 17.0.33 version: 17.0.33 + axios: + specifier: 1.12.2 + version: 1.12.2 dotenv: specifier: 16.5.0 version: 16.5.0 ethers: specifier: 6.13.7 version: 6.13.7 + express: + specifier: 5.1.0 + version: 5.1.0 jest: specifier: 29.7.0 version: 29.7.0 jest-mock-extended: specifier: 3.0.7 version: 3.0.7 + neverthrow: + specifier: 8.2.0 + version: 8.2.0 + prom-client: + specifier: 15.1.3 + version: 15.1.3 ts-jest: specifier: 29.3.4 version: 29.3.4 + tsup: + specifier: 8.5.0 + version: 8.5.0 typechain: specifier: 8.3.2 version: 8.3.2 @@ -45,6 +60,9 @@ catalogs: yargs: specifier: 17.7.2 version: 17.7.2 + zod: + specifier: 3.24.2 + version: 3.24.2 overrides: '@metamask/sdk@>=0.16.0 <=0.33.0': '>=0.33.1' @@ -54,6 +72,7 @@ overrides: axios@>=0.8.1 <0.28.0: '>=0.28.0' axios@<0.30.0: '>=0.30.0' axios@<1.12.0: '>=1.12.0' + color: 4.2.3 cookie@<0.7.0: '>=0.7.0' elliptic@>=5.2.1 <=6.5.6: '>=6.5.7' elliptic@>=4.0.0 <=6.5.6: '>=6.5.7' @@ -71,7 +90,6 @@ overrides: ws@>=8.0.0 <8.17.1: '>=8.17.1' ws@>=7.0.0 <7.5.10: '>=7.5.10' ws@>=2.1.0 <5.2.4: '>=5.2.4' - color: 4.2.3 importers: @@ -163,7 +181,7 @@ importers: version: 9.7.0(@babel/runtime@7.27.6)(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2) '@web3auth/modal': specifier: 10.6.0 - version: 10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(@solana/web3.js@1.98.2(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(encoding@0.1.13)(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(react-dom@18.3.1(react@18.3.1))(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2) + version: 10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(@solana/web3.js@1.98.2(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(encoding@0.1.13)(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(react-dom@18.3.1(react@18.3.1))(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2) '@web3auth/openlogin-adapter': specifier: 8.12.4 version: 8.12.4(@babel/runtime@7.27.6)(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -207,7 +225,7 @@ importers: specifier: 2.16.9 version: 2.16.9(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@tanstack/query-core@5.87.4)(@tanstack/react-query@5.87.4(react@18.3.1))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2) zod: - specifier: 3.24.2 + specifier: 'catalog:' version: 3.24.2 zustand: specifier: 4.5.4 @@ -388,6 +406,49 @@ importers: specifier: 'catalog:' version: 3.17.0 + native-yield-operations/automation-service: + dependencies: + '@apollo/client': + specifier: 4.0.7 + version: 4.0.7(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rxjs@7.8.2) + '@consensys/linea-shared-utils': + specifier: workspace:* + version: link:../../ts-libs/linea-shared-utils + '@lidofinance/lsv-cli': + specifier: 1.0.0-alpha.62 + version: 1.0.0-alpha.62(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2) + dotenv: + specifier: 'catalog:' + version: 16.5.0 + neverthrow: + specifier: 'catalog:' + version: 8.2.0 + viem: + specifier: 'catalog:' + version: 2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2) + winston: + specifier: 'catalog:' + version: 3.17.0 + zod: + specifier: 'catalog:' + version: 3.24.2 + devDependencies: + '@jest/globals': + specifier: 'catalog:' + version: 29.7.0 + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)) + jest-mock-extended: + specifier: 'catalog:' + version: 3.0.7(jest@29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)))(typescript@5.4.5) + ts-jest: + specifier: 'catalog:' + version: 29.3.4(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(esbuild@0.25.5)(jest@29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)))(typescript@5.4.5) + operations: dependencies: '@aws-sdk/client-cost-explorer': @@ -460,6 +521,9 @@ importers: '@consensys/linea-sdk': specifier: workspace:* version: link:../sdk/sdk-ethers + '@consensys/linea-shared-utils': + specifier: workspace:* + version: link:../ts-libs/linea-shared-utils better-sqlite3: specifier: 11.6.0 version: 11.6.0 @@ -473,7 +537,7 @@ importers: specifier: 'catalog:' version: 6.13.7(bufferutil@4.0.8)(utf-8-validate@5.0.10) express: - specifier: 5.1.0 + specifier: 'catalog:' version: 5.1.0 filtrex: specifier: 3.1.0 @@ -482,7 +546,7 @@ importers: specifier: 8.13.1 version: 8.13.1 prom-client: - specifier: 15.1.3 + specifier: 'catalog:' version: 15.1.3 typeorm: specifier: 0.3.20 @@ -535,7 +599,7 @@ importers: specifier: 'catalog:' version: 29.3.4(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(esbuild@0.25.5)(jest@29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)))(typescript@5.4.5) tsup: - specifier: 8.5.0 + specifier: 'catalog:' version: 8.5.0(jiti@2.3.3)(postcss@8.5.3)(typescript@5.4.5)(yaml@2.8.0) viem: specifier: 'catalog:' @@ -588,7 +652,7 @@ importers: specifier: 'catalog:' version: 29.3.4(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(esbuild@0.25.5)(jest@29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)))(typescript@5.4.5) tsup: - specifier: 8.5.0 + specifier: 'catalog:' version: 8.5.0(jiti@2.3.3)(postcss@8.5.3)(typescript@5.4.5)(yaml@2.8.0) viem: specifier: 'catalog:' @@ -622,7 +686,7 @@ importers: specifier: 'catalog:' version: 29.3.4(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(esbuild@0.25.5)(jest@29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)))(typescript@5.4.5) tsup: - specifier: 8.5.0 + specifier: 'catalog:' version: 8.5.0(jiti@2.3.3)(postcss@8.5.3)(typescript@5.4.5)(yaml@2.8.0) unzipper: specifier: 0.12.3 @@ -631,6 +695,52 @@ importers: specifier: 2.29.1 version: 2.29.1(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.25.76) + ts-libs/linea-shared-utils: + dependencies: + axios: + specifier: 'catalog:' + version: 1.12.2(debug@4.4.0) + express: + specifier: 'catalog:' + version: 5.1.0 + neverthrow: + specifier: 'catalog:' + version: 8.2.0 + node-forge: + specifier: 1.3.1 + version: 1.3.1 + prom-client: + specifier: 'catalog:' + version: 15.1.3 + viem: + specifier: 'catalog:' + version: 2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.25.76) + winston: + specifier: 'catalog:' + version: 3.17.0 + devDependencies: + '@types/express': + specifier: 5.0.4 + version: 5.0.4 + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/node-forge': + specifier: 1.3.14 + version: 1.3.14 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)) + jest-mock-extended: + specifier: 'catalog:' + version: 3.0.7(jest@29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)))(typescript@5.4.5) + ts-jest: + specifier: 'catalog:' + version: 29.3.4(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(esbuild@0.25.5)(jest@29.7.0(@types/node@22.7.5)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@22.7.5)(typescript@5.4.5)))(typescript@5.4.5) + tsup: + specifier: 'catalog:' + version: 8.5.0(jiti@2.3.3)(postcss@8.5.3)(typescript@5.4.5)(yaml@2.8.0) + packages: '@0no-co/graphql.web@1.1.2': @@ -660,9 +770,31 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@apollo/client@4.0.7': + resolution: {integrity: sha512-hZp/mKtAqM+g6buUnu6Wqtyc33QebvfdY0SE46xWea4lU1CxwI57VORy2N2vA9CoCRgYM4ELNXzr6nNErAdhfg==} + peerDependencies: + graphql: ^16.0.0 + graphql-ws: ^5.5.5 || ^6.0.3 + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + rxjs: ^7.3.0 + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + '@arbitrum/nitro-contracts@3.0.0': resolution: {integrity: sha512-7VzNW9TxvrX9iONDDsi7AZlEUPa6z+cjBkB4Mxlnog9VQZAapRC3CdRXyUzHnBYmUhRzyNJdyxkWPw59QGcLmA==} + '@assemblyscript/loader@0.9.4': + resolution: {integrity: sha512-HazVq9zwTVwGmqdwYzu7WyQ6FQVZ7SwET0KKQuKm55jD0IfUpZgN0OPIiZG3zV1iSrVYcN0bdwLRXI/VNCYsUA==} + '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -750,6 +882,10 @@ packages: resolution: {integrity: sha512-4SosHWRQ8hj1X2yDenCYHParcCjHcd7S+Mdb/lelwF0JBFCNC+dNCI9ws3cP/dFdZO/AIhJQGUBzEQtieloixw==} engines: {node: '>=18.0.0'} + '@aws-sdk/types@3.664.0': + resolution: {integrity: sha512-+GtXktvVgpreM2b+NJL9OqZGsOzHwlCUrO8jgQUvH/yA6Kd8QO2YFhQCp0C9sSzTteZJVqGBu8E0CQurxJHPbw==} + engines: {node: '>=16.0.0'} + '@aws-sdk/types@3.901.0': resolution: {integrity: sha512-FfEM25hLEs4LoXsLXQ/q6X6L4JmKkKkbVFpKD4mwfVHtRVQG6QxJiCPcrkcPISquiy6esbwK2eh64TWbiD60cg==} engines: {node: '>=18.0.0'} @@ -1588,6 +1724,115 @@ packages: resolution: {integrity: sha512-SpNCJ0TPOI6pa2l702Wk4WIP8ccw5ARcRP1E/ZTqaFffXNoZeF03WhsVL8f3l3OTRFA9Z40O5KcZzmJmZQkoFA==} engines: {node: '>=18', pnpm: '>=10'} + '@chainsafe/as-sha256@1.2.0': + resolution: {integrity: sha512-H2BNHQ5C3RS+H0ZvOdovK6GjFAyq5T6LClad8ivwj9Oaiy28uvdsGVS7gNJKuZmg0FGHAI+n7F0Qju6U0QkKDA==} + + '@chainsafe/blst-darwin-arm64@2.2.0': + resolution: {integrity: sha512-BOOy2KHbV028cioPWaAMqHdLRKd6/3XyEmUEcQC2E/SpyYLdNcaKiBUYIU4pT9CrWBbJJxX68UI+3vZVg0M8/w==} + engines: {node: '>= 16'} + cpu: [arm64] + os: [darwin] + + '@chainsafe/blst-darwin-x64@2.2.0': + resolution: {integrity: sha512-jG64cwIdPT7u/haRrW26tWCpfMfHBQCfGY169mFQifCwO4VEwvaiVBPOh5olFis6LjpcmD+O0jpM8GqrnsmUHQ==} + engines: {node: '>= 16'} + cpu: [x64] + os: [darwin] + + '@chainsafe/blst-linux-arm64-gnu@2.2.0': + resolution: {integrity: sha512-L8xV2uuLn8we76vdzfryS9ePdheuZrmY6yArGUFaF1Uzcwml6V1/VvyPl9/uooo/YfVRIrvF/D+lQfI2GFAnhw==} + engines: {node: '>= 16'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@chainsafe/blst-linux-arm64-musl@2.2.0': + resolution: {integrity: sha512-0Vn0luxLYVgC3lvWT1MapFHSAoz99PldqjhilXTGv0AcAk/X5LXPH2RC9Dp2KJGqthyUkpbk1j47jUBfBI+BIg==} + engines: {node: '>= 16'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@chainsafe/blst-linux-x64-gnu@2.2.0': + resolution: {integrity: sha512-gEY/z2SDBA7kXtFEI9VNhWTJAIjx16jdeAyCaS2k4ACGurWZaWk+Ee4KniTsr4WieSqeuNTUr7Pdja0Sr4EKNQ==} + engines: {node: '>= 16'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@chainsafe/blst-linux-x64-musl@2.2.0': + resolution: {integrity: sha512-58GKtiUmtVSuerRzPEcMNQZpICPboBKFnL7+1Wo+PSuajkvbae7tEFrFTtWeMoKIPgOEsPMnk96LF+0yNgavUg==} + engines: {node: '>= 16'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@chainsafe/blst-win32-x64-msvc@2.2.0': + resolution: {integrity: sha512-UFrZshl4dfX5Uh2zeKXAZtrkQ+otczHMON2tsrapQNICWmfHZrzE6pKuBL+9QeGAbgflwpbz7+D5nQRDpiuHxQ==} + engines: {node: '>= 16'} + cpu: [x64] + os: [win32] + + '@chainsafe/blst@2.2.0': + resolution: {integrity: sha512-VBaQoNE2a9d9+skAjQKv3Suk0yGKqp3mZM0YWYJNPj/Ae/f6lAyeVSgKqo2LrsNQBzD/LqrJLKUY8rJT3vDKLA==} + engines: {node: '>= 16'} + + '@chainsafe/hashtree-darwin-arm64@1.0.2': + resolution: {integrity: sha512-yIIwn9SUR5ZTl2vN1QqRtDFL/w2xYW4o68A1k8UexMbieGAnE7Ab7NvtCZRHRe8x0eONO46F/bWn5bxxyYlFXw==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [darwin] + + '@chainsafe/hashtree-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-MDz1xBRTRHw2eezGqx1Ff8NoeUUQP3bhbeeVG8ZZTkFYqvRc8O65OQOTtgO+fFGvqnDjVBSRHmiTXU5eNeH/mQ==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@chainsafe/hashtree-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-BUy+/9brJwAFAtraro4y/1F+aP/8j/7HrnYdde8PTu7jHWAClI9xZygadaJbk0GoWxyCOUAJKUs8KHVnYxJDeg==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@chainsafe/hashtree-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-bFy9ffFG77SivmeOjOlZmOCrxzQ/WqUESy0I+dW6IX7wquTXHldJKWvohs9+FEn3TSXgeigFmEATz5tfxBfIZw==} + engines: {node: '>= 18'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@chainsafe/hashtree-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mbJB3C0RjwpqOMPZIUQm3IBH6d3sYiKDXMU6ORt5nuk7Ix2I80xxffAciDO1d7kKNnW6HStOj5s/rGhIDxK1ug==} + engines: {node: '>= 18'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@chainsafe/hashtree-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-wXFhGqaydgadefQbjSTGqZY1R1MBhnJj+gbJhULNRUXco5pHsXfOk3QhCDAefp1PPW+wQwfT4clEnQCqJIf58w==} + engines: {node: '>= 18'} + cpu: [x64] + os: [win32] + + '@chainsafe/hashtree@1.0.2': + resolution: {integrity: sha512-OaWjsZ6S/GaT2RvaqdpsF5Mux8qQOE2KbitX2yHmQJZNUZkdh7C3N4PA5LsvewqX+z8Nkv8mr1uSe0LSrHGiQw==} + engines: {node: '>= 18'} + + '@chainsafe/is-ip@2.1.0': + resolution: {integrity: sha512-KIjt+6IfysQ4GCv66xihEitBjvhU/bixbbbFxdJ1sqCp4uJ0wuZiYBPhksZoy4lfaF0k9cwNzY5upEW/VWdw3w==} + + '@chainsafe/netmask@2.0.0': + resolution: {integrity: sha512-I3Z+6SWUoaljh3TBzCnCxjlUyN8tA+NAk5L6m9IxvCf1BENQTePzPMis97CoN/iMW1St3WN+AWCCRp+TTBRiDg==} + + '@chainsafe/persistent-merkle-tree@1.2.1': + resolution: {integrity: sha512-AOSEVLfaqwb9eTCKuY1ri0DrRxVQ3Rh+we1VBj1GahUGfEdE8OC3Vkbca7Up6RoI9Ip9FLnI31Y7AjKH9ZqAGA==} + + '@chainsafe/ssz@1.2.2': + resolution: {integrity: sha512-kIA3fJO6h2RsQndsNBlCSQYB4xfdZGMQvNPKPgbiB0mysV6okuxeJU3Nyl16xDCKv3tqej76eGYHcyjMVt7V1w==} + '@chaitanyapotti/register-service-worker@1.7.4': resolution: {integrity: sha512-+u78X4ljCleLy1okQMtYLTXGLHdFQcwai822xu3oHRTviKEIVkQTMNhCmbYTCiP24thY6AbH9g+c6p2LNU0pnA==} @@ -2514,6 +2759,10 @@ packages: cpu: [x64] os: [win32] + '@ipld/dag-pb@4.1.5': + resolution: {integrity: sha512-w4PZ2yPqvNmlAir7/2hsCRMqny1EY5jj26iZcSgxREJexmbAc2FI21jp26MqiNdfgAxvkCnf2N/TJI18GaDNwA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2651,6 +2900,19 @@ packages: react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc zustand: 4.5.7 + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + + '@libp2p/interface@2.11.0': + resolution: {integrity: sha512-0MUFKoXWHTQW3oWIgSHApmYMUKWO/Y02+7Hpyp+n3z+geD4Xo2Rku2gYWmxcq+Pyjkz6Q9YjDWz3Yb2SoV2E8Q==} + + '@libp2p/logger@5.2.0': + resolution: {integrity: sha512-OEFS529CnIKfbWEHmuCNESw9q0D0hL8cQ8klQfjIVPur15RcgAEgc1buQ7Y6l0B6tCYg120bp55+e9tGvn8c0g==} + + '@lidofinance/lsv-cli@1.0.0-alpha.62': + resolution: {integrity: sha512-z0Zq0PqoblhjR12oilLkot4OnWOWA370el9pI8ciy/18rsb6UuKgscf1INnD4yA0GCHaAcHRaZNc5EdJqOJlew==} + hasBin: true + '@lifi/sdk@3.7.9': resolution: {integrity: sha512-QPSbUi032SK70h0JZ1yKAUSl5E7K5Ph/7RPXRmGwFzYf8VD712zkvndQCqZefN5I4fl3taw6dMTf1Js3gIEICA==} peerDependencies: @@ -2689,6 +2951,12 @@ packages: '@lit/reactive-element@2.1.0': resolution: {integrity: sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==} + '@lodestar/params@1.35.0': + resolution: {integrity: sha512-Ky2HmruUY1BZEiNeK6pfbxInvahdb9xXJLenzw0uuT7TYGkf6ShO30DgvUMWSmxeqCY1/mUOVUYxPgiLo9B7jQ==} + + '@lodestar/types@1.35.0': + resolution: {integrity: sha512-c2Kws0wpzA0N5P8ggm2OBKdZIVCmLPCliNA+LzbUKqkn9nz7bTVHwjA563J+3b+brZ9aSkq8fKNp+CRM/6qruA==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -2707,6 +2975,10 @@ packages: resolution: {integrity: sha512-+bqFNe6/YCe2LZapiVJO3/hn7BSJLExYT+cDJS1spJocPeVsTyURv3IjblZxRqTOv+igTwrYbh45LvdkgzxkZQ==} engines: {node: ^18.18 || >=20} + '@metamask/abi-utils@2.0.4': + resolution: {integrity: sha512-StnIgUB75x7a7AgUhiaUZDpCsqGp7VkNnZh2XivXkJ6mPkE83U8ARGQj5MbRis7VJY8BC5V1AbB1fjdh0hupPQ==} + engines: {node: '>=16.0.0'} + '@metamask/abi-utils@3.0.0': resolution: {integrity: sha512-a/l0DiSIr7+CBYVpHygUa3ztSlYLFCQMsklLna+t6qmNY9+eIO5TedNxhyIyvaJ+4cN7TLy0NQFbp9FV3X2ktg==} engines: {node: ^18.18 || ^20.14 || >=22} @@ -2908,6 +3180,16 @@ packages: '@types/react': optional: true + '@multiformats/dns@1.0.10': + resolution: {integrity: sha512-6X200ceQLns0b/CU0S/So16tGjB5eIXHJ1xvJMPoWaKFHWSgfpW2EhkWJrqap4U3+c37zcowVR0ToPXeYEL7Vw==} + + '@multiformats/multiaddr@12.5.1': + resolution: {integrity: sha512-+DDlr9LIRUS8KncI1TX/FfUn8F2dl6BIxJgshS/yFQCNB5IAF0OGzcwB39g5NLE22s4qqDePv0Qof6HdpJ/4aQ==} + + '@multiformats/murmur3@2.1.8': + resolution: {integrity: sha512-6vId1C46ra3R1sbJUOFCZnsUIveR9oF20yhPmAFxPm0JfrX3/ZRCgP3YDrBzlGoEppOXnA9czHeYc0T9mB6hbA==} + engines: {node: '>=16.0.0', npm: '>=7.0.0'} + '@mysten/bcs@1.6.2': resolution: {integrity: sha512-po3tjm3Ue7UM1cuveDvrIckR6y22LKXr7KAQf12ZOMasZ0rRjBOdq3zDCVBJC+m536hNLq5uG5U2SQPUGN5+JA==} @@ -3301,6 +3583,9 @@ packages: '@nomicfoundation/hardhat-verify': optional: true + '@openzeppelin/merkle-tree@1.0.8': + resolution: {integrity: sha512-E2c9/Y3vjZXwVvPZKqCKUn7upnvam1P1ZhowJyZVQSkzZm5WhumtaRr+wkUXrZVfkIc7Gfrl7xzabElqDL09ow==} + '@openzeppelin/upgrades-core@1.42.1': resolution: {integrity: sha512-8qnz2XfQrco8R8u9NjV+KiSLrVn7DnWFd+3BuhTUjhVy0bzCSu2SMKCVpZLtXbxf4f2dpz8jYPQYRa6s23PhLA==} hasBin: true @@ -4692,32 +4977,32 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} - '@smithy/abort-controller@4.2.0': - resolution: {integrity: sha512-PLUYa+SUKOEZtXFURBu/CNxlsxfaFGxSBPcStL13KpVeVWIfdezWyDqkz7iDLmwnxojXD0s5KzuB5HGHvt4Aeg==} + '@smithy/abort-controller@4.2.3': + resolution: {integrity: sha512-xWL9Mf8b7tIFuAlpjKtRPnHrR8XVrwTj5NPYO/QwZPtc0SDLsPxb56V5tzi5yspSMytISHybifez+4jlrx0vkQ==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.3.0': - resolution: {integrity: sha512-9oH+n8AVNiLPK/iK/agOsoWfrKZ3FGP3502tkksd6SRsKMYiu7AFX0YXo6YBADdsAj7C+G/aLKdsafIJHxuCkQ==} + '@smithy/config-resolver@4.4.0': + resolution: {integrity: sha512-Kkmz3Mup2PGp/HNJxhCWkLNdlajJORLSjwkcfrj0E7nu6STAEdcMR1ir5P9/xOmncx8xXfru0fbUYLlZog/cFg==} engines: {node: '>=18.0.0'} - '@smithy/core@3.15.0': - resolution: {integrity: sha512-VJWncXgt+ExNn0U2+Y7UywuATtRYaodGQKFo9mDyh70q+fJGedfrqi2XuKU1BhiLeXgg6RZrW7VEKfeqFhHAJA==} + '@smithy/core@3.17.1': + resolution: {integrity: sha512-V4Qc2CIb5McABYfaGiIYLTmo/vwNIK7WXI5aGveBd9UcdhbOMwcvIMxIw/DJj1S9QgOMa/7FBkarMdIC0EOTEQ==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.0': - resolution: {integrity: sha512-SOhFVvFH4D5HJZytb0bLKxCrSnwcqPiNlrw+S4ZXjMnsC+o9JcUQzbZOEQcA8yv9wJFNhfsUiIUKiEnYL68Big==} + '@smithy/credential-provider-imds@4.2.3': + resolution: {integrity: sha512-hA1MQ/WAHly4SYltJKitEsIDVsNmXcQfYBRv2e+q04fnqtAX5qXaybxy/fhUeAMCnQIdAjaGDb04fMHQefWRhw==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.1': - resolution: {integrity: sha512-3AvYYbB+Dv5EPLqnJIAgYw/9+WzeBiUYS8B+rU0pHq5NMQMvrZmevUROS4V2GAt0jEOn9viBzPLrZE+riTNd5Q==} + '@smithy/fetch-http-handler@5.3.4': + resolution: {integrity: sha512-bwigPylvivpRLCm+YK9I5wRIYjFESSVwl8JQ1vVx/XhCw0PtCi558NwTnT2DaVCl5pYlImGuQTSwMsZ+pIavRw==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.0': - resolution: {integrity: sha512-ugv93gOhZGysTctZh9qdgng8B+xO0cj+zN0qAZ+Sgh7qTQGPOJbMdIuyP89KNfUyfAqFSNh5tMvC+h2uCpmTtA==} + '@smithy/hash-node@4.2.3': + resolution: {integrity: sha512-6+NOdZDbfuU6s1ISp3UOk5Rg953RJ2aBLNLLBEcamLjHAg1Po9Ha7QIB5ZWhdRUVuOUrT8BVFR+O2KIPmw027g==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.0': - resolution: {integrity: sha512-ZmK5X5fUPAbtvRcUPtk28aqIClVhbfcmfoS4M7UQBTnDdrNxhsrxYVv0ZEl5NaPSyExsPWqL4GsPlRvtlwg+2A==} + '@smithy/invalid-dependency@4.2.3': + resolution: {integrity: sha512-Cc9W5DwDuebXEDMpOpl4iERo8I0KFjTnomK2RMdhhR87GwrSmUmwMxS4P5JdRf+LsjOdIqumcerwRgYMr/tZ9Q==} engines: {node: '>=18.0.0'} '@smithy/is-array-buffer@2.2.0': @@ -4728,72 +5013,76 @@ packages: resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.0': - resolution: {integrity: sha512-6ZAnwrXFecrA4kIDOcz6aLBhU5ih2is2NdcZtobBDSdSHtE9a+MThB5uqyK4XXesdOCvOcbCm2IGB95birTSOQ==} + '@smithy/middleware-content-length@4.2.3': + resolution: {integrity: sha512-/atXLsT88GwKtfp5Jr0Ks1CSa4+lB+IgRnkNrrYP0h1wL4swHNb0YONEvTceNKNdZGJsye+W2HH8W7olbcPUeA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.3.1': - resolution: {integrity: sha512-JtM4SjEgImLEJVXdsbvWHYiJ9dtuKE8bqLlvkvGi96LbejDL6qnVpVxEFUximFodoQbg0Gnkyff9EKUhFhVJFw==} + '@smithy/middleware-endpoint@4.3.5': + resolution: {integrity: sha512-SIzKVTvEudFWJbxAaq7f2GvP3jh2FHDpIFI6/VAf4FOWGFZy0vnYMPSRj8PGYI8Hjt29mvmwSRgKuO3bK4ixDw==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.1': - resolution: {integrity: sha512-wXxS4ex8cJJteL0PPQmWYkNi9QKDWZIpsndr0wZI2EL+pSSvA/qqxXU60gBOJoIc2YgtZSWY/PE86qhKCCKP1w==} + '@smithy/middleware-retry@4.4.5': + resolution: {integrity: sha512-DCaXbQqcZ4tONMvvdz+zccDE21sLcbwWoNqzPLFlZaxt1lDtOE2tlVpRSwcTOJrjJSUThdgEYn7HrX5oLGlK9A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.0': - resolution: {integrity: sha512-rpTQ7D65/EAbC6VydXlxjvbifTf4IH+sADKg6JmAvhkflJO2NvDeyU9qsWUNBelJiQFcXKejUHWRSdmpJmEmiw==} + '@smithy/middleware-serde@4.2.3': + resolution: {integrity: sha512-8g4NuUINpYccxiCXM5s1/V+uLtts8NcX4+sPEbvYQDZk4XoJfDpq5y2FQxfmUL89syoldpzNzA0R9nhzdtdKnQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.0': - resolution: {integrity: sha512-G5CJ//eqRd9OARrQu9MK1H8fNm2sMtqFh6j8/rPozhEL+Dokpvi1Og+aCixTuwDAGZUkJPk6hJT5jchbk/WCyg==} + '@smithy/middleware-stack@4.2.3': + resolution: {integrity: sha512-iGuOJkH71faPNgOj/gWuEGS6xvQashpLwWB1HjHq1lNNiVfbiJLpZVbhddPuDbx9l4Cgl0vPLq5ltRfSaHfspA==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.0': - resolution: {integrity: sha512-5QgHNuWdT9j9GwMPPJCKxy2KDxZ3E5l4M3/5TatSZrqYVoEiqQrDfAq8I6KWZw7RZOHtVtCzEPdYz7rHZixwcA==} + '@smithy/node-config-provider@4.3.3': + resolution: {integrity: sha512-NzI1eBpBSViOav8NVy1fqOlSfkLgkUjUTlohUSgAEhHaFWA3XJiLditvavIP7OpvTjDp5u2LhtlBhkBlEisMwA==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.3.0': - resolution: {integrity: sha512-RHZ/uWCmSNZ8cneoWEVsVwMZBKy/8123hEpm57vgGXA3Irf/Ja4v9TVshHK2ML5/IqzAZn0WhINHOP9xl+Qy6Q==} + '@smithy/node-http-handler@4.4.3': + resolution: {integrity: sha512-MAwltrDB0lZB/H6/2M5PIsISSwdI5yIh6DaBB9r0Flo9nx3y0dzl/qTMJPd7tJvPdsx6Ks/cwVzheGNYzXyNbQ==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.0': - resolution: {integrity: sha512-rV6wFre0BU6n/tx2Ztn5LdvEdNZ2FasQbPQmDOPfV9QQyDmsCkOAB0osQjotRCQg+nSKFmINhyda0D3AnjSBJw==} + '@smithy/property-provider@4.2.3': + resolution: {integrity: sha512-+1EZ+Y+njiefCohjlhyOcy1UNYjT+1PwGFHCxA/gYctjg3DQWAU19WigOXAco/Ql8hZokNehpzLd0/+3uCreqQ==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.0': - resolution: {integrity: sha512-6POSYlmDnsLKb7r1D3SVm7RaYW6H1vcNcTWGWrF7s9+2noNYvUsm7E4tz5ZQ9HXPmKn6Hb67pBDRIjrT4w/d7Q==} + '@smithy/protocol-http@5.3.3': + resolution: {integrity: sha512-Mn7f/1aN2/jecywDcRDvWWWJF4uwg/A0XjFMJtj72DsgHTByfjRltSqcT9NyE9RTdBSN6X1RSXrhn/YWQl8xlw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.0': - resolution: {integrity: sha512-Q4oFD0ZmI8yJkiPPeGUITZj++4HHYCW3pYBYfIobUCkYpI6mbkzmG1MAQQ3lJYYWj3iNqfzOenUZu+jqdPQ16A==} + '@smithy/querystring-builder@4.2.3': + resolution: {integrity: sha512-LOVCGCmwMahYUM/P0YnU/AlDQFjcu+gWbFJooC417QRB/lDJlWSn8qmPSDp+s4YVAHOgtgbNG4sR+SxF/VOcJQ==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.0': - resolution: {integrity: sha512-BjATSNNyvVbQxOOlKse0b0pSezTWGMvA87SvoFoFlkRsKXVsN3bEtjCxvsNXJXfnAzlWFPaT9DmhWy1vn0sNEA==} + '@smithy/querystring-parser@4.2.3': + resolution: {integrity: sha512-cYlSNHcTAX/wc1rpblli3aUlLMGgKZ/Oqn8hhjFASXMCXjIqeuQBei0cnq2JR8t4RtU9FpG6uyl6PxyArTiwKA==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.0': - resolution: {integrity: sha512-Ylv1ttUeKatpR0wEOMnHf1hXMktPUMObDClSWl2TpCVT4DwtJhCeighLzSLbgH3jr5pBNM0LDXT5yYxUvZ9WpA==} + '@smithy/service-error-classification@4.2.3': + resolution: {integrity: sha512-NkxsAxFWwsPsQiwFG2MzJ/T7uIR6AQNh1SzcxSUnmmIqIQMlLRQDKhc17M7IYjiuBXhrQRjQTo3CxX+DobS93g==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.3.0': - resolution: {integrity: sha512-VCUPPtNs+rKWlqqntX0CbVvWyjhmX30JCtzO+s5dlzzxrvSfRh5SY0yxnkirvc1c80vdKQttahL71a9EsdolSQ==} + '@smithy/shared-ini-file-loader@4.3.3': + resolution: {integrity: sha512-9f9Ixej0hFhroOK2TxZfUUDR13WVa8tQzhSzPDgXe5jGL3KmaM9s8XN7RQwqtEypI82q9KHnKS71CJ+q/1xLtQ==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.0': - resolution: {integrity: sha512-MKNyhXEs99xAZaFhm88h+3/V+tCRDQ+PrDzRqL0xdDpq4gjxcMmf5rBA3YXgqZqMZ/XwemZEurCBQMfxZOWq/g==} + '@smithy/signature-v4@5.3.3': + resolution: {integrity: sha512-CmSlUy+eEYbIEYN5N3vvQTRfqt0lJlQkaQUIf+oizu7BbDut0pozfDjBGecfcfWf7c62Yis4JIEgqQ/TCfodaA==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.7.1': - resolution: {integrity: sha512-WXVbiyNf/WOS/RHUoFMkJ6leEVpln5ojCjNBnzoZeMsnCg3A0BRhLK3WYc4V7PmYcYPZh9IYzzAg9XcNSzYxYQ==} + '@smithy/smithy-client@4.9.1': + resolution: {integrity: sha512-Ngb95ryR5A9xqvQFT5mAmYkCwbXvoLavLFwmi7zVg/IowFPCfiqRfkOKnbc/ZRL8ZKJ4f+Tp6kSu6wjDQb8L/g==} engines: {node: '>=18.0.0'} - '@smithy/types@4.6.0': - resolution: {integrity: sha512-4lI9C8NzRPOv66FaY1LL1O/0v0aLVrq/mXP/keUa9mJOApEeae43LsLd2kZRUJw91gxOQfLIrV3OvqPgWz1YsA==} + '@smithy/types@3.5.0': + resolution: {integrity: sha512-QN0twHNfe8mNJdH9unwsCK13GURU7oEAZqkBI+rsvpv1jrmserO+WnLE7jidR9W/1dxwZ0u/CB01mV2Gms/K2Q==} + engines: {node: '>=16.0.0'} + + '@smithy/types@4.8.0': + resolution: {integrity: sha512-QpELEHLO8SsQVtqP+MkEgCYTFW0pleGozfs3cZ183ZBj9z3VC1CX1/wtFMK64p+5bhtZo41SeLK1rBRtd25nHQ==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.0': - resolution: {integrity: sha512-AlBmD6Idav2ugmoAL6UtR6ItS7jU5h5RNqLMZC7QrLCoITA9NzIN3nx9GWi8g4z1pfWh2r9r96SX/jHiNwPJ9A==} + '@smithy/url-parser@4.2.3': + resolution: {integrity: sha512-I066AigYvY3d9VlU3zG9XzZg1yT10aNqvCaBTw9EPgu5GrsEl1aUkcMvhkIXascYH1A8W0LQo3B1Kr1cJNcQEw==} engines: {node: '>=18.0.0'} '@smithy/util-base64@4.3.0': @@ -4820,32 +5109,32 @@ packages: resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.0': - resolution: {integrity: sha512-H4MAj8j8Yp19Mr7vVtGgi7noJjvjJbsKQJkvNnLlrIFduRFT5jq5Eri1k838YW7rN2g5FTnXpz5ktKVr1KVgPQ==} + '@smithy/util-defaults-mode-browser@4.3.4': + resolution: {integrity: sha512-qI5PJSW52rnutos8Bln8nwQZRpyoSRN6k2ajyoUHNMUzmWqHnOJCnDELJuV6m5PML0VkHI+XcXzdB+6awiqYUw==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.1': - resolution: {integrity: sha512-PuDcgx7/qKEMzV1QFHJ7E4/MMeEjaA7+zS5UNcHCLPvvn59AeZQ0DSDGMpqC2xecfa/1cNGm4l8Ec/VxCuY7Ug==} + '@smithy/util-defaults-mode-node@4.2.6': + resolution: {integrity: sha512-c6M/ceBTm31YdcFpgfgQAJaw3KbaLuRKnAz91iMWFLSrgxRpYm03c3bu5cpYojNMfkV9arCUelelKA7XQT36SQ==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.2.0': - resolution: {integrity: sha512-TXeCn22D56vvWr/5xPqALc9oO+LN+QpFjrSM7peG/ckqEPoI3zaKZFp+bFwfmiHhn5MGWPaLCqDOJPPIixk9Wg==} + '@smithy/util-endpoints@3.2.3': + resolution: {integrity: sha512-aCfxUOVv0CzBIkU10TubdgKSx5uRvzH064kaiPEWfNIvKOtNpu642P4FP1hgOFkjQIkDObrfIDnKMKkeyrejvQ==} engines: {node: '>=18.0.0'} '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.0': - resolution: {integrity: sha512-u9OOfDa43MjagtJZ8AapJcmimP+K2Z7szXn8xbty4aza+7P1wjFmy2ewjSbhEiYQoW1unTlOAIV165weYAaowA==} + '@smithy/util-middleware@4.2.3': + resolution: {integrity: sha512-v5ObKlSe8PWUHCqEiX2fy1gNv6goiw6E5I/PN2aXg3Fb/hse0xeaAnSpXDiWl7x6LamVKq7senB+m5LOYHUAHw==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.0': - resolution: {integrity: sha512-BWSiuGbwRnEE2SFfaAZEX0TqaxtvtSYPM/J73PFVm+A29Fg1HTPiYFb8TmX1DXp4hgcdyJcNQmprfd5foeORsg==} + '@smithy/util-retry@4.2.3': + resolution: {integrity: sha512-lLPWnakjC0q9z+OtiXk+9RPQiYPNAovt2IXD3CP4LkOnd9NpUsxOjMx1SnoUVB7Orb7fZp67cQMtTBKMFDvOGg==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.0': - resolution: {integrity: sha512-0TD5M5HCGu5diEvZ/O/WquSjhJPasqv7trjoqHyWjNh/FBeBl7a0ztl9uFMOsauYtRfd8jvpzIAQhDHbx+nvZw==} + '@smithy/util-stream@4.5.4': + resolution: {integrity: sha512-+qDxSkiErejw1BAIXUFBSfM5xh3arbz1MmxlbMCKanDDZtVEQ7PSKW9FQS0Vud1eI/kYn0oCTVKyNzRlq+9MUw==} engines: {node: '>=18.0.0'} '@smithy/util-uri-escape@4.2.0': @@ -5450,6 +5739,9 @@ packages: '@types/express@5.0.1': resolution: {integrity: sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==} + '@types/express@5.0.4': + resolution: {integrity: sha512-g64dbryHk7loCIrsa0R3shBnEu5p6LPJ09bu9NG58+jz+cRUjFrc3Bz0kNQ7j9bXeCsrRDvNET1G54P/GJkAyA==} + '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} @@ -5984,6 +6276,22 @@ packages: peerDependencies: '@babel/runtime': 7.x + '@wry/caches@1.0.1': + resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} + engines: {node: '>=8'} + + '@wry/context@0.7.4': + resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} + engines: {node: '>=8'} + + '@wry/equality@0.5.7': + resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} + engines: {node: '>=8'} + + '@wry/trie@0.5.0': + resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} + engines: {node: '>=8'} + '@xrplf/isomorphic@1.0.1': resolution: {integrity: sha512-0bIpgx8PDjYdrLFeC3csF305QQ1L7sxaWnL5y71mCvhenZzJgku9QsA+9QCXBC1eNYtxWO/xR91zrXJy2T/ixg==} engines: {node: '>=16.0.0'} @@ -6042,6 +6350,9 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + abort-error@1.0.1: + resolution: {integrity: sha512-fxqCblJiIPdSXIUrxI0PL+eJG49QdP9SQ70qtB65MVAoMr2rASlOyAbJFOylfB467F/f+5BCLJJq58RYi7mGfg==} + abortcontroller-polyfill@1.7.5: resolution: {integrity: sha512-JMJ5soJWP18htbbxJjG7bG6yuI6pRhgJ0scHHTfkUjf6wjP912xZWvM+A4sJK3gqd9E8fcPbDnOefbA9Th/FIQ==} @@ -6120,9 +6431,21 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@6.2.1: + resolution: {integrity: sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==} + engines: {node: '>=14.16'} + + ansi-escapes@7.1.1: + resolution: {integrity: sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==} + engines: {node: '>=18'} + ansi-fragments@0.2.1: resolution: {integrity: sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==} + ansi-regex@2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} + engines: {node: '>=0.10.0'} + ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -6135,6 +6458,10 @@ packages: resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} engines: {node: '>=12'} + ansi-styles@2.2.1: + resolution: {integrity: sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==} + engines: {node: '>=0.10.0'} + ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} engines: {node: '>=4'} @@ -6151,6 +6478,12 @@ packages: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} + ansi-term@0.0.2: + resolution: {integrity: sha512-jLnGE+n8uAjksTJxiWZf/kcUmXq+cRWSl550B9NmQ8YiqaTM+lILcSe5dHdp8QkJPhaOghDjnMKwyYSMjosgAA==} + + ansicolors@0.3.2: + resolution: {integrity: sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==} + ansis@3.17.0: resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} engines: {node: '>=14'} @@ -6469,6 +6802,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bl@5.1.0: + resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + blake2b-wasm@2.4.0: resolution: {integrity: sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w==} @@ -6478,6 +6814,17 @@ packages: blakejs@1.2.1: resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} + blessed-contrib@4.11.0: + resolution: {integrity: sha512-P00Xji3xPp53+FdU9f74WpvnOAn/SS0CKLy4vLAf5Ps7FGDOTY711ruJPZb3/7dpFuP+4i7f4a/ZTZdLlKG9WA==} + + blessed@0.1.81: + resolution: {integrity: sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==} + engines: {node: '>= 0.8.0'} + hasBin: true + + blockstore-core@5.0.4: + resolution: {integrity: sha512-v7wtBEpW2J/kKljN7Z2u4Tnwr7qwnOvW1aPVfynIxEdejlVC7gg4z9k6iJt7n5XMGkdNnH4HOmVcjYcaMnu7yg==} + bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} @@ -6527,6 +6874,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + bresenham@0.0.3: + resolution: {integrity: sha512-wbMxoJJM1p3+6G7xEFXYNCJ30h2qkwmVxebkbwIl4OcnWtno5R3UT9VuYLfStlVNAQCmRjkGwjPFdfaPd4iNXw==} + brorand@1.1.0: resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} @@ -6690,6 +7040,10 @@ packages: caniuse-lite@1.0.30001706: resolution: {integrity: sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==} + cardinal@2.1.1: + resolution: {integrity: sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==} + hasBin: true + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -6717,6 +7071,10 @@ packages: chainsaw@0.1.0: resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@1.1.3: + resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} + engines: {node: '>=0.10.0'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -6743,6 +7101,9 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + charm@0.1.2: + resolution: {integrity: sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==} + check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} @@ -6823,11 +7184,19 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + cli-progress@3.12.0: + resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} + engines: {node: '>=4'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} @@ -7418,6 +7787,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dns-packet@5.6.1: + resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} + engines: {node: '>=6'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -7452,6 +7825,12 @@ packages: resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} engines: {node: '>=12'} + drawille-blessed-contrib@1.0.0: + resolution: {integrity: sha512-WnHMgf5en/hVOsFhxLI8ZX0qTJmerOsVjIMQmn4cR1eI8nLGu+L7w5ENbul+lZ6w827A3JakCuernES5xbHLzQ==} + + drawille-canvas-blessed-contrib@0.1.3: + resolution: {integrity: sha512-bdDvVJOxlrEoPLifGDPaxIzFh3cD7QH05ePoQ4fwnqfi08ZSxzEhOUpI5Z0/SQMlWgcCQOEtuw0zrwezacXglw==} + dset@3.1.4: resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} engines: {node: '>=4'} @@ -7494,6 +7873,9 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -7554,6 +7936,10 @@ packages: engines: {node: '>=4'} hasBin: true + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + era-contracts@https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9: resolution: {tarball: https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9} version: 0.1.0 @@ -7875,6 +8261,9 @@ packages: event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + event-stream@0.9.8: + resolution: {integrity: sha512-o5h0Mp1bkoR6B0i7pTCAzRy+VzdsRWH997KQD4Psb0EOPoKEIiaRx/EsOdUl7p1Ktjw7aIWvweI/OY1R9XrlUg==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -8197,6 +8586,10 @@ packages: resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} engines: {node: '>=14.14'} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fs-extra@4.0.3: resolution: {integrity: sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==} @@ -8256,6 +8649,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -8311,6 +8708,9 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + gl-matrix@2.8.1: + resolution: {integrity: sha512-0YCjVpE3pS5XWlN3J4X7AiAx65+nqAI54LndtVFnQZB6G/FVLkZH8y8V6R3cIoOQR4pUdfwQGd1iwyoXHJ4Qfw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -8416,6 +8816,12 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql-tag@2.12.6: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql@16.11.0: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -8426,6 +8832,9 @@ packages: h3@1.13.0: resolution: {integrity: sha512-vFEAu/yf8UMUcB4s43OaDaigcqpQd14yanmOsn+NcRX3/guSKncyE2rOYhq8RIchgJrPSs/QiIddnTTR1ddiAg==} + hamt-sharding@3.0.6: + resolution: {integrity: sha512-nZeamxfymIWLpVcAN0CRrb7uVq3hCOGj9IcL6NMA6VVCVWqj+h9Jo/SmaWuS92AEDf1thmHsM5D5c70hM3j2Tg==} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -8471,6 +8880,10 @@ packages: typescript: optional: true + has-ansi@2.0.0: + resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} + engines: {node: '>=0.10.0'} + has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -8511,6 +8924,9 @@ packages: hash.js@1.1.7: resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + hashlru@2.3.0: + resolution: {integrity: sha512-0cMsjjIC8I+D3M44pOQdsy0OHXGLVz6Z0beRuufhKa0KfaD2wGwAev6jILzXsd3/vpnNQJmWyZtIILqM1N+n5A==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -8525,6 +8941,9 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + here@0.0.2: + resolution: {integrity: sha512-U7VYImCTcPoY27TSmzoiFsmWLEqQFaYNdpsPb9K0dXJhE6kufUqycaz51oR09CW85dDU9iWyy7At8M+p7hb3NQ==} + hermes-estree@0.19.1: resolution: {integrity: sha512-daLGV3Q2MKk8w4evNMKwS8zBE/rcpA800nu1Q5kM08IKijoSnPe9Uo1iIxzPKRkn95IxxsgBMPeYHt3VG4ej2g==} @@ -8713,6 +9132,15 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + interface-blockstore@5.3.2: + resolution: {integrity: sha512-oA9Pjkxun/JHAsZrYEyKX+EoPjLciTzidE7wipLc/3YoHDjzsnXRJzAzFJXNUvogtY4g7hIwxArx8+WKJs2RIg==} + + interface-datastore@8.3.2: + resolution: {integrity: sha512-R3NLts7pRbJKc3qFdQf+u40hK8XWc0w4Qkx3OFEstC80VoaDUABY/dXA2EJPhtNC+bsrf41Ehvqb6+pnIclyRA==} + + interface-store@6.0.3: + resolution: {integrity: sha512-+WvfEZnFUhRwFxgz+QCQi7UC6o9AM0EHM9bpIe2Nhqb100NHCsTvNAn4eJgvgV2/tmLo1MP9nGxQKEcZTAueLA==} + internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} @@ -8735,6 +9163,12 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipfs-unixfs-importer@15.4.0: + resolution: {integrity: sha512-laypY07Q0uGLMSxx5YsfVAEPVzdbV2p9cEC1/HNxqoWoz83LA2nX45usr8dcehalKLHm38u0FwUiRHq5wCHngQ==} + + ipfs-unixfs@11.2.5: + resolution: {integrity: sha512-uasYJ0GLPbViaTFsOLnL9YPjX5VmhnqtWRriogAHOe4ApmIi9VAOFBzgDHsUW2ub4pEa/EysbtWk126g2vkU/g==} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -8819,6 +9253,10 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + is-function@1.0.2: resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} @@ -8962,6 +9400,9 @@ packages: resolution: {integrity: sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==} engines: {node: '>=18'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -9029,6 +9470,36 @@ packages: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} + it-all@3.0.9: + resolution: {integrity: sha512-fz1oJJ36ciGnu2LntAlE6SA97bFZpW7Rnt0uEc1yazzR2nKokZLr8lIRtgnpex4NsmaBcvHF+Z9krljWFy/mmg==} + + it-batch@3.0.9: + resolution: {integrity: sha512-z6p89Q8gm2urBtF3JcpnbJogacijWk3m1uc3xZYI3x0eJUoYLUbgF8IxJ2fnuVObV7yRv3SixfwGCufaZY1NCg==} + + it-filter@3.1.4: + resolution: {integrity: sha512-80kWEKgiFEa4fEYD3mwf2uygo1dTQ5Y5midKtL89iXyjinruA/sNXl6iFkTcdNedydjvIsFhWLiqRPQP4fAwWQ==} + + it-first@3.0.9: + resolution: {integrity: sha512-ZWYun273Gbl7CwiF6kK5xBtIKR56H1NoRaiJek2QzDirgen24u8XZ0Nk+jdnJSuCTPxC2ul1TuXKxu/7eK6NuA==} + + it-merge@3.0.12: + resolution: {integrity: sha512-nnnFSUxKlkZVZD7c0jYw6rDxCcAQYcMsFj27thf7KkDhpj0EA0g9KHPxbFzHuDoc6US2EPS/MtplkNj8sbCx4Q==} + + it-parallel-batch@3.0.9: + resolution: {integrity: sha512-TszXWqqLG8IG5DUEnC4cgH9aZI6CsGS7sdkXTiiacMIj913bFy7+ohU3IqsFURCcZkpnXtNLNzrYnXISsKBhbQ==} + + it-peekable@3.0.8: + resolution: {integrity: sha512-7IDBQKSp/dtBxXV3Fj0v3qM1jftJ9y9XrWLRIuU1X6RdKqWiN60syNwP0fiDxZD97b8SYM58dD3uklIk1TTQAw==} + + it-pushable@3.2.3: + resolution: {integrity: sha512-gzYnXYK8Y5t5b/BnJUr7glfQLO4U5vyb05gPx/TyTw+4Bv1zM9gFk4YsOrnulWefMewlphCjKkakFvj1y99Tcg==} + + it-queueless-pushable@2.0.2: + resolution: {integrity: sha512-2BqIt7XvDdgEgudLAdJkdseAwbVSBc0yAd8yPVHrll4eBuJPWIj9+8C3OIxzEKwhswLtd3bi+yLrzgw9gCyxMA==} + + it-stream-types@2.0.2: + resolution: {integrity: sha512-Rz/DEZ6Byn/r9+/SBCuJhpPATDF9D+dz5pbgSUyBsCDtza6wtNATrz/jz1gDyNanC3XdLboriHnOC925bZRBww==} + iterator.prototype@1.1.3: resolution: {integrity: sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==} engines: {node: '>= 0.4'} @@ -9257,6 +9728,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -9478,6 +9952,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -9529,6 +10007,9 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + main-event@1.0.1: + resolution: {integrity: sha512-NWtdGrAca/69fm6DIVd8T9rtfDII4Q8NQbIbsKQq2VzS9eqOGYs8uaNQjcuaCq/d9H/o625aOTJX2Qoxzqw0Pw==} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -9547,9 +10028,23 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + map-canvas@0.1.5: + resolution: {integrity: sha512-f7M3sOuL9+up0NCOZbb1rQpWDLZwR/ftCiNbyscjl9LUUEwrRaoumH4sz6swgs58lF21DQ0hsYOCw5C6Zz7hbg==} + markdown-table@2.0.0: resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + marked-terminal@5.2.0: + resolution: {integrity: sha512-Piv6yNwAQXGFjZSaiNljyNFw7jKDdGrw70FSbtxEyldLsyeuV5ZHm/1wW++kWbrOF1VPnUgYOhB2oLL0ZpnekA==} + engines: {node: '>=14.13.1 || >=16.0.0'} + peerDependencies: + marked: ^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + marky@1.2.5: resolution: {integrity: sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==} @@ -9583,6 +10078,9 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + memory-streams@0.1.3: + resolution: {integrity: sha512-qVQ/CjkMyMInPaaRMrwWNDvf6boRZXaT/DbQeMYcCWuXPEBf1v8qChOc9OlEVQp2uOvRXa1Qu30fLmKhY6NipA==} + memorystream@0.3.1: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} @@ -9722,6 +10220,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -9886,6 +10388,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + ms@3.0.0-canary.202508261828: + resolution: {integrity: sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==} + engines: {node: '>=18'} + multibase@0.6.1: resolution: {integrity: sha512-pFfAwyTjbbQgNc3G7D48JkJxWtoJoBMaR4xQUOuB8RnCgRqaYmWNFeJTTvrJ2w51bjLq2zTby6Rqj9TQ9elSUw==} deprecated: This module has been superseded by the multiformats module @@ -9902,6 +10408,9 @@ packages: resolution: {integrity: sha512-NDd7FeS3QamVtbgfvu5h7fd1IlbaC4EQ0/pgU4zqE2vdHCmBGsUa0TiM8/TdSeG6BMPC92OOCf8F1ocE/Wkrrg==} deprecated: This module has been superseded by the multiformats module + multiformats@13.4.1: + resolution: {integrity: sha512-VqO6OSvLrFVAYYjgsr8tyv62/rCQhPgsZUXLTqoFLSgdkgiUYKYeArbt1uWLlEpkjxQe+P0+sHlbPEte1Bi06Q==} + multiformats@9.9.0: resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} @@ -9911,6 +10420,10 @@ packages: murmur-128@0.2.1: resolution: {integrity: sha512-WseEgiRkI6aMFBbj8Cg9yBj/y+OdipwVC7zUo3W2W1JAJITwouUOtpqsmGSg67EQmwwSyod7hsVsWY5LsrfQVg==} + murmurhash3js-revisited@3.0.0: + resolution: {integrity: sha512-/sF3ee6zvScXMb1XFJ8gDsSnY+X8PbOyjIuBhtgis10W2Jx4ZjIhikUCIF9c4gpJxVnQIsPAFrSwTCuAjicP6g==} + engines: {node: '>=8.0.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -10052,6 +10565,10 @@ packages: resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==} engines: {node: '>=12.19'} + nopt@2.1.2: + resolution: {integrity: sha512-x8vXm7BZ2jE1Txrxh/hO74HTuYZQEbo8edoRcANgdZ4+PCV+pbjd/xdummkmjjC7LU5EjPzlu8zEq/oxWylnKA==} + hasBin: true + nopt@3.0.6: resolution: {integrity: sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==} hasBin: true @@ -10278,6 +10795,10 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + open@6.4.0: resolution: {integrity: sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==} engines: {node: '>=8'} @@ -10292,6 +10813,15 @@ packages: openapi-typescript-helpers@0.0.15: resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + optimism@0.18.1: + resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} + + optimist@0.2.8: + resolution: {integrity: sha512-Wy7E3cQDpqsTIFyW7m22hSevyTLxw850ahYv7FWsw4G6MIKVTZ8NSA95KBrQ95a4SMsMr1UGUUnwEFKhVaSzIg==} + + optimist@0.3.7: + resolution: {integrity: sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==} + optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -10345,6 +10875,14 @@ packages: typescript: optional: true + ox@0.8.9: + resolution: {integrity: sha512-8pDZzrfZ3EE/ubomc57Nf+ZEQzvtdDjJaW8/ygI8O026V8oVWV4+WwBRCaSP0IYc3Pi0fQCgpg9WDQjl9qN3yQ==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + ox@0.9.3: resolution: {integrity: sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg==} peerDependencies: @@ -10361,6 +10899,10 @@ packages: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} + p-defer@4.0.1: + resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==} + engines: {node: '>=12'} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -10393,6 +10935,14 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-queue@9.0.0: + resolution: {integrity: sha512-KO1RyxstL9g1mK76530TExamZC/S2Glm080Nx8PE5sTd7nlduDQsAfEl4uXX+qZjLiwvDauvzXavufy3+rJ9zQ==} + engines: {node: '>=20'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -10552,6 +11102,11 @@ packages: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + picture-tuber@1.0.2: + resolution: {integrity: sha512-49/xq+wzbwDeI32aPvwQJldM8pr7dKDRuR76IjztrkmiCkAQDaWFJzkmfVqCHmt/iFoPFhHmI9L0oKhthrTOQw==} + engines: {node: '>=0.4.0'} + hasBin: true + pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} @@ -10617,6 +11172,9 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + png-js@0.1.1: + resolution: {integrity: sha512-NTtk2SyfjBm+xYl2/VZJBhFnTQ4kU5qWC7VC4/iGbrgiU4FuB4xC+74erxADYJIqZICOR1HCvRA7EBHkpjTg9g==} + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -10757,6 +11315,9 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + progress-events@1.0.1: + resolution: {integrity: sha512-MOzLIwhpt64KIVN64h1MwdKWiyKFNc/S6BoYKPIVUHFg0/eIEyBulhWCgn678v/4c0ri3FdGuzXymNCv02MUIw==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -10797,6 +11358,9 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + protons-runtime@5.6.0: + resolution: {integrity: sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -10827,6 +11391,10 @@ packages: qrcode-generator@1.5.2: resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} + qrcode-terminal@0.12.0: + resolution: {integrity: sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==} + hasBin: true + qrcode.react@3.2.0: resolution: {integrity: sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g==} peerDependencies: @@ -10883,6 +11451,13 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + rabin-wasm@0.1.5: + resolution: {integrity: sha512-uWgQTo7pim1Rnj5TuWcCewRDTf0PEFTSlaUjWP4eY9EbLV9em08v89oCz/WO+wRxpYuO36XEHp4wgYQnAgOHzA==} + hasBin: true + + race-signal@1.1.3: + resolution: {integrity: sha512-Mt2NznMgepLfORijhQMncE26IhkmjEphig+/1fKC0OtaKwys/gpvpmswSjoN01SS+VO951mj0L4VIDXdXsjnfA==} + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -11091,6 +11666,9 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readable-stream@1.0.34: + resolution: {integrity: sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -11137,6 +11715,9 @@ packages: resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} engines: {node: '>=6.0.0'} + redeyed@2.1.1: + resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==} + reduce-flatten@2.0.0: resolution: {integrity: sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==} engines: {node: '>=6'} @@ -11258,6 +11839,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -11364,6 +11949,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + sc-istanbul@0.4.6: resolution: {integrity: sha512-qJFF/8tW/zJsbyfh/iT/ZM5QNHE3CXxtLJbZsL+CzdJLBsPD7SedJZoUA4d8iAcN2IoMp/Dx80shOOd2x96X/g==} hasBin: true @@ -11561,6 +12149,10 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + slow-redact@0.3.2: resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==} @@ -11654,6 +12246,14 @@ packages: engines: {node: '>= 8'} deprecated: The work that was done in this beta branch won't be included in future versions + sparkline@0.1.2: + resolution: {integrity: sha512-t//aVOiWt9fi/e22ea1vXVWBDX+gp18y+Ch9sKqmHl828bRfvP2VtfTJVEcgWFBQHd0yDPNQRiHdqzCvbcYSDA==} + engines: {node: '>= 0.8.0'} + hasBin: true + + sparse-array@1.3.2: + resolution: {integrity: sha512-ZT711fePGn3+kQyLuv1fpd3rNSkNF8vd5Kv2D+qnOANeyKs3fx6bUMGWRPvgTTcYV64QMqZKZwcuaQSP3AZ0tg==} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -11738,6 +12338,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.includes@2.0.0: resolution: {integrity: sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==} @@ -11759,12 +12363,19 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} + engines: {node: '>=0.10.0'} + strip-ansi@5.2.0: resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} engines: {node: '>=6'} @@ -11844,6 +12455,14 @@ packages: resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} engines: {node: '>=14.0.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@2.0.0: + resolution: {integrity: sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==} + engines: {node: '>=0.8.0'} + supports-color@3.2.3: resolution: {integrity: sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A==} engines: {node: '>=0.8.0'} @@ -11860,6 +12479,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-hyperlinks@2.3.0: + resolution: {integrity: sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==} + engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -11933,6 +12556,9 @@ packages: resolution: {integrity: sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==} engines: {node: '>=6.0.0'} + term-canvas@0.0.5: + resolution: {integrity: sha512-eZ3rIWi5yLnKiUcsW8P79fKyooaLmyLWAGqBhFspqMxRNUiB4GmHHk5AzQ4LxvFbJILaXqQZLwbbATLOhCFwkw==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -12373,6 +12999,9 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uint8-varint@2.0.4: + resolution: {integrity: sha512-FwpTa7ZGA/f/EssWAb5/YV6pHgVF1fViKdW8cWaEarjB8t7NyofSWBdOTyFPaGuUG4gx3v1O3PQ8etsiOs3lcw==} + uint8array-tools@0.0.8: resolution: {integrity: sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==} engines: {node: '>=14.0.0'} @@ -12381,12 +13010,18 @@ packages: resolution: {integrity: sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==} engines: {node: '>=14.0.0'} + uint8arraylist@2.4.8: + resolution: {integrity: sha512-vc1PlGOzglLF0eae1M8mLRTBivsvrGsdmJ5RbK3e+QRvRLOZfZhQROTwH/OfyF3+ZVUg9/8hE8bmKP2CvP9quQ==} + uint8arrays@3.1.0: resolution: {integrity: sha512-ei5rfKtoRO8OyOIor2Rz5fhzjThwIHJZ3uyDPnDHTXbP0aMQ1RN/6AI5B5d9dBxJOU+BvOAk7ZQ1xphsX8Lrog==} uint8arrays@3.1.1: resolution: {integrity: sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==} + uint8arrays@5.1.0: + resolution: {integrity: sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==} + unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -12721,6 +13356,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + weald@1.0.6: + resolution: {integrity: sha512-sX1PzkcMJZUJ848JbFzB6aKHHglTxqACEnq2KgI75b7vWYvfXFBNbOuDKqFKwCT44CrP6c5r+L4+5GmPnb5/SQ==} + web-encoding@1.1.5: resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} @@ -12888,6 +13526,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@0.0.3: + resolution: {integrity: sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==} + engines: {node: '>=0.4.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -12910,6 +13552,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -13003,6 +13649,10 @@ packages: utf-8-validate: optional: true + x256@0.0.2: + resolution: {integrity: sha512-ZsIH+sheoF8YG9YG+QKEEIdtqpHRA9FYuD7MqhfyB1kayXU43RUNBFSxBEnF8ywSUxdg+8no4+bPr5qLbyxKgA==} + engines: {node: '>=0.4.0'} + xhr-request-promise@0.1.3: resolution: {integrity: sha512-YUBytBsuwgitWtdRzXDDkWAXzhdGB8bYm0sSzMPZT7Z2MBjMSTHFsyCT1yCRATY+XC69DUrQraRAEgcoCRaIPg==} @@ -13012,6 +13662,14 @@ packages: xhr@2.6.0: resolution: {integrity: sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==} + xml2js@0.4.23: + resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlhttprequest-ssl@2.1.1: resolution: {integrity: sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==} engines: {node: '>=0.4.0'} @@ -13214,6 +13872,21 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@apollo/client@4.0.7(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rxjs@7.8.2)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.11.0) + '@wry/caches': 1.0.1 + '@wry/equality': 0.5.7 + '@wry/trie': 0.5.0 + graphql: 16.11.0 + graphql-tag: 2.12.6(graphql@16.11.0) + optimism: 0.18.1 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@arbitrum/nitro-contracts@3.0.0': dependencies: '@offchainlabs/upgrade-executor': 1.1.0-beta.0 @@ -13222,6 +13895,8 @@ snapshots: patch-package: 6.5.1 solady: 0.0.182 + '@assemblyscript/loader@0.9.4': {} + '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -13235,7 +13910,7 @@ snapshots: '@aws-crypto/sha256-js@1.2.2': dependencies: '@aws-crypto/util': 1.2.2 - '@aws-sdk/types': 3.901.0 + '@aws-sdk/types': 3.664.0 tslib: 1.14.1 '@aws-crypto/sha256-js@5.2.0': @@ -13250,7 +13925,7 @@ snapshots: '@aws-crypto/util@1.2.2': dependencies: - '@aws-sdk/types': 3.901.0 + '@aws-sdk/types': 3.664.0 '@aws-sdk/util-utf8-browser': 3.259.0 tslib: 1.14.1 @@ -13275,30 +13950,30 @@ snapshots: '@aws-sdk/util-endpoints': 3.901.0 '@aws-sdk/util-user-agent-browser': 3.907.0 '@aws-sdk/util-user-agent-node': 3.908.0 - '@smithy/config-resolver': 4.3.0 - '@smithy/core': 3.15.0 - '@smithy/fetch-http-handler': 5.3.1 - '@smithy/hash-node': 4.2.0 - '@smithy/invalid-dependency': 4.2.0 - '@smithy/middleware-content-length': 4.2.0 - '@smithy/middleware-endpoint': 4.3.1 - '@smithy/middleware-retry': 4.4.1 - '@smithy/middleware-serde': 4.2.0 - '@smithy/middleware-stack': 4.2.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/node-http-handler': 4.3.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 - '@smithy/url-parser': 4.2.0 + '@smithy/config-resolver': 4.4.0 + '@smithy/core': 3.17.1 + '@smithy/fetch-http-handler': 5.3.4 + '@smithy/hash-node': 4.2.3 + '@smithy/invalid-dependency': 4.2.3 + '@smithy/middleware-content-length': 4.2.3 + '@smithy/middleware-endpoint': 4.3.5 + '@smithy/middleware-retry': 4.4.5 + '@smithy/middleware-serde': 4.2.3 + '@smithy/middleware-stack': 4.2.3 + '@smithy/node-config-provider': 4.3.3 + '@smithy/node-http-handler': 4.4.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 + '@smithy/url-parser': 4.2.3 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.0 - '@smithy/util-defaults-mode-node': 4.2.1 - '@smithy/util-endpoints': 3.2.0 - '@smithy/util-middleware': 4.2.0 - '@smithy/util-retry': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.3.4 + '@smithy/util-defaults-mode-node': 4.2.6 + '@smithy/util-endpoints': 3.2.3 + '@smithy/util-middleware': 4.2.3 + '@smithy/util-retry': 4.2.3 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -13318,30 +13993,30 @@ snapshots: '@aws-sdk/util-endpoints': 3.901.0 '@aws-sdk/util-user-agent-browser': 3.907.0 '@aws-sdk/util-user-agent-node': 3.908.0 - '@smithy/config-resolver': 4.3.0 - '@smithy/core': 3.15.0 - '@smithy/fetch-http-handler': 5.3.1 - '@smithy/hash-node': 4.2.0 - '@smithy/invalid-dependency': 4.2.0 - '@smithy/middleware-content-length': 4.2.0 - '@smithy/middleware-endpoint': 4.3.1 - '@smithy/middleware-retry': 4.4.1 - '@smithy/middleware-serde': 4.2.0 - '@smithy/middleware-stack': 4.2.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/node-http-handler': 4.3.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 - '@smithy/url-parser': 4.2.0 + '@smithy/config-resolver': 4.4.0 + '@smithy/core': 3.17.1 + '@smithy/fetch-http-handler': 5.3.4 + '@smithy/hash-node': 4.2.3 + '@smithy/invalid-dependency': 4.2.3 + '@smithy/middleware-content-length': 4.2.3 + '@smithy/middleware-endpoint': 4.3.5 + '@smithy/middleware-retry': 4.4.5 + '@smithy/middleware-serde': 4.2.3 + '@smithy/middleware-stack': 4.2.3 + '@smithy/node-config-provider': 4.3.3 + '@smithy/node-http-handler': 4.4.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 + '@smithy/url-parser': 4.2.3 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.0 - '@smithy/util-defaults-mode-node': 4.2.1 - '@smithy/util-endpoints': 3.2.0 - '@smithy/util-middleware': 4.2.0 - '@smithy/util-retry': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.3.4 + '@smithy/util-defaults-mode-node': 4.2.6 + '@smithy/util-endpoints': 3.2.3 + '@smithy/util-middleware': 4.2.3 + '@smithy/util-retry': 4.2.3 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -13351,15 +14026,15 @@ snapshots: dependencies: '@aws-sdk/types': 3.901.0 '@aws-sdk/xml-builder': 3.901.0 - '@smithy/core': 3.15.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/property-provider': 4.2.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/signature-v4': 5.3.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 + '@smithy/core': 3.17.1 + '@smithy/node-config-provider': 4.3.3 + '@smithy/property-provider': 4.2.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/signature-v4': 5.3.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.0 + '@smithy/util-middleware': 4.2.3 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 @@ -13367,21 +14042,21 @@ snapshots: dependencies: '@aws-sdk/core': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/property-provider': 4.2.0 - '@smithy/types': 4.6.0 + '@smithy/property-provider': 4.2.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/credential-provider-http@3.908.0': dependencies: '@aws-sdk/core': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/fetch-http-handler': 5.3.1 - '@smithy/node-http-handler': 4.3.0 - '@smithy/property-provider': 4.2.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 - '@smithy/util-stream': 4.5.0 + '@smithy/fetch-http-handler': 5.3.4 + '@smithy/node-http-handler': 4.4.3 + '@smithy/property-provider': 4.2.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 + '@smithy/util-stream': 4.5.4 tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.908.0': @@ -13394,10 +14069,10 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.908.0 '@aws-sdk/nested-clients': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/credential-provider-imds': 4.2.0 - '@smithy/property-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/credential-provider-imds': 4.2.3 + '@smithy/property-provider': 4.2.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -13411,10 +14086,10 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.908.0 '@aws-sdk/credential-provider-web-identity': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/credential-provider-imds': 4.2.0 - '@smithy/property-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/credential-provider-imds': 4.2.3 + '@smithy/property-provider': 4.2.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -13423,9 +14098,9 @@ snapshots: dependencies: '@aws-sdk/core': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/property-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/property-provider': 4.2.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/credential-provider-sso@3.908.0': @@ -13434,9 +14109,9 @@ snapshots: '@aws-sdk/core': 3.908.0 '@aws-sdk/token-providers': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/property-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/property-provider': 4.2.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -13446,9 +14121,9 @@ snapshots: '@aws-sdk/core': 3.908.0 '@aws-sdk/nested-clients': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/property-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/property-provider': 4.2.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -13456,22 +14131,22 @@ snapshots: '@aws-sdk/middleware-host-header@3.901.0': dependencies: '@aws-sdk/types': 3.901.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/middleware-logger@3.901.0': dependencies: '@aws-sdk/types': 3.901.0 - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/middleware-recursion-detection@3.901.0': dependencies: '@aws-sdk/types': 3.901.0 '@aws/lambda-invoke-store': 0.0.1 - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.908.0': @@ -13479,9 +14154,9 @@ snapshots: '@aws-sdk/core': 3.908.0 '@aws-sdk/types': 3.901.0 '@aws-sdk/util-endpoints': 3.901.0 - '@smithy/core': 3.15.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 + '@smithy/core': 3.17.1 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/nested-clients@3.908.0': @@ -13498,30 +14173,30 @@ snapshots: '@aws-sdk/util-endpoints': 3.901.0 '@aws-sdk/util-user-agent-browser': 3.907.0 '@aws-sdk/util-user-agent-node': 3.908.0 - '@smithy/config-resolver': 4.3.0 - '@smithy/core': 3.15.0 - '@smithy/fetch-http-handler': 5.3.1 - '@smithy/hash-node': 4.2.0 - '@smithy/invalid-dependency': 4.2.0 - '@smithy/middleware-content-length': 4.2.0 - '@smithy/middleware-endpoint': 4.3.1 - '@smithy/middleware-retry': 4.4.1 - '@smithy/middleware-serde': 4.2.0 - '@smithy/middleware-stack': 4.2.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/node-http-handler': 4.3.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 - '@smithy/url-parser': 4.2.0 + '@smithy/config-resolver': 4.4.0 + '@smithy/core': 3.17.1 + '@smithy/fetch-http-handler': 5.3.4 + '@smithy/hash-node': 4.2.3 + '@smithy/invalid-dependency': 4.2.3 + '@smithy/middleware-content-length': 4.2.3 + '@smithy/middleware-endpoint': 4.3.5 + '@smithy/middleware-retry': 4.4.5 + '@smithy/middleware-serde': 4.2.3 + '@smithy/middleware-stack': 4.2.3 + '@smithy/node-config-provider': 4.3.3 + '@smithy/node-http-handler': 4.4.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 + '@smithy/url-parser': 4.2.3 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.0 - '@smithy/util-defaults-mode-node': 4.2.1 - '@smithy/util-endpoints': 3.2.0 - '@smithy/util-middleware': 4.2.0 - '@smithy/util-retry': 4.2.0 + '@smithy/util-defaults-mode-browser': 4.3.4 + '@smithy/util-defaults-mode-node': 4.2.6 + '@smithy/util-endpoints': 3.2.3 + '@smithy/util-middleware': 4.2.3 + '@smithy/util-retry': 4.2.3 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -13530,10 +14205,10 @@ snapshots: '@aws-sdk/region-config-resolver@3.901.0': dependencies: '@aws-sdk/types': 3.901.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/node-config-provider': 4.3.3 + '@smithy/types': 4.8.0 '@smithy/util-config-provider': 4.2.0 - '@smithy/util-middleware': 4.2.0 + '@smithy/util-middleware': 4.2.3 tslib: 2.8.1 '@aws-sdk/token-providers@3.908.0': @@ -13541,24 +14216,29 @@ snapshots: '@aws-sdk/core': 3.908.0 '@aws-sdk/nested-clients': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/property-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/property-provider': 4.2.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 transitivePeerDependencies: - aws-crt + '@aws-sdk/types@3.664.0': + dependencies: + '@smithy/types': 3.5.0 + tslib: 2.8.1 + '@aws-sdk/types@3.901.0': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/util-endpoints@3.901.0': dependencies: '@aws-sdk/types': 3.901.0 - '@smithy/types': 4.6.0 - '@smithy/url-parser': 4.2.0 - '@smithy/util-endpoints': 3.2.0 + '@smithy/types': 4.8.0 + '@smithy/url-parser': 4.2.3 + '@smithy/util-endpoints': 3.2.3 tslib: 2.8.1 '@aws-sdk/util-locate-window@3.893.0': @@ -13568,7 +14248,7 @@ snapshots: '@aws-sdk/util-user-agent-browser@3.907.0': dependencies: '@aws-sdk/types': 3.901.0 - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 bowser: 2.12.1 tslib: 2.8.1 @@ -13576,8 +14256,8 @@ snapshots: dependencies: '@aws-sdk/middleware-user-agent': 3.908.0 '@aws-sdk/types': 3.901.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/node-config-provider': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@aws-sdk/util-utf8-browser@3.259.0': @@ -13586,7 +14266,7 @@ snapshots: '@aws-sdk/xml-builder@3.901.0': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 fast-xml-parser: 5.2.5 tslib: 2.8.1 @@ -14695,6 +15375,83 @@ snapshots: - ethers - utf-8-validate + '@chainsafe/as-sha256@1.2.0': {} + + '@chainsafe/blst-darwin-arm64@2.2.0': + optional: true + + '@chainsafe/blst-darwin-x64@2.2.0': + optional: true + + '@chainsafe/blst-linux-arm64-gnu@2.2.0': + optional: true + + '@chainsafe/blst-linux-arm64-musl@2.2.0': + optional: true + + '@chainsafe/blst-linux-x64-gnu@2.2.0': + optional: true + + '@chainsafe/blst-linux-x64-musl@2.2.0': + optional: true + + '@chainsafe/blst-win32-x64-msvc@2.2.0': + optional: true + + '@chainsafe/blst@2.2.0': + optionalDependencies: + '@chainsafe/blst-darwin-arm64': 2.2.0 + '@chainsafe/blst-darwin-x64': 2.2.0 + '@chainsafe/blst-linux-arm64-gnu': 2.2.0 + '@chainsafe/blst-linux-arm64-musl': 2.2.0 + '@chainsafe/blst-linux-x64-gnu': 2.2.0 + '@chainsafe/blst-linux-x64-musl': 2.2.0 + '@chainsafe/blst-win32-x64-msvc': 2.2.0 + + '@chainsafe/hashtree-darwin-arm64@1.0.2': + optional: true + + '@chainsafe/hashtree-linux-arm64-gnu@1.0.2': + optional: true + + '@chainsafe/hashtree-linux-arm64-musl@1.0.2': + optional: true + + '@chainsafe/hashtree-linux-x64-gnu@1.0.2': + optional: true + + '@chainsafe/hashtree-linux-x64-musl@1.0.2': + optional: true + + '@chainsafe/hashtree-win32-x64-msvc@1.0.2': + optional: true + + '@chainsafe/hashtree@1.0.2': + optionalDependencies: + '@chainsafe/hashtree-darwin-arm64': 1.0.2 + '@chainsafe/hashtree-linux-arm64-gnu': 1.0.2 + '@chainsafe/hashtree-linux-arm64-musl': 1.0.2 + '@chainsafe/hashtree-linux-x64-gnu': 1.0.2 + '@chainsafe/hashtree-linux-x64-musl': 1.0.2 + '@chainsafe/hashtree-win32-x64-msvc': 1.0.2 + + '@chainsafe/is-ip@2.1.0': {} + + '@chainsafe/netmask@2.0.0': + dependencies: + '@chainsafe/is-ip': 2.1.0 + + '@chainsafe/persistent-merkle-tree@1.2.1': + dependencies: + '@chainsafe/as-sha256': 1.2.0 + '@chainsafe/hashtree': 1.0.2 + '@noble/hashes': 1.8.0 + + '@chainsafe/ssz@1.2.2': + dependencies: + '@chainsafe/as-sha256': 1.2.0 + '@chainsafe/persistent-merkle-tree': 1.2.1 + '@chaitanyapotti/register-service-worker@1.7.4': {} '@changesets/apply-release-plan@7.0.12': @@ -15901,6 +16658,10 @@ snapshots: '@img/sharp-win32-x64@0.34.3': optional: true + '@ipld/dag-pb@4.1.5': + dependencies: + multiformats: 13.4.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -16252,6 +17013,73 @@ snapshots: - debug - tailwindcss + '@leichtgewicht/ip-codec@2.0.5': {} + + '@libp2p/interface@2.11.0': + dependencies: + '@multiformats/dns': 1.0.10 + '@multiformats/multiaddr': 12.5.1 + it-pushable: 3.2.3 + it-stream-types: 2.0.2 + main-event: 1.0.1 + multiformats: 13.4.1 + progress-events: 1.0.1 + uint8arraylist: 2.4.8 + + '@libp2p/logger@5.2.0': + dependencies: + '@libp2p/interface': 2.11.0 + '@multiformats/multiaddr': 12.5.1 + interface-datastore: 8.3.2 + multiformats: 13.4.1 + weald: 1.0.6 + + '@lidofinance/lsv-cli@1.0.0-alpha.62(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)': + dependencies: + '@chainsafe/blst': 2.2.0 + '@chainsafe/persistent-merkle-tree': 1.2.1 + '@chainsafe/ssz': 1.2.2 + '@lodestar/types': 1.35.0 + '@openzeppelin/merkle-tree': 1.0.8 + '@walletconnect/sign-client': 2.23.0(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2) + blessed: 0.1.81 + blessed-contrib: 4.11.0 + blockstore-core: 5.0.4 + chalk: 5.4.1 + cli-progress: 3.12.0 + cli-table3: 0.6.5 + commander: 12.1.0 + dotenv: 16.5.0 + fs-extra: 11.3.2 + ipfs-unixfs-importer: 15.4.0(encoding@0.1.13) + json-bigint: 1.0.0 + log-update: 6.1.0 + multiformats: 13.4.1 + ox: 0.8.9(typescript@5.4.5)(zod@3.24.2) + prompts: 2.4.2 + qrcode-terminal: 0.12.0 + viem: 2.37.6(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - ioredis + - supports-color + - typescript + - utf-8-validate + - zod + '@lifi/sdk@3.7.9(@solana/wallet-adapter-base@0.9.26(@solana/web3.js@1.98.2(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.2(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10))(@types/react@18.3.11)(bufferutil@4.0.8)(react@18.3.1)(typescript@5.4.5)(use-sync-external-store@1.4.0(react@18.3.1))(utf-8-validate@5.0.10)(viem@2.37.6(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2)': dependencies: '@bigmi/core': 0.3.1(@types/react@18.3.11)(bs58@6.0.0)(react@18.3.1)(typescript@5.4.5)(use-sync-external-store@1.4.0(react@18.3.1)) @@ -16403,9 +17231,17 @@ snapshots: '@lit-labs/ssr-dom-shim@1.2.1': {} - '@lit/reactive-element@2.1.0': + '@lit/reactive-element@2.1.0': + dependencies: + '@lit-labs/ssr-dom-shim': 1.2.1 + + '@lodestar/params@1.35.0': {} + + '@lodestar/types@1.35.0': dependencies: - '@lit-labs/ssr-dom-shim': 1.2.1 + '@chainsafe/ssz': 1.2.2 + '@lodestar/params': 1.35.0 + ethereum-cryptography: 2.2.1 '@lukeed/csprng@1.1.0': {} @@ -16431,6 +17267,13 @@ snapshots: '@metamask/7715-permission-types@0.3.0': {} + '@metamask/abi-utils@2.0.4': + dependencies: + '@metamask/superstruct': 3.1.0 + '@metamask/utils': 9.3.0 + transitivePeerDependencies: + - supports-color + '@metamask/abi-utils@3.0.0': dependencies: '@metamask/superstruct': 3.1.0 @@ -16745,6 +17588,30 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 + '@multiformats/dns@1.0.10': + dependencies: + buffer: 6.0.3 + dns-packet: 5.6.1 + hashlru: 2.3.0 + p-queue: 9.0.0 + progress-events: 1.0.1 + uint8arrays: 5.1.0 + + '@multiformats/multiaddr@12.5.1': + dependencies: + '@chainsafe/is-ip': 2.1.0 + '@chainsafe/netmask': 2.0.0 + '@multiformats/dns': 1.0.10 + abort-error: 1.0.1 + multiformats: 13.4.1 + uint8-varint: 2.0.4 + uint8arrays: 5.1.0 + + '@multiformats/murmur3@2.1.8': + dependencies: + multiformats: 13.4.1 + murmurhash3js-revisited: 3.0.0 + '@mysten/bcs@1.6.2': dependencies: '@mysten/utils': 0.0.1 @@ -17220,6 +18087,13 @@ snapshots: - supports-color - utf-8-validate + '@openzeppelin/merkle-tree@1.0.8': + dependencies: + '@metamask/abi-utils': 2.0.4 + ethereum-cryptography: 3.2.0 + transitivePeerDependencies: + - supports-color + '@openzeppelin/upgrades-core@1.42.1': dependencies: '@nomicfoundation/slang': 0.18.3 @@ -19104,58 +19978,59 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@smithy/abort-controller@4.2.0': + '@smithy/abort-controller@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/config-resolver@4.3.0': + '@smithy/config-resolver@4.4.0': dependencies: - '@smithy/node-config-provider': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/node-config-provider': 4.3.3 + '@smithy/types': 4.8.0 '@smithy/util-config-provider': 4.2.0 - '@smithy/util-middleware': 4.2.0 + '@smithy/util-endpoints': 3.2.3 + '@smithy/util-middleware': 4.2.3 tslib: 2.8.1 - '@smithy/core@3.15.0': + '@smithy/core@3.17.1': dependencies: - '@smithy/middleware-serde': 4.2.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 + '@smithy/middleware-serde': 4.2.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.0 - '@smithy/util-stream': 4.5.0 + '@smithy/util-middleware': 4.2.3 + '@smithy/util-stream': 4.5.4 '@smithy/util-utf8': 4.2.0 '@smithy/uuid': 1.1.0 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.0': + '@smithy/credential-provider-imds@4.2.3': dependencies: - '@smithy/node-config-provider': 4.3.0 - '@smithy/property-provider': 4.2.0 - '@smithy/types': 4.6.0 - '@smithy/url-parser': 4.2.0 + '@smithy/node-config-provider': 4.3.3 + '@smithy/property-provider': 4.2.3 + '@smithy/types': 4.8.0 + '@smithy/url-parser': 4.2.3 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.1': + '@smithy/fetch-http-handler@5.3.4': dependencies: - '@smithy/protocol-http': 5.3.0 - '@smithy/querystring-builder': 4.2.0 - '@smithy/types': 4.6.0 + '@smithy/protocol-http': 5.3.3 + '@smithy/querystring-builder': 4.2.3 + '@smithy/types': 4.8.0 '@smithy/util-base64': 4.3.0 tslib: 2.8.1 - '@smithy/hash-node@4.2.0': + '@smithy/hash-node@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 '@smithy/util-buffer-from': 4.2.0 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/invalid-dependency@4.2.0': + '@smithy/invalid-dependency@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@smithy/is-array-buffer@2.2.0': @@ -19166,120 +20041,124 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/middleware-content-length@4.2.0': + '@smithy/middleware-content-length@4.2.3': dependencies: - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.3.1': + '@smithy/middleware-endpoint@4.3.5': dependencies: - '@smithy/core': 3.15.0 - '@smithy/middleware-serde': 4.2.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 - '@smithy/url-parser': 4.2.0 - '@smithy/util-middleware': 4.2.0 + '@smithy/core': 3.17.1 + '@smithy/middleware-serde': 4.2.3 + '@smithy/node-config-provider': 4.3.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 + '@smithy/url-parser': 4.2.3 + '@smithy/util-middleware': 4.2.3 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.1': + '@smithy/middleware-retry@4.4.5': dependencies: - '@smithy/node-config-provider': 4.3.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/service-error-classification': 4.2.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 - '@smithy/util-middleware': 4.2.0 - '@smithy/util-retry': 4.2.0 + '@smithy/node-config-provider': 4.3.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/service-error-classification': 4.2.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 + '@smithy/util-middleware': 4.2.3 + '@smithy/util-retry': 4.2.3 '@smithy/uuid': 1.1.0 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.0': + '@smithy/middleware-serde@4.2.3': dependencies: - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/middleware-stack@4.2.0': + '@smithy/middleware-stack@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/node-config-provider@4.3.0': + '@smithy/node-config-provider@4.3.3': dependencies: - '@smithy/property-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/property-provider': 4.2.3 + '@smithy/shared-ini-file-loader': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.3.0': + '@smithy/node-http-handler@4.4.3': dependencies: - '@smithy/abort-controller': 4.2.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/querystring-builder': 4.2.0 - '@smithy/types': 4.6.0 + '@smithy/abort-controller': 4.2.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/querystring-builder': 4.2.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/property-provider@4.2.0': + '@smithy/property-provider@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/protocol-http@5.3.0': + '@smithy/protocol-http@5.3.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/querystring-builder@4.2.0': + '@smithy/querystring-builder@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 - '@smithy/querystring-parser@4.2.0': + '@smithy/querystring-parser@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/service-error-classification@4.2.0': + '@smithy/service-error-classification@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 - '@smithy/shared-ini-file-loader@4.3.0': + '@smithy/shared-ini-file-loader@4.3.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/signature-v4@5.3.0': + '@smithy/signature-v4@5.3.3': dependencies: '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.0 + '@smithy/util-middleware': 4.2.3 '@smithy/util-uri-escape': 4.2.0 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@4.7.1': + '@smithy/smithy-client@4.9.1': + dependencies: + '@smithy/core': 3.17.1 + '@smithy/middleware-endpoint': 4.3.5 + '@smithy/middleware-stack': 4.2.3 + '@smithy/protocol-http': 5.3.3 + '@smithy/types': 4.8.0 + '@smithy/util-stream': 4.5.4 + tslib: 2.8.1 + + '@smithy/types@3.5.0': dependencies: - '@smithy/core': 3.15.0 - '@smithy/middleware-endpoint': 4.3.1 - '@smithy/middleware-stack': 4.2.0 - '@smithy/protocol-http': 5.3.0 - '@smithy/types': 4.6.0 - '@smithy/util-stream': 4.5.0 tslib: 2.8.1 - '@smithy/types@4.6.0': + '@smithy/types@4.8.0': dependencies: tslib: 2.8.1 - '@smithy/url-parser@4.2.0': + '@smithy/url-parser@4.2.3': dependencies: - '@smithy/querystring-parser': 4.2.0 - '@smithy/types': 4.6.0 + '@smithy/querystring-parser': 4.2.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@smithy/util-base64@4.3.0': @@ -19310,49 +20189,49 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.0': + '@smithy/util-defaults-mode-browser@4.3.4': dependencies: - '@smithy/property-provider': 4.2.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 + '@smithy/property-provider': 4.2.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.1': + '@smithy/util-defaults-mode-node@4.2.6': dependencies: - '@smithy/config-resolver': 4.3.0 - '@smithy/credential-provider-imds': 4.2.0 - '@smithy/node-config-provider': 4.3.0 - '@smithy/property-provider': 4.2.0 - '@smithy/smithy-client': 4.7.1 - '@smithy/types': 4.6.0 + '@smithy/config-resolver': 4.4.0 + '@smithy/credential-provider-imds': 4.2.3 + '@smithy/node-config-provider': 4.3.3 + '@smithy/property-provider': 4.2.3 + '@smithy/smithy-client': 4.9.1 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.0': + '@smithy/util-endpoints@3.2.3': dependencies: - '@smithy/node-config-provider': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/node-config-provider': 4.3.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@4.2.0': + '@smithy/util-middleware@4.2.3': dependencies: - '@smithy/types': 4.6.0 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/util-retry@4.2.0': + '@smithy/util-retry@4.2.3': dependencies: - '@smithy/service-error-classification': 4.2.0 - '@smithy/types': 4.6.0 + '@smithy/service-error-classification': 4.2.3 + '@smithy/types': 4.8.0 tslib: 2.8.1 - '@smithy/util-stream@4.5.0': + '@smithy/util-stream@4.5.4': dependencies: - '@smithy/fetch-http-handler': 5.3.1 - '@smithy/node-http-handler': 4.3.0 - '@smithy/types': 4.6.0 + '@smithy/fetch-http-handler': 5.3.4 + '@smithy/node-http-handler': 4.4.3 + '@smithy/types': 4.8.0 '@smithy/util-base64': 4.3.0 '@smithy/util-buffer-from': 4.2.0 '@smithy/util-hex-encoding': 4.2.0 @@ -20018,7 +20897,7 @@ snapshots: dependencies: elliptic: 6.6.1 - '@toruslabs/ethereum-controllers@8.8.4(@babel/runtime@7.27.6)(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))': + '@toruslabs/ethereum-controllers@8.8.4(@babel/runtime@7.27.6)(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))': dependencies: '@babel/runtime': 7.27.6 '@ethereumjs/util': 10.0.0 @@ -20036,7 +20915,7 @@ snapshots: fast-safe-stringify: 2.1.1 jsonschema: 1.5.0 loglevel: 1.9.2 - permissionless: 0.2.57(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)) + permissionless: 0.2.57(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)) viem: 2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2) transitivePeerDependencies: - '@sentry/core' @@ -20382,6 +21261,12 @@ snapshots: '@types/express-serve-static-core': 5.0.6 '@types/serve-static': 1.15.7 + '@types/express@5.0.4': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 5.0.6 + '@types/serve-static': 1.15.7 + '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 @@ -21757,7 +22642,7 @@ snapshots: - utf-8-validate - zod - '@web3auth/modal@10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(@solana/web3.js@1.98.2(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(encoding@0.1.13)(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(react-dom@18.3.1(react@18.3.1))(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2)': + '@web3auth/modal@10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(@solana/web3.js@1.98.2(bufferutil@4.0.8)(encoding@0.1.13)(typescript@5.4.5)(utf-8-validate@5.0.10))(bufferutil@4.0.8)(encoding@0.1.13)(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(react-dom@18.3.1(react@18.3.1))(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2)': dependencies: '@babel/runtime': 7.27.6 '@hcaptcha/react-hcaptcha': 1.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -21765,7 +22650,7 @@ snapshots: '@toruslabs/base-controllers': 8.8.3(@babel/runtime@7.27.6)(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(utf-8-validate@5.0.10) '@toruslabs/http-helpers': 8.1.1(@babel/runtime@7.27.6)(@sentry/core@9.46.0) '@web3auth/auth': 10.6.0(@babel/runtime@7.27.6)(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(utf-8-validate@5.0.10) - '@web3auth/no-modal': 10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(encoding@0.1.13)(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2) + '@web3auth/no-modal': 10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(encoding@0.1.13)(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2) bowser: 2.12.1 classnames: 2.5.1 clsx: 2.1.1 @@ -21805,7 +22690,7 @@ snapshots: - utf-8-validate - zod - '@web3auth/no-modal@10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(encoding@0.1.13)(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2)': + '@web3auth/no-modal@10.6.0(@babel/runtime@7.27.6)(@coinbase/wallet-sdk@4.3.7(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(@react-native-async-storage/async-storage@1.24.0(react-native@0.74.0(@babel/core@7.25.7)(@babel/preset-env@7.25.7(@babel/core@7.25.7))(@types/react@18.3.11)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)(utf-8-validate@5.0.10)))(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(encoding@0.1.13)(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(react@18.3.1)(typescript@5.4.5)(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2))(zod@3.24.2)': dependencies: '@babel/runtime': 7.27.6 '@ethereumjs/util': 10.0.0 @@ -21817,7 +22702,7 @@ snapshots: '@toruslabs/bs58': 1.0.0(@babel/runtime@7.27.6) '@toruslabs/constants': 15.0.0(@babel/runtime@7.27.6) '@toruslabs/eccrypto': 6.2.0 - '@toruslabs/ethereum-controllers': 8.8.4(@babel/runtime@7.27.6)(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)) + '@toruslabs/ethereum-controllers': 8.8.4(@babel/runtime@7.27.6)(@sentry/core@9.46.0)(bufferutil@4.0.8)(color@4.2.3)(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(utf-8-validate@5.0.10)(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)) '@toruslabs/http-helpers': 8.1.1(@babel/runtime@7.27.6)(@sentry/core@9.46.0) '@toruslabs/loglevel-sentry': 8.1.0(@babel/runtime@7.27.6) '@toruslabs/secure-pub-sub': 3.0.2(@babel/runtime@7.27.6)(@sentry/core@9.46.0)(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -21841,7 +22726,7 @@ snapshots: jwt-decode: 4.0.0 loglevel: 1.9.2 mipd: 0.0.7(typescript@5.4.5) - permissionless: 0.2.57(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)) + permissionless: 0.2.57(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)) ripple-keypairs: 1.3.1 ts-custom-error: 3.3.1 xrpl: 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -21903,6 +22788,22 @@ snapshots: - supports-color - utf-8-validate + '@wry/caches@1.0.1': + dependencies: + tslib: 2.8.1 + + '@wry/context@0.7.4': + dependencies: + tslib: 2.8.1 + + '@wry/equality@0.5.7': + dependencies: + tslib: 2.8.1 + + '@wry/trie@0.5.0': + dependencies: + tslib: 2.8.1 + '@xrplf/isomorphic@1.0.1(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@noble/hashes': 1.8.0 @@ -21965,6 +22866,8 @@ snapshots: dependencies: event-target-shim: 5.0.1 + abort-error@1.0.1: {} + abortcontroller-polyfill@1.7.5: {} accepts@1.3.8: @@ -22053,18 +22956,28 @@ snapshots: dependencies: type-fest: 0.21.3 + ansi-escapes@6.2.1: {} + + ansi-escapes@7.1.1: + dependencies: + environment: 1.1.0 + ansi-fragments@0.2.1: dependencies: colorette: 1.4.0 slice-ansi: 2.1.0 strip-ansi: 5.2.0 + ansi-regex@2.1.1: {} + ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} + ansi-styles@2.2.1: {} + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 @@ -22077,6 +22990,12 @@ snapshots: ansi-styles@6.2.1: {} + ansi-term@0.0.2: + dependencies: + x256: 0.0.2 + + ansicolors@0.3.2: {} + ansis@3.17.0: {} antlr4@4.13.2: {} @@ -22453,6 +23372,12 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bl@5.1.0: + dependencies: + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 3.6.2 + blake2b-wasm@2.4.0: dependencies: b4a: 1.7.3 @@ -22469,6 +23394,34 @@ snapshots: blakejs@1.2.1: {} + blessed-contrib@4.11.0: + dependencies: + ansi-term: 0.0.2 + chalk: 1.1.3 + drawille-canvas-blessed-contrib: 0.1.3 + lodash: 4.17.21 + map-canvas: 0.1.5 + marked: 4.3.0 + marked-terminal: 5.2.0(marked@4.3.0) + memory-streams: 0.1.3 + memorystream: 0.3.1 + picture-tuber: 1.0.2 + sparkline: 0.1.2 + strip-ansi: 3.0.1 + term-canvas: 0.0.5 + x256: 0.0.2 + + blessed@0.1.81: {} + + blockstore-core@5.0.4: + dependencies: + '@libp2p/logger': 5.2.0 + interface-blockstore: 5.3.2 + interface-store: 6.0.3 + it-filter: 3.1.4 + it-merge: 3.0.12 + multiformats: 13.4.1 + bluebird@3.4.7: {} bluebird@3.7.2: {} @@ -22546,6 +23499,8 @@ snapshots: dependencies: fill-range: 7.1.1 + bresenham@0.0.3: {} + brorand@1.1.0: {} brotli-wasm@2.0.1: {} @@ -22728,6 +23683,11 @@ snapshots: caniuse-lite@1.0.30001706: {} + cardinal@2.1.1: + dependencies: + ansicolors: 0.3.2 + redeyed: 2.1.1 + caseless@0.12.0: {} cbor@10.0.3: @@ -22766,6 +23726,14 @@ snapshots: dependencies: traverse: 0.3.9 + chalk@1.1.3: + dependencies: + ansi-styles: 2.2.1 + escape-string-regexp: 1.0.5 + has-ansi: 2.0.0 + strip-ansi: 3.0.1 + supports-color: 2.0.0 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -22787,6 +23755,8 @@ snapshots: charenc@0.0.2: {} + charm@0.1.2: {} + check-error@1.0.3: dependencies: get-func-name: 2.0.2 @@ -22873,6 +23843,10 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-highlight@2.1.11: dependencies: chalk: 4.1.2 @@ -22882,6 +23856,10 @@ snapshots: parse5-htmlparser2-tree-adapter: 6.0.1 yargs: 16.2.0 + cli-progress@3.12.0: + dependencies: + string-width: 4.2.3 + cli-spinners@2.9.2: {} cli-table3@0.6.5: @@ -23459,6 +24437,10 @@ snapshots: dependencies: path-type: 4.0.0 + dns-packet@5.6.1: + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -23499,6 +24481,16 @@ snapshots: dotenv@16.5.0: {} + drawille-blessed-contrib@1.0.0: {} + + drawille-canvas-blessed-contrib@0.1.3: + dependencies: + ansi-term: 0.0.2 + bresenham: 0.0.3 + drawille-blessed-contrib: 1.0.0 + gl-matrix: 2.8.1 + x256: 0.0.2 + dset@3.1.4: {} dunder-proto@1.0.1: @@ -23552,6 +24544,8 @@ snapshots: emittery@0.13.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -23612,6 +24606,8 @@ snapshots: envinfo@7.14.0: {} + environment@1.1.0: {} + era-contracts@https://codeload.github.com/matter-labs/era-contracts/tar.gz/446d391d34bdb48255d5f8fef8a8248925fc98b9: {} err-code@2.0.3: {} @@ -24226,6 +25222,10 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 + event-stream@0.9.8: + dependencies: + optimist: 0.2.8 + event-target-shim@5.0.1: {} eventemitter2@6.4.9: {} @@ -24337,7 +25337,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.1 cookie-signature: 1.2.2 - debug: 4.4.0 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -24629,6 +25629,12 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fs-extra@4.0.3: dependencies: graceful-fs: 4.2.11 @@ -24692,6 +25698,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} + get-func-name@2.0.2: {} get-intrinsic@1.3.0: @@ -24749,6 +25757,8 @@ snapshots: github-from-package@0.0.0: {} + gl-matrix@2.8.1: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -24927,6 +25937,11 @@ snapshots: graphemer@1.4.0: {} + graphql-tag@2.12.6(graphql@16.11.0): + dependencies: + graphql: 16.11.0 + tslib: 2.8.1 + graphql@16.11.0: {} gsap@3.13.0: {} @@ -24944,6 +25959,11 @@ snapshots: uncrypto: 0.1.3 unenv: 1.10.0 + hamt-sharding@3.0.6: + dependencies: + sparse-array: 1.3.2 + uint8arrays: 5.1.0 + handlebars@4.7.8: dependencies: minimist: 1.2.8 @@ -25084,6 +26104,10 @@ snapshots: - supports-color - utf-8-validate + has-ansi@2.0.0: + dependencies: + ansi-regex: 2.1.1 + has-bigints@1.0.2: {} has-flag@1.0.0: {} @@ -25119,6 +26143,8 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 + hashlru@2.3.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -25129,6 +26155,8 @@ snapshots: help-me@5.0.0: {} + here@0.0.2: {} + hermes-estree@0.19.1: {} hermes-estree@0.23.1: {} @@ -25310,6 +26338,18 @@ snapshots: ini@1.3.8: {} + interface-blockstore@5.3.2: + dependencies: + interface-store: 6.0.3 + multiformats: 13.4.1 + + interface-datastore@8.3.2: + dependencies: + interface-store: 6.0.3 + uint8arrays: 5.1.0 + + interface-store@6.0.3: {} + internal-slot@1.0.7: dependencies: es-errors: 1.3.0 @@ -25333,6 +26373,32 @@ snapshots: ipaddr.js@1.9.1: {} + ipfs-unixfs-importer@15.4.0(encoding@0.1.13): + dependencies: + '@ipld/dag-pb': 4.1.5 + '@multiformats/murmur3': 2.1.8 + hamt-sharding: 3.0.6 + interface-blockstore: 5.3.2 + interface-store: 6.0.3 + ipfs-unixfs: 11.2.5 + it-all: 3.0.9 + it-batch: 3.0.9 + it-first: 3.0.9 + it-parallel-batch: 3.0.9 + multiformats: 13.4.1 + progress-events: 1.0.1 + rabin-wasm: 0.1.5(encoding@0.1.13) + uint8arraylist: 2.4.8 + uint8arrays: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + + ipfs-unixfs@11.2.5: + dependencies: + protons-runtime: 5.6.0 + uint8arraylist: 2.4.8 + iron-webcrypto@1.2.1: {} is-arguments@1.1.1: @@ -25404,6 +26470,10 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.4.0 + is-function@1.0.2: {} is-generator-fn@2.1.0: {} @@ -25515,6 +26585,8 @@ snapshots: dependencies: system-architecture: 0.1.0 + isarray@0.0.1: {} + isarray@1.0.0: {} isarray@2.0.5: {} @@ -25599,6 +26671,38 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + it-all@3.0.9: {} + + it-batch@3.0.9: {} + + it-filter@3.1.4: + dependencies: + it-peekable: 3.0.8 + + it-first@3.0.9: {} + + it-merge@3.0.12: + dependencies: + it-queueless-pushable: 2.0.2 + + it-parallel-batch@3.0.9: + dependencies: + it-batch: 3.0.9 + + it-peekable@3.0.8: {} + + it-pushable@3.2.3: + dependencies: + p-defer: 4.0.1 + + it-queueless-pushable@2.0.2: + dependencies: + abort-error: 1.0.1 + p-defer: 4.0.1 + race-signal: 1.1.3 + + it-stream-types@2.0.2: {} + iterator.prototype@1.1.3: dependencies: define-properties: 1.2.1 @@ -26130,6 +27234,10 @@ snapshots: jsesc@3.0.2: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -26356,6 +27464,14 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-update@6.1.0: + dependencies: + ansi-escapes: 7.1.1 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.2 + logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -26407,6 +27523,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + main-event@1.0.1: {} + make-dir@2.1.0: dependencies: pify: 4.0.1 @@ -26439,10 +27557,27 @@ snapshots: dependencies: tmpl: 1.0.5 + map-canvas@0.1.5: + dependencies: + drawille-canvas-blessed-contrib: 0.1.3 + xml2js: 0.4.23 + markdown-table@2.0.0: dependencies: repeat-string: 1.6.1 + marked-terminal@5.2.0(marked@4.3.0): + dependencies: + ansi-escapes: 6.2.1 + cardinal: 2.1.1 + chalk: 5.4.1 + cli-table3: 0.6.5 + marked: 4.3.0 + node-emoji: 1.11.0 + supports-hyperlinks: 2.3.0 + + marked@4.3.0: {} + marky@1.2.5: {} match-all@1.2.6: {} @@ -26469,6 +27604,10 @@ snapshots: memoize-one@5.2.1: {} + memory-streams@0.1.3: + dependencies: + readable-stream: 1.0.34 + memorystream@0.3.1: {} merge-descriptors@1.0.3: {} @@ -26705,6 +27844,8 @@ snapshots: mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -26876,6 +28017,8 @@ snapshots: ms@2.1.3: {} + ms@3.0.0-canary.202508261828: {} + multibase@0.6.1: dependencies: base-x: 3.0.11 @@ -26895,6 +28038,8 @@ snapshots: buffer: 5.7.1 varint: 5.0.2 + multiformats@13.4.1: {} + multiformats@9.9.0: {} multihashes@0.4.21: @@ -26909,6 +28054,8 @@ snapshots: fmix: 0.1.0 imul: 1.0.1 + murmurhash3js-revisited@3.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -26935,7 +28082,7 @@ snapshots: neverthrow@8.2.0: optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.44.0 + '@rollup/rollup-linux-x64-gnu': 4.52.5 new-date@1.0.3: dependencies: @@ -27039,6 +28186,10 @@ snapshots: nofilter@3.1.0: {} + nopt@2.1.2: + dependencies: + abbrev: 1.0.9 + nopt@3.0.6: dependencies: abbrev: 1.0.9 @@ -27192,6 +28343,10 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + open@6.4.0: dependencies: is-wsl: 1.1.0 @@ -27207,6 +28362,21 @@ snapshots: openapi-typescript-helpers@0.0.15: {} + optimism@0.18.1: + dependencies: + '@wry/caches': 1.0.1 + '@wry/context': 0.7.4 + '@wry/trie': 0.5.0 + tslib: 2.8.1 + + optimist@0.2.8: + dependencies: + wordwrap: 0.0.3 + + optimist@0.3.7: + dependencies: + wordwrap: 0.0.3 + optionator@0.8.3: dependencies: deep-is: 0.1.4 @@ -27344,6 +28514,21 @@ snapshots: transitivePeerDependencies: - zod + ox@0.8.9(typescript@5.4.5)(zod@3.24.2): + dependencies: + '@adraffy/ens-normalize': 1.11.0 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.1.0(typescript@5.4.5)(zod@3.24.2) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.4.5 + transitivePeerDependencies: + - zod + ox@0.9.3(typescript@5.4.5)(zod@3.24.2): dependencies: '@adraffy/ens-normalize': 1.11.0 @@ -27363,6 +28548,8 @@ snapshots: p-cancelable@3.0.0: {} + p-defer@4.0.1: {} + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -27393,6 +28580,13 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-queue@9.0.0: + dependencies: + eventemitter3: 5.0.1 + p-timeout: 7.0.1 + + p-timeout@7.0.1: {} + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -27495,11 +28689,11 @@ snapshots: performance-now@2.1.0: {} - permissionless@0.2.57(ox@0.8.1(typescript@5.4.5)(zod@3.24.2))(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)): + permissionless@0.2.57(ox@0.8.9(typescript@5.4.5)(zod@3.24.2))(viem@2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2)): dependencies: viem: 2.31.3(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.24.2) optionalDependencies: - ox: 0.8.1(typescript@5.4.5)(zod@3.24.2) + ox: 0.8.9(typescript@5.4.5)(zod@3.24.2) pg-cloudflare@1.1.1: optional: true @@ -27542,6 +28736,15 @@ snapshots: picomatch@4.0.2: {} + picture-tuber@1.0.2: + dependencies: + buffers: 0.1.1 + charm: 0.1.2 + event-stream: 0.9.8 + optimist: 0.3.7 + png-js: 0.1.1 + x256: 0.0.2 + pify@3.0.0: {} pify@4.0.1: {} @@ -27631,6 +28834,8 @@ snapshots: pluralize@8.0.0: {} + png-js@0.1.1: {} + pngjs@5.0.0: {} pony-cause@2.1.11: {} @@ -27741,6 +28946,8 @@ snapshots: process@0.11.10: {} + progress-events@1.0.1: {} + progress@2.0.3: {} prom-client@15.1.3: @@ -27788,6 +28995,12 @@ snapshots: proto-list@1.2.4: {} + protons-runtime@5.6.0: + dependencies: + uint8-varint: 2.0.4 + uint8arraylist: 2.4.8 + uint8arrays: 5.1.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -27815,6 +29028,8 @@ snapshots: qrcode-generator@1.5.2: {} + qrcode-terminal@0.12.0: {} + qrcode.react@3.2.0(react@18.3.1): dependencies: react: 18.3.1 @@ -27869,6 +29084,20 @@ snapshots: quick-lru@5.1.1: {} + rabin-wasm@0.1.5(encoding@0.1.13): + dependencies: + '@assemblyscript/loader': 0.9.4 + bl: 5.1.0 + debug: 4.4.3(supports-color@8.1.1) + minimist: 1.2.8 + node-fetch: 2.7.0(encoding@0.1.13) + readable-stream: 3.6.2 + transitivePeerDependencies: + - encoding + - supports-color + + race-signal@1.1.3: {} + radix3@1.1.2: {} randombytes@2.1.0: @@ -28113,6 +29342,13 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readable-stream@1.0.34: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -28166,6 +29402,10 @@ snapshots: dependencies: minimatch: 3.1.2 + redeyed@2.1.1: + dependencies: + esprima: 4.0.1 + reduce-flatten@2.0.0: {} reflect-metadata@0.2.2: {} @@ -28302,6 +29542,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.12.0: {} retry@0.13.1: {} @@ -28421,7 +29666,7 @@ snapshots: buffer: 6.0.3 eventemitter3: 5.0.1 uuid: 8.3.2 - ws: 8.18.2(bufferutil@4.0.8)(utf-8-validate@5.0.10) + ws: 8.18.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) optionalDependencies: bufferutil: 4.0.8 utf-8-validate: 5.0.10 @@ -28433,7 +29678,6 @@ snapshots: rxjs@7.8.2: dependencies: tslib: 2.8.1 - optional: true safe-array-concat@1.1.2: dependencies: @@ -28464,6 +29708,8 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.4.1 + sax@1.4.1: {} + sc-istanbul@0.4.6: dependencies: abbrev: 1.0.9 @@ -28770,6 +30016,11 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.1.0 + slow-redact@0.3.2: {} smart-buffer@4.2.0: {} @@ -28926,6 +30177,13 @@ snapshots: dependencies: whatwg-url: 7.1.0 + sparkline@0.1.2: + dependencies: + here: 0.0.2 + nopt: 2.1.2 + + sparse-array@1.3.2: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -29004,6 +30262,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.0 + string.prototype.includes@2.0.0: dependencies: define-properties: 1.2.1 @@ -29048,6 +30312,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@0.10.31: {} + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -29056,6 +30322,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + strip-ansi@3.0.1: + dependencies: + ansi-regex: 2.1.1 + strip-ansi@5.2.0: dependencies: ansi-regex: 4.1.1 @@ -29114,6 +30384,10 @@ snapshots: superstruct@2.0.2: {} + supports-color@10.2.2: {} + + supports-color@2.0.0: {} + supports-color@3.2.3: dependencies: has-flag: 1.0.0 @@ -29130,6 +30404,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@2.3.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} svg-parser@2.0.4: {} @@ -29232,6 +30511,8 @@ snapshots: dependencies: rimraf: 2.6.3 + term-canvas@0.0.5: {} + term-size@2.2.1: {} terser@5.34.1: @@ -29512,7 +30793,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.1 + debug: 4.4.3(supports-color@8.1.1) esbuild: 0.25.5 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -29672,10 +30953,19 @@ snapshots: uglify-js@3.19.3: optional: true + uint8-varint@2.0.4: + dependencies: + uint8arraylist: 2.4.8 + uint8arrays: 5.1.0 + uint8array-tools@0.0.8: {} uint8array-tools@0.0.9: {} + uint8arraylist@2.4.8: + dependencies: + uint8arrays: 5.1.0 + uint8arrays@3.1.0: dependencies: multiformats: 9.9.0 @@ -29684,6 +30974,10 @@ snapshots: dependencies: multiformats: 9.9.0 + uint8arrays@5.1.0: + dependencies: + multiformats: 13.4.1 + unbox-primitive@1.0.2: dependencies: call-bind: 1.0.8 @@ -30115,6 +31409,11 @@ snapshots: dependencies: defaults: 1.0.4 + weald@1.0.6: + dependencies: + ms: 3.0.0-canary.202508261828 + supports-color: 10.2.2 + web-encoding@1.1.5: dependencies: util: 0.12.5 @@ -30453,6 +31752,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@0.0.3: {} + wordwrap@1.0.0: {} wordwrapjs@4.0.1: @@ -30480,6 +31781,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + wrappy@1.0.2: {} write-file-atomic@2.4.3: @@ -30530,6 +31837,8 @@ snapshots: bufferutil: 4.0.8 utf-8-validate: 5.0.10 + x256@0.0.2: {} + xhr-request-promise@0.1.3: dependencies: xhr-request: 1.1.0 @@ -30551,6 +31860,13 @@ snapshots: parse-headers: 2.0.5 xtend: 4.0.2 + xml2js@0.4.23: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlhttprequest-ssl@2.1.1: {} xrpl-secret-numbers@0.3.5(bufferutil@4.0.8)(utf-8-validate@5.0.10): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1dbfbc718f..7f5ca09c56 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ packages: - 'e2e/**' - 'sdk/**' - 'postman/**' + - 'native-yield-operations/**' - 'operations/**' - 'bridge-ui/**' - 'ts-libs/**' @@ -16,16 +17,22 @@ catalog: "@typechain/ethers-v6": 0.5.1 "@types/jest": 29.5.14 "@types/yargs": 17.0.33 + axios: 1.12.2 dotenv: 16.5.0 ethers: 6.13.7 # TODO in later ticket - investigate why SDK build seems to break with ^6.14.0 + express: "5.1.0" jest: 29.7.0 jest-mock-extended: 3.0.7 + neverthrow: 8.2.0 prettier: 3.5.3 + prom-client: 15.1.3 ts-jest: 29.3.4 + tsup: 8.5.0 typechain: 8.3.2 winston: 3.17.0 yargs: 17.7.2 viem: 2.31.3 + zod: "3.24.2" ignoredBuiltDependencies: - "@arbitrum/nitro-contracts" diff --git a/postman/Dockerfile b/postman/Dockerfile index c39e31cadb..4ed1c090d6 100644 --- a/postman/Dockerfile +++ b/postman/Dockerfile @@ -25,12 +25,14 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ COPY ./postman/package.json ./postman/package.json COPY ./sdk/sdk-ethers/package.json ./sdk/sdk-ethers/package.json COPY ./ts-libs/linea-native-libs/package.json ./ts-libs/linea-native-libs/package.json +COPY ./ts-libs/linea-shared-utils/package.json ./ts-libs/linea-shared-utils/package.json RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prefer-offline --ignore-scripts COPY ./postman ./postman COPY ./sdk/sdk-ethers ./sdk/sdk-ethers COPY ts-libs/linea-native-libs ./ts-libs/linea-native-libs +COPY ts-libs/linea-shared-utils ./ts-libs/linea-shared-utils RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm run build \ && pnpm deploy --legacy --filter=./postman --prod ./prod/postman diff --git a/postman/README.md b/postman/README.md index 7ea2675639..28d4ed1e62 100644 --- a/postman/README.md +++ b/postman/README.md @@ -171,7 +171,9 @@ Stop the postman docker container manually. Before the postman can be run and tested locally, we must build the monorepo projects linea-sdk and linea-native-libs ```bash -NATIVE_LIBS_RELEASE_TAG=blob-libs-v1.2.0 pnpm run -F linea-native-libs build && pnpm run -F "./sdk/*" build +NATIVE_LIBS_RELEASE_TAG=blob-libs-v1.2.0 pnpm run -F linea-native-libs build && \ +pnpm run -F linea-shared-utils build && \ +pnpm run -F "./sdk/*" build ``` From the postman folder run the following commands: diff --git a/postman/jest.config.js b/postman/jest.config.js index 2c7710f2f7..b760e8ffc0 100644 --- a/postman/jest.config.js +++ b/postman/jest.config.js @@ -13,7 +13,7 @@ module.exports = { "src/application/postman/persistence/repositories/", "src/application/postman/persistence/subscribers/", "src/index.ts", - "src/utils/WinstonLogger.ts", + "src/utils/PostmanWinstonLogger.ts", ], coveragePathIgnorePatterns: [ "src/clients/blockchain/typechain", @@ -21,7 +21,7 @@ module.exports = { "src/application/postman/persistence/repositories/", "src/application/postman/persistence/subscribers/", "src/index.ts", - "src/utils/WinstonLogger.ts", + "src/utils/PostmanWinstonLogger.ts", "src/utils/testing/", ], }; diff --git a/postman/package.json b/postman/package.json index 8ca38da8e3..63a50c3eb9 100644 --- a/postman/package.json +++ b/postman/package.json @@ -21,14 +21,15 @@ "dependencies": { "@consensys/linea-native-libs": "workspace:*", "@consensys/linea-sdk": "workspace:*", + "@consensys/linea-shared-utils": "workspace:*", "better-sqlite3": "11.6.0", "class-validator": "0.14.1", "dotenv": "catalog:", "ethers": "catalog:", - "express": "5.1.0", + "express": "catalog:", "filtrex": "3.1.0", "pg": "8.13.1", - "prom-client": "15.1.3", + "prom-client": "catalog:", "typeorm": "0.3.20", "typeorm-naming-strategies": "4.1.0", "winston": "catalog:" diff --git a/postman/src/application/postman/api/Api.ts b/postman/src/application/postman/api/Api.ts deleted file mode 100644 index 6fa93bd7f9..0000000000 --- a/postman/src/application/postman/api/Api.ts +++ /dev/null @@ -1,65 +0,0 @@ -import express, { Express, Request, Response } from "express"; -import { IMetricsService } from "../../../core/metrics/IMetricsService"; -import { ILogger } from "../../../core/utils/logging/ILogger"; - -type ApiConfig = { - port: number; -}; - -export class Api { - private readonly app: Express; - private server?: ReturnType; - - constructor( - private readonly config: ApiConfig, - private readonly metricsService: IMetricsService, - private readonly logger: ILogger, - ) { - this.app = express(); - - this.setupMiddleware(); - this.setupRoutes(); - } - - private setupMiddleware(): void { - this.app.use(express.json()); - } - - private setupRoutes(): void { - this.app.get("/metrics", this.handleMetrics.bind(this)); - } - - private async handleMetrics(_req: Request, res: Response): Promise { - try { - const registry = this.metricsService.getRegistry(); - res.set("Content-Type", registry.contentType); - res.end(await registry.metrics()); - } catch (error) { - res.status(500).json({ error: "Failed to collect metrics" }); - } - } - - public async start(): Promise { - this.server = this.app.listen(this.config.port); - - await new Promise((resolve) => { - this.server?.on("listening", () => { - this.logger.info(`Listening on port ${this.config.port}`); - resolve(); - }); - }); - } - - public async stop(): Promise { - if (!this.server) return; - - await new Promise((resolve, reject) => { - this.server?.close((err) => { - if (err) return reject(err); - this.logger.info(`Closing API server on port ${this.config.port}`); - this.server = undefined; - resolve(); - }); - }); - } -} diff --git a/postman/src/application/postman/api/__tests__/Api.test.ts b/postman/src/application/postman/api/__tests__/Api.test.ts deleted file mode 100644 index f4cc343739..0000000000 --- a/postman/src/application/postman/api/__tests__/Api.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Registry } from "prom-client"; -import { mock } from "jest-mock-extended"; -import { Api } from "../Api"; -import { IMetricsService } from "../../../../core/metrics/IMetricsService"; -import { ILogger } from "../../../../core/utils/logging/ILogger"; - -describe("Api", () => { - let api: Api; - const mockConfig = { port: 3000 }; - const mockMetricService = mock(); - const mockLogger = mock(); - - beforeEach(async () => { - mockMetricService.getRegistry.mockReturnValue({ - contentType: "text/plain; version=0.0.4; charset=utf-8", - metrics: async () => "mocked metrics", - } as Registry); - api = new Api(mockConfig, mockMetricService, mockLogger); - }); - - afterEach(async () => { - await api.stop(); - }); - - it("should initialize the API", () => { - expect(api).toBeDefined(); - }); - - it("should return metrics from the metric service", async () => { - await api.start(); - - const registry = api["metricsService"].getRegistry(); - expect(registry.contentType).toBe("text/plain; version=0.0.4; charset=utf-8"); - expect(await registry.metrics()).toBe("mocked metrics"); - }); - - it("should start the server", async () => { - await api.start(); - expect(api["server"]).toBeDefined(); - }); - - it("should stop the server", async () => { - await api.start(); - await api.stop(); - expect(api["server"]).toBeUndefined(); - }); -}); diff --git a/postman/src/application/postman/api/metrics/MessageMetricsUpdater.ts b/postman/src/application/postman/api/metrics/MessageMetricsUpdater.ts index 9f3bdf6b14..a880ffdd68 100644 --- a/postman/src/application/postman/api/metrics/MessageMetricsUpdater.ts +++ b/postman/src/application/postman/api/metrics/MessageMetricsUpdater.ts @@ -1,18 +1,14 @@ import { EntityManager } from "typeorm"; import { MessageEntity } from "../../persistence/entities/Message.entity"; -import { - IMetricsService, - IMessageMetricsUpdater, - LineaPostmanMetrics, - MessagesMetricsAttributes, -} from "../../../../core/metrics"; +import { IMessageMetricsUpdater, LineaPostmanMetrics, MessagesMetricsAttributes } from "../../../../core/metrics"; import { Direction } from "@consensys/linea-sdk"; import { MessageStatus } from "../../../../core/enums"; +import { IMetricsService } from "@consensys/linea-shared-utils"; export class MessageMetricsUpdater implements IMessageMetricsUpdater { constructor( private readonly entityManager: EntityManager, - private readonly metricsService: IMetricsService, + private readonly metricsService: IMetricsService, ) { this.metricsService.createGauge( LineaPostmanMetrics.Messages, diff --git a/postman/src/application/postman/api/metrics/PostmanMetricsService.ts b/postman/src/application/postman/api/metrics/PostmanMetricsService.ts new file mode 100644 index 0000000000..c9938865d8 --- /dev/null +++ b/postman/src/application/postman/api/metrics/PostmanMetricsService.ts @@ -0,0 +1,8 @@ +import { LineaPostmanMetrics } from "../../../../core/metrics/LineaPostmanMetrics"; +import { SingletonMetricsService } from "@consensys/linea-shared-utils"; + +export class PostmanMetricsService extends SingletonMetricsService { + constructor(defaultLabels: Record = { app: "postman" }) { + super(defaultLabels); + } +} diff --git a/postman/src/application/postman/api/metrics/SponsorshipMetricsUpdater.ts b/postman/src/application/postman/api/metrics/SponsorshipMetricsUpdater.ts index 467dc5cc58..f2bc40ace0 100644 --- a/postman/src/application/postman/api/metrics/SponsorshipMetricsUpdater.ts +++ b/postman/src/application/postman/api/metrics/SponsorshipMetricsUpdater.ts @@ -1,8 +1,9 @@ -import { IMetricsService, ISponsorshipMetricsUpdater, LineaPostmanMetrics } from "../../../../core/metrics"; +import { ISponsorshipMetricsUpdater, LineaPostmanMetrics } from "../../../../core/metrics"; import { Direction } from "@consensys/linea-sdk"; +import { IMetricsService } from "@consensys/linea-shared-utils"; export class SponsorshipMetricsUpdater implements ISponsorshipMetricsUpdater { - constructor(private readonly metricsService: IMetricsService) { + constructor(private readonly metricsService: IMetricsService) { this.metricsService.createCounter( LineaPostmanMetrics.SponsorshipFeesGwei, "Gwei component of tx fees paid for sponsored messages by direction", diff --git a/postman/src/application/postman/api/metrics/TransactionMetricsUpdater.ts b/postman/src/application/postman/api/metrics/TransactionMetricsUpdater.ts index 4af58f5ec7..b520b6958b 100644 --- a/postman/src/application/postman/api/metrics/TransactionMetricsUpdater.ts +++ b/postman/src/application/postman/api/metrics/TransactionMetricsUpdater.ts @@ -1,7 +1,8 @@ -import { IMetricsService, LineaPostmanMetrics, ITransactionMetricsUpdater } from "../../../../core/metrics"; +import { LineaPostmanMetrics, ITransactionMetricsUpdater } from "../../../../core/metrics"; +import { IMetricsService } from "@consensys/linea-shared-utils"; export class TransactionMetricsUpdater implements ITransactionMetricsUpdater { - constructor(private readonly metricsService: IMetricsService) { + constructor(private readonly metricsService: IMetricsService) { this.metricsService.createHistogram( LineaPostmanMetrics.TransactionProcessingTime, [0.1, 0.5, 1, 2, 3, 5, 7, 10], diff --git a/postman/src/application/postman/api/metrics/__tests__/MessageMetricsUpdater.test.ts b/postman/src/application/postman/api/metrics/__tests__/MessageMetricsUpdater.test.ts index 83de0e6813..7540c39d2c 100644 --- a/postman/src/application/postman/api/metrics/__tests__/MessageMetricsUpdater.test.ts +++ b/postman/src/application/postman/api/metrics/__tests__/MessageMetricsUpdater.test.ts @@ -3,7 +3,7 @@ import { Direction } from "@consensys/linea-sdk"; import { MessageMetricsUpdater } from "../MessageMetricsUpdater"; import { mock, MockProxy } from "jest-mock-extended"; import { MessageStatus } from "../../../../../core/enums"; -import { SingletonMetricsService } from "../SingletonMetricsService"; +import { PostmanMetricsService } from "../PostmanMetricsService"; import { IMessageMetricsUpdater } from "../../../../../core/metrics"; describe("MessageMetricsUpdater", () => { @@ -12,7 +12,7 @@ describe("MessageMetricsUpdater", () => { beforeEach(() => { mockEntityManager = mock(); - const metricService = new SingletonMetricsService(); + const metricService = new PostmanMetricsService(); messageMetricsUpdater = new MessageMetricsUpdater(mockEntityManager, metricService); }); diff --git a/postman/src/application/postman/api/metrics/__tests__/SingletonMetricsService.test.ts b/postman/src/application/postman/api/metrics/__tests__/SingletonMetricsService.test.ts deleted file mode 100644 index f18a16e312..0000000000 --- a/postman/src/application/postman/api/metrics/__tests__/SingletonMetricsService.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Counter, Gauge, Histogram } from "prom-client"; -import { IMetricsService, LineaPostmanMetrics } from "../../../../../core/metrics/IMetricsService"; -import { SingletonMetricsService } from "../SingletonMetricsService"; - -describe("SingletonMetricsService", () => { - let metricService: IMetricsService; - - beforeEach(() => { - metricService = new SingletonMetricsService(); - }); - - it("should create a counter", () => { - const counter = metricService.createCounter(LineaPostmanMetrics.Messages, "A test counter"); - expect(counter).toBeInstanceOf(Counter); - }); - - it("should increment a counter", async () => { - const counter = metricService.createCounter(LineaPostmanMetrics.Messages, "A test counter"); - metricService.incrementCounter(LineaPostmanMetrics.Messages, {}, 1); - expect((await counter.get()).values[0].value).toBe(1); - }); - - it("should create a gauge", () => { - const gauge = metricService.createGauge(LineaPostmanMetrics.Messages, "A test gauge"); - expect(gauge).toBeInstanceOf(Gauge); - }); - - it("should increment a gauge", async () => { - const gauge = metricService.createGauge(LineaPostmanMetrics.Messages, "A test gauge"); - metricService.incrementGauge(LineaPostmanMetrics.Messages, {}, 5); - expect((await gauge.get()).values[0].value).toBe(5); - }); - - it("should decrement a gauge", async () => { - metricService.createGauge(LineaPostmanMetrics.Messages, "A test gauge"); - metricService.incrementGauge(LineaPostmanMetrics.Messages, {}, 5); - metricService.decrementGauge(LineaPostmanMetrics.Messages, {}, 2); - expect(await metricService.getGaugeValue(LineaPostmanMetrics.Messages, {})).toBe(3); - }); - - it("should return the correct counter value", async () => { - metricService.createCounter(LineaPostmanMetrics.Messages, "A test counter"); - metricService.incrementCounter(LineaPostmanMetrics.Messages, {}, 5); - const counterValue = await metricService.getCounterValue(LineaPostmanMetrics.Messages, {}); - expect(counterValue).toBe(5); - }); - - it("should return the correct gauge value", async () => { - metricService.createGauge(LineaPostmanMetrics.Messages, "A test gauge"); - metricService.incrementGauge(LineaPostmanMetrics.Messages, {}, 10); - const gaugeValue = await metricService.getGaugeValue(LineaPostmanMetrics.Messages, {}); - expect(gaugeValue).toBe(10); - }); - - it("should create a histogram and add values", async () => { - const histogram = metricService.createHistogram( - LineaPostmanMetrics.TransactionProcessingTime, - [0.1, 0.5, 1, 2, 3, 5], - "A test histogram", - ); - expect(histogram).toBeInstanceOf(Histogram); - }); - - it("should add values to histogram and retrieve them", async () => { - metricService.createHistogram( - LineaPostmanMetrics.TransactionProcessingTime, - [0.1, 0.5, 1, 2, 3, 5], - "A test histogram", - ); - metricService.addValueToHistogram(LineaPostmanMetrics.TransactionProcessingTime, 0.3); - metricService.addValueToHistogram(LineaPostmanMetrics.TransactionProcessingTime, 1.5); - const histogramValues = await metricService.getHistogramMetricsValues( - LineaPostmanMetrics.TransactionProcessingTime, - ); - expect(histogramValues?.values.length).toBe(9); - }); -}); diff --git a/postman/src/application/postman/api/metrics/__tests__/SponsorshipMetricsUpdater.test.ts b/postman/src/application/postman/api/metrics/__tests__/SponsorshipMetricsUpdater.test.ts index 0709418dd6..cc7565c86a 100644 --- a/postman/src/application/postman/api/metrics/__tests__/SponsorshipMetricsUpdater.test.ts +++ b/postman/src/application/postman/api/metrics/__tests__/SponsorshipMetricsUpdater.test.ts @@ -1,13 +1,13 @@ import { Direction } from "@consensys/linea-sdk"; import { SponsorshipMetricsUpdater } from "../SponsorshipMetricsUpdater"; -import { SingletonMetricsService } from "../SingletonMetricsService"; +import { PostmanMetricsService } from "../PostmanMetricsService"; import { ISponsorshipMetricsUpdater } from "../../../../../core/metrics"; describe("SponsorshipMetricsUpdater", () => { let sponsorshipMetricsUpdater: ISponsorshipMetricsUpdater; beforeEach(() => { - const metricService = new SingletonMetricsService(); + const metricService = new PostmanMetricsService(); sponsorshipMetricsUpdater = new SponsorshipMetricsUpdater(metricService); }); diff --git a/postman/src/application/postman/api/metrics/__tests__/TransactionMetricsUpdater.test.ts b/postman/src/application/postman/api/metrics/__tests__/TransactionMetricsUpdater.test.ts index 5b79c6cf42..1f5512c5c5 100644 --- a/postman/src/application/postman/api/metrics/__tests__/TransactionMetricsUpdater.test.ts +++ b/postman/src/application/postman/api/metrics/__tests__/TransactionMetricsUpdater.test.ts @@ -1,13 +1,14 @@ -import { SingletonMetricsService } from "../SingletonMetricsService"; +import { PostmanMetricsService } from "../PostmanMetricsService"; import { ITransactionMetricsUpdater, LineaPostmanMetrics } from "../../../../../core/metrics"; import { TransactionMetricsUpdater } from "../TransactionMetricsUpdater"; +import { IMetricsService } from "@consensys/linea-shared-utils"; describe("TransactionMetricsUpdater", () => { let transactionMetricsUpdater: ITransactionMetricsUpdater; - let metricsService: SingletonMetricsService; + let metricsService: IMetricsService; beforeEach(() => { - metricsService = new SingletonMetricsService(); + metricsService = new PostmanMetricsService(); transactionMetricsUpdater = new TransactionMetricsUpdater(metricsService); }); diff --git a/postman/src/application/postman/app/PostmanServiceClient.ts b/postman/src/application/postman/app/PostmanServiceClient.ts index 9da5239b38..6ab11beebc 100644 --- a/postman/src/application/postman/app/PostmanServiceClient.ts +++ b/postman/src/application/postman/app/PostmanServiceClient.ts @@ -1,8 +1,7 @@ import { DataSource } from "typeorm"; import { LineaSDK, Direction } from "@consensys/linea-sdk"; -import { ILogger } from "../../../core/utils/logging/ILogger"; +import { ExpressApiApplication, ILogger } from "@consensys/linea-shared-utils"; import { TypeOrmMessageRepository } from "../persistence/repositories/TypeOrmMessageRepository"; -import { WinstonLogger } from "../../../utils/WinstonLogger"; import { IPoller } from "../../../core/services/pollers/IPoller"; import { MessageAnchoringProcessor, @@ -26,22 +25,23 @@ import { L2ClaimTransactionSizeCalculator } from "../../../services/L2ClaimTrans import { LineaTransactionValidationService } from "../../../services/LineaTransactionValidationService"; import { EthereumTransactionValidationService } from "../../../services/EthereumTransactionValidationService"; import { getConfig } from "./config/utils"; -import { Api } from "../api/Api"; import { MessageStatusSubscriber } from "../persistence/subscribers/MessageStatusSubscriber"; -import { SingletonMetricsService } from "../api/metrics/SingletonMetricsService"; +import { PostmanWinstonLogger } from "../../../utils/PostmanWinstonLogger"; +import { PostmanMetricsService } from "../api/metrics/PostmanMetricsService"; import { MessageMetricsUpdater } from "../api/metrics/MessageMetricsUpdater"; import { IMessageMetricsUpdater, - IMetricsService, ISponsorshipMetricsUpdater, ITransactionMetricsUpdater, + LineaPostmanMetrics, } from "../../../../src/core/metrics"; import { SponsorshipMetricsUpdater } from "../api/metrics/SponsorshipMetricsUpdater"; import { TransactionMetricsUpdater } from "../api/metrics/TransactionMetricsUpdater"; +import { IMetricsService, IApplication } from "@consensys/linea-shared-utils"; export class PostmanServiceClient { // Metrics services - private singletonMetricsService: IMetricsService; + private postmanMetricsService: IMetricsService; private messageMetricsUpdater: IMessageMetricsUpdater; private sponsorshipMetricsUpdater: ISponsorshipMetricsUpdater; private transactionMetricsUpdater: ITransactionMetricsUpdater; @@ -67,7 +67,7 @@ export class PostmanServiceClient { private l1L2AutoClaimEnabled: boolean; private l2L1AutoClaimEnabled: boolean; - private api: Api; + private api: IApplication; private config: PostmanConfig; /** @@ -79,7 +79,7 @@ export class PostmanServiceClient { const config = getConfig(options); this.config = config; - this.logger = new WinstonLogger(PostmanServiceClient.name, config.loggerOptions); + this.logger = new PostmanWinstonLogger(PostmanServiceClient.name, config.loggerOptions); this.l1L2AutoClaimEnabled = config.l1L2AutoClaimEnabled; this.l2L1AutoClaimEnabled = config.l2L1AutoClaimEnabled; @@ -130,10 +130,10 @@ export class PostmanServiceClient { const ethereumMessageDBService = new EthereumMessageDBService(l1GasProvider, messageRepository); // Metrics services - this.singletonMetricsService = new SingletonMetricsService(); - this.messageMetricsUpdater = new MessageMetricsUpdater(this.db.manager, this.singletonMetricsService); - this.sponsorshipMetricsUpdater = new SponsorshipMetricsUpdater(this.singletonMetricsService); - this.transactionMetricsUpdater = new TransactionMetricsUpdater(this.singletonMetricsService); + this.postmanMetricsService = new PostmanMetricsService(); + this.messageMetricsUpdater = new MessageMetricsUpdater(this.db.manager, this.postmanMetricsService); + this.sponsorshipMetricsUpdater = new SponsorshipMetricsUpdater(this.postmanMetricsService); + this.transactionMetricsUpdater = new TransactionMetricsUpdater(this.postmanMetricsService); // L1 -> L2 flow @@ -149,7 +149,7 @@ export class PostmanServiceClient { isCalldataEnabled: config.l1Config.isCalldataEnabled, eventFilters: config.l1Config.listener.eventFilters, }, - new WinstonLogger(`L1${MessageSentEventProcessor.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessageSentEventProcessor.name}`, config.loggerOptions), ); this.l1MessageSentEventPoller = new MessageSentEventPoller( @@ -162,7 +162,7 @@ export class PostmanServiceClient { initialFromBlock: config.l1Config.listener.initialFromBlock, originContractAddress: config.l1Config.messageServiceContractAddress, }, - new WinstonLogger(`L1${MessageSentEventPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessageSentEventPoller.name}`, config.loggerOptions), ); const l2MessageAnchoringProcessor = new MessageAnchoringProcessor( @@ -173,7 +173,7 @@ export class PostmanServiceClient { maxFetchMessagesFromDb: config.l1Config.listener.maxFetchMessagesFromDb, originContractAddress: config.l1Config.messageServiceContractAddress, }, - new WinstonLogger(`L2${MessageAnchoringProcessor.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessageAnchoringProcessor.name}`, config.loggerOptions), ); this.l2MessageAnchoringPoller = new MessageAnchoringPoller( @@ -182,7 +182,7 @@ export class PostmanServiceClient { direction: Direction.L1_TO_L2, pollingInterval: config.l2Config.listener.pollingInterval, }, - new WinstonLogger(`L2${MessageAnchoringPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessageAnchoringPoller.name}`, config.loggerOptions), ); const l2TransactionValidationService = new LineaTransactionValidationService( @@ -212,7 +212,7 @@ export class PostmanServiceClient { maxClaimGasLimit: BigInt(config.l2Config.claiming.maxClaimGasLimit), claimViaAddress: config.l2Config.claiming.claimViaAddress, }, - new WinstonLogger(`L2${MessageClaimingProcessor.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessageClaimingProcessor.name}`, config.loggerOptions), ); this.l2MessageClaimingPoller = new MessageClaimingPoller( @@ -221,7 +221,7 @@ export class PostmanServiceClient { direction: Direction.L1_TO_L2, pollingInterval: config.l2Config.listener.pollingInterval, }, - new WinstonLogger(`L2${MessageClaimingPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessageClaimingPoller.name}`, config.loggerOptions), ); const l2MessageClaimingPersister = new MessageClaimingPersister( @@ -235,7 +235,7 @@ export class PostmanServiceClient { messageSubmissionTimeout: config.l2Config.claiming.messageSubmissionTimeout, maxTxRetries: config.l2Config.claiming.maxTxRetries, }, - new WinstonLogger(`L2${MessageClaimingPersister.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessageClaimingPersister.name}`, config.loggerOptions), ); this.l2MessagePersistingPoller = new MessagePersistingPoller( @@ -244,7 +244,7 @@ export class PostmanServiceClient { direction: Direction.L1_TO_L2, pollingInterval: config.l2Config.listener.receiptPollingInterval, }, - new WinstonLogger(`L2${MessagePersistingPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessagePersistingPoller.name}`, config.loggerOptions), ); const transactionSizeCalculator = new L2ClaimTransactionSizeCalculator(l2MessageServiceClient); @@ -256,7 +256,7 @@ export class PostmanServiceClient { direction: Direction.L1_TO_L2, originContractAddress: config.l1Config.messageServiceContractAddress, }, - new WinstonLogger(`${L2ClaimMessageTransactionSizeProcessor.name}`, config.loggerOptions), + new PostmanWinstonLogger(`${L2ClaimMessageTransactionSizeProcessor.name}`, config.loggerOptions), ); this.l2ClaimMessageTransactionSizePoller = new L2ClaimMessageTransactionSizePoller( @@ -264,7 +264,7 @@ export class PostmanServiceClient { { pollingInterval: config.l2Config.listener.pollingInterval, }, - new WinstonLogger(`${L2ClaimMessageTransactionSizePoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`${L2ClaimMessageTransactionSizePoller.name}`, config.loggerOptions), ); // L2 -> L1 flow @@ -280,7 +280,7 @@ export class PostmanServiceClient { isCalldataEnabled: config.l2Config.isCalldataEnabled, eventFilters: config.l2Config.listener.eventFilters, }, - new WinstonLogger(`L2${MessageSentEventProcessor.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessageSentEventProcessor.name}`, config.loggerOptions), ); this.l2MessageSentEventPoller = new MessageSentEventPoller( @@ -293,7 +293,7 @@ export class PostmanServiceClient { initialFromBlock: config.l2Config.listener.initialFromBlock, originContractAddress: config.l2Config.messageServiceContractAddress, }, - new WinstonLogger(`L2${MessageSentEventPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L2${MessageSentEventPoller.name}`, config.loggerOptions), ); const l1MessageAnchoringProcessor = new MessageAnchoringProcessor( @@ -304,7 +304,7 @@ export class PostmanServiceClient { maxFetchMessagesFromDb: config.l1Config.listener.maxFetchMessagesFromDb, originContractAddress: config.l2Config.messageServiceContractAddress, }, - new WinstonLogger(`L1${MessageAnchoringProcessor.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessageAnchoringProcessor.name}`, config.loggerOptions), ); this.l1MessageAnchoringPoller = new MessageAnchoringPoller( @@ -313,7 +313,7 @@ export class PostmanServiceClient { direction: Direction.L2_TO_L1, pollingInterval: config.l1Config.listener.pollingInterval, }, - new WinstonLogger(`L1${MessageAnchoringPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessageAnchoringPoller.name}`, config.loggerOptions), ); const l1TransactionValidationService = new EthereumTransactionValidationService(lineaRollupClient, l1GasProvider, { @@ -339,7 +339,7 @@ export class PostmanServiceClient { maxClaimGasLimit: BigInt(config.l1Config.claiming.maxClaimGasLimit), claimViaAddress: config.l1Config.claiming.claimViaAddress, }, - new WinstonLogger(`L1${MessageClaimingProcessor.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessageClaimingProcessor.name}`, config.loggerOptions), ); this.l1MessageClaimingPoller = new MessageClaimingPoller( @@ -348,7 +348,7 @@ export class PostmanServiceClient { direction: Direction.L2_TO_L1, pollingInterval: config.l1Config.listener.pollingInterval, }, - new WinstonLogger(`L1${MessageClaimingPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessageClaimingPoller.name}`, config.loggerOptions), ); const l1MessageClaimingPersister = new MessageClaimingPersister( @@ -362,7 +362,7 @@ export class PostmanServiceClient { messageSubmissionTimeout: config.l1Config.claiming.messageSubmissionTimeout, maxTxRetries: config.l1Config.claiming.maxTxRetries, }, - new WinstonLogger(`L1${MessageClaimingPersister.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessageClaimingPersister.name}`, config.loggerOptions), ); this.l1MessagePersistingPoller = new MessagePersistingPoller( @@ -371,18 +371,18 @@ export class PostmanServiceClient { direction: Direction.L2_TO_L1, pollingInterval: config.l1Config.listener.receiptPollingInterval, }, - new WinstonLogger(`L1${MessagePersistingPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`L1${MessagePersistingPoller.name}`, config.loggerOptions), ); // Database Cleaner const databaseCleaner = new DatabaseCleaner( ethereumMessageDBService, - new WinstonLogger(`${DatabaseCleaner.name}`, config.loggerOptions), + new PostmanWinstonLogger(`${DatabaseCleaner.name}`, config.loggerOptions), ); this.databaseCleaningPoller = new DatabaseCleaningPoller( databaseCleaner, - new WinstonLogger(`${DatabaseCleaningPoller.name}`, config.loggerOptions), + new PostmanWinstonLogger(`${DatabaseCleaningPoller.name}`, config.loggerOptions), { enabled: config.databaseCleanerConfig.enabled, daysBeforeNowToDelete: config.databaseCleanerConfig.daysBeforeNowToDelete, @@ -415,15 +415,15 @@ export class PostmanServiceClient { await this.messageMetricsUpdater.initialize(); const messageStatusSubscriber = new MessageStatusSubscriber( this.messageMetricsUpdater, - new WinstonLogger(MessageStatusSubscriber.name), + new PostmanWinstonLogger(MessageStatusSubscriber.name), ); this.db.subscribers.push(messageStatusSubscriber); // Initialize or reinitialize the API using the metrics service. - this.api = new Api( - { port: this.config.apiConfig.port }, - this.singletonMetricsService, - new WinstonLogger(Api.name), + this.api = new ExpressApiApplication( + this.config.apiConfig.port, + this.postmanMetricsService, + new PostmanWinstonLogger(ExpressApiApplication.name), ); this.logger.info("Metrics and API have been initialized successfully."); diff --git a/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts b/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts index c6c55ec8ac..642a337c72 100644 --- a/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts +++ b/postman/src/application/postman/app/__tests__/PostmanServiceClient.test.ts @@ -10,7 +10,7 @@ import { TEST_L2_SIGNER_PRIVATE_KEY, TEST_RPC_URL, } from "../../../../utils/testing/constants"; -import { WinstonLogger } from "../../../../utils/WinstonLogger"; +import { PostmanWinstonLogger } from "../../../../utils/PostmanWinstonLogger"; import { PostmanOptions } from "../config/config"; import { MessageEntity } from "../../persistence/entities/Message.entity"; import { InitialDatabaseSetup1685985945638 } from "../../persistence/migrations/1685985945638-InitialDatabaseSetup"; @@ -28,7 +28,7 @@ import { L2ClaimMessageTransactionSizePoller } from "../../../../services/poller import { DEFAULT_MAX_CLAIM_GAS_LIMIT } from "../../../../core/constants"; import { MessageStatusSubscriber } from "../../persistence/subscribers/MessageStatusSubscriber"; import { MessageMetricsUpdater } from "../../api/metrics/MessageMetricsUpdater"; -import { Api } from "../../api/Api"; +import { ExpressApiApplication } from "@consensys/linea-shared-utils"; jest.mock("ethers", () => { const allAutoMocked = jest.createMockFromModule("ethers"); @@ -111,7 +111,7 @@ describe("PostmanServiceClient", () => { beforeEach(() => { postmanServiceClient = new PostmanServiceClient(postmanServiceClientOptions); - loggerSpy = jest.spyOn(WinstonLogger.prototype, "info"); + loggerSpy = jest.spyOn(PostmanWinstonLogger.prototype, "info"); }); afterEach(() => { @@ -215,7 +215,7 @@ describe("PostmanServiceClient", () => { jest.spyOn(MessagePersistingPoller.prototype, "start").mockImplementationOnce(jest.fn()); jest.spyOn(DatabaseCleaningPoller.prototype, "start").mockImplementationOnce(jest.fn()); jest.spyOn(TypeOrmMessageRepository.prototype, "getLatestMessageSent").mockImplementationOnce(jest.fn()); - jest.spyOn(Api.prototype, "start").mockImplementationOnce(jest.fn()); + jest.spyOn(ExpressApiApplication.prototype, "start").mockImplementationOnce(jest.fn()); jest.spyOn(MessageMetricsUpdater.prototype, "initialize").mockResolvedValueOnce(); await postmanServiceClient.initializeMetricsAndApi(); @@ -235,7 +235,7 @@ describe("PostmanServiceClient", () => { jest.spyOn(L2ClaimMessageTransactionSizePoller.prototype, "stop").mockImplementationOnce(jest.fn()); jest.spyOn(MessagePersistingPoller.prototype, "stop").mockImplementationOnce(jest.fn()); jest.spyOn(DatabaseCleaningPoller.prototype, "stop").mockImplementationOnce(jest.fn()); - jest.spyOn(Api.prototype, "stop").mockImplementationOnce(jest.fn()); + jest.spyOn(ExpressApiApplication.prototype, "stop").mockImplementationOnce(jest.fn()); jest.spyOn(MessageMetricsUpdater.prototype, "initialize").mockResolvedValueOnce(); await postmanServiceClient.initializeMetricsAndApi(); diff --git a/postman/src/application/postman/persistence/subscribers/MessageStatusSubscriber.ts b/postman/src/application/postman/persistence/subscribers/MessageStatusSubscriber.ts index bc6096c819..068eea7440 100644 --- a/postman/src/application/postman/persistence/subscribers/MessageStatusSubscriber.ts +++ b/postman/src/application/postman/persistence/subscribers/MessageStatusSubscriber.ts @@ -9,7 +9,7 @@ import { import { Direction } from "@consensys/linea-sdk"; import { MessageEntity } from "../entities/Message.entity"; import { type IMessageMetricsUpdater, MessagesMetricsAttributes } from "../../../../core/metrics"; -import { type ILogger } from "../../../../core/utils/logging/ILogger"; +import { type ILogger } from "@consensys/linea-shared-utils"; import { MessageStatus } from "../../../../core/enums"; @EventSubscriber() diff --git a/postman/src/core/metrics/IMetricsService.ts b/postman/src/core/metrics/LineaPostmanMetrics.ts similarity index 57% rename from postman/src/core/metrics/IMetricsService.ts rename to postman/src/core/metrics/LineaPostmanMetrics.ts index acac9f506d..faa679ffe5 100644 --- a/postman/src/core/metrics/IMetricsService.ts +++ b/postman/src/core/metrics/LineaPostmanMetrics.ts @@ -1,5 +1,3 @@ -import { Counter, Gauge, Histogram, MetricObjectWithValues, MetricValueWithName, Registry } from "prom-client"; - export enum LineaPostmanMetrics { Messages = "linea_postman_messages", // Example PromQL query for hourly rate of sponsored messages 'rate(linea_postman_sponsored_messages_total{direction="L1_TO_L2",app="postman"}[60m]) * 3600' @@ -23,19 +21,3 @@ export enum LineaPostmanMetrics { TransactionProcessingTime = "linea_postman_l2_transaction_tx_processing_time", TransactionInfuraConfirmationTime = "linea_postman_l2_transaction_tx_infura_confirmation_time", } - -export interface IMetricsService { - getRegistry(): Registry; - createCounter(name: LineaPostmanMetrics, help: string, labelNames?: string[]): Counter; - createGauge(name: LineaPostmanMetrics, help: string, labelNames?: string[]): Gauge; - incrementCounter(name: LineaPostmanMetrics, labels?: Record, value?: number): void; - incrementGauge(name: LineaPostmanMetrics, labels?: Record, value?: number): void; - decrementGauge(name: LineaPostmanMetrics, labels?: Record, value?: number): void; - getGaugeValue(name: LineaPostmanMetrics, labels: Record): Promise; - getCounterValue(name: LineaPostmanMetrics, labels: Record): Promise; - createHistogram(name: LineaPostmanMetrics, buckets: number[], help: string, labelNames?: string[]): Histogram; - addValueToHistogram(name: LineaPostmanMetrics, value: number, labels?: Record): void; - getHistogramMetricsValues( - name: LineaPostmanMetrics, - ): Promise> | undefined>; -} diff --git a/postman/src/core/metrics/index.ts b/postman/src/core/metrics/index.ts index 5aa4c07b37..c1ec8a80f3 100644 --- a/postman/src/core/metrics/index.ts +++ b/postman/src/core/metrics/index.ts @@ -1,5 +1,5 @@ export * from "./IMessageMetricsUpdater"; -export * from "./IMetricsService"; +export * from "./LineaPostmanMetrics"; export * from "./ISponsorshipMetricsUpdater"; export * from "./MessageMetricsAttributes"; export * from "./ITransactionMetricsUpdater"; diff --git a/postman/src/services/persistence/DatabaseCleaner.ts b/postman/src/services/persistence/DatabaseCleaner.ts index 294d1fc3ed..800bbd3784 100644 --- a/postman/src/services/persistence/DatabaseCleaner.ts +++ b/postman/src/services/persistence/DatabaseCleaner.ts @@ -1,7 +1,7 @@ import { ContractTransactionResponse } from "ethers"; import { IMessageDBService } from "../../core/persistence/IMessageDBService"; import { IDatabaseCleaner } from "../../core/persistence/IDatabaseCleaner"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; export class DatabaseCleaner implements IDatabaseCleaner { /** diff --git a/postman/src/services/persistence/__tests__/DatabaseCleaner.test.ts b/postman/src/services/persistence/__tests__/DatabaseCleaner.test.ts index eed5391bda..883e4a693e 100644 --- a/postman/src/services/persistence/__tests__/DatabaseCleaner.test.ts +++ b/postman/src/services/persistence/__tests__/DatabaseCleaner.test.ts @@ -2,7 +2,7 @@ import { describe, it, beforeEach } from "@jest/globals"; import { mock } from "jest-mock-extended"; import { ContractTransactionResponse } from "ethers"; import { DatabaseCleaner } from "../DatabaseCleaner"; -import { ILogger } from "../../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { DatabaseAccessError } from "../../../core/errors/DatabaseErrors"; import { DatabaseErrorType, DatabaseRepoName } from "../../../core/enums"; import { IMessageDBService } from "../../../core/persistence/IMessageDBService"; diff --git a/postman/src/services/pollers/DatabaseCleaningPoller.ts b/postman/src/services/pollers/DatabaseCleaningPoller.ts index 33ba38ddb4..0833b210d9 100644 --- a/postman/src/services/pollers/DatabaseCleaningPoller.ts +++ b/postman/src/services/pollers/DatabaseCleaningPoller.ts @@ -1,5 +1,5 @@ import { wait } from "@consensys/linea-sdk"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IPoller } from "../../core/services/pollers/IPoller"; import { IDatabaseCleaner } from "../../core/persistence/IDatabaseCleaner"; import { DBCleanerConfig } from "../../application/postman/persistence/config/types"; diff --git a/postman/src/services/pollers/L2ClaimMessageTransactionSizePoller.ts b/postman/src/services/pollers/L2ClaimMessageTransactionSizePoller.ts index cfa059c9ba..256c60ee61 100644 --- a/postman/src/services/pollers/L2ClaimMessageTransactionSizePoller.ts +++ b/postman/src/services/pollers/L2ClaimMessageTransactionSizePoller.ts @@ -1,5 +1,5 @@ import { Direction, wait } from "@consensys/linea-sdk"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IPoller } from "../../core/services/pollers/IPoller"; import { L2ClaimMessageTransactionSizeProcessor } from "../processors/L2ClaimMessageTransactionSizeProcessor"; diff --git a/postman/src/services/pollers/MessageAnchoringPoller.ts b/postman/src/services/pollers/MessageAnchoringPoller.ts index 2dec7632fb..bb493273ac 100644 --- a/postman/src/services/pollers/MessageAnchoringPoller.ts +++ b/postman/src/services/pollers/MessageAnchoringPoller.ts @@ -1,5 +1,5 @@ import { Direction, wait } from "@consensys/linea-sdk"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IPoller } from "../../core/services/pollers/IPoller"; import { IMessageAnchoringProcessor } from "../../core/services/processors/IMessageAnchoringProcessor"; diff --git a/postman/src/services/pollers/MessageClaimingPoller.ts b/postman/src/services/pollers/MessageClaimingPoller.ts index d95c1bef57..412c715b45 100644 --- a/postman/src/services/pollers/MessageClaimingPoller.ts +++ b/postman/src/services/pollers/MessageClaimingPoller.ts @@ -1,5 +1,5 @@ import { Direction, wait } from "@consensys/linea-sdk"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IPoller } from "../../core/services/pollers/IPoller"; import { IMessageClaimingProcessor } from "../../core/services/processors/IMessageClaimingProcessor"; diff --git a/postman/src/services/pollers/MessagePersistingPoller.ts b/postman/src/services/pollers/MessagePersistingPoller.ts index ea0e170872..5fa632dc1c 100644 --- a/postman/src/services/pollers/MessagePersistingPoller.ts +++ b/postman/src/services/pollers/MessagePersistingPoller.ts @@ -1,5 +1,5 @@ import { Direction, wait } from "@consensys/linea-sdk"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IPoller } from "../../core/services/pollers/IPoller"; import { IMessageClaimingPersister } from "../../core/services/processors/IMessageClaimingPersister"; diff --git a/postman/src/services/pollers/MessageSentEventPoller.ts b/postman/src/services/pollers/MessageSentEventPoller.ts index 60fb339a66..f71a052260 100644 --- a/postman/src/services/pollers/MessageSentEventPoller.ts +++ b/postman/src/services/pollers/MessageSentEventPoller.ts @@ -7,8 +7,8 @@ import { TransactionResponse, } from "ethers"; import { Direction, wait } from "@consensys/linea-sdk"; -import { ILogger } from "../../core/utils/logging/ILogger"; import { DEFAULT_INITIAL_FROM_BLOCK } from "../../core/constants"; +import { IPostmanLogger } from "../../utils/IPostmanLogger"; import { IMessageSentEventProcessor } from "../../core/services/processors/IMessageSentEventProcessor"; import { Message } from "../../core/entities/Message"; import { DatabaseAccessError } from "../../core/errors/DatabaseErrors"; @@ -33,7 +33,7 @@ export class MessageSentEventPoller implements IPoller { * @param {IProvider} provider - An instance of a class implementing the `IProvider` interface, used to query blockchain data. * @param {IMessageDBService} databaseService - An instance of a class implementing the `IMessageDBService` interface, used for storing and retrieving message data. * @param {MessageSentEventPollerConfig} config - Configuration settings for the poller, including the direction of message flow, the polling interval, and the initial block number to start listening from. - * @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages related to the polling process. + * @param {IPostmanLogger} logger - An instance of a class implementing the `IPostmanLogger` interface, used for logging messages related to the polling process. */ constructor( private readonly eventProcessor: IMessageSentEventProcessor, @@ -46,7 +46,7 @@ export class MessageSentEventPoller implements IPoller { >, private readonly databaseService: IMessageDBService, private readonly config: MessageSentEventPollerConfig, - private readonly logger: ILogger, + private readonly logger: IPostmanLogger, ) {} /** diff --git a/postman/src/services/processors/L2ClaimMessageTransactionSizeProcessor.ts b/postman/src/services/processors/L2ClaimMessageTransactionSizeProcessor.ts index 9dcb265d06..f315e94d74 100644 --- a/postman/src/services/processors/L2ClaimMessageTransactionSizeProcessor.ts +++ b/postman/src/services/processors/L2ClaimMessageTransactionSizeProcessor.ts @@ -7,8 +7,8 @@ import { TransactionResponse, } from "ethers"; import { MessageStatus } from "../../core/enums"; -import { ILogger } from "../../core/utils/logging/ILogger"; import { IMessageDBService } from "../../core/persistence/IMessageDBService"; +import { IPostmanLogger } from "../../utils/IPostmanLogger"; import { IL2MessageServiceClient } from "../../core/clients/blockchain/linea/IL2MessageServiceClient"; import { IL2ClaimMessageTransactionSizeProcessor, @@ -26,7 +26,7 @@ export class L2ClaimMessageTransactionSizeProcessor implements IL2ClaimMessageTr * @param {IL2MessageServiceClient} l2MessageServiceClient - The L2 message service client for estimating gas fees. * @param {IL2ClaimTransactionSizeCalculator} transactionSizeCalculator - The calculator for determining the transaction size. * @param {L2ClaimMessageTransactionSizeProcessorConfig} config - Configuration settings for the processor, including the direction and origin contract address. - * @param {ILogger} logger - The logger for logging information and errors. + * @param {IPostmanLogger} logger - The logger for logging information and errors. */ constructor( private readonly databaseService: IMessageDBService, @@ -40,7 +40,7 @@ export class L2ClaimMessageTransactionSizeProcessor implements IL2ClaimMessageTr >, private readonly transactionSizeCalculator: IL2ClaimTransactionSizeCalculator, private readonly config: L2ClaimMessageTransactionSizeProcessorConfig, - private readonly logger: ILogger, + private readonly logger: IPostmanLogger, ) {} /** diff --git a/postman/src/services/processors/MessageAnchoringProcessor.ts b/postman/src/services/processors/MessageAnchoringProcessor.ts index 3367611725..151fa8f759 100644 --- a/postman/src/services/processors/MessageAnchoringProcessor.ts +++ b/postman/src/services/processors/MessageAnchoringProcessor.ts @@ -15,7 +15,7 @@ import { } from "../../core/services/processors/IMessageAnchoringProcessor"; import { IProvider } from "../../core/clients/blockchain/IProvider"; import { MessageStatus } from "../../core/enums"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IMessageServiceContract } from "../../core/services/contracts/IMessageServiceContract"; import { IMessageDBService } from "../../core/persistence/IMessageDBService"; import { ErrorParser } from "../../utils/ErrorParser"; diff --git a/postman/src/services/processors/MessageClaimingPersister.ts b/postman/src/services/processors/MessageClaimingPersister.ts index 1538426fab..d6331efbce 100644 --- a/postman/src/services/processors/MessageClaimingPersister.ts +++ b/postman/src/services/processors/MessageClaimingPersister.ts @@ -11,7 +11,7 @@ import { import { Direction, OnChainMessageStatus } from "@consensys/linea-sdk"; import { BaseError } from "../../core/errors"; import { MessageStatus } from "../../core/enums"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IMessageServiceContract } from "../../core/services/contracts/IMessageServiceContract"; import { IProvider } from "../../core/clients/blockchain/IProvider"; import { Message } from "../../core/entities/Message"; diff --git a/postman/src/services/processors/MessageClaimingProcessor.ts b/postman/src/services/processors/MessageClaimingProcessor.ts index 3711c06475..4fe204de09 100644 --- a/postman/src/services/processors/MessageClaimingProcessor.ts +++ b/postman/src/services/processors/MessageClaimingProcessor.ts @@ -13,8 +13,8 @@ import { IMessageClaimingProcessor, MessageClaimingProcessorConfig, } from "../../core/services/processors/IMessageClaimingProcessor"; -import { ILogger } from "../../core/utils/logging/ILogger"; import { IMessageServiceContract } from "../../core/services/contracts/IMessageServiceContract"; +import { IPostmanLogger } from "../../utils/IPostmanLogger"; import { Message } from "../../core/entities/Message"; import { IMessageDBService } from "../../core/persistence/IMessageDBService"; import { ITransactionValidationService } from "../../core/services/ITransactionValidationService"; @@ -30,7 +30,7 @@ export class MessageClaimingProcessor implements IMessageClaimingProcessor { * @param {IMessageDBService} databaseService - An instance of a class implementing the `IMessageDBService` interface, used for storing and retrieving message data. * @param {ITransactionValidationService} transactionValidationService - An instance of a class implementing the `ITransactionValidationService` interface, used for validating transactions. * @param {MessageClaimingProcessorConfig} config - Configuration for network-specific settings, including transaction submission timeout, maximum transaction retries, and gas limit. - * @param {ILogger} logger - An instance of a class implementing the `ILogger` interface, used for logging messages. + * @param {IPostmanLogger} logger - An instance of a class implementing the `IPostmanLogger` interface, used for logging messages. */ constructor( private readonly messageServiceContract: IMessageServiceContract< @@ -44,7 +44,7 @@ export class MessageClaimingProcessor implements IMessageClaimingProcessor { private readonly databaseService: IMessageDBService, private readonly transactionValidationService: ITransactionValidationService, private readonly config: MessageClaimingProcessorConfig, - private readonly logger: ILogger, + private readonly logger: IPostmanLogger, ) { this.maxNonceDiff = Math.max(config.maxNonceDiff, 0); } diff --git a/postman/src/services/processors/MessageSentEventProcessor.ts b/postman/src/services/processors/MessageSentEventProcessor.ts index 20c35848d3..7ccead6076 100644 --- a/postman/src/services/processors/MessageSentEventProcessor.ts +++ b/postman/src/services/processors/MessageSentEventProcessor.ts @@ -13,7 +13,7 @@ import { serialize, isEmptyBytes, MessageSent } from "@consensys/linea-sdk"; import { ILineaRollupLogClient } from "../../core/clients/blockchain/ethereum/ILineaRollupLogClient"; import { IProvider } from "../../core/clients/blockchain/IProvider"; import { MessageFactory } from "../../core/entities/MessageFactory"; -import { ILogger } from "../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { MessageStatus } from "../../core/enums"; import { IL2MessageServiceLogClient } from "../../core/clients/blockchain/linea/IL2MessageServiceLogClient"; import { diff --git a/postman/src/services/processors/__tests__/MessageSentEventProcessor.test.ts b/postman/src/services/processors/__tests__/MessageSentEventProcessor.test.ts index f3268408f6..c82c2ed1b8 100644 --- a/postman/src/services/processors/__tests__/MessageSentEventProcessor.test.ts +++ b/postman/src/services/processors/__tests__/MessageSentEventProcessor.test.ts @@ -26,7 +26,7 @@ import { } from "ethers"; import { EthereumMessageDBService } from "../../persistence/EthereumMessageDBService"; import { IMessageDBService } from "../../../core/persistence/IMessageDBService"; -import { ILogger } from "../../../core/utils/logging/ILogger"; +import { ILogger } from "@consensys/linea-shared-utils"; import { IL2MessageServiceLogClient } from "../../../core/clients/blockchain/linea/IL2MessageServiceLogClient"; class TestMessageSentEventProcessor extends MessageSentEventProcessor { diff --git a/postman/src/utils/IPostmanLogger.ts b/postman/src/utils/IPostmanLogger.ts new file mode 100644 index 0000000000..0aca1538d5 --- /dev/null +++ b/postman/src/utils/IPostmanLogger.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ILogger } from "@consensys/linea-shared-utils"; + +/** + * Extended logger interface for the postman project. + * Extends the base ILogger interface with the warnOrError method. + */ +export interface IPostmanLogger extends ILogger { + /** + * Decides whether to log a message as a `warning` or an `error` based on its content and severity. + * + * This method is particularly useful for handling errors that may not always require immediate attention or could be retried successfully. + * + * @param {any} message - The primary log message or error object. + * @param {...any[]} params - Additional parameters or metadata to log alongside the message. + */ + warnOrError(message: any, ...params: any[]): void; +} diff --git a/postman/src/utils/PostmanWinstonLogger.ts b/postman/src/utils/PostmanWinstonLogger.ts new file mode 100644 index 0000000000..c098387808 --- /dev/null +++ b/postman/src/utils/PostmanWinstonLogger.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { EthersError } from "ethers"; +import { WinstonLogger } from "@consensys/linea-shared-utils"; +import { IPostmanLogger } from "./IPostmanLogger"; + +export class PostmanWinstonLogger extends WinstonLogger implements IPostmanLogger { + /** + * Decides whether to log a message as a `warning` or an `error` based on its content and severity. + * + * This method is particularly useful for handling errors that may not always require immediate attention or could be retried successfully. + * + * @param {any} message - The primary log message or error object. + * @param {...any[]} params - Additional parameters or metadata to log alongside the message. + */ + public warnOrError(message: any, ...params: any[]): void { + if (this.shouldLogErrorAsWarning(message)) { + this.warn(message, ...params); + } else { + this.error(message, ...params); + } + } + + /** + * Determines whether a given error should be logged as a `warning` instead of an `error`. + * + * This captures the original Postman-specific heuristics for common RPC responses. + */ + protected shouldLogErrorAsWarning(error: unknown): boolean { + const isEthersError = (value: unknown): value is EthersError => { + return (value as EthersError).shortMessage !== undefined || (value as EthersError).code !== undefined; + }; + + if (!isEthersError(error)) { + return false; + } + + return ( + (error.shortMessage?.includes("processing response error") || + error.info?.error?.message?.includes("processing response error")) && + error.code === "SERVER_ERROR" && + error.info?.error?.code === -32603 + ); + } +} diff --git a/postman/src/utils/testing/helpers.ts b/postman/src/utils/testing/helpers.ts index fec0fd0db4..ac3a850bc9 100644 --- a/postman/src/utils/testing/helpers.ts +++ b/postman/src/utils/testing/helpers.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Direction } from "@consensys/linea-sdk"; -import { ILogger } from "../../core/utils/logging/ILogger"; import { TEST_ADDRESS_1, TEST_CONTRACT_ADDRESS_1, TEST_CONTRACT_ADDRESS_2, TEST_MESSAGE_HASH } from "./constants"; import { MessageStatus } from "../../core/enums"; import { Message, MessageProps } from "../../core/entities/Message"; import { MessageEntity } from "../../application/postman/persistence/entities/Message.entity"; +import { IPostmanLogger } from "../IPostmanLogger"; -export class TestLogger implements ILogger { +export class TestLogger implements IPostmanLogger { public readonly name: string; constructor(loggerName: string) { diff --git a/postman/tsconfig.json b/postman/tsconfig.json index 903e94ca4d..7613e59648 100644 --- a/postman/tsconfig.json +++ b/postman/tsconfig.json @@ -10,6 +10,9 @@ { "path": "../ts-libs/linea-native-libs" }, + { + "path": "../ts-libs/linea-shared-utils" + }, { "path": "../sdk/sdk-ethers" } diff --git a/sdk/sdk-core/package.json b/sdk/sdk-core/package.json index e1c006ff9b..6f7684b8c0 100644 --- a/sdk/sdk-core/package.json +++ b/sdk/sdk-core/package.json @@ -28,7 +28,7 @@ "@types/jest": "catalog:", "jest": "catalog:", "ts-jest": "catalog:", - "tsup": "8.5.0", + "tsup": "catalog:", "viem": "catalog:" }, "dependencies": { diff --git a/sdk/sdk-viem/package.json b/sdk/sdk-viem/package.json index b0035491d8..138caf8d23 100644 --- a/sdk/sdk-viem/package.json +++ b/sdk/sdk-viem/package.json @@ -31,7 +31,7 @@ "@types/jest": "catalog:", "jest": "catalog:", "ts-jest": "catalog:", - "tsup": "8.5.0", + "tsup": "catalog:", "viem": "catalog:" }, "peerDependencies": { diff --git a/ts-libs/linea-native-libs/package.json b/ts-libs/linea-native-libs/package.json index 803d2f2e0d..161263d34d 100644 --- a/ts-libs/linea-native-libs/package.json +++ b/ts-libs/linea-native-libs/package.json @@ -35,7 +35,7 @@ "jest": "catalog:", "jest-it-up": "4.0.1", "ts-jest": "catalog:", - "tsup": "8.5.0", + "tsup": "catalog:", "unzipper": "0.12.3", "viem": "2.29.1" }, diff --git a/ts-libs/linea-shared-utils/.eslintignore b/ts-libs/linea-shared-utils/.eslintignore new file mode 100644 index 0000000000..db4c6d9b67 --- /dev/null +++ b/ts-libs/linea-shared-utils/.eslintignore @@ -0,0 +1,2 @@ +dist +node_modules \ No newline at end of file diff --git a/ts-libs/linea-shared-utils/.eslintrc.js b/ts-libs/linea-shared-utils/.eslintrc.js new file mode 100644 index 0000000000..302bd2a817 --- /dev/null +++ b/ts-libs/linea-shared-utils/.eslintrc.js @@ -0,0 +1,15 @@ +module.exports = { + extends: "../../.eslintrc.js", + env: { + commonjs: true, + es2021: true, + node: true, + jest: true, + }, + parserOptions: { + sourceType: "module", + }, + rules: { + "prettier/prettier": "error", + }, +}; diff --git a/ts-libs/linea-shared-utils/.prettierignore b/ts-libs/linea-shared-utils/.prettierignore new file mode 100644 index 0000000000..db4c6d9b67 --- /dev/null +++ b/ts-libs/linea-shared-utils/.prettierignore @@ -0,0 +1,2 @@ +dist +node_modules \ No newline at end of file diff --git a/ts-libs/linea-shared-utils/.prettierrc.js b/ts-libs/linea-shared-utils/.prettierrc.js new file mode 100644 index 0000000000..e6454e14f7 --- /dev/null +++ b/ts-libs/linea-shared-utils/.prettierrc.js @@ -0,0 +1,3 @@ +module.exports = { + ...require('../../.prettierrc.js'), +}; diff --git a/ts-libs/linea-shared-utils/README.md b/ts-libs/linea-shared-utils/README.md new file mode 100644 index 0000000000..a2304a03e2 --- /dev/null +++ b/ts-libs/linea-shared-utils/README.md @@ -0,0 +1,81 @@ +# Linea Shared Utils + +## Overview + +The linea-shared-utils package is a shared TypeScript utilities library for Linea TypeScript projects within the monorepo. This package houses shared TypeScript utilities that don't fit the SDK, which is designed for public external consumers. + +The package may contain some duplication of SDK code to avoid bundling the SDK project in certain monorepo project builds (e.g., linea-native-yield-automation-service). + +## Contents + +- **Blockchain client adapters** - Viem-based adapters for blockchain interactions +- **Contract signing** - Web3Signer and Viem Wallet adapters for transaction signing +- **Beacon chain API client** - API client for Ethereum beacon chain interactions +- **OAuth2 authentication** - OAuth2 token management for authenticated API access +- **Prometheus metrics service** - Metrics collection with singleton pattern +- **Winston logging** - Structured logging implementation +- **Express API application framework** - Pre-configured Express server with metrics endpoint +- **Retry services** - Retry logic (including exponential backoff, and Viem transaction retries) +- **Utility functions** - Pure functions for blockchain conversions, time, math, string operations, and error handling + +## Codebase Architecture + +The codebase follows a **Layered Architecture with Dependency Inversion**, incorporating concepts from Hexagonal Architecture (Ports and Adapters): + +- **`core/`** - Interfaces and constants. This layer has no dependencies on other internal layers. +- **`clients/`** - Infrastructure adapter implementations (blockchain, API clients) that implement interfaces from `core/`. +- **`services/`** - Service implementations for internal business logic that implement interfaces from `core/`. +- **`applications/`** - Accessory applications (e.g. Metrics HTTP endpoint using Express) that implement interfaces from `core/`. +- **`logging/`** - Logging implementations (Winston) that implement interfaces from `core/`. +- **`utils/`** - Pure standalone utility functions with no dependencies on other internal layers. + +Dependencies flow inward: outer layers depend on `core/` interfaces, enabling testability and flexibility. This ensures implementations can be swapped without affecting dependent code. + +## Folder Structure + +``` +linea-shared-utils/ +├── src/ +│ ├── applications/ # Accessory applications (e.g., metrics API) +│ │ └── ExpressApiApplication.ts +│ ├── clients/ # Infrastructure client adapters +│ ├── core/ # Interfaces and constants +│ ├── logging/ # Logging implementations +│ ├── services/ # Business logic service implementations +│ ├── utils/ # Standalone utility functions +└── scripts/ # Testing scripts +``` + +## Installation and Usage + +This package is part of the Linea monorepo and is typically used as a workspace dependency. It is used by other Linea services such as the `automation-service`. + +Example imports: + +```typescript +import { + ViemBlockchainClientAdapter, + WinstonLogger, + SingletonMetricsService, + ExpressApiApplication, + ExponentialBackoffRetryService +} from "@consensys/linea-shared-utils"; +``` + +## Development + +### Build + +```bash +pnpm --filter @consensys/linea-shared-utils build +``` + +### Test + +```bash +pnpm --filter @consensys/linea-shared-utils test +``` + +## License + +This package is licensed under the [Apache 2.0](../../LICENSE-APACHE) and the [MIT](../../LICENSE-MIT) licenses. diff --git a/ts-libs/linea-shared-utils/jest.config.js b/ts-libs/linea-shared-utils/jest.config.js new file mode 100644 index 0000000000..ae6a5a92b1 --- /dev/null +++ b/ts-libs/linea-shared-utils/jest.config.js @@ -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/index.ts", "src/logging", "src/core"], + coveragePathIgnorePatterns: ["src/index.ts", "src/logging", "src/core", "src/utils/file.ts"], +}; diff --git a/ts-libs/linea-shared-utils/package.json b/ts-libs/linea-shared-utils/package.json new file mode 100644 index 0000000000..bf83dfbcdf --- /dev/null +++ b/ts-libs/linea-shared-utils/package.json @@ -0,0 +1,45 @@ +{ + "name": "@consensys/linea-shared-utils", + "version": "1.0.0", + "description": "", + "author": "Consensys Software Inc.", + "license": "Apache-2.0", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup --tsconfig tsconfig.build.json", + "test": "npx jest --bail --detectOpenHandles", + "lint:ts": "npx eslint '**/*.ts'", + "lint:ts:fix": "npx eslint --fix '**/*.ts'", + "prettier": "prettier -c '**/*.ts'", + "prettier:fix": "prettier -w '**/*.ts'", + "lint:fix": "pnpm run lint:ts:fix && pnpm run prettier:fix", + "clean": "rimraf dist node_modules coverage" + }, + "keywords": [], + "devDependencies": { + "@types/express": "5.0.4", + "@types/jest": "catalog:", + "@types/node-forge": "1.3.14", + "jest": "catalog:", + "jest-mock-extended": "catalog:", + "ts-jest": "catalog:", + "tsup": "catalog:" + }, + "dependencies": { + "axios": "catalog:", + "express": "catalog:", + "neverthrow": "catalog:", + "node-forge": "1.3.1", + "prom-client": "catalog:", + "winston": "catalog:", + "viem": "catalog:" + } +} diff --git a/ts-libs/linea-shared-utils/scripts/test-beacon-node-api-client.ts b/ts-libs/linea-shared-utils/scripts/test-beacon-node-api-client.ts new file mode 100644 index 0000000000..6f9cf35995 --- /dev/null +++ b/ts-libs/linea-shared-utils/scripts/test-beacon-node-api-client.ts @@ -0,0 +1,35 @@ +// pnpm --filter @consensys/linea-shared-utils exec tsx scripts/test-beacon-node-api-client.ts + +import { ExponentialBackoffRetryService } from "../src"; +import { BeaconNodeApiClient } from "../src/clients/BeaconNodeApiClient"; +import { WinstonLogger } from "../src/logging/WinstonLogger"; + +async function main() { + const rpcUrl = process.env.BEACON_NODE_RPC_URL; + + if (!rpcUrl) { + console.error("Missing required env var: BEACON_NODE_RPC_URL"); + process.exitCode = 1; + return; + } + + const retryService = new ExponentialBackoffRetryService(new WinstonLogger(ExponentialBackoffRetryService.name)); + const client = new BeaconNodeApiClient(new WinstonLogger("BeaconNodeApiClient.integration"), retryService, rpcUrl); + + console.log(`Fetching pending partial withdrawals from ${rpcUrl}...`); + try { + const withdrawals = await client.getPendingPartialWithdrawals(); + if (!withdrawals) { + throw "undefined withdrawals"; + } + console.log(`Received ${withdrawals.length} withdrawals.`); + if (withdrawals.length > 0) { + console.log("Sample entry:", withdrawals[0]); + } + } catch (err) { + console.error("BeaconNodeApiClient integration script failed:", err); + process.exitCode = 1; + } +} + +main(); diff --git a/ts-libs/linea-shared-utils/scripts/test-ethereum-mainnet-client-library-viem-signer.ts b/ts-libs/linea-shared-utils/scripts/test-ethereum-mainnet-client-library-viem-signer.ts new file mode 100644 index 0000000000..5723b55aae --- /dev/null +++ b/ts-libs/linea-shared-utils/scripts/test-ethereum-mainnet-client-library-viem-signer.ts @@ -0,0 +1,60 @@ +/* Run against anvil node + +RPC_URL=http://127.0.0.1:8545 \ +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +pnpm --filter @consensys/linea-shared-utils exec tsx scripts/test-ethereum-mainnet-client-library-viem-signer.ts + + */ +import { ViemBlockchainClientAdapter } from "../src/clients/ViemBlockchainClientAdapter"; +import { ViemWalletSignerClientAdapter } from "../src/clients/ViemWalletSignerClientAdapter"; +import { WinstonLogger } from "../src/logging/WinstonLogger"; +import { Hex } from "viem"; +import { anvil } from "viem/chains"; + +async function main() { + const requiredEnvVars = ["RPC_URL", "PRIVATE_KEY"]; + + const missing = requiredEnvVars.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.error(`Missing required env vars: ${missing.join(", ")}`); + process.exitCode = 1; + return; + } + const rpcUrl = process.env.RPC_URL as string; + const privateKey = process.env.PRIVATE_KEY as Hex; + + const signer = new ViemWalletSignerClientAdapter( + new WinstonLogger("ViemWalletSignerClientAdapter.integration"), + rpcUrl, + privateKey, + anvil, + ); + const clientLibrary = new ViemBlockchainClientAdapter( + new WinstonLogger("ViemBlockchainClientAdapter.integration"), + rpcUrl, + anvil, + signer, + ); + + try { + const address = signer.getAddress(); + console.log("Address:", address); + + const chainId = await clientLibrary.getChainId(); + console.log("Chain ID:", chainId); + + const balance = await clientLibrary.getBalance(address); + console.log(`Balance for ${address}:`, balance.toString()); + + const fees = await clientLibrary.estimateGasFees(); + console.log("Estimated fees:", fees); + + const receipt = await clientLibrary.sendSignedTransaction(address, "0x"); + console.log("Receipt:", receipt); + } catch (err) { + console.error("ViemBlockchainClientAdapter integration script failed:", err); + process.exitCode = 1; + } +} + +main(); diff --git a/ts-libs/linea-shared-utils/scripts/test-ethereum-mainnet-client-library-web3-signer.ts b/ts-libs/linea-shared-utils/scripts/test-ethereum-mainnet-client-library-web3-signer.ts new file mode 100644 index 0000000000..bf87d02188 --- /dev/null +++ b/ts-libs/linea-shared-utils/scripts/test-ethereum-mainnet-client-library-web3-signer.ts @@ -0,0 +1,106 @@ +/* Run against anvil node + +Terminal 1 - Run anvil + +Prefund the Signer +cast rpc anvil_setBalance 0xD42E308FC964b71E18126dF469c21B0d7bcb86cC 0x8AC7230489E80000 --rpc-url http://localhost:8545 + +Terminal 2 - Run Web3Signer + +docker run --rm \ + --platform=linux/amd64 \ + --name web3signer \ + -p 9000:9000 \ + -v "$(pwd)/docker/web3signer/key-files:/key-files" \ + -v "$(pwd)/docker/web3signer/tls-files:/tls-files" \ + consensys/web3signer:25.2 \ + --key-store-path=/key-files/ \ + --tls-keystore-file=/tls-files/web3signer-keystore.p12 \ + --tls-keystore-password-file=/tls-files/web3signer-keystore-password.txt \ + --tls-known-clients-file=/tls-files/known-clients.txt \ + --http-host-allowlist='*' \ + eth1 \ + --chain-id=31337 + +Terminal 3 - Run script + +RPC_URL=http://127.0.0.1:8545 \ +WEB3_SIGNER_URL=https://127.0.0.1:9000 \ +WEB3_SIGNER_PUBLIC_KEY=0x4a788ad6fa008beed58de6418369717d7492f37d173d70e2c26d9737e2c6eeae929452ef8602a19410844db3e200a0e73f5208fd76259a8766b73953fc3e7023 \ +WEB3_SIGNER_KEYSTORE_PATH="$(pwd)/docker/config/linea-besu-sequencer/tls-files/sequencer_client_keystore.p12" \ +WEB3_SIGNER_KEYSTORE_PASSPHRASE=changeit \ +WEB3_SIGNER_TRUST_STORE_PATH="$(pwd)/docker/config/linea-besu-sequencer/tls-files/web3signer_truststore.p12" \ +WEB3_SIGNER_TRUST_STORE_PASSPHRASE=changeit \ +pnpm --filter @consensys/linea-shared-utils exec tsx scripts/test-ethereum-mainnet-client-library-web3-signer.ts + + */ +import { Web3SignerClientAdapter } from "../src"; +import { ViemBlockchainClientAdapter } from "../src/clients/ViemBlockchainClientAdapter"; +import { WinstonLogger } from "../src/logging/WinstonLogger"; +import { Hex } from "viem"; +import { anvil } from "viem/chains"; + +async function main() { + const requiredEnvVars = [ + "RPC_URL", + "WEB3_SIGNER_URL", + "WEB3_SIGNER_PUBLIC_KEY", + "WEB3_SIGNER_KEYSTORE_PATH", + "WEB3_SIGNER_KEYSTORE_PASSPHRASE", + "WEB3_SIGNER_TRUST_STORE_PATH", + "WEB3_SIGNER_TRUST_STORE_PASSPHRASE", + ]; + + const missing = requiredEnvVars.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.error(`Missing required env vars: ${missing.join(", ")}`); + process.exitCode = 1; + return; + } + const rpcUrl = process.env.RPC_URL as string; + const web3SignerUrl = process.env.WEB3_SIGNER_URL as string; + const web3SignerPublicKey = process.env.WEB3_SIGNER_PUBLIC_KEY as Hex; + const web3SignerKeystorePath = process.env.WEB3_SIGNER_KEYSTORE_PATH as string; + const web3SignerKeystorePassphrase = process.env.WEB3_SIGNER_KEYSTORE_PASSPHRASE as string; + const web3SignerTrustedStorePath = process.env.WEB3_SIGNER_TRUST_STORE_PATH as string; + const web3SignerTrustedStorePassphrase = process.env.WEB3_SIGNER_TRUST_STORE_PASSPHRASE as string; + + const logger = new WinstonLogger("Web3SignerClientAdapter.integration", { level: "debug" }); + const signer = new Web3SignerClientAdapter( + logger, + web3SignerUrl, + web3SignerPublicKey, + web3SignerKeystorePath, + web3SignerKeystorePassphrase, + web3SignerTrustedStorePath, + web3SignerTrustedStorePassphrase, + ); + const clientLibrary = new ViemBlockchainClientAdapter( + new WinstonLogger("ViemBlockchainClientAdapter.integration", { level: "debug" }), + rpcUrl, + anvil, + signer, + ); + + try { + const address = signer.getAddress(); + console.log("Address:", address); + + const chainId = await clientLibrary.getChainId(); + console.log("Chain ID:", chainId); + + const balance = await clientLibrary.getBalance(address); + console.log(`Balance for ${address}:`, balance.toString()); + + const fees = await clientLibrary.estimateGasFees(); + console.log("Estimated fees:", fees); + + const receipt = await clientLibrary.sendSignedTransaction(address, "0x"); + console.log("Receipt:", receipt); + } catch (err) { + console.error("ViemBlockchainClientAdapter integration script failed:", err); + process.exitCode = 1; + } +} + +main(); diff --git a/ts-libs/linea-shared-utils/scripts/test-oauth2-token-client.ts b/ts-libs/linea-shared-utils/scripts/test-oauth2-token-client.ts new file mode 100644 index 0000000000..1212178028 --- /dev/null +++ b/ts-libs/linea-shared-utils/scripts/test-oauth2-token-client.ts @@ -0,0 +1,44 @@ +// Script to manually test OAuth2TokenClient against a live server +// pnpm --filter @consensys/linea-shared-utils exec tsx scripts/test-oauth2-token-client.ts +import { ExponentialBackoffRetryService } from "../src"; +import { OAuth2TokenClient } from "../src/clients/OAuth2TokenClient"; +import { WinstonLogger } from "../src/logging/WinstonLogger"; + +async function main() { + const requiredEnvVars = ["TOKEN_URL", "CLIENT_ID", "CLIENT_SECRET", "AUDIENCE"]; + + const missing = requiredEnvVars.filter((key) => !process.env[key]); + if (missing.length > 0) { + console.error(`Missing required env vars: ${missing.join(", ")}`); + process.exitCode = 1; + return; + } + + const logger = new WinstonLogger("OAuth2TokenClient.integration"); + const retryService = new ExponentialBackoffRetryService(new WinstonLogger(ExponentialBackoffRetryService.name)); + const client = new OAuth2TokenClient( + logger, + retryService, + process.env.TOKEN_URL!, + process.env.CLIENT_ID!, + process.env.CLIENT_SECRET!, + process.env.AUDIENCE!, + process.env.GRANT_TYPE ?? "client_credentials", + ); + + const firstToken = await client.getBearerToken(); + console.log("First token:", firstToken); + + const secondToken = await client.getBearerToken(); + console.log("Second token (should match first):", secondToken); + + if (firstToken !== secondToken) { + console.error("Expected cached token but received a different value on second call."); + process.exitCode = 1; + } +} + +main().catch((err) => { + console.error("OAuth2TokenClient integration script failed:", err); + process.exitCode = 1; +}); diff --git a/ts-libs/linea-shared-utils/scripts/test-web3-signer-client-adapter.ts b/ts-libs/linea-shared-utils/scripts/test-web3-signer-client-adapter.ts new file mode 100644 index 0000000000..377058181f --- /dev/null +++ b/ts-libs/linea-shared-utils/scripts/test-web3-signer-client-adapter.ts @@ -0,0 +1,169 @@ +/* Example usage (ensure certificates are accessible from the repo root): + +First run web3signer Docker container: + +docker run --rm \ + --platform=linux/amd64 \ + --name web3signer \ + -p 9000:9000 \ + -v "$(pwd)/docker/web3signer/key-files:/key-files" \ + -v "$(pwd)/docker/web3signer/tls-files:/tls-files" \ + consensys/web3signer:25.2 \ + --key-store-path=/key-files/ \ + --tls-keystore-file=/tls-files/web3signer-keystore.p12 \ + --tls-keystore-password-file=/tls-files/web3signer-keystore-password.txt \ + --tls-known-clients-file=/tls-files/known-clients.txt \ + --http-host-allowlist='*' \ + eth1 \ + --chain-id=1337 + +Then run this script + +WEB3_SIGNER_URL=https://127.0.0.1:9000 \ +WEB3_SIGNER_PUBLIC_KEY=4a788ad6fa008beed58de6418369717d7492f37d173d70e2c26d9737e2c6eeae929452ef8602a19410844db3e200a0e73f5208fd76259a8766b73953fc3e7023 \ +WEB3_SIGNER_KEYSTORE_PATH="$(pwd)/docker/config/linea-besu-sequencer/tls-files/sequencer_client_keystore.p12" \ +WEB3_SIGNER_KEYSTORE_PASSPHRASE=changeit \ +WEB3_SIGNER_TRUST_STORE_PATH="$(pwd)/docker/config/linea-besu-sequencer/tls-files/web3signer_truststore.p12" \ +WEB3_SIGNER_TRUST_STORE_PASSPHRASE=changeit \ +pnpm --filter @consensys/linea-shared-utils exec tsx scripts/test-web3-signer-client-adapter.ts + +// Optional overrides (defaults shown): +// TX_CHAIN_ID=1337 +// TX_NONCE=0 +// TX_GAS_LIMIT=21000 +// TX_MAX_FEE_PER_GAS=1000000000 +// TX_MAX_PRIORITY_FEE_PER_GAS=100000000 +// TX_VALUE=0 +// TX_TO=0x0000000000000000000000000000000000000000 +// TX_DATA=0x + */ + +import { Web3SignerClientAdapter } from "../src/clients/Web3SignerClientAdapter"; +import { WinstonLogger } from "../src/logging/WinstonLogger"; +import { Address, Hex, TransactionSerializableEIP1559, serializeTransaction } from "viem"; + +const REQUIRED_ENV_VARS = [ + "WEB3_SIGNER_URL", + "WEB3_SIGNER_PUBLIC_KEY", + "WEB3_SIGNER_KEYSTORE_PATH", + "WEB3_SIGNER_KEYSTORE_PASSPHRASE", + "WEB3_SIGNER_TRUST_STORE_PATH", + "WEB3_SIGNER_TRUST_STORE_PASSPHRASE", +]; + +function readRequiredEnv() { + const missing = REQUIRED_ENV_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error(`Missing required env vars: ${missing.join(", ")}`); + } +} + +function numberFromEnv(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (!raw) { + return defaultValue; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed)) { + throw new Error(`Environment variable ${name} must be a finite number, received: ${raw}`); + } + return parsed; +} + +function bigintFromEnv(name: string, defaultValue: bigint): bigint { + const raw = process.env[name]; + if (!raw) { + return defaultValue; + } + try { + return BigInt(raw); + } catch (err) { + throw new Error(`Environment variable ${name} must be a bigint-compatible value, received: ${raw}`); + } +} + +function hexFromEnv(name: string, defaultValue: Hex): Hex { + const raw = process.env[name]; + if (!raw) { + return defaultValue; + } + if (!raw.startsWith("0x")) { + throw new Error(`Environment variable ${name} must be a 0x-prefixed hex string, received: ${raw}`); + } + return raw as Hex; +} + +function addressFromEnv(name: string, defaultValue: Address): Address { + const raw = process.env[name]; + if (!raw) { + return defaultValue; + } + if (!raw.startsWith("0x") || raw.length !== 42) { + throw new Error(`Environment variable ${name} must be a 20-byte 0x-prefixed address, received: ${raw}`); + } + return raw as Address; +} + +async function main() { + try { + readRequiredEnv(); + } catch (err) { + console.error((err as Error).message); + process.exitCode = 1; + return; + } + + const web3SignerUrl = process.env.WEB3_SIGNER_URL as string; + const web3SignerPublicKey = process.env.WEB3_SIGNER_PUBLIC_KEY as Hex; + const web3SignerKeystorePath = process.env.WEB3_SIGNER_KEYSTORE_PATH as string; + const web3SignerKeystorePassphrase = process.env.WEB3_SIGNER_KEYSTORE_PASSPHRASE as string; + const web3SignerTrustedStorePath = process.env.WEB3_SIGNER_TRUST_STORE_PATH as string; + const web3SignerTrustedStorePassphrase = process.env.WEB3_SIGNER_TRUST_STORE_PASSPHRASE as string; + + const chainId = numberFromEnv("TX_CHAIN_ID", 1337); + const nonce = numberFromEnv("TX_NONCE", 0); + const gasLimit = bigintFromEnv("TX_GAS_LIMIT", BigInt(21_000)); + const maxFeePerGas = bigintFromEnv("TX_MAX_FEE_PER_GAS", BigInt(1_000_000_000)); + const maxPriorityFeePerGas = bigintFromEnv("TX_MAX_PRIORITY_FEE_PER_GAS", BigInt(100_000_000)); + const value = bigintFromEnv("TX_VALUE", BigInt(0)); + const to = addressFromEnv("TX_TO", "0x0000000000000000000000000000000000000000"); + const data = hexFromEnv("TX_DATA", "0x"); + + const logger = new WinstonLogger("Web3SignerClientAdapter.integration"); + const signer = new Web3SignerClientAdapter( + logger, + web3SignerUrl, + web3SignerPublicKey, + web3SignerKeystorePath, + web3SignerKeystorePassphrase, + web3SignerTrustedStorePath, + web3SignerTrustedStorePassphrase, + ); + + console.log("Derived address from public key:", signer.getAddress()); + + const tx: TransactionSerializableEIP1559 = { + type: "eip1559", + chainId, + nonce, + gas: gasLimit, + to, + value, + maxFeePerGas, + maxPriorityFeePerGas, + data, + }; + + console.log("Prepared transaction payload:", tx); + console.log("Unsigned serialized transaction:", serializeTransaction(tx)); + + try { + const signature = await signer.sign(tx); + console.log("Web3Signer signature:", signature); + } catch (err) { + console.error("Web3SignerClientAdapter integration script failed:", err); + process.exitCode = 1; + } +} + +main(); diff --git a/ts-libs/linea-shared-utils/src/applications/ExpressApiApplication.ts b/ts-libs/linea-shared-utils/src/applications/ExpressApiApplication.ts new file mode 100644 index 0000000000..ab6b5a8d99 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/applications/ExpressApiApplication.ts @@ -0,0 +1,123 @@ +import express, { Express, Request, Response } from "express"; +import { IMetricsService } from "../core/services/IMetricsService"; +import { ILogger } from "../logging/ILogger"; +import { IApplication } from "../core/applications/IApplication"; + +/** + * Express-based API application that provides HTTP server functionality with metrics endpoint. + * Uses protected members instead of private to allow for subclass overrides. + * Supports optional lifecycle hooks for custom behavior during startup and shutdown. + */ +export class ExpressApiApplication implements IApplication { + protected readonly app: Express; + protected server?: ReturnType; + + /** + * Creates a new ExpressApiApplication instance. + * + * @param {number} port - The port number on which the server will listen. + * @param {IMetricsService} metricsService - The metrics service for exposing Prometheus metrics. + * @param {ILogger} logger - The logger instance for logging application events. + */ + constructor( + protected readonly port: number, + protected readonly metricsService: IMetricsService, + protected readonly logger: ILogger, + ) { + this.app = express(); + + this.setupMiddleware(); + this.setupMetricsRoute(); + } + + /** + * Sets up Express middleware. + * Currently configures JSON body parsing middleware. + * Can be overridden in subclasses to add additional middleware. + */ + protected setupMiddleware(): void { + this.app.use(express.json()); + } + + /** + * Sets up the /metrics route for Prometheus metrics collection. + * Returns metrics from the metrics service registry. + * Handles errors gracefully by logging and returning a 500 status. + */ + protected setupMetricsRoute() { + this.app.get("/metrics", async (_req: Request, res: Response) => { + try { + const registry = this.metricsService.getRegistry(); + res.set("Content-Type", registry.contentType); + res.end(await registry.metrics()); + } catch (error) { + this.logger.warn("Failed to collect metrics", { error }); + res.status(500).json({ error: "Failed to collect metrics" }); + } + }); + } + + /** + * Starts the Express server and begins listening on the configured port. + * Executes optional lifecycle hooks before and after starting the server. + * Waits for the server to be ready before resolving. + * + * @returns {Promise} A promise that resolves when the server is listening. + */ + public async start(): Promise { + await this.onBeforeStart?.(); + this.server = this.app.listen(this.port); + + await new Promise((resolve) => { + this.server?.on("listening", () => { + this.logger.info(`Listening on port ${this.port}`); + resolve(); + }); + }); + await this.onAfterStart?.(); + } + + /** + * Stops the Express server gracefully. + * Executes optional lifecycle hooks before and after stopping the server. + * If the server is not running, returns immediately. + * + * @returns {Promise} A promise that resolves when the server is closed. + * @throws {Error} If an error occurs while closing the server. + */ + public async stop(): Promise { + await this.onBeforeStop?.(); + if (!this.server) return; + + await new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) return reject(err); + this.logger.info(`Closing API server on port ${this.port}`); + this.server = undefined; + resolve(); + }); + }); + await this.onAfterStop?.(); + } + + /** + * Optional hook executed before the server starts listening. + * Can be assigned from outside to perform setup operations. + */ + onBeforeStart?: () => Promise | void; + /** + * Optional hook executed after the server has started listening. + * Can be assigned from outside to perform post-startup operations. + */ + onAfterStart?: () => Promise | void; + /** + * Optional hook executed before the server stops. + * Can be assigned from outside to perform pre-shutdown operations. + */ + onBeforeStop?: () => Promise | void; + /** + * Optional hook executed after the server has stopped. + * Can be assigned from outside to perform cleanup operations. + */ + onAfterStop?: () => Promise | void; +} diff --git a/ts-libs/linea-shared-utils/src/applications/__tests__/ExpressApiApplication.test.ts b/ts-libs/linea-shared-utils/src/applications/__tests__/ExpressApiApplication.test.ts new file mode 100644 index 0000000000..2432879793 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/applications/__tests__/ExpressApiApplication.test.ts @@ -0,0 +1,210 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { ExpressApiApplication } from "../ExpressApiApplication"; +import { Request, Response } from "express"; +import { ILogger, IMetricsService } from "@consensys/linea-shared-utils"; +import { Registry } from "prom-client"; + +enum ExampleMetrics { + ExampleMetrics = "ExampleMetrics", +} + +const createRegistry = (overrides?: Partial): Registry => + ({ + contentType: "text/plain; version=0.0.4; charset=utf-8", + metrics: async () => "mocked metrics", + ...overrides, + }) as unknown as Registry; + +const captureMetricsHandler = (appInstance: ExpressApiApplication) => { + const expressApp = appInstance["app"] as any; + const originalGet = expressApp.get.bind(expressApp); + let handler: ((req: Request, res: Response) => Promise) | undefined; + + expressApp.get = (path: string, ...handlers: any[]) => { + if (path === "/metrics" && handlers.length > 0) { + handler = handlers[handlers.length - 1]; + } + return originalGet(path, ...handlers); + }; + + (appInstance as any).setupMetricsRoute(); + expressApp.get = originalGet; + + if (!handler) { + throw new Error("Metrics handler could not be captured"); + } + return handler; +}; + +const createResponseMock = () => { + const res: Partial & { + set: jest.Mock; + end: jest.Mock; + status: jest.Mock; + json: jest.Mock; + } = { + set: jest.fn(), + end: jest.fn(), + json: jest.fn(), + status: jest.fn(), + }; + res.status.mockImplementation(() => res as Response); + return res; +}; + +describe("ExpressApiApplication", () => { + let app: ExpressApiApplication; + let metricsService: MockProxy>; + let logger: MockProxy; + + beforeEach(() => { + metricsService = mock>(); + logger = mock(); + metricsService.getRegistry.mockReturnValue(createRegistry()); + app = new ExpressApiApplication(0, metricsService, logger); + }); + + afterEach(async () => { + app.onBeforeStop = undefined; + app.onAfterStop = undefined; + await app.stop(); + jest.clearAllMocks(); + }); + + it("wires metrics service through the /metrics route", async () => { + const handler = captureMetricsHandler(app); + const res = createResponseMock(); + + await handler({} as Request, res as Response); + + expect(res.set).toHaveBeenCalledWith("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); + expect(res.end).toHaveBeenCalledWith("mocked metrics"); + expect(res.status).not.toHaveBeenCalled(); + expect(metricsService.getRegistry).toHaveBeenCalled(); + }); + + it("returns 500 and logs when metrics collection fails", async () => { + const metricsError = new Error("metrics failure"); + metricsService.getRegistry.mockReturnValue( + createRegistry({ + metrics: async () => { + throw metricsError; + }, + }), + ); + + const handler = captureMetricsHandler(app); + const res = createResponseMock(); + + await handler({} as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: "Failed to collect metrics" }); + expect(logger.warn).toHaveBeenCalledWith("Failed to collect metrics", { error: metricsError }); + }); + + it("runs lifecycle hooks and logs when starting and stopping", async () => { + type ServerMock = { + on: jest.MockedFunction<(event: string, handler: () => void) => ServerMock>; + close: jest.MockedFunction<(callback?: (err?: Error | null) => void) => ServerMock>; + }; + const serverMock = { + on: jest.fn(), + close: jest.fn(), + } as ServerMock; + serverMock.on.mockImplementation((event, handler) => { + if (event === "listening") handler(); + return serverMock; + }); + serverMock.close.mockImplementation((callback) => { + callback?.(); + return serverMock; + }); + const listenSpy = jest.spyOn(app["app"], "listen").mockImplementation(() => serverMock as any); + + const onBeforeStart = jest.fn(); + const onAfterStart = jest.fn(); + const onBeforeStop = jest.fn(); + const onAfterStop = jest.fn(); + + app.onBeforeStart = onBeforeStart; + app.onAfterStart = onAfterStart; + app.onBeforeStop = onBeforeStop; + app.onAfterStop = onAfterStop; + + await app.start(); + expect(onBeforeStart).toHaveBeenCalledTimes(1); + expect(onAfterStart).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith("Listening on port 0"); + + await app.stop(); + expect(onBeforeStop).toHaveBeenCalledTimes(1); + expect(onAfterStop).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith("Closing API server on port 0"); + expect(app["server"]).toBeUndefined(); + + listenSpy.mockRestore(); + }); + + it("handles stop gracefully when the server was never started", async () => { + const onBeforeStop = jest.fn(); + const onAfterStop = jest.fn(); + app.onBeforeStop = onBeforeStop; + app.onAfterStop = onAfterStop; + + await app.stop(); + + expect(onBeforeStop).toHaveBeenCalledTimes(1); + expect(onAfterStop).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + + app.onBeforeStop = undefined; + app.onAfterStop = undefined; + }); + + it("propagates errors from server.close and skips after-stop hook", async () => { + type ServerMock = { + on: jest.MockedFunction<(event: string, handler: () => void) => ServerMock>; + close: jest.MockedFunction<(callback?: (err?: Error | null) => void) => ServerMock>; + }; + const serverMock = { + on: jest.fn(), + close: jest.fn(), + } as ServerMock; + serverMock.on.mockImplementation((event, handler) => { + if (event === "listening") handler(); + return serverMock; + }); + const closeError = new Error("close failure"); + serverMock.close.mockImplementation((callback) => { + callback?.(closeError); + return serverMock; + }); + const listenSpy = jest.spyOn(app["app"], "listen").mockImplementation(() => serverMock as any); + + const onBeforeStop = jest.fn(); + const onAfterStop = jest.fn(); + app.onBeforeStop = onBeforeStop; + app.onAfterStop = onAfterStop; + + await app.start(); + + await expect(app.stop()).rejects.toThrow(closeError); + + expect(onBeforeStop).toHaveBeenCalledTimes(1); + expect(onAfterStop).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalledWith("Closing API server on port 0"); + expect(app["server"]).toBe(serverMock); + + // cleanup: allow stop to succeed so afterEach does not throw + app.onBeforeStop = undefined; + app.onAfterStop = undefined; + serverMock.close.mockImplementation((callback) => { + callback?.(); + return serverMock; + }); + logger.info.mockClear(); + await app.stop().catch(() => {}); + listenSpy.mockRestore(); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/clients/BeaconNodeApiClient.ts b/ts-libs/linea-shared-utils/src/clients/BeaconNodeApiClient.ts new file mode 100644 index 0000000000..1583b88026 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/BeaconNodeApiClient.ts @@ -0,0 +1,46 @@ +import { + IBeaconNodeAPIClient, + PendingPartialWithdrawal, + PendingPartialWithdrawalResponse, +} from "../core/client/IBeaconNodeApiClient"; +import axios from "axios"; +import { ILogger } from "../logging/ILogger"; +import { IRetryService } from "../core/services/IRetryService"; + +/** + * Client for interacting with a Beacon Node API. + * Provides methods to fetch beacon chain state information with automatic retry support. + */ +export class BeaconNodeApiClient implements IBeaconNodeAPIClient { + /** + * Creates a new BeaconNodeApiClient instance. + * + * @param {ILogger} logger - The logger instance for logging API requests and responses. + * @param {IRetryService} retryService - The retry service for handling failed API requests. + * @param {string} rpcURL - The base URL of the Beacon Node API endpoint. + */ + constructor( + private readonly logger: ILogger, + private readonly retryService: IRetryService, + private readonly rpcURL: string, + ) {} + + /** + * Fetches pending partial withdrawals from the beacon chain state. + * Makes a GET request to the Beacon Node API with automatic retry on failure. + * + * @returns {Promise} An array of pending partial withdrawals if successful, undefined if the request fails or returns invalid data. + */ + async getPendingPartialWithdrawals(): Promise { + const url = `${this.rpcURL}/eth/v1/beacon/states/head/pending_partial_withdrawals`; + this.logger.debug(`getPendingPartialWithdrawals making GET request to url=${url}`); + const { data } = await this.retryService.retry(() => axios.get(url)); + if (data === undefined || data?.data === undefined) { + this.logger.error("Failed GET request to", url); + return undefined; + } + const returnVal = data.data; + this.logger.debug("getPendingPartialWithdrawals return value", { returnVal }); + return returnVal; + } +} diff --git a/ts-libs/linea-shared-utils/src/clients/OAuth2TokenClient.ts b/ts-libs/linea-shared-utils/src/clients/OAuth2TokenClient.ts new file mode 100644 index 0000000000..9c3006a5f2 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/OAuth2TokenClient.ts @@ -0,0 +1,103 @@ +import axios from "axios"; +import { ILogger } from "../logging/ILogger"; +import { getCurrentUnixTimestampSeconds } from "../utils/time"; +import { IOAuth2TokenClient, OAuth2TokenResponse } from "../core/client/IOAuth2TokenClient"; +import { IRetryService } from "../core/services/IRetryService"; + +/** + * Client for obtaining and managing OAuth2 bearer tokens. + * Implements token caching with automatic refresh when tokens are near expiration. + * Uses the client credentials grant type to obtain access tokens. + */ +export class OAuth2TokenClient implements IOAuth2TokenClient { + private bearerToken?: string; + private tokenExpiresAtSeconds?: number; + + /** + * Creates a new OAuth2TokenClient instance. + * + * @param {ILogger} logger - The logger instance for logging token operations. + * @param {IRetryService} retryService - The retry service for handling failed token requests. + * @param {string} tokenUrl - The OAuth2 token endpoint URL. + * @param {string} clientId - The OAuth2 client ID. + * @param {string} clientSecret - The OAuth2 client secret. + * @param {string} audience - The OAuth2 audience identifier. + * @param {string} [grantType="client_credentials"] - The OAuth2 grant type to use. + * @param {number} [expiryBufferSeconds=60] - Buffer time in seconds before token expiration to trigger refresh. + */ + constructor( + private readonly logger: ILogger, + private readonly retryService: IRetryService, + private readonly tokenUrl: string, + private readonly clientId: string, + private readonly clientSecret: string, + private readonly audience: string, + private readonly grantType: string = "client_credentials", + private readonly expiryBufferSeconds: number = 60, + ) {} + + /** + * Retrieves a valid bearer token, using cached token if available and not expired. + * Automatically requests a new token if the cached token is expired or missing. + * The token is refreshed when it's within the expiry buffer time of expiration. + * + * @returns {Promise} The bearer token string (e.g., "Bearer ") if successful, undefined if the request fails or the token is invalid. + */ + async getBearerToken(): Promise { + // Serve cached token while it remains valid. + if ( + this.bearerToken && + this.tokenExpiresAtSeconds && + getCurrentUnixTimestampSeconds() < this.tokenExpiresAtSeconds - this.expiryBufferSeconds + ) { + this.logger.info("getBearerToken cache-hit"); + return this.bearerToken; + } + + this.logger.info("getBearerToken requesting new token"); + const { data } = await this.retryService.retry(() => + axios.post( + this.tokenUrl, + { + client_id: this.clientId, + client_secret: this.clientSecret, + audience: this.audience, + grant_type: this.grantType, + }, + { + headers: { + "content-type": "application/json", + }, + }, + ), + ); + + if (!data?.access_token) { + this.logger.error("Failed to retrieve OAuth2 access token"); + return undefined; + } + + const tokenType = data.token_type ?? "Bearer"; + this.bearerToken = `${tokenType} ${data.access_token}`.trim(); + + if (data?.expires_at) { + const tokenExpiresAtSecondsCandidate = data?.expires_at; + if (tokenExpiresAtSecondsCandidate < getCurrentUnixTimestampSeconds()) { + this.logger.error(`OAuth2 access token already expired at ${tokenExpiresAtSecondsCandidate}`); + return undefined; + } + this.tokenExpiresAtSeconds = tokenExpiresAtSecondsCandidate; + } else if (data?.expires_in) { + this.tokenExpiresAtSeconds = getCurrentUnixTimestampSeconds() + data?.expires_in; + } else { + this.logger.error(`OAuth2 access token did not provide expiry data`); + return undefined; + } + + this.logger.info( + `getBearerToken successfully retrived new OAuth2 Bearer token tokenExpiresAtSeconds=${this.tokenExpiresAtSeconds}`, + ); + + return this.bearerToken; + } +} diff --git a/ts-libs/linea-shared-utils/src/clients/ViemBlockchainClientAdapter.ts b/ts-libs/linea-shared-utils/src/clients/ViemBlockchainClientAdapter.ts new file mode 100644 index 0000000000..42922390d8 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/ViemBlockchainClientAdapter.ts @@ -0,0 +1,373 @@ +import { IBlockchainClient } from "../core/client/IBlockchainClient"; +import { + Hex, + createPublicClient, + http, + PublicClient, + TransactionReceipt, + Address, + TransactionSerializableEIP1559, + serializeTransaction, + parseSignature, + Chain, + withTimeout, + TimeoutError, + BaseError, +} from "viem"; +import { sendRawTransaction, waitForTransactionReceipt } from "viem/actions"; +import { IContractSignerClient } from "../core/client/IContractSignerClient"; +import { ILogger } from "../logging/ILogger"; +import { MAX_BPS } from "../core/constants/maths"; + +/** + * Adapter that wraps viem's PublicClient to provide blockchain interaction functionality. + * Implements transaction sending with retry logic, gas fee estimation, and connection pooling. + * Uses a single PublicClient instance for better connection pooling and memory efficiency. + */ +export class ViemBlockchainClientAdapter implements IBlockchainClient { + blockchainClient: PublicClient; + + /** + * Creates a new ViemBlockchainClientAdapter instance. + * + * @param {ILogger} logger - The logger instance for logging blockchain operations. + * @param {string} rpcUrl - The RPC URL for the blockchain network. + * @param {Chain} chain - The blockchain chain configuration. + * @param {IContractSignerClient} contractSignerClient - The client for signing transactions. + * @param {number} [sendTransactionsMaxRetries=3] - Maximum number of retry attempts for sending transactions (must be at least 1). + * @param {bigint} [gasRetryBumpBps=1000n] - Gas price bump in basis points per retry (e.g., 1000n = +10% per retry). + * @param {number} [sendTransactionAttemptTimeoutMs=300000] - Timeout in milliseconds for each transaction attempt (default: 5 minutes). + * @throws {Error} If sendTransactionsMaxRetries is less than 1. + */ + constructor( + private readonly logger: ILogger, + rpcUrl: string, + chain: Chain, + private readonly contractSignerClient: IContractSignerClient, + private readonly sendTransactionsMaxRetries = 3, + private readonly gasRetryBumpBps: bigint = 1000n, // +10% per retry + private readonly sendTransactionAttemptTimeoutMs = 300_000, // 5m + ) { + if (sendTransactionsMaxRetries < 1) { + throw new Error("sendTransactionsMaxRetries must be at least 1"); + } + // Aim re-use single blockchain client for + // i.) Better connection pooling + // ii.) Memory efficient + // iii.) Single point of configuration + this.blockchainClient = createPublicClient({ + chain, + transport: http(rpcUrl, { + batch: true, + // TODO - How does this interact with our custom retry logic in sendSignedTransaction? + // Hypothesis - Default Viem timeout of 10s will kick in first. It should still retry because we are using the native Viem Timeout error. + retryCount: 3, + onFetchRequest: async (request) => { + const cloned = request.clone(); // clone before reading body + try { + const bodyText = await cloned.text(); + this.logger.debug("onFetchRequest", { + method: request.method, + url: request.url, + body: bodyText, + }); + } catch (err) { + this.logger.warn("Failed to read request body", { err }); + } + }, + onFetchResponse: async (resp) => { + const cloned = resp.clone(); // clone before reading body + try { + const bodyText = await cloned.text(); + this.logger.debug("onFetchResponse", { + status: resp.status, + statusText: resp.statusText, + body: bodyText, + }); + } catch (err) { + this.logger.warn("Failed to read response body", { err }); + } + }, + }), + batch: { + // Not sure if this will help or not, need to experiment in testnet + multicall: true, + }, + }); + } + + /** + * Gets the underlying viem PublicClient instance. + * + * @returns {PublicClient} The viem PublicClient instance used for blockchain interactions. + */ + getBlockchainClient(): PublicClient { + return this.blockchainClient; + } + + /** + * Gets the chain ID of the connected blockchain network. + * + * @returns {Promise} The chain ID of the blockchain network. + */ + async getChainId(): Promise { + return await this.blockchainClient.getChainId(); + } + + /** + * Gets the balance of an Ethereum address in wei. + * + * @param {Address} address - The Ethereum address to query the balance for. + * @returns {Promise} The balance in wei. + */ + async getBalance(address: Address): Promise { + return await this.blockchainClient.getBalance({ + address, + }); + } + + /** + * Estimates the current gas fees for EIP-1559 transactions. + * + * @returns {Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }>} An object containing the estimated maxFeePerGas and maxPriorityFeePerGas values. + */ + async estimateGasFees(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> { + const { maxFeePerGas, maxPriorityFeePerGas } = await this.blockchainClient.estimateFeesPerGas(); + return { maxFeePerGas, maxPriorityFeePerGas }; + } + + /** + * Attempts to send a signed transaction with retry-on-timeout semantics. + * On each retry, bumps gas fees by `gasRetryBumpBps` basis points. + * Uses a single nonce for all retry attempts to prevent nonce conflicts. + * Does not retry on contract revert errors or non-timeout errors, only on timeout. + * + * @param {Address} contractAddress - The address of the contract to interact with. + * @param {Hex} calldata - The encoded function call data. + * @param {bigint} [value=0n] - The amount of ether to send with the transaction (default: 0). + * @returns {Promise} The transaction receipt if successful. + * @throws {ContractFunctionRevertedError} If the contract call reverts (not retried). + * @throws {Error} If retry attempts are exhausted or a non-timeout error occurs. + */ + async sendSignedTransaction( + contractAddress: Address, + calldata: Hex, + value: bigint = 0n, + ): Promise { + this.logger.debug("sendSignedTransaction started"); + let gasMultiplierBps = MAX_BPS; // Start at 100% + let maxFeePerGas = 0n; + let maxPriorityFeePerGas = 0n; + let lastError: unknown; + + // Use a single nonce for all retries. + const [nonce, gasLimit, chainId] = await Promise.all([ + this.blockchainClient.getTransactionCount({ address: this.contractSignerClient.getAddress() }), + this.blockchainClient.estimateGas({ + account: this.contractSignerClient.getAddress(), + to: contractAddress, + data: calldata, + value, + }), + this.getChainId(), + ]); + + for (let attempt = 1; attempt <= this.sendTransactionsMaxRetries; attempt += 1) { + // Try to send tx with a timeout + try { + // Get new fee estimate each time + const { maxFeePerGas: maxFeePerGasCandidate, maxPriorityFeePerGas: maxPriorityFeePerGasCandidate } = + await this.estimateGasFees(); + + // Use the highest fee estimate retrieved + maxFeePerGas = maxFeePerGasCandidate > maxFeePerGas ? maxFeePerGasCandidate : maxFeePerGas; + maxPriorityFeePerGas = + maxPriorityFeePerGasCandidate > maxPriorityFeePerGas ? maxPriorityFeePerGasCandidate : maxPriorityFeePerGas; + + const receipt = await withTimeout( + () => + this._sendSignedEIP1559Transaction( + contractAddress, + calldata, + value, + nonce, + gasLimit, + chainId, + maxFeePerGas, + maxPriorityFeePerGas, + gasMultiplierBps, + ), + { + timeout: this.sendTransactionAttemptTimeoutMs, + signal: false, // don’t try to abort, just reject + errorInstance: new TimeoutError({ + body: { message: "sendSignedTransaction attempt timed out" }, + url: "local:sendSignedTransaction", + }), + }, + ); + this.logger.debug(`sendSignedTransaction succeeded`, { receipt }); + return receipt; + } catch (error) { + if (error instanceof BaseError && !this._shouldRetryViemSendRawTranasctionError(error)) { + const decodedError = error.walk(); + this.logger.error("sendSignedTransaction failed and will not be retried", { decodedError }); + throw decodedError; + } + if (attempt >= this.sendTransactionsMaxRetries) { + this.logger.error( + `sendSignedTransaction retry attempts exhausted sendTransactionsMaxRetries=${this.sendTransactionsMaxRetries}`, + { error }, + ); + throw error; + } + + this.logger.warn( + `sendSignedTransaction retry attempt failed attempt=${attempt} sendTransactionsMaxRetries=${this.sendTransactionsMaxRetries}`, + { error }, + ); + lastError = error; + // Compound gas for next retry + gasMultiplierBps = (gasMultiplierBps * (MAX_BPS + this.gasRetryBumpBps)) / MAX_BPS; + } + } + + // Unreachable but required to simplify TypeScript return type + throw lastError; + } + + /** + * Internal method that sends a signed EIP-1559 transaction to the blockchain. + * Signs the transaction using the contract signer client, serializes it, and broadcasts it. + * Waits for the transaction receipt before returning. + * + * @param {Address} contractAddress - The address of the contract to interact with. + * @param {Hex} calldata - The encoded function call data. + * @param {bigint} value - The amount of ether to send with the transaction. + * @param {number} nonce - The transaction nonce. + * @param {bigint} gasLimit - The estimated gas limit for the transaction. + * @param {number} chainId - The chain ID of the blockchain network. + * @param {bigint} maxFeePerGas - The maximum fee per gas (EIP-1559). + * @param {bigint} maxPriorityFeePerGas - The maximum priority fee per gas (EIP-1559). + * @param {bigint} gasMultiplierBps - Gas multiplier in basis points to apply to gas values. + * @returns {Promise} The transaction receipt after the transaction is mined. + */ + private async _sendSignedEIP1559Transaction( + contractAddress: Address, + calldata: Hex, + value: bigint, + nonce: number, + gasLimit: bigint, + chainId: number, + maxFeePerGas: bigint, + maxPriorityFeePerGas: bigint, + gasMultiplierBps: bigint, + ): Promise { + const tx: TransactionSerializableEIP1559 = { + to: contractAddress, + type: "eip1559", + data: calldata, + chainId: chainId, + gas: (gasLimit * gasMultiplierBps) / MAX_BPS, + maxFeePerGas: (maxFeePerGas * gasMultiplierBps) / MAX_BPS, + maxPriorityFeePerGas: (maxPriorityFeePerGas * gasMultiplierBps) / MAX_BPS, + nonce, + value, + }; + this.logger.debug("_sendSignedTransaction tx for signing", { tx }); + const signature = await this.contractSignerClient.sign(tx); + const serializedTransaction = serializeTransaction(tx, parseSignature(signature)); + + this.logger.debug( + `_sendSignedTransaction - sending raw transaction serializedTransaction=${serializedTransaction}`, + ); + const txHash = await sendRawTransaction(this.blockchainClient, { serializedTransaction }); + const receipt = await waitForTransactionReceipt(this.blockchainClient, { hash: txHash }); + return receipt; + } + + /** + * Determines whether a viem sendRawTransaction error should be retried. + * In general we retry server-side errors, and exclude client-side errors from retries. + * + * Uses error.walk() to traverse the error chain and find errors with specific properties + * (code, status) or error types. This is more robust than checking only the root error. + * + * @param {BaseError} error - The error to evaluate for retry eligibility. + * @returns {boolean} True if the error should be retried, false otherwise. + */ + private _shouldRetryViemSendRawTranasctionError(error: BaseError): boolean { + // We don't want to retry our own timeout + // But Viem internal retry should be ok - TODO test for conflict here + if (error instanceof TimeoutError) { + return false; + } + + // Check RPC error codes using walk() to find error with code property in the error chain + const errorWithCode = error.walk((err) => typeof (err as { code?: unknown }).code === "number"); + if (errorWithCode) { + const code = (errorWithCode as unknown as { code: number }).code; + + // Explicitly retry these RPC error codes + // -32603 InternalRpcError + // -32005 LimitExceededRpcError + // -32002 ResourceUnavailableRpcError + // -1 Unknown error + // 4900 ProviderDisconnectedError + // 4901 ChainDisconnectedError + if (code === -32603 || code === -32005 || code === -32002 || code === -1 || code === 4900 || code === 4901) { + return true; + } + + // Explicitly do NOT retry these RPC error codes + if ( + code === -32700 || // ParseRpcError + code === -32600 || // InvalidRequestRpcError + code === -32601 || // MethodNotFoundRpcError + code === -32602 || // InvalidParamsRpcError + code === -32000 || // InvalidInputRpcError + code === -32001 || // ResourceNotFoundRpcError + code === -32003 || // TransactionRejectedRpcError + code === -32004 || // MethodNotSupportedRpcError + code === -32006 || // JsonRpcVersionUnsupportedError + code === -32015 || // VM execution error (Solidity revert) + code === 4001 || // UserRejectedRequestError + code === 5000 || // UserRejectedRequestError (CAIP-25) + code === 4100 || // UnauthorizedProviderError + code === 4200 || // UnsupportedProviderMethodError + code === 4902 || // SwitchChainError + (code >= 5700 && code <= 5760) // Various capability errors (5700-5760) + ) { + return false; + } + + // For errors with codes not in explicit retry/non-retry lists above, default to retry + // This follows the guide's default behavior: retry unknown errors + return true; + } + + // Check HTTP status codes using walk() to find error with status property in the error chain + const errorWithStatus = error.walk((err) => typeof (err as { status?: unknown }).status === "number"); + if (errorWithStatus) { + const status = (errorWithStatus as unknown as { status: number }).status; + // Retry on these HTTP status codes + if ([408, 429, 500, 502, 503, 504].includes(status)) { + return true; + } + // Do not retry on other HTTP status codes + return false; + } + + // Check specific error types using walk() to find errors by name in the error chain + // Retry WebSocketRequestError and UnknownRpcError + if ( + error.walk((err) => (err as { name?: string }).name === "WebSocketRequestError") || + error.walk((err) => (err as { name?: string }).name === "UnknownRpcError") + ) { + return true; + } + + // Default behavior - retry unknown errors + return true; + } +} diff --git a/ts-libs/linea-shared-utils/src/clients/ViemWalletSignerClientAdapter.ts b/ts-libs/linea-shared-utils/src/clients/ViemWalletSignerClientAdapter.ts new file mode 100644 index 0000000000..ecbe1ce43e --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/ViemWalletSignerClientAdapter.ts @@ -0,0 +1,97 @@ +import { + Account, + Address, + Chain, + createWalletClient, + Hex, + http, + parseTransaction, + serializeSignature, + TransactionSerializable, + WalletClient, +} from "viem"; +import { IContractSignerClient } from "../core/client/IContractSignerClient"; +import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts"; +import { ILogger } from "../logging/ILogger"; + +/** + * Adapter that wraps viem's WalletClient to provide contract signing functionality. + * Uses a private key to create a wallet account and sign transactions. + */ +export class ViemWalletSignerClientAdapter implements IContractSignerClient { + private readonly account: Account; + private readonly address: Address; + private readonly wallet: WalletClient; + + /** + * Creates a new ViemWalletSignerClientAdapter instance. + * + * @param {ILogger} logger - The logger instance for logging signing operations. + * @param {string} rpcUrl - The RPC URL for the blockchain network. + * @param {Hex} privateKey - The private key in hex format to use for signing. + * @param {Chain} chain - The blockchain chain configuration. + */ + constructor( + private readonly logger: ILogger, + rpcUrl: string, + privateKey: Hex, + chain: Chain, + ) { + this.account = privateKeyToAccount(privateKey); + this.address = privateKeyToAddress(privateKey); + this.wallet = createWalletClient({ + account: this.account, + chain, + transport: http(rpcUrl), + }); + } + + /** + * Signs a transaction using the wallet's private key. + * Strips any existing signature fields from the transaction before signing. + * + * @param {TransactionSerializable} tx - The transaction to sign (signature fields will be removed if present). + * @returns {Promise} The serialized signature as a hex string. + * @throws {Error} If the signature components (r, s, yParity) are missing after signing. + */ + async sign(tx: TransactionSerializable): Promise { + this.logger.debug("sign started...", { tx }); + // Remove any signature fields if they exist on the object + // 'as any' required to avoid enforcing strict structural validation + // Fine because we are only removing fields, not depending on them existing + // Practical way to strip off optional keys from a union type + const { r: r_void, s: s_void, v: v_void, yParity: yParity_void, ...unsigned } = tx as any; // eslint-disable-line @typescript-eslint/no-explicit-any + void r_void; + void s_void; + void v_void; + void yParity_void; + + const serializedSignedTx = await this.wallet.signTransaction({ ...unsigned }); + const parsedTx = parseTransaction(serializedSignedTx); + this.logger.debug("sign", { parsedTx }); + const { r, s, yParity } = parsedTx; + // TODO - Better error handling + if (!r || !s || yParity === undefined) { + this.logger.error("sign - r, s or yParity missing"); + throw new Error("sign - r, s or yParity missing"); + } + + const signatureHex = serializeSignature({ + r, + s, + yParity, + }); + + this.logger.debug(`sign completed signatureHex=${signatureHex}`); + return signatureHex; + } + + /** + * Gets the Ethereum address associated with the wallet's private key. + * + * @returns {Address} The Ethereum address derived from the private key. + */ + getAddress(): Address { + return this.address; + } +} diff --git a/ts-libs/linea-shared-utils/src/clients/Web3SignerClientAdapter.ts b/ts-libs/linea-shared-utils/src/clients/Web3SignerClientAdapter.ts new file mode 100644 index 0000000000..4b646c2a9a --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/Web3SignerClientAdapter.ts @@ -0,0 +1,143 @@ +import axios from "axios"; +import { Agent } from "https"; +import { Address, Hex, serializeTransaction, TransactionSerializable } from "viem"; +import { IContractSignerClient } from "../core/client/IContractSignerClient"; +import { publicKeyToAddress } from "viem/accounts"; +import forge from "node-forge"; +import { readFileSync } from "fs"; +import path from "path"; +import { ILogger } from "../logging/ILogger"; +import { getModuleDir } from "../utils/file"; + +/** + * Adapter for Web3Signer service that provides contract signing functionality via remote API. + * Uses HTTPS client authentication with P12 keystore and trusted store certificates. + */ +export class Web3SignerClientAdapter implements IContractSignerClient { + private readonly agent: Agent; + /** + * Creates a new Web3SignerClientAdapter instance. + * + * @param {ILogger} logger - The logger instance for logging signing operations. + * @param {string} web3SignerUrl - The base URL of the Web3Signer service. + * @param {Hex} web3SignerPublicKey - The public key in hex format for the signing key. + * @param {string} web3SignerKeystorePath - Path to the P12 keystore file for client authentication. + * @param {string} web3SignerKeystorePassphrase - Passphrase for the keystore file. + * @param {string} web3SignerTrustedStorePath - Path to the P12 trusted store file for CA certificate. + * @param {string} web3SignerTrustedStorePassphrase - Passphrase for the trusted store file. + */ + constructor( + private readonly logger: ILogger, + private readonly web3SignerUrl: string, + private readonly web3SignerPublicKey: Hex, + web3SignerKeystorePath: string, + web3SignerKeystorePassphrase: string, + web3SignerTrustedStorePath: string, + web3SignerTrustedStorePassphrase: string, + ) { + this.logger.info("Initialising HTTPS agent"); + this.agent = this.getHttpsAgent( + web3SignerKeystorePath, + web3SignerKeystorePassphrase, + web3SignerTrustedStorePath, + web3SignerTrustedStorePassphrase, + ); + } + + /** + * Signs a transaction by sending it to the remote Web3Signer service. + * The transaction is serialized and sent via HTTPS POST request with client certificate authentication. + * + * @param {TransactionSerializable} tx - The transaction to sign. + * @returns {Promise} The signature as a hex string returned from the Web3Signer service. + */ + async sign(tx: TransactionSerializable): Promise { + this.logger.debug("Signing transaction via remote Web3Signer"); + const { data } = await axios.post( + `${this.web3SignerUrl}/api/v1/eth1/sign/${this.web3SignerPublicKey}`, + { + data: serializeTransaction(tx), + }, + { httpsAgent: this.agent }, + ); + this.logger.debug(`Signing successful signature=${data}`); + return data; + } + + /** + * Gets the Ethereum address associated with the Web3Signer public key. + * + * @returns {Address} The Ethereum address derived from the public key. + */ + getAddress(): Address { + // Seems that Viem `publicKeyToAddress` expects the secp256k1 pubkey with the 0x04 format identifier byte - `0x04 || <32-byte X> || <32-byte Y>` + // However Web3Signer does not accept the 0x04 format identifier byte, and instead expects either: + // - `<32-byte X> || <32-byte Y>` (no prefix) + // - `0x<32-byte X> || <32-byte Y>` (with hex prefix) + const pubkeyWithoutPrefix = this.web3SignerPublicKey.startsWith("0x") + ? this.web3SignerPublicKey.slice(2) + : this.web3SignerPublicKey; + const uncompressedPubkeyWithFormatByte = `0x04${pubkeyWithoutPrefix}` as const; + return publicKeyToAddress(uncompressedPubkeyWithFormatByte); + } + + /** + * Converts a P12 certificate to PEM format. + * + * @param {string | forge.util.ByteStringBuffer} p12base64 - The P12 certificate data in base64 or ByteStringBuffer format. + * @param {string} password - The password to decrypt the P12 certificate. + * @returns {{ pemCertificate: string }} An object containing the PEM-formatted certificate. + */ + private convertToPem(p12base64: string | forge.util.ByteStringBuffer, password: string) { + const p12Asn1 = forge.asn1.fromDer(p12base64); + const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, false, password); + return this.getCertificateFromP12(p12); + } + + /** + * Extracts a PEM certificate from a PKCS12 object. + * + * @param {forge.pkcs12.Pkcs12Pfx} p12 - The PKCS12 object containing the certificate. + * @returns {{ pemCertificate: string }} An object containing the PEM-formatted certificate. + * @throws {Error} If the certificate is not found in the P12 object. + */ + private getCertificateFromP12(p12: forge.pkcs12.Pkcs12Pfx) { + const certData = p12.getBags({ bagType: forge.pki.oids.certBag }); + const certificate = certData[forge.pki.oids.certBag]?.[0]; + if (!certificate?.cert) { + throw new Error("Certificate not found in P12"); + } + + const pemCertificate = forge.pki.certificateToPem(certificate.cert); + return { pemCertificate }; + } + + /** + * Creates an HTTPS agent configured with client certificate authentication. + * Loads the keystore (client certificate) and trusted store (CA certificate) from P12 files. + * + * @param {string} keystorePath - Path to the P12 keystore file for client authentication. + * @param {string} keystorePassphrase - Passphrase for the keystore file. + * @param {string} trustedStorePath - Path to the P12 trusted store file for CA certificate. + * @param {string} trustedStorePassphrase - Passphrase for the trusted store file. + * @returns {Agent} An HTTPS agent configured with the client and CA certificates. + */ + private getHttpsAgent( + keystorePath: string, + keystorePassphrase: string, + trustedStorePath: string, + trustedStorePassphrase: string, + ): Agent { + const moduleDir = getModuleDir(); + const trustedStoreFile = readFileSync(path.resolve(moduleDir, trustedStorePath), { encoding: "binary" }); + this.logger.debug("Loading trusted store certificate"); + + const { pemCertificate } = this.convertToPem(trustedStoreFile, trustedStorePassphrase); + + return new Agent({ + pfx: readFileSync(path.resolve(moduleDir, keystorePath)), + passphrase: keystorePassphrase, + ca: pemCertificate, + }); + } +} diff --git a/ts-libs/linea-shared-utils/src/clients/__tests__/BeaconNodeApiClient.test.ts b/ts-libs/linea-shared-utils/src/clients/__tests__/BeaconNodeApiClient.test.ts new file mode 100644 index 0000000000..f089f04886 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/__tests__/BeaconNodeApiClient.test.ts @@ -0,0 +1,81 @@ +import axios from "axios"; +import { mock } from "jest-mock-extended"; +import { BeaconNodeApiClient } from "../BeaconNodeApiClient"; +import { ILogger } from "../../logging/ILogger"; +import { IRetryService } from "../../core/services/IRetryService"; +import { PendingPartialWithdrawal } from "../../core/client/IBeaconNodeApiClient"; + +jest.mock("axios"); + +const mockedAxios = axios as jest.Mocked; + +describe("BeaconNodeApiClient", () => { + const rpcURL = "http://localhost:5051"; + let logger: jest.Mocked; + let retryService: jest.Mocked; + let client: BeaconNodeApiClient; + + beforeEach(() => { + logger = { + name: "test", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + retryService = mock(); + retryService.retry.mockImplementation(async (fn) => fn()); + mockedAxios.get.mockReset(); + + client = new BeaconNodeApiClient(logger, retryService, rpcURL); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("fetches and returns pending partial withdrawals", async () => { + const responseData: PendingPartialWithdrawal[] = [ + { validator_index: 42, amount: 1234n, withdrawable_epoch: 10 }, + { validator_index: 43, amount: 5678n, withdrawable_epoch: 11 }, + ]; + mockedAxios.get.mockResolvedValue({ data: { data: responseData } }); + + const result = await client.getPendingPartialWithdrawals(); + + const expectedUrl = `${rpcURL}/eth/v1/beacon/states/head/pending_partial_withdrawals`; + expect(result).toEqual(responseData); + expect(retryService.retry).toHaveBeenCalledTimes(1); + expect(retryService.retry.mock.calls[0][0]).toEqual(expect.any(Function)); + expect(mockedAxios.get).toHaveBeenCalledWith(expectedUrl); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + `getPendingPartialWithdrawals making GET request to url=${expectedUrl}`, + ); + expect(logger.debug).toHaveBeenNthCalledWith(2, "getPendingPartialWithdrawals return value", { + returnVal: responseData, + }); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("logs an error and returns undefined when response payload is empty", async () => { + mockedAxios.get.mockResolvedValue({ data: undefined }); + + const result = await client.getPendingPartialWithdrawals(); + + const expectedUrl = `${rpcURL}/eth/v1/beacon/states/head/pending_partial_withdrawals`; + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith("Failed GET request to", expectedUrl); + expect(logger.debug).toHaveBeenCalledTimes(1); + }); + + it("returns an empty array when the API responds with no withdrawals", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }); + + const result = await client.getPendingPartialWithdrawals(); + + expect(result).toEqual([]); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/clients/__tests__/OAuth2TokenClient.test.ts b/ts-libs/linea-shared-utils/src/clients/__tests__/OAuth2TokenClient.test.ts new file mode 100644 index 0000000000..58421256e7 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/__tests__/OAuth2TokenClient.test.ts @@ -0,0 +1,193 @@ +import axios from "axios"; +import { OAuth2TokenClient } from "../OAuth2TokenClient"; +import { ILogger } from "../../logging/ILogger"; +import { IRetryService } from "../../core/services/IRetryService"; +import { getCurrentUnixTimestampSeconds } from "../../utils/time"; + +jest.mock("axios"); +jest.mock("../../utils/time", () => ({ + getCurrentUnixTimestampSeconds: jest.fn(), +})); + +const mockedAxios = axios as jest.Mocked; +const getCurrentUnixTimestampSecondsMock = getCurrentUnixTimestampSeconds as jest.MockedFunction< + typeof getCurrentUnixTimestampSeconds +>; + +describe("OAuth2TokenClient", () => { + const tokenUrl = "https://auth.local/token"; + const clientId = "client-id"; + const clientSecret = "client-secret"; + const audience = "api-audience"; + const grantType = "client_credentials"; + + let logger: jest.Mocked; + let retryService: jest.Mocked; + let client: OAuth2TokenClient; + + beforeEach(() => { + logger = { + name: "test", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }; + retryService = { + retry: jest.fn(async (fn) => fn()), + } as unknown as jest.Mocked; + mockedAxios.post.mockReset(); + getCurrentUnixTimestampSecondsMock.mockReset(); + + client = new OAuth2TokenClient(logger, retryService, tokenUrl, clientId, clientSecret, audience, grantType, 60); + }); + + it("returns the cached token when it is still valid", async () => { + (client as any).bearerToken = "Bearer cached-token"; + (client as any).tokenExpiresAtSeconds = 500; + getCurrentUnixTimestampSecondsMock.mockReturnValue(430); + + const token = await client.getBearerToken(); + + expect(token).toBe("Bearer cached-token"); + expect(logger.info).toHaveBeenCalledWith("getBearerToken cache-hit"); + expect(retryService.retry).not.toHaveBeenCalled(); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it("requests a new token and stores expiration based on expires_in", async () => { + getCurrentUnixTimestampSecondsMock.mockReturnValueOnce(1_000); + mockedAxios.post.mockResolvedValue({ + data: { + access_token: "new-token", + token_type: "Custom", + expires_in: 120, + }, + }); + + const token = await client.getBearerToken(); + + expect(token).toBe("Custom new-token"); + + expect(retryService.retry).toHaveBeenCalledTimes(1); + const expectedBody = { + client_id: clientId, + client_secret: clientSecret, + audience, + grant_type: grantType, + }; + expect(mockedAxios.post).toHaveBeenCalledWith( + tokenUrl, + expectedBody, + expect.objectContaining({ + headers: { "content-type": "application/json" }, + }), + ); + + expect((client as any).bearerToken).toBe("Custom new-token"); + expect((client as any).tokenExpiresAtSeconds).toBe(1_120); + expect(logger.info).toHaveBeenNthCalledWith(1, "getBearerToken requesting new token"); + expect(logger.info).toHaveBeenNthCalledWith( + 2, + "getBearerToken successfully retrived new OAuth2 Bearer token tokenExpiresAtSeconds=1120", + ); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("requests a new token and respects expires_at when provided", async () => { + mockedAxios.post.mockResolvedValue({ + data: { + access_token: "expires-at-token", + token_type: undefined, + expires_at: 9_999, + }, + }); + + const token = await client.getBearerToken(); + + expect(token).toBe("Bearer expires-at-token"); + expect((client as any).tokenExpiresAtSeconds).toBe(9_999); + expect(logger.info).toHaveBeenNthCalledWith(1, "getBearerToken requesting new token"); + expect(logger.info).toHaveBeenNthCalledWith( + 2, + "getBearerToken successfully retrived new OAuth2 Bearer token tokenExpiresAtSeconds=9999", + ); + }); + + it("logs an error and returns undefined when access_token is missing", async () => { + mockedAxios.post.mockResolvedValue({ + data: {}, + }); + + const token = await client.getBearerToken(); + + expect(token).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith("Failed to retrieve OAuth2 access token"); + expect(logger.info).toHaveBeenCalledWith("getBearerToken requesting new token"); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining("successfully retrived")); + }); + + it("returns undefined and logs when expires_at is already elapsed", async () => { + getCurrentUnixTimestampSecondsMock.mockReturnValue(2_000); + mockedAxios.post.mockResolvedValue({ + data: { + access_token: "expired-token", + expires_at: 1_000, + }, + }); + + const token = await client.getBearerToken(); + + expect(token).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith("OAuth2 access token already expired at 1000"); + expect(logger.info).toHaveBeenCalledWith("getBearerToken requesting new token"); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining("successfully retrived")); + }); + + it("returns undefined and logs when expiry metadata is missing", async () => { + mockedAxios.post.mockResolvedValue({ + data: { + access_token: "no-expiry-token", + }, + }); + + const token = await client.getBearerToken(); + + expect(token).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith("OAuth2 access token did not provide expiry data"); + expect(logger.info).toHaveBeenCalledWith("getBearerToken requesting new token"); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining("successfully retrived")); + }); + + it("uses default grant type and expiry buffer when omitted", async () => { + client = new OAuth2TokenClient(logger, retryService, tokenUrl, clientId, clientSecret, audience); + + expect((client as any).grantType).toBe("client_credentials"); + expect((client as any).expiryBufferSeconds).toBe(60); + + mockedAxios.post.mockResolvedValueOnce({ + data: { + access_token: "default-token", + expires_in: 120, + }, + }); + + getCurrentUnixTimestampSecondsMock.mockReturnValueOnce(1_000).mockReturnValueOnce(1_000); + + const token = await client.getBearerToken(); + expect(token).toBe("Bearer default-token"); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith( + tokenUrl, + { + client_id: clientId, + client_secret: clientSecret, + audience, + grant_type: grantType, + }, + expect.objectContaining({ + headers: { "content-type": "application/json" }, + }), + ); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/clients/__tests__/ViemBlockchainClientAdapter.test.ts b/ts-libs/linea-shared-utils/src/clients/__tests__/ViemBlockchainClientAdapter.test.ts new file mode 100644 index 0000000000..f09b03d560 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/__tests__/ViemBlockchainClientAdapter.test.ts @@ -0,0 +1,1350 @@ +import { + Address, + Chain, + ContractFunctionRevertedError, + Hex, + PublicClient, + TimeoutError, + BaseError, + createPublicClient, + http, + parseSignature, + serializeTransaction, + withTimeout, +} from "viem"; +import { sendRawTransaction, waitForTransactionReceipt } from "viem/actions"; +import { ViemBlockchainClientAdapter } from "../ViemBlockchainClientAdapter"; +import { ILogger } from "../../logging/ILogger"; +import { IContractSignerClient } from "../../core/client/IContractSignerClient"; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + http: jest.fn(() => "mock-transport"), + createPublicClient: jest.fn(), + withTimeout: jest.fn((fn: any) => fn({ signal: null })), + serializeTransaction: jest.fn(), + parseSignature: jest.fn(), + }; +}); + +jest.mock("viem/actions", () => ({ + sendRawTransaction: jest.fn(), + waitForTransactionReceipt: jest.fn(), +})); + +const mockedHttp = http as jest.MockedFunction; +const mockedCreatePublicClient = createPublicClient as jest.MockedFunction; +const mockedWithTimeout = withTimeout as unknown as jest.MockedFunction; +const mockedSerializeTransaction = serializeTransaction as jest.MockedFunction; +const mockedParseSignature = parseSignature as jest.MockedFunction; +const mockedSendRawTransaction = sendRawTransaction as jest.MockedFunction; +const mockedWaitForTransactionReceipt = waitForTransactionReceipt as jest.MockedFunction< + typeof waitForTransactionReceipt +>; + +const createLogger = (): jest.Mocked => + ({ + name: "viem-blockchain-client", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }) as jest.Mocked; + +const createContractSignerClient = (): jest.Mocked => + ({ + getAddress: jest.fn().mockReturnValue("0xSIGNER"), + sign: jest.fn(), + }) as unknown as jest.Mocked; + +const createPublicClientMock = () => + ({ + getChainId: jest.fn(), + getBalance: jest.fn(), + estimateFeesPerGas: jest.fn(), + getTransactionCount: jest.fn(), + estimateGas: jest.fn(), + }) as unknown as jest.Mocked; + +describe("ViemBlockchainClientAdapter", () => { + const rpcUrl = "https://rpc.local"; + const chain = { id: 11155111 } as Chain; + const contractAddress = "0xCONTRACT" as Address; + const calldata = "0xabcdef" as Hex; + + let logger: jest.Mocked; + let contractSignerClient: jest.Mocked; + let publicClientMock: jest.Mocked; + let adapter: ViemBlockchainClientAdapter; + + beforeEach(() => { + jest.clearAllMocks(); + logger = createLogger(); + contractSignerClient = createContractSignerClient(); + publicClientMock = createPublicClientMock(); + mockedHttp.mockReturnValue("mock-transport" as any); + mockedCreatePublicClient.mockReturnValue(publicClientMock as unknown as PublicClient); + mockedWithTimeout.mockImplementation((fn: any, _opts?: any) => fn({ signal: null })); + mockedParseSignature.mockReturnValue({ r: "0x1", s: "0x2", yParity: 1 } as any); + mockedSerializeTransaction.mockReturnValue("0x02serialized"); + mockedSendRawTransaction.mockResolvedValue("0xHASH"); + mockedWaitForTransactionReceipt.mockResolvedValue({ transactionHash: "0xHASH", status: "success" } as any); + + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 3, 1000n, 300_000); + }); + + it("logs request and response bodies in viem transport hooks", async () => { + expect(mockedHttp).toHaveBeenCalled(); + const transportConfig = mockedHttp.mock.calls[0]?.[1] as { + onFetchRequest: (request: any) => Promise; + onFetchResponse: (response: any) => Promise; + }; + expect(transportConfig).toBeDefined(); + + const requestBody = JSON.stringify({ foo: "bar" }); + const requestClone = { text: jest.fn().mockResolvedValue(requestBody) }; + const request = { + method: "POST", + url: "https://rpc.local", + clone: jest.fn().mockReturnValue(requestClone), + }; + + await transportConfig.onFetchRequest(request); + + expect(logger.debug).toHaveBeenCalledWith("onFetchRequest", { + method: "POST", + url: "https://rpc.local", + body: requestBody, + }); + + const failingRequest = { + clone: jest.fn().mockReturnValue({ + text: jest.fn().mockRejectedValue(new Error("request-read-fail")), + }), + }; + + await transportConfig.onFetchRequest(failingRequest); + + expect(logger.warn).toHaveBeenCalledWith("Failed to read request body", { + err: expect.any(Error), + }); + + const responseBody = JSON.stringify({ ok: true }); + const responseClone = { text: jest.fn().mockResolvedValue(responseBody) }; + const response = { + status: 200, + statusText: "OK", + clone: jest.fn().mockReturnValue(responseClone), + }; + + await transportConfig.onFetchResponse(response); + + expect(logger.debug).toHaveBeenCalledWith("onFetchResponse", { + status: 200, + statusText: "OK", + body: responseBody, + }); + + const responseError = { + clone: jest.fn().mockReturnValue({ + text: jest.fn().mockRejectedValue(new Error("read-fail")), + }), + }; + + await transportConfig.onFetchResponse(responseError); + + expect(logger.warn).toHaveBeenCalledWith("Failed to read response body", { + err: expect.any(Error), + }); + }); + + it("throws if sendTransactionsMaxRetries is less than 1", () => { + expect(() => new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 0, 1000n, 1_000)).toThrow( + "sendTransactionsMaxRetries must be at least 1", + ); + }); + + it("exposes the underlying public client", () => { + expect(adapter.getBlockchainClient()).toBe(publicClientMock); + }); + + it("delegates to public client for getChainId, getBalance, estimateGasFees", async () => { + publicClientMock.getChainId.mockResolvedValue(5); + publicClientMock.getBalance.mockResolvedValue(123n); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 40n, + maxPriorityFeePerGas: 3n, + }); + + await expect(adapter.getChainId()).resolves.toBe(5); + await expect(adapter.getBalance("0xabc" as Address)).resolves.toBe(123n); + await expect(adapter.estimateGasFees()).resolves.toEqual({ + maxFeePerGas: 40n, + maxPriorityFeePerGas: 3n, + }); + + expect(publicClientMock.getChainId).toHaveBeenCalledTimes(1); + expect(publicClientMock.getBalance).toHaveBeenCalledWith({ address: "0xabc" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(1); + }); + + it("uses default constructor parameters and default tx value", async () => { + const defaultsAdapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient); + + // Use a retryable RPC error (InternalRpcError -32603) instead of TimeoutError + const retryableError = Object.assign(new BaseError("Internal RPC error"), { code: -32603 }); + + publicClientMock.getTransactionCount.mockResolvedValue(4); + publicClientMock.estimateGas.mockResolvedValue(200n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 3n }) + .mockResolvedValueOnce({ maxFeePerGas: 8n, maxPriorityFeePerGas: 2n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw retryableError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await defaultsAdapter.sendSignedTransaction(contractAddress, calldata); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=3", + { error: retryableError }, + ); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenNthCalledWith(1, { + to: contractAddress, + type: "eip1559", + data: calldata, + chainId: chain.id, + gas: 200n, + maxFeePerGas: 10n, + maxPriorityFeePerGas: 3n, + nonce: 4, + value: 0n, + }); + expect(contractSignerClient.sign).toHaveBeenNthCalledWith(2, { + to: contractAddress, + type: "eip1559", + data: calldata, + chainId: chain.id, + gas: 220n, + maxFeePerGas: 11n, + maxPriorityFeePerGas: 3n, + nonce: 4, + value: 0n, + }); + }); + + it("successfully sends a signed transaction on the first attempt", async () => { + publicClientMock.getTransactionCount.mockResolvedValue(7); + publicClientMock.estimateGas.mockResolvedValue(21_000n); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 100n, + maxPriorityFeePerGas: 2n, + }); + publicClientMock.getChainId.mockResolvedValue(chain.id); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 10n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledWith({ + to: contractAddress, + type: "eip1559", + data: calldata, + chainId: chain.id, + gas: 21_000n, + maxFeePerGas: 100n, + maxPriorityFeePerGas: 2n, + nonce: 7, + value: 10n, + }); + expect(mockedParseSignature).toHaveBeenCalledWith("0xSIGNATURE"); + expect(mockedSerializeTransaction).toHaveBeenCalledWith( + { + to: contractAddress, + type: "eip1559", + data: calldata, + chainId: chain.id, + gas: 21_000n, + maxFeePerGas: 100n, + maxPriorityFeePerGas: 2n, + nonce: 7, + value: 10n, + }, + { r: "0x1", s: "0x2", yParity: 1 }, + ); + expect(mockedSendRawTransaction).toHaveBeenCalledWith(publicClientMock, { + serializedTransaction: "0x02serialized", + }); + expect(mockedWaitForTransactionReceipt).toHaveBeenCalledWith(publicClientMock, { hash: "0xHASH" }); + }); + + it("retries on retryable errors and applies gas bump multipliers", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 3, 1_000n, 300_000); + + // Use a retryable RPC error (ResourceUnavailableRpcError -32002) instead of TimeoutError + const retryableError = Object.assign(new BaseError("Resource unavailable"), { code: -32002 }); + + publicClientMock.getTransactionCount.mockResolvedValue(5); + publicClientMock.estimateGas.mockResolvedValue(200n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 3n }) + .mockResolvedValueOnce({ maxFeePerGas: 8n, maxPriorityFeePerGas: 2n }); + contractSignerClient.getAddress.mockReturnValue("0xSIGNER"); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw retryableError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=3", + { error: retryableError }, + ); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenNthCalledWith(1, { + to: contractAddress, + type: "eip1559", + data: calldata, + chainId: chain.id, + gas: 200n, + maxFeePerGas: 10n, + maxPriorityFeePerGas: 3n, + nonce: 5, + value: 0n, + }); + expect(contractSignerClient.sign).toHaveBeenNthCalledWith(2, { + to: contractAddress, + type: "eip1559", + data: calldata, + chainId: chain.id, + gas: 220n, + maxFeePerGas: 11n, + maxPriorityFeePerGas: 3n, + nonce: 5, + value: 0n, + }); + }); + + it("does not retry when TimeoutError is thrown", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const timeoutError = new TimeoutError({ + body: { message: "timeout" }, + url: "local:test", + }); + + publicClientMock.getTransactionCount.mockResolvedValue(9); + publicClientMock.estimateGas.mockResolvedValue(150n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 12n, + maxPriorityFeePerGas: 4n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw timeoutError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(timeoutError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(1); + }); + + // TODO: Add working test for non-BaseError errors + it.skip("retries non-BaseError errors", async () => {}); + + it("rethrows ContractFunctionRevertedError without retrying", async () => { + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(50n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 5n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + const revertError = new ContractFunctionRevertedError({ + abi: [] as any, + functionName: "test", + message: "execution reverted", + }); + (revertError as any).data = { errorName: "RevertReason" }; + // Add code -32015 (VM execution error) to make it non-retryable + Object.assign(revertError, { code: -32015 }); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw revertError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toThrow(); + + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + }); + + it("rethrows ContractFunctionRevertedError without retrying when error data is missing", async () => { + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(50n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 5n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + const revertError = new ContractFunctionRevertedError({ + abi: [] as any, + functionName: "test", + message: "execution reverted", + }); + (revertError as any).data = undefined; + // Add code -32015 (VM execution error) to make it non-retryable + Object.assign(revertError, { code: -32015 }); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw revertError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toThrow(); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + }); + + it("throws after exhausting retryable error retries", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 1_000); + + // Use a retryable RPC error (LimitExceededRpcError -32005) + const retryableError = Object.assign(new BaseError("Limit exceeded"), { code: -32005 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementation(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw retryableError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toThrow(); + + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + "sendSignedTransaction retry attempts exhausted sendTransactionsMaxRetries=2", + { error: retryableError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on retryable HTTP status codes", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + // Use a retryable HTTP status code (500 Internal Server Error) + const httpError = Object.assign(new BaseError("HTTP error"), { status: 500 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw httpError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: httpError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("does not retry on non-retryable HTTP status codes", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + // Use a non-retryable HTTP status code (400 Bad Request) + const httpError = Object.assign(new BaseError("HTTP error"), { status: 400 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw httpError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(httpError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(1); + }); + + it("retries on WebSocketRequestError", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + // Use WebSocketRequestError name + const wsError = Object.assign(new BaseError("WebSocket error"), { name: "WebSocketRequestError" }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw wsError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: wsError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on UnknownRpcError", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + // Use UnknownRpcError name + const unknownRpcError = Object.assign(new BaseError("Unknown RPC error"), { name: "UnknownRpcError" }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw unknownRpcError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: unknownRpcError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on default case (error with no code/status/name)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + // Use a BaseError without code, status, or matching name properties + const defaultError = new BaseError("Unknown error"); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw defaultError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: defaultError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on unknown RPC error code (default case - line 346)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + // Use an RPC error code that's NOT in the explicit retry or non-retry lists + // This should hit line 346 (default retry behavior) + const unknownRpcError = Object.assign(new BaseError("Unknown RPC error"), { code: 9999 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw unknownRpcError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(publicClientMock.estimateFeesPerGas).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: unknownRpcError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("does not retry on capability error code 5700 (lower boundary)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const capabilityError = Object.assign(new BaseError("Capability error"), { code: 5700 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw capabilityError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(capabilityError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on capability error code 5760 (upper boundary)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const capabilityError = Object.assign(new BaseError("Capability error"), { code: 5760 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw capabilityError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(capabilityError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("retries on RPC error code 5699 (just below capability range)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const error = Object.assign(new BaseError("RPC error"), { code: 5699 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw error; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on RPC error code 5761 (just above capability range)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const error = Object.assign(new BaseError("RPC error"), { code: 5761 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw error; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on HTTP status code 408 (Request Timeout)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const httpError = Object.assign(new BaseError("HTTP error"), { status: 408 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw httpError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: httpError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on HTTP status code 429 (Too Many Requests)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const httpError = Object.assign(new BaseError("HTTP error"), { status: 429 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw httpError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: httpError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on HTTP status code 502 (Bad Gateway)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const httpError = Object.assign(new BaseError("HTTP error"), { status: 502 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw httpError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: httpError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on HTTP status code 503 (Service Unavailable)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const httpError = Object.assign(new BaseError("HTTP error"), { status: 503 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw httpError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: httpError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("retries on HTTP status code 504 (Gateway Timeout)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const httpError = Object.assign(new BaseError("HTTP error"), { status: 504 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas + .mockResolvedValueOnce({ maxFeePerGas: 9n, maxPriorityFeePerGas: 1n }) + .mockResolvedValueOnce({ maxFeePerGas: 10n, maxPriorityFeePerGas: 1n }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout + .mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw httpError; + }) + .mockImplementationOnce(async (fn: any, _opts?: any) => fn({ signal: null })); + + const receipt = await adapter.sendSignedTransaction(contractAddress, calldata, 0n); + + expect(receipt).toEqual({ transactionHash: "0xHASH", status: "success" }); + expect(logger.warn).toHaveBeenCalledWith( + "sendSignedTransaction retry attempt failed attempt=1 sendTransactionsMaxRetries=2", + { error: httpError }, + ); + expect(mockedWithTimeout).toHaveBeenCalledTimes(2); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(2); + }); + + it("does not retry on ParseRpcError (-32700)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const parseError = Object.assign(new BaseError("Parse error"), { code: -32700 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw parseError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(parseError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on InvalidRequestRpcError (-32600)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const invalidRequestError = Object.assign(new BaseError("Invalid request"), { code: -32600 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw invalidRequestError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(invalidRequestError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on MethodNotFoundRpcError (-32601)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const methodNotFoundError = Object.assign(new BaseError("Method not found"), { code: -32601 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw methodNotFoundError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(methodNotFoundError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on InvalidParamsRpcError (-32602)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const invalidParamsError = Object.assign(new BaseError("Invalid params"), { code: -32602 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw invalidParamsError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(invalidParamsError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on InvalidInputRpcError (-32000)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const invalidInputError = Object.assign(new BaseError("Invalid input"), { code: -32000 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw invalidInputError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(invalidInputError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on ResourceNotFoundRpcError (-32001)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const resourceNotFoundError = Object.assign(new BaseError("Resource not found"), { code: -32001 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw resourceNotFoundError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(resourceNotFoundError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on TransactionRejectedRpcError (-32003)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const transactionRejectedError = Object.assign(new BaseError("Transaction rejected"), { code: -32003 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw transactionRejectedError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(transactionRejectedError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on MethodNotSupportedRpcError (-32004)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const methodNotSupportedError = Object.assign(new BaseError("Method not supported"), { code: -32004 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw methodNotSupportedError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(methodNotSupportedError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on JsonRpcVersionUnsupportedError (-32006)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const jsonRpcVersionError = Object.assign(new BaseError("JSON-RPC version unsupported"), { code: -32006 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw jsonRpcVersionError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(jsonRpcVersionError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on UserRejectedRequestError (4001)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const userRejectedError = Object.assign(new BaseError("User rejected request"), { code: 4001 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw userRejectedError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(userRejectedError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on UserRejectedRequestError CAIP-25 (5000)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const userRejectedError = Object.assign(new BaseError("User rejected request"), { code: 5000 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw userRejectedError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(userRejectedError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on UnauthorizedProviderError (4100)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const unauthorizedError = Object.assign(new BaseError("Unauthorized provider"), { code: 4100 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw unauthorizedError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(unauthorizedError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on UnsupportedProviderMethodError (4200)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const unsupportedMethodError = Object.assign(new BaseError("Unsupported provider method"), { code: 4200 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw unsupportedMethodError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(unsupportedMethodError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); + + it("does not retry on SwitchChainError (4902)", async () => { + adapter = new ViemBlockchainClientAdapter(logger, rpcUrl, chain, contractSignerClient, 2, 1_000n, 300_000); + + const switchChainError = Object.assign(new BaseError("Switch chain error"), { code: 4902 }); + + publicClientMock.getTransactionCount.mockResolvedValue(1); + publicClientMock.estimateGas.mockResolvedValue(100n); + publicClientMock.getChainId.mockResolvedValue(chain.id); + publicClientMock.estimateFeesPerGas.mockResolvedValue({ + maxFeePerGas: 9n, + maxPriorityFeePerGas: 1n, + }); + contractSignerClient.sign.mockResolvedValue("0xSIGNATURE"); + + mockedWithTimeout.mockReset(); + mockedWithTimeout.mockImplementationOnce(async (fn: any, _opts?: any) => { + await fn({ signal: null }); + throw switchChainError; + }); + + await expect(adapter.sendSignedTransaction(contractAddress, calldata, 0n)).rejects.toBe(switchChainError); + expect(logger.error).toHaveBeenCalledWith("sendSignedTransaction failed and will not be retried", { + decodedError: expect.any(Error), + }); + expect(mockedWithTimeout).toHaveBeenCalledTimes(1); + expect(contractSignerClient.sign).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/clients/__tests__/ViemWalletSignerClientAdapter.test.ts b/ts-libs/linea-shared-utils/src/clients/__tests__/ViemWalletSignerClientAdapter.test.ts new file mode 100644 index 0000000000..b48391c265 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/__tests__/ViemWalletSignerClientAdapter.test.ts @@ -0,0 +1,121 @@ +import { createWalletClient, http, parseTransaction, serializeSignature } from "viem"; +import { privateKeyToAccount, privateKeyToAddress } from "viem/accounts"; +import { ViemWalletSignerClientAdapter } from "../ViemWalletSignerClientAdapter"; +import { ILogger } from "../../logging/ILogger"; + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + http: jest.fn(() => "mock-transport"), + createWalletClient: jest.fn(), + parseTransaction: jest.fn(), + serializeSignature: jest.fn(), + }; +}); + +jest.mock("viem/accounts", () => ({ + privateKeyToAccount: jest.fn(), + privateKeyToAddress: jest.fn(), +})); + +const createLogger = (): jest.Mocked => + ({ + name: "viem-wallet-signer", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }) as jest.Mocked; + +describe("ViemWalletSignerClientAdapter", () => { + const privateKey = "0xabc" as const; + const rpcUrl = "https://rpc.local"; + const chain = { id: 111 } as any; + + let logger: jest.Mocked; + let walletSignTransaction: jest.Mock; + let client: ViemWalletSignerClientAdapter; + + const mockedHttp = http as jest.MockedFunction; + const mockedCreateWalletClient = createWalletClient as jest.MockedFunction; + const mockedParseTransaction = parseTransaction as jest.MockedFunction; + const mockedSerializeSignature = serializeSignature as jest.MockedFunction; + const mockedPrivateKeyToAccount = privateKeyToAccount as jest.MockedFunction; + const mockedPrivateKeyToAddress = privateKeyToAddress as jest.MockedFunction; + + const derivedAccount = { address: "0xACCOUNT" } as any; + const derivedAddress = "0xADDRESS" as any; + + beforeEach(() => { + logger = createLogger(); + walletSignTransaction = jest.fn(); + mockedHttp.mockReturnValue("mock-transport" as any); + mockedPrivateKeyToAccount.mockReturnValue(derivedAccount); + mockedPrivateKeyToAddress.mockReturnValue(derivedAddress); + mockedCreateWalletClient.mockReturnValue({ signTransaction: walletSignTransaction } as any); + mockedParseTransaction.mockReset(); + mockedSerializeSignature.mockReset(); + + client = new ViemWalletSignerClientAdapter(logger, rpcUrl, privateKey, chain); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("constructs wallet client with derived account and transport", () => { + expect(mockedPrivateKeyToAccount).toHaveBeenCalledWith(privateKey); + expect(mockedPrivateKeyToAddress).toHaveBeenCalledWith(privateKey); + expect(mockedHttp).toHaveBeenCalledWith(rpcUrl); + expect(mockedCreateWalletClient).toHaveBeenCalledWith({ + account: derivedAccount, + chain, + transport: "mock-transport", + }); + }); + + it("signs a transaction removing existing signature fields and returns serialized signature", async () => { + walletSignTransaction.mockResolvedValue("0xserialized"); + mockedParseTransaction.mockReturnValue({ + r: "0x1", + s: "0x2", + yParity: 1, + } as any); + mockedSerializeSignature.mockReturnValue("0xsig"); + + const tx = { + to: "0xRecipient", + value: 1n, + gas: 21_000n, + r: "0xdead", + s: "0xbeef", + v: 27n, + yParity: 0, + } as any; + + const signature = await client.sign(tx); + + expect(signature).toBe("0xsig"); + expect(logger.debug).toHaveBeenNthCalledWith(1, "sign started...", { tx }); + expect(logger.debug).toHaveBeenNthCalledWith(2, "sign", { parsedTx: { r: "0x1", s: "0x2", yParity: 1 } }); + expect(walletSignTransaction).toHaveBeenCalledWith({ to: "0xRecipient", value: 1n, gas: 21_000n }); + expect(mockedParseTransaction).toHaveBeenCalledWith("0xserialized"); + expect(mockedSerializeSignature).toHaveBeenCalledWith({ r: "0x1", s: "0x2", yParity: 1 }); + expect(logger.debug).toHaveBeenCalledWith("sign completed signatureHex=0xsig"); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it("throws when the parsed transaction is missing signature parts", async () => { + walletSignTransaction.mockResolvedValue("0xserialized"); + mockedParseTransaction.mockReturnValue({ r: undefined, s: "0x2", yParity: undefined } as any); + + await expect(client.sign({ nonce: 0n } as any)).rejects.toThrow("sign - r, s or yParity missing"); + expect(logger.error).toHaveBeenCalledWith("sign - r, s or yParity missing"); + expect(mockedSerializeSignature).not.toHaveBeenCalled(); + }); + + it("returns the derived address", () => { + expect(client.getAddress()).toBe(derivedAddress); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/clients/__tests__/Web3SignerClientAdapter.test.ts b/ts-libs/linea-shared-utils/src/clients/__tests__/Web3SignerClientAdapter.test.ts new file mode 100644 index 0000000000..8e7fcdaaed --- /dev/null +++ b/ts-libs/linea-shared-utils/src/clients/__tests__/Web3SignerClientAdapter.test.ts @@ -0,0 +1,199 @@ +import path from "path"; +import axios from "axios"; +import { Agent } from "https"; +import { Hex, serializeTransaction } from "viem"; +import { readFileSync } from "fs"; +import forge from "node-forge"; +import { Web3SignerClientAdapter } from "../Web3SignerClientAdapter"; +import { ILogger } from "../../logging/ILogger"; + +// Mock getModuleDir to avoid Jest parsing issues with import.meta.url in file.ts +jest.mock("../../utils/file", () => ({ + getModuleDir: jest.fn(() => process.cwd()), +})); + +const agentInstance = { mock: "agent-instance" } as const; + +const getBagsMock = jest.fn(); +const forgeCertificate = { subject: "CN=web3signer" }; + +jest.mock("axios", () => ({ + __esModule: true, + default: { + post: jest.fn(), + }, +})); + +jest.mock("https", () => ({ + Agent: jest.fn(), +})); + +jest.mock("fs", () => ({ + readFileSync: jest.fn(), +})); + +jest.mock("node-forge", () => ({ + __esModule: true, + default: { + asn1: { fromDer: jest.fn() }, + pkcs12: { pkcs12FromAsn1: jest.fn() }, + pki: { oids: { certBag: "certBag" }, certificateToPem: jest.fn() }, + }, +})); + +jest.mock("viem", () => { + const actual = jest.requireActual("viem"); + return { + ...actual, + serializeTransaction: jest.fn(), + }; +}); + +const AgentMock = Agent as unknown as jest.Mock; +const axiosPostMock = axios.post as jest.MockedFunction; +const serializeTransactionMock = serializeTransaction as jest.MockedFunction; +const readFileSyncMock = readFileSync as unknown as jest.Mock; +const fromDerMock = forge.asn1.fromDer as jest.Mock; +const pkcs12FromAsn1Mock = forge.pkcs12.pkcs12FromAsn1 as jest.Mock; +const certificateToPemMock = forge.pki.certificateToPem as jest.Mock; + +const createLogger = (): jest.Mocked => + ({ + name: "web3signer-client", + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }) as jest.Mocked; + +describe("Web3SignerClientAdapter", () => { + const web3SignerUrl = "https://127.0.0.1:9000"; + const web3SignerPublicKey: Hex = + "0x4a788ad6fa008beed58de6418369717d7492f37d173d70e2c26d9737e2c6eeae929452ef8602a19410844db3e200a0e73f5208fd76259a8766b73953fc3e7023"; + const keystorePassphrase = "keystore-pass"; + const truststorePassphrase = "trust-pass"; + const keystorePath = path.join(process.cwd(), "fixtures", "sequencer_client_keystore.p12"); + const truststorePath = path.join(process.cwd(), "fixtures", "web3signer_truststore.p12"); + + const keystoreBuffer = Buffer.from("KEYSTORE_PFX"); + const truststoreBinary = "TRUSTSTORE_BINARY"; + + beforeEach(() => { + jest.clearAllMocks(); + + readFileSyncMock.mockImplementation((filePath: string, options?: { encoding?: string }) => { + if (options?.encoding === "binary") { + return truststoreBinary; + } + return keystoreBuffer; + }); + + fromDerMock.mockReturnValue("ASN1_STRUCT"); + getBagsMock.mockReturnValue({ certBag: [{ cert: forgeCertificate }] }); + pkcs12FromAsn1Mock.mockReturnValue({ getBags: getBagsMock }); + certificateToPemMock.mockReturnValue("PEM_CERT"); + + AgentMock.mockImplementation(() => agentInstance); + serializeTransactionMock.mockReturnValue("0x02serialized"); + axiosPostMock.mockResolvedValue({ data: "0xsigned" } as any); + }); + + const createAdapter = (logger: jest.Mocked) => + new Web3SignerClientAdapter( + logger, + web3SignerUrl, + web3SignerPublicKey, + keystorePath, + keystorePassphrase, + truststorePath, + truststorePassphrase, + ); + + it("initialises the HTTPS agent using the provided keystore and truststore", () => { + const logger = createLogger(); + + createAdapter(logger); + + expect(logger.info).toHaveBeenCalledWith("Initialising HTTPS agent"); + expect(readFileSyncMock).toHaveBeenCalledWith(truststorePath, { encoding: "binary" }); + expect(readFileSyncMock).toHaveBeenCalledWith(keystorePath); + + expect(fromDerMock).toHaveBeenCalledWith(truststoreBinary); + expect(pkcs12FromAsn1Mock).toHaveBeenCalledWith("ASN1_STRUCT", false, truststorePassphrase); + expect(getBagsMock).toHaveBeenCalledWith({ bagType: "certBag" }); + expect(certificateToPemMock).toHaveBeenCalledWith(forgeCertificate); + expect(logger.debug).toHaveBeenCalledWith("Loading trusted store certificate"); + + expect(AgentMock).toHaveBeenCalledWith({ + pfx: keystoreBuffer, + passphrase: keystorePassphrase, + ca: "PEM_CERT", + }); + }); + + it("throws when the trusted store certificate is missing", () => { + const logger = createLogger(); + getBagsMock.mockReturnValue({}); + + expect(() => createAdapter(logger)).toThrow("Certificate not found in P12"); + expect(logger.info).toHaveBeenCalledWith("Initialising HTTPS agent"); + expect(certificateToPemMock).not.toHaveBeenCalled(); + expect(AgentMock).not.toHaveBeenCalled(); + }); + + it("signs transactions via Web3Signer", async () => { + const logger = createLogger(); + const adapter = createAdapter(logger); + const tx = { + type: "eip1559", + chainId: 1337, + nonce: 0, + gas: BigInt(21_000), + maxFeePerGas: BigInt(1_000_000_000), + maxPriorityFeePerGas: BigInt(100_000_000), + to: "0x0000000000000000000000000000000000000000", + value: BigInt(0), + data: "0x", + }; + + const signature = await adapter.sign(tx as any); + + expect(serializeTransactionMock).toHaveBeenCalledWith(tx); + expect(axiosPostMock).toHaveBeenCalledWith( + `${web3SignerUrl}/api/v1/eth1/sign/${web3SignerPublicKey}`, + { data: "0x02serialized" }, + { httpsAgent: agentInstance }, + ); + expect(logger.debug).toHaveBeenCalledWith("Signing transaction via remote Web3Signer"); + expect(logger.debug).toHaveBeenCalledWith("Signing successful signature=0xsigned"); + expect(signature).toBe("0xsigned"); + }); + + it("derives the signer address from the configured public key", () => { + const logger = createLogger(); + const adapter = createAdapter(logger); + + const address = adapter.getAddress(); + + expect(address).toBe("0xD42E308FC964b71E18126dF469c21B0d7bcb86cC"); + }); + + it("derives the signer address from the configured public key without 0x prefix", () => { + const logger = createLogger(); + const web3SignerPublicKeyWithoutPrefix: Hex = + "4a788ad6fa008beed58de6418369717d7492f37d173d70e2c26d9737e2c6eeae929452ef8602a19410844db3e200a0e73f5208fd76259a8766b73953fc3e7023" as Hex; + const adapter = new Web3SignerClientAdapter( + logger, + web3SignerUrl, + web3SignerPublicKeyWithoutPrefix, + keystorePath, + keystorePassphrase, + truststorePath, + truststorePassphrase, + ); + + const address = adapter.getAddress(); + + expect(address).toBe("0xD42E308FC964b71E18126dF469c21B0d7bcb86cC"); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/core/applications/IApplication.ts b/ts-libs/linea-shared-utils/src/core/applications/IApplication.ts new file mode 100644 index 0000000000..6a26163440 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/applications/IApplication.ts @@ -0,0 +1,4 @@ +export interface IApplication { + start(): Promise; + stop(): Promise; +} diff --git a/ts-libs/linea-shared-utils/src/core/client/IBaseContractClient.ts b/ts-libs/linea-shared-utils/src/core/client/IBaseContractClient.ts new file mode 100644 index 0000000000..a471b05c49 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/client/IBaseContractClient.ts @@ -0,0 +1,6 @@ +import { Address, GetContractReturnType } from "viem"; + +export interface IBaseContractClient { + getAddress(): Address; + getContract(): GetContractReturnType; +} diff --git a/ts-libs/linea-shared-utils/src/core/client/IBeaconNodeApiClient.ts b/ts-libs/linea-shared-utils/src/core/client/IBeaconNodeApiClient.ts new file mode 100644 index 0000000000..e11284afec --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/client/IBeaconNodeApiClient.ts @@ -0,0 +1,17 @@ +// https://ethereum.github.io/beacon-APIs/ + +export interface IBeaconNodeAPIClient { + getPendingPartialWithdrawals(): Promise; +} + +export interface PendingPartialWithdrawalResponse { + execution_optimistic: boolean; + finalized: boolean; + data: PendingPartialWithdrawal[]; +} + +export interface PendingPartialWithdrawal { + validator_index: number; + amount: bigint; + withdrawable_epoch: number; +} diff --git a/ts-libs/linea-shared-utils/src/core/client/IBlockchainClient.ts b/ts-libs/linea-shared-utils/src/core/client/IBlockchainClient.ts new file mode 100644 index 0000000000..6a5473cbe0 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/client/IBlockchainClient.ts @@ -0,0 +1,10 @@ +import { Address, Hex } from "viem"; + +// TODO - Make generic and uncoupled from Viem +export interface IBlockchainClient { + getBlockchainClient(): TClient; + estimateGasFees(): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }>; + getChainId(): Promise; + getBalance(address: Address): Promise; + sendSignedTransaction(contractAddress: Address, calldata: Hex, value?: bigint): Promise; +} diff --git a/ts-libs/linea-shared-utils/src/core/client/IContractSignerClient.ts b/ts-libs/linea-shared-utils/src/core/client/IContractSignerClient.ts new file mode 100644 index 0000000000..84b317fc04 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/client/IContractSignerClient.ts @@ -0,0 +1,6 @@ +import { Address, Hex, TransactionSerializable } from "viem"; + +export interface IContractSignerClient { + sign(tx: TransactionSerializable): Promise; + getAddress(): Address; +} diff --git a/ts-libs/linea-shared-utils/src/core/client/IOAuth2TokenClient.ts b/ts-libs/linea-shared-utils/src/core/client/IOAuth2TokenClient.ts new file mode 100644 index 0000000000..f2a2093752 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/client/IOAuth2TokenClient.ts @@ -0,0 +1,12 @@ +export interface IOAuth2TokenClient { + getBearerToken(): Promise; +} + +export interface OAuth2TokenResponse { + access_token?: string; + token_type?: string; + // Expected in seconds + expires_in?: number; + // Expected as Unix timestamp in seconds + expires_at?: number; +} diff --git a/ts-libs/linea-shared-utils/src/core/constants/blockchain.ts b/ts-libs/linea-shared-utils/src/core/constants/blockchain.ts new file mode 100644 index 0000000000..9a2b874291 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/constants/blockchain.ts @@ -0,0 +1,3 @@ +export const ONE_GWEI = 1000000000n; +export const ONE_ETHER = 1000000000000000000n; +export const WEI_PER_GWEI = ONE_GWEI; diff --git a/ts-libs/linea-shared-utils/src/core/constants/maths.ts b/ts-libs/linea-shared-utils/src/core/constants/maths.ts new file mode 100644 index 0000000000..3380f8d1da --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/constants/maths.ts @@ -0,0 +1 @@ +export const MAX_BPS = 10000n; diff --git a/ts-libs/linea-shared-utils/src/core/constants/time.ts b/ts-libs/linea-shared-utils/src/core/constants/time.ts new file mode 100644 index 0000000000..338f600596 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/constants/time.ts @@ -0,0 +1 @@ +export const MS_PER_SECOND = 1000; diff --git a/ts-libs/linea-shared-utils/src/core/services/IMetricsService.ts b/ts-libs/linea-shared-utils/src/core/services/IMetricsService.ts new file mode 100644 index 0000000000..14790c4eb0 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/services/IMetricsService.ts @@ -0,0 +1,18 @@ +import { Counter, Gauge, Histogram, MetricObjectWithValues, MetricValueWithName, Registry } from "prom-client"; + +export interface IMetricsService { + getRegistry(): Registry; + createCounter(name: TMetricName, help: string, labelNames?: string[]): Counter; + createGauge(name: TMetricName, help: string, labelNames?: string[]): Gauge; + incrementCounter(name: TMetricName, labels?: Record, value?: number): void; + setGauge(name: TMetricName, labels?: Record, value?: number): void; + incrementGauge(name: TMetricName, labels?: Record, value?: number): void; + decrementGauge(name: TMetricName, labels?: Record, value?: number): void; + getGaugeValue(name: TMetricName, labels: Record): Promise; + getCounterValue(name: TMetricName, labels: Record): Promise; + createHistogram(name: TMetricName, buckets: number[], help: string, labelNames?: string[]): Histogram; + addValueToHistogram(name: TMetricName, value: number, labels?: Record): void; + getHistogramMetricsValues( + name: TMetricName, + ): Promise> | undefined>; +} diff --git a/ts-libs/linea-shared-utils/src/core/services/IRetryService.ts b/ts-libs/linea-shared-utils/src/core/services/IRetryService.ts new file mode 100644 index 0000000000..0193d468f5 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/core/services/IRetryService.ts @@ -0,0 +1,7 @@ +export interface IRetryService { + /** + * @notice Retry an asynchronous operation until success or failure. + * @param fn An async callback returning a promise of type TReturn. + */ + retry(fn: () => Promise, timeoutMs?: number): Promise; +} diff --git a/ts-libs/linea-shared-utils/src/index.ts b/ts-libs/linea-shared-utils/src/index.ts new file mode 100644 index 0000000000..c4ef79512f --- /dev/null +++ b/ts-libs/linea-shared-utils/src/index.ts @@ -0,0 +1,26 @@ +export * from "./applications/ExpressApiApplication"; +export * from "./clients/BeaconNodeApiClient"; +export * from "./clients/OAuth2TokenClient"; +export * from "./clients/ViemWalletSignerClientAdapter"; +export * from "./clients/Web3SignerClientAdapter"; +export * from "./clients/ViemBlockchainClientAdapter"; +export * from "./core/applications/IApplication"; +export * from "./core/client/IBaseContractClient"; +export * from "./core/client/IBeaconNodeApiClient"; +export * from "./core/client/IBlockchainClient"; +export * from "./core/client/IContractSignerClient"; +export * from "./core/client/IOAuth2TokenClient"; +export * from "./core/constants/blockchain"; +export * from "./core/constants/maths"; +export * from "./core/constants/time"; +export * from "./core/services/IRetryService"; +export * from "./core/services/IMetricsService"; +export * from "./logging/ILogger"; +export * from "./logging/WinstonLogger"; +export * from "./services/ExponentialBackoffRetryService"; +export * from "./services/SingletonMetricsService"; +export * from "./utils/blockchain"; +export * from "./utils/errors"; +export * from "./utils/maths"; +export * from "./utils/time"; +export * from "./utils/string"; diff --git a/postman/src/core/utils/logging/ILogger.ts b/ts-libs/linea-shared-utils/src/logging/ILogger.ts similarity index 84% rename from postman/src/core/utils/logging/ILogger.ts rename to ts-libs/linea-shared-utils/src/logging/ILogger.ts index a4ea425817..57c626d6f6 100644 --- a/postman/src/core/utils/logging/ILogger.ts +++ b/ts-libs/linea-shared-utils/src/logging/ILogger.ts @@ -5,5 +5,4 @@ export interface ILogger { error(message: any, ...params: any[]): void; warn(message: any, ...params: any[]): void; debug(message: any, ...params: any[]): void; - warnOrError(message: any, ...params: any[]): void; } diff --git a/postman/src/utils/WinstonLogger.ts b/ts-libs/linea-shared-utils/src/logging/WinstonLogger.ts similarity index 64% rename from postman/src/utils/WinstonLogger.ts rename to ts-libs/linea-shared-utils/src/logging/WinstonLogger.ts index a7a3f35403..2dcf717c9b 100644 --- a/postman/src/utils/WinstonLogger.ts +++ b/ts-libs/linea-shared-utils/src/logging/WinstonLogger.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Logger as LoggerClass, LoggerOptions, createLogger, format, transports } from "winston"; -import { EthersError } from "ethers"; -import { serialize, isString } from "@consensys/linea-sdk"; -import { ILogger } from "../core/utils/logging/ILogger"; +import { ILogger } from "./ILogger"; +import { isString, serialize } from "../utils/string"; export class WinstonLogger implements ILogger { private logger: LoggerClass; @@ -18,7 +17,7 @@ export class WinstonLogger implements ILogger { const { align, combine, colorize, timestamp, printf, errors, splat, label } = format; this.logger = createLogger({ - level: "info", + level: options?.level ?? "info", format: combine( timestamp(), errors({ stack: true }), @@ -105,45 +104,4 @@ export class WinstonLogger implements ILogger { public debug(message: any, ...params: any[]): void { this.logger.debug(message, ...params); } - - /** - * Decides whether to log a message as a `warning` or an `error` based on its content and severity. - * - * This method is particularly useful for handling errors that may not always require immediate attention or could be retried successfully. - * - * @param {any} message - The primary log message or error object. - * @param {...any[]} params - Additional parameters or metadata to log alongside the message. - */ - public warnOrError(message: any, ...params: any[]): void { - if (this.shouldLogErrorAsWarning(message)) { - this.warn(message, ...params); - } else { - this.error(message, ...params); - } - } - - /** - * Determines whether a given error should be logged as a `warning` instead of an `error`. - * - * This decision is based on specific characteristics of the error, such as known error messages and codes that indicate a less severe issue. - * - * @param {EthersError} error - The error object to evaluate. - * @returns {boolean} `true` if the error should be logged as a `warning`, `false` otherwise. - */ - private shouldLogErrorAsWarning(error: EthersError | Error): boolean { - const isEthersError = (error: any): error is EthersError => { - return (error as EthersError).shortMessage !== undefined || (error as EthersError).code !== undefined; - }; - - if (isEthersError(error)) { - return ( - (error.shortMessage?.includes("processing response error") || - error.info?.error?.message?.includes("processing response error")) && - error.code === "SERVER_ERROR" && - error.info?.error?.code === -32603 // Internal JSON-RPC error (EIP-1474) - ); - } - - return false; - } } diff --git a/ts-libs/linea-shared-utils/src/services/ExponentialBackoffRetryService.ts b/ts-libs/linea-shared-utils/src/services/ExponentialBackoffRetryService.ts new file mode 100644 index 0000000000..0b8c8c2534 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/services/ExponentialBackoffRetryService.ts @@ -0,0 +1,121 @@ +import { IRetryService } from "../core/services/IRetryService"; +import { ILogger } from "../logging/ILogger"; +import { wait } from "../utils/time"; + +/** + * A retry service that implements exponential backoff with jitter for retrying failed operations. + * Retries are performed with increasing delays between attempts, with randomization to prevent thundering herd problems. + * + * @example + * ```ts + * const retryService = new ExponentialBackoffRetryService(logger, 3, 1000, 10000); + * const result = await retryService.retry(() => someAsyncOperation()); + * ``` + */ +export class ExponentialBackoffRetryService implements IRetryService { + /** + * Creates a new ExponentialBackoffRetryService instance. + * + * @param {ILogger} logger - The logger instance for logging retry attempts and errors. + * @param {number} [maxRetryAttempts=3] - Maximum number of retry attempts (must be at least 1). + * @param {number} [baseDelayMs=1000] - Base delay in milliseconds for exponential backoff (must be non-negative). + * @param {number} [defaultTimeoutMs=10000] - Default timeout in milliseconds for each operation attempt. + * @throws {Error} If maxRetryAttempts is less than 1 or baseDelayMs is negative. + */ + constructor( + private readonly logger: ILogger, + private readonly maxRetryAttempts: number = 3, + private readonly baseDelayMs: number = 1000, + private readonly defaultTimeoutMs: number = 10000, + ) { + if (maxRetryAttempts < 1) { + throw new Error("maxRetryAttempts must be at least 1"); + } + if (baseDelayMs < 0) { + throw new Error("baseDelay must be non-negative"); + } + } + + /** + * Retries an asynchronous operation with exponential backoff until success or max attempts are reached. + * Each retry attempt uses a timeout, and failed attempts are retried with increasing delays. + * + * @param {() => Promise} fn - An async callback returning a promise of type TReturn. + * @param {number} [timeoutMs] - Optional timeout in milliseconds for each attempt. Defaults to defaultTimeoutMs. + * @returns {Promise} The result of the successful operation. + * @throws {Error} If timeoutMs is provided and is less than or equal to 0. + * @throws {Error} If all retry attempts are exhausted, throws the last encountered error. + */ + public async retry(fn: () => Promise, timeoutMs?: number): Promise { + const effectiveTimeoutMs = timeoutMs ?? this.defaultTimeoutMs; + + if (effectiveTimeoutMs <= 0) { + throw new Error("timeoutMs must be greater than 0"); + } + + let lastError: unknown; + + for (let attempt = 1; attempt <= this.maxRetryAttempts; attempt += 1) { + try { + return await this.executeWithTimeout(fn, effectiveTimeoutMs); + } catch (error) { + if (attempt >= this.maxRetryAttempts) { + this.logger.error(`Retry attempts exhausted maxRetryAttempts=${this.maxRetryAttempts}`, { error }); + throw error; + } + + lastError = error; + this.logger.warn(`Retry attempt failed attempt=${attempt} maxRetryAttempts=${this.maxRetryAttempts}`, { + error, + }); + + const delayMs = this.getDelayMs(attempt); + this.logger.debug(`Retrying after delay=${delayMs}ms`); + await wait(delayMs); + } + } + + // Unreachable, but required to simplify TS return type. + throw lastError; + } + + /** + * Executes an async function with a timeout, rejecting if the operation takes longer than the specified timeout. + * + * @param {() => Promise} fn - The async function to execute. + * @param {number} timeoutMs - Timeout in milliseconds (must be greater than 0). + * @returns {Promise} The result of the function if it completes within the timeout. + * @throws {Error} If timeoutMs is less than or equal to 0. + * @throws {Error} If the operation times out after timeoutMs milliseconds. + */ + private executeWithTimeout(fn: () => Promise, timeoutMs: number): Promise { + if (timeoutMs <= 0) { + throw new Error("timeoutMs must be greater than 0"); + } + + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + reject(new Error(`${fn.name} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + fn() + .then(resolve) + .catch(reject) + .finally(() => clearTimeout(timeoutHandle)); + }); + } + + /** + * Calculates the delay in milliseconds for a given retry attempt using exponential backoff with jitter. + * The delay doubles with each attempt (2^(attempt-1) * baseDelayMs) and includes random jitter + * to prevent synchronized retries across multiple instances. + * + * @param {number} attempt - The current attempt number (1-indexed). + * @returns {number} The delay in milliseconds before the next retry attempt. + */ + private getDelayMs(attempt: number): number { + const exponentialDelay = this.baseDelayMs * 2 ** (attempt - 1); + const jitter = Math.random() * exponentialDelay; + return exponentialDelay + jitter; + } +} diff --git a/postman/src/application/postman/api/metrics/SingletonMetricsService.ts b/ts-libs/linea-shared-utils/src/services/SingletonMetricsService.ts similarity index 76% rename from postman/src/application/postman/api/metrics/SingletonMetricsService.ts rename to ts-libs/linea-shared-utils/src/services/SingletonMetricsService.ts index 974173c1c2..7d9ab63054 100644 --- a/postman/src/application/postman/api/metrics/SingletonMetricsService.ts +++ b/ts-libs/linea-shared-utils/src/services/SingletonMetricsService.ts @@ -7,24 +7,21 @@ import { MetricValueWithName, Registry, } from "prom-client"; -import { IMetricsService, LineaPostmanMetrics } from "../../../../core/metrics/IMetricsService"; +import { IMetricsService } from "../core/services/IMetricsService"; /** - * Take care to instantiate as a singleton because there should be only be one instance of prom-client Registry - * TODO - Implement Singleton pattern for this class - * * MetricsService class that implements the IMetricsService interface. * This class provides methods to create and manage Prometheus metrics. */ -export class SingletonMetricsService implements IMetricsService { +export class SingletonMetricsService implements IMetricsService { private readonly registry: Registry; - private readonly counters: Map>; - private readonly gauges: Map>; - private readonly histograms: Map>; + private readonly counters: Map>; + private readonly gauges: Map>; + private readonly histograms: Map>; - constructor() { + constructor(defaultLabels: Record) { this.registry = new Registry(); - this.registry.setDefaultLabels({ app: "postman" }); + this.registry.setDefaultLabels(defaultLabels); this.counters = new Map(); this.gauges = new Map(); @@ -42,7 +39,7 @@ export class SingletonMetricsService implements IMetricsService { /** * Creates counter metric */ - public createCounter(name: LineaPostmanMetrics, help: string, labelNames: string[] = []): Counter { + public createCounter(name: TMetricName, help: string, labelNames: string[] = []): Counter { if (!this.counters.has(name)) { this.counters.set( name, @@ -63,7 +60,7 @@ export class SingletonMetricsService implements IMetricsService { * @param labels - Labels for the metric * @returns Value of the counter metric */ - public async getCounterValue(name: LineaPostmanMetrics, labels: Record): Promise { + public async getCounterValue(name: TMetricName, labels: Record): Promise { const counter = this.counters.get(name); if (counter === undefined) { return undefined; @@ -84,7 +81,7 @@ export class SingletonMetricsService implements IMetricsService { * @param labelNames - Array of label names for the metric * @returns Gauge metric */ - public createGauge(name: LineaPostmanMetrics, help: string, labelNames: string[] = []): Gauge { + public createGauge(name: TMetricName, help: string, labelNames: string[] = []): Gauge { if (!this.gauges.has(name)) { this.gauges.set( name, @@ -105,7 +102,7 @@ export class SingletonMetricsService implements IMetricsService { * @param labels - Labels for the metric * @returns Value of the gauge metric */ - public async getGaugeValue(name: LineaPostmanMetrics, labels: Record): Promise { + public async getGaugeValue(name: TMetricName, labels: Record): Promise { const gauge = this.gauges.get(name); if (gauge === undefined) { @@ -127,13 +124,27 @@ export class SingletonMetricsService implements IMetricsService { * @param value - Value to increment by (default is 1) * @returns void */ - public incrementCounter(name: LineaPostmanMetrics, labels: Record = {}, value?: number): void { + public incrementCounter(name: TMetricName, labels: Record = {}, value?: number): void { const counter = this.counters.get(name); if (counter !== undefined) { counter.inc(labels, value); } } + /** + * Sets a gauge metric to a specific value. + * @param name - Name of the metric + * @param labels - Labels for the metric + * @param value - Value to set (required) + * @returns void + */ + public setGauge(name: TMetricName, labels: Record = {}, value: number): void { + const gauge = this.gauges.get(name); + if (gauge !== undefined) { + gauge.set(labels, value); + } + } + /** * Increment a gauge metric value * @param name - Name of the metric @@ -141,7 +152,7 @@ export class SingletonMetricsService implements IMetricsService { * @param value - Value to increment by (default is 1) * @returns void */ - public incrementGauge(name: LineaPostmanMetrics, labels: Record = {}, value?: number): void { + public incrementGauge(name: TMetricName, labels: Record = {}, value?: number): void { const gauge = this.gauges.get(name); if (gauge !== undefined) { gauge.inc(labels, value); @@ -155,7 +166,7 @@ export class SingletonMetricsService implements IMetricsService { * @param labels - Labels for the metric * @returns void */ - public decrementGauge(name: LineaPostmanMetrics, labels: Record = {}, value?: number): void { + public decrementGauge(name: TMetricName, labels: Record = {}, value?: number): void { const gauge = this.gauges.get(name); if (gauge !== undefined) { gauge.dec(labels, value); @@ -189,7 +200,7 @@ export class SingletonMetricsService implements IMetricsService { * @returns Histogram metric */ public createHistogram( - name: LineaPostmanMetrics, + name: TMetricName, buckets: number[], help: string, labelNames: string[] = [], @@ -215,7 +226,7 @@ export class SingletonMetricsService implements IMetricsService { * @returns Values of the histogram metric */ public async getHistogramMetricsValues( - name: LineaPostmanMetrics, + name: TMetricName, ): Promise> | undefined> { const histogram = this.histograms.get(name); @@ -231,7 +242,7 @@ export class SingletonMetricsService implements IMetricsService { * @param value - Value to add to the histogram * @param labels - Labels for the metric */ - public addValueToHistogram(name: LineaPostmanMetrics, value: number, labels: Record = {}): void { + public addValueToHistogram(name: TMetricName, value: number, labels: Record = {}): void { const histogram = this.histograms.get(name); if (histogram !== undefined) { histogram.observe(labels, value); diff --git a/ts-libs/linea-shared-utils/src/services/__tests__/ExponentialBackoffRetryService.test.ts b/ts-libs/linea-shared-utils/src/services/__tests__/ExponentialBackoffRetryService.test.ts new file mode 100644 index 0000000000..d2bea8a67d --- /dev/null +++ b/ts-libs/linea-shared-utils/src/services/__tests__/ExponentialBackoffRetryService.test.ts @@ -0,0 +1,141 @@ +jest.mock("../../utils/time", () => ({ + wait: jest.fn(() => Promise.resolve()), +})); + +import { wait } from "../../utils/time"; +import { ILogger } from "../../logging/ILogger"; +import { ExponentialBackoffRetryService } from "../ExponentialBackoffRetryService"; + +const waitMock = wait as jest.MockedFunction; + +const createLogger = (): jest.Mocked => + ({ + name: "test", + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }) as unknown as jest.Mocked; + +describe("ExponentialBackoffRetryService", () => { + beforeEach(() => { + jest.clearAllMocks(); + waitMock.mockResolvedValue(undefined); + }); + + it("throws when instantiated with maxRetryAttempts less than 1", () => { + const logger = createLogger(); + expect(() => new ExponentialBackoffRetryService(logger, 0)).toThrow("maxRetryAttempts must be at least 1"); + }); + + it("throws when instantiated with a negative base delay", () => { + const logger = createLogger(); + expect(() => new ExponentialBackoffRetryService(logger, 3, -1)).toThrow("baseDelay must be non-negative"); + }); + + it("resolves immediately when the operation succeeds on the first attempt", async () => { + const logger = createLogger(); + const service = new ExponentialBackoffRetryService(logger); + const fn = jest.fn().mockResolvedValue("ok"); + + await expect(service.retry(fn)).resolves.toBe("ok"); + + expect(fn).toHaveBeenCalledTimes(1); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(waitMock).not.toHaveBeenCalled(); + }); + + it("retries after failures and succeeds when a later attempt resolves", async () => { + const logger = createLogger(); + const service = new ExponentialBackoffRetryService(logger, 3, 100); + const error = new Error("boom"); + const fn = jest.fn().mockRejectedValueOnce(error).mockResolvedValueOnce("ok"); + const randomSpy = jest.spyOn(Math, "random").mockReturnValue(0); + + await expect(service.retry(fn)).resolves.toBe("ok"); + + expect(fn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith("Retry attempt failed attempt=1 maxRetryAttempts=3", { error }); + expect(logger.debug).toHaveBeenCalledWith("Retrying after delay=100ms"); + expect(waitMock).toHaveBeenCalledWith(100); + + randomSpy.mockRestore(); + }); + + it("propagates the last error when retry attempts are exhausted", async () => { + const logger = createLogger(); + const service = new ExponentialBackoffRetryService(logger, 2, 50); + const error = new Error("still failing"); + const fn = jest.fn().mockRejectedValue(error); + const randomSpy = jest.spyOn(Math, "random").mockReturnValue(0); + + await expect(service.retry(fn)).rejects.toBe(error); + + expect(fn).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith("Retry attempt failed attempt=1 maxRetryAttempts=2", { error }); + expect(logger.error).toHaveBeenCalledWith("Retry attempts exhausted maxRetryAttempts=2", { error }); + expect(logger.debug).toHaveBeenCalledWith("Retrying after delay=50ms"); + expect(waitMock).toHaveBeenCalledWith(50); + + randomSpy.mockRestore(); + }); + + it("throws when provided timeout is not greater than zero", async () => { + const logger = createLogger(); + const service = new ExponentialBackoffRetryService(logger); + const fn = jest.fn().mockResolvedValue(undefined); + + await expect(service.retry(fn, 0)).rejects.toThrow("timeoutMs must be greater than 0"); + }); + + it("does not perform retry side effects when timeout validation fails", async () => { + const logger = createLogger(); + const service = new ExponentialBackoffRetryService(logger, 2, 25); + const fn = jest.fn, []>(() => Promise.resolve()); + + await expect(service.retry(fn, -5)).rejects.toThrow("timeoutMs must be greater than 0"); + + expect(fn).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(waitMock).not.toHaveBeenCalled(); + }); + + it("uses the provided timeout value and times out when the operation does not resolve in time", async () => { + jest.useFakeTimers(); + const logger = createLogger(); + const service = new ExponentialBackoffRetryService(logger, 1, 10); + const fn = jest.fn( + () => + new Promise(() => { + /* never resolves */ + }), + ); + + const promise = service.retry(fn, 50); + + jest.advanceTimersByTime(50); + + await expect(promise).rejects.toThrow("mockConstructor timed out after 50ms"); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error.mock.calls[0][0]).toBe("Retry attempts exhausted maxRetryAttempts=1"); + expect(logger.error.mock.calls[0][1].error).toBeInstanceOf(Error); + expect(logger.warn).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it("throws when executeWithTimeout receives a non-positive timeout", async () => { + const logger = createLogger(); + const service = new ExponentialBackoffRetryService(logger); + const fn = jest.fn().mockResolvedValue("ok"); + + const executeWithTimeout = (service as any).executeWithTimeout.bind(service); + + expect(() => executeWithTimeout(fn, 0)).toThrow("timeoutMs must be greater than 0"); + expect(fn).not.toHaveBeenCalled(); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/services/__tests__/SingletonMetricsService.test.ts b/ts-libs/linea-shared-utils/src/services/__tests__/SingletonMetricsService.test.ts new file mode 100644 index 0000000000..7c72217210 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/services/__tests__/SingletonMetricsService.test.ts @@ -0,0 +1,198 @@ +import { Counter, Gauge, Histogram, Registry } from "prom-client"; +import { IMetricsService } from "../../core/services/IMetricsService"; +import { SingletonMetricsService } from "../SingletonMetricsService"; + +export enum ExampleMetrics { + ExampleMetrics = "ExampleMetrics", +} + +describe("SingletonMetricsService", () => { + let metricService: IMetricsService; + + beforeEach(() => { + metricService = new SingletonMetricsService({ app: "app" }); + }); + + it("should create a counter", () => { + const counter = metricService.createCounter(ExampleMetrics.ExampleMetrics, "A test counter"); + expect(counter).toBeInstanceOf(Counter); + }); + + it("should reuse an existing counter instance", () => { + const counter = metricService.createCounter(ExampleMetrics.ExampleMetrics, "A test counter"); + const counterAgain = metricService.createCounter(ExampleMetrics.ExampleMetrics, "A test counter"); + expect(counterAgain).toBe(counter); + }); + + it("should increment a counter", async () => { + const counter = metricService.createCounter(ExampleMetrics.ExampleMetrics, "A test counter"); + metricService.incrementCounter(ExampleMetrics.ExampleMetrics, {}, 1); + expect((await counter.get()).values[0].value).toBe(1); + }); + + it("should increment a counter using default labels and value", async () => { + metricService.createCounter(ExampleMetrics.ExampleMetrics, "A test counter"); + metricService.incrementCounter(ExampleMetrics.ExampleMetrics); + const counterValue = await metricService.getCounterValue(ExampleMetrics.ExampleMetrics, {}); + expect(counterValue).toBe(1); + }); + + it("should leave counter untouched when incrementing a missing counter", async () => { + metricService.incrementCounter(ExampleMetrics.ExampleMetrics, {}, 3); + const counterValue = await metricService.getCounterValue(ExampleMetrics.ExampleMetrics, {}); + expect(counterValue).toBeUndefined(); + }); + + it("should create a gauge", () => { + const gauge = metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + expect(gauge).toBeInstanceOf(Gauge); + }); + + it("should reuse an existing gauge instance", () => { + const gauge = metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + const gaugeAgain = metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + expect(gaugeAgain).toBe(gauge); + }); + + it("should increment a gauge", async () => { + const gauge = metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics, {}, 5); + expect((await gauge.get()).values[0].value).toBe(5); + }); + + it("should decrement a gauge", async () => { + metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics, {}, 5); + metricService.decrementGauge(ExampleMetrics.ExampleMetrics, {}, 2); + expect(await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {})).toBe(3); + }); + + it("should set a gauge to a specific value", async () => { + metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + metricService.setGauge(ExampleMetrics.ExampleMetrics, {}, 12); + expect(await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {})).toBe(12); + }); + + it("should set a gauge when labels are omitted", async () => { + metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + metricService.setGauge(ExampleMetrics.ExampleMetrics, undefined, 9); + expect(await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {})).toBe(9); + }); + + it("should increment and decrement gauges using default step", async () => { + metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics); + metricService.decrementGauge(ExampleMetrics.ExampleMetrics); + expect(await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {})).toBe(1); + }); + + it("should ignore setGauge when gauge does not exist", async () => { + metricService.setGauge(ExampleMetrics.ExampleMetrics, {}, 7); + const value = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {}); + expect(value).toBeUndefined(); + }); + + it("should ignore incrementGauge when gauge does not exist", async () => { + metricService.incrementGauge(ExampleMetrics.ExampleMetrics, {}, 4); + const value = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {}); + expect(value).toBeUndefined(); + }); + + it("should ignore decrementGauge when gauge does not exist", async () => { + metricService.decrementGauge(ExampleMetrics.ExampleMetrics, {}, 2); + const value = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {}); + expect(value).toBeUndefined(); + }); + + it("should aggregate gauge values by matching labels", async () => { + const labelNames = ["status", "region"]; + metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge", labelNames); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics, { status: "ok", region: "us" }, 2); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics, { status: "ok", region: "eu" }, 3); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics, { status: "fail", region: "us" }, 5); + + const aggregatedOk = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, { status: "ok" }); + const aggregatedFail = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, { status: "fail" }); + const missing = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, { status: "missing" }); + + expect(aggregatedOk).toBe(5); + expect(aggregatedFail).toBe(5); + expect(missing).toBeUndefined(); + }); + + it("should aggregate counter values by matching labels", async () => { + const labelNames = ["status", "region"]; + metricService.createCounter(ExampleMetrics.ExampleMetrics, "A test counter", labelNames); + metricService.incrementCounter(ExampleMetrics.ExampleMetrics, { status: "ok", region: "us" }, 4); + metricService.incrementCounter(ExampleMetrics.ExampleMetrics, { status: "ok", region: "eu" }, 6); + + const aggregated = await metricService.getCounterValue(ExampleMetrics.ExampleMetrics, { status: "ok" }); + expect(aggregated).toBe(10); + }); + + it("should return undefined for gauge value when gauge does not exist", async () => { + const value = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {}); + expect(value).toBeUndefined(); + }); + + it("should expose the registry instance", () => { + const registry = (metricService as SingletonMetricsService).getRegistry(); + expect(registry).toBeInstanceOf(Registry); + }); + + it("should return the correct counter value", async () => { + metricService.createCounter(ExampleMetrics.ExampleMetrics, "A test counter"); + metricService.incrementCounter(ExampleMetrics.ExampleMetrics, {}, 5); + const counterValue = await metricService.getCounterValue(ExampleMetrics.ExampleMetrics, {}); + expect(counterValue).toBe(5); + }); + + it("should return the correct gauge value", async () => { + metricService.createGauge(ExampleMetrics.ExampleMetrics, "A test gauge"); + metricService.incrementGauge(ExampleMetrics.ExampleMetrics, {}, 10); + const gaugeValue = await metricService.getGaugeValue(ExampleMetrics.ExampleMetrics, {}); + expect(gaugeValue).toBe(10); + }); + + it("should create a histogram and add values", async () => { + const histogram = metricService.createHistogram( + ExampleMetrics.ExampleMetrics, + [0.1, 0.5, 1, 2, 3, 5], + "A test histogram", + ); + expect(histogram).toBeInstanceOf(Histogram); + }); + + it("should add values to histogram and retrieve them", async () => { + metricService.createHistogram(ExampleMetrics.ExampleMetrics, [0.1, 0.5, 1, 2, 3, 5], "A test histogram"); + metricService.addValueToHistogram(ExampleMetrics.ExampleMetrics, 0.3); + metricService.addValueToHistogram(ExampleMetrics.ExampleMetrics, 1.5); + const histogramValues = await metricService.getHistogramMetricsValues(ExampleMetrics.ExampleMetrics); + expect(histogramValues?.values.length).toBe(9); + }); + + it("should reuse an existing histogram instance", () => { + const histogram = metricService.createHistogram(ExampleMetrics.ExampleMetrics, [0.1, 0.5], "A test histogram", [ + "status", + ]); + const histogramAgain = metricService.createHistogram( + ExampleMetrics.ExampleMetrics, + [0.1, 0.5], + "A test histogram", + ["status"], + ); + expect(histogramAgain).toBe(histogram); + }); + + it("should return undefined for histogram metrics when histogram does not exist", async () => { + const values = await metricService.getHistogramMetricsValues(ExampleMetrics.ExampleMetrics); + expect(values).toBeUndefined(); + }); + + it("should ignore histogram updates when histogram is missing", async () => { + metricService.addValueToHistogram(ExampleMetrics.ExampleMetrics, 1.2); + const values = await metricService.getHistogramMetricsValues(ExampleMetrics.ExampleMetrics); + expect(values).toBeUndefined(); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/utils/__tests__/blockchain.test.ts b/ts-libs/linea-shared-utils/src/utils/__tests__/blockchain.test.ts new file mode 100644 index 0000000000..ec98632367 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/__tests__/blockchain.test.ts @@ -0,0 +1,40 @@ +import { gweiToWei, weiToGwei, weiToGweiNumber } from "../blockchain"; +import { WEI_PER_GWEI } from "../../core/constants/blockchain"; + +describe("weiToGwei", () => { + it("returns zero when converting zero wei", () => { + expect(weiToGwei(0n)).toBe(0n); + }); + + it("converts exact multiples of WEI_PER_GWEI to whole gwei", () => { + expect(weiToGwei(5n * WEI_PER_GWEI)).toBe(5n); + }); + + it("floors fractional gwei values", () => { + const fractionalWei = 1234n * WEI_PER_GWEI + 567n; + expect(weiToGwei(fractionalWei)).toBe(1234n); + }); +}); + +describe("weiToGweiNumber", () => { + it("returns a number representation of gwei", () => { + expect(weiToGweiNumber(7n * WEI_PER_GWEI)).toBe(7); + }); + + it("handles large but safe values", () => { + const gweiValue = 9_000_000n; + expect(weiToGweiNumber(gweiValue * WEI_PER_GWEI)).toBe(Number(gweiValue)); + }); +}); + +describe("gweiToWei", () => { + it("converts gwei to wei", () => { + expect(gweiToWei(42n)).toBe(42n * WEI_PER_GWEI); + }); + + it("is the inverse of weiToGwei for whole gwei amounts", () => { + const gweiValue = 1_234_567n; + const wei = gweiToWei(gweiValue); + expect(weiToGwei(wei)).toBe(gweiValue); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/utils/__tests__/errors.test.ts b/ts-libs/linea-shared-utils/src/utils/__tests__/errors.test.ts new file mode 100644 index 0000000000..f69ba80a4d --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/__tests__/errors.test.ts @@ -0,0 +1,121 @@ +import { ILogger } from "../../logging/ILogger"; +import { attempt, tryResult } from "../errors"; + +const buildLogger = () => { + const warnMock = jest.fn(); + const logger: ILogger = { + name: "test", + info: jest.fn(), + error: jest.fn(), + warn: warnMock, + debug: jest.fn(), + }; + return { logger, warnMock }; +}; + +describe("tryResult", () => { + it("wraps synchronous success values", async () => { + await tryResult(() => 42).match( + (value) => { + expect(value).toBe(42); + }, + () => { + throw new Error("expected success branch"); + }, + ); + }); + + it("captures thrown errors from async functions", async () => { + const expectedError = new Error("boom"); + + await tryResult(async () => { + throw expectedError; + }).match( + () => { + throw new Error("expected error branch"); + }, + (error) => { + expect(error).toBe(expectedError); + }, + ); + }); + + it("converts non-Error throws into Error instances", async () => { + await tryResult(() => { + throw "not-an-error"; + }).match( + () => { + throw new Error("expected error branch"); + }, + (error) => { + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("not-an-error"); + }, + ); + }); +}); + +describe("attempt", () => { + it("returns successful results without logging", async () => { + const { logger, warnMock } = buildLogger(); + + await attempt(logger, () => "ok", "should not log").match( + (value) => { + expect(value).toBe("ok"); + }, + () => { + throw new Error("expected success branch"); + }, + ); + + expect(warnMock).not.toHaveBeenCalled(); + }); + + it("logs and propagates errors", async () => { + const { logger, warnMock } = buildLogger(); + const expectedError = new Error("failure"); + + await attempt( + logger, + () => { + throw expectedError; + }, + "operation failed", + ).match( + () => { + throw new Error("expected error branch"); + }, + (error) => { + expect(error).toBe(expectedError); + }, + ); + + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock).toHaveBeenCalledWith("operation failed", { error: expectedError }); + }); + + it("logs coerced Error instances when non-Error values are thrown", async () => { + const { logger, warnMock } = buildLogger(); + + await attempt( + logger, + () => { + throw "coerce me"; + }, + "non-error throw", + ).match( + () => { + throw new Error("expected error branch"); + }, + (error) => { + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe("coerce me"); + }, + ); + + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls[0][1]).toHaveProperty("error"); + expect(warnMock.mock.calls[0][1].error).toBeInstanceOf(Error); + expect(warnMock.mock.calls[0][1].error.message).toBe("coerce me"); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/utils/__tests__/maths.test.ts b/ts-libs/linea-shared-utils/src/utils/__tests__/maths.test.ts new file mode 100644 index 0000000000..88c75d0475 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/__tests__/maths.test.ts @@ -0,0 +1,29 @@ +import { safeSub, min } from "../maths"; + +describe("safeSub", () => { + it("returns the difference when the minuend is greater than the subtrahend", () => { + expect(safeSub(10n, 3n)).toBe(7n); + }); + + it("returns zero when values are equal", () => { + expect(safeSub(5n, 5n)).toBe(0n); + }); + + it("returns zero when the minuend is smaller than the subtrahend", () => { + expect(safeSub(2n, 8n)).toBe(0n); + }); +}); + +describe("min", () => { + it("returns the first value when it is smaller", () => { + expect(min(1n, 4n)).toBe(1n); + }); + + it("returns the second value when it is smaller", () => { + expect(min(9n, 3n)).toBe(3n); + }); + + it("returns either value when they are equal", () => { + expect(min(6n, 6n)).toBe(6n); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/utils/__tests__/string.test.ts b/ts-libs/linea-shared-utils/src/utils/__tests__/string.test.ts new file mode 100644 index 0000000000..e5c93bd184 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/__tests__/string.test.ts @@ -0,0 +1,45 @@ +import { bigintReplacer, isString, serialize } from "../string"; + +describe("bigintReplacer", () => { + it("converts bigint values to strings", () => { + expect(bigintReplacer("amount", 123n)).toBe("123"); + expect(bigintReplacer("negative", -456n)).toBe("-456"); + }); + + it("returns non-bigint values unchanged", () => { + const value = { foo: "bar" }; + expect(bigintReplacer("object", value)).toBe(value); + expect(bigintReplacer("number", 10)).toBe(10); + }); +}); + +describe("serialize", () => { + it("stringifies bigint values recursively", () => { + const input = { + total: 999n, + nested: { + arr: [1n, 2, { inner: 3n }], + }, + }; + + const serialized = serialize(input); + expect(serialized).toBe('{"total":"999","nested":{"arr":["1",2,{"inner":"3"}]}}'); + }); + + it("matches JSON.stringify behaviour for non-bigint values", () => { + const input = { foo: "bar", count: 3 }; + expect(serialize(input)).toBe(JSON.stringify(input)); + }); +}); + +describe("isString", () => { + it("returns true for primitive strings", () => { + expect(isString("hello")).toBe(true); + }); + + it("returns false for non-strings", () => { + expect(isString(123)).toBe(false); + expect(isString({ text: "hi" })).toBe(false); + expect(isString(new String("wrapped"))).toBe(false); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/utils/__tests__/time.test.ts b/ts-libs/linea-shared-utils/src/utils/__tests__/time.test.ts new file mode 100644 index 0000000000..03ed6d3f97 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/__tests__/time.test.ts @@ -0,0 +1,48 @@ +import { getCurrentUnixTimestampSeconds, msToSeconds, wait } from "../time"; + +describe("getCurrentUnixTimestampSeconds", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns the floored Unix timestamp derived from Date.now()", () => { + const now = 1_700_000_123_456; + jest.spyOn(Date, "now").mockReturnValue(now); + + const result = getCurrentUnixTimestampSeconds(); + expect(result).toBe(Math.floor(now / 1000)); + }); +}); + +describe("msToSeconds", () => { + it("floors fractional seconds", () => { + expect(msToSeconds(1999)).toBe(1); + }); + + it("handles exact seconds", () => { + expect(msToSeconds(2000)).toBe(2); + expect(msToSeconds(0)).toBe(0); + }); +}); + +describe("wait", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("resolves after the specified timeout", async () => { + const onResolve = jest.fn(); + const promise = wait(1000).then(onResolve); + jest.advanceTimersByTime(999); + await Promise.resolve(); + expect(onResolve).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + await Promise.resolve(); + expect(onResolve).toHaveBeenCalledTimes(1); + await expect(promise).resolves.toBeUndefined(); + }); +}); diff --git a/ts-libs/linea-shared-utils/src/utils/blockchain.ts b/ts-libs/linea-shared-utils/src/utils/blockchain.ts new file mode 100644 index 0000000000..a127a4021c --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/blockchain.ts @@ -0,0 +1,28 @@ +import { WEI_PER_GWEI } from "../core/constants/blockchain"; + +/** + * Converts wei to gwei (rounded down). + * @param wei - Value in wei. + * @returns Value in gwei as bigint. + */ +export function weiToGwei(wei: bigint): bigint { + return wei / WEI_PER_GWEI; +} + +/** + * Converts wei to gwei (rounded down). + * @param wei - Value in wei. + * @returns Value in gwei as number - safely store up to ~9M ETH. + */ +export function weiToGweiNumber(wei: bigint): number { + return Number(weiToGwei(wei)); +} + +/** + * Converts gwei to wei. + * @param gwei - Value in gwei. + * @returns Value in wei as bigint. + */ +export function gweiToWei(gwei: bigint): bigint { + return gwei * WEI_PER_GWEI; +} diff --git a/ts-libs/linea-shared-utils/src/utils/errors.ts b/ts-libs/linea-shared-utils/src/utils/errors.ts new file mode 100644 index 0000000000..30f51562ca --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/errors.ts @@ -0,0 +1,42 @@ +import { ResultAsync } from "neverthrow"; +import { ILogger } from "../logging/ILogger"; + +/** + * @notice Wraps a potentially-throwing function (sync or async) into a `ResultAsync`. + * @dev Useful for making code resilient to thrown errors, enabling Go-style error handling. + * + * Example: + * const result = await tryResult(() => mightThrowAsync()); + * if (result.isErr()) return handle(result.error); + * doSomething(result.value); + * + * @param fn A function that may throw or reject. + * @returns A `ResultAsync` that resolves to `ok(value)` or `err(error)`. + */ +export const tryResult = (fn: () => Promise | T): ResultAsync => { + return ResultAsync.fromPromise(Promise.resolve().then(fn), (e) => (e instanceof Error ? e : new Error(String(e)))); +}; + +/** + * @notice Attempts to execute a fn but does not throw on failure. + * @dev Intended for operations where errors should be logged and tolerated rather than propagated. + * @dev Wraps the call in a `ResultAsync` via {@link tryResult}, automatically logging a warning if an error occurs. + * This is useful for fault-tolerant operations where failures should be logged but not thrown. + * + * Example: + * ```ts + * const result = await attempt(logger, () => mightThrowAsync(), "Failed to execute task"); + * if (result.isErr()) return handle(result.error); + * doSomething(result.value); + * ``` + * + * @param logger The logger instance used to record warnings when the operation fails. + * @param fn A function that may throw or reject. + * @param msg A message to include in the warning log if an error occurs. + * @returns A `ResultAsync` that resolves to `ok(value)` or `err(error)`. + */ +export const attempt = (logger: ILogger, fn: () => Promise | T, msg: string): ResultAsync => + tryResult(fn).mapErr((error) => { + logger.warn(msg, { error }); + return error; + }); diff --git a/ts-libs/linea-shared-utils/src/utils/file.ts b/ts-libs/linea-shared-utils/src/utils/file.ts new file mode 100644 index 0000000000..6c6363e0ba --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/file.ts @@ -0,0 +1,31 @@ +import path from "path"; +import { fileURLToPath } from "url"; + +/** + * Gets the directory path of the current module, compatible with both ES modules and CommonJS. + * Uses import.meta.url in ES modules and __dirname in CommonJS. + * tsup will transform this appropriately for each output format. + * + * @returns {string} The directory path of the current module. + */ +export function getModuleDir(): string { + // In CommonJS, __dirname is available (check first for CJS compatibility) + // eslint-disable-next-line @typescript-eslint/no-require-imports + if (typeof __dirname !== "undefined") { + return __dirname; + } + // In ES modules, use import.meta.url + // tsup will handle the transformation: ESM builds use import.meta.url, CJS builds use __dirname + // The check below will be evaluated at runtime in ESM builds + try { + // @ts-expect-error - import.meta.url is available in ESM but TypeScript complains for CJS + const moduleUrl = import.meta.url; + if (moduleUrl) { + return path.dirname(fileURLToPath(moduleUrl)); + } + } catch { + // import.meta not available, fall through to process.cwd() + } + // Fallback to current working directory if neither is available + return process.cwd(); +} diff --git a/ts-libs/linea-shared-utils/src/utils/maths.ts b/ts-libs/linea-shared-utils/src/utils/maths.ts new file mode 100644 index 0000000000..9f573d20c6 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/maths.ts @@ -0,0 +1,22 @@ +/** + * Performs safe subtraction of two bigint values, preventing negative results. + * If the result would be negative, returns 0 instead. + * + * @param {bigint} a - The minuend (value to subtract from). + * @param {bigint} b - The subtrahend (value to subtract). + * @returns {bigint} The result of a - b if a > b, otherwise 0n. + */ +export function safeSub(a: bigint, b: bigint): bigint { + return a > b ? a - b : 0n; +} + +/** + * Returns the minimum of two bigint values. + * + * @param {bigint} a - The first value to compare. + * @param {bigint} b - The second value to compare. + * @returns {bigint} The smaller of the two values. + */ +export function min(a: bigint, b: bigint): bigint { + return a < b ? a : b; +} diff --git a/ts-libs/linea-shared-utils/src/utils/string.ts b/ts-libs/linea-shared-utils/src/utils/string.ts new file mode 100644 index 0000000000..675e6b79d2 --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/string.ts @@ -0,0 +1,30 @@ +/** + * Replacer function for JSON.stringify that converts bigint values to strings. + * + * @param {string} key - The property key (unused). + * @param {unknown} value - The value to check and potentially convert. + * @returns {unknown} The value as a string if it's a bigint, otherwise the original value. + */ +export function bigintReplacer(key: string, value: unknown) { + return typeof value === "bigint" ? value.toString() : value; +} + +/** + * Serializes a value to a JSON string, converting bigint values to strings. + * + * @param {unknown} value - The value to serialize. + * @returns {string} A JSON string representation of the value with bigints converted to strings. + */ +export function serialize(value: unknown): string { + return JSON.stringify(value, (_, value: unknown) => (typeof value === "bigint" ? value.toString() : value)); +} + +/** + * Type guard function to check if a given value is a string. + * + * @param {unknown} value - The value to check. + * @returns {boolean} `true` if the value is a string, `false` otherwise. + */ +export function isString(value: unknown): value is string { + return typeof value === "string"; +} diff --git a/ts-libs/linea-shared-utils/src/utils/time.ts b/ts-libs/linea-shared-utils/src/utils/time.ts new file mode 100644 index 0000000000..ed5d134eaf --- /dev/null +++ b/ts-libs/linea-shared-utils/src/utils/time.ts @@ -0,0 +1,26 @@ +import { MS_PER_SECOND } from "../core/constants/time"; + +/** + * Gets the current Unix timestamp in seconds. + * @returns The current Unix timestamp as a number of seconds since the Unix epoch (January 1, 1970 UTC), floored. + */ +export function getCurrentUnixTimestampSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +/** + * Converts milliseconds to whole seconds (rounded down). + * @param ms - Milliseconds value + * @returns Number of seconds, floored + */ +export function msToSeconds(ms: number): number { + return Math.floor(ms / MS_PER_SECOND); +} + +/** + * Creates a promise that resolves after a specified timeout period. + * + * @param {number} timeout - The duration in milliseconds to wait before resolving the promise. + * @returns {Promise} A promise that resolves after the specified timeout period. + */ +export const wait = (timeout: number): Promise => new Promise((resolve) => setTimeout(resolve, timeout)); diff --git a/ts-libs/linea-shared-utils/tsconfig.build.json b/ts-libs/linea-shared-utils/tsconfig.build.json new file mode 100644 index 0000000000..34f71b24ec --- /dev/null +++ b/ts-libs/linea-shared-utils/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "noEmit": false, + "emitDecoratorMetadata": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "composite": false + }, + "include": ["src"], + } \ No newline at end of file diff --git a/ts-libs/linea-shared-utils/tsconfig.json b/ts-libs/linea-shared-utils/tsconfig.json new file mode 100644 index 0000000000..e78d1e2291 --- /dev/null +++ b/ts-libs/linea-shared-utils/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "composite": true, + "removeComments": false, + "isolatedModules": false, + "strictPropertyInitialization": false, + "exactOptionalPropertyTypes": false + } +} diff --git a/ts-libs/linea-shared-utils/tsup.config.ts b/ts-libs/linea-shared-utils/tsup.config.ts new file mode 100644 index 0000000000..f09fdb9e53 --- /dev/null +++ b/ts-libs/linea-shared-utils/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + tsconfig: "tsconfig.build.json", + format: ["esm", "cjs"], + target: "esnext", + dts: true, + clean: true, + sourcemap: true, + minify: true, + splitting: false, + outDir: "dist", + external: ["node-forge", "crypto", "viem"], // Avoid 'Error: Dynamic require of "crypto" is not supported' when used from native-yield-automation-service +});