diff --git a/.claude/settings.json b/.claude/settings.json index 264f8ef5b..8eecf8064 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,7 @@ "enabledPlugins": { "code-review@claude-plugins-official": true, "feature-dev@claude-plugins-official": true, - "hookify@claude-plugins-official": true + "hookify@claude-plugins-official": true, + "sentry@claude-plugins-official": true } } diff --git a/.dockerignore b/.dockerignore index d32c20574..69f30185f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,8 @@ README.md docker/ !docker/entrypoint-*.sh +!docker/scripts/ +!docker/genvm-source-build.nix.conf .venv/ .vscode/ .ollama/ diff --git a/.e2e-genvm-prebuilt/.gitkeep b/.e2e-genvm-prebuilt/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/.e2e-genvm-prebuilt/.gitkeep @@ -0,0 +1 @@ + diff --git a/.env.example b/.env.example index 8e2201924..a077ccce6 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ LOGCONFIG='dev' # dev/prod LOG_LEVEL='debug' # 'critical', 'error', 'warning', 'info', 'debug', 'trace' DISABLE_INFO_LOGS_ENDPOINTS='["ping", "eth_getTransactionByHash","gen_getContractSchema", "gen_getContractSchemaForCode", "net_version", "sim_getTransactionsForAddress", "sim_getConsensusContract", "eth_estimateGas", "eth_chainId", "eth_getBlockByNumber", "eth_gasPrice", "sim_getFinalityWindowTime"]' SHOW_VALIDATOR_PRIVATE_KEYS_IN_LOGS='false' # Set true only when debugging local validator signing. +SHOW_VALIDATOR_PRIVATE_KEYS_IN_RPC='false' # Set true only when inspecting validator signing data locally. ######################################## # JsonRPC Server Configuration @@ -36,9 +37,27 @@ REDIS_URL='redis://redis:6379/0' # Redis URL for Socket.IO message queue (e.g., # GenVM Configuration GENVM_BIN="/genvm/bin" GENVMROOT="/genvm" +# Acquisition mode: prebuilt, source, release, or empty for auto selection. +# Auto precedence is prebuilt E2E tree > : source pin > release. +# Release is the default and uses third_party/genvm/version unless GENVM_TAG overrides it. +GENVM_SOURCE_MODE="" +GENVM_TAG="" # Exact release tag, e.g. v0.6.0-rc0. Mutually exclusive with GENVM_REF. +GENVM_REF="" # Source git ref/SHA; set mode=source, or use : for auto source mode. +GENVM_EXECUTOR_VERSION_NAME="" # Optional source-build executor label, e.g. v0.3.0-e2e-dev. +NIX_NETRC_FILE="/dev/null" # Netrc with a nix cache pull token; must exist, empty one just means no cache hits. GENVM_LLM_DEBUG="0" GENVM_WEB_DEBUG="0" +######################################## +# Studio Fee Accounting +# Set all three price values to 0 to run Studio in gasless mode. +GENLAYER_STUDIO_GEN_PER_TIME_UNIT='1000000000000000' # 0.001 GEN per time unit +GENLAYER_STUDIO_STORAGE_UNIT_PRICE='1' +GENLAYER_STUDIO_RECEIPT_GAS_PRICE='1' +GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS='210000' +GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS='100000' +GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES='1024' + # Ollama Server Configuration OLAMAPORT='11434' @@ -96,12 +115,41 @@ RATE_LIMIT_ENABLED='false' # Enable/disable API key rate limiting RATE_LIMIT_ANON_PER_MINUTE='30' # Anonymous (no API key) per-minute limit RATE_LIMIT_ANON_PER_HOUR='500' # Anonymous per-hour limit RATE_LIMIT_ANON_PER_DAY='5000' # Anonymous per-day limit +RATE_LIMIT_READ_MULTIPLIER='10' # Cheap reads (no GenVM) get this multiple of the tier limits # PENDING-tx queue depth caps for eth_sendRawTransaction (admission control). # Empty / unset = no cap (the default for self-hosted). Public shared # deployments should set both to prevent one user filling the queue. MAX_PENDING_PER_CONTRACT_DEFAULT='' # Cap PENDING txs per contract (e.g. 50) MAX_PENDING_PER_SENDER_DEFAULT='' # Cap PENDING txs per sender (e.g. 20) +# Daily per-contract snapshot-byte budget (empty = off). Hosted sandbox +# example: 1073741824 (1 GiB). First write of the UTC day always allowed. +MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY='' + +######################################## +# Terminal Contract Snapshot Pruning (Optional) +# Archives terminal transaction contract_snapshot payloads before pruning them +# from the hot transactions table. Disabled by default. +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ENABLED='false' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_ENABLED='true' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_VERIFY_ARCHIVE='true' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ALLOW_LOSSY_PRUNE='false' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_DRY_RUN='false' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_BATCH_SIZE='5' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_RETENTION_HOURS='24' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_INTERVAL_SECONDS='300' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND='s3' # file, gcs, or s3 +STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED='false' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_FILE_DIR='data/terminal-contract-snapshot-archive' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_BUCKET='' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_PREFIX='studio/terminal-contract-snapshots' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_STORAGE_CLASS='NEARLINE' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_BUCKET='' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_PREFIX='studio/terminal-contract-snapshots' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_REGION='' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_STORAGE_CLASS='STANDARD' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_SSE='aws:kms' +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_KMS_KEY_ID='' ######################################## # Usage Metrics Configuration (Optional) @@ -122,6 +170,9 @@ VALIDATORS_CONFIG_JSON='[ JSONRPC_REPLICAS='1' CONSENSUS_WORKERS='3' MAX_PARALLEL_TXS_PER_WORKER='1' +# Max validators executing concurrently per transaction (GenVM subprocesses). +# Raise for large committees if memory allows. +CONSENSUS_VALIDATOR_MAX_CONCURRENT='8' # Production Configuration (for Gunicorn deployment) WEB_CONCURRENCY='1' # Number of Gunicorn workers (default: CPU cores * 2) # Service resources limit diff --git a/.genvm-nix-closure/.gitkeep b/.genvm-nix-closure/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.github/actions/genvm-runners-closure/action.yml b/.github/actions/genvm-runners-closure/action.yml new file mode 100644 index 000000000..31d78add6 --- /dev/null +++ b/.github/actions/genvm-runners-closure/action.yml @@ -0,0 +1,61 @@ +name: Prebuild GenVM runners closure +description: > + Builds genvm-manager's `runners-all` on the runner, where the Nix sandbox + works, and exports its store closure into the Docker build context so the + in-image source build can import it instead of rebuilding it. + + The runner tree is assembled from fixed-output derivations that compile C to + wasm (`genvm-cpython-objs`, `genvm-ffi`, bz2/xz/zlib, numpy, PIL). Their + identity is their output hash, and the `nixos/nix` image cannot enable the + sandbox, so building them there picks up host state and misses the pinned + hashes. Everything else in the build is input-addressed and does not care. + +inputs: + ref: + description: GenVM source binding, `:` or a bare commit. + required: true + destination: + description: Directory in the build context to write the closure into. + required: false + default: .genvm-nix-closure + nix_cache_pull_token: + description: >- + pull token for the GenLayer nix cache, which is private. A composite + action cannot read `secrets`, so the caller has to thread it through. + Empty means the runner closure is built from source. + required: false + default: "" + +outputs: + netrc_path: + description: >- + Netrc holding the cache pull token, written by nix-setup. The Docker + source build fetches from the same cache, so it needs the same credential + — pass it as a BuildKit secret, never as an ARG or a COPY. + value: ${{ steps.nix.outputs.netrc_path }} + +runs: + using: composite + steps: + # Substituters, trusted keys and cache auth come from the shared action; the + # sandbox settings below are this action's own and must survive it, so they + # go through extra_nix_config, whose lines are appended last and win. + - name: Install Nix + id: nix + uses: genlayerlabs/github-actions/nix-setup@39b0a0d5e9bb27a1612d2e98b0f8509d31745157 + with: + cache_pull_token: ${{ inputs.nix_cache_pull_token }} + extra_nix_config: | + sandbox = true + # Without this Nix quietly builds unsandboxed when it cannot set the + # sandbox up, which is the exact output this action must never export. + sandbox-fallback = false + + - name: Build and export runners-all + shell: bash + env: + GENVM_CLOSURE_REF: ${{ inputs.ref }} + GENVM_CLOSURE_DEST: ${{ inputs.destination }} + run: | + ./scripts/genvm-runners-closure.sh \ + "$GENVM_CLOSURE_REF" "$GENVM_CLOSURE_DEST" diff --git a/.github/scripts/install-python-dependencies.sh b/.github/scripts/install-python-dependencies.sh new file mode 100644 index 000000000..efd0a1530 --- /dev/null +++ b/.github/scripts/install-python-dependencies.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +python -m pip install --only-binary :all: -r requirements.txt +python -m pip install --only-binary :all: -r requirements.test.txt +python -m pip install --only-binary :all: -r backend/requirements.txt diff --git a/.github/scripts/validate-branch-policy.sh b/.github/scripts/validate-branch-policy.sh new file mode 100755 index 000000000..906e39c16 --- /dev/null +++ b/.github/scripts/validate-branch-policy.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +failed=0 + +error() { + echo "::error::$*" >&2 + failed=1 +} + +warning() { + echo "::warning::$*" +} + +active_branch_file="support/ci/ACTIVE_DEV_BRANCH" +if [[ ! -f "${active_branch_file}" ]]; then + error "${active_branch_file} is required." + active_branch="" +else + active_branch="$(tr -d '[:space:]' < "${active_branch_file}")" +fi + +if [[ -z "${active_branch}" ]]; then + error "${active_branch_file} must not be empty." +elif [[ "${active_branch}" == "main" ]]; then + error "${active_branch_file} must point to a dev branch, not main." +elif [[ "${active_branch}" != *-dev ]]; then + warning "${active_branch_file} should normally point to a -dev branch; got ${active_branch}." +fi + +release_branch="${active_branch%-dev}" +default_branch="${GITHUB_DEFAULT_BRANCH:-}" +event_name="${GITHUB_EVENT_NAME:-local}" +base_ref="${GITHUB_BASE_REF:-}" +head_ref="${GITHUB_HEAD_REF:-}" +ref_name="${GITHUB_REF_NAME:-}" +actor="${GITHUB_ACTOR:-}" + +if [[ -n "${default_branch}" && "${default_branch}" != "main" ]]; then + warning "Repository default branch should be main after branch-policy rollout; currently ${default_branch}." +fi + +if [[ -n "${base_ref}" && "${base_ref}" == "main" ]]; then + warning "PR targets main; retarget-main-prs should move it to ${active_branch}." +fi + +if [[ -n "${base_ref}" && -n "${active_branch}" && "${base_ref}" == "${release_branch}" && "${head_ref}" != "${active_branch}" && "${ALLOW_DIRECT_RELEASE_PR:-false}" != "true" ]]; then + error "PRs into ${release_branch} must come from ${active_branch}. Merge feature work into ${active_branch}, then promote ${active_branch} -> ${release_branch}." +fi + +if [[ "${event_name}" == "push" && "${ref_name}" == "main" ]]; then + case "${actor}" in + github-actions[bot]|ci-core-e2e-runner[bot]) + ;; + *) + error "main should only move by automation from ${active_branch}; direct push actor was ${actor:-unknown}." + ;; + esac +fi + +if [[ ! -f ".github/workflows/fast-forward-main.yaml" ]]; then + error ".github/workflows/fast-forward-main.yaml is required." +fi + +if [[ ! -f ".github/workflows/retarget-main-prs.yaml" ]]; then + error ".github/workflows/retarget-main-prs.yaml is required." +fi + +if [[ -f ".github/workflows/release-from-main.yml" ]]; then + error ".github/workflows/release-from-main.yml is forbidden. Releases must be tag/version-branch driven." +fi + +if [[ -f "release.config.js" ]]; then + error "release.config.js is forbidden in versioned tooling branches; semantic-release-on-main must not be restored." +fi + +if [[ -f ".github/workflows/release-from-tag.yml" ]]; then + if ! grep -Fq 'v*.*.*' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must trigger only from version tags matching v*.*.*." + fi + if ! grep -Fq 'refs/remotes/origin/${version_branch}' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'tag_commit' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'branch_head' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must verify the tag commit is the current matching version branch head." + fi +fi + +if [[ -f ".github/workflows/manual-docker-release.yml" ]]; then + if ! grep -Fq 'expected_branch=' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must derive and enforce the expected version branch from the tag." + fi + if ! grep -Fq './.github/workflows/release-from-tag.yml' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must delegate image promotion to release-from-tag.yml." + fi +fi + +if [[ "${failed}" -ne 0 ]]; then + exit 1 +fi + +if [[ -n "${base_ref}" ]]; then + echo "Branch policy ok for PR ${head_ref} -> ${base_ref}; active dev branch is ${active_branch}." +else + echo "Branch policy ok for ${event_name} on ${ref_name:-detached ref}; active dev branch is ${active_branch}." +fi diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 8154937ee..85e0ebe71 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1 +1,15 @@ -# Trigger CI re-run +# Studio CI Notes + +`branch-policy.yml` keeps the release-train model explicit: + +- independently releasable work may target a stable branch directly +- multi-feature or cross-repo train work goes to the active `*-dev` integration + branch +- promotion PRs into a release branch normally come from the matching `*-dev` + branch, for example `v0.123-dev` into `v0.123` +- `main` is treated as the static/default GitHub branch, not a release surface +- `master`, stale release branches, `release-from-main.yml`, and + `release.config.js` are treated as invalid release surfaces +- releases must go through version tags validated by `release-from-tag.yml` + +See `docs/BRANCHING.md` for the contributor-facing branch model. diff --git a/.github/workflows/backend_integration_tests_pr.yml b/.github/workflows/backend_integration_tests_pr.yml index 17336a240..c4fdc1685 100644 --- a/.github/workflows/backend_integration_tests_pr.yml +++ b/.github/workflows/backend_integration_tests_pr.yml @@ -35,6 +35,7 @@ jobs: env: PYTHONPATH: ${{ github.workspace }} COMPOSE_PROFILES: hardhat + GENVM_CACHE_DIR: /tmp/genvm-cache steps: - name: Checkout code @@ -43,7 +44,82 @@ jobs: - name: Copy .env file run: cp .env.example .env - # TODO: we should also add also heuristai and anthropic keys to the e2e tests and test all providers + - name: Resolve GenVM binding + env: + GH_TOKEN: ${{ github.token }} + run: | + # Binding model: codebase default < env override. + # third_party/genvm/version (same convention as genlayer-node) pins + # the GenVM this branch's host protocol speaks; it is flipped to the + # release tag at release cut. The e2e pipeline / release train can + # override by injecting GENVM_TAG or GENVM_REF into the environment. + # Exact vX.Y.Z[-suffix] values use release assets. Source pins must be + # explicit : values so builds cannot drift with a branch. + if [[ -n "${GENVM_TAG:-}" && -n "${GENVM_REF:-}" ]]; then + echo "::error::GENVM_TAG and GENVM_REF are both set; they are mutually exclusive" + exit 1 + fi + GENVM_TAG_VALUE="${GENVM_TAG:-}" + GENVM_REF_VALUE="${GENVM_REF:-}" + GENVM_SOURCE_MODE_VALUE="${GENVM_SOURCE_MODE:-}" + if [[ -z "$GENVM_TAG_VALUE" && -z "$GENVM_REF_VALUE" ]]; then + if [[ ! -f third_party/genvm/version ]]; then + echo "::error::third_party/genvm/version is missing and no GENVM_TAG/GENVM_REF env override is set" + exit 1 + fi + BINDING="$(head -n1 third_party/genvm/version | sed -e 's/\r$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [[ "$BINDING" =~ ^\".*\"$ || "$BINDING" =~ ^\'.*\'$ ]]; then + BINDING="${BINDING:1:${#BINDING}-2}" + BINDING="$(printf '%s' "$BINDING" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + fi + if [[ -z "$BINDING" ]]; then + echo "::error::third_party/genvm/version is empty" + exit 1 + fi + if [[ "$BINDING" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + GENVM_TAG_VALUE="$BINDING" + elif [[ "$BINDING" == *-dev ]]; then + echo "::error::Bare GenVM branch pin '$BINDING' is not supported; pin an exact vX.Y.Z release or ':'" + exit 1 + elif [[ "$BINDING" =~ ^.+:[0-9a-fA-F]{7,40}$ ]]; then + GENVM_REF_VALUE="$BINDING" + GENVM_SOURCE_MODE_VALUE="source" + else + echo "::error::Invalid GenVM pin '$BINDING'; expected vX.Y.Z[-suffix] or ':'" + exit 1 + fi + fi + if [[ -n "$GENVM_TAG_VALUE" && ! "$GENVM_TAG_VALUE" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid GENVM_TAG '$GENVM_TAG_VALUE'; expected vX.Y.Z[-suffix]" + exit 1 + fi + if [[ -n "$GENVM_REF_VALUE" ]]; then + if [[ "$GENVM_REF_VALUE" == *-dev ]]; then + echo "::error::Bare GenVM branch pin '$GENVM_REF_VALUE' is not supported; pin an exact vX.Y.Z release or ':'" + exit 1 + elif [[ "$GENVM_REF_VALUE" =~ ^(.+):([0-9a-fA-F]{7,40})$ ]]; then + GENVM_REF_BRANCH="${BASH_REMATCH[1]}" + GENVM_REF_COMMIT="$(gh api "repos/genlayerlabs/genvm-manager/commits/${BASH_REMATCH[2]}" --jq .sha)" + GENVM_REF_VALUE="$GENVM_REF_BRANCH:$GENVM_REF_COMMIT" + elif [[ "$GENVM_SOURCE_MODE_VALUE" == "source" ]]; then + GENVM_REF_VALUE="$(gh api "repos/genlayerlabs/genvm-manager/commits/$GENVM_REF_VALUE" --jq .sha)" + else + echo "::error::GENVM_REF must be ':' unless GENVM_SOURCE_MODE=source is explicit" + exit 1 + fi + GENVM_SOURCE_MODE_VALUE="source" + fi + sed -i "s|^GENVM_TAG=.*|GENVM_TAG=\"$GENVM_TAG_VALUE\"|" .env + sed -i "s|^GENVM_REF=.*|GENVM_REF=\"$GENVM_REF_VALUE\"|" .env + sed -i "s|^GENVM_SOURCE_MODE=.*|GENVM_SOURCE_MODE=\"$GENVM_SOURCE_MODE_VALUE\"|" .env + { + echo "GENVM_TAG=$GENVM_TAG_VALUE" + echo "GENVM_REF=$GENVM_REF_VALUE" + echo "GENVM_SOURCE_MODE=$GENVM_SOURCE_MODE_VALUE" + } >> "$GITHUB_ENV" + grep "^GENVM_TAG=" .env + grep "^GENVM_REF=" .env + grep "^GENVM_SOURCE_MODE=" .env - name: Configure LLM provider env: @@ -97,24 +173,67 @@ jobs: - name: Extract GenVM version for cache key id: genvm - run: echo "tag=$(grep -m1 'ARG GENVM_TAG=' docker/Dockerfile.backend | cut -d= -f2)" >> "$GITHUB_OUTPUT" + run: | + GENVM_REF_VALUE="${GENVM_REF:-}" + if [[ -z "$GENVM_REF_VALUE" && -f .env ]]; then + GENVM_REF_VALUE="$(sed -n 's/^GENVM_REF=["'\'']\{0,1\}\([^"'\'']*\)["'\'']\{0,1\}.*/\1/p' .env | head -n1)" + fi + if [[ -n "$GENVM_REF_VALUE" ]]; then + GENVM_CACHE_REF="${GENVM_REF_VALUE##*:}" + echo "binding=ref-$GENVM_CACHE_REF" >> "$GITHUB_OUTPUT" + echo "bake_cache=,scope=genvm-$GENVM_CACHE_REF" >> "$GITHUB_OUTPUT" + else + GENVM_TAG_VALUE="${GENVM_TAG:-}" + if [[ -z "$GENVM_TAG_VALUE" && -f .env ]]; then + GENVM_TAG_VALUE="$(sed -n 's/^GENVM_TAG=["'\'']\{0,1\}\([^"'\'']*\)["'\'']\{0,1\}.*/\1/p' .env | head -n1)" + fi + if [[ -z "$GENVM_TAG_VALUE" ]]; then + echo "::warning::GENVM_TAG not set (this job runs prebuilt images) — using shared 'untagged' precompile cache key that will not invalidate across GenVM upgrades" + GENVM_TAG_VALUE="untagged" + fi + echo "binding=$GENVM_TAG_VALUE" >> "$GITHUB_OUTPUT" + echo "bake_cache=" >> "$GITHUB_OUTPUT" + fi - name: Restore GenVM precompile cache uses: actions/cache@v5 with: - path: /tmp/genvm-cache - key: genvm-precompile-${{ steps.genvm.outputs.tag }}-amd64 + path: ${{ env.GENVM_CACHE_DIR }} + key: genvm-precompile-${{ runner.os }}-${{ runner.arch }}-${{ steps.genvm.outputs.binding }} - name: Prepare GenVM cache directory - run: mkdir -p /tmp/genvm-cache/pc && chmod -R 0777 /tmp/genvm-cache + run: | + sudo mkdir -p "$GENVM_CACHE_DIR/pc" + sudo chown -R 999:999 "$GENVM_CACHE_DIR" + + # Source builds need the GenVM runner fixed-output derivations built under + # a real Nix sandbox, which the nixos/nix build stage cannot provide. + - name: Prebuild GenVM runners closure + id: genvm_closure + if: env.GENVM_SOURCE_MODE == 'source' + uses: ./.github/actions/genvm-runners-closure + with: + ref: ${{ env.GENVM_REF }} + nix_cache_pull_token: ${{ secrets.NIX_CACHE_PULL_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 # Build only backend images with Buildx Bake and enable GHA cache - name: Build backend images with cache (buildx bake) + # The source build inside Dockerfile.backend substitutes from the same + # private cache, so it needs the same credential. docker-compose.yml + # reads this path into the `nix_netrc` build secret; unset (a release + # build, or a fork PR with no token) falls back to /dev/null. + env: + NIX_NETRC_FILE: ${{ steps.genvm_closure.outputs.netrc_path }} uses: docker/bake-action@v6 with: + # Bake defaults to the Git context, which ignores anything earlier + # steps wrote into the workspace. Source builds need the prebuilt + # runners closure from there; release builds keep the Git context, + # where an empty value is the same as not passing the input. + source: ${{ env.GENVM_SOURCE_MODE == 'source' && '.' || '' }} files: | ./docker-compose.yml targets: | @@ -122,13 +241,17 @@ jobs: jsonrpc consensus-worker set: | - *.cache-from=type=gha - *.cache-to=type=gha,mode=max + *.cache-from=type=gha${{ steps.genvm.outputs.bake_cache }} + *.cache-to=type=gha,mode=max${{ steps.genvm.outputs.bake_cache }} database-migration.tags=genlayer-studio-database-migration:latest jsonrpc.tags=genlayer-studio-jsonrpc:latest consensus-worker.tags=genlayer-studio-consensus-worker:latest load: true + - name: Precompile GenVM cache + timeout-minutes: 30 + run: docker/scripts/precompile_genvm.sh + # Start services without rebuilding images; CI override provides fast healthcheck timings. # --wait blocks until all healthchecks pass (jsonrpc /ready + consensus-worker /health). - name: Run Docker Compose @@ -164,6 +287,262 @@ jobs: if: always() run: docker compose down + test-gasless: + needs: triggers + if: ${{ needs.triggers.outputs.is_pull_request_opened == 'true' || needs.triggers.outputs.is_pull_request_review_approved == 'true' || needs.triggers.outputs.is_pull_request_labeled_with_run_tests == 'true' }} + + runs-on: ubuntu-latest + + env: + PYTHONPATH: ${{ github.workspace }} + COMPOSE_PROFILES: hardhat + GENVM_CACHE_DIR: /tmp/genvm-cache + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Copy .env file + run: cp .env.example .env + + - name: Resolve GenVM binding + env: + GH_TOKEN: ${{ github.token }} + run: | + # Binding model: codebase default < env override. + # third_party/genvm/version (same convention as genlayer-node) pins + # the GenVM this branch's host protocol speaks; it is flipped to the + # release tag at release cut. The e2e pipeline / release train can + # override by injecting GENVM_TAG or GENVM_REF into the environment. + # Exact vX.Y.Z[-suffix] values use release assets. Source pins must be + # explicit : values so builds cannot drift with a branch. + if [[ -n "${GENVM_TAG:-}" && -n "${GENVM_REF:-}" ]]; then + echo "::error::GENVM_TAG and GENVM_REF are both set; they are mutually exclusive" + exit 1 + fi + GENVM_TAG_VALUE="${GENVM_TAG:-}" + GENVM_REF_VALUE="${GENVM_REF:-}" + GENVM_SOURCE_MODE_VALUE="${GENVM_SOURCE_MODE:-}" + if [[ -z "$GENVM_TAG_VALUE" && -z "$GENVM_REF_VALUE" ]]; then + if [[ ! -f third_party/genvm/version ]]; then + echo "::error::third_party/genvm/version is missing and no GENVM_TAG/GENVM_REF env override is set" + exit 1 + fi + BINDING="$(head -n1 third_party/genvm/version | sed -e 's/\r$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [[ "$BINDING" =~ ^\".*\"$ || "$BINDING" =~ ^\'.*\'$ ]]; then + BINDING="${BINDING:1:${#BINDING}-2}" + BINDING="$(printf '%s' "$BINDING" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + fi + if [[ -z "$BINDING" ]]; then + echo "::error::third_party/genvm/version is empty" + exit 1 + fi + if [[ "$BINDING" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + GENVM_TAG_VALUE="$BINDING" + elif [[ "$BINDING" == *-dev ]]; then + echo "::error::Bare GenVM branch pin '$BINDING' is not supported; pin an exact vX.Y.Z release or ':'" + exit 1 + elif [[ "$BINDING" =~ ^.+:[0-9a-fA-F]{7,40}$ ]]; then + GENVM_REF_VALUE="$BINDING" + GENVM_SOURCE_MODE_VALUE="source" + else + echo "::error::Invalid GenVM pin '$BINDING'; expected vX.Y.Z[-suffix] or ':'" + exit 1 + fi + fi + if [[ -n "$GENVM_TAG_VALUE" && ! "$GENVM_TAG_VALUE" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid GENVM_TAG '$GENVM_TAG_VALUE'; expected vX.Y.Z[-suffix]" + exit 1 + fi + if [[ -n "$GENVM_REF_VALUE" ]]; then + if [[ "$GENVM_REF_VALUE" == *-dev ]]; then + echo "::error::Bare GenVM branch pin '$GENVM_REF_VALUE' is not supported; pin an exact vX.Y.Z release or ':'" + exit 1 + elif [[ "$GENVM_REF_VALUE" =~ ^(.+):([0-9a-fA-F]{7,40})$ ]]; then + GENVM_REF_BRANCH="${BASH_REMATCH[1]}" + GENVM_REF_COMMIT="$(gh api "repos/genlayerlabs/genvm-manager/commits/${BASH_REMATCH[2]}" --jq .sha)" + GENVM_REF_VALUE="$GENVM_REF_BRANCH:$GENVM_REF_COMMIT" + elif [[ "$GENVM_SOURCE_MODE_VALUE" == "source" ]]; then + GENVM_REF_VALUE="$(gh api "repos/genlayerlabs/genvm-manager/commits/$GENVM_REF_VALUE" --jq .sha)" + else + echo "::error::GENVM_REF must be ':' unless GENVM_SOURCE_MODE=source is explicit" + exit 1 + fi + GENVM_SOURCE_MODE_VALUE="source" + fi + sed -i "s|^GENVM_TAG=.*|GENVM_TAG=\"$GENVM_TAG_VALUE\"|" .env + sed -i "s|^GENVM_REF=.*|GENVM_REF=\"$GENVM_REF_VALUE\"|" .env + sed -i "s|^GENVM_SOURCE_MODE=.*|GENVM_SOURCE_MODE=\"$GENVM_SOURCE_MODE_VALUE\"|" .env + { + echo "GENVM_TAG=$GENVM_TAG_VALUE" + echo "GENVM_REF=$GENVM_REF_VALUE" + echo "GENVM_SOURCE_MODE=$GENVM_SOURCE_MODE_VALUE" + } >> "$GITHUB_ENV" + grep "^GENVM_TAG=" .env + grep "^GENVM_REF=" .env + grep "^GENVM_SOURCE_MODE=" .env + + # TODO: we should also add also heuristai and anthropic keys to the e2e tests and test all providers + + - name: Configure LLM provider + env: + OPENROUTERAPIKEY: ${{ secrets.OPENROUTERAPIKEY }} + LLM_PROVIDER: ${{ vars.LLM_PROVIDER || 'openrouter' }} + LLM_MODEL: ${{ vars.LLM_MODEL || 'deepseek/deepseek-v3.2' }} + run: | + # Inject OpenRouter API key + sed -i "/^OPENROUTERAPIKEY *= *''$/d" .env + printf "\nOPENROUTERAPIKEY='$OPENROUTERAPIKEY'\n" >> .env + + # Override validators config with configured provider/model + sed -i "/^VALIDATORS_CONFIG_JSON=/,/^]'/d" .env + printf "\nVALIDATORS_CONFIG_JSON='[\n {\"stake\": 100, \"provider\": \"$LLM_PROVIDER\", \"model\": \"$LLM_MODEL\", \"amount\": 5}\n]'\n" >> .env + + - name: Configure CI environment + run: | + sed -i "s/VITE_FINALITY_WINDOW=\".*\"/VITE_FINALITY_WINDOW=\"10\"/" .env + sed -i "s/COMPOSE_RPC_CPU_LIMIT=\".*\"/COMPOSE_RPC_CPU_LIMIT=\"4\"/" .env + sed -i "s/COMPOSE_WORKER_CPU_LIMIT=\".*\"/COMPOSE_WORKER_CPU_LIMIT=\"4\"/" .env + sed -i "s/COMPOSE_RPC_MEM_LIMIT=\".*\"/COMPOSE_RPC_MEM_LIMIT=\"6gb\"/" .env + sed -i "s/COMPOSE_WORKER_MEM_LIMIT=\".*\"/COMPOSE_WORKER_MEM_LIMIT=\"6gb\"/" .env + sed -i "s/COMPOSE_RPC_MEM_RESERVATION=\".*\"/COMPOSE_RPC_MEM_RESERVATION=\"2gb\"/" .env + sed -i "s/COMPOSE_WORKER_MEM_RESERVATION=\".*\"/COMPOSE_WORKER_MEM_RESERVATION=\"2gb\"/" .env + sed -i "s/JSONRPC_REPLICAS=\".*\"/JSONRPC_REPLICAS=\"1\"/" .env + sed -i "s/CONSENSUS_WORKERS=\".*\"/CONSENSUS_WORKERS=\"3\"/" .env + sed -i "s/MAX_PARALLEL_TXS_PER_WORKER=\".*\"/MAX_PARALLEL_TXS_PER_WORKER=\"2\"/" .env + echo >> .env + echo "TEST_WITH_MOCK_LLMS=true" >> .env + + - name: Configure gasless mode + run: | + sed -i "s/^GENLAYER_STUDIO_GEN_PER_TIME_UNIT=.*/GENLAYER_STUDIO_GEN_PER_TIME_UNIT='0'/" .env + sed -i "s/^GENLAYER_STUDIO_STORAGE_UNIT_PRICE=.*/GENLAYER_STUDIO_STORAGE_UNIT_PRICE='0'/" .env + sed -i "s/^GENLAYER_STUDIO_RECEIPT_GAS_PRICE=.*/GENLAYER_STUDIO_RECEIPT_GAS_PRICE='0'/" .env + grep "^GENLAYER_STUDIO_" .env + + # Set up Python + pip early so it overlaps with Docker build + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + requirements.txt + requirements.test.txt + backend/requirements.txt + + - name: Install Python dependencies + run: | + bash .github/scripts/install-python-dependencies.sh + + - name: Extract GenVM version for cache key + id: genvm + run: | + GENVM_REF_VALUE="${GENVM_REF:-}" + if [[ -z "$GENVM_REF_VALUE" && -f .env ]]; then + GENVM_REF_VALUE="$(sed -n 's/^GENVM_REF=["'\'']\{0,1\}\([^"'\'']*\)["'\'']\{0,1\}.*/\1/p' .env | head -n1)" + fi + if [[ -n "$GENVM_REF_VALUE" ]]; then + GENVM_CACHE_REF="${GENVM_REF_VALUE##*:}" + echo "binding=ref-$GENVM_CACHE_REF" >> "$GITHUB_OUTPUT" + echo "bake_cache=,scope=genvm-$GENVM_CACHE_REF" >> "$GITHUB_OUTPUT" + else + GENVM_TAG_VALUE="${GENVM_TAG:-}" + if [[ -z "$GENVM_TAG_VALUE" && -f .env ]]; then + GENVM_TAG_VALUE="$(sed -n 's/^GENVM_TAG=["'\'']\{0,1\}\([^"'\'']*\)["'\'']\{0,1\}.*/\1/p' .env | head -n1)" + fi + if [[ -z "$GENVM_TAG_VALUE" ]]; then + echo "::warning::GENVM_TAG not set (this job runs prebuilt images) — using shared 'untagged' precompile cache key that will not invalidate across GenVM upgrades" + GENVM_TAG_VALUE="untagged" + fi + echo "binding=$GENVM_TAG_VALUE" >> "$GITHUB_OUTPUT" + echo "bake_cache=" >> "$GITHUB_OUTPUT" + fi + + - name: Restore GenVM precompile cache + uses: actions/cache@v5 + with: + path: ${{ env.GENVM_CACHE_DIR }} + key: genvm-precompile-${{ runner.os }}-${{ runner.arch }}-${{ steps.genvm.outputs.binding }} + + - name: Prepare GenVM cache directory + run: | + sudo mkdir -p "$GENVM_CACHE_DIR/pc" + sudo chown -R 999:999 "$GENVM_CACHE_DIR" + + # Source builds need the GenVM runner fixed-output derivations built under + # a real Nix sandbox, which the nixos/nix build stage cannot provide. + - name: Prebuild GenVM runners closure + id: genvm_closure + if: env.GENVM_SOURCE_MODE == 'source' + uses: ./.github/actions/genvm-runners-closure + with: + ref: ${{ env.GENVM_REF }} + nix_cache_pull_token: ${{ secrets.NIX_CACHE_PULL_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + # Build only backend images with Buildx Bake and enable GHA cache + - name: Build backend images with cache (buildx bake) + # The source build inside Dockerfile.backend substitutes from the same + # private cache, so it needs the same credential. docker-compose.yml + # reads this path into the `nix_netrc` build secret; unset (a release + # build, or a fork PR with no token) falls back to /dev/null. + env: + NIX_NETRC_FILE: ${{ steps.genvm_closure.outputs.netrc_path }} + uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6 + with: + # Bake defaults to the Git context, which ignores anything earlier + # steps wrote into the workspace. Source builds need the prebuilt + # runners closure from there; release builds keep the Git context, + # where an empty value is the same as not passing the input. + source: ${{ env.GENVM_SOURCE_MODE == 'source' && '.' || '' }} + files: | + ./docker-compose.yml + targets: | + database-migration + jsonrpc + consensus-worker + set: | + *.cache-from=type=gha${{ steps.genvm.outputs.bake_cache }} + database-migration.tags=genlayer-studio-database-migration:latest + jsonrpc.tags=genlayer-studio-jsonrpc:latest + consensus-worker.tags=genlayer-studio-consensus-worker:latest + load: true + + - name: Precompile GenVM cache + timeout-minutes: 30 + run: docker/scripts/precompile_genvm.sh + + # Start services without rebuilding images; CI override provides fast healthcheck timings. + # --wait blocks until all healthchecks pass (jsonrpc /ready + consensus-worker /health). + - name: Run Docker Compose + timeout-minutes: 5 + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --no-build --wait database-migration jsonrpc consensus-worker + + - name: Verify gasless stack is ready + run: | + echo "Services passed healthchecks, verifying gasless RPC endpoint..." + curl -sS -X POST -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"sim_getFeeConfig","params":[],"id":1}' \ + 0.0.0.0:4000/api | tee /tmp/feeconfig.json + python3 -c "import json;cfg=json.load(open('/tmp/feeconfig.json'))['result'];assert cfg['enabled'] is False, cfg" + + - name: Run gasless tests + env: + TEST_PROVIDER: ${{ vars.LLM_PROVIDER || 'openrouter' }} + TEST_PROVIDER_MODEL: ${{ vars.LLM_MODEL || 'deepseek/deepseek-v3.2' }} + run: gltest --contracts-dir . --default-wait-retries 140 tests/integration/test_gasless_mode.py -svv -m gasless + + - name: Dump Docker Compose logs + run: docker compose logs + if: failure() + + - name: Shutdown Docker Compose + if: always() + run: docker compose down + db-integration-test: needs: triggers if: ${{ needs.triggers.outputs.is_pull_request_opened == 'true' || needs.triggers.outputs.is_pull_request_review_approved == 'true' || needs.triggers.outputs.is_pull_request_labeled_with_run_tests == 'true' }} @@ -212,4 +591,3 @@ jobs: # - name: Run Docker Compose # run: docker compose -f tests/hardhat/docker-compose.yml --project-directory . up tests --build --force-recreate --always-recreate-deps - diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 000000000..7759870f7 --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,24 @@ +name: Branch Policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + push: + branches: + - "**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + branch-policy: + name: Validate branch policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate branch policy + env: + GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: ./.github/scripts/validate-branch-policy.sh diff --git a/.github/workflows/docker-build-and-push-all.yml b/.github/workflows/docker-build-and-push-all.yml index 229f83a72..3f3ebe350 100644 --- a/.github/workflows/docker-build-and-push-all.yml +++ b/.github/workflows/docker-build-and-push-all.yml @@ -11,6 +11,11 @@ on: description: "Docker image tag to publish" required: true type: string + push_latest: + description: "Also publish Docker Hub latest manifests" + required: false + type: boolean + default: true workflow_call: inputs: ref: @@ -19,6 +24,13 @@ on: image_tag: required: true type: string + push_latest: + required: false + type: boolean + default: true + secrets: + DOCKERHUB_TOKEN: + required: true permissions: contents: read @@ -29,10 +41,12 @@ jobs: with: docker_build_context: . dockerfile: docker/Dockerfile.backend + target: prod dockerhub_repo: yeagerai/simulator-jsonrpc dockerhub_username: ${{ vars.DOCKERHUB_USERNAME }} ref: ${{ inputs.ref }} image_tag: ${{ inputs.image_tag }} + push_latest: ${{ inputs.push_latest }} secrets: dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} @@ -45,6 +59,7 @@ jobs: dockerhub_username: ${{ vars.DOCKERHUB_USERNAME }} ref: ${{ inputs.ref }} image_tag: ${{ inputs.image_tag }} + push_latest: ${{ inputs.push_latest }} secrets: dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} @@ -57,6 +72,7 @@ jobs: dockerhub_username: ${{ vars.DOCKERHUB_USERNAME }} ref: ${{ inputs.ref }} image_tag: ${{ inputs.image_tag }} + push_latest: ${{ inputs.push_latest }} secrets: dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} @@ -64,11 +80,13 @@ jobs: uses: ./.github/workflows/docker-build-and-push-image.yml with: docker_build_context: . - dockerfile: docker/Dockerfile.consensus-worker + dockerfile: docker/Dockerfile.backend + target: consensus-worker dockerhub_repo: yeagerai/simulator-consensus-worker dockerhub_username: ${{ vars.DOCKERHUB_USERNAME }} ref: ${{ inputs.ref }} image_tag: ${{ inputs.image_tag }} + push_latest: ${{ inputs.push_latest }} secrets: dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} @@ -81,5 +99,6 @@ jobs: dockerhub_username: ${{ vars.DOCKERHUB_USERNAME }} ref: ${{ inputs.ref }} image_tag: ${{ inputs.image_tag }} + push_latest: ${{ inputs.push_latest }} secrets: dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docker-build-and-push-image.yml b/.github/workflows/docker-build-and-push-image.yml index 8eff93b34..affaa4b57 100644 --- a/.github/workflows/docker-build-and-push-image.yml +++ b/.github/workflows/docker-build-and-push-image.yml @@ -9,6 +9,10 @@ on: dockerfile: required: true type: string + target: + required: false + type: string + default: "" dockerhub_repo: required: true type: string @@ -25,6 +29,10 @@ on: image_tag: required: true type: string + push_latest: + required: false + type: boolean + default: true secrets: dockerhub_token: required: true @@ -68,6 +76,7 @@ jobs: with: context: ${{ inputs.docker_build_context }} file: ${{ inputs.dockerfile }} + target: ${{ inputs.target }} platforms: ${{ matrix.platform }} push: true tags: ${{ inputs.dockerhub_repo }}:${{ inputs.image_tag }}-${{ matrix.arch }} @@ -95,6 +104,7 @@ jobs: env: DOCKERHUB_REPO: ${{ inputs.dockerhub_repo }} IMAGE_TAG: ${{ inputs.image_tag }} + PUSH_LATEST: ${{ inputs.push_latest }} run: | set -euo pipefail @@ -104,6 +114,11 @@ jobs: "${DOCKERHUB_REPO}:${IMAGE_TAG}-arm64" docker manifest push "${DOCKERHUB_REPO}:${IMAGE_TAG}" + if [[ "${PUSH_LATEST}" != "true" ]]; then + echo "Skipping latest manifest push" + exit 0 + fi + # Create manifest for latest tag docker manifest create "${DOCKERHUB_REPO}:latest" \ "${DOCKERHUB_REPO}:${IMAGE_TAG}-amd64" \ diff --git a/.github/workflows/docker-build-test-native-arm.yml b/.github/workflows/docker-build-test-native-arm.yml index 14b8f1045..e9eb80e0b 100644 --- a/.github/workflows/docker-build-test-native-arm.yml +++ b/.github/workflows/docker-build-test-native-arm.yml @@ -23,7 +23,8 @@ permissions: contents: read env: - DOCKERFILE_MAP: '{"jsonrpc":"docker/Dockerfile.backend","frontend":"docker/Dockerfile.frontend","consensus-worker":"docker/Dockerfile.consensus-worker","database-migration":"docker/Dockerfile.database-migration"}' + DOCKERFILE_MAP: '{"jsonrpc":"docker/Dockerfile.backend","frontend":"docker/Dockerfile.frontend","consensus-worker":"docker/Dockerfile.backend","database-migration":"docker/Dockerfile.database-migration"}' + TARGET_MAP: '{"jsonrpc":"prod","frontend":"","consensus-worker":"consensus-worker","database-migration":""}' REPO_MAP: '{"jsonrpc":"yeagerai/simulator-jsonrpc","frontend":"yeagerai/simulator-frontend","consensus-worker":"yeagerai/simulator-consensus-worker","database-migration":"yeagerai/simulator-database-migration"}' jobs: @@ -45,8 +46,11 @@ jobs: - name: Set image config id: config run: | - echo "dockerfile=${{ fromJson(env.DOCKERFILE_MAP)[inputs.image] }}" >> $GITHUB_OUTPUT - echo "repo=${{ fromJson(env.REPO_MAP)[inputs.image] }}" >> $GITHUB_OUTPUT + { + echo "dockerfile=${{ fromJson(env.DOCKERFILE_MAP)[inputs.image] }}" + echo "repo=${{ fromJson(env.REPO_MAP)[inputs.image] }}" + echo "target=${{ fromJson(env.TARGET_MAP)[inputs.image] }}" + } >> "$GITHUB_OUTPUT" - name: Login to Docker Hub if: ${{ inputs.push }} @@ -66,6 +70,7 @@ jobs: with: context: . file: ${{ steps.config.outputs.dockerfile }} + target: ${{ steps.config.outputs.target }} platforms: ${{ matrix.platform }} push: ${{ inputs.push }} tags: ${{ steps.config.outputs.repo }}:test-${{ matrix.arch }} @@ -74,10 +79,12 @@ jobs: - name: Report build time run: | - echo "## Build Summary" >> $GITHUB_STEP_SUMMARY - echo "- **Image:** ${{ inputs.image }}" >> $GITHUB_STEP_SUMMARY - echo "- **Platform:** ${{ matrix.platform }}" >> $GITHUB_STEP_SUMMARY - echo "- **Runner:** ${{ matrix.runner }}" >> $GITHUB_STEP_SUMMARY + { + echo "## Build Summary" + echo "- **Image:** ${{ inputs.image }}" + echo "- **Platform:** ${{ matrix.platform }}" + echo "- **Runner:** ${{ matrix.runner }}" + } >> "$GITHUB_STEP_SUMMARY" create-manifest: name: Create multi-arch manifest @@ -88,7 +95,7 @@ jobs: - name: Set image config id: config run: | - echo "repo=${{ fromJson(env.REPO_MAP)[inputs.image] }}" >> $GITHUB_OUTPUT + echo "repo=${{ fromJson(env.REPO_MAP)[inputs.image] }}" >> "$GITHUB_OUTPUT" - name: Login to Docker Hub uses: docker/login-action@v3 @@ -98,7 +105,7 @@ jobs: - name: Create and push manifest run: | - docker manifest create ${{ steps.config.outputs.repo }}:test-multiarch \ - ${{ steps.config.outputs.repo }}:test-amd64 \ - ${{ steps.config.outputs.repo }}:test-arm64 - docker manifest push ${{ steps.config.outputs.repo }}:test-multiarch + docker manifest create "${{ steps.config.outputs.repo }}:test-multiarch" \ + "${{ steps.config.outputs.repo }}:test-amd64" \ + "${{ steps.config.outputs.repo }}:test-arm64" + docker manifest push "${{ steps.config.outputs.repo }}:test-multiarch" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3de13916c..a684d77ae 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -124,11 +124,6 @@ on: permissions: contents: read - id-token: write - issues: write - checks: write - actions: write - pull-requests: write # /run-e2e triggers share a per-PR group so a new comment cancels the # previous in-progress run. Other issue_comment events (and @@ -164,6 +159,13 @@ jobs: # the dispatch path). # =========================================================================== acknowledge: + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write # PR-state guard: refuse /run-e2e on closed / merged PRs. After a # PR merges, its head branch is typically deleted, which breaks # every "content at PR head" fetch (third_party version files, @@ -197,6 +199,13 @@ jobs: # the `github.event_name == 'workflow_dispatch'` clause filters it out. # =========================================================================== plan: + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write needs: acknowledge if: | !cancelled() && @@ -233,6 +242,13 @@ jobs: # =========================================================================== build: name: build (dev-env) + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write needs: [acknowledge, plan] # Without an explicit `if:`, GHA's implicit `success()` would require # acknowledge to have succeeded — but acknowledge is intentionally @@ -287,6 +303,13 @@ jobs: # =========================================================================== build-studio: name: build (studio) + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write needs: [acknowledge, plan] if: | !cancelled() && @@ -311,6 +334,13 @@ jobs: # every per-wave plan array). # =========================================================================== wave-1: + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write # `name:` overrides GHA's default `wave-1 (component, test-task, …)` # matrix-tuple display. Reads matrix.job-name (resolved from # components.yaml's `job-name` field, falling back to the @@ -401,6 +431,13 @@ jobs: github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} wave-2: + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write # Cascade-skip label rules (walked in order, first match wins): # 1. build failed → "(skipped - build fails)" # 2. wave-1's failure-label is non-empty (e.g. "core") → @@ -471,6 +508,13 @@ jobs: github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} wave-3: + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write # See wave-2 for the cascade-skip label semantics. Walk order # (first match wins): build → wave-1 failure-label → wave-2 # failure-label → generic-fallback. The `.result == 'failure'` @@ -534,6 +578,13 @@ jobs: github-retry-max-delay: ${{ needs.plan.outputs.github-retry-max-delay }} wave-4: + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write # See wave-2 for the cascade-skip label semantics. Walk order # (first match wins): build → wave-1 → wave-2 → wave-3 failure- # label, then generic-fallback via `.result == 'failure'`. @@ -602,6 +653,13 @@ jobs: # `.result` for the rough verdict. # =========================================================================== result: + permissions: + contents: read + id-token: write + issues: write + checks: write + actions: write + pull-requests: write needs: - acknowledge - plan diff --git a/.github/workflows/fast-forward-main.yaml b/.github/workflows/fast-forward-main.yaml new file mode 100644 index 000000000..688e997a2 --- /dev/null +++ b/.github/workflows/fast-forward-main.yaml @@ -0,0 +1,57 @@ +name: Fast-forward main + +# main is the static/default branch for GitHub UX and tools that assume a +# stable default branch. It is not the integration target. On each push to the +# configured active dev branch, fast-forward main to that commit. + +on: + push: + branches: ["**"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: fast-forward-main-${{ github.repository }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + fast-forward: + if: github.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fast-forward main to active dev branch + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + if [[ "${GITHUB_REF_NAME}" != "${active_branch}" ]]; then + echo "Push was to ${GITHUB_REF_NAME}; active dev branch is ${active_branch}. Nothing to do." + exit 0 + fi + + if git ls-remote --exit-code --heads origin main >/dev/null 2>&1; then + git fetch origin main + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "::error::main has diverged from ${active_branch}; refusing non-fast-forward update" + exit 1 + fi + else + echo "main does not exist yet; creating it at ${GITHUB_SHA}." + fi + + git push origin "HEAD:refs/heads/main" diff --git a/.github/workflows/frontend-unit-tests.yml b/.github/workflows/frontend-unit-tests.yml index 52313d475..71f7bb0e3 100644 --- a/.github/workflows/frontend-unit-tests.yml +++ b/.github/workflows/frontend-unit-tests.yml @@ -32,5 +32,5 @@ jobs: with: verbose: true token: ${{ secrets.codecov_token }} - fail_ci_if_error: true + fail_ci_if_error: false directory: frontend/coverage diff --git a/.github/workflows/genvm-lint.yml b/.github/workflows/genvm-lint.yml index 5468ef99e..120b1c929 100644 --- a/.github/workflows/genvm-lint.yml +++ b/.github/workflows/genvm-lint.yml @@ -32,12 +32,21 @@ jobs: - name: Install genvm-linter run: pip install genvm-linter + # Contracts pinned to runners newer than the released linter's bundled + # SDK cannot be loaded by genvm-linter (<= 0.11.0): the genvm-main + # runner (1zr6nqk...) fails with "No module named 'genlayer.py'", and + # the v0.3.0-rc7 runner (bq43ya7v...) fails with + # "filename 'runners/py-genlayer/bq/43ya....zip' not found". Skip both + # until a linter release bundles them (genvm-linter dxp-694), then + # drop this filter. - name: Lint example contracts run: | failed=0 for f in examples/contracts/*.py; do echo "::group::$f" - if ! genvm-lint check "$f"; then + if grep -qE "py-genlayer:(1zr6nqk597d97kg0dyxg0shhrykx5v02zjgnyrajapy4wlqvfvwh|5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng)" "$f"; then + echo "SKIP: pinned to a runner unsupported by released genvm-linter" + elif ! genvm-lint check "$f"; then failed=1 fi echo "::endgroup::" @@ -50,7 +59,9 @@ jobs: for f in tests/load/contracts/*.py tests/direct/contracts/*.py; do [ -f "$f" ] || continue echo "::group::$f" - if ! genvm-lint check "$f"; then + if grep -qE "py-genlayer:(1zr6nqk597d97kg0dyxg0shhrykx5v02zjgnyrajapy4wlqvfvwh|5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng)" "$f"; then + echo "SKIP: pinned to a runner unsupported by released genvm-linter" + elif ! genvm-lint check "$f"; then failed=1 fi echo "::endgroup::" diff --git a/.github/workflows/load-test-oha.yml b/.github/workflows/load-test-oha.yml index 13ce99635..0d10a053c 100644 --- a/.github/workflows/load-test-oha.yml +++ b/.github/workflows/load-test-oha.yml @@ -35,6 +35,9 @@ jobs: if: ${{ needs.triggers.outputs.is_pull_request_opened == 'true' || needs.triggers.outputs.is_pull_request_review_approved == 'true' || needs.triggers.outputs.is_pull_request_labeled_with_run_tests == 'true' }} runs-on: ubuntu-latest + env: + GENVM_CACHE_DIR: /tmp/genvm-cache + steps: - name: Checkout code uses: actions/checkout@v6 @@ -63,6 +66,83 @@ jobs: - name: Copy .env file run: cp .env.example .env + - name: Resolve GenVM binding + env: + GH_TOKEN: ${{ github.token }} + run: | + # Binding model: codebase default < env override. + # third_party/genvm/version (same convention as genlayer-node) pins + # the GenVM this branch's host protocol speaks; it is flipped to the + # release tag at release cut. The e2e pipeline / release train can + # override by injecting GENVM_TAG or GENVM_REF into the environment. + # Exact vX.Y.Z[-suffix] values use release assets. Source pins must be + # explicit : values so builds cannot drift with a branch. + if [[ -n "${GENVM_TAG:-}" && -n "${GENVM_REF:-}" ]]; then + echo "::error::GENVM_TAG and GENVM_REF are both set; they are mutually exclusive" + exit 1 + fi + GENVM_TAG_VALUE="${GENVM_TAG:-}" + GENVM_REF_VALUE="${GENVM_REF:-}" + GENVM_SOURCE_MODE_VALUE="${GENVM_SOURCE_MODE:-}" + if [[ -z "$GENVM_TAG_VALUE" && -z "$GENVM_REF_VALUE" ]]; then + if [[ ! -f third_party/genvm/version ]]; then + echo "::error::third_party/genvm/version is missing and no GENVM_TAG/GENVM_REF env override is set" + exit 1 + fi + BINDING="$(head -n1 third_party/genvm/version | sed -e 's/\r$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + if [[ "$BINDING" =~ ^\".*\"$ || "$BINDING" =~ ^\'.*\'$ ]]; then + BINDING="${BINDING:1:${#BINDING}-2}" + BINDING="$(printf '%s' "$BINDING" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + fi + if [[ -z "$BINDING" ]]; then + echo "::error::third_party/genvm/version is empty" + exit 1 + fi + if [[ "$BINDING" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + GENVM_TAG_VALUE="$BINDING" + elif [[ "$BINDING" == *-dev ]]; then + echo "::error::Bare GenVM branch pin '$BINDING' is not supported; pin an exact vX.Y.Z release or ':'" + exit 1 + elif [[ "$BINDING" =~ ^.+:[0-9a-fA-F]{7,40}$ ]]; then + GENVM_REF_VALUE="$BINDING" + GENVM_SOURCE_MODE_VALUE="source" + else + echo "::error::Invalid GenVM pin '$BINDING'; expected vX.Y.Z[-suffix] or ':'" + exit 1 + fi + fi + if [[ -n "$GENVM_TAG_VALUE" && ! "$GENVM_TAG_VALUE" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid GENVM_TAG '$GENVM_TAG_VALUE'; expected vX.Y.Z[-suffix]" + exit 1 + fi + if [[ -n "$GENVM_REF_VALUE" ]]; then + if [[ "$GENVM_REF_VALUE" == *-dev ]]; then + echo "::error::Bare GenVM branch pin '$GENVM_REF_VALUE' is not supported; pin an exact vX.Y.Z release or ':'" + exit 1 + elif [[ "$GENVM_REF_VALUE" =~ ^(.+):([0-9a-fA-F]{7,40})$ ]]; then + GENVM_REF_BRANCH="${BASH_REMATCH[1]}" + GENVM_REF_COMMIT="$(gh api "repos/genlayerlabs/genvm-manager/commits/${BASH_REMATCH[2]}" --jq .sha)" + GENVM_REF_VALUE="$GENVM_REF_BRANCH:$GENVM_REF_COMMIT" + elif [[ "$GENVM_SOURCE_MODE_VALUE" == "source" ]]; then + GENVM_REF_VALUE="$(gh api "repos/genlayerlabs/genvm-manager/commits/$GENVM_REF_VALUE" --jq .sha)" + else + echo "::error::GENVM_REF must be ':' unless GENVM_SOURCE_MODE=source is explicit" + exit 1 + fi + GENVM_SOURCE_MODE_VALUE="source" + fi + sed -i "s|^GENVM_TAG=.*|GENVM_TAG=\"$GENVM_TAG_VALUE\"|" .env + sed -i "s|^GENVM_REF=.*|GENVM_REF=\"$GENVM_REF_VALUE\"|" .env + sed -i "s|^GENVM_SOURCE_MODE=.*|GENVM_SOURCE_MODE=\"$GENVM_SOURCE_MODE_VALUE\"|" .env + { + echo "GENVM_TAG=$GENVM_TAG_VALUE" + echo "GENVM_REF=$GENVM_REF_VALUE" + echo "GENVM_SOURCE_MODE=$GENVM_SOURCE_MODE_VALUE" + } >> "$GITHUB_ENV" + grep "^GENVM_TAG=" .env + grep "^GENVM_REF=" .env + grep "^GENVM_SOURCE_MODE=" .env + - name: Changing URL for rpc server to localhost run: sed -i "s/'jsonrpc'/'localhost'/g" .env @@ -82,44 +162,87 @@ jobs: - name: Extract GenVM version for cache key id: genvm - run: echo "tag=$(grep -m1 'ARG GENVM_TAG=' docker/Dockerfile.backend | cut -d= -f2)" >> "$GITHUB_OUTPUT" + run: | + GENVM_REF_VALUE="${GENVM_REF:-}" + if [[ -z "$GENVM_REF_VALUE" && -f .env ]]; then + GENVM_REF_VALUE="$(sed -n 's/^GENVM_REF=["'\'']\{0,1\}\([^"'\'']*\)["'\'']\{0,1\}.*/\1/p' .env | head -n1)" + fi + if [[ -n "$GENVM_REF_VALUE" ]]; then + GENVM_CACHE_REF="${GENVM_REF_VALUE##*:}" + echo "binding=ref-$GENVM_CACHE_REF" >> "$GITHUB_OUTPUT" + echo "bake_cache=,scope=genvm-$GENVM_CACHE_REF" >> "$GITHUB_OUTPUT" + else + GENVM_TAG_VALUE="${GENVM_TAG:-}" + if [[ -z "$GENVM_TAG_VALUE" && -f .env ]]; then + GENVM_TAG_VALUE="$(sed -n 's/^GENVM_TAG=["'\'']\{0,1\}\([^"'\'']*\)["'\'']\{0,1\}.*/\1/p' .env | head -n1)" + fi + if [[ -z "$GENVM_TAG_VALUE" ]]; then + echo "::warning::GENVM_TAG not set (this job runs prebuilt images) — using shared 'untagged' precompile cache key that will not invalidate across GenVM upgrades" + GENVM_TAG_VALUE="untagged" + fi + echo "binding=$GENVM_TAG_VALUE" >> "$GITHUB_OUTPUT" + echo "bake_cache=" >> "$GITHUB_OUTPUT" + fi - name: Restore GenVM precompile cache uses: actions/cache@v5 with: - path: /tmp/genvm-cache - key: genvm-precompile-${{ steps.genvm.outputs.tag }}-amd64 + path: ${{ env.GENVM_CACHE_DIR }} + key: genvm-precompile-${{ runner.os }}-${{ runner.arch }}-${{ steps.genvm.outputs.binding }} - name: Prepare GenVM cache directory - run: mkdir -p /tmp/genvm-cache/pc && chmod -R 0777 /tmp/genvm-cache + run: | + sudo mkdir -p "$GENVM_CACHE_DIR/pc" + sudo chown -R 999:999 "$GENVM_CACHE_DIR" + + # Source builds need the GenVM runner fixed-output derivations built under + # a real Nix sandbox, which the nixos/nix build stage cannot provide. + - name: Prebuild GenVM runners closure + id: genvm_closure + if: env.GENVM_SOURCE_MODE == 'source' + uses: ./.github/actions/genvm-runners-closure + with: + ref: ${{ env.GENVM_REF }} + nix_cache_pull_token: ${{ secrets.NIX_CACHE_PULL_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build images with cache (buildx bake) + # The source build inside Dockerfile.backend substitutes from the same + # private cache, so it needs the same credential. docker-compose.yml + # reads this path into the `nix_netrc` build secret; unset (a release + # build, or a fork PR with no token) falls back to /dev/null. + env: + NIX_NETRC_FILE: ${{ steps.genvm_closure.outputs.netrc_path }} uses: docker/bake-action@v6 with: + # Bake defaults to the Git context, which ignores anything earlier + # steps wrote into the workspace. Source builds need the prebuilt + # runners closure from there; release builds keep the Git context, + # where an empty value is the same as not passing the input. + source: ${{ env.GENVM_SOURCE_MODE == 'source' && '.' || '' }} files: | ./docker-compose.yml targets: | - frontend database-migration jsonrpc consensus-worker - explorer set: | - *.cache-from=type=gha - *.cache-to=type=gha,mode=max - frontend.tags=genlayer-studio-frontend:latest + *.cache-from=type=gha${{ steps.genvm.outputs.bake_cache }} + *.cache-to=type=gha,mode=max${{ steps.genvm.outputs.bake_cache }} database-migration.tags=genlayer-studio-database-migration:latest jsonrpc.tags=genlayer-studio-jsonrpc:latest consensus-worker.tags=genlayer-studio-consensus-worker:latest - explorer.tags=genlayer-studio-explorer:latest load: true - - name: Run Docker Compose with multiple workers + - name: Precompile GenVM cache + timeout-minutes: 30 + run: docker/scripts/precompile_genvm.sh + + - name: Run backend stack with multiple workers timeout-minutes: 5 - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --no-build --wait + run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --no-build --wait database-migration jsonrpc consensus-worker env: CONSENSUS_WORKERS: 3 diff --git a/.github/workflows/manual-docker-release.yml b/.github/workflows/manual-docker-release.yml index f1b4cc018..29a643e4d 100644 --- a/.github/workflows/manual-docker-release.yml +++ b/.github/workflows/manual-docker-release.yml @@ -73,4 +73,6 @@ jobs: uses: ./.github/workflows/release-from-tag.yml with: tag: ${{ github.event.inputs.version }} - secrets: inherit + secrets: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + RELEASE_LANE_APP_KEY: ${{ secrets.RELEASE_LANE_APP_KEY }} diff --git a/.github/workflows/release-from-tag.yml b/.github/workflows/release-from-tag.yml index 4d333d698..f7365d7b5 100644 --- a/.github/workflows/release-from-tag.yml +++ b/.github/workflows/release-from-tag.yml @@ -16,6 +16,11 @@ on: description: "Release tag to build and promote" required: true type: string + secrets: + DOCKERHUB_TOKEN: + required: true + RELEASE_LANE_APP_KEY: + required: true permissions: contents: read @@ -87,7 +92,8 @@ jobs: with: ref: ${{ needs.validate-tag.outputs.tag }} image_tag: ${{ needs.validate-tag.outputs.tag }} - secrets: inherit + secrets: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} trigger-workload-release-lane: name: Trigger AWS Release Lane @@ -108,7 +114,7 @@ jobs: repositories: devexp-argocd-apps - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - name: Resolve image digests id: digests diff --git a/.github/workflows/retarget-main-prs.yaml b/.github/workflows/retarget-main-prs.yaml new file mode 100644 index 000000000..1a3a31667 --- /dev/null +++ b/.github/workflows/retarget-main-prs.yaml @@ -0,0 +1,53 @@ +name: Retarget main PRs + +# main is a static/default alias of the active dev branch. Contributions should +# target the active dev branch directly; PRs opened against main are retargeted +# automatically so required checks and release-train rules run in the right +# branch context. +# +# pull_request_target is used for the write-scoped token. This workflow never +# checks out or executes PR head code; it reads only trusted base-branch files. + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +defaults: + run: + shell: bash + +jobs: + retarget: + if: github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - name: Retarget PR to active dev branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + + active_branch="$( + gh api "/repos/${GITHUB_REPOSITORY}/contents/support/ci/ACTIVE_DEV_BRANCH?ref=${BASE_REF}" \ + --jq '.content' | base64 -d | tr -d '[:space:]' + )" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --base "${active_branch}" + + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body "$(cat <:` ref for automatic source + mode), prepare the runner closure before `docker compose build` or + `docker compose up`: + + ```sh + $ ./scripts/prepare-genvm-source-build.sh + ``` + + The runner tree contains fixed-output derivations that need a real Nix + sandbox to retain their pinned hashes. The Docker build imports this + prepared closure because its `nixos/nix` stage cannot provide that sandbox. + - **1.4. Running Tests**: ```sh diff --git a/README.md b/README.md index c2f5d4032..06846d3d8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ This Studio is an interactive sandbox designed for developers to explore the potential of the [GenLayer Protocol](https://genlayer.com/). It replicates the GenLayer network's execution environment and consensus algorithm, but offers a controlled and local environment to test different ideas and behaviors. +## Branching + +See [docs/BRANCHING.md](docs/BRANCHING.md) for the release-train model used by +this repo. + ## Prerequisites Before installing the GenLayer CLI, ensure you have the following prerequisites installed: diff --git a/backend/consensus/base.py b/backend/consensus/base.py index f6dad1fa1..91e0601a1 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -9,7 +9,7 @@ import os import asyncio -from typing import Callable, List, Iterable, Literal +from typing import Any, Callable, List, Iterable, Literal import time from abc import ABC, abstractmethod import random @@ -29,6 +29,7 @@ TransactionsProcessor, TransactionStatus, ) +from backend.database_handler.models import Transactions from backend.database_handler.accounts_manager import AccountsManager from backend.database_handler.types import ConsensusData from backend.domain.types import ( @@ -52,10 +53,22 @@ EventType, EventScope, ) +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + FeeValidationError, + StudioFeePolicy, + consume_message_fees, + create_child_fee_accounting, + derive_external_message_call_key, + fill_message_fee_payload_from_allocation, + record_external_message_execution_fees, + record_reveal_message_fees, + unwind_reveal_message_fees, +) from backend.rollup.consensus_service import ConsensusService import backend.validators as validators -from backend.node.genvm.origin.public_abi import ResultCode +from backend.node.genvm.origin.host_fns import ResultCode from backend.consensus.types import ConsensusResult, ConsensusRound from backend.consensus.utils import determine_consensus_from_votes from backend.consensus.decisions import ( @@ -80,6 +93,13 @@ from backend.node.genvm.error_codes import GenVMInternalError, GenVMErrorCode from backend.node.base import Manager as GenVMManager +# Cap on concurrently executing validators per transaction. Bounds GenVM +# subprocess memory, fd, and DB-session usage; larger committees run through +# this window. See issue #1721. +VALIDATOR_MAX_CONCURRENT = max( + 1, int(os.environ.get("CONSENSUS_VALIDATOR_MAX_CONCURRENT", "8")) +) + type NodeFactory = Callable[ [ dict, @@ -306,6 +326,15 @@ def node_factory( ) +def transaction_genvm_executor_selector(transaction: Transaction) -> str | None: + """Studio-only GenVM executor override carried by the transaction.""" + return ( + transaction.sim_config.genvm_executor_selector + if transaction.sim_config + else None + ) + + def contract_snapshot_factory( contract_address: str, session: Session, @@ -339,6 +368,9 @@ def contract_snapshot_factory( ret.contract_code = transaction.data["contract_code"] ret.balance = transaction.value or 0 ret.states = {"accepted": {}, "finalized": {}} + # The contract row is still empty at deploy time, so the executor + # override can only come from the deploy transaction itself. + ret.genvm_executor_selector = transaction_genvm_executor_selector(transaction) return ret # Return a ContractSnapshot instance for an existing contract @@ -1121,6 +1153,7 @@ async def process_validator_appeal( "Appeal failed, no validators found to process the appeal", { "transaction_hash": context.transaction.hash, + "error": str(e), }, transaction_hash=context.transaction.hash, ) @@ -1183,18 +1216,66 @@ async def process_validator_appeal( await self.rollback_transactions(context) # Get the previous state of the contract + previous_contract_state = None if context.transaction.contract_snapshot: - previous_contact_state = ( + previous_contract_state = ( context.transaction.contract_snapshot.states["accepted"] ) + elif ( + context.transaction.type == TransactionType.DEPLOY_CONTRACT + ): + # Rolling back a deploy: clear the contract state + previous_contract_state = {} else: - previous_contact_state = {} + # Defense in depth: the in-memory transaction may have + # been built without the stored contract_snapshot. + # Re-fetch it instead of clobbering the contract state + # with {} (which would wipe the code slot). + refetched = ( + context.transactions_processor.get_transaction_by_hash( + context.transaction.hash + ) + ) + refetched_snapshot = ContractSnapshot.from_dict( + (refetched or {}).get("contract_snapshot") + ) + if refetched_snapshot: + previous_contract_state = refetched_snapshot.states[ + "accepted" + ] + else: + from loguru import logger + + logger.error( + f"Missing contract_snapshot for appealed " + f"transaction {context.transaction.hash}; " + f"skipping contract state restore" + ) + # Surface to monitoring: the appeal succeeded but + # the contract kept the appealed transaction's + # state — recoverable, but needs operator eyes. + context.msg_handler.send_message( + LogEvent( + "consensus_event", + EventType.ERROR, + EventScope.CONSENSUS, + "Missing contract_snapshot on successful " + "validator appeal; contract state restore " + "skipped", + { + "transaction_hash": context.transaction.hash, + "contract_address": context.transaction.to_address, + }, + transaction_hash=context.transaction.hash, + ) + ) # Restore the contract state - context.contract_processor.update_contract_state( - context.transaction.to_address, - accepted_state=previous_contact_state, - ) + if previous_contract_state is not None: + context.contract_processor.update_contract_state( + context.transaction.to_address, + accepted_state=previous_contract_state, + ) # Always clear snapshot on successful appeal (including timeout appeals) # so re-execution loads fresh state from DB @@ -1475,12 +1556,398 @@ async def handle( """ +def _external_message_value_total( + pending_transactions: Iterable[PendingTransaction], +) -> int: + return sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if pending_transaction.is_eth_send and int(pending_transaction.value or 0) > 0 + ) + + +def _external_message_value_for_phase( + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if pending_transaction.is_eth_send + and pending_transaction.on == on + and int(pending_transaction.value or 0) > 0 + ) + + +def _apply_external_message_freeze_check( + context: TransactionContext, + leader_receipt: Receipt, +) -> None: + if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: + return + + declared_value = _external_message_value_total(leader_receipt.pending_transactions) + if declared_value <= 0: + return + + other_reserved = _external_message_pending_freeze_total(context) + balance = context.accounts_manager.get_account_balance( + context.transaction.to_address + ) + available = max(balance - other_reserved, 0) + if declared_value <= available: + return + + error_message = ( + "ExternalMessageFreezeExceeded: " + f"declaredValue={declared_value}, availableLimit={available}" + ) + leader_receipt.execution_result = ExecutionResultStatus.ERROR + leader_receipt.result = bytes([ResultCode.VM_ERROR]) + error_message.encode("utf-8") + leader_receipt.contract_state = {} + leader_receipt.contract_state_hash = None + leader_receipt.pending_transactions = [] + leader_receipt.genvm_result = { + **(leader_receipt.genvm_result or {}), + "error_code": "EXTERNAL_MESSAGE_FREEZE_EXCEEDED", + "error_description": error_message, + "external_message_freeze": { + "declaredValue": declared_value, + "availableLimit": available, + "balance": balance, + "reservedExternal": other_reserved, + }, + } + + +def _internal_message_value_for_phase( + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if not pending_transaction.is_eth_send + and pending_transaction.on == on + and int(pending_transaction.value or 0) > 0 + ) + + +def _remaining_external_freeze_after_phase( + context: TransactionContext, + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + pending_freeze = _external_message_pending_freeze_total(context) + if on == "finalized": + return pending_freeze + + return pending_freeze + _external_message_value_for_phase( + pending_transactions, "finalized" + ) + + +def _external_message_pending_freeze_total(context: TransactionContext) -> int: + contract_address = context.transaction.to_address + if not contract_address or not hasattr(context.transactions_processor, "session"): + return 0 + + current_created_at = ( + context.transactions_processor.session.query(Transactions.created_at) + .filter(Transactions.hash == context.transaction.hash) + .scalar() + ) + filters = [ + Transactions.to_address == contract_address, + Transactions.status == TransactionStatus.ACCEPTED, + Transactions.hash != context.transaction.hash, + Transactions.consensus_data.isnot(None), + ] + if current_created_at is not None: + filters.append(Transactions.created_at < current_created_at) + + rows = ( + context.transactions_processor.session.query( + Transactions.hash, + Transactions.consensus_data, + ) + .filter(*filters) + .all() + ) + + total = 0 + for row in rows: + for receipt in _leader_receipts_from_consensus_data(row.consensus_data): + if ( + _receipt_execution_result(receipt) + != ExecutionResultStatus.SUCCESS.value + ): + continue + total += _external_message_value_for_phase_from_raw( + _receipt_pending_transactions(receipt), + "finalized", + ) + return total + + +def _leader_receipts_from_consensus_data(consensus_data: Any) -> list[Any]: + if isinstance(consensus_data, ConsensusData): + leader_receipt = consensus_data.leader_receipt + if isinstance(leader_receipt, list): + return leader_receipt[:1] + if leader_receipt: + return [leader_receipt] + return [] + + if not isinstance(consensus_data, dict): + return [] + + leader_receipt = consensus_data.get("leader_receipt") + if isinstance(leader_receipt, list): + return leader_receipt[:1] + if isinstance(leader_receipt, dict): + return [leader_receipt] + return [] + + +def _receipt_execution_result(receipt: Any) -> str | None: + if isinstance(receipt, Receipt): + return receipt.execution_result.value + if isinstance(receipt, dict): + return receipt.get("execution_result") + return None + + +def _receipt_pending_transactions(receipt: Any) -> Iterable[Any]: + if isinstance(receipt, Receipt): + return receipt.pending_transactions + if isinstance(receipt, dict): + return receipt.get("pending_transactions") or [] + return [] + + +def _external_message_value_for_phase_from_raw( + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + _pending_transaction_external_value(pending_transaction, on) + for pending_transaction in pending_transactions + ) + + +def _pending_transaction_external_value( + pending_transaction: Any, + on: Literal["accepted", "finalized"], +) -> int: + if isinstance(pending_transaction, PendingTransaction): + if not pending_transaction.is_eth_send or pending_transaction.on != on: + return 0 + return int(pending_transaction.value or 0) + + if not isinstance(pending_transaction, dict): + return 0 + + is_external = bool( + pending_transaction.get("is_eth_send") + or pending_transaction.get("isEthSend") + or pending_transaction.get("messageType") in {0, "0", "External", "external"} + ) + if not is_external: + return 0 + + pending_on = pending_transaction.get("on") + if pending_on is None and "onAcceptance" in pending_transaction: + pending_on = ( + "accepted" if pending_transaction.get("onAcceptance") else "finalized" + ) + if pending_on != on: + return 0 + + return int(pending_transaction.get("value", 0) or 0) + + +def _pending_transaction_with_value( + pending_transaction: PendingTransaction, + value: int, +) -> PendingTransaction: + adjusted = deepcopy(pending_transaction) + adjusted.value = value + return adjusted + + +def _debit_external_message_value_for_phase( + context: TransactionContext, + pending_transactions: list[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> bool: + external_value = _external_message_value_for_phase(pending_transactions, on) + if external_value <= 0: + return True + + debited = context.accounts_manager.debit_account_balance( + context.transaction.to_address, external_value + ) + if not debited: + _log_message_value_debit_failure( + context, + on, + external_value, + "external", + "Skipping value-bearing external child emission.", + ) + return debited + + +def _debit_internal_message_value_for_phase( + context: TransactionContext, + pending_transactions: list[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> bool: + internal_value = _internal_message_value_for_phase(pending_transactions, on) + if internal_value <= 0: + return True + + internal_cap = _internal_message_value_cap(context, pending_transactions, on) + if internal_value > internal_cap: + _log_internal_message_value_cap_failure( + context, + on, + internal_value, + internal_cap, + pending_transactions, + ) + return False + + debited = context.accounts_manager.debit_account_balance( + context.transaction.to_address, internal_value + ) + if not debited: + _log_message_value_debit_failure( + context, + on, + internal_value, + "internal", + "Emitting internal children with value=0.", + ) + return debited + + +def _internal_message_value_cap( + context: TransactionContext, + pending_transactions: list[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + frozen_after_phase = _remaining_external_freeze_after_phase( + context, pending_transactions, on + ) + balance_after_external = context.accounts_manager.get_account_balance( + context.transaction.to_address + ) + return max(balance_after_external - frozen_after_phase, 0) + + +def _log_internal_message_value_cap_failure( + context: TransactionContext, + on: Literal["accepted", "finalized"], + amount: int, + available: int, + pending_transactions: list[PendingTransaction], +) -> None: + from loguru import logger + + reserved_external = _remaining_external_freeze_after_phase( + context, pending_transactions, on + ) + logger.error( + f"Contract internal message value is not backed for {context.transaction.to_address}, " + f"phase={on}, amount={amount}, available={available}, " + f"reserved_external={reserved_external}, tx={context.transaction.hash}. " + f"Emitting internal children with value=0." + ) + + +def _log_message_value_debit_failure( + context: TransactionContext, + on: Literal["accepted", "finalized"], + amount: int, + message_kind: str, + consequence: str, +) -> None: + from loguru import logger + + logger.error( + f"Contract {message_kind} message debit failed for {context.transaction.to_address}, " + f"phase={on}, amount={amount}, tx={context.transaction.hash}. {consequence}" + ) + + +def _adjust_unbacked_message_values( + pending_transactions: list[PendingTransaction], + on: Literal["accepted", "finalized"], + *, + external_value_backed: bool, + internal_value_backed: bool, +) -> list[PendingTransaction]: + adjusted_pending_transactions = [] + for pending_transaction in pending_transactions: + value = int(pending_transaction.value or 0) + if pending_transaction.on == on and value > 0: + if pending_transaction.is_eth_send and not external_value_backed: + continue + if not pending_transaction.is_eth_send and not internal_value_backed: + adjusted_pending_transactions.append( + _pending_transaction_with_value(pending_transaction, 0) + ) + continue + + adjusted_pending_transactions.append(pending_transaction) + + return adjusted_pending_transactions + + +def _apply_message_value_withdrawals_for_phase( + context: TransactionContext, + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> list[PendingTransaction]: + pending_list = list(pending_transactions) + external_value_backed = _debit_external_message_value_for_phase( + context, pending_list, on + ) + internal_value_backed = _debit_internal_message_value_for_phase( + context, pending_list, on + ) + + if external_value_backed and internal_value_backed: + return pending_list + + return _adjust_unbacked_message_values( + pending_list, + on, + external_value_backed=external_value_backed, + internal_value_backed=internal_value_backed, + ) + + class PendingState(TransactionState): """ Class representing the pending state of a transaction. """ async def handle(self, context): + # Refresh transaction from DB FIRST. The claim-path dict that built + # context.transaction omits columns whose Transaction.from_dict + # defaults are silently wrong for re-processed appeals (e.g. + # appeal_undetermined/appeal_leader_timeout default to False) — the + # same drift class as the PR #1724 state wipe. Nothing in this state + # may read claim-built appeal fields before this refresh. + context.transaction = Transaction.from_dict( + context.transactions_processor.get_transaction_by_hash( + context.transaction.hash + ) + ) + # Pre-effects: timestamp + reset rotation count pre_effects = decide_pending_pre( tx_hash=context.transaction.hash, @@ -1489,13 +1956,6 @@ async def handle(self, context): ) await EffectExecutor(context).execute(pre_effects) - # Refresh transaction from DB - context.transaction = Transaction.from_dict( - context.transactions_processor.get_transaction_by_hash( - context.transaction.hash - ) - ) - # Log executing message (unless appeal) if ( not context.transaction.appeal_leader_timeout @@ -1651,7 +2111,7 @@ async def handle(self, context): # Credit target contract on activation (value from transaction) # Placed AFTER validator check — if no validators, tx gets canceled # and refund_tx_value must be able to refund (requires value_credited=false) - tx_value = context.transaction.value or 0 + tx_value = int(context.transaction.value or 0) if tx_value > 0: credited = context.accounts_manager.credit_tx_value_once( context.transaction.hash, @@ -1922,7 +2382,7 @@ def validator_timing_callback(step_name: str): ) # Execute the transaction with a semaphore to limit the number of concurrent validators - sem = asyncio.Semaphore(8) + sem = asyncio.Semaphore(VALIDATOR_MAX_CONCURRENT) # Build replacement pool: all validators minus those already assigned assigned_addresses: set[str] = set() @@ -2404,6 +2864,8 @@ class AcceptedState(TransactionState): async def handle(self, context): leader_receipt = context.consensus_data.leader_receipt[0] + _apply_external_message_freeze_check(context, leader_receipt) + _sync_reveal_message_fee_accounting(context, leader_receipt) accepted_contract_state = leader_receipt.contract_state execution_success = ( leader_receipt.execution_result == ExecutionResultStatus.SUCCESS @@ -2441,6 +2903,11 @@ async def handle(self, context): ), to_address=context.transaction.to_address, leader_node_config=leader_receipt.node_config, + genvm_executor_selector=( + transaction_genvm_executor_selector(context.transaction) + if is_deploy + else None + ), ) # Execute pre-effects (includes contract registration/update via executor) @@ -2450,39 +2917,13 @@ async def handle(self, context): # Impure: triggered transaction processing (needs DB reads for nonce/accounts) # Cumulative: child emission happens on every acceptance round (including appeal re-acceptance) if execution_success: - # Balance debit for on_accepted messages BEFORE child emission - total_msg_debit = sum( - pt.value - for pt in leader_receipt.pending_transactions - if pt.on == "accepted" and pt.value > 0 - ) - debit_ok = True - if total_msg_debit > 0: - debit_ok = context.accounts_manager.debit_account_balance( - context.transaction.to_address, total_msg_debit - ) - if not debit_ok: - from loguru import logger - - logger.error( - f"Contract balance debit failed for {context.transaction.to_address}, " - f"amount={total_msg_debit}, tx={context.transaction.hash}. " - f"Skipping value-bearing child emission." - ) - - # Emit child messages — filter out value-bearing children if debit failed - if debit_ok: - pending_to_emit = leader_receipt.pending_transactions - else: - pending_to_emit = [ - pt - for pt in leader_receipt.pending_transactions - if pt.on != "accepted" or pt.value <= 0 - ] - internal_messages_data, insert_transactions_data = _get_messages_data( context, - pending_to_emit, + _apply_message_value_withdrawals_for_phase( + context, + leader_receipt.pending_transactions, + "accepted", + ), "accepted", ) @@ -2649,38 +3090,13 @@ async def handle(self, context): finalized_state=accepted_state, ) - # Balance debit BEFORE child emission - total_finalized_debit = sum( - pt.value - for pt in leader_receipt.pending_transactions - if pt.on == "finalized" and pt.value > 0 - ) - finalize_debit_ok = True - if total_finalized_debit > 0: - finalize_debit_ok = context.accounts_manager.debit_account_balance( - context.transaction.to_address, total_finalized_debit - ) - if not finalize_debit_ok: - from loguru import logger - - logger.error( - f"Contract finalization debit failed for {context.transaction.to_address}, " - f"amount={total_finalized_debit}, tx={context.transaction.hash}" - ) - - # Filter out value-bearing children if debit failed - if finalize_debit_ok: - pending_to_finalize = leader_receipt.pending_transactions - else: - pending_to_finalize = [ - pt - for pt in leader_receipt.pending_transactions - if pt.on != "finalized" or pt.value <= 0 - ] - internal_messages_data, insert_transactions_data = _get_messages_data( context, - pending_to_finalize, + _apply_message_value_withdrawals_for_phase( + context, + leader_receipt.pending_transactions, + "finalized", + ), "finalized", ) @@ -2697,6 +3113,18 @@ async def handle(self, context): await executor.execute(post_effects) + refund_recipient = ( + context.transaction.origin_address or context.transaction.from_address + ) + if refund_recipient: + context.accounts_manager.settle_tx_fee_accounting_once( + context.transaction.hash, + refund_recipient, + receipt=leader_receipt, + reason="finalized", + ) + context.accounts_manager.session.commit() + def _get_messages_data( context: TransactionContext, @@ -2705,53 +3133,31 @@ def _get_messages_data( ): insert_transactions_data = [] internal_messages_data = [] + message_fee_payloads = [] + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + reveal_recorded = bool( + parent_fee_accounting + and parent_fee_accounting.get("message_fees_recorded_at_reveal") + ) base_nonce = context.transactions_processor.get_transaction_count( context.transaction.to_address ) - nonce_offset = 0 - for pending_transaction in filter(lambda t: t.on == on, pending_transactions): + for nonce_offset, pending_transaction in enumerate( + _pending_transactions_for_phase(pending_transactions, on) + ): nonce = base_nonce + nonce_offset - nonce_offset += 1 - data: dict - transaction_type: TransactionType - if pending_transaction.is_eth_send: - transaction_type = TransactionType.SEND - data = {} - elif pending_transaction.is_deploy(): - transaction_type = TransactionType.DEPLOY_CONTRACT - new_contract_address: str - if pending_transaction.salt_nonce == 0: - # NOTE: this address is random, which doesn't 100% align with consensus spec - new_contract_address = ( - context.accounts_manager.create_new_account().address - ) - else: - from eth_utils.crypto import keccak - from backend.node.types import Address - from backend.node.base import get_simulator_chain_id - - arr = bytearray() - arr.append(1) - arr.extend(Address(context.transaction.to_address).as_bytes) - arr.extend( - pending_transaction.salt_nonce.to_bytes(32, "big", signed=False) - ) - arr.extend(get_simulator_chain_id().to_bytes(32, "big", signed=False)) - new_contract_address = Address(keccak(arr)[:20]).as_hex - context.accounts_manager.create_new_account_with_address( - new_contract_address - ) - pending_transaction.address = new_contract_address - data = { - "contract_address": new_contract_address, - "contract_code": pending_transaction.code, - "calldata": pending_transaction.calldata, - } - else: - transaction_type = TransactionType.RUN_CONTRACT - data = { - "calldata": pending_transaction.calldata, - } + transaction_type, data = _child_transaction_payload( + context, pending_transaction + ) + + _append_message_fee_payload( + context, + pending_transaction, + parent_fee_accounting, + message_fee_payloads, + data, + on, + ) insert_transactions_data.append( [ @@ -2763,28 +3169,371 @@ def _get_messages_data( ] ) - serializable_data = data.copy() - if "contract_code" in serializable_data: - serializable_data["contract_code"] = serializable_data[ - "contract_code" - ].decode() - if "calldata" in serializable_data: - # Encode binary calldata as base64 instead of trying to decode as UTF-8 - serializable_data["calldata"] = base64.b64encode( - serializable_data["calldata"] - ).decode("utf-8") - internal_messages_data.append( - { - "sender": context.transaction.to_address, - "recipient": pending_transaction.address, - "data": json.dumps(serializable_data).encode(), - } + _internal_message_event_data(context, pending_transaction, data) ) + _record_parent_message_fee_consumption( + context, + parent_fee_accounting, + message_fee_payloads, + reveal_recorded, + ) + return internal_messages_data, insert_transactions_data +def _pending_transactions_for_phase( + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> Iterable[PendingTransaction]: + return ( + pending_transaction + for pending_transaction in pending_transactions + if pending_transaction.on == on + ) + + +def _child_transaction_payload( + context: TransactionContext, + pending_transaction: PendingTransaction, +) -> tuple[TransactionType, dict]: + if pending_transaction.is_eth_send: + return TransactionType.SEND, {} + if pending_transaction.is_deploy(): + return _deploy_child_transaction_payload(context, pending_transaction) + return TransactionType.RUN_CONTRACT, {"calldata": pending_transaction.calldata} + + +def _deploy_child_transaction_payload( + context: TransactionContext, + pending_transaction: PendingTransaction, +) -> tuple[TransactionType, dict]: + new_contract_address = _child_contract_address(context, pending_transaction) + pending_transaction.address = new_contract_address + return ( + TransactionType.DEPLOY_CONTRACT, + { + "contract_address": new_contract_address, + "contract_code": pending_transaction.code, + "calldata": pending_transaction.calldata, + }, + ) + + +def _child_contract_address( + context: TransactionContext, + pending_transaction: PendingTransaction, +) -> str: + if pending_transaction.salt_nonce == 0: + # NOTE: this address is random, which doesn't 100% align with consensus spec + return context.accounts_manager.create_new_account().address + + from eth_utils.crypto import keccak + from backend.node.types import Address + from backend.node.base import get_simulator_chain_id + + arr = bytearray() + arr.append(1) + arr.extend(Address(context.transaction.to_address).as_bytes) + arr.extend(pending_transaction.salt_nonce.to_bytes(32, "big", signed=False)) + arr.extend(get_simulator_chain_id().to_bytes(32, "big", signed=False)) + new_contract_address = Address(keccak(arr)[:20]).as_hex + context.accounts_manager.create_new_account_with_address(new_contract_address) + return new_contract_address + + +def _append_message_fee_payload( + context: TransactionContext, + pending_transaction: PendingTransaction, + parent_fee_accounting: dict[str, Any] | None, + message_fee_payloads: list[dict[str, Any]], + data: dict, + on: Literal["accepted", "finalized"], +) -> None: + if not parent_fee_accounting: + return + + message_payload = _parent_message_fee_payload( + parent_fee_accounting, + pending_transaction, + on, + ) + message_fee_payloads.append(message_payload) + if pending_transaction.is_eth_send: + return + + _attach_child_fee_accounting( + context, + parent_fee_accounting, + message_payload, + pending_transaction, + data, + ) + + +def _parent_message_fee_payload( + parent_fee_accounting: dict[str, Any], + pending_transaction: PendingTransaction, + on: Literal["accepted", "finalized"], +) -> dict[str, Any]: + payload = _pending_transaction_fee_payload(pending_transaction, on) + if pending_transaction.is_eth_send: + return payload + + try: + return fill_message_fee_payload_from_allocation(parent_fee_accounting, payload) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + +def _attach_child_fee_accounting( + context: TransactionContext, + parent_fee_accounting: dict[str, Any], + message_payload: dict[str, Any], + pending_transaction: PendingTransaction, + data: dict, +) -> None: + if int(message_payload.get("declaredBudget", 0) or 0) <= 0: + return + + try: + child_fees, child_fee_accounting = create_child_fee_accounting( + message=message_payload, + parent_fees_distribution=parent_fee_accounting.get("fees_distribution"), + message_allocations=message_payload.get("allocationSubtree") or [], + sender=context.transaction.origin_address + or context.transaction.from_address, + policy=StudioFeePolicy.from_env(), + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + data.update( + { + "fee_value": int(message_payload["declaredBudget"]), + "user_value": pending_transaction.value, + "fees_distribution": child_fees, + "message_allocations_count": len( + child_fee_accounting.get("message_allocations") or [] + ), + FEE_ACCOUNTING_KEY: child_fee_accounting, + } + ) + + +def _internal_message_event_data( + context: TransactionContext, + pending_transaction: PendingTransaction, + data: dict, +) -> dict[str, Any]: + return { + "sender": context.transaction.to_address, + "recipient": pending_transaction.address, + "data": json.dumps(_serializable_message_data(data)).encode(), + } + + +def _serializable_message_data(data: dict) -> dict: + serializable_data = data.copy() + if "contract_code" in serializable_data: + serializable_data["contract_code"] = serializable_data["contract_code"].decode() + if "calldata" in serializable_data: + serializable_data["calldata"] = base64.b64encode( + serializable_data["calldata"] + ).decode("utf-8") + return serializable_data + + +def _record_parent_message_fee_consumption( + context: TransactionContext, + parent_fee_accounting: dict[str, Any] | None, + message_fee_payloads: list[dict[str, Any]], + reveal_recorded: bool, +) -> None: + if not parent_fee_accounting or not message_fee_payloads: + return + + updated_accounting = _consume_parent_message_fee_payloads( + parent_fee_accounting, + message_fee_payloads, + reveal_recorded, + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + context.transactions_processor.update_transaction_fee_accounting( + context.transaction.hash, updated_accounting + ) + + +def _consume_parent_message_fee_payloads( + parent_fee_accounting: dict[str, Any], + message_fee_payloads: list[dict[str, Any]], + reveal_recorded: bool, +) -> dict[str, Any]: + try: + if reveal_recorded: + return record_external_message_execution_fees( + parent_fee_accounting, + message_fee_payloads, + ) + return consume_message_fees( + parent_fee_accounting, + message_fee_payloads, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + +def _sync_reveal_message_fee_accounting( + context: TransactionContext, + leader_receipt: Receipt, +) -> None: + if ( + leader_receipt.execution_result != ExecutionResultStatus.SUCCESS + or not leader_receipt.pending_transactions + ): + _unwind_discarded_reveal_message_fee_accounting(context) + return + + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if not parent_fee_accounting: + return + + message_fee_payloads = _reveal_message_fee_payloads( + parent_fee_accounting, + leader_receipt.pending_transactions, + ) + if not message_fee_payloads: + return + + try: + updated_accounting = record_reveal_message_fees( + parent_fee_accounting, + message_fee_payloads, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + context.transactions_processor.update_transaction_fee_accounting( + context.transaction.hash, + updated_accounting, + ) + + +def _reveal_message_fee_payloads( + parent_fee_accounting: dict[str, Any], + pending_transactions: Iterable[Any], +) -> list[dict[str, Any]]: + message_fee_payloads = [] + for raw_pending_transaction in pending_transactions: + pending_transaction = _coerce_pending_transaction(raw_pending_transaction) + message_payload = _pending_transaction_fee_payload( + pending_transaction, + pending_transaction.on, + ) + if not pending_transaction.is_eth_send: + message_payload = fill_message_fee_payload_from_allocation( + parent_fee_accounting, + message_payload, + ) + message_fee_payloads.append(message_payload) + return message_fee_payloads + + +def _unwind_discarded_reveal_message_fee_accounting( + context: TransactionContext, +) -> None: + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if not parent_fee_accounting: + return + + prior_receipts = _leader_receipts_from_consensus_data( + context.transaction.consensus_data + ) + if not prior_receipts: + prior_receipts = _leader_receipts_from_consensus_history( + context.transaction.consensus_history + ) + if not prior_receipts: + return + + message_fee_payloads = _reveal_message_fee_payloads( + parent_fee_accounting, + _receipt_pending_transactions(prior_receipts[0]), + ) + if not message_fee_payloads: + return + + updated_accounting = unwind_reveal_message_fees( + parent_fee_accounting, + message_fee_payloads, + acceptance_dispatched=context.transaction.status == TransactionStatus.ACCEPTED, + ) + updated_accounting["message_fees_recorded_at_reveal"] = True + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + context.transactions_processor.update_transaction_fee_accounting( + context.transaction.hash, + updated_accounting, + ) + + +def _coerce_pending_transaction(raw: Any) -> PendingTransaction: + if isinstance(raw, PendingTransaction): + return raw + if isinstance(raw, dict): + return PendingTransaction.from_dict(raw) + raise TypeError(f"Unsupported pending transaction type: {type(raw).__name__}") + + +def _leader_receipts_from_consensus_history(consensus_history: Any) -> list[Any]: + if not isinstance(consensus_history, dict): + return [] + + consensus_results = consensus_history.get("consensus_results") + if not isinstance(consensus_results, list): + return [] + + for consensus_round in reversed(consensus_results): + if not isinstance(consensus_round, dict): + continue + leader_result = consensus_round.get("leader_result") + if isinstance(leader_result, list): + return leader_result[:1] + if isinstance(leader_result, dict): + return [leader_result] + return [] + + +def _pending_transaction_fee_payload( + pending_transaction: PendingTransaction, + on: Literal["accepted", "finalized"], +) -> dict[str, Any]: + message_type = 0 if pending_transaction.is_eth_send else 1 + call_key = pending_transaction.call_key + if message_type == 0: + call_key = derive_external_message_call_key( + call_key, + pending_transaction.calldata, + ) + return { + "messageType": message_type, + "recipient": pending_transaction.address, + "value": pending_transaction.value, + "data": pending_transaction.calldata, + "onAcceptance": on == "accepted", + "saltNonce": pending_transaction.salt_nonce, + "feeParams": pending_transaction.fee_params, + "declaredBudget": pending_transaction.declared_budget, + "allocationSubtree": pending_transaction.allocation_subtree, + "callKey": call_key, + "gasUsed": pending_transaction.gas_used, + } + + def _emit_messages( context: TransactionContext, insert_transactions_data: list, diff --git a/backend/consensus/decisions.py b/backend/consensus/decisions.py index 1aef1228a..27929d1cd 100644 --- a/backend/consensus/decisions.py +++ b/backend/consensus/decisions.py @@ -321,6 +321,7 @@ def decide_accepted( code_slot_b64: str | None, to_address: str, leader_node_config: dict, + genvm_executor_selector: str | None = None, ) -> tuple[list[Effect], list[Effect], ConsensusRound, ConsensusRound | None]: """Decide effects for AcceptedState. @@ -419,6 +420,7 @@ def decide_accepted( }, }, }, + "genvm_executor_selector": genvm_executor_selector, } pre_effects.append(RegisterContractEffect(contract_data=new_contract)) pre_effects.append( diff --git a/backend/consensus/history.py b/backend/consensus/history.py new file mode 100644 index 000000000..ccee039fe --- /dev/null +++ b/backend/consensus/history.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from typing import Any + +from backend.consensus.types import ConsensusRound + +TIME_UNIT_MILLISECONDS = 1000 +# Protocol mapping for node parity: +# 1 time unit (TU) == 1 second of GenVM wall-clock runtime. Studio receipts +# measure each execution as processing_time milliseconds, so TU consumption is +# ceil(processing_time_ms / 1000) per receipt. Missing, zero, negative, or +# malformed processing_time values consume 0 TU. + +NON_ROUND_CONSENSUS_EVENTS = { + ConsensusRound.LEADER_ROTATION.value, + ConsensusRound.LEADER_ROTATION_APPEAL.value, +} + + +def is_completed_consensus_round(entry: dict[str, Any]) -> bool: + return str(entry.get("consensus_round") or "") not in NON_ROUND_CONSENSUS_EVENTS + + +def completed_consensus_rounds( + consensus_history: dict[str, Any] | None, +) -> list[dict[str, Any]]: + if not isinstance(consensus_history, dict): + return [] + results = consensus_history.get("consensus_results") + if not isinstance(results, list): + return [] + return [ + entry + for entry in results + if isinstance(entry, dict) and is_completed_consensus_round(entry) + ] + + +def completed_consensus_round_index(consensus_history: dict[str, Any] | None) -> int: + return max(0, len(completed_consensus_rounds(consensus_history)) - 1) + + +def actual_leader_rotations_by_round( + consensus_history: dict[str, Any] | None, +) -> dict[int, int]: + if not isinstance(consensus_history, dict): + return {} + results = consensus_history.get("consensus_results") + if not isinstance(results, list): + return {} + + rotations: dict[int, int] = {} + pending_rotations = 0 + round_index = 0 + for entry in results: + if not isinstance(entry, dict): + continue + event = str(entry.get("consensus_round") or "") + if event in NON_ROUND_CONSENSUS_EVENTS: + pending_rotations += 1 + continue + rotations[round_index] = pending_rotations + pending_rotations = 0 + round_index += 1 + return rotations + + +def receipt_time_units(receipt: dict | None) -> int: + if not isinstance(receipt, dict): + return 0 + try: + processing_time_ms = int(receipt.get("processing_time") or 0) + except (TypeError, ValueError): + return 0 + if processing_time_ms <= 0: + return 0 + return (processing_time_ms + TIME_UNIT_MILLISECONDS - 1) // TIME_UNIT_MILLISECONDS + + +def _receipt_iter(receipts: Any): + if isinstance(receipts, list): + yield from receipts + elif isinstance(receipts, dict): + yield receipts + + +def _entry_receipts(entry: dict[str, Any]): + yield from _receipt_iter(entry.get("leader_result")) + yield from _receipt_iter(entry.get("validator_results")) + + +def _consensus_data_receipts(consensus_data: dict[str, Any]): + yield from _receipt_iter(consensus_data.get("leader_receipt")) + validators = consensus_data.get("validators") + if isinstance(validators, list): + for validator in validators: + if isinstance(validator, dict) and "receipt" in validator: + yield from _receipt_iter(validator.get("receipt")) + else: + yield from _receipt_iter(validator) + else: + yield from _receipt_iter(validators) + + +def _bucket_time_units(receipts: Any) -> tuple[int, int, int]: + leader_timeunits = 0 + validator_timeunits = 0 + max_validator_timeunits = 0 + for receipt in receipts: + if not isinstance(receipt, dict): + continue + time_units = receipt_time_units(receipt) + mode = receipt.get("mode") + if mode == "leader": + leader_timeunits += time_units + elif mode == "validator": + validator_timeunits += time_units + max_validator_timeunits = max(max_validator_timeunits, time_units) + return leader_timeunits, validator_timeunits, max_validator_timeunits + + +def _has_receipts(receipts: list[Any]) -> bool: + # Only receipts with a recognized execution mode carry attributable + # time-unit consumption; mode-less dicts (e.g. partial or legacy + # payloads) must not produce a per-round entry. + return any( + isinstance(receipt, dict) and receipt.get("mode") in ("leader", "validator") + for receipt in receipts + ) + + +def _round_entry( + *, + round_index: int, + consensus_round: str, + leader_timeunits: int, + validator_timeunits: int, + max_validator_timeunits: int, +) -> dict[str, int | str]: + return { + "round": round_index, + "consensus_round": consensus_round, + "leader_timeunits": leader_timeunits, + "validator_timeunits": validator_timeunits, + "max_validator_timeunits": max_validator_timeunits, + } + + +def _empty_pending_time_units() -> dict[str, int | str | bool]: + return { + "leader_timeunits": 0, + "validator_timeunits": 0, + "max_validator_timeunits": 0, + "consensus_round": "", + "has_rotation": False, + } + + +def _record_round( + per_round: list[dict[str, int | str]], + *, + consensus_round: str, + leader_timeunits: int, + validator_timeunits: int, + max_validator_timeunits: int, +) -> tuple[int, int]: + per_round.append( + _round_entry( + round_index=len(per_round), + consensus_round=consensus_round, + leader_timeunits=leader_timeunits, + validator_timeunits=validator_timeunits, + max_validator_timeunits=max_validator_timeunits, + ) + ) + return leader_timeunits, validator_timeunits + + +def _history_results(consensus_history: dict | None) -> list[Any]: + results = ( + consensus_history.get("consensus_results") + if isinstance(consensus_history, dict) + else None + ) + return results if isinstance(results, list) else [] + + +def _accumulate_pending_rotation( + pending: dict[str, int | str | bool], + consensus_round: str, + leader_timeunits: int, + validator_timeunits: int, + max_validator_timeunits: int, +) -> None: + pending["leader_timeunits"] = int(pending["leader_timeunits"]) + leader_timeunits + pending["validator_timeunits"] = ( + int(pending["validator_timeunits"]) + validator_timeunits + ) + pending["max_validator_timeunits"] = max( + int(pending["max_validator_timeunits"]), max_validator_timeunits + ) + pending["consensus_round"] = consensus_round + pending["has_rotation"] = True + + +def _consume_history_time_units( + results: list[Any], +) -> tuple[list[dict[str, int | str]], int, int, dict[str, int | str | bool]]: + per_round: list[dict[str, int | str]] = [] + pending = _empty_pending_time_units() + leader_timeunits_used = 0 + validator_timeunits_used = 0 + + for entry in results: + if not isinstance(entry, dict): + continue + consensus_round = str(entry.get("consensus_round") or "") + leader_timeunits, validator_timeunits, max_validator_timeunits = ( + _bucket_time_units(_entry_receipts(entry)) + ) + if consensus_round in NON_ROUND_CONSENSUS_EVENTS: + _accumulate_pending_rotation( + pending, + consensus_round, + leader_timeunits, + validator_timeunits, + max_validator_timeunits, + ) + continue + + leader_timeunits += int(pending["leader_timeunits"]) + validator_timeunits += int(pending["validator_timeunits"]) + max_validator_timeunits = max( + max_validator_timeunits, int(pending["max_validator_timeunits"]) + ) + pending = _empty_pending_time_units() + leader_used, validator_used = _record_round( + per_round, + consensus_round=consensus_round, + leader_timeunits=leader_timeunits, + validator_timeunits=validator_timeunits, + max_validator_timeunits=max_validator_timeunits, + ) + leader_timeunits_used += leader_used + validator_timeunits_used += validator_used + + return per_round, leader_timeunits_used, validator_timeunits_used, pending + + +def _fallback_consensus_data_round( + per_round: list[dict[str, int | str]], + consensus_data: dict | None, +) -> tuple[int, int]: + if not isinstance(consensus_data, dict): + return 0, 0 + receipts = list(_consensus_data_receipts(consensus_data)) + if not _has_receipts(receipts): + return 0, 0 + leader_timeunits, validator_timeunits, max_validator_timeunits = _bucket_time_units( + receipts + ) + return _record_round( + per_round, + consensus_round="", + leader_timeunits=leader_timeunits, + validator_timeunits=validator_timeunits, + max_validator_timeunits=max_validator_timeunits, + ) + + +def time_unit_consumption( + consensus_history: dict | None, + consensus_data: dict | None, +) -> dict: + per_round, leader_timeunits_used, validator_timeunits_used, pending = ( + _consume_history_time_units(_history_results(consensus_history)) + ) + + if ( + not per_round + and pending["leader_timeunits"] == 0 + and pending["validator_timeunits"] == 0 + and not pending["has_rotation"] + ): + leader_used, validator_used = _fallback_consensus_data_round( + per_round, consensus_data + ) + leader_timeunits_used += leader_used + validator_timeunits_used += validator_used + + if pending["has_rotation"]: + leader_used, validator_used = _record_round( + per_round, + consensus_round=str(pending["consensus_round"]), + leader_timeunits=int(pending["leader_timeunits"]), + validator_timeunits=int(pending["validator_timeunits"]), + max_validator_timeunits=int(pending["max_validator_timeunits"]), + ) + leader_timeunits_used += leader_used + validator_timeunits_used += validator_used + + return { + "leader_timeunits_used": leader_timeunits_used, + "validator_timeunits_used": validator_timeunits_used, + "per_round": per_round, + } diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 1e6808508..bcd29c2e2 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -11,6 +11,7 @@ from backend.database_handler.models import Transactions, TransactionStatus from backend.database_handler.transactions_processor import TransactionsProcessor +from backend.database_handler.accounts_manager import AccountsManager from backend.database_handler.errors import ContractNotFoundError from backend.domain.types import Transaction from backend.node.genvm.error_codes import GenVMInternalError @@ -25,6 +26,89 @@ from backend.node.base import Manager as GenVMManager from backend.services.usage_metrics_service import UsageMetricsService +# GenVM-module error causes that are reported as fatal but are transient in +# Studio: validator changes stop+restart the llm module, so a leader run +# claimed inside that window can race the provider-table rebuild and get +# NO_PROVIDER_FOR_PROMPT for its own node address. These must not trip the +# stop-the-worker circuit breaker (see _transaction_context). +_TRANSIENT_LEADER_FATAL_CAUSES = frozenset({"NO_PROVIDER_FOR_PROMPT"}) + +# --------------------------------------------------------------------------- +# Claim column manifest +# +# Single source of truth for what a claim query RETURNs and what the rebuilt +# transaction dict contains. Both the RETURNING clause and the row->dict +# conversion are generated from these tuples, so the two can never drift +# apart. Drift is exactly what caused the contract-state wipe fixed in +# PR #1724: claim_next_appeal's hand-written RETURNING list omitted +# contract_snapshot/consensus_history and Transaction.from_dict silently +# defaulted them, so a successful validator appeal "restored" the contract +# state to {}. +# +# Entries are (sql_column, dict_key). dict_key differs from the column name +# only for triggered_by_hash, which Transaction.from_dict consumes as +# "triggered_by". +# --------------------------------------------------------------------------- +_TX_CLAIM_BASE_COLUMNS: tuple[tuple[str, str], ...] = ( + ("hash", "hash"), + ("from_address", "from_address"), + ("to_address", "to_address"), + ("data", "data"), + ("value", "value"), + ("type", "type"), + ("nonce", "nonce"), + ("gaslimit", "gaslimit"), + ("r", "r"), + ("s", "s"), + ("v", "v"), + ("leader_only", "leader_only"), + ("execution_mode", "execution_mode"), + ("sim_config", "sim_config"), + ("status", "status"), + ("consensus_data", "consensus_data"), + ("input_data", "input_data"), + ("created_at", "created_at"), + ("blocked_at", "blocked_at"), + ("triggered_by_hash", "triggered_by"), +) + +# Stored per-transaction state. Heavy: contract_snapshot has been observed at +# tens of MB, so only claims whose downstream consumes it include this group. +# Appeals restore the contract's accepted state from it; the pending and +# finalization paths rebuild snapshots via contract_snapshot_factory and +# persist consensus_history through DB-side jsonb merges, so they omit it +# deliberately (see _TX_CLAIM_OMISSIONS in the manifest tests). +_TX_STATE_COLUMNS: tuple[tuple[str, str], ...] = ( + ("contract_snapshot", "contract_snapshot"), + ("consensus_history", "consensus_history"), +) + +_TX_APPEAL_COLUMNS: tuple[tuple[str, str], ...] = ( + ("appealed", "appealed"), + ("appeal_failed", "appeal_failed"), + ("timestamp_appeal", "timestamp_appeal"), + ("appeal_undetermined", "appeal_undetermined"), + ("appeal_leader_timeout", "appeal_leader_timeout"), + ("appeal_validators_timeout", "appeal_validators_timeout"), +) + +_TX_FINALIZATION_COLUMNS: tuple[tuple[str, str], ...] = ( + ("timestamp_awaiting_finalization", "timestamp_awaiting_finalization"), + ("appeal_failed", "appeal_failed"), +) + + +def _tx_returning_clause(*column_groups: tuple[tuple[str, str], ...]) -> str: + """Render a RETURNING column list from manifest groups.""" + columns = [col for group in column_groups for col, _ in group] + return ", ".join(f"transactions.{col}" for col in columns) + + +def _tx_row_to_dict(row: Any, *column_groups: tuple[tuple[str, str], ...]) -> dict: + """Build the claimed-transaction dict from the same manifest groups that + generated the query's RETURNING clause.""" + return {key: getattr(row, col) for group in column_groups for col, key in group} + class ConsensusWorker: """ @@ -215,7 +299,7 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: # They must be in ACCEPTED/UNDETERMINED/TIMEOUT states and appeal window must have passed start_time = time.perf_counter() query = text( - """ + f""" WITH locked_finalizations AS ( SELECT t.* FROM transactions t @@ -277,13 +361,7 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: worker_id = :worker_id FROM single_finalization WHERE transactions.hash = single_finalization.hash - RETURNING transactions.hash, transactions.from_address, transactions.to_address, - transactions.data, transactions.value, transactions.type, transactions.nonce, - transactions.gaslimit, transactions.r, transactions.s, transactions.v, - transactions.leader_only, transactions.execution_mode, transactions.sim_config, - transactions.status, transactions.consensus_data, - transactions.input_data, transactions.created_at, transactions.timestamp_awaiting_finalization, - transactions.appeal_failed, transactions.blocked_at, transactions.triggered_by_hash; + RETURNING {_tx_returning_clause(_TX_CLAIM_BASE_COLUMNS, _TX_FINALIZATION_COLUMNS)}; """ ) @@ -305,31 +383,9 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: f"[Worker {self.worker_id}] Claimed next finalization result {result.hash}" ) session.commit() - # Convert result to dict - return { - "hash": result.hash, - "from_address": result.from_address, - "to_address": result.to_address, - "data": result.data, - "value": result.value, - "type": result.type, - "nonce": result.nonce, - "gaslimit": result.gaslimit, - "r": result.r, - "s": result.s, - "v": result.v, - "leader_only": result.leader_only, - "execution_mode": result.execution_mode, - "sim_config": result.sim_config, - "status": result.status, - "consensus_data": result.consensus_data, - "input_data": result.input_data, - "created_at": result.created_at, - "timestamp_awaiting_finalization": result.timestamp_awaiting_finalization, - "appeal_failed": result.appeal_failed, - "blocked_at": result.blocked_at, - "triggered_by": result.triggered_by_hash, - } + return _tx_row_to_dict( + result, _TX_CLAIM_BASE_COLUMNS, _TX_FINALIZATION_COLUMNS + ) return None @@ -344,7 +400,7 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: # Query to atomically claim an appealed transaction start_time = time.perf_counter() query = text( - """ + f""" WITH locked_appeals AS ( SELECT t.hash, t.to_address, t.created_at FROM transactions t @@ -385,16 +441,7 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: worker_id = :worker_id FROM single_appeal WHERE transactions.hash = single_appeal.hash - RETURNING transactions.hash, transactions.from_address, transactions.to_address, - transactions.data, transactions.value, transactions.type, transactions.nonce, - transactions.gaslimit, transactions.r, transactions.s, transactions.v, - transactions.leader_only, transactions.execution_mode, transactions.sim_config, - transactions.status, transactions.consensus_data, - transactions.input_data, transactions.created_at, transactions.appealed, - transactions.appeal_failed, transactions.timestamp_appeal, - transactions.appeal_undetermined, transactions.appeal_leader_timeout, - transactions.appeal_validators_timeout, transactions.blocked_at, - transactions.triggered_by_hash; + RETURNING {_tx_returning_clause(_TX_CLAIM_BASE_COLUMNS, _TX_STATE_COLUMNS, _TX_APPEAL_COLUMNS)}; """ ) @@ -410,35 +457,9 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: if result: session.commit() - # Convert result to dict - return { - "hash": result.hash, - "from_address": result.from_address, - "to_address": result.to_address, - "data": result.data, - "value": result.value, - "type": result.type, - "nonce": result.nonce, - "gaslimit": result.gaslimit, - "r": result.r, - "s": result.s, - "v": result.v, - "leader_only": result.leader_only, - "execution_mode": result.execution_mode, - "sim_config": result.sim_config, - "status": result.status, - "consensus_data": result.consensus_data, - "input_data": result.input_data, - "created_at": result.created_at, - "appealed": result.appealed, - "appeal_failed": result.appeal_failed, - "timestamp_appeal": result.timestamp_appeal, - "appeal_undetermined": result.appeal_undetermined, - "appeal_leader_timeout": result.appeal_leader_timeout, - "appeal_validators_timeout": result.appeal_validators_timeout, - "blocked_at": result.blocked_at, - "triggered_by": result.triggered_by_hash, - } + return _tx_row_to_dict( + result, _TX_CLAIM_BASE_COLUMNS, _TX_STATE_COLUMNS, _TX_APPEAL_COLUMNS + ) return None @@ -454,7 +475,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: # Ensures only one transaction per contract is processed at a time start_time = time.perf_counter() query = text( - """ + f""" WITH candidate_transactions AS ( SELECT t.hash, t.to_address, t.type, t.created_at, t.recovery_count FROM transactions t @@ -477,9 +498,14 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: -- This clause makes PENDING/ACTIVATED claims defer when an -- ACCEPTED-class tx for the same contract is past its -- finality window — letting finalization drain the queue. + -- Only older finalizations can preempt this candidate: + -- claim_next_finalization will not claim a younger tx while + -- this candidate is still non-terminal, so deferring here + -- would deadlock the contract head. AND NOT EXISTS ( SELECT 1 FROM transactions t3 WHERE t3.to_address IS NOT DISTINCT FROM t.to_address + AND t3.created_at < t.created_at AND t3.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') AND t3.appealed = false AND t3.timestamp_awaiting_finalization IS NOT NULL @@ -529,13 +555,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: worker_id = :worker_id FROM single_transaction WHERE transactions.hash = single_transaction.hash - RETURNING transactions.hash, transactions.from_address, transactions.to_address, - transactions.data, transactions.value, transactions.type, transactions.nonce, - transactions.gaslimit, transactions.r, transactions.s, transactions.v, - transactions.leader_only, transactions.execution_mode, transactions.sim_config, - transactions.status, transactions.consensus_data, - transactions.input_data, transactions.created_at, transactions.blocked_at, - transactions.triggered_by_hash; + RETURNING {_tx_returning_clause(_TX_CLAIM_BASE_COLUMNS)}; """ ) @@ -554,29 +574,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: if result: logger.debug(f"[Worker {self.worker_id}] Claimed transaction {result.hash}") session.commit() - # Convert result to dict - return { - "hash": result.hash, - "from_address": result.from_address, - "to_address": result.to_address, - "data": result.data, - "value": result.value, - "type": result.type, - "nonce": result.nonce, - "gaslimit": result.gaslimit, - "r": result.r, - "s": result.s, - "v": result.v, - "leader_only": result.leader_only, - "execution_mode": result.execution_mode, - "sim_config": result.sim_config, - "status": result.status, - "consensus_data": result.consensus_data, - "input_data": result.input_data, - "created_at": result.created_at, - "blocked_at": result.blocked_at, - "triggered_by": result.triggered_by_hash, - } + return _tx_row_to_dict(result, _TX_CLAIM_BASE_COLUMNS) return None @@ -789,6 +787,19 @@ async def _transaction_context( # helper above. Don't release/reset — it's in ACCEPTED now. transaction_reset = True else: + if e.is_fatal and _TRANSIENT_LEADER_FATAL_CAUSES.intersection( + e.causes or [] + ): + # Hold the claim briefly before resetting so the retry + # doesn't burn through its recovery cycles inside the + # same llm-module restart window that caused the error + # (each validator change stops+starts the module; a + # burst of 5 creations churns for several seconds). + await asyncio.sleep( + float( + os.environ.get("GENVM_TRANSIENT_FATAL_BACKOFF_S", "3") + ) + ) # Retryable leader error — reset for another worker to pick up. try: with self.get_session() as reset_session: @@ -802,11 +813,32 @@ async def _transaction_context( # For fatal leader errors, stop the worker to trigger K8s restart via health check if e.is_fatal: - logger.warning( - f"[Worker {self.worker_id}] Fatal GenVM error in leader - stopping worker. " - f"{tx_type.capitalize()} {tx_hash} will be reset for another worker." + transient_causes = _TRANSIENT_LEADER_FATAL_CAUSES.intersection( + e.causes or [] ) - self.running = False + if transient_causes: + # The module reports these as fatal, but in Studio + # they are transient config races: every validator + # change stops+restarts the llm module, and a run + # claimed inside that window can see a provider + # table that lacks the leader's address + # (NO_PROVIDER_FOR_PROMPT). The tx was already + # reset above with a bounded retry budget + # (recovery cycles escalate to CANCELED), so keep + # the worker alive instead of draining claim + # capacity — with all workers stopped the queue + # stalls silently. + logger.warning( + f"[Worker {self.worker_id}] Transient fatal GenVM error in leader " + f"({', '.join(sorted(transient_causes))}) - keeping worker alive. " + f"{tx_type.capitalize()} {tx_hash} was reset for retry." + ) + else: + logger.warning( + f"[Worker {self.worker_id}] Fatal GenVM error in leader - stopping worker. " + f"{tx_type.capitalize()} {tx_hash} will be reset for another worker." + ) + self.running = False except (ContractNotFoundError, _NoValidatorsError): # Re-raise for specific handling by caller raise @@ -1297,6 +1329,12 @@ async def _handle_no_validators_retry( from backend.database_handler.accounts_manager import AccountsManager AccountsManager(session).refund_tx_value(tx_hash, tx.from_address) + if tx.from_address: + from backend.database_handler.accounts_manager import AccountsManager + + AccountsManager(session).cancel_tx_fee_accounting_once( + tx_hash, tx.from_address, "no_validators_available" + ) session.commit() # Clean up retry tracking @@ -1362,6 +1400,14 @@ async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): AccountsManager(cancel_session).refund_tx_value( tx_hash, tx.from_address ) + if tx.from_address: + from backend.database_handler.accounts_manager import ( + AccountsManager, + ) + + AccountsManager(cancel_session).cancel_tx_fee_accounting_once( + tx_hash, tx.from_address, "max_generic_retries_exceeded" + ) cancel_session.commit() # Send WebSocket notification @@ -1698,6 +1744,14 @@ async def process_finalization(self, finalization_data: dict, session: Session): TransactionStatus.FINALIZED, self.msg_handler, ) + tx = error_session.query(Transactions).filter_by(hash=tx_hash).one() + refund_recipient = tx.origin_address or tx.from_address + if refund_recipient: + AccountsManager(error_session).settle_tx_fee_accounting_once( + tx_hash, + refund_recipient, + reason="finalized_contract_not_found", + ) error_session.commit() logger.info( @@ -1802,6 +1856,14 @@ async def process_appeal(self, appeal_data: dict, session: Session): TransactionStatus.FINALIZED, self.msg_handler, ) + tx = error_session.query(Transactions).filter_by(hash=tx_hash).one() + refund_recipient = tx.origin_address or tx.from_address + if refund_recipient: + AccountsManager(error_session).settle_tx_fee_accounting_once( + tx_hash, + refund_recipient, + reason="finalized_contract_not_found_during_appeal", + ) error_session.commit() logger.info( diff --git a/backend/consensus/worker_service.py b/backend/consensus/worker_service.py index a27487eb6..6b41ea5e9 100644 --- a/backend/consensus/worker_service.py +++ b/backend/consensus/worker_service.py @@ -102,6 +102,97 @@ def get_genvm_failure_count() -> int: return _genvm_consecutive_failures +def _get_blocked_tx_unhealthy_after_minutes(transaction_timeout_minutes=None) -> int: + """Return the liveness threshold for a claimed transaction. + + The worker recovery timeout owns deciding when a transaction is stuck. + Liveness must stay above that timeout so Kubernetes does not kill a + legitimately long consensus attempt and force a recovery cycle. + """ + if isinstance(transaction_timeout_minutes, (int, str)) and not isinstance( + transaction_timeout_minutes, bool + ): + raw_transaction_timeout = transaction_timeout_minutes + else: + raw_transaction_timeout = os.getenv("TRANSACTION_TIMEOUT_MINUTES", "30") + + try: + transaction_timeout = int(raw_transaction_timeout) + except (TypeError, ValueError): + transaction_timeout = 30 + if transaction_timeout <= 0: + transaction_timeout = 30 + + try: + buffer_minutes = int( + os.getenv("WORKER_BLOCKED_TX_UNHEALTHY_BUFFER_MINUTES", "5") + ) + except ValueError: + buffer_minutes = 5 + if buffer_minutes <= 0: + buffer_minutes = 5 + + minimum_threshold = max(14, transaction_timeout + buffer_minutes) + + configured_threshold = os.getenv("WORKER_BLOCKED_TX_UNHEALTHY_AFTER_MINUTES") + if configured_threshold is None: + return minimum_threshold + + try: + configured_threshold_minutes = int(configured_threshold) + except ValueError: + return minimum_threshold + if configured_threshold_minutes <= 0: + return minimum_threshold + + return max(configured_threshold_minutes, minimum_threshold) + + +def _get_worker_graceful_shutdown_timeout_seconds() -> int: + """Return how long shutdown should wait before releasing claimed txs. + + Kubernetes sends SIGKILL when terminationGracePeriodSeconds expires. The + worker must release claims before that hard deadline, otherwise scale-down + strands in-flight transactions until the 30m stale-claim recovery path bumps + recovery_count. + """ + default_termination_grace = 180 + try: + termination_grace = int( + os.getenv( + "WORKER_TERMINATION_GRACE_SECONDS", str(default_termination_grace) + ) + ) + except ValueError: + termination_grace = default_termination_grace + if termination_grace <= 0: + termination_grace = default_termination_grace + + try: + release_buffer = int(os.getenv("WORKER_SHUTDOWN_RELEASE_BUFFER_SECONDS", "30")) + except ValueError: + release_buffer = 30 + if release_buffer <= 0: + release_buffer = 30 + + default_timeout = max(1, termination_grace - release_buffer) + + configured_timeout = os.getenv("WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS") + if configured_timeout is None: + return default_timeout + + try: + timeout = int(configured_timeout) + except ValueError: + return default_timeout + if timeout <= 0: + return default_timeout + + # Preserve a release buffer even when the configured value is too close to + # Kubernetes' hard kill deadline. + return min(timeout, default_timeout) + + @asynccontextmanager async def lifespan(app: FastAPI): """Manage the worker lifecycle.""" @@ -123,7 +214,9 @@ def handle_signal(sig, frame): # CRITICAL: Kill any orphaned GenVM processes from previous crashes # These zombie processes can consume gigabytes of memory outside Docker limits logger.info("Cleaning up orphaned GenVM processes from previous crashes...") - _pkill_rc = os.system("pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true") + _pkill_rc = os.system( # noqa: ASYNC221 - one-shot startup orphan cleanup + "pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true" + ) logger.info("GenVM cleanup complete") # Database setup @@ -323,11 +416,10 @@ async def run_worker_with_auto_restart(): f"Waiting for {tx_count} transactions / {task_count} tasks to complete before shutdown..." ) - # Get graceful shutdown timeout from env (default: 180 seconds) - # This gives the transactions time to finish before we force-stop - graceful_timeout = int( - os.environ.get("WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS", "180") - ) + # Release claims before Kubernetes' SIGKILL deadline. This gives + # in-flight work time to complete, but still leaves a buffer for + # cleanup/requeue if the pod is being scaled down. + graceful_timeout = _get_worker_graceful_shutdown_timeout_seconds() start_time = time.time() while (worker.current_transactions or worker._active_tasks) and ( @@ -389,7 +481,9 @@ async def run_worker_with_auto_restart(): # Final safety check: Kill any remaining genvm processes logger.info("Final cleanup: killing any remaining GenVM processes...") - os.system("pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true") + os.system( # noqa: ASYNC221 - one-shot shutdown orphan cleanup + "pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true" + ) logger.info("GenVM cleanup complete") print("Consensus Worker Service stopped") @@ -432,7 +526,7 @@ def health_check(): long-running synchronous DB operations in the consensus worker. """ import psutil - from datetime import datetime + from datetime import datetime, timezone from fastapi.responses import JSONResponse from urllib.request import urlopen, Request from urllib.error import URLError @@ -556,16 +650,9 @@ def health_check(): # Check if ANY transaction is blocked for too long current_txs = [] if worker.current_transactions: - # Get unhealthy threshold from env - try: - blocked_tx_unhealthy_after_minutes = int( - os.getenv("WORKER_BLOCKED_TX_UNHEALTHY_AFTER_MINUTES", "14") - ) - except ValueError: - blocked_tx_unhealthy_after_minutes = 14 - if blocked_tx_unhealthy_after_minutes <= 0: - blocked_tx_unhealthy_after_minutes = 14 - + blocked_tx_unhealthy_after_minutes = _get_blocked_tx_unhealthy_after_minutes( + getattr(worker, "transaction_timeout_minutes", None) + ) blocked_tx_unhealthy_after_seconds = blocked_tx_unhealthy_after_minutes * 60 for tx_hash, tx_info in worker.current_transactions.items(): @@ -584,7 +671,8 @@ def health_check(): if blocked_at.tzinfo is not None: blocked_at = blocked_at.replace(tzinfo=None) - elapsed = datetime.utcnow() - blocked_at + now_utc = datetime.now(timezone.utc).replace(tzinfo=None) + elapsed = now_utc - blocked_at # Check if blocked for too long - pod is unhealthy if elapsed.total_seconds() > blocked_tx_unhealthy_after_seconds: diff --git a/backend/database_handler/accounts_manager.py b/backend/database_handler/accounts_manager.py index 32149d893..6169b5171 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -3,8 +3,14 @@ from eth_account import Account from eth_utils import is_address, to_checksum_address -from .models import CurrentState +from .models import CurrentState, Transactions from backend.database_handler.errors import AccountNotFoundError +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + cancel_fee_accounting, + settle_fee_accounting, +) +from backend.consensus.history import completed_consensus_round_index from sqlalchemy.orm import Session from sqlalchemy import text @@ -177,3 +183,75 @@ def refund_tx_value(self, tx_hash: str, sender_address: str) -> bool: return False # target already received funds, can't refund self.credit_account_balance(sender_address, row.value) return True + + def cancel_tx_fee_accounting_once( + self, tx_hash: str, sender_address: str, reason: str = "canceled" + ) -> int: + transaction = ( + self.session.query(Transactions).filter_by(hash=tx_hash).one_or_none() + ) + if transaction is None: + return 0 + if not isinstance(transaction.data, dict): + return 0 + data = dict(transaction.data) + accounting = data.get(FEE_ACCOUNTING_KEY) + if not accounting: + return 0 + was_terminal = accounting.get("status") in {"settled", "canceled"} + updated, refund = cancel_fee_accounting(accounting, reason=reason) + data[FEE_ACCOUNTING_KEY] = updated + transaction.data = data + if refund > 0: + self.credit_account_balance(sender_address, refund) + if not was_terminal: + self._credit_appeal_bond_payouts(updated) + return refund + + def settle_tx_fee_accounting_once( + self, + tx_hash: str, + sender_address: str, + receipt=None, + reason: str = "finalized", + ) -> int: + transaction = ( + self.session.query(Transactions).filter_by(hash=tx_hash).one_or_none() + ) + if transaction is None: + return 0 + if not isinstance(transaction.data, dict): + return 0 + data = dict(transaction.data) + accounting = data.get(FEE_ACCOUNTING_KEY) + if not accounting: + return 0 + was_terminal = accounting.get("status") in {"settled", "canceled"} + updated, refund = settle_fee_accounting( + accounting, + receipt=receipt, + reason=reason, + actual_final_round=_infer_final_round(transaction.consensus_history), + num_of_validators=transaction.num_of_initial_validators, + consensus_history=transaction.consensus_history, + ) + data[FEE_ACCOUNTING_KEY] = updated + transaction.data = data + if refund > 0: + self.credit_account_balance(sender_address, refund) + if not was_terminal: + self._credit_appeal_bond_payouts(updated) + return refund + + def _credit_appeal_bond_payouts(self, accounting: dict) -> None: + for payout in accounting.get("appeal_bond_settlements") or []: + if not isinstance(payout, dict): + continue + amount = int(payout.get("payout", 0) or 0) + appealer = payout.get("appealer") + if amount > 0 and appealer: + self.credit_account_balance(appealer, amount) + + +def _infer_final_round(consensus_history: dict | None) -> int: + return completed_consensus_round_index(consensus_history) diff --git a/backend/database_handler/contract_processor.py b/backend/database_handler/contract_processor.py index 32d1886c7..69de5f2e0 100644 --- a/backend/database_handler/contract_processor.py +++ b/backend/database_handler/contract_processor.py @@ -19,6 +19,10 @@ def register_contract(self, contract: dict): self.session.query(CurrentState).filter_by(id=contract["id"]).one() ) current_contract.data = contract["data"] + if "genvm_executor_selector" in contract: + current_contract.genvm_executor_selector = contract[ + "genvm_executor_selector" + ] self.session.commit() def update_contract_state( diff --git a/backend/database_handler/contract_snapshot.py b/backend/database_handler/contract_snapshot.py index f3e6f3e80..f113074e0 100644 --- a/backend/database_handler/contract_snapshot.py +++ b/backend/database_handler/contract_snapshot.py @@ -1,6 +1,7 @@ # database_handler/contract_snapshot.py from .models import CurrentState from .errors import ContractNotFoundError +from sqlalchemy import func, select from sqlalchemy.orm import Session from typing import Optional, Dict import base64 @@ -17,6 +18,9 @@ class ContractSnapshot: contract_address: str balance: int states: Dict[str, Dict[str, str]] + # Executor version or `re:` selector this contract is pinned to, forwarded + # to the GenVM manager as `reroute_to`. + genvm_executor_selector: Optional[str] = None def __init__(self, contract_address: str | None, session: Session): if contract_address is not None: @@ -25,6 +29,7 @@ def __init__(self, contract_address: str | None, session: Session): contract_account = self._load_contract_account(session) self.contract_data = contract_account.data self.balance = contract_account.balance + self.genvm_executor_selector = contract_account.genvm_executor_selector if ("accepted" in self.contract_data["state"]) and ( isinstance(self.contract_data["state"]["accepted"], dict) @@ -43,6 +48,7 @@ def to_dict(self): "balance": ( int(b) if (b := getattr(self, "balance", None)) is not None else None ), + "genvm_executor_selector": self.genvm_executor_selector, } @classmethod @@ -53,6 +59,9 @@ def from_dict(cls, input: dict | None) -> Optional["ContractSnapshot"]: instance.states = input.get("states", {"accepted": {}, "finalized": {}}) raw_balance = input.get("balance") instance.balance = int(raw_balance) if raw_balance is not None else None + instance.genvm_executor_selector = input.get( + "genvm_executor_selector", None + ) return instance else: return None @@ -87,20 +96,80 @@ def extract_deployed_code_b64(self) -> Optional[str]: slices out the code payload, and returns it base64-encoded. Returns None if missing/invalid. """ - # Import here to avoid circular dependencies at module import time - from backend.node.genvm import get_code_slot - accepted = self.states.get("accepted") or {} try: - code_slot_b64 = base64.b64encode(get_code_slot()).decode("ascii") - stored = accepted.get(code_slot_b64) + stored = accepted.get(_code_slot_b64()) if not stored: return None - - raw = base64.b64decode(stored, validate=True) - code_len = int.from_bytes(raw[0:4], byteorder="little", signed=False) - code_bytes = raw[4 : 4 + code_len] - return base64.b64encode(code_bytes).decode("ascii") + return _decode_code_payload(stored) except Exception: return None + + +def _code_slot_b64() -> str: + """Base64 of the deterministic storage slot the deployed code lives in.""" + # Import here to avoid circular dependencies at module import time + from backend.node.genvm import get_code_slot + + return base64.b64encode(get_code_slot()).decode("ascii") + + +def _decode_code_payload(stored: str) -> Optional[str]: + """Slice the code out of a stored slot blob and re-encode it as base64. + + The blob is a 4-byte little-endian length prefix followed by the code. + """ + raw = base64.b64decode(stored, validate=True) + code_len = int.from_bytes(raw[0:4], byteorder="little", signed=False) + code_bytes = raw[4 : 4 + code_len] + return base64.b64encode(code_bytes).decode("ascii") + + +def fetch_deployed_code_b64(session: Session, contract_address: str) -> Optional[str]: + """Read just the deployed code, without loading the contract's whole state. + + ``ContractSnapshot`` pulls the entire ``data`` JSONB — every storage slot + the contract owns — in order to read one deterministic slot out of it. For a + contract holding a large vector store that is a big fetch and deserialize + per call, which matters because ``gen_getContractCode`` is polled heavily by + batch tooling. Extracting the slot in SQL keeps the state off the wire and + out of Python. + + Postgres still has to detoast the JSONB server-side, so this narrows the + transfer and parse cost rather than eliminating the read entirely. + + Raises ContractNotFoundError when the contract is absent or undeployed, and + returns None when the contract exists but holds no code. + """ + slot = _code_slot_b64() + + row = session.execute( + select( + func.jsonb_typeof(CurrentState.data).label("data_kind"), + func.jsonb_typeof(CurrentState.data["state"]).label("state_kind"), + CurrentState.data["state"]["accepted"][slot].astext.label("nested"), + CurrentState.data["state"][slot].astext.label("flat"), + ).where(CurrentState.id == contract_address) + ).one_or_none() + + if row is None: + raise ContractNotFoundError(contract_address) + + if row.data_kind != "object" or row.state_kind is None: + # Legacy rows store `data` as a JSON string scalar, and undeployed ones + # store an empty object with no `state` key. Both are rare and fiddly, + # so hand them to the original path rather than reimplementing its error + # handling in SQL. + return ContractSnapshot(contract_address, session).extract_deployed_code_b64() + + # Current rows nest slots under `state.accepted`; the pre-migration format + # put them directly under `state`. + stored = row.nested if row.nested is not None else row.flat + if not stored: + return None + + try: + return _decode_code_payload(stored) + except Exception: + return None diff --git a/backend/database_handler/migration/versions/b8c9d0e1f2a3_add_terminal_snapshot_archive_candidate_index.py b/backend/database_handler/migration/versions/b8c9d0e1f2a3_add_terminal_snapshot_archive_candidate_index.py new file mode 100644 index 000000000..b8e3ce972 --- /dev/null +++ b/backend/database_handler/migration/versions/b8c9d0e1f2a3_add_terminal_snapshot_archive_candidate_index.py @@ -0,0 +1,43 @@ +"""add terminal snapshot archive candidate index + +Revision ID: b8c9d0e1f2a3 +Revises: a7b8c9d0e1f2 +Create Date: 2026-06-17 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "b8c9d0e1f2a3" +down_revision: Union[str, None] = "a7b8c9d0e1f2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: # pragma: no cover + # The transactions table can be multi-TB in prod. Build the candidate + # index concurrently so enabling the worker does not block writes. + op.execute("COMMIT") + op.execute( + """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS + idx_transactions_terminal_snapshot_archive_candidates + ON transactions (created_at, hash) + WHERE contract_snapshot IS NOT NULL + AND status IN ('FINALIZED', 'CANCELED') + """ + ) + + +def downgrade() -> None: # pragma: no cover + op.execute("COMMIT") + op.execute( + """ + DROP INDEX CONCURRENTLY IF EXISTS + idx_transactions_terminal_snapshot_archive_candidates + """ + ) diff --git a/backend/database_handler/migration/versions/c1d2e3f4a5b6_add_reroute_to_to_current_state.py b/backend/database_handler/migration/versions/c1d2e3f4a5b6_add_reroute_to_to_current_state.py new file mode 100644 index 000000000..dc459f2bc --- /dev/null +++ b/backend/database_handler/migration/versions/c1d2e3f4a5b6_add_reroute_to_to_current_state.py @@ -0,0 +1,62 @@ +"""add reroute_to to current_state + +Revision ID: c1d2e3f4a5b6 +Revises: d0e1f2a3b4c5 +Create Date: 2026-07-24 12:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "c1d2e3f4a5b6" +down_revision: Union[str, None] = "d0e1f2a3b4c5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +LEGACY_EXECUTOR_SELECTOR = r"re:^v0\.2\." +""" +Backfilled onto every pre-existing contract row on upgrade. + +Studios being upgraded from before multi-version support only ever had a +single GenVM line (v0.2.x) to deploy against, so every genuine contract row +that isn't already pinned needs a legacy selector: without it, an unpinned +row means "resolve from the manifest" (the current/latest line), which would +silently move already-deployed v0.2 contracts onto an executor they were +never deployed or tested against. +""" + + +def upgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.execute("SET LOCAL statement_timeout = '30s'") + + op.add_column( + "current_state", + sa.Column("reroute_to", sa.String(255), nullable=True), + ) + + # Only genuine contract rows: EOAs (and reset contracts) carry + # `data = '{}'`, deployed contracts carry `data->'state'`. Excluding rows + # that already have a `reroute_to` preserves anything set some other way + # (e.g. by a data migration or manual fixup run ahead of this one). + op.execute( + sa.text( + """ + UPDATE current_state + SET reroute_to = :selector + WHERE reroute_to IS NULL + AND data ? 'state' + """ + ).bindparams(selector=LEGACY_EXECUTOR_SELECTOR) + ) + + +def downgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.drop_column("current_state", "reroute_to") diff --git a/backend/database_handler/migration/versions/c9d0e1f2a3b4_add_transaction_snapshot_archives.py b/backend/database_handler/migration/versions/c9d0e1f2a3b4_add_transaction_snapshot_archives.py new file mode 100644 index 000000000..c25b9b488 --- /dev/null +++ b/backend/database_handler/migration/versions/c9d0e1f2a3b4_add_transaction_snapshot_archives.py @@ -0,0 +1,93 @@ +"""add transaction snapshot archive index + +Revision ID: c9d0e1f2a3b4 +Revises: b8c9d0e1f2a3 +Create Date: 2026-06-17 12:05:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "c9d0e1f2a3b4" +down_revision: Union[str, None] = "b8c9d0e1f2a3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: # pragma: no cover + op.create_table( + "transaction_snapshot_archives", + sa.Column( + "tx_hash", + sa.String(length=66), + sa.ForeignKey("transactions.hash", ondelete="CASCADE"), + primary_key=True, + nullable=False, + ), + sa.Column("backend", sa.String(length=20), nullable=False), + sa.Column("bucket", sa.String(length=255), nullable=True), + sa.Column("object_key", sa.String(length=1024), nullable=False), + sa.Column("uri", sa.String(length=2048), nullable=False), + sa.Column( + "format", + sa.String(length=64), + nullable=False, + server_default="full-json-gzip-v1", + ), + sa.Column("snapshot_sha256", sa.String(length=64), nullable=False), + sa.Column("compressed_sha256", sa.String(length=64), nullable=False), + sa.Column("snapshot_bytes", sa.BigInteger(), nullable=False), + sa.Column("compressed_bytes", sa.BigInteger(), nullable=False), + sa.Column( + "archive_status", + sa.String(length=20), + nullable=False, + server_default="archived", + ), + sa.Column( + "archived_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.current_timestamp(), + ), + sa.Column("pruned_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "object_metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + sa.CheckConstraint( + "backend IN ('file', 'gcs', 's3')", + name="transaction_snapshot_archives_backend_check", + ), + sa.CheckConstraint( + "archive_status IN ('archived', 'pruned')", + name="transaction_snapshot_archives_status_check", + ), + ) + op.create_index( + "idx_transaction_snapshot_archives_status_archived_at", + "transaction_snapshot_archives", + ["archive_status", "archived_at"], + ) + op.create_index( + "idx_transaction_snapshot_archives_backend", + "transaction_snapshot_archives", + ["backend"], + ) + + +def downgrade() -> None: # pragma: no cover + op.drop_index( + "idx_transaction_snapshot_archives_backend", + table_name="transaction_snapshot_archives", + ) + op.drop_index( + "idx_transaction_snapshot_archives_status_archived_at", + table_name="transaction_snapshot_archives", + ) + op.drop_table("transaction_snapshot_archives") diff --git a/backend/database_handler/migration/versions/d0e1f2a3b4c5_add_snapshot_archive_verification.py b/backend/database_handler/migration/versions/d0e1f2a3b4c5_add_snapshot_archive_verification.py new file mode 100644 index 000000000..2831e97e0 --- /dev/null +++ b/backend/database_handler/migration/versions/d0e1f2a3b4c5_add_snapshot_archive_verification.py @@ -0,0 +1,48 @@ +"""add snapshot archive verification marker + +Revision ID: d0e1f2a3b4c5 +Revises: c9d0e1f2a3b4 +Create Date: 2026-06-24 15:55:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "d0e1f2a3b4c5" +down_revision: Union[str, None] = "c9d0e1f2a3b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: # pragma: no cover + op.add_column( + "transaction_snapshot_archives", + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + "idx_transaction_snapshot_archives_verify_queue", + "transaction_snapshot_archives", + ["archive_status", "verified_at", "archived_at"], + ) + op.create_index( + "idx_transaction_snapshot_archives_prune_queue", + "transaction_snapshot_archives", + ["archive_status", "verified_at"], + ) + + +def downgrade() -> None: # pragma: no cover + op.drop_index( + "idx_transaction_snapshot_archives_prune_queue", + table_name="transaction_snapshot_archives", + ) + op.drop_index( + "idx_transaction_snapshot_archives_verify_queue", + table_name="transaction_snapshot_archives", + ) + op.drop_column("transaction_snapshot_archives", "verified_at") diff --git a/backend/database_handler/migration/versions/d2e3f4a5b6c7_rename_reroute_to_to_genvm_executor_selector.py b/backend/database_handler/migration/versions/d2e3f4a5b6c7_rename_reroute_to_to_genvm_executor_selector.py new file mode 100644 index 000000000..f3067d3a0 --- /dev/null +++ b/backend/database_handler/migration/versions/d2e3f4a5b6c7_rename_reroute_to_to_genvm_executor_selector.py @@ -0,0 +1,40 @@ +"""rename current_state.reroute_to to genvm_executor_selector + +`reroute_to` named the manager's internal/debug override field, not what the +persisted value means: an exact executor version or a `re:` selector pinning +a contract to a GenVM executor line. `genvm_executor_selector` says that +directly. The external `sim_config.reroute_to` RPC field is unaffected. + +Revision ID: d2e3f4a5b6c7 +Revises: c1d2e3f4a5b6 +Create Date: 2026-07-30 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d2e3f4a5b6c7" +down_revision: Union[str, None] = "c1d2e3f4a5b6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.alter_column( + "current_state", + "reroute_to", + new_column_name="genvm_executor_selector", + ) + + +def downgrade() -> None: + op.execute("SET LOCAL lock_timeout = '5s'") + op.alter_column( + "current_state", + "genvm_executor_selector", + new_column_name="reroute_to", + ) diff --git a/backend/database_handler/models.py b/backend/database_handler/models.py index 81a6ca1de..a3ccf52ed 100644 --- a/backend/database_handler/models.py +++ b/backend/database_handler/models.py @@ -76,6 +76,12 @@ class CurrentState(Base): id: Mapped[str] = mapped_column(String(255), primary_key=True) data: Mapped[dict] = mapped_column(JSONB) balance: Mapped[int] = mapped_column(IntNumeric(), default=0, nullable=False) + # Executor version or `re:` selector this contract is pinned to, forwarded + # to the GenVM manager as `reroute_to`. NULL means "no override, resolve + # from the manifest". + genvm_executor_selector: Mapped[Optional[str]] = mapped_column( + String(255), default=None, nullable=True + ) updated_at: Mapped[Optional[datetime.datetime]] = mapped_column( DateTime(True), init=False, @@ -181,6 +187,60 @@ class Transactions(Base): ) +class TransactionSnapshotArchive(Base): + __tablename__ = "transaction_snapshot_archives" + __table_args__ = ( + CheckConstraint( + "backend IN ('file', 'gcs', 's3')", + name="transaction_snapshot_archives_backend_check", + ), + CheckConstraint( + "archive_status IN ('archived', 'pruned')", + name="transaction_snapshot_archives_status_check", + ), + ) + + tx_hash: Mapped[str] = mapped_column( + String(66), + ForeignKey("transactions.hash", ondelete="CASCADE"), + primary_key=True, + ) + backend: Mapped[str] = mapped_column(String(20), nullable=False) + object_key: Mapped[str] = mapped_column(String(1024), nullable=False) + uri: Mapped[str] = mapped_column(String(2048), nullable=False) + snapshot_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + compressed_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + snapshot_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + compressed_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) + bucket: Mapped[Optional[str]] = mapped_column(String(255), default=None) + format: Mapped[str] = mapped_column( + String(64), + nullable=False, + server_default="full-json-gzip-v1", + default="full-json-gzip-v1", + ) + archive_status: Mapped[str] = mapped_column( + String(20), + nullable=False, + server_default="archived", + default="archived", + ) + archived_at: Mapped[datetime.datetime] = mapped_column( + DateTime(True), + server_default=func.current_timestamp(), + init=False, + ) + pruned_at: Mapped[Optional[datetime.datetime]] = mapped_column( + DateTime(True), nullable=True, default=None + ) + verified_at: Mapped[Optional[datetime.datetime]] = mapped_column( + DateTime(True), nullable=True, default=None + ) + object_metadata: Mapped[Optional[dict]] = mapped_column( + JSONB, nullable=True, default=None + ) + + class Validators(Base): __tablename__ = "validators" __table_args__ = ( diff --git a/backend/database_handler/prune_terminal_snapshots.py b/backend/database_handler/prune_terminal_snapshots.py new file mode 100644 index 000000000..d997c62b8 --- /dev/null +++ b/backend/database_handler/prune_terminal_snapshots.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import argparse +from concurrent.futures import ThreadPoolExecutor +import logging +import os +import threading +import time +from dataclasses import replace + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from backend.database_handler.terminal_snapshot_pruner import ( + TerminalSnapshotPruner, + TerminalSnapshotPrunerConfig, +) + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +class BatchBudget: + def __init__(self, max_batches: int): + self.max_batches = max_batches + self.started_batches = 0 + self._lock = threading.Lock() + + def reserve(self) -> int | None: + with self._lock: + if self.max_batches and self.started_batches >= self.max_batches: + return None + self.started_batches += 1 + return self.started_batches + + +def _format_mib_per_second(byte_count: int, elapsed_seconds: float) -> str: + if elapsed_seconds <= 0: + return "n/a" + return f"{byte_count / elapsed_seconds / (1024 * 1024):.2f} MiB/s" + + +def _empty_totals() -> dict: + return { + "batches": 0, + "candidates": 0, + "archived": 0, + "verified": 0, + "pruned": 0, + "logical_bytes": 0, + "compressed_bytes": 0, + } + + +def _add_batch_result(totals: dict, result) -> None: + totals["batches"] += 1 + totals["candidates"] += result.candidates + totals["archived"] += result.archived + totals["verified"] += result.verified + totals["pruned"] += result.pruned + totals["logical_bytes"] += result.logical_bytes + totals["compressed_bytes"] += result.compressed_bytes + + +def _log_batch_result( + *, + phase: str, + batch_number: int, + worker_id: int, + result, + elapsed_seconds: float, +) -> None: + logger.info( + "Batch %s complete: phase=%s worker=%s candidates=%s archived=%s " + "verified=%s pruned=%s logical_bytes=%s compressed_bytes=%s " + "dry_run=%s elapsed=%.2fs logical_rate=%s compressed_rate=%s", + batch_number, + phase, + worker_id, + result.candidates, + result.archived, + result.verified, + result.pruned, + result.logical_bytes, + result.compressed_bytes, + result.dry_run, + elapsed_seconds, + _format_mib_per_second(result.logical_bytes, elapsed_seconds), + _format_mib_per_second(result.compressed_bytes, elapsed_seconds), + ) + + +def _run_pruner_worker( + *, + worker_id: int, + session_factory, + config: TerminalSnapshotPrunerConfig, + batch_budget: BatchBudget, + sleep_seconds: float, + totals: dict, + totals_lock: threading.Lock, + phase: str, + verify_inline: bool, +) -> None: + pruner = TerminalSnapshotPruner(session_factory, config) + + while True: + batch_number = batch_budget.reserve() + if batch_number is None: + return + + batch_started_at = time.monotonic() + if phase == "archive": + result = pruner.archive_once(verify_inline=verify_inline) + elif phase == "verify": + result = pruner.verify_archives_once() + elif phase == "prune": + result = pruner.prune_verified_once() + else: + result = pruner.prune_once() + batch_elapsed = time.monotonic() - batch_started_at + if result.candidates == 0: + logger.info( + "Worker %s found no eligible terminal contract snapshots " + "for phase %s", + worker_id, + phase, + ) + return + + with totals_lock: + _add_batch_result(totals, result) + + _log_batch_result( + phase=phase, + batch_number=batch_number, + worker_id=worker_id, + result=result, + elapsed_seconds=batch_elapsed, + ) + + if sleep_seconds > 0: + time.sleep(sleep_seconds) + + +def _get_db_name(database: str) -> str: + return "genlayer_state" if database == "genlayer" else database + + +def get_database_url() -> str: + explicit_url = os.getenv("DB_URL") or os.getenv("POSTGRES_URL") + if explicit_url: + return explicit_url + + db_user = os.getenv("DBUSER", "postgres") + db_password = os.getenv("DBPASSWORD", "postgres") # NOSONAR - local dev fallback + db_host = os.getenv("DBHOST", "localhost") + db_port = os.getenv("DBPORT", "5432") + db_name = os.getenv("DBNAME") or _get_db_name("genlayer") + return ( + f"postgresql+psycopg2://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Archive terminal transaction contract snapshots and prune them from " + "the hot transactions table." + ) + ) + parser.add_argument( + "--phase", + choices=("full", "archive", "verify", "prune"), + default="full", + help=( + "Pipeline phase to run. full preserves the original archive, verify, " + "and prune behavior in one pass. archive writes archive rows without " + "pruning. verify reads archived objects back and marks them verified. " + "prune removes hot snapshots only for verified archive rows." + ), + ) + parser.add_argument( + "--inline-verify", + action="store_true", + help=( + "When --phase archive is used, read each object back immediately and " + "mark it verified. Off by default so archive and verification can be " + "scaled independently." + ), + ) + parser.add_argument( + "--batch-size", + type=int, + default=None, + help="Rows to process per transaction. Defaults to env/config value.", + ) + parser.add_argument( + "--retention-hours", + type=int, + default=None, + help="Only prune terminal snapshots older than this many hours.", + ) + parser.add_argument( + "--max-batches", + type=int, + default=0, + help=( + "Stop after this many claimed batch attempts across all workers. " + "0 means run until no candidates remain." + ), + ) + parser.add_argument( + "--workers", + type=int, + default=1, + help="Parallel pruner workers. Each worker uses its own DB session.", + ) + parser.add_argument( + "--sleep-seconds", + type=float, + default=0.25, + help="Pause between batches to reduce database pressure.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Count candidates in batches without writing archive objects or pruning rows.", + ) + return parser + + +def main() -> int: + args = build_parser().parse_args() + if args.workers < 1: + raise SystemExit("--workers must be at least 1") + + config = TerminalSnapshotPrunerConfig.from_environment() + config = replace( + config, + enabled=True, + dry_run=args.dry_run or config.dry_run, + batch_size=args.batch_size or config.batch_size, + retention_hours=( + args.retention_hours + if args.retention_hours is not None + else config.retention_hours + ), + ) + if args.phase == "archive": + config.validate_for_archive() + elif args.phase == "verify": + config.validate_for_verify() + elif args.phase == "full": + config.validate_for_run() + + engine = create_engine( + get_database_url(), + pool_pre_ping=True, + pool_recycle=3600, + pool_size=args.workers, + max_overflow=0, + ) + SessionLocal = sessionmaker( + autocommit=False, autoflush=False, bind=engine, expire_on_commit=False + ) + + totals = _empty_totals() + totals_lock = threading.Lock() + batch_budget = BatchBudget(args.max_batches) + + logger.info( + "Starting terminal snapshot pruning " + "(phase=%s batch_size=%s retention_hours=%s archive_enabled=%s " + "archive_backend=%s dry_run=%s workers=%s max_batches=%s " + "inline_verify=%s)", + args.phase, + config.batch_size, + config.retention_hours, + config.archive_enabled, + config.archive_backend, + config.dry_run, + args.workers, + args.max_batches, + args.inline_verify, + ) + + started_at = time.monotonic() + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = [ + executor.submit( + _run_pruner_worker, + worker_id=worker_id, + session_factory=SessionLocal, + config=config, + batch_budget=batch_budget, + sleep_seconds=args.sleep_seconds, + totals=totals, + totals_lock=totals_lock, + phase=args.phase, + verify_inline=args.inline_verify, + ) + for worker_id in range(1, args.workers + 1) + ] + for future in futures: + future.result() + + total_elapsed = time.monotonic() - started_at + logger.info( + "Finished terminal snapshot pruning: elapsed=%.2fs logical_rate=%s " + "compressed_rate=%s attempted_batches=%s totals=%s", + total_elapsed, + _format_mib_per_second(totals["logical_bytes"], total_elapsed), + _format_mib_per_second(totals["compressed_bytes"], total_elapsed), + batch_budget.started_batches, + totals, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/database_handler/snapshot_manager.py b/backend/database_handler/snapshot_manager.py index f610abe62..37e0435b6 100644 --- a/backend/database_handler/snapshot_manager.py +++ b/backend/database_handler/snapshot_manager.py @@ -33,6 +33,7 @@ def create_snapshot(self) -> Snapshot: state.id: { "data": state.data, "balance": state.balance, + "genvm_executor_selector": state.genvm_executor_selector, "updated_at": ( state.updated_at.isoformat() if state.updated_at else None ), @@ -69,7 +70,6 @@ def create_snapshot(self) -> Snapshot: "appealed": tx.appealed, "appeal_undetermined": tx.appeal_undetermined, "triggered_by_hash": tx.triggered_by_hash, - "appealed": tx.appealed, "timestamp_awaiting_finalization": tx.timestamp_awaiting_finalization, "num_of_initial_validators": tx.num_of_initial_validators, "last_vote_timestamp": tx.last_vote_timestamp, @@ -111,7 +111,10 @@ def restore_snapshot(self, snapshot_id: int) -> bool: # Restore current states for state_id, state_info in state_data.items(): new_state = CurrentState( - id=state_id, data=state_info["data"], balance=state_info["balance"] + id=state_id, + data=state_info["data"], + balance=state_info["balance"], + genvm_executor_selector=state_info.get("genvm_executor_selector"), ) if state_info["updated_at"]: new_state.updated_at = datetime.fromisoformat(state_info["updated_at"]) diff --git a/backend/database_handler/terminal_snapshot_pruner.py b/backend/database_handler/terminal_snapshot_pruner.py new file mode 100644 index 000000000..0309de05b --- /dev/null +++ b/backend/database_handler/terminal_snapshot_pruner.py @@ -0,0 +1,1359 @@ +from __future__ import annotations + +import asyncio +import base64 +import gzip +import hashlib +import json +import logging +import os +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Callable, Protocol + +from sqlalchemy import text +from sqlalchemy.orm import Session + + +logger = logging.getLogger(__name__) + + +TERMINAL_STATUSES = ("FINALIZED", "CANCELED") +ARCHIVE_FORMAT = "full-json-gzip-v1" +DEFAULT_ARCHIVE_PREFIX = "studio/terminal-contract-snapshots" +DEFAULT_FILE_ARCHIVE_DIR = "data/terminal-contract-snapshot-archive" + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int, minimum: int | None = None) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + try: + value = int(raw) + except ValueError: + logger.warning("Invalid integer for %s=%r; using %s", name, raw, default) + return default + if minimum is not None and value < minimum: + logger.warning( + "%s=%s below minimum %s; using %s", name, value, minimum, default + ) + return default + return value + + +def snapshot_archive_read_through_enabled() -> bool: + return _env_bool("STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED") + + +def _configured_archive_backend() -> str: + backend = os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND") + if backend: + return backend.strip().lower() + if os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_BUCKET"): + return "gcs" + return "s3" + + +@dataclass(frozen=True) +class TerminalSnapshotPrunerConfig: + enabled: bool = False + archive_enabled: bool = True + verify_archive: bool = True + allow_lossy_prune: bool = False + dry_run: bool = False + batch_size: int = 5 + retention_hours: int = 24 + interval_seconds: int = 300 + archive_backend: str = "s3" + file_dir: str = DEFAULT_FILE_ARCHIVE_DIR + gcs_bucket: str | None = None + gcs_prefix: str = DEFAULT_ARCHIVE_PREFIX + gcs_storage_class: str | None = None + s3_bucket: str | None = None + s3_prefix: str = DEFAULT_ARCHIVE_PREFIX + s3_region: str | None = None + s3_storage_class: str | None = None + s3_sse: str | None = None + s3_kms_key_id: str | None = None + + @classmethod + def from_environment(cls) -> "TerminalSnapshotPrunerConfig": + return cls( + enabled=_env_bool("STUDIO_CONTRACT_SNAPSHOT_PRUNER_ENABLED"), + archive_enabled=_env_bool( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_ENABLED", default=True + ), + verify_archive=_env_bool( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_VERIFY_ARCHIVE", default=True + ), + allow_lossy_prune=_env_bool( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ALLOW_LOSSY_PRUNE" + ), + dry_run=_env_bool("STUDIO_CONTRACT_SNAPSHOT_PRUNER_DRY_RUN"), + batch_size=_env_int("STUDIO_CONTRACT_SNAPSHOT_PRUNER_BATCH_SIZE", 5, 1), + retention_hours=_env_int( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_RETENTION_HOURS", 24, 0 + ), + interval_seconds=_env_int( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_INTERVAL_SECONDS", 300, 1 + ), + archive_backend=_configured_archive_backend(), + file_dir=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_FILE_DIR") + or DEFAULT_FILE_ARCHIVE_DIR, + gcs_bucket=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_BUCKET") or None, + gcs_prefix=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_PREFIX") + or DEFAULT_ARCHIVE_PREFIX, + gcs_storage_class=os.getenv( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_STORAGE_CLASS" + ) + or None, + s3_bucket=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_BUCKET") or None, + s3_prefix=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_PREFIX") + or DEFAULT_ARCHIVE_PREFIX, + s3_region=os.getenv("AWS_REGION") + or os.getenv("AWS_DEFAULT_REGION") + or os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_REGION") + or None, + s3_storage_class=os.getenv( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_STORAGE_CLASS" + ) + or None, + s3_sse=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_SSE") or None, + s3_kms_key_id=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_KMS_KEY_ID") + or None, + ) + + def _validate_archive_backend(self) -> None: + backend = self.archive_backend.lower() + if backend not in {"file", "gcs", "s3"}: + raise RuntimeError( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND must be one of " + "file, gcs, or s3" + ) + if backend == "file" and not self.file_dir: + raise RuntimeError( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_FILE_DIR is required when " + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND=file" + ) + if backend == "gcs" and not self.gcs_bucket: + raise RuntimeError( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_BUCKET is required when " + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND=gcs" + ) + if backend == "s3" and not self.s3_bucket: + raise RuntimeError( + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_BUCKET is required when " + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND=s3" + ) + + def validate_for_archive(self) -> None: + if self.dry_run: + return + if not self.archive_enabled: + raise RuntimeError( + "Archive phase requires " + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_ENABLED=true" + ) + self._validate_archive_backend() + + def validate_for_verify(self) -> None: + if self.dry_run: + return + self._validate_archive_backend() + + def validate_for_run(self) -> None: + if self.dry_run: + return + if not self.archive_enabled: + if self.allow_lossy_prune: + return + raise RuntimeError( + "Refusing to prune terminal contract snapshots without archiving. " + "Set STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_ENABLED=true, or set " + "STUDIO_CONTRACT_SNAPSHOT_PRUNER_ALLOW_LOSSY_PRUNE=true to make " + "the data-loss mode explicit." + ) + self._validate_archive_backend() + + +@dataclass(frozen=True) +class SnapshotCandidate: + tx_hash: str + status: str + created_at: datetime | None + snapshot_bytes: int + snapshot_json: str + + +@dataclass(frozen=True) +class ArchiveResult: + backend: str + bucket: str | None + key: str + uri: str + format: str + uncompressed_bytes: int + compressed_bytes: int + uncompressed_sha256: str + compressed_sha256: str + metadata: dict[str, str] + + +@dataclass(frozen=True) +class SnapshotArchiveRecord: + tx_hash: str + backend: str + bucket: str | None + object_key: str + uri: str + format: str + snapshot_sha256: str + compressed_sha256: str + snapshot_bytes: int + compressed_bytes: int + + def to_archive_result(self) -> ArchiveResult: + return ArchiveResult( + backend=self.backend, + bucket=self.bucket, + key=self.object_key, + uri=self.uri, + format=self.format, + uncompressed_bytes=self.snapshot_bytes, + compressed_bytes=self.compressed_bytes, + uncompressed_sha256=self.snapshot_sha256, + compressed_sha256=self.compressed_sha256, + metadata={"tx-hash": self.tx_hash}, + ) + + +@dataclass(frozen=True) +class PruneBatchResult: + candidates: int = 0 + archived: int = 0 + verified: int = 0 + pruned: int = 0 + logical_bytes: int = 0 + compressed_bytes: int = 0 + dry_run: bool = False + + +class SnapshotArchiveWriter(Protocol): + def archive(self, candidate: SnapshotCandidate) -> ArchiveResult: ... + + def verify(self, archive_result: ArchiveResult) -> None: ... + + +def _object_key_for_hash(prefix: str, tx_hash: str) -> str: + normalized = tx_hash.lower().removeprefix("0x") + shard = normalized[:2] if len(normalized) >= 2 else "unknown" + filename = f"{tx_hash}.contract_snapshot.json.gz" + clean_prefix = prefix.strip("/") + if not clean_prefix: + return f"v1/{shard}/{filename}" + return f"{clean_prefix}/v1/{shard}/{filename}" + + +def _archive_body(candidate: SnapshotCandidate) -> tuple[bytes, bytes, str, str]: + raw = candidate.snapshot_json.encode("utf-8") + body = gzip.compress(raw, compresslevel=6, mtime=0) + raw_sha256 = hashlib.sha256(raw).hexdigest() + compressed_sha256 = hashlib.sha256(body).hexdigest() + return raw, body, raw_sha256, compressed_sha256 + + +def _archive_metadata( + candidate: SnapshotCandidate, + *, + raw_sha256: str, + compressed_sha256: str, + raw_bytes: int, + compressed_bytes: int, +) -> dict[str, str]: + return { + "schema-version": "1", + "archive-format": ARCHIVE_FORMAT, + "tx-hash": candidate.tx_hash, + "tx-status": candidate.status, + "snapshot-sha256": raw_sha256, + "compressed-sha256": compressed_sha256, + "snapshot-bytes": str(raw_bytes), + "compressed-bytes": str(compressed_bytes), + } + + +def _decode_verified_archive_body( + *, + body: bytes, + tx_hash: str, + archive_format: str, + uncompressed_sha256: str | None, + compressed_sha256: str | None, +) -> dict | list: + if archive_format != ARCHIVE_FORMAT: + raise RuntimeError( + f"Unsupported contract snapshot archive format: {archive_format}" + ) + + actual_compressed_sha256 = hashlib.sha256(body).hexdigest() + if compressed_sha256 and actual_compressed_sha256 != compressed_sha256: + raise RuntimeError( + f"Archived contract snapshot checksum mismatch for {tx_hash}" + ) + + raw = gzip.decompress(body) + actual_uncompressed_sha256 = hashlib.sha256(raw).hexdigest() + if uncompressed_sha256 and actual_uncompressed_sha256 != uncompressed_sha256: + raise RuntimeError( + f"Archived contract snapshot content checksum mismatch for {tx_hash}" + ) + return json.loads(raw.decode("utf-8")) + + +def _verify_archive_result_body(archive_result: ArchiveResult, body: bytes) -> None: + _decode_verified_archive_body( + body=body, + tx_hash=archive_result.metadata.get("tx-hash", archive_result.key), + archive_format=archive_result.format, + uncompressed_sha256=archive_result.uncompressed_sha256, + compressed_sha256=archive_result.compressed_sha256, + ) + + +def _is_precondition_failed(exc: Exception) -> bool: + if exc.__class__.__name__ == "PreconditionFailed": + return True + return getattr(exc, "code", None) == 412 + + +class FileSnapshotArchiveWriter: + backend = "file" + + def __init__(self, *, base_dir: str | Path, prefix: str) -> None: + self.base_dir = Path(base_dir) + self.prefix = prefix.strip("/") + + @classmethod + def from_config(cls, config: TerminalSnapshotPrunerConfig): + return cls(base_dir=config.file_dir, prefix=DEFAULT_ARCHIVE_PREFIX) + + def key_for_hash(self, tx_hash: str) -> str: + return _object_key_for_hash(self.prefix, tx_hash) + + def archive(self, candidate: SnapshotCandidate) -> ArchiveResult: + raw, body, raw_sha256, compressed_sha256 = _archive_body(candidate) + metadata = _archive_metadata( + candidate, + raw_sha256=raw_sha256, + compressed_sha256=compressed_sha256, + raw_bytes=len(raw), + compressed_bytes=len(body), + ) + key = self.key_for_hash(candidate.tx_hash) + destination = self.base_dir / key + destination.parent.mkdir(parents=True, exist_ok=True) + tmp_path = destination.with_name( + f".{destination.name}.{os.getpid()}.{time.monotonic_ns()}.tmp" + ) + tmp_path.write_bytes(body) + tmp_path.replace(destination) + + return ArchiveResult( + backend=self.backend, + bucket=None, + key=key, + uri=f"file://{destination.resolve()}", + format=ARCHIVE_FORMAT, + uncompressed_bytes=len(raw), + compressed_bytes=len(body), + uncompressed_sha256=raw_sha256, + compressed_sha256=compressed_sha256, + metadata=metadata, + ) + + def verify(self, archive_result: ArchiveResult) -> None: + uri = archive_result.uri + if uri.startswith("file://"): + path = Path(uri[7:]) + else: + path = self.base_dir / archive_result.key + _verify_archive_result_body(archive_result, path.read_bytes()) + + +class GCSSnapshotArchiveWriter: + backend = "gcs" + + def __init__( + self, + *, + bucket: str, + prefix: str, + storage_class: str | None = None, + client: Any | None = None, + ) -> None: + self.bucket = bucket + self.prefix = prefix.strip("/") + self.storage_class = storage_class + self._client = client + + @classmethod + def from_config(cls, config: TerminalSnapshotPrunerConfig): + if not config.gcs_bucket: + raise RuntimeError("GCS bucket is required for snapshot archiving") + return cls( + bucket=config.gcs_bucket, + prefix=config.gcs_prefix, + storage_class=config.gcs_storage_class, + ) + + @property + def client(self): + if self._client is None: + try: + from google.cloud import storage + except ImportError as exc: # pragma: no cover - depends on image deps + raise RuntimeError( + "google-cloud-storage is required for GCS contract snapshot " + "archiving" + ) from exc + self._client = storage.Client() + return self._client + + def key_for_hash(self, tx_hash: str) -> str: + return _object_key_for_hash(self.prefix, tx_hash) + + def archive(self, candidate: SnapshotCandidate) -> ArchiveResult: + raw, body, raw_sha256, compressed_sha256 = _archive_body(candidate) + metadata = _archive_metadata( + candidate, + raw_sha256=raw_sha256, + compressed_sha256=compressed_sha256, + raw_bytes=len(raw), + compressed_bytes=len(body), + ) + key = self.key_for_hash(candidate.tx_hash) + bucket = self.client.bucket(self.bucket) + blob = bucket.blob(key) + # Deliberately not setting content_encoding: the body is already gzipped and + # the key ends in .json.gz. Declaring it makes GCS decompressively transcode + # on read, which returns a byte count that disagrees with the listed size and + # silently ignores Range requests, breaking external readers. + blob.metadata = metadata + if self.storage_class: + blob.storage_class = self.storage_class + archive_result = ArchiveResult( + backend=self.backend, + bucket=self.bucket, + key=key, + uri=f"gs://{self.bucket}/{key}", + format=ARCHIVE_FORMAT, + uncompressed_bytes=len(raw), + compressed_bytes=len(body), + uncompressed_sha256=raw_sha256, + compressed_sha256=compressed_sha256, + metadata=metadata, + ) + try: + blob.upload_from_string( + body, + content_type="application/json", + if_generation_match=0, + ) + except Exception as exc: + if not _is_precondition_failed(exc): + raise + self._verify_existing_object(bucket, key, archive_result, metadata) + + return archive_result + + def _verify_existing_object( + self, + bucket, + key: str, + archive_result: ArchiveResult, + expected_metadata: dict[str, str], + ) -> None: + existing_blob = bucket.get_blob(key) + if existing_blob is None: + raise RuntimeError( + f"GCS archive object already exists but could not be read: {key}" + ) + + body = existing_blob.download_as_bytes(raw_download=True) + _verify_archive_result_body(archive_result, body) + + actual_metadata = existing_blob.metadata or {} + for metadata_key, expected_value in expected_metadata.items(): + actual_value = actual_metadata.get(metadata_key) + if actual_value != expected_value: + raise RuntimeError( + "Existing GCS archive metadata mismatch for " + f"{archive_result.key}: {metadata_key}" + ) + + def verify(self, archive_result: ArchiveResult) -> None: + if not archive_result.bucket: + raise RuntimeError("GCS archive result is missing bucket") + body = ( + self.client.bucket(archive_result.bucket) + .blob(archive_result.key) + .download_as_bytes(raw_download=True) + ) + _verify_archive_result_body(archive_result, body) + + +class S3SnapshotArchiveWriter: + backend = "s3" + + def __init__( + self, + *, + bucket: str, + prefix: str, + region: str | None = None, + storage_class: str | None = None, + sse: str | None = None, + kms_key_id: str | None = None, + client: Any | None = None, + ) -> None: + self.bucket = bucket + self.prefix = prefix.strip("/") + self.region = region + self.storage_class = storage_class + self.sse = sse + self.kms_key_id = kms_key_id + self._client = client + + @classmethod + def from_config(cls, config: TerminalSnapshotPrunerConfig): + if not config.s3_bucket: + raise RuntimeError("S3 bucket is required for snapshot archiving") + return cls( + bucket=config.s3_bucket, + prefix=config.s3_prefix, + region=config.s3_region, + storage_class=config.s3_storage_class, + sse=config.s3_sse, + kms_key_id=config.s3_kms_key_id, + ) + + @property + def client(self): + if self._client is None: + try: + import boto3 + except ImportError as exc: # pragma: no cover - depends on image deps + raise RuntimeError( + "boto3 is required for S3 contract snapshot archiving" + ) from exc + self._client = boto3.client("s3", region_name=self.region) + return self._client + + def key_for_hash(self, tx_hash: str) -> str: + return _object_key_for_hash(self.prefix, tx_hash) + + def archive(self, candidate: SnapshotCandidate) -> ArchiveResult: + raw, body, raw_sha256, compressed_sha256 = _archive_body(candidate) + metadata = _archive_metadata( + candidate, + raw_sha256=raw_sha256, + compressed_sha256=compressed_sha256, + raw_bytes=len(raw), + compressed_bytes=len(body), + ) + key = self.key_for_hash(candidate.tx_hash) + compressed_digest = bytes.fromhex(compressed_sha256) + + # ContentEncoding is likewise omitted; readers decompress the stored bytes + # themselves, and the header only invites clients to do it for them. + put_kwargs: dict[str, Any] = { + "Bucket": self.bucket, + "Key": key, + "Body": body, + "ContentType": "application/json", + "ChecksumSHA256": base64.b64encode(compressed_digest).decode("ascii"), + "Metadata": metadata, + } + if self.storage_class: + put_kwargs["StorageClass"] = self.storage_class + if self.sse: + put_kwargs["ServerSideEncryption"] = self.sse + if self.kms_key_id: + put_kwargs["SSEKMSKeyId"] = self.kms_key_id + + self.client.put_object(**put_kwargs) + + return ArchiveResult( + backend=self.backend, + bucket=self.bucket, + key=key, + uri=f"s3://{self.bucket}/{key}", + format=ARCHIVE_FORMAT, + uncompressed_bytes=len(raw), + compressed_bytes=len(body), + uncompressed_sha256=raw_sha256, + compressed_sha256=compressed_sha256, + metadata=metadata, + ) + + def verify(self, archive_result: ArchiveResult) -> None: + if not archive_result.bucket: + raise RuntimeError("S3 archive result is missing bucket") + response = self.client.get_object( + Bucket=archive_result.bucket, + Key=archive_result.key, + ) + _verify_archive_result_body(archive_result, response["Body"].read()) + + +def build_snapshot_archive_writer( + config: TerminalSnapshotPrunerConfig, +) -> SnapshotArchiveWriter: + backend = config.archive_backend.lower() + if backend == "file": + return FileSnapshotArchiveWriter.from_config(config) + if backend == "gcs": + return GCSSnapshotArchiveWriter.from_config(config) + if backend == "s3": + return S3SnapshotArchiveWriter.from_config(config) + raise RuntimeError(f"Unsupported contract snapshot archive backend: {backend}") + + +class SnapshotArchiveReader: + def __init__( + self, + *, + file_dir: str | Path | None = None, + s3_region: str | None = None, + s3_client: Any | None = None, + gcs_client: Any | None = None, + ) -> None: + self.file_dir = Path(file_dir) if file_dir else None + self.s3_region = s3_region + self._s3_client = s3_client + self._gcs_client = gcs_client + + @classmethod + def from_environment(cls) -> "SnapshotArchiveReader": + return cls( + file_dir=os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_FILE_DIR") + or DEFAULT_FILE_ARCHIVE_DIR, + s3_region=os.getenv("AWS_REGION") + or os.getenv("AWS_DEFAULT_REGION") + or os.getenv("STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_REGION") + or None, + ) + + @property + def s3_client(self): + if self._s3_client is None: + try: + import boto3 + except ImportError as exc: # pragma: no cover - depends on image deps + raise RuntimeError( + "boto3 is required for S3 contract snapshot retrieval" + ) from exc + self._s3_client = boto3.client("s3", region_name=self.s3_region) + return self._s3_client + + @property + def gcs_client(self): + if self._gcs_client is None: + try: + from google.cloud import storage + except ImportError as exc: # pragma: no cover - depends on image deps + raise RuntimeError( + "google-cloud-storage is required for GCS contract snapshot " + "retrieval" + ) from exc + self._gcs_client = storage.Client() + return self._gcs_client + + def load_snapshot(self, session: Session, tx_hash: str) -> dict | list | None: + row = ( + session.execute( + text( + """ + SELECT + tx_hash, + backend, + bucket, + object_key, + uri, + format, + snapshot_sha256, + compressed_sha256, + snapshot_bytes, + compressed_bytes + FROM transaction_snapshot_archives + WHERE tx_hash = :hash + AND ( + archive_status = 'pruned' + OR verified_at IS NOT NULL + ) + ORDER BY archived_at DESC + LIMIT 1 + """ + ), + {"hash": tx_hash}, + ) + .mappings() + .first() + ) + if row is None: + return None + body = self._download(row) + return _decode_verified_archive_body( + body=body, + tx_hash=tx_hash, + archive_format=row["format"], + uncompressed_sha256=row["snapshot_sha256"], + compressed_sha256=row["compressed_sha256"], + ) + + def _download(self, row: dict[str, Any]) -> bytes: + backend = row["backend"].lower() + if backend == "file": + return self._download_file(row) + if backend == "gcs": + return self._download_gcs(row) + if backend == "s3": + return self._download_s3(row) + raise RuntimeError(f"Unsupported contract snapshot archive backend: {backend}") + + def _download_file(self, row: dict[str, Any]) -> bytes: + uri = row.get("uri") + if uri and uri.startswith("file://"): + path = Path(uri[7:]) + elif self.file_dir is not None: + path = self.file_dir / row["object_key"] + else: + path = Path(row["object_key"]) + return path.read_bytes() + + def _download_gcs(self, row: dict[str, Any]) -> bytes: + bucket = row["bucket"] + if not bucket: + raise RuntimeError("GCS archive row is missing bucket") + return ( + self.gcs_client.bucket(bucket) + .blob(row["object_key"]) + .download_as_bytes(raw_download=True) + ) + + def _download_s3(self, row: dict[str, Any]) -> bytes: + bucket = row["bucket"] + if not bucket: + raise RuntimeError("S3 archive row is missing bucket") + response = self.s3_client.get_object(Bucket=bucket, Key=row["object_key"]) + return response["Body"].read() + + +class TerminalSnapshotPruner: + def __init__( + self, + get_session: Callable[[], Session], + config: TerminalSnapshotPrunerConfig, + archive_writer: SnapshotArchiveWriter | None = None, + ) -> None: + self.get_session = get_session + self.config = config + self.archive_writer = archive_writer + + def _archive_writer(self) -> SnapshotArchiveWriter: + if self.archive_writer is None: + self.archive_writer = build_snapshot_archive_writer(self.config) + return self.archive_writer + + def _cutoff(self) -> datetime: + return datetime.now(timezone.utc) - timedelta(hours=self.config.retention_hours) + + def _fetch_candidates(self, session: Session) -> list[SnapshotCandidate]: + rows = ( + session.execute( + text( + """ + SELECT + hash, + status::text AS status, + created_at, + pg_column_size(contract_snapshot) AS snapshot_bytes, + contract_snapshot::text AS snapshot_json + FROM transactions + WHERE contract_snapshot IS NOT NULL + AND status IN ('FINALIZED', 'CANCELED') + AND created_at < :cutoff + ORDER BY created_at ASC, hash ASC + LIMIT :batch_size + FOR UPDATE SKIP LOCKED + """ + ), + { + "cutoff": self._cutoff(), + "batch_size": self.config.batch_size, + }, + ) + .mappings() + .all() + ) + + return [ + SnapshotCandidate( + tx_hash=row["hash"], + status=row["status"], + created_at=row["created_at"], + snapshot_bytes=int(row["snapshot_bytes"] or 0), + snapshot_json=row["snapshot_json"], + ) + for row in rows + ] + + def _fetch_archive_candidates(self, session: Session) -> list[SnapshotCandidate]: + candidate_scan_limit = self.config.batch_size * 5 + rows = ( + session.execute( + text( + """ + WITH candidate_rows AS MATERIALIZED ( + SELECT + hash, + status::text AS status, + created_at + FROM transactions + WHERE contract_snapshot IS NOT NULL + AND status IN ('FINALIZED', 'CANCELED') + AND created_at < :cutoff + ORDER BY created_at ASC, hash ASC + LIMIT :candidate_scan_limit + FOR UPDATE SKIP LOCKED + ), + selected_rows AS MATERIALIZED ( + SELECT + hash, + status, + created_at + FROM candidate_rows + WHERE NOT EXISTS ( + SELECT 1 + FROM transaction_snapshot_archives archives + WHERE archives.tx_hash = candidate_rows.hash + AND archives.archive_status IN ('archived', 'pruned') + ) + ORDER BY created_at ASC, hash ASC + LIMIT :batch_size + ) + SELECT + selected_rows.hash, + selected_rows.status, + selected_rows.created_at, + pg_column_size(transactions.contract_snapshot) AS snapshot_bytes, + transactions.contract_snapshot::text AS snapshot_json + FROM selected_rows + JOIN transactions ON transactions.hash = selected_rows.hash + ORDER BY selected_rows.created_at ASC, selected_rows.hash ASC + """ + ), + { + "cutoff": self._cutoff(), + "batch_size": self.config.batch_size, + "candidate_scan_limit": candidate_scan_limit, + }, + ) + .mappings() + .all() + ) + + return [ + SnapshotCandidate( + tx_hash=row["hash"], + status=row["status"], + created_at=row["created_at"], + snapshot_bytes=int(row["snapshot_bytes"] or 0), + snapshot_json=row["snapshot_json"], + ) + for row in rows + ] + + def _fetch_unverified_archives( + self, session: Session + ) -> list[SnapshotArchiveRecord]: + rows = ( + session.execute( + text( + """ + SELECT + tx_hash, + backend, + bucket, + object_key, + uri, + format, + snapshot_sha256, + compressed_sha256, + snapshot_bytes, + compressed_bytes + FROM transaction_snapshot_archives + WHERE archive_status = 'archived' + AND verified_at IS NULL + ORDER BY archived_at ASC, tx_hash ASC + LIMIT :batch_size + FOR UPDATE SKIP LOCKED + """ + ), + {"batch_size": self.config.batch_size}, + ) + .mappings() + .all() + ) + + return [ + SnapshotArchiveRecord( + tx_hash=row["tx_hash"], + backend=row["backend"], + bucket=row["bucket"], + object_key=row["object_key"], + uri=row["uri"], + format=row["format"], + snapshot_sha256=row["snapshot_sha256"], + compressed_sha256=row["compressed_sha256"], + snapshot_bytes=int(row["snapshot_bytes"] or 0), + compressed_bytes=int(row["compressed_bytes"] or 0), + ) + for row in rows + ] + + def _fetch_verified_prune_candidates( + self, session: Session + ) -> list[SnapshotArchiveRecord]: + rows = ( + session.execute( + text( + """ + SELECT + archives.tx_hash, + archives.backend, + archives.bucket, + archives.object_key, + archives.uri, + archives.format, + archives.snapshot_sha256, + archives.compressed_sha256, + pg_column_size(transactions.contract_snapshot) + AS snapshot_bytes, + archives.compressed_bytes + FROM transaction_snapshot_archives archives + JOIN transactions ON transactions.hash = archives.tx_hash + WHERE archives.archive_status = 'archived' + AND archives.verified_at IS NOT NULL + AND transactions.contract_snapshot IS NOT NULL + AND transactions.status IN ('FINALIZED', 'CANCELED') + AND transactions.created_at < :cutoff + ORDER BY archives.verified_at ASC, + transactions.created_at ASC, + archives.tx_hash ASC + LIMIT :batch_size + FOR UPDATE OF archives, transactions SKIP LOCKED + """ + ), + { + "cutoff": self._cutoff(), + "batch_size": self.config.batch_size, + }, + ) + .mappings() + .all() + ) + + return [ + SnapshotArchiveRecord( + tx_hash=row["tx_hash"], + backend=row["backend"], + bucket=row["bucket"], + object_key=row["object_key"], + uri=row["uri"], + format=row["format"], + snapshot_sha256=row["snapshot_sha256"], + compressed_sha256=row["compressed_sha256"], + snapshot_bytes=int(row["snapshot_bytes"] or 0), + compressed_bytes=int(row["compressed_bytes"] or 0), + ) + for row in rows + ] + + def _record_archive( + self, + session: Session, + candidate: SnapshotCandidate, + archive_result: ArchiveResult, + *, + verified: bool = False, + ) -> None: + session.execute( + text( + """ + INSERT INTO transaction_snapshot_archives ( + tx_hash, + backend, + bucket, + object_key, + uri, + format, + snapshot_sha256, + compressed_sha256, + snapshot_bytes, + compressed_bytes, + archive_status, + archived_at, + verified_at, + object_metadata + ) + VALUES ( + :tx_hash, + :backend, + :bucket, + :object_key, + :uri, + :format, + :snapshot_sha256, + :compressed_sha256, + :snapshot_bytes, + :compressed_bytes, + 'archived', + CURRENT_TIMESTAMP, + CASE WHEN :verified THEN CURRENT_TIMESTAMP ELSE NULL END, + CAST(:object_metadata AS jsonb) + ) + ON CONFLICT (tx_hash) DO UPDATE SET + backend = EXCLUDED.backend, + bucket = EXCLUDED.bucket, + object_key = EXCLUDED.object_key, + uri = EXCLUDED.uri, + format = EXCLUDED.format, + snapshot_sha256 = EXCLUDED.snapshot_sha256, + compressed_sha256 = EXCLUDED.compressed_sha256, + snapshot_bytes = EXCLUDED.snapshot_bytes, + compressed_bytes = EXCLUDED.compressed_bytes, + archive_status = 'archived', + archived_at = CURRENT_TIMESTAMP, + verified_at = EXCLUDED.verified_at, + pruned_at = NULL, + object_metadata = EXCLUDED.object_metadata + """ + ), + { + "tx_hash": candidate.tx_hash, + "backend": archive_result.backend, + "bucket": archive_result.bucket, + "object_key": archive_result.key, + "uri": archive_result.uri, + "format": archive_result.format, + "snapshot_sha256": archive_result.uncompressed_sha256, + "compressed_sha256": archive_result.compressed_sha256, + "snapshot_bytes": archive_result.uncompressed_bytes, + "compressed_bytes": archive_result.compressed_bytes, + "verified": verified, + "object_metadata": json.dumps(archive_result.metadata), + }, + ) + + def _mark_archive_verified(self, session: Session, tx_hash: str) -> None: + session.execute( + text( + """ + UPDATE transaction_snapshot_archives + SET verified_at = CURRENT_TIMESTAMP + WHERE tx_hash = :hash + AND archive_status = 'archived' + """ + ), + {"hash": tx_hash}, + ) + + def _mark_archive_pruned(self, session: Session, tx_hash: str) -> None: + session.execute( + text( + """ + UPDATE transaction_snapshot_archives + SET archive_status = 'pruned', + pruned_at = CURRENT_TIMESTAMP + WHERE tx_hash = :hash + """ + ), + {"hash": tx_hash}, + ) + + def archive_once(self, *, verify_inline: bool = False) -> PruneBatchResult: + self.config.validate_for_archive() + session = self.get_session() + archived = 0 + verified = 0 + logical_bytes = 0 + compressed_bytes = 0 + try: + candidates = self._fetch_archive_candidates(session) + if not candidates: + session.rollback() + return PruneBatchResult(dry_run=self.config.dry_run) + + logical_bytes = sum(candidate.snapshot_bytes for candidate in candidates) + if self.config.dry_run: + session.rollback() + return PruneBatchResult( + candidates=len(candidates), + logical_bytes=logical_bytes, + dry_run=True, + ) + + writer = self._archive_writer() + for candidate in candidates: + archive_result = writer.archive(candidate) + if verify_inline: + writer.verify(archive_result) + verified += 1 + self._record_archive( + session, + candidate, + archive_result, + verified=verify_inline, + ) + archived += 1 + compressed_bytes += archive_result.compressed_bytes + + session.commit() + return PruneBatchResult( + candidates=len(candidates), + archived=archived, + verified=verified, + logical_bytes=logical_bytes, + compressed_bytes=compressed_bytes, + dry_run=False, + ) + except Exception: + session.rollback() + raise + finally: + session.close() + + def verify_archives_once(self) -> PruneBatchResult: + self.config.validate_for_verify() + session = self.get_session() + try: + archives = self._fetch_unverified_archives(session) + if not archives: + session.rollback() + return PruneBatchResult(dry_run=self.config.dry_run) + + logical_bytes = sum(archive.snapshot_bytes for archive in archives) + compressed_bytes = sum(archive.compressed_bytes for archive in archives) + if self.config.dry_run: + session.rollback() + return PruneBatchResult( + candidates=len(archives), + logical_bytes=logical_bytes, + compressed_bytes=compressed_bytes, + dry_run=True, + ) + + writer = self._archive_writer() + verified = 0 + for archive in archives: + writer.verify(archive.to_archive_result()) + self._mark_archive_verified(session, archive.tx_hash) + verified += 1 + + session.commit() + return PruneBatchResult( + candidates=len(archives), + verified=verified, + logical_bytes=logical_bytes, + compressed_bytes=compressed_bytes, + dry_run=False, + ) + except Exception: + session.rollback() + raise + finally: + session.close() + + def prune_verified_once(self) -> PruneBatchResult: + session = self.get_session() + pruned = 0 + try: + archives = self._fetch_verified_prune_candidates(session) + if not archives: + session.rollback() + return PruneBatchResult(dry_run=self.config.dry_run) + + logical_bytes = sum(archive.snapshot_bytes for archive in archives) + compressed_bytes = sum(archive.compressed_bytes for archive in archives) + if self.config.dry_run: + session.rollback() + return PruneBatchResult( + candidates=len(archives), + logical_bytes=logical_bytes, + compressed_bytes=compressed_bytes, + dry_run=True, + ) + + for archive in archives: + result = session.execute( + text( + """ + UPDATE transactions + SET contract_snapshot = NULL + WHERE hash = :hash + AND contract_snapshot IS NOT NULL + """ + ), + {"hash": archive.tx_hash}, + ) + rowcount = result.rowcount or 0 + pruned += rowcount + if rowcount: + self._mark_archive_pruned(session, archive.tx_hash) + + session.commit() + return PruneBatchResult( + candidates=len(archives), + pruned=pruned, + logical_bytes=logical_bytes, + compressed_bytes=compressed_bytes, + dry_run=False, + ) + except Exception: + session.rollback() + raise + finally: + session.close() + + def prune_once(self) -> PruneBatchResult: + self.config.validate_for_run() + session = self.get_session() + archived = 0 + verified = 0 + pruned = 0 + logical_bytes = 0 + compressed_bytes = 0 + try: + candidates = self._fetch_candidates(session) + if not candidates: + session.rollback() + return PruneBatchResult(dry_run=self.config.dry_run) + + if self.config.dry_run: + logical_bytes = sum( + candidate.snapshot_bytes for candidate in candidates + ) + session.rollback() + return PruneBatchResult( + candidates=len(candidates), + logical_bytes=logical_bytes, + dry_run=True, + ) + + writer = self._archive_writer() if self.config.archive_enabled else None + for candidate in candidates: + logical_bytes += candidate.snapshot_bytes + if writer is not None: + archive_result = writer.archive(candidate) + if self.config.verify_archive: + writer.verify(archive_result) + verified += 1 + self._record_archive( + session, + candidate, + archive_result, + verified=self.config.verify_archive, + ) + archived += 1 + compressed_bytes += archive_result.compressed_bytes + + result = session.execute( + text( + """ + UPDATE transactions + SET contract_snapshot = NULL + WHERE hash = :hash + AND contract_snapshot IS NOT NULL + """ + ), + {"hash": candidate.tx_hash}, + ) + rowcount = result.rowcount or 0 + pruned += rowcount + if rowcount and writer is not None: + self._mark_archive_pruned(session, candidate.tx_hash) + + session.commit() + return PruneBatchResult( + candidates=len(candidates), + archived=archived, + verified=verified, + pruned=pruned, + logical_bytes=logical_bytes, + compressed_bytes=compressed_bytes, + dry_run=False, + ) + except Exception: + session.rollback() + raise + finally: + session.close() + + +async def run_terminal_snapshot_pruner_loop( + get_session: Callable[[], Session], + config: TerminalSnapshotPrunerConfig, + archive_writer: SnapshotArchiveWriter | None = None, +) -> None: + if not config.enabled: + return + + config.validate_for_run() + pruner = TerminalSnapshotPruner(get_session, config, archive_writer) + logger.info( + "Terminal contract snapshot pruner started " + "(batch_size=%s retention_hours=%s archive_enabled=%s " + "archive_backend=%s dry_run=%s)", + config.batch_size, + config.retention_hours, + config.archive_enabled, + config.archive_backend, + config.dry_run, + ) + + while True: + try: + start = time.monotonic() + result = await asyncio.to_thread(pruner.prune_once) + elapsed = time.monotonic() - start + if result.candidates: + logger.info( + "Terminal contract snapshot pruning batch complete: " + "candidates=%s archived=%s verified=%s pruned=%s " + "logical_bytes=%s compressed_bytes=%s dry_run=%s " + "elapsed=%.2fs", + result.candidates, + result.archived, + result.verified, + result.pruned, + result.logical_bytes, + result.compressed_bytes, + result.dry_run, + elapsed, + ) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Terminal contract snapshot pruning batch failed") + + await asyncio.sleep(config.interval_seconds) diff --git a/backend/database_handler/transactions_processor.py b/backend/database_handler/transactions_processor.py index e58db974c..e78d6dc1c 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -4,7 +4,7 @@ import rlp import re import random -from sqlalchemy.orm import Session, selectinload +from sqlalchemy.orm import Session, defer, selectinload from sqlalchemy import or_, desc, and_, JSON, type_coerce, text from backend.node.types import Vote, Receipt, ExecutionResultStatus @@ -16,9 +16,39 @@ from backend.domain.types import TransactionType from web3 import Web3 from backend.consensus.types import ConsensusRound +from backend.consensus.history import time_unit_consumption from backend.consensus.utils import determine_consensus_from_votes +from backend.protocol_rpc.fees import FEE_ACCOUNTING_KEY, normalize_fees_distribution +from backend.database_handler.terminal_snapshot_pruner import ( + SnapshotArchiveReader, + snapshot_archive_read_through_enabled, +) from backend.rollup.web3_pool import Web3ConnectionPool +MAX_JSON_SAFE_INTEGER = (2**53) - 1 + +# Canonical v0.6 ITransactions.TransactionStatus ordinals (0-14). Studio-only +# states map to their on-chain equivalents (ACTIVATED -> Proposing: activation +# transitions the on-chain tx into Proposing). +TRANSACTION_STATUS_CODES = { + "UNINITIALIZED": 0, + "PENDING": 1, + "ACTIVATED": 2, + "PROPOSING": 2, + "COMMITTING": 3, + "REVEALING": 4, + "ACCEPTED": 5, + "UNDETERMINED": 6, + "FINALIZED": 7, + "CANCELED": 8, + "APPEAL_REVEALING": 9, + "APPEAL_COMMITTING": 10, + "READY_TO_FINALIZE": 11, + "VALIDATORS_TIMEOUT": 12, + "LEADER_TIMEOUT": 13, + "LEADER_REVEALING": 14, +} + class TransactionAddressFilter(Enum): ALL = "all" @@ -66,14 +96,57 @@ class TransactionsProcessor: def __init__( self, session: Session, + snapshot_archive: SnapshotArchiveReader | None = None, ): self.session = session + self.snapshot_archive = snapshot_archive + if self.snapshot_archive is None and snapshot_archive_read_through_enabled(): + self.snapshot_archive = SnapshotArchiveReader.from_environment() # Use singleton Web3 connection pool self.web3 = Web3ConnectionPool.get() @staticmethod - def _parse_transaction_data(transaction_data: Transactions) -> dict: + def _select_receipt(receipts, index: int = 0) -> dict | None: + if isinstance(receipts, dict): + return receipts + if isinstance(receipts, list) and 0 <= index < len(receipts): + receipt = receipts[index] + if isinstance(receipt, dict): + return receipt + return None + + @staticmethod + def _json_safe_numbers(value): + if isinstance(value, bool) or value is None or isinstance(value, str): + return value + if isinstance(value, int): + return str(value) if abs(value) > MAX_JSON_SAFE_INTEGER else value + if isinstance(value, list): + return [TransactionsProcessor._json_safe_numbers(item) for item in value] + if isinstance(value, dict): + return { + key: TransactionsProcessor._json_safe_numbers(item) + for key, item in value.items() + } + return value + + @staticmethod + def _parse_transaction_data( + transaction_data: Transactions, + *, + include_contract_snapshot: bool = True, + ) -> dict: + fee_accounting = ( + transaction_data.data.get(FEE_ACCOUNTING_KEY) + if isinstance(transaction_data.data, dict) + else None + ) + execution_result, execution_result_name = ( + TransactionsProcessor._execution_result_fields( + transaction_data.consensus_data + ) + ) if transaction_data.consensus_data: leader_receipts = transaction_data.consensus_data.get("leader_receipt", []) if isinstance(leader_receipts, dict): @@ -90,12 +163,26 @@ def _parse_transaction_data(transaction_data: Transactions) -> dict: "hash": transaction_data.hash, "from_address": transaction_data.from_address, "to_address": transaction_data.to_address, - "data": transaction_data.data, + "data": TransactionsProcessor._json_safe_numbers(transaction_data.data), + # Numeric-columns contract (tests/db-sqlalchemy/test_numeric_types.py): + # top-level "value" is a plain int. The blanket _json_safe_numbers + # stringification (fee-accounting era) broke that contract for + # values > 2^53; big-int JSON consumers should read the canonical + # decimal-string fees object instead. "value": transaction_data.value, "type": transaction_data.type, "status": transaction_data.status.value, + "txExecutionResult": execution_result, + "txExecutionResultName": execution_result_name, + "fees": TransactionsProcessor._canonical_fees( + fee_accounting, + consensus_history=transaction_data.consensus_history, + consensus_data=transaction_data.consensus_data, + ), "result": TransactionsProcessor._decode_base64_data(result), - "consensus_data": transaction_data.consensus_data, + "consensus_data": TransactionsProcessor._json_safe_numbers( + transaction_data.consensus_data + ), "gaslimit": transaction_data.nonce, "nonce": transaction_data.nonce, "r": transaction_data.r, @@ -118,7 +205,11 @@ def _parse_transaction_data(transaction_data: Transactions) -> dict: "consensus_history": transaction_data.consensus_history, "timestamp_appeal": transaction_data.timestamp_appeal, "appeal_processing_time": transaction_data.appeal_processing_time, - "contract_snapshot": transaction_data.contract_snapshot, + "contract_snapshot": ( + transaction_data.contract_snapshot + if include_contract_snapshot + else None + ), "config_rotation_rounds": transaction_data.config_rotation_rounds, "num_of_initial_validators": transaction_data.num_of_initial_validators, "last_vote_timestamp": transaction_data.last_vote_timestamp, @@ -133,6 +224,158 @@ def _parse_transaction_data(transaction_data: Transactions) -> dict: "value_credited": transaction_data.value_credited, } + def _hydrate_archived_contract_snapshot(self, transaction_data: dict) -> None: + if transaction_data.get("contract_snapshot") is not None: + return + if self.snapshot_archive is None: + return + tx_hash = transaction_data.get("hash") + if not tx_hash: + return + + snapshot = self.snapshot_archive.load_snapshot(self.session, tx_hash) + if snapshot is not None: + transaction_data["contract_snapshot"] = snapshot + + @staticmethod + def _status_payload(status: str) -> dict: + status_name = str(status) + return { + "status": status_name, + "statusCode": TRANSACTION_STATUS_CODES.get(status_name.upper(), 0), + } + + @staticmethod + def _execution_result_fields(consensus_data: dict | None) -> tuple[int, str]: + receipt = TransactionsProcessor._leader_receipt(consensus_data) + if not isinstance(receipt, dict): + return 0, "NOT_VOTED" + value = str(receipt.get("execution_result") or "").upper() + if value == "SUCCESS": + return 1, "FINISHED_WITH_RETURN" + if value in {"ERROR", "FAILURE", "FINISHEDWITHERROR", "FINISHED_WITH_ERROR"}: + return 2, "FINISHED_WITH_ERROR" + return 0, "NOT_VOTED" + + @staticmethod + def _leader_receipt(consensus_data: dict | None) -> dict | None: + if not isinstance(consensus_data, dict): + return None + leader_receipts = consensus_data.get("leader_receipt") + if isinstance(leader_receipts, dict): + return leader_receipts + if isinstance(leader_receipts, list) and len(leader_receipts) > 0: + return leader_receipts[0] + return None + + @staticmethod + def _storage_fee_used(accounting: dict) -> int: + report = accounting.get("execution_fee_report") or {} + genvm_buckets = report.get("genvmBuckets") if isinstance(report, dict) else {} + if isinstance(genvm_buckets, dict): + return int(genvm_buckets.get("storage", 0) or 0) + + consumed_buckets = accounting.get("genvm_fee_consumed_buckets") or [] + if len(consumed_buckets) > 1: + return int(consumed_buckets[1]) + return 0 + + @staticmethod + def _policy_int(policy: dict, camel_key: str, snake_key: str) -> int: + return int(policy.get(camel_key, policy.get(snake_key, 0)) or 0) + + @staticmethod + def _locked_fee_policy(policy: dict | None) -> dict | None: + if not isinstance(policy, dict): + return None + return { + "genPerTimeUnit": str( + TransactionsProcessor._policy_int( + policy, "genPerTimeUnit", "gen_per_time_unit" + ) + ), + "storageUnitPrice": str( + TransactionsProcessor._policy_int( + policy, "storageUnitPrice", "storage_unit_price" + ) + ), + "receiptGasPrice": str( + TransactionsProcessor._policy_int( + policy, "receiptGasPrice", "receipt_gas_price" + ) + ), + } + + @staticmethod + def _fee_distribution_fields(fees: dict) -> dict: + return { + "leaderTimeunitsAllocation": str(fees["leaderTimeunitsAllocation"]), + "validatorTimeunitsAllocation": str(fees["validatorTimeunitsAllocation"]), + "appealRounds": str(fees["appealRounds"]), + "executionBudgetPerRound": str(fees["executionBudgetPerRound"]), + "totalMessageFees": str(fees["totalMessageFees"]), + "rotations": [str(rotation) for rotation in fees["rotations"]], + "maxPriceGenPerTimeUnit": str(fees["maxPriceGenPerTimeUnit"]), + "storageFeeMaxGasPrice": str(fees["storageFeeMaxGasPrice"]), + "receiptFeeMaxGasPrice": str(fees["receiptFeeMaxGasPrice"]), + } + + @staticmethod + def _time_unit_rounds(tu: dict) -> list[dict]: + return [ + { + "round": entry["round"], + "consensusRound": entry["consensus_round"], + "leaderTimeunits": str(entry["leader_timeunits"]), + "validatorTimeunits": str(entry["validator_timeunits"]), + "maxValidatorTimeunits": str(entry["max_validator_timeunits"]), + } + for entry in tu["per_round"] + ] + + @staticmethod + def _canonical_fees( + accounting: dict | None, + consensus_history: dict | None = None, + consensus_data: dict | None = None, + ) -> dict | None: + if not isinstance(accounting, dict): + return None + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + tu = time_unit_consumption(consensus_history, consensus_data) + policy = accounting.get("policy_snapshot") + + return { + "deposit": str(int(accounting.get("paid_fee_value", 0) or 0)), + "userValue": str(int(accounting.get("user_value", 0) or 0)), + "distribution": TransactionsProcessor._fee_distribution_fields(fees), + "locked": TransactionsProcessor._locked_fee_policy(policy), + "consumed": { + "executionConsumed": str( + int(accounting.get("execution_fee_consumed", 0) or 0) + ), + "storageFeeUsed": str( + TransactionsProcessor._storage_fee_used(accounting) + ), + "messageFeesConsumed": str( + int(accounting.get("message_fee_consumed", 0) or 0) + ), + "messageFeesBudgetTotal": str( + int(accounting.get("message_fee_budget", 0) or 0) + ), + # Protocol unit matches distribution.leaderTimeunitsAllocation and + # validatorTimeunitsAllocation: 1 TU = 1s GenVM runtime. Values + # are derived from measured per-execution wall time in ms, + # rounded UP per execution. validatorTimeunitsUsed is the SUM + # across validator-mode executions; compare per-validator + # allocations against maxValidatorTimeunits. This is the + # reference shape mirrored by nodes. + "leaderTimeunitsUsed": str(tu["leader_timeunits_used"]), + "validatorTimeunitsUsed": str(tu["validator_timeunits_used"]), + "perRound": TransactionsProcessor._time_unit_rounds(tu), + }, + } + @staticmethod def _transaction_data_to_str(data: dict) -> str: """ @@ -165,9 +408,7 @@ def decode_value(value): bytes(value, encoding="utf-8") ).decode("utf-8", errors="ignore") byte_content = re.sub(r"^[\x00-\x1f]+", "", decoded_str) - if byte_content or len(byte_content) >= 0: - return byte_content - return decoded_str + return byte_content except (ValueError, UnicodeDecodeError): return value # Return original if decoding fails @@ -326,12 +567,13 @@ def _process_round_data(self, transaction_data: dict) -> dict: len(transaction_data["consensus_history"]["consensus_results"]) - 1 ) last_round = transaction_data["consensus_history"]["consensus_results"][-1] + leader = self._select_receipt(last_round.get("leader_result"), index=1) if ( - "leader_result" in last_round - and last_round["leader_result"] is not None - and len(last_round["leader_result"]) > 1 + leader is not None + and leader.get("vote") is not None + and isinstance(leader.get("node_config"), dict) + and leader["node_config"].get("address") is not None ): - leader = last_round["leader_result"][1] validator_votes_name.append(leader["vote"].upper()) vote_number = int(Vote.from_string(leader["vote"])) validator_votes.append(vote_number) @@ -430,18 +672,35 @@ def _prepare_basic_transaction_data(self, transaction_data: dict) -> dict: transaction_data["consensus_history"] is not None and "consensus_results" in transaction_data["consensus_history"] ): - transaction_data["activator"] = transaction_data["consensus_history"][ - "consensus_results" - ][0]["leader_result"][0]["node_config"]["address"] + first_round = transaction_data["consensus_history"]["consensus_results"][0] + leader = self._select_receipt(first_round.get("leader_result"), index=0) + if ( + leader is not None + and isinstance(leader.get("node_config"), dict) + and leader["node_config"].get("address") is not None + ): + transaction_data["activator"] = leader["node_config"]["address"] + else: + transaction_data["activator"] = "" else: transaction_data["activator"] = "" if (transaction_data["consensus_data"] is not None) and ( "leader_receipt" in transaction_data["consensus_data"] ): - transaction_data["last_leader"] = transaction_data["consensus_data"][ - "leader_receipt" - ][0]["node_config"]["address"] + leader_receipt = self._select_receipt( + transaction_data["consensus_data"]["leader_receipt"], index=0 + ) + if ( + leader_receipt is not None + and isinstance(leader_receipt.get("node_config"), dict) + and leader_receipt["node_config"].get("address") is not None + ): + transaction_data["last_leader"] = leader_receipt["node_config"][ + "address" + ] + else: + transaction_data["last_leader"] = "" else: transaction_data["last_leader"] = "" return transaction_data @@ -467,21 +726,24 @@ def _encode_transaction_data(self, transaction_data: dict) -> dict: return transaction_data def _process_execution_hash(self, transaction_data: dict) -> dict: + leader_receipt = None if ( transaction_data["consensus_data"] is not None and "leader_receipt" in transaction_data["consensus_data"] - and len(transaction_data["consensus_data"]["leader_receipt"]) > 1 - and "node_config" in transaction_data["consensus_data"]["leader_receipt"][1] + ): + leader_receipt = self._select_receipt( + transaction_data["consensus_data"]["leader_receipt"], index=1 + ) + + if ( + leader_receipt is not None + and isinstance(leader_receipt.get("node_config"), dict) + and leader_receipt["node_config"].get("address") is not None + and leader_receipt.get("vote") is not None ): transaction_data["tx_execution_hash"] = get_tx_execution_hash( - transaction_data["consensus_data"]["leader_receipt"][1]["node_config"][ - "address" - ], - int( - Vote.from_string( - transaction_data["consensus_data"]["leader_receipt"][1]["vote"] - ) - ), + leader_receipt["node_config"]["address"], + int(Vote.from_string(leader_receipt["vote"])), ) else: transaction_data["tx_execution_hash"] = "" @@ -498,46 +760,44 @@ def _process_messages(self, transaction_data: dict) -> dict: for consensus_round in transaction_data["consensus_history"][ "consensus_results" ]: - if consensus_round["leader_result"] is not None: + leader_result = self._select_receipt( + consensus_round.get("leader_result"), index=0 + ) + if ( + leader_result is not None + and leader_result.get("result") is not None + ): eq_output.append( [ len(eq_output), # key [ - base64.b64decode( - consensus_round["leader_result"][0]["result"] - )[ - 0 - ], # kind + base64.b64decode(leader_result["result"])[0], # kind "\x00", ], ] ) # data kind = 0 + leader_receipt = None if ( transaction_data["consensus_data"] is not None and "leader_receipt" in transaction_data["consensus_data"] - and "result" in transaction_data["consensus_data"]["leader_receipt"] ): - kind = base64.b64decode( - transaction_data["consensus_data"]["leader_receipt"][0]["result"] - )[0] + leader_receipt = self._select_receipt( + transaction_data["consensus_data"]["leader_receipt"], index=0 + ) + if leader_receipt is not None and leader_receipt.get("result") is not None: + kind = base64.b64decode(leader_receipt["result"])[0] + pending_transactions = [] messages = [] - if ( - transaction_data["consensus_data"] is not None - and "leader_receipt" in transaction_data["consensus_data"] - and transaction_data["consensus_data"]["leader_receipt"] is not None - and "pending_transactions" - in transaction_data["consensus_data"]["leader_receipt"][0] - and transaction_data["consensus_data"]["leader_receipt"][0][ - "pending_transactions" - ] - is not None - ): - for message in transaction_data["consensus_data"]["leader_receipt"][0][ - "pending_transactions" - ]: + pending_messages = ( + leader_receipt.get("pending_transactions") + if leader_receipt is not None + else None + ) + if pending_messages is not None: + for message in pending_messages: pending_transactions.append( [ message.get("address", ""), # Account @@ -632,20 +892,28 @@ def _process_result(self, transaction_data: dict) -> dict: return transaction_data def get_transaction_by_hash( - self, transaction_hash: str, sim_config: dict | None = None + self, + transaction_hash: str, + sim_config: dict | None = None, + include_contract_snapshot: bool = True, ) -> dict | None: # Expire cached ORM objects to ensure we read fresh data after raw SQL writes self.session.expire_all() - transaction = ( - self.session.query(Transactions) - .filter_by(hash=transaction_hash) - .one_or_none() - ) + query = self.session.query(Transactions) + if not include_contract_snapshot: + query = query.options(defer(Transactions.contract_snapshot)) + transaction = query.filter_by(hash=transaction_hash).one_or_none() if transaction is None: return None - transaction_data = self._parse_transaction_data(transaction) + transaction_data = self._parse_transaction_data( + transaction, include_contract_snapshot=include_contract_snapshot + ) + if include_contract_snapshot: + self._hydrate_archived_contract_snapshot(transaction_data) + else: + transaction_data.pop("contract_snapshot", None) # Handle contract_state based on sim_config include_contract_state = sim_config and sim_config.get( @@ -692,6 +960,8 @@ def get_studio_transaction_by_hash( return None transaction_data = self._parse_transaction_data(transaction) + if full: + self._hydrate_archived_contract_snapshot(transaction_data) # Transform studio fields to testnet fields transaction_data["tx_id"] = transaction_data.pop("hash", None) @@ -900,6 +1170,40 @@ def set_transaction_result( self.session.commit() + def update_transaction_data(self, transaction_hash: str, data: dict | None): + result = self.session.execute( + text( + "UPDATE transactions SET data = CAST(:data AS jsonb) WHERE hash = :hash" + ), + { + "hash": transaction_hash, + "data": json.dumps(data) if data is not None else None, + }, + ) + if result.rowcount == 0: + print( + f"[TRANSACTIONS_PROCESSOR]: Transaction {transaction_hash} not found, skipping data update" + ) + return + self.session.commit() + + def update_transaction_fee_accounting( + self, transaction_hash: str, fee_accounting: dict + ): + transaction = ( + self.session.query(Transactions) + .filter_by(hash=transaction_hash) + .one_or_none() + ) + if transaction is None: + print( + f"[TRANSACTIONS_PROCESSOR]: Transaction {transaction_hash} not found, skipping fee accounting update" + ) + return + data = dict(transaction.data or {}) + data["fee_accounting"] = fee_accounting + self.update_transaction_data(transaction_hash, data) + def get_transaction_count(self, address: str) -> int: # Normalize address to checksum format try: @@ -1028,7 +1332,10 @@ def get_highest_timestamp(self) -> int: return transaction.timestamp_awaiting_finalization def get_transactions_for_block( - self, block_number: int, include_full_tx: bool + self, + block_number: int, + include_full_tx: bool, + include_contract_snapshot: bool = True, ) -> dict: query = self.session.query(Transactions).filter( Transactions.timestamp_awaiting_finalization == block_number @@ -1036,6 +1343,8 @@ def get_transactions_for_block( # Only eager load triggered_transactions if we need full transaction data if include_full_tx: query = query.options(selectinload(Transactions.triggered_transactions)) + if not include_contract_snapshot: + query = query.options(defer(Transactions.contract_snapshot)) transactions = query.all() block_hash = "0x" + "0" * 64 @@ -1047,7 +1356,15 @@ def get_transactions_for_block( ) if include_full_tx: - transaction_data = [self._parse_transaction_data(tx) for tx in transactions] + transaction_data = [ + self._parse_transaction_data( + tx, include_contract_snapshot=include_contract_snapshot + ) + for tx in transactions + ] + if not include_contract_snapshot: + for transaction in transaction_data: + transaction.pop("contract_snapshot", None) else: transaction_data = [tx.hash for tx in transactions] @@ -1329,6 +1646,7 @@ def reset_transaction_rotation_count(self, transaction_hash: str): text("UPDATE transactions SET rotation_count = 0 WHERE hash = :hash"), {"hash": transaction_hash}, ) + self.session.commit() def set_transaction_appeal_leader_timeout( self, transaction_hash: str, appeal_leader_timeout: bool @@ -1400,14 +1718,14 @@ def get_pending_transaction_count_for_address(self, address: str) -> int: ) return count - def get_transaction_status(self, transaction_hash: str) -> str | None: + def get_transaction_status(self, transaction_hash: str) -> dict | None: transaction = ( self.session.query(Transactions).filter_by(hash=transaction_hash).first() ) if not transaction: return None transaction_status = transaction.status - return transaction_status.value + return self._status_payload(transaction_status.value) def get_processing_transaction_for_contract( self, contract_address: str diff --git a/backend/database_handler/validators_registry.py b/backend/database_handler/validators_registry.py index aea670f1f..f16aa05e6 100644 --- a/backend/database_handler/validators_registry.py +++ b/backend/database_handler/validators_registry.py @@ -102,17 +102,21 @@ async def update_validator( validator.plugin_config = new_validator.llmprovider.plugin_config self.session.flush() # Ensure the validator update is persisted - return to_dict(validator, False) + result = to_dict(validator, False) + self.session.commit() + return result async def delete_validator(self, validator_address): validator = self._get_validator_or_fail(validator_address) self.session.delete(validator) self.session.flush() # Ensure the validator deletion is persisted + self.session.commit() async def delete_all_validators(self): self.session.query(Validators).delete(synchronize_session=False) self.session.flush() # Ensure all validator deletions are persisted + self.session.commit() async def batch_create_validators(self, validators: list[Validator]) -> list[dict]: """Create multiple validators in a single batch without triggering restarts per-validator.""" diff --git a/backend/domain/types.py b/backend/domain/types.py index 3fd56c88d..60b63c209 100644 --- a/backend/domain/types.py +++ b/backend/domain/types.py @@ -46,6 +46,11 @@ def to_dict(self) -> dict: class SimConfig: validators: list[SimValidatorConfig] genvm_datetime: str | None = None + # Studio-only: GenVM executor version or `re:` selector to run instead of + # the manifest-resolved one. On a deploy it is used for the deployment + # execution itself and stored on the contract, so every later execution + # of that contract keeps using it. + genvm_executor_selector: str | None = None @property def genvm_datetime_as_datetime(self) -> datetime.datetime | None: @@ -65,6 +70,7 @@ def from_dict(cls, d: dict) -> "SimConfig": return cls( validators=validators, genvm_datetime=d.get("genvm_datetime"), + genvm_executor_selector=d.get("genvm_executor_selector"), ) def to_dict(self) -> dict: @@ -73,6 +79,7 @@ def to_dict(self) -> dict: v.to_dict() if hasattr(v, "to_dict") else v for v in self.validators ], "genvm_datetime": self.genvm_datetime, + "genvm_executor_selector": self.genvm_executor_selector, } @@ -164,6 +171,12 @@ class TransactionExecutionMode(Enum): NORMAL = "NORMAL" +def _int_from_serialized(value, default: int | None = 0) -> int | None: + if value is None or value == "": + return default + return int(value) + + @dataclass class Transaction: hash: str @@ -259,7 +272,7 @@ def from_dict(cls, input: dict) -> "Transaction": data=input.get("data"), consensus_data=ConsensusData.from_dict(input.get("consensus_data")), nonce=input.get("nonce"), - value=input.get("value"), + value=_int_from_serialized(input.get("value"), None), gaslimit=input.get("gaslimit"), r=input.get("r"), s=input.get("s"), diff --git a/backend/node/base.py b/backend/node/base.py index 20de9f5e5..dc343d4fb 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -15,12 +15,19 @@ from backend.domain.types import Validator, Transaction, TransactionType from backend.protocol_rpc.message_handler.types import LogEvent, EventType, EventScope +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + FeeValidationError, + genvm_fee_context, + genvm_message_fee_allocation, +) import backend.node.genvm.base as genvmbase import backend.node.genvm.origin.calldata as calldata from backend.database_handler.contract_snapshot import ContractSnapshot from backend.node.types import Receipt, ExecutionMode, Vote, ExecutionResultStatus from backend.protocol_rpc.message_handler.base import IMessageHandler from .genvm.origin import logger as genvm_logger +from .genvm.origin import host_fns from .genvm.origin import public_abi from .types import Address @@ -68,22 +75,16 @@ def _env_bool(name: str, default: bool = False) -> bool: return raw.strip().lower() in {"1", "true", "yes", "on"} -def _genvm_extra_args() -> list[str]: - """Extra CLI args for the genvm executor. +def _genvm_debug_mode() -> str: + """genvm-manager `debug_mode` level for the run request. - `--debug-mode` enables the `:latest` and `:test` runner version - aliases (see genvm/executor/src/exe/run.rs:58-62). Convenient in - dev/stg where you want to iterate without pinning a specific runner - hash, but a footgun in prd: those aliases float, so two validators - on the same tx can resolve different runner binaries — breaks - determinism / consensus. - - Gated on GENVM_DEBUG_MODE (default true to preserve dev/stg - convenience). Prd manifests should set GENVM_DEBUG_MODE=false so - contracts that try to use `py-genlayer:latest` or `py-genlayer:test` - fail fast at the executor. + `unsafe` (dev/stg default) captures unbounded output and enables the + `:latest`/`:test` runner aliases that studio's bundled contracts depend + on; only `unsafe`/`unsafe-tracing` resolve those floating aliases. Prd + sets GENVM_DEBUG_MODE=false -> `safe` (consensus-safe) so the aliases + fail fast. """ - return ["--debug-mode"] if _env_bool("GENVM_DEBUG_MODE", default=True) else [] + return "unsafe" if _env_bool("GENVM_DEBUG_MODE", default=True) else "safe" def _filter_genvm_log_by_level(genvm_log: list[dict]) -> list[dict]: @@ -371,6 +372,12 @@ def get_balance(self, addr: Address) -> int: bal = getattr(snap, "balance", 0) return int(bal) if bal is not None else 0 + def genvm_executor_selector_for(self, addr: Address) -> str | None: + # A nested call target picks up its own executor pin; loading its + # snapshot here is the same lookup its storage reads already trigger. + selector = getattr(self._get_snapshot(addr), "genvm_executor_selector", None) + return selector or None + import aiohttp @@ -472,10 +479,10 @@ async def stop_module(self, module_type: typing.Literal["llm", "web"]): body = await resp.json() if resp.status != 200: self.logger.error( - f"Failed to stop LLM module", body=body, status=resp.status + "Failed to stop LLM module", body=body, status=resp.status ) else: - self.logger.info(f"Stopped LLM module", body=body, status=resp.status) + self.logger.info("Stopped LLM module", body=body, status=resp.status) async def start_module( self, @@ -490,7 +497,7 @@ async def start_module( body = await resp.json() if resp.status != 200: self.logger.error( - f"Failed to start module", + "Failed to start module", module=module_type, body=body, status=resp.status, @@ -518,9 +525,7 @@ async def try_llms( async with aiohttp.request("POST", f"{self.url}/llm/check", json=data) as resp: body = await resp.json() if resp.status != 200: - self.logger.error( - f"Failed to check llms", body=body, status=resp.status - ) + self.logger.error("Failed to check llms", body=body, status=resp.status) # Return error response for each config when the check fails return [ { @@ -626,6 +631,7 @@ async def exec_transaction(self, transaction: Transaction) -> Receipt: assert transaction.data is not None transaction_data = transaction.data + fee_accounting = transaction_data.get(FEE_ACCOUNTING_KEY) assert transaction.from_address is not None # Override transaction timestamp @@ -650,6 +656,7 @@ async def exec_transaction(self, transaction: Transaction) -> Receipt: transaction_created_at, value=transaction.value or 0, origin_address=transaction.origin_address, + fee_accounting=fee_accounting, ) self.timing_callback("DEPLOY_END") @@ -667,6 +674,7 @@ async def exec_transaction(self, transaction: Transaction) -> Receipt: transaction_created_at, value=transaction.value or 0, origin_address=transaction.origin_address, + fee_accounting=fee_accounting, ) self.timing_callback("RUN_END") @@ -713,6 +721,7 @@ def _create_enhanced_node_config(self, host_data: dict | None) -> dict: if fallback_validator: enhanced_node_config["secondary_model"] = { + "address": fallback_validator.address, "provider": fallback_validator.llmprovider.provider, "model": fallback_validator.llmprovider.model, "plugin": fallback_validator.llmprovider.plugin, @@ -726,7 +735,7 @@ def _set_vote(self, receipt: Receipt) -> Receipt: result_code = receipt.result[0] # 1. Timeout: VM-level timeout or GenVM internal error - if result_code == public_abi.ResultCode.VM_ERROR: + if result_code == host_fns.ResultCode.VM_ERROR: error_message = receipt.result[1:] if error_message == b"timeout" or error_message.startswith( b"GenVM internal error" @@ -797,6 +806,7 @@ async def deploy_contract( transaction_created_at: str | None = None, value: int = 0, origin_address: str | None = None, + fee_accounting: dict | None = None, ) -> Receipt: assert self.contract_snapshot is not None @@ -814,6 +824,7 @@ async def deploy_contract( code=code_to_deploy, value=value, origin_address=origin_address, + fee_accounting=fee_accounting, ) async def run_contract( @@ -824,6 +835,7 @@ async def run_contract( transaction_created_at: str | None = None, value: int = 0, origin_address: str | None = None, + fee_accounting: dict | None = None, ) -> Receipt: return await self._run_genvm( from_address, @@ -834,6 +846,7 @@ async def run_contract( transaction_datetime=self._date_from_str(transaction_created_at), value=value, origin_address=origin_address, + fee_accounting=fee_accounting, ) async def get_contract_data( @@ -905,6 +918,7 @@ async def get_contract_schema(self, code: bytes) -> str: "contract_address": NO_ADDR, "sender_address": NO_ADDR, "origin_address": NO_ADDR, + "signer_address": NO_ADDR, "value": 0, "chain_id": 0, } @@ -916,16 +930,16 @@ async def get_contract_schema(self, code: bytes) -> str: functools.partial( genvmbase.Host, calldata_bytes=calldata.encode( - {"method": public_abi.SpecialMethod.GET_SCHEMA.value} + {"": public_abi.SpecialMethod.GET_SCHEMA.value} ), state_proxy=state_proxy, leader_results=None, ), message=message, permissions="rw", - extra_args=_genvm_extra_args(), host_data='{"node_address":"0x", "tx_id":"0x"}', capture_output=True, + debug_mode=_genvm_debug_mode(), is_sync=True, logger=self.logger, timeout=30, @@ -967,15 +981,16 @@ async def _run_genvm( transaction_hash: str | None = None, transaction_datetime: datetime.datetime | None, state_status: str | None = None, - timeout: float = 10 * 60, + timeout: float = 10 * 60, # noqa: ASYNC109 - forwarded GenVM deadline code: bytes | None = None, value: int = 0, origin_address: str | None = None, + fee_accounting: dict | None = None, ) -> Receipt: self.timing_callback("GENVM_PREPARATION_START") leader_res: None | dict[int, bytes] - if self.leader_receipt is None or not self.leader_receipt.eq_outputs: + if self.leader_receipt is None or self.leader_receipt.eq_outputs is None: leader_res = None else: leader_res = { @@ -1020,24 +1035,31 @@ async def _run_genvm( host_data["node_address"] = self.address logger = self.logger.with_keys({"tx_id": host_data["tx_id"]}) - message = { "is_init": is_init, "contract_address": contract_address, "sender_address": Address(from_address), "origin_address": Address(origin_address or from_address), + "signer_address": Address(origin_address or from_address), "value": int(value), "chain_id": get_simulator_chain_id(), } if transaction_datetime is not None: assert transaction_datetime.tzinfo is not None - message["datetime"] = transaction_datetime.isoformat() + message["transaction_timestamp"] = transaction_datetime.isoformat() perms = "rcn" # read/call/spawn nondet if not readonly: perms += "ws" # write/send start_time = time.time() try: + bucket_totals, gas_data = genvm_fee_context( + fee_accounting, + ) + message_fee_allocation = genvm_message_fee_allocation( + fee_accounting, + address_factory=Address, + ) result = await genvmbase.run_genvm_host( functools.partial( genvmbase.Host, @@ -1048,13 +1070,42 @@ async def _run_genvm( message=message, permissions=perms, capture_output=True, + debug_mode=_genvm_debug_mode(), host_data=json.dumps(host_data), - extra_args=_genvm_extra_args(), is_sync=is_sync, manager_uri=self.manager.url, timeout=timeout, code=code, + fee_context=genvmbase.GenVMFeeContext( + bucket_totals=bucket_totals, + gas_data=gas_data, + message_fee_allocation=message_fee_allocation, + ), logger=logger, + genvm_executor_selector=self.contract_snapshot.genvm_executor_selector, + ) + except FeeValidationError as e: + result = genvmbase.ExecutionResult( + result=genvmbase.ExecutionError( + message=str(e), + kind=host_fns.ResultCode.USER_ERROR, + error_code=e.__class__.__name__, + raw_error={ + "fatal": False, + "causes": [str(e)], + "ctx": {"source": "studio_fee_accounting"}, + }, + description=str(e), + ), + eq_outputs={}, + pending_transactions=[], + stdout="", + stderr=str(e), + genvm_log=[], + state=snapshot_view, + processing_time=int((time.time() - start_time) * 1000), + nondet_disagree=None, + execution_stats=None, ) except genvmbase.GenVMInternalError as e: e.is_leader = self.validator_mode == ExecutionMode.LEADER @@ -1082,6 +1133,17 @@ async def _run_genvm( if isinstance(result.result, genvmbase.ExecutionReturn) else ExecutionResultStatus.ERROR ) + data_fees_consumed = None + if ( + result.data_fee_bucket_totals is not None + and result.data_fees_remaining is not None + ): + data_fees_consumed = [ + max(0, int(total) - int(remaining)) + for total, remaining in zip( + result.data_fee_bucket_totals, result.data_fees_remaining + ) + ] result = Receipt( result=genvmbase.encode_result_to_bytes(result.result), @@ -1121,6 +1183,9 @@ async def _run_genvm( if isinstance(result.result, genvmbase.ExecutionError) else None ), + "data_fee_bucket_totals": result.data_fee_bucket_totals, + "data_fees_remaining": result.data_fees_remaining, + "data_fees_consumed": data_fees_consumed, }, processing_time=result.processing_time, nondet_disagree=result.nondet_disagree, diff --git a/backend/node/create_nodes/default_providers/llm-router_policy-auto.json b/backend/node/create_nodes/default_providers/llm-router_policy-auto.json new file mode 100644 index 000000000..bf52f0468 --- /dev/null +++ b/backend/node/create_nodes/default_providers/llm-router_policy-auto.json @@ -0,0 +1,10 @@ +{ + "provider": "llm-router", + "plugin": "openai-compatible", + "model": "policy:auto", + "config": {}, + "plugin_config": { + "api_key_env_var": "LLM_ROUTER_API_KEY", + "api_url": "https://internal-router.genlayer.com" + } +} diff --git a/backend/node/genvm/__init__.py b/backend/node/genvm/__init__.py index 763cd12a5..b62ef97cb 100644 --- a/backend/node/genvm/__init__.py +++ b/backend/node/genvm/__init__.py @@ -1,5 +1,8 @@ import hashlib +from backend.node.genvm.origin.public_abi import root_offsets + def get_code_slot() -> bytes: - return hashlib.sha3_256(b"\x00" * 32 + b"\x01\x00\x00\x00").digest() + offset = root_offsets.CODE.to_bytes(4, byteorder="little", signed=False) + return hashlib.sha3_256(b"\x00" * 32 + offset).digest() diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index a5927e409..3e77000cb 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -10,12 +10,14 @@ "ExecutionResult", "apply_storage_changes", "GenVMInternalError", + "GenVMFeeContext", "Context", "set_genvm_callbacks", ) import math import os +import re import typing import tempfile from pathlib import Path @@ -23,6 +25,7 @@ import json import base64 import asyncio +import contextlib import socket import backend.node.genvm.origin.base_host as genvmhost import collections.abc @@ -31,6 +34,8 @@ import time import copy +from eth_abi import decode, encode + from backend.node.types import ( PendingTransaction, Address, @@ -40,7 +45,13 @@ from .origin.public_abi import * from .origin import base_host +from .origin import host_fns + +# The wire result code lives in `host_fns`; `public_abi` only carries the +# subset the SDK exposes, so import it last to win over the star import. +from .origin.host_fns import ResultCode from .origin import logger as genvm_logger +from .origin.leader_public_data import LeaderPublicData from .error_codes import ( extract_error_code, extract_error_code_from_timeout, @@ -49,6 +60,37 @@ GenVMInternalError, ) +GENVM_GASLESS_GAS_DATA: dict[str, str] = { + "storageUnitPrice": "0", + "receiptGasPerByte": "0", + "gasPerChangedSlot": "0", + "intrinsicGas": "0", + "bootloaderOverhead": "0", + "fixedProposeReceiptGas": "0", + "fixedMessageRevealGas": "0", + "genPerTimeUnit": "0", +} + +INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256,uint256,uint256,uint256[])" +INTERNAL_MESSAGE_FEE_PARAMS_WITH_CAPS_ABI_TYPE = ( + "(uint256,uint256,uint256,uint256,uint256[],uint256,uint256,uint256)" +) +EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256)" +MESSAGE_ALLOCATION_NODE_ABI_TYPE = ( + "(uint8,bool,uint256,address,bytes32,uint256,bytes)[]" +) +MESSAGE_TYPE_EXTERNAL = 0 +MESSAGE_TYPE_INTERNAL = 1 +NODE_ROOT_SENTINEL = (1 << 256) - 1 +CALL_KEY_WILDCARD = "0x" + ("0" * 64) + + +@dataclass(frozen=True) +class GenVMFeeContext: + bucket_totals: list[int] | None = None + gas_data: dict[str, str] | None = None + message_fee_allocation: list[dict] | None = None + @dataclass class ExecutionError: @@ -95,6 +137,17 @@ def storage_read( @abc.abstractmethod def get_balance(self, addr: Address) -> int: ... + def genvm_executor_selector_for(self, addr: Address) -> str | None: + """Executor selector this contract is pinned to, or None if unpinned. + + Backs the nested cross-major `resolve_call_contract_executor` hook: the + genvm asks which executor a call target runs on, and a pinned contract + answers with its stored `genvm_executor_selector`. Proxies without a + contract store (e.g. deploy-time `_StateProxyNone`) inherit this None + default. + """ + return None + class StateProxyWritable(StateProxy, metaclass=abc.ABCMeta): @abc.abstractmethod @@ -113,6 +166,53 @@ def storage_write( def get_balance(self, addr: Address) -> int: ... +EXECUTOR_VERSION_RE: typing.Final = re.compile(r"v?\d+(\.\d+)*(-[0-9A-Za-z.]+)?") +""" +Shape of an exact executor pin. + +The manager uses a pin verbatim as the executor directory name, so anything not +shaped like a version must never reach it as a path component. Checked both at +submit time and again where a stored pin is read back. +""" + +_CLOSE_CONNECTIONS_TIMEOUT_S: typing.Final = 10.0 +""" +Cap on how long `Host.close_connections` waits for a cancelled task to +actually finish. + +Cancellation only requests a `CancelledError` at the next await point; a +nested connection task stuck outside the event loop (e.g. in blocking I/O) +would otherwise hang shutdown indefinitely instead of just losing that one +task's cleanup. +""" + +EXECUTOR_SELECTOR_REGEX_PREFIX: typing.Final = "re:" +"""Prefix marking a selector as a regex pattern, matching the manager's +`VERSION_REGEX_PREFIX` (genvm-manager crates/modules-interfaces/src/nested.rs).""" + + +def is_valid_executor_selector(value: str) -> bool: + """ + Same selector grammar the manager accepts for `reroute_to`: either an exact + executor version (see `EXECUTOR_VERSION_RE`), or a `re:`-prefixed pattern + matched by the manager against the directory names in its manifest. + + Both submit-time validation + (`protocol_rpc.endpoints._validate_genvm_executor_selector`) and + nested-call resolution (`Host.resolve_call_contract_executor`) must use + this same grammar so a value that is accepted (or backfilled) on one + boundary never gets rejected at the other. + """ + if value.startswith(EXECUTOR_SELECTOR_REGEX_PREFIX): + pattern = value[len(EXECUTOR_SELECTOR_REGEX_PREFIX) :] + try: + re.compile(pattern) + except re.error: + return False + return True + return bool(EXECUTOR_VERSION_RE.fullmatch(value)) + + def apply_storage_changes( storage_changes: list[tuple[bytes, bytes]], state: StateProxyWritable ) -> None: @@ -176,50 +276,12 @@ def on_genvm_failure(self): def add_stat(self, key: str, value: typing.Any, /): self.stats[key] = value - def get_timeout( - self, - action: base_host.TimeoutAction, - type: base_host.TimeoutType, - /, - ) -> float | None: - TA = base_host.TimeoutAction - TT = base_host.TimeoutType - - if action == TA.GenVMRun: - total = _get_env_float("GENVM_MANAGER_RUN_HTTP_TIMEOUT_SECONDS", 10.0) - if type == TT.TOTAL_S: - return total - if type == TT.CONNECT_S: - return min(5.0, total) - if type == TT.SOCK_READ_S: - return total - elif action == TA.GenVMGet: - total = _get_env_float("GENVM_MANAGER_STATUS_HTTP_TIMEOUT_SECONDS", 10.0) - if type == TT.TOTAL_S: - return total - if type == TT.CONNECT_S: - return min(3.0, total) - if type == TT.SOCK_READ_S: - return total - elif action == TA.GenVMDelete: - if type == TT.TOTAL_S: - return _get_env_float("GENVM_MANAGER_DELETE_HTTP_TIMEOUT_SECONDS", 3.0) - if type == TT.CONNECT_S: - return 1.5 - if type == TT.SOCK_READ_S: - return 1.5 - if type == TT.DELETE_HTTP_GRACEFUL_TIMEOUT_MS: - return 20.0 - return None - - def retry_delay( - self, action: base_host.TimeoutAction, attempt_no: int, / - ) -> float | None: - max_retries = _get_int("GENVM_MANAGER_RUN_RETRIES", 3) - if attempt_no >= max_retries - 1: - return None - base_delay = _get_env_float("GENVM_MANAGER_RUN_RETRY_DELAY_SECONDS", 1.0) - return base_delay * (2**attempt_no) + def get_manager_connect_timeout(self) -> float | None: + # The manager socket is a local websocket; bound only the connect phase + # so a dead manager fails fast instead of hanging the run. Mirrors the + # old GenVMRun connect budget (min(5, run-timeout)). + total = _get_env_float("GENVM_MANAGER_RUN_HTTP_TIMEOUT_SECONDS", 10.0) + return min(5.0, total) @dataclass @@ -234,6 +296,185 @@ class ExecutionResult: processing_time: int nondet_disagree: int | None execution_stats: dict | None = None + data_fee_bucket_totals: list[int] | None = None + data_fees_remaining: list[int] | None = None + + +def _emission_value(emission: dict, name: str): + snake = "".join(f"_{char.lower()}" if char.isupper() else char for char in name) + return emission.get(name, emission.get(snake)) + + +def _emission_bytes(emission: dict, name: str) -> bytes: + value = _emission_value(emission, name) + return _bytes_from_emission_value(value) + + +def _bytes_from_emission_value(value) -> bytes: + if value is None: + return b"" + if isinstance(value, bytes): + return value + if isinstance(value, str): + raw = value.removeprefix("0x") + try: + return bytes.fromhex(raw) + except ValueError: + return base64.b64decode(value) + return bytes(value) + + +def _emission_internal_fee_params(emission: dict) -> bytes: + value = _emission_value(emission, "feeParams") + if isinstance(value, dict): + rotations = [int(rotation) for rotation in value.get("rotations", [])] + appeal_rounds = max(len(rotations) - 1, 0) + return encode( + [INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [ + ( + int(value.get("leader_timeunits_allocation", 0)), + int(value.get("validator_timeunits_allocation", 0)), + appeal_rounds, + int(value.get("execution_budget_per_round", 0)), + rotations, + ) + ], + ) + return _bytes_from_emission_value(value) + + +def _emission_external_fee_params(emission: dict) -> bytes: + value = _emission_value(emission, "feeParams") + if isinstance(value, dict): + return encode( + [EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [ + ( + int(value.get("gas_limit", 0)), + int(value.get("max_gas_price", 0)), + ) + ], + ) + return _bytes_from_emission_value(value) + + +def _emission_allocation_subtree(emission: dict) -> list[dict]: + value = _emission_value(emission, "allocationSubtree") + if isinstance(value, list): + return value + + subtree = _emission_value(emission, "subtree") + if subtree is None: + return [] + + raw = _bytes_from_emission_value(subtree) + if not raw: + return [] + + try: + decoded = decode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], raw)[0] + except Exception: + return [] + + allocation_subtree = [] + for node in decoded: + message_type = int(node[0]) + fee_params = bytes(node[6]) + if message_type == MESSAGE_TYPE_INTERNAL: + fee_params = _canonical_internal_fee_params_from_genvm(fee_params) + allocation_subtree.append( + { + "messageType": message_type, + "onAcceptance": bool(node[1]), + "parentIndex": int(node[2]), + "recipient": str(node[3]).lower(), + "callKey": "0x" + bytes(node[4]).hex(), + "budget": int(node[5]), + "feeParams": "0x" + fee_params.hex(), + } + ) + return allocation_subtree + + +def _canonical_internal_fee_params_from_genvm(fee_params: bytes) -> bytes: + try: + decoded = decode([INTERNAL_MESSAGE_FEE_PARAMS_WITH_CAPS_ABI_TYPE], fee_params)[ + 0 + ] + except Exception: + return fee_params + return encode( + [INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [ + ( + int(decoded[0]), + int(decoded[1]), + int(decoded[2]), + int(decoded[3]), + [int(rotation) for rotation in decoded[4]], + ) + ], + ) + + +def _emission_int(emission: dict, name: str) -> int: + return int(_emission_value(emission, name) or 0) + + +def _emission_on(emission: dict) -> typing.Literal["accepted", "finalized"]: + """GenVM calls the pre-finalization lifecycle `decided`; Studio calls it `accepted`. + + Both executor lines normalize to `decided` at the host boundary, so `accepted` + never arrives on the wire — this maps one way, GenVM → Studio. + """ + return "finalized" if emission["on"] == "finalized" else "accepted" + + +def _emission_hex(emission: dict, name: str) -> str: + value = _emission_value(emission, name) + if value is None: + return "0x" + ("0" * 64) + if isinstance(value, bytes): + return "0x" + value.hex().rjust(64, "0")[-64:] + return "0x" + str(value).removeprefix("0x").lower().rjust(64, "0")[-64:] + + +def _emission_list(emission: dict, name: str) -> list: + value = _emission_value(emission, name) + return value if isinstance(value, list) else [] + + +def _extract_llm_token_metrics( + metrics: dict[str, typing.Any] | None, +) -> dict[str, typing.Any] | None: + if not isinstance(metrics, dict): + return None + + llm_metrics = metrics.get("llm") + if not isinstance(llm_metrics, dict): + return None + + token_metrics = llm_metrics.get("tokens") + if not isinstance(token_metrics, dict) or not token_metrics: + return None + + return token_metrics + + +def _close_watched(sock: socket.socket) -> None: + """ + Closes a socket that an asyncio task may still be reading from. + + `Task.cancel` only takes effect on the next loop iteration, so a task blocked + in `sock_recv` deregisters its reader *after* a synchronous `close` has freed + the file descriptor -- by which point the number may already belong to + someone else's socket, whose reader it then silently removes. + """ + with contextlib.suppress(Exception): + asyncio.get_event_loop().remove_reader(sock.fileno()) + with contextlib.suppress(OSError): + sock.close() class Host(genvmhost.IHost): @@ -261,6 +502,22 @@ def __init__( self._state_proxy = state_proxy self.calldata_bytes = calldata_bytes self._leader_results = leader_results + # A run that delegates across a major boundary spawns nested executors, + # and each of them dials the same listener, so the first connection is + # not necessarily the only one. + self._ctx: Context | None = None + self._accept_task: asyncio.Task | None = None + self._connection_tasks: list[asyncio.Task] = [] + self._accepted_sockets: list[socket.socket] = [] + + def bind_context(self, ctx: Context) -> None: + """ + Gives the host the context its nested connections are served with. + + `loop_enter` is the only seam the host protocol offers and it carries no + context, so the caller that owns both hands it over before the run. + """ + self._ctx = ctx def provide_result( self, @@ -340,13 +597,19 @@ def provide_result( else: raise Exception(f"invalid result {res.result_kind}") - apply_storage_changes(res.result_storage_changes, state) + # Readonly (view) executions can still report storage changes on GenVM + # main — e.g. lazy data-structure initialization on first access + # (genlayer-embeddings VecDB._do_init inside a view knn). Those writes + # are ephemeral VM-side effects: discard them instead of tripping the + # storage_write readonly assertion. + if not getattr(state, "readonly", False): + apply_storage_changes(res.result_storage_deltas, state) # Extract pending_transactions from result_emissions pending_transactions = [] for emission in res.result_emissions: match emission["type"]: - case "PostMessage": + case "InternalMessage": pending_transactions.append( PendingTransaction( emission["address"].as_hex, @@ -354,10 +617,14 @@ def provide_result( code=None, salt_nonce=0, value=emission["value"], - on=emission["on"], + on=_emission_on(emission), + fee_params=_emission_internal_fee_params(emission), + declared_budget=_emission_int(emission, "declaredBudget"), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_allocation_subtree(emission), ) ) - case "DeployContract": + case "InternalDeployMessage": pending_transactions.append( PendingTransaction( address="0x", @@ -365,10 +632,14 @@ def provide_result( code=emission["code"], salt_nonce=emission["salt_nonce"], value=emission["value"], - on=emission["on"], + on=_emission_on(emission), + fee_params=_emission_internal_fee_params(emission), + declared_budget=_emission_int(emission, "declaredBudget"), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_allocation_subtree(emission), ) ) - case "EthSend": + case "ExternalMessage": pending_transactions.append( PendingTransaction( address=emission["address"].as_hex, @@ -378,11 +649,23 @@ def provide_result( value=emission["value"], on="finalized", is_eth_send=True, + fee_params=_emission_external_fee_params(emission), + declared_budget=_emission_int(emission, "declaredBudget"), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_allocation_subtree(emission), + gas_used=_emission_int(emission, "gasUsed"), ) ) - # Extract eq_outputs from result_nondet_results - eq_outputs = {i: data for i, data in enumerate(res.result_nondet_results)} + leader_public_data = LeaderPublicData.decode(res.result_leader_public_data) + eq_outputs = { + i: data for i, data in enumerate(leader_public_data.nondet_block_outputs) + } + + execution_stats = dict(ctx.stats) + llm_token_metrics = _extract_llm_token_metrics(res.metrics) + if llm_token_metrics is not None: + execution_stats["llm"] = {"tokens": llm_token_metrics} return ExecutionResult( eq_outputs=eq_outputs, @@ -394,51 +677,177 @@ def provide_result( state=state, processing_time=0, nondet_disagree=self._nondet_disagreement, - execution_stats=ctx.stats, + execution_stats=execution_stats, + data_fees_remaining=res.data_fees_remaining, ) async def loop_enter(self, cancellation) -> socket.socket: + sock = await self._accept(cancellation) + if sock is None: + raise Exception("Program failed") + self.sock = sock + assert self._ctx is not None, "bind_context must run before the genvm connects" + if self._accept_task is None: + # Serve every later connection ourselves: accepting stops when the + # run ends, which is when `run_genvm` sets the cancellation event. + self._accept_task = asyncio.create_task( + self._accept_connections(cancellation) + ) + return sock + + async def _accept(self, cancellation) -> socket.socket | None: + """ + Accepts one connection, or returns `None` once the run is over. + """ async_loop = asyncio.get_event_loop() assert self.sock_listener is not None - interesting = asyncio.ensure_future(async_loop.sock_accept(self.sock_listener)) + accepting = asyncio.ensure_future(async_loop.sock_accept(self.sock_listener)) canc = asyncio.ensure_future(cancellation.wait()) - done, pending = await asyncio.wait( - [canc, interesting], return_when=asyncio.FIRST_COMPLETED - ) - if canc in done: - raise Exception("Program failed") - canc.cancel() + accepted: socket.socket | None = None + try: + await asyncio.wait([canc, accepting], return_when=asyncio.FIRST_COMPLETED) + finally: + # Also runs when this task is cancelled mid-accept, which is the + # normal way the background acceptor ends. The accept is awaited out + # even then: until it is gone the event loop still watches the + # listener, which the caller is about to close. + canc.cancel() + if not accepting.done(): + accepting.cancel() + result = None + with contextlib.suppress(BaseException): + result = await accepting + if result is not None: + # Recorded here rather than after the `finally`, because this + # block also runs while a CancelledError is propagating: a + # connection that arrived just as we were cancelled is still + # ours to close, and nothing else knows about it. + accepted, _addr = result + accepted.setblocking(False) + self._accepted_sockets.append(accepted) + + return accepted + + async def _accept_connections(self, cancellation) -> None: + assert self._ctx is not None + while True: + sock = await self._accept(cancellation) + if sock is None: + return + self._connection_tasks.append( + asyncio.create_task(genvmhost.host_loop_on(self, sock, ctx=self._ctx)) + ) - self.sock, _addr = interesting.result() - self.sock.setblocking(False) - self.sock_listener.close() - self.sock_listener = None - return self.sock + async def close_connections(self) -> None: + """ + Winds down the nested-connection loops and drops every accepted socket. + + A nested loop that died on its own is reported rather than dropped: the + run's own result comes from the manager and stays authoritative, but a + nested executor that lost its host is why it looks the way it does. + + The listener is not closed here: it belongs to whoever created it. + + Every drain below is capped at `_CLOSE_CONNECTIONS_TIMEOUT_S` via + `asyncio.wait` (not `wait_for`, which -- if the cancelled task keeps + swallowing `CancelledError` -- keeps re-awaiting it past its own + timeout instead of returning): a task that never actually stops must + not be able to hang shutdown forever. + """ + if self._accept_task is not None: + task = self._accept_task + task.cancel() + done, pending = await asyncio.wait( + [task], timeout=_CLOSE_CONNECTIONS_TIMEOUT_S + ) + if pending and self._ctx is not None: + self._ctx.logger.error( + "accept task did not stop within close_connections timeout" + ) + elif done and not task.cancelled(): + exc = task.exception() + if exc is not None and self._ctx is not None: + self._ctx.logger.error("accept task failed", error=exc) + self._accept_task = None + for task in self._connection_tasks: + if not task.done(): + task.cancel() + if self._connection_tasks: + done, pending = await asyncio.wait( + self._connection_tasks, timeout=_CLOSE_CONNECTIONS_TIMEOUT_S + ) + if pending and self._ctx is not None: + self._ctx.logger.error( + f"{len(pending)} nested host connection(s) did not stop " + "within close_connections timeout" + ) + for task in done: + if task.cancelled(): + continue + exc = task.exception() + if exc is not None and self._ctx is not None: + self._ctx.logger.error("nested host connection failed", error=exc) + self._connection_tasks.clear() + for accepted in self._accepted_sockets: + _close_watched(accepted) + self._accepted_sockets.clear() + self.sock = None async def storage_read( - self, type: StorageType, account: bytes, slot: bytes, index: int, le: int, / + self, type: StorageView, address: bytes, slot: bytes, offset: int, le: int, / ) -> bytes: - assert type != StorageType.LATEST_FINAL + assert type != StorageView.LATEST_FINALIZED return await asyncio.to_thread( - self._state_proxy.storage_read, Address(account), slot, index, le + self._state_proxy.storage_read, Address(address), slot, offset, le ) - async def consume_gas(self, gas: int, /) -> None: + async def resolve_call_contract_executor( + self, + contract_address: Address, + state_mode: StorageView, + advisory_major: int, + /, + ) -> bytes | None: + # Cross-major nested calls: the genvm asks which executor a call target + # runs on. Answer with the target's own pin (genvm_executor_selector) + # so a contract deployed against an older line keeps executing there + # even when called from a newer one. Unpinned targets return None -> + # the caller's runner keeps advising the major (same-major behavior). + # + # The pin names the line outright rather than deriving a major from it: + # every line released so far is semver major 0, so a major resolves to + # the newest one whichever line the pin meant. + reroute = self._state_proxy.genvm_executor_selector_for(contract_address) + if not reroute: + return None + # A pin that is not a version is rejected at submit time, so reaching + # here means a stored one went bad. Fail this call as a host error: + # anything else escapes `host_loop_on`, kills the host task and turns a + # permanently broken callee into retries that only end when the + # transaction's time budget does. + if not is_valid_executor_selector(reroute): + raise base_host.HostException( + host_fns.Errors.FORBIDDEN, + f"contract {contract_address.as_hex} is pinned to an unusable executor version: {reroute!r}", + ) + return gvm_calldata.encode({"kind": "version", "version": reroute}) + + async def consume_time_fee_gen_wei(self, time_fee_gen_wei: int, /) -> None: pass - async def eth_call(self, account: bytes, calldata: bytes, /) -> bytes: + async def external_call(self, address: bytes, calldata: bytes, /) -> bytes: # FIXME(core-team): #748 assert False - async def get_balance(self, account: bytes, /) -> int: - return await asyncio.to_thread(self._state_proxy.get_balance, Address(account)) + async def get_balance_gen_wei(self, address: bytes, /) -> int: + return await asyncio.to_thread(self._state_proxy.get_balance, Address(address)) async def notify_nondet_disagreement(self, call_no: int, /) -> None: self._nondet_disagreement = call_no - async def remaining_fuel_as_gen(self, /) -> int: + async def get_remaining_time_fee_gen_wei(self, /) -> int: return 2**60 @@ -499,38 +908,68 @@ def _create_timeout_result( state=state_proxy, processing_time=processing_time, nondet_disagree=None, + data_fees_remaining=[], ) -def _leader_results_to_list( +def _encode_leader_public_data( leader_results: dict[int, bytes] | None, -) -> list[bytes] | None: - """Convert dict[int, bytes] keyed by call_no to ordered list[bytes].""" +) -> bytes | None: if leader_results is None: return None - if not leader_results: - return [] - max_key = max(leader_results.keys()) - return [leader_results.get(i, b"") for i in range(max_key + 1)] + outputs = [] + if leader_results: + max_key = max(leader_results.keys()) + outputs = [leader_results.get(i, b"") for i in range(max_key + 1)] + return LeaderPublicData(outputs).encode() async def run_genvm_host( host_supplier: typing.Callable[[socket.socket], Host], *, - timeout: float, + timeout: float, # noqa: ASYNC109 - retry budget spans multiple awaits manager_uri: str = "http://127.0.0.1:3999", logger: genvm_logger.Logger | None = None, is_sync: bool, capture_output: bool = True, + debug_mode: str | None = None, message: typing.Any, host_data: str = "", extra_args: list[str] = [], permissions: str = "rwscn", code: bytes | None = None, + fee_context: GenVMFeeContext | None = None, + genvm_executor_selector: str | None = None, ) -> ExecutionResult: if logger is None: logger = genvm_logger.NoLogger() + # base_host.run_genvm no longer derives the level from capture_output, so + # resolve it here: capture_output implies safe-unbounded (host reads + # stdout/stderr artifacts), otherwise disabled. + effective_debug_mode: base_host.DebugMode = debug_mode or ( + "safe-unbounded" if capture_output else "disabled" + ) + if genvm_executor_selector and effective_debug_mode == "disabled": + # The manager honors the executor override only under debug_mode >= safe + # and ignores it silently otherwise, which would run the contract on the + # manifest-resolved executor instead of the requested one. + raise ValueError( + f"genvm_executor_selector={genvm_executor_selector!r} requires " + "debug_mode >= safe, got 'disabled'" + ) ctx = Context(logger=logger) + fee_context = fee_context or GenVMFeeContext() + effective_bucket_totals = fee_context.bucket_totals or [ + 10_000_000, + 10_000_000, + 10_000_000, + 10_000_000, + ] + effective_gas_data = ( + dict(fee_context.gas_data) + if fee_context.gas_data + else dict(GENVM_GASLESS_GAS_DATA) + ) tmpdir = Path(tempfile.mkdtemp()) try: base_delay = 5 # seconds @@ -546,7 +985,16 @@ async def run_genvm_host( ) fresh_args = {} + # Backoff owed to a failed attempt. It is served after that attempt's + # listener, sockets and nested connection tasks are gone, so a dead + # attempt cannot keep serving executors for the length of the sleep. + retry_delay = 0.0 + while True: + if retry_delay: + await asyncio.sleep(retry_delay) + retry_delay = 0.0 + remaining_time = timeout - (time.time() - start_time) if remaining_time <= 0: # When the genvm keeps crashing we send a timeout error @@ -572,7 +1020,9 @@ async def run_genvm_host( sock_listener.setblocking(False) sock_path = tmpdir.joinpath(f"sock_{retry_count}") sock_listener.bind(str(sock_path)) - sock_listener.listen(1) + # A run that delegates across a major boundary spawns nested + # executors, and each dials this same listener. + sock_listener.listen(8) fresh_host_supplier = functools.partial( ( @@ -583,36 +1033,60 @@ async def run_genvm_host( **fresh_args, ) host: Host = fresh_host_supplier(sock_listener) + host.bind_context(ctx) leader_results = fresh_args.get( "leader_results", host_args.get("leader_results") ) - leader_nondet_results = _leader_results_to_list(leader_results) + leader_public_data = _encode_leader_public_data(leader_results) try: - res = await base_host.run_genvm( - host, - manager_uri=manager_uri, - message=message, - timeout=timeout, - capture_output=capture_output, - is_sync=is_sync, - host_data=host_data, - ctx=ctx, - host=f"unix://{sock_path}", - extra_args=extra_args, - code=code, - calldata=fresh_args.get( - "calldata_bytes", host_args.get("calldata_bytes", b"") - ), - leader_nondet_results=leader_nondet_results, - ) + # Fresh manager websocket per attempt: run_genvm never owns + # the client's lifecycle, and a retry after a bounce wants a + # clean connection rather than a poisoned one. + async with base_host.ManagerClient( + manager_uri, + connect_timeout=ctx.get_manager_connect_timeout(), + ) as manager_client: + res = await base_host.run_genvm( + host, + manager_uri=manager_uri, + manager_client=manager_client, + message=message, + timeout=timeout, + debug_mode=effective_debug_mode, + is_sync=is_sync, + host_data=host_data, + ctx=ctx, + host=f"unix://{sock_path}", + extra_args=extra_args, + code=code, + bucket_totals=effective_bucket_totals, + gas_data=effective_gas_data, + message_fee_allocation=fee_context.message_fee_allocation + or [], + calldata=fresh_args.get( + "calldata_bytes", host_args.get("calldata_bytes", b"") + ), + leader_public_data=leader_public_data, + unsafe_overrides=base_host.UnsafeOverrides( + reroute_to=genvm_executor_selector or "" + ), + # Ask to be consulted on where a CallContract runs. + # Opting out makes the manager answer the resolve + # query itself with "stay in-process", which silently + # runs a pinned callee's code on the caller's + # executor -- the very thing the pin exists to stop. + # A nested run inherits this from its parent. + request_extra={"hook_cross_contract_calls": True}, + ) execution_result = host.provide_result( res, fresh_args.get("state_proxy", host_args.get("state_proxy")), ctx, ) + execution_result.data_fee_bucket_totals = effective_bucket_totals execution_result.processing_time = math.ceil( (time.time() - start_time) * 1000 @@ -623,9 +1097,35 @@ async def run_genvm_host( # Re-raise GenVMInternalError to propagate to worker for proper handling # (stop worker, release transaction, report unhealthy) raise + except base_host.TerminalResultUnavailable: + # The genvm already executed exactly once and reached a + # terminal state; only fetching/decoding its result failed. + # Falling into the generic `except Exception` below would + # start a brand new run here, executing the contract a + # second time for a result that already exists -- so this + # propagates as a permanent failure instead of retrying. + raise + except base_host.ManagerRunNotStarted as e: + # The genvm never executed. base_host already classified the + # refusal, so no transport knowledge or string matching here: + # a permanent refusal (bad request/runner) fails fast; a + # transient one (manager still starting modules) retries + # until the deadline budget runs out (top-of-loop check). + if not e.retryable: + raise + logger.warning( + "genvm run not started, retrying", + reason=e.reason, + retry_count=retry_count, + ) + last_error = e + retry_count += 1 + retry_delay = min( + base_delay * (2 ** (retry_count - 1)), remaining_time + ) except Exception as e: logger.error( - f"GenVM execution attempt failed", + "GenVM execution attempt failed", error=e, retry_count=retry_count, ) @@ -640,13 +1140,14 @@ async def run_genvm_host( ) retry_count += 1 - # Sleep for a longer time than the previous attempt to avoid executing it too many times - delay = min(base_delay * (2 ** (retry_count - 1)), remaining_time) - await asyncio.sleep(delay) + # Back off longer than the previous attempt to avoid + # executing it too many times. + retry_delay = min( + base_delay * (2 ** (retry_count - 1)), remaining_time + ) finally: - if host.sock is not None: - host.sock.close() + await host.close_connections() sock_path.unlink(missing_ok=True) finally: shutil.rmtree(tmpdir, ignore_errors=True) diff --git a/backend/node/genvm/origin/__init__.py b/backend/node/genvm/origin/__init__.py index 247ccd3e8..e69de29bb 100644 --- a/backend/node/genvm/origin/__init__.py +++ b/backend/node/genvm/origin/__init__.py @@ -1 +0,0 @@ -# This code is taken from genvm repo diff --git a/backend/node/genvm/origin/base_host.py b/backend/node/genvm/origin/base_host.py index f86158aba..c2ff5c505 100644 --- a/backend/node/genvm/origin/base_host.py +++ b/backend/node/genvm/origin/base_host.py @@ -1,45 +1,79 @@ -""" -This module is a part of GenVM source code. When updating - -#. Open PR to https://github.com/genlayerlabs/genvm/blob/main/tests/runner/origin/base_host.py -#. Keep interface integration-agnostic: no usage of environment variables, no assumptions -""" - -import enum -import socket -import typing -import asyncio import abc +import asyncio +import base64 +import collections.abc +import contextlib +import json +import math +import socket import time +import types +import typing +import urllib.parse +from dataclasses import dataclass, field import aiohttp -from dataclasses import dataclass - - +from . import ( + calldata as gvm_calldata, +) +from . import ( + fees, + host_fns, + manager_api, + public_abi, +) from .calldata import Address -from . import calldata as gvm_calldata -from . import host_fns -from . import public_abi +from .logger import Logger ACCOUNT_ADDR_SIZE = 20 SLOT_ID_SIZE = 32 -from .logger import Logger +ZERO_SLOT = b"\x00" * SLOT_ID_SIZE +"""The root `SlotID`, all zeroes.""" +ROOT_OFFSET_MAJOR = 0 +"""Offset of the single-octet public-ABI `major` within the root slot.""" -class TimeoutAction(enum.StrEnum): - VMErrorDescribe = "vm-error/describe" - GenVMGet = "/genvm/{id}" - GenVMRun = "/genvm/run" - GenVMDelete = "DELETE /genvm/{id}" +UNDEPLOYED_MAJOR = 0 +""" +Major to declare when there is no deployed contract to read one from. + +Every line released so far has semver major `0`, and the manager matches `0` +against all of them and picks the newest, so this is a de-facto "any line". +A deploy is the honest case for it: the real value comes from the contract +package, which this harness does not parse, and the harness pins its executor +with `unsafe_overrides.reroute_to` regardless. It is also why the manager cannot +yet reject `major == 0`. +""" +# Mirrors the executor's `DebugMode` enum (crates/common/src/debug_mode.rs). +DebugMode = typing.Literal[ + "disabled", + "safe", + "safe-unbounded", + "unsafe", + "unsafe-tracing", +] -class TimeoutType(enum.StrEnum): - TOTAL_S = "HTTP_TIMEOUT_TOTAL_S" - CONNECT_S = "HTTP_TIMEOUT_CONNECT_S" - SOCK_READ_S = "HTTP_TIMEOUT_SOCK_READ_S" - DELETE_HTTP_GRACEFUL_TIMEOUT_MS = "DELETE_HTTP_GRACEFUL_TIMEOUT_MS" +# Default host-provided `node` fee constants (see fees.expr_prelude in +# install/config/genvm.yaml). Values are strings (gas_data is Map) +# and are kept minimal/deterministic for tests. `validatorsPerRound` is +# intentionally omitted so the prelude default table is used. +DEFAULT_GAS_DATA: dict[str, str] = { + "storageUnitPrice": "1", + "receiptGasPerByte": "1", + "gasPerChangedSlot": "1", + "intrinsicGas": "0", + "bootloaderOverhead": "0", + "fixedProposeReceiptGas": "0", + "fixedMessageRevealGas": "0", + "genPerTimeUnit": "0", + # 0 = no per-phase timeunit floor, so default-allocation tests are unaffected. + "minTimeUnitsPerPhase": "0", + # 0 = no per-round execution-budget floor for balance-funded messages. + "messageBudgetFloor": "0", +} class Context(typing.Protocol): @@ -50,28 +84,12 @@ def on_genvm_failure(self): ... def add_stat(self, key: str, value: typing.Any, /): ... - def get_timeout(self, action: TimeoutAction, type: TimeoutType, /) -> float | None: + def get_manager_connect_timeout(self) -> float | None: return None - def retry_delay(self, action: TimeoutAction, attempt_no: int, /) -> float | None: - """Returns delay before next retry, or None if no retries are left.""" - return None - -def _http_timeout( - ctx: Context, - action: TimeoutAction, -) -> aiohttp.ClientTimeout: - """ - Explicit aiohttp timeout to avoid wedging consensus when the local GenVM manager - accepts a connection but never responds. - """ - total_s = ctx.get_timeout(action, TimeoutType.TOTAL_S) - connect_s = ctx.get_timeout(action, TimeoutType.CONNECT_S) - sock_read_s = ctx.get_timeout(action, TimeoutType.SOCK_READ_S) - return aiohttp.ClientTimeout( - total=total_s, connect=connect_s, sock_read=sock_read_s - ) +def _http_timeout(ctx: Context) -> aiohttp.ClientTimeout: + return aiohttp.ClientTimeout(connect=ctx.get_manager_connect_timeout()) class HostException(Exception): @@ -82,14 +100,35 @@ def __init__(self, error_code: host_fns.Errors, message: str = ""): super().__init__(message or f"GenVM error: {error_code}") +@dataclass(frozen=True) +class UnsafeOverrides: + """ + Request overrides that reach boundaries production traffic cannot. + + Each member states the `debug_mode` the manager requires before it applies: + `reroute_to` from `safe`, `initial_recursion` from `unsafe`. With debugging + disabled none of them take effect. + """ + + reroute_to: str = "" + initial_recursion: int | None = None + + def as_request_field(self) -> dict[str, typing.Any]: + return { + "reroute_to": self.reroute_to, + "initial_recursion": self.initial_recursion, + } + + class Message(typing.TypedDict): contract_address: Address sender_address: Address origin_address: Address + signer_address: Address chain_id: int value: typing.NotRequired[int] is_init: bool - datetime: typing.NotRequired[str] + transaction_timestamp: typing.NotRequired[str] class FingerprintFrame(typing.TypedDict): @@ -102,41 +141,59 @@ class ResultFingerprint(typing.TypedDict): module_instances: dict[str, typing.Any] -class EthSendInner(typing.TypedDict): - type: typing.Literal["EthSend"] +class ExternalMessageInner(typing.TypedDict): + type: typing.Literal["ExternalMessage"] address: Address calldata: bytes value: int + message_fee: int + receipt_fee: int + fee_params: fees.ExternalMessageParams -class PostMessageInner(typing.TypedDict): - type: typing.Literal["PostMessage"] +class InternalMessageInner(typing.TypedDict): + type: typing.Literal["InternalMessage"] address: Address calldata: gvm_calldata.Decoded value: int - on: typing.Literal["finalized", "accepted"] - - -class DeployContractInner(typing.TypedDict): - type: typing.Literal["DeployContract"] + on: typing.Literal["finalized", "decided"] + message_fee: int + receipt_fee: int + fee_params: fees.InternalMessageParams + # ABI-encoded allocation subtree carried in the receipt under commitment modes. + subtree: bytes + # Chain `useBalance`: fee funded from the emitting contract's balance. + use_balance: bool + + +class InternalDeployMessageInner(typing.TypedDict): + type: typing.Literal["InternalDeployMessage"] calldata: gvm_calldata.Decoded code: bytes value: int - on: typing.Literal["finalized", "accepted"] + on: typing.Literal["finalized", "decided"] salt_nonce: int + message_fee: int + receipt_fee: int + fee_params: fees.InternalMessageParams + # ABI-encoded allocation subtree carried in the receipt under commitment modes. + subtree: bytes + # Chain `useBalance`: fee funded from the emitting contract's balance. + use_balance: bool -class EmitEventInner(typing.TypedDict): - type: typing.Literal["EmitEvent"] +class EventInner(typing.TypedDict): + type: typing.Literal["Event"] topics: list[bytes] blob: dict[str, gvm_calldata.Decoded] + storage_fee: int type ResultEmission = typing.Union[ - EthSendInner, - PostMessageInner, - DeployContractInner, - EmitEventInner, + ExternalMessageInner, + InternalMessageInner, + InternalDeployMessageInner, + EventInner, ] @@ -147,34 +204,69 @@ async def loop_enter(self, cancellation: asyncio.Event) -> socket.socket: ... @abc.abstractmethod async def storage_read( self, - mode: public_abi.StorageType, - account: bytes, + mode: public_abi.StorageView, + address: bytes, slot: bytes, - index: int, + offset: int, le: int, /, ) -> bytes: ... + async def resolve_call_contract_executor( + self, + contract_address: Address, + state_mode: public_abi.StorageView, + advisory_major: int, + /, + ) -> bytes | None: + return None + @abc.abstractmethod - async def consume_gas(self, gas: int, /) -> None: ... + async def consume_time_fee_gen_wei(self, time_fee_gen_wei: int, /) -> None: ... @abc.abstractmethod - async def eth_call(self, account: bytes, calldata: bytes, /) -> bytes: ... + async def external_call(self, address: bytes, calldata: bytes, /) -> bytes: ... @abc.abstractmethod - async def get_balance(self, account: bytes, /) -> int: ... + async def get_balance_gen_wei(self, address: bytes, /) -> int: ... @abc.abstractmethod - async def remaining_fuel_as_gen(self, /) -> int: ... + async def get_remaining_time_fee_gen_wei(self, /) -> int: ... @abc.abstractmethod async def notify_nondet_disagreement(self, call_no: int, /) -> None: ... +async def read_contract_major(handler: IHost, message: Message) -> int: + """ + Reads the public-ABI major the run's contract was deployed against. + + The major is octet `ROOT_OFFSET_MAJOR` of the root slot, written at deploy + time and read on every load. A deploy has nothing to read, so it declares + `UNDEPLOYED_MAJOR` instead. + + The read uses the storage view a top-level run itself uses, so a run cannot + take its major from a state it would not otherwise observe. An address with + no contract reads back `0`, which is indistinguishable from major `0`; that + stays a fallback for now. + """ + if message.get("is_init", False): + return UNDEPLOYED_MAJOR + octet = await handler.storage_read( + public_abi.StorageView.LATEST_DECIDED, + message["contract_address"].as_bytes, + ZERO_SLOT, + ROOT_OFFSET_MAJOR, + 1, + ) + return octet[0] + + async def host_loop( handler: IHost, cancellation: asyncio.Event, *, ctx: Context, ) -> None: - async_loop = asyncio.get_event_loop() - + """ + Accepts one connection through the handler, then serves it. + """ logger = ctx.logger logger.trace("entering loop") @@ -187,6 +279,26 @@ async def host_loop( round((host_loop_entered_s - loop_enter_wait_start) * 1000), ) logger.trace("entered loop") + + await host_loop_on(handler, sock, ctx=ctx) + + +async def host_loop_on( + handler: IHost, + sock: socket.socket, + *, + ctx: Context, +) -> None: + """ + Serves an already accepted connection. + + A run that spawns nested executors produces several connections to one + listener, so the accept step has to be separable from the protocol it feeds. + """ + async_loop = asyncio.get_event_loop() + + logger = ctx.logger + accept_time = time.perf_counter() first_method_name: str | None = None first_method_received_s: float | None = None @@ -246,6 +358,22 @@ async def read_slice() -> memoryview: call_counts = {} meth_id: host_fns.Methods | None = None + def emit_host_loop_stats(): + if first_method_name is not None: + ctx.add_stat("host_first_method", first_method_name) + ctx.add_stat("host_total_handling_time_ms", round(total_handling_time * 1000)) + ctx.add_stat( + "host_time_per_method_ms", + {k: round(v * 1000) for k, v in time_per_method.items()}, + ) + ctx.add_stat("call_counts", call_counts) + logger.debug( + "handling time", + total=total_handling_time, + by_method=time_per_method, + call_counts=call_counts, + ) + handling_start = time.time() while True: cur_delta = time.time() - handling_start @@ -257,7 +385,11 @@ async def read_slice() -> memoryview: await flush_socket_buffer() - meth_id = host_fns.Methods(await recv_int(1)) + try: + meth_id = host_fns.Methods(await recv_int(1)) + except ConnectionResetError: + emit_host_loop_stats() + return None if first_method_name is None: first_method_name = meth_id.name first_method_received_s = time.perf_counter() @@ -273,84 +405,76 @@ async def read_slice() -> memoryview: match meth_id: case host_fns.Methods.STORAGE_READ: mode = await read_exact(1) - mode = public_abi.StorageType(mode[0]) - account = await read_exact(ACCOUNT_ADDR_SIZE) + mode = public_abi.StorageView(mode[0]) + address = await read_exact(ACCOUNT_ADDR_SIZE) slot = await read_exact(SLOT_ID_SIZE) - index = await recv_int() + offset = await recv_int() le = await recv_int() try: - res = await handler.storage_read(mode, account, slot, index, le) + res = await handler.storage_read(mode, address, slot, offset, le) assert len(res) == le except HostException as e: await send_all(bytes([e.error_code])) else: await send_all(bytes([host_fns.Errors.OK])) await send_all(res) + case host_fns.Methods.RESOLVE_CALL_CONTRACT_EXECUTOR: + contract_address = Address(await read_exact(ACCOUNT_ADDR_SIZE)) + state_mode = public_abi.StorageView((await read_exact(1))[0]) + advisory_major = await recv_int(1) + + try: + res = await handler.resolve_call_contract_executor( + contract_address, + state_mode, + advisory_major, + ) + except HostException as e: + await send_all(bytes([e.error_code])) + else: + encoded_res = gvm_calldata.encode(res) + await send_all(bytes([host_fns.Errors.OK])) + await send_int(len(encoded_res)) + await send_all(encoded_res) case host_fns.Methods.CONSUME_RESULT: raise Exception( "CONSUME_RESULT is not supported in this host loop implementation, use manager provided one" ) - case host_fns.Methods.NOTIFY_FINISHED: - logger.debug( - "handling time", - total=total_handling_time, - by_method=time_per_method, - call_counts=call_counts, - ) - await send_all(bytes([0])) - await flush_socket_buffer() - - if first_method_name is not None: - ctx.add_stat("host_first_method", first_method_name) - ctx.add_stat( - "host_total_handling_time_ms", round(total_handling_time * 1000) - ) - ctx.add_stat( - "host_time_per_method_ms", - {k: round(v * 1000) for k, v in time_per_method.items()}, - ) - ctx.add_stat("call_counts", call_counts) - logger.debug( - "handling time", - total=total_handling_time, - by_method=time_per_method, - call_counts=call_counts, - ) - return None - case host_fns.Methods.CONSUME_FUEL: - gas = await recv_int(8) - await handler.consume_gas(gas) - case host_fns.Methods.ETH_CALL: - account = await read_exact(ACCOUNT_ADDR_SIZE) + case host_fns.Methods.CONSUME_TIME_FEE_GEN_WEI: + time_fee_gen_wei = await recv_int(32) + await handler.consume_time_fee_gen_wei(time_fee_gen_wei) + case host_fns.Methods.EXTERNAL_CALL: + address = await read_exact(ACCOUNT_ADDR_SIZE) calldata_len = await recv_int() calldata = await read_exact(calldata_len) try: - res = await handler.eth_call(account, calldata) + res = await handler.external_call(address, calldata) except HostException as e: await send_all(bytes([e.error_code])) else: await send_all(bytes([host_fns.Errors.OK])) await send_int(len(res)) await send_all(res) - case host_fns.Methods.GET_BALANCE: - account = await read_exact(ACCOUNT_ADDR_SIZE) + case host_fns.Methods.GET_BALANCE_GEN_WEI: + address = await read_exact(ACCOUNT_ADDR_SIZE) try: - res = await handler.get_balance(account) + res = await handler.get_balance_gen_wei(address) except HostException as e: await send_all(bytes([e.error_code])) else: await send_all(bytes([host_fns.Errors.OK])) await send_all(res.to_bytes(32, byteorder="little", signed=False)) - case host_fns.Methods.REMAINING_FUEL_AS_GEN: + case host_fns.Methods.GET_REMAINING_TIME_FEE_GEN_WEI: try: - res = await handler.remaining_fuel_as_gen() + time_fee_gen_wei = await handler.get_remaining_time_fee_gen_wei() except HostException as e: await send_all(bytes([e.error_code])) else: - res = min(res, 2**53 - 1) await send_all(bytes([host_fns.Errors.OK])) - await send_all(res.to_bytes(8, byteorder="little", signed=False)) + await send_all( + time_fee_gen_wei.to_bytes(32, byteorder="little", signed=False) + ) case host_fns.Methods.NOTIFY_NONDET_DISAGREEMENT: call_no = await recv_int() await handler.notify_nondet_disagreement(call_no) @@ -359,6 +483,87 @@ async def read_slice() -> memoryview: raise Exception(f"unknown method {x}") +class ConsumedResultDecodeError(Exception): + """ + `consumed_result` bytes did not parse into a `ConsumedResult` at all. + + Distinct from `ConsumedResult.internal_error(...)`, which is itself a + valid (if unhappy) result value produced from bytes that *did* parse: + this means the bytes never parsed, so there is no result to hand back. + Callers must treat it the same as any other post-terminal failure -- see + `run_genvm`'s `TerminalResultUnavailable` wrapping -- never as grounds to + start a new run. + """ + + +@dataclass +class ConsumedResult: + """The decoded `consumed_result` blob: a `ResultCode` byte plus calldata.""" + + execution_hash: bytes + result_kind: host_fns.ResultCode + result_data: gvm_calldata.Decoded + result_fingerprint: ResultFingerprint | None = None + result_storage_deltas: list[tuple[bytes, bytes]] = field(default_factory=list) + result_emissions: list[ResultEmission] = field(default_factory=list) + result_leader_public_data: bytes = b"" + data_fees_remaining: list[int] = field(default_factory=list) + + @classmethod + def internal_error(cls, message: str) -> "ConsumedResult": + return cls( + execution_hash=b"", + result_kind=host_fns.ResultCode.INTERNAL_ERROR, + result_data=message, + ) + + @classmethod + def decode(cls, raw: typing.Any) -> "ConsumedResult": + if raw is None: + # The manager never attempted to report a result at all. + return cls.internal_error("no_result") + empty = False + try: + # The socket sends bytes, the deprecated http shim sends base64. + as_bytes = ( + base64.b64decode(raw, validate=True) + if isinstance(raw, str) + else bytes(raw) + ) + empty = not as_bytes + if not empty: + result_kind = host_fns.ResultCode(as_bytes[0]) + if result_kind == host_fns.ResultCode.FATAL_VM_ERROR: + raise ValueError( + "fatal_vm_error crossed the top-level result boundary" + ) + decoded = gvm_calldata.decode(as_bytes[1:]) + except Exception as exc: + # Unreadable bytes are a protocol violation rather than a result, so + # raise instead of returning an `internal_error(...)` value that a + # caller could mistake for a real (if unhappy) execution outcome. + raise ConsumedResultDecodeError( + f"malformed consumed_result ({raw!r:.80}): {exc}" + ) from exc + if empty: + # Distinct from `None`: the manager did send a `consumed_result`, + # but it carries no `ResultCode` byte to read. Still "no usable + # result data" from the caller's point of view. + return cls.internal_error("empty_result") + if not isinstance(decoded, dict): + return cls.internal_error("result is not a mapping") + return cls( + execution_hash=decoded.get("execution_hash", b""), + result_kind=result_kind, + result_data=decoded.get("data"), + result_fingerprint=decoded.get("fingerprint"), + result_storage_deltas=decoded.get("storage_deltas", []), + result_emissions=decoded.get("emissions", []), + result_leader_public_data=decoded.get("leader_public_data", b""), + data_fees_remaining=decoded.get("data_fees_remaining", []), + ) + + @dataclass class RunHostAndProgramRes: stdout: str @@ -369,451 +574,817 @@ class RunHostAndProgramRes: execution_hash: bytes - result_kind: public_abi.ResultCode + result_kind: host_fns.ResultCode result_data: gvm_calldata.Decoded result_fingerprint: ResultFingerprint | None - result_storage_changes: list[tuple[bytes, bytes]] + result_storage_deltas: list[tuple[bytes, bytes]] result_emissions: list[ResultEmission] - result_nondet_results: list[bytes] + result_leader_public_data: bytes + data_fees_remaining: list[int] + metrics: dict[str, typing.Any] | None = None vm_error_description: str | None = None -async def _send_timeout( - manager_uri: str, - genvm_id: str, - ctx: Context, -): - try: - graceful_shutdown_wait_time_ms = ctx.get_timeout( - TimeoutAction.GenVMDelete, TimeoutType.DELETE_HTTP_GRACEFUL_TIMEOUT_MS - ) - if graceful_shutdown_wait_time_ms is None: - graceful_shutdown_wait_time_ms = 20 - else: - graceful_shutdown_wait_time_ms = int(graceful_shutdown_wait_time_ms) - async with aiohttp.request( - "DELETE", - f"{manager_uri}/genvm/{genvm_id}?wait_timeout_ms={graceful_shutdown_wait_time_ms}", - timeout=_http_timeout(ctx, TimeoutAction.GenVMDelete), - ) as resp: - ctx.add_stat("delete_genvm_status", resp.status) - if resp.status != 200: - ctx.add_stat("delete_genvm_failed", True) - ctx.add_stat("delete_genvm_body", await resp.text()) - except (aiohttp.ClientError, asyncio.TimeoutError) as exc: - ctx.add_stat("delete_genvm_request_failed", True) - ctx.add_stat("delete_genvm_request_error", str(exc)) - - -async def _await_first_cancel_others(*it): - _done, pending = await asyncio.wait( - [asyncio.ensure_future(x) for x in it], - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - for task in pending: - try: - await task - except asyncio.CancelledError: - pass +class Frame(typing.NamedTuple): + """One manager socket message: a method, a request id and a calldata payload.""" + method: manager_api.Methods + request_id: int + payload: typing.Any -async def run_genvm( - handler: IHost, - *, - timeout: float | None = None, - manager_uri: str = "http://127.0.0.1:3999", - ctx: Context, - is_sync: bool, - capture_output: bool = True, - message: Message, - host_data: str = "", - host: str, - extra_args: list[str] = [], - data_fees_limit: int = 10_000_000, - storage_page_cost: int = 1, - receipt_word_cost: int = 1, - code: bytes | None = None, - calldata: bytes, - leader_nondet_results: list[bytes] | None = None, - request_extra: dict[str, gvm_calldata.Encodable] = {}, -) -> RunHostAndProgramRes: - logger = ctx.logger - perf_timeline: dict[str, typing.Any] = { - "run_started_s": time.perf_counter(), - } - genvm_id_cell: list[str | None] = [None] - status_cell: list[dict | Exception | None] = [None] - timeout_task_cell: list[asyncio.Task | None] = [None] - cancellation_event = asyncio.Event() +class ManagerSocketError(Exception): + def __init__(self, code: manager_api.Errors, message: str): + self.code = code + self.message = message + super().__init__(f"{code.name}: {message}") - started_at = [time.time()] - async def wrap_proc_body(attempt: int): - max_exec_mins = 20 - if timeout is not None: - max_exec_mins = int(max(max_exec_mins, (timeout * 1.5 + 59) // 60)) +_MANAGER_RECONNECT_ATTEMPTS = 3 +_MANAGER_RECONNECT_BACKOFF_S = 0.05 - timestamp = message.get("datetime", "2024-11-26T06:42:42.424242Z") - async with aiohttp.request( - "POST", - f"{manager_uri}/genvm/run", - data=gvm_calldata.encode( - { - "major": 0, # FIXME - "message": message, - "is_sync": is_sync, - "capture_output": capture_output, - "host_data": host_data, - "max_execution_minutes": max_exec_mins, # this parameter is needed to prevent zombie genvms - "timestamp": timestamp, - "host": host, - "extra_args": extra_args, - "code": code, - "calldata": calldata, - "leader_nondet_results": leader_nondet_results, - "data_fees_limit": data_fees_limit, - "storage_page_cost": storage_page_cost, - "receipt_word_cost": receipt_word_cost, - **request_extra, - } - ), - timeout=_http_timeout(ctx, TimeoutAction.GenVMRun), - ) as resp: - logger.debug("post /genvm/run", status=resp.status, attempt=attempt) - data = await resp.json() - logger.trace("post /genvm/run", body=data) - if resp.status != 200: - logger.error( - f"genvm manager /genvm/run failed", status=resp.status, body=data - ) - raise Exception( - f"genvm manager /genvm/run failed: {resp.status} {data}" - ) - else: - genvm_id = data["id"] - logger.debug( - "genvm manager /genvm", genvm_id=genvm_id, status=resp.status - ) - genvm_id_cell[0] = genvm_id - perf_timeline["genvm_id_obtained_s"] = time.perf_counter() - timeout_task_cell[0] = asyncio.ensure_future(wrap_timeout(genvm_id)) - ctx.on_genvm_success() +class ManagerConnectionLost(Exception): + """ + The manager socket dropped. - async def wrap_proc(): - attempt = 0 - while True: - attempt_start = time.perf_counter() - try: - await wrap_proc_body(attempt) - ctx.add_stat( - "manager_run_attempt_success", - { - "attempt": attempt, - "duration_ms": round( - (time.perf_counter() - attempt_start) * 1000 - ), - }, - ) - break - except (aiohttp.ClientError, asyncio.TimeoutError) as exc: - delay = ctx.retry_delay(TimeoutAction.GenVMRun, attempt) - ctx.add_stat( - f"manager_run_attempt_{attempt}_error", - { - "attempt": attempt, - "error_type": type(exc).__name__, - "duration_ms": round( - (time.perf_counter() - attempt_start) * 1000 - ), - "will_retry": delay is not None, - }, - ) - if delay is None: - logger.error( - "genvm manager request failed after all retries", - error=str(exc), - attempt=attempt, - ) - ctx.on_genvm_failure() - cancellation_event.set() - raise - logger.warning( - "genvm manager request failed, retrying", - error=str(exc), - attempt=attempt, - retry_delay_s=delay, - ) - await asyncio.sleep(delay) - except Exception: - ctx.add_stat( - f"manager_run_attempt_{attempt}_error", - { - "attempt": attempt, - "outcome": "fatal_error", - "duration_ms": round( - (time.perf_counter() - attempt_start) * 1000 - ), - }, - ) - raise - finally: - if genvm_id_cell[0] is not None: - logger.debug("proc started", genvm_id=genvm_id_cell[0]) - attempt += 1 - started_at[0] = time.time() + `generation` is the connection this happened on, so a waiter that wakes up + after somebody else already reconnected can tell that its loss is stale + rather than reconnecting a second time. + """ - async def wrap_host(): - r = await host_loop(handler, cancellation_event, ctx=ctx) - logger.debug("host loop finished") - return r + def __init__(self, message: str, generation: int = -1): + super().__init__(message) + self.generation = generation - timeout_fired = asyncio.Event() - async def wrap_timeout(genvm_id: str): - if timeout is None: - return - await asyncio.sleep(timeout) - logger.debug("timeout reached", genvm_id=genvm_id) - timeout_fired.set() - await _send_timeout( - manager_uri, - genvm_id, - ctx=ctx, - ) +class ManagerRunLost(Exception): + """ + The manager process that owned a run is gone, so the run is unrecoverable. - poll_status_mutex = asyncio.Lock() + Deliberately not a `ManagerConnectionLost`: that one means "reconnect and keep + waiting", and a waiter that treats this as one waits forever for a run no + reconnect can bring back. + """ - async def poll_status(genvm_id: str): - async with poll_status_mutex: - old_status = status_cell[0] - if old_status is not None: - return old_status - try: - async with aiohttp.request( - "GET", - f"{manager_uri}/genvm/{genvm_id}", - timeout=_http_timeout(ctx, TimeoutAction.GenVMGet), - ) as resp: - logger.debug("get /genvm", genvm_id=genvm_id, status=resp.status) - body = await resp.json() - logger.trace("get /genvm", genvm_id=genvm_id, body=body) - if resp.status != 200: - new_res = Exception( - f"genvm manager /genvm failed: {resp.status} {body}" - ) - elif body["status"] is None: - return None - else: - new_res = typing.cast(dict, body["status"]) - except (aiohttp.ClientError, asyncio.TimeoutError) as exc: - new_res = Exception(f"genvm manager /genvm request failed: {exc}") - status_cell[0] = new_res - return new_res - - async def prob_died(): - await _await_first_cancel_others(asyncio.sleep(1), cancellation_event.wait()) - - genvm_id = genvm_id_cell[0] - if genvm_id is None: - return - status = await poll_status(genvm_id) - if status is not None and not cancellation_event.is_set(): - logger.error( - "genvm died without connecting", genvm_id=genvm_id, status=status - ) - cancellation_event.set() - fut_host = asyncio.ensure_future(wrap_host()) - fut_proc = asyncio.ensure_future(wrap_proc()) - fut_prob = asyncio.ensure_future(prob_died()) +class ManagerRunNotStarted(Exception): + """ + A run never reached execution, so retrying it re-executes nothing. + + Raised for the two ways the manager can refuse before the genvm runs -- a + rejected RUN request and a `failed_to_start` terminal. Retry policy belongs + to the caller; `retryable` tells it whether the refusal is transient (the + manager is still bringing modules up, or the socket bounced) rather than a + permanent rejection (malformed request, absent runner, bad calldata). This + is deliberately distinct from a returned `RETURN`/`*_ERROR` result, which + means the genvm did run and must never be blindly retried. + """ - # Map futures to names for debugging - task_names = { - id(fut_host): "host_loop", - id(fut_proc): "genvm_run", - id(fut_prob): "prob_died", - } + def __init__(self, message: str, *, retryable: bool, reason: str): + self.retryable = retryable + self.reason = reason + super().__init__(message) - # IMPORTANT: if proc setup fails (e.g., manager accepts TCP but never replies), - # don't wait forever on host_loop. - try: - done, pending = await asyncio.wait( - [fut_host, fut_proc, fut_prob], return_when=asyncio.FIRST_EXCEPTION + +class TerminalResultUnavailable(Exception): + """ + A run reached a terminal `finished` state, but its result could not be + retrieved or decoded -- artifact transfer failed, or `consumed_result` was + empty/malformed. + + The genvm already executed exactly once for this `genvm_id`; the caller + must never treat this the way it treats a plain `Exception` from + `run_genvm` (which starts a brand new run on retry). Always non-retryable + for that reason -- there is no `retryable` flag to override. + """ + + def __init__(self, message: str, *, genvm_id: int): + self.genvm_id = genvm_id + super().__init__(message) + + +# Transient manager refusals worth retrying. The manager reports these with the +# generic `Errors.INTERNAL` code (no dedicated variant yet), so the message is +# the only discriminator -- kept here so callers never have to string-match. +_RETRYABLE_RUN_REFUSAL_MARKERS: typing.Final = ( + "modules are required but not running", + "modules are required but not all are running", +) + + +def _classify_run_refusal(message: str) -> tuple[bool, str]: + for marker in _RETRYABLE_RUN_REFUSAL_MARKERS: + if marker in message: + return True, "manager_modules_not_running" + return False, "manager_refused" + + +@dataclass +class RunState: + boot_id: int + genvm_id: int + host_genvm_id: str | None + events: asyncio.Queue[dict[str, typing.Any] | BaseException] + terminal: dict[str, typing.Any] | None = None + acked: bool = False + + +class ManagerClient: + def __init__( + self, + manager_uri: str, + *, + connect_timeout: float | None = None, + max_msg_size: int = 64 * 1024 * 1024, + ): + self.manager_uri = manager_uri + self.connect_timeout = connect_timeout + self.max_msg_size = max_msg_size + self.boot_id: int | None = None + self._session: aiohttp.ClientSession | None = None + self._ws: aiohttp.ClientWebSocketResponse | None = None + self._reader_task: asyncio.Task | None = None + self._request_id = 1 + self._pending: dict[int, asyncio.Future[Frame]] = {} + # The protocol identifies a run as (boot_id, genvm_id): ids restart at 1 + # with the manager process, so genvm_id alone aliases across a restart. + self._runs: dict[tuple[int, int], RunState] = {} + self._orphan_events: dict[tuple[int, int], list[dict[str, typing.Any]]] = {} + self._connect_lock = asyncio.Lock() + self._send_lock = asyncio.Lock() + self._reconnect_lock = asyncio.Lock() + # Bumped on every successful connect, so a disconnect can be attributed + # to the connection it happened on. + self._generation = 0 + + @property + def run_states(self) -> dict[tuple[int, int], RunState]: + return self._runs + + def _key(self, genvm_id: int) -> tuple[int, int]: + assert self.boot_id is not None, "no hello received yet" + return (self.boot_id, genvm_id) + + async def __aenter__(self): + await self._ensure_connected() + return self + + async def __aexit__(self, *_args): + await self._close() + + async def disconnect(self) -> None: + await self._close() + + async def run(self, payload: dict[str, typing.Any]) -> RunState: + response = await self._request( + manager_api.Methods.RUN, + {"run": payload}, + retry_on_disconnect=True, ) - except BaseException: - cancellation_event.set() - tasks = [fut_host, fut_proc, fut_prob] - for task in tasks: - if not task.done(): - task.cancel() - for task in tasks: - try: - await task - except BaseException: - pass + genvm_id = int(response["genvm_id"]) + key = self._key(genvm_id) + state = self._runs.get(key) + if state is None: + state = RunState( + boot_id=key[0], + genvm_id=genvm_id, + host_genvm_id=payload.get("host_genvm_id"), + events=asyncio.Queue(), + ) + self._runs[key] = state + for event in self._orphan_events.pop(key, []): + self._queue_event(state, event) + return state + + async def attach(self, boot_id: int, genvm_id: int) -> RunState: + response = await self._request( + manager_api.Methods.ATTACH, + {"attach": {"boot_id": boot_id, "genvm_id": genvm_id}}, + retry_on_disconnect=True, + ) + key = (boot_id, genvm_id) + state = self._runs.get(key) + if state is None: + state = RunState( + boot_id=boot_id, + genvm_id=genvm_id, + host_genvm_id=None, + events=asyncio.Queue(), + ) + self._runs[key] = state + self._queue_event(state, response["snapshot"]) + return state + + def _require_current_generation( + self, boot_id: int, genvm_id: int, *, op: str + ) -> None: + """ + Refuse to act on a run from a manager generation we have since left. + + `CANCEL`/`ACK`/`GET_ARTIFACT` identify a run to the manager by + `genvm_id` alone -- the wire protocol has no `boot_id` field for them + (unlike `ATTACH`, which the manager itself rejects on a mismatch). If + the manager restarted and reused this `genvm_id` for an unrelated run, + sending one of these blind would act on that run instead of a stale + reference to the one this call actually means. + """ + if self.boot_id is not None and boot_id != self.boot_id: + raise ManagerRunLost( + f"refusing to {op} genvm {genvm_id}: it belonged to manager " + f"boot {boot_id}, but the client is now talking to boot " + f"{self.boot_id} -- the id may have been reused by the new " + "process" + ) - timeout_task = timeout_task_cell[0] - if timeout_task is not None and not timeout_task.done(): - timeout_task.cancel() + async def cancel(self, boot_id: int, genvm_id: int) -> None: + self._require_current_generation(boot_id, genvm_id, op="cancel") + await self._request( + manager_api.Methods.CANCEL, + {"cancel": {"genvm_id": genvm_id}}, + retry_on_disconnect=True, + ) + + async def ack(self, boot_id: int, genvm_id: int) -> None: + self._require_current_generation(boot_id, genvm_id, op="ack") + await self._request( + manager_api.Methods.ACK, + {"ack": {"genvm_id": genvm_id}}, + retry_on_disconnect=True, + ) + key = (boot_id, genvm_id) + if state := self._runs.get(key): + state.acked = True + self._runs.pop(key, None) + self._orphan_events.pop(key, None) + + async def get_artifact(self, boot_id: int, genvm_id: int, field: str) -> bytes: + self._require_current_generation(boot_id, genvm_id, op="get_artifact") + offset = 0 + out = bytearray() + while True: + response = await self._request( + manager_api.Methods.GET_ARTIFACT, + { + "get_artifact": { + "genvm_id": genvm_id, + "field": field, + "offset": offset, + "max_len": 256 * 1024, + } + }, + retry_on_disconnect=True, + ) + data = bytes(response["data"]) + out.extend(data) + offset += len(data) + if offset >= int(response["total_len"]): + return bytes(out) + if not data: + raise ManagerConnectionLost("artifact transfer made no progress") + + async def wait_terminal(self, state: RunState) -> dict[str, typing.Any]: + if state.terminal is not None: + return state.terminal + while True: + event = await state.events.get() + if isinstance(event, ManagerConnectionLost): + await self._reconnect_live_runs(event.generation) + continue + if isinstance(event, BaseException): + raise event + for variant in ("failed_to_start", "finished"): + if variant in event: + state.terminal = event + return event + + async def _request( + self, + method: manager_api.Methods, + payload: typing.Any, + *, + retry_on_disconnect: bool, + ) -> typing.Any: + last_error: BaseException | None = None + for attempt in range(_MANAGER_RECONNECT_ATTEMPTS if retry_on_disconnect else 1): + generation = self._generation try: - await timeout_task - except BaseException: - pass - - genvm_id = genvm_id_cell[0] - if genvm_id is not None: - await _send_timeout(manager_uri, genvm_id, ctx=ctx) - raise - - # Log which tasks completed/failed for debugging - done_names = [task_names.get(id(t), "unknown") for t in done] - pending_names = [task_names.get(id(t), "unknown") for t in pending] - logger.debug( - "asyncio.wait returned", - done_tasks=done_names, - pending_tasks=pending_names, - genvm_id=genvm_id_cell[0], - ) + return await self._request_once(method, payload) + except ManagerConnectionLost as exc: + last_error = exc + if attempt + 1 < _MANAGER_RECONNECT_ATTEMPTS and retry_on_disconnect: + await asyncio.sleep(_MANAGER_RECONNECT_BACKOFF_S * (2**attempt)) + await self._reconnect_live_runs(generation) + continue + raise + assert last_error is not None + raise last_error - # If anything errored, stop the host loop. - for task in done: - exc = task.exception() - if exc is not None: - task_name = task_names.get(id(task), "unknown") - logger.error( - "task raised exception", - task_name=task_name, - exception_type=type(exc).__name__, - exception_msg=str(exc), - genvm_id=genvm_id_cell[0], + async def _request_once( + self, + method: manager_api.Methods, + payload: typing.Any, + ) -> typing.Any: + await self._ensure_connected() + assert self._ws is not None + async with self._send_lock: + request_id = self._request_id + self._request_id += 1 + future = asyncio.get_running_loop().create_future() + self._pending[request_id] = future + body = ( + int(method).to_bytes(2, "big") + + request_id.to_bytes(8, "big") + + gvm_calldata.encode(payload) ) - cancellation_event.set() + try: + await self._ws.send_bytes(body) + except ( + BrokenPipeError, + ConnectionError, + aiohttp.ClientConnectionError, + ) as exc: + self._pending.pop(request_id, None) + self._mark_disconnected(ManagerConnectionLost(str(exc))) + raise ManagerConnectionLost(str(exc)) from exc + try: + response = await future + finally: + self._pending.pop(request_id, None) + if response.method != method: + raise ManagerConnectionLost( + f"manager replied with {response.method} for {method}" + ) + return response.payload - # Cancel any pending tasks to prevent leaks - for task in pending: - task.cancel() + async def _ensure_connected(self) -> None: + if self._ws is not None and not self._ws.closed: + return + async with self._connect_lock: + await self._reconnect_locked(-1) + + async def _connect(self) -> None: + timeout = aiohttp.ClientTimeout(connect=self.connect_timeout) + if self.manager_uri.startswith("unix://"): + connector = aiohttp.UnixConnector( + path=self.manager_uri.removeprefix("unix://") + ) + self._session = aiohttp.ClientSession(connector=connector, timeout=timeout) + ws_url = "http://localhost/ws" + else: + self._session = aiohttp.ClientSession(timeout=timeout) + ws_url = urllib.parse.urljoin(self.manager_uri.rstrip("/") + "/", "ws") try: - await task - except asyncio.CancelledError: - pass + self._ws = await self._session.ws_connect( + ws_url, + max_msg_size=self.max_msg_size, + ) + frame = await self._read_frame() + except BaseException: + await self._close() + raise + if frame.method != manager_api.Methods.HELLO or frame.request_id != 0: + await self._close() + raise ManagerConnectionLost("manager did not send hello") + hello = frame.payload["hello"] + if hello["protocol_major"] != manager_api.CURRENT_MAJOR: + await self._close() + raise ManagerConnectionLost( + f'unsupported manager socket protocol {hello["protocol_major"]}' + ) + hello_boot_id = int(hello["boot_id"]) + # Always adopt the generation we are actually talking to. Keeping the old + # one because a run is still around is what let stale state outlive a + # manager restart and alias a reused id. + if self.boot_id is not None and self.boot_id != hello_boot_id: + self._retire_generation(self.boot_id) + self.boot_id = hello_boot_id + self._generation += 1 + self._reader_task = asyncio.create_task(self._reader_loop()) + + def _retire_generation(self, boot_id: int) -> None: + """ + Drops every run belonging to a manager process that is gone. + + Their ids are about to be handed out again by the new process, so a + waiter must learn its run died with its manager rather than silently + binding to an unrelated run of the same number. + """ + for key in [k for k in self._runs if k[0] == boot_id]: + state = self._runs.pop(key) + if not state.acked and state.terminal is None: + state.events.put_nowait( + ManagerRunLost( + f"manager restarted; run {state.genvm_id} of boot {boot_id} is gone" + ) + ) + for key in [k for k in self._orphan_events if k[0] == boot_id]: + del self._orphan_events[key] - # Cancel the timeout task if it's still pending - timeout_task = timeout_task_cell[0] - if timeout_task is not None and not timeout_task.done(): - timeout_task.cancel() + async def _reader_loop(self) -> None: try: - await timeout_task + while True: + frame = await self._read_frame() + if frame.request_id == 0: + self._handle_notification(frame.method, frame.payload) + continue + future = self._pending.pop(frame.request_id, None) + if future is None or future.done(): + continue + if frame.method == manager_api.Methods.ERROR: + code = manager_api.Errors(frame.payload["code"]) + future.set_exception( + ManagerSocketError(code, frame.payload["message"]) + ) + else: + future.set_result(frame) except asyncio.CancelledError: - pass + raise + except BaseException as exc: + self._mark_disconnected(ManagerConnectionLost(str(exc))) + + async def _read_frame(self) -> Frame: + assert self._ws is not None + msg = await self._ws.receive() + if msg.type == aiohttp.WSMsgType.BINARY: + body = bytes(msg.data) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.ERROR, + ): + raise ManagerConnectionLost("manager websocket closed") + else: + raise ManagerConnectionLost(f"unexpected websocket message {msg.type}") + if len(body) < 10: + raise ManagerConnectionLost("manager sent a short message") + method = manager_api.Methods(int.from_bytes(body[:2], "big")) + request_id = int.from_bytes(body[2:10], "big") + payload = gvm_calldata.decode(body[10:]) + return Frame(method, request_id, payload) + + def _handle_notification( + self, method: manager_api.Methods, payload: typing.Any + ) -> None: + if method != manager_api.Methods.EVENT: + return + genvm_id = None + for event in payload.values(): + if isinstance(event, dict) and "genvm_id" in event: + genvm_id = int(event["genvm_id"]) + break + if genvm_id is None: + return + # Events arrive on the live connection, so they belong to its generation. + key = self._key(genvm_id) + state = self._runs.get(key) + if state is None: + self._orphan_events.setdefault(key, []).append(payload) + return + self._queue_event(state, payload) + + def _queue_event(self, state: RunState, event: dict[str, typing.Any]) -> None: + if "failed_to_start" in event or "finished" in event: + state.terminal = event + state.events.put_nowait(event) + + def _mark_disconnected(self, exc: ManagerConnectionLost) -> None: + exc.generation = self._generation + self._ws = None + for future in list(self._pending.values()): + if not future.done(): + future.set_exception(exc) + for state in self._runs.values(): + if not state.acked and state.terminal is None: + state.events.put_nowait(exc) + + async def _reconnect_locked(self, generation: int) -> None: + """ + Rebuilds the connection unless someone already did. Holds `_connect_lock`. + + Every teardown-and-reconnect goes through here, so the two entry points + (`_ensure_connected` and `_reconnect_live_runs`) cannot run `_close()` + and `_connect()` against each other and orphan a reader task, a session + or a socket the other one is installing. + + `generation < 0` means "any live connection will do"; a non-negative one + names the generation the caller saw die, so a connection newer than it + is already the replacement and is left alone. + """ + assert self._connect_lock.locked() + if self._ws is not None and not self._ws.closed: + if generation < 0 or self._generation > generation: + return + await self._close() + await self._connect() + + async def _reconnect_live_runs(self, generation: int) -> None: + """ + Reconnects the connection generation `generation` was lost on. + + Several waiters observe the same disconnect, so whoever gets the lock + second finds a newer generation already connected and has nothing to do. + Reconnecting again there would tear down the working socket the first + waiter just built. + """ + async with self._reconnect_lock: + async with self._connect_lock: + await self._reconnect_locked(generation) + # Re-attaching runs its own requests, which take `_connect_lock` + # themselves, so it happens outside of it. `_reconnect_lock` still + # keeps a second waiter out until this generation is whole again. + # + # A restart already retired the previous generation's runs during + # hello, so whatever is left here belongs to the manager we are now + # talking to and re-attaches under its own boot id. + for state in list(self._runs.values()): + if state.acked: + continue + try: + response = await self._request( + manager_api.Methods.ATTACH, + { + "attach": { + "boot_id": state.boot_id, + "genvm_id": state.genvm_id, + } + }, + retry_on_disconnect=False, + ) + except ManagerSocketError as exc: + state.events.put_nowait(exc) + else: + self._queue_event(state, response["snapshot"]) + + async def _close(self) -> None: + ws = self._ws + self._ws = None + if self._reader_task is not None: + self._reader_task.cancel() + with contextlib.suppress(BaseException): + await self._reader_task + self._reader_task = None + if ws is not None: + with contextlib.suppress(BaseException): + await ws.close() + if self._session is not None: + with contextlib.suppress(BaseException): + await self._session.close() + self._session = None + + +def _duration_string(seconds: float | None) -> str | None: + if seconds is None: + return None + if seconds < 1: + millis = max(1, math.ceil(seconds * 1000)) + return f"{millis}ms" + if float(seconds).is_integer(): + return f"{int(seconds)}s" + return f"{seconds:g}s" - # Collect exceptions from all tasks, including CancelledError - # Note: CancelledError inherits from BaseException, not Exception - exceptions: list[BaseException] = [] - cancelled_tasks: list[str] = [] - try: - fut_host.result() - except asyncio.CancelledError: - cancelled_tasks.append("host_loop") - except ConnectionResetError as e: - if not timeout_fired.is_set(): - logger.warning("connection reset without timeout", error=e) - except BaseException as e: - if not timeout_fired.is_set(): - exceptions.append(e) - else: - logger.warning("host handler failed after timeout", error=e) +def _decode_genvm_log(data: bytes) -> list[dict[str, typing.Any]]: + """ + Decodes the genvm log artifact, which is json lines. - try: - fut_proc.result() - except asyncio.CancelledError: - cancelled_tasks.append("genvm_run") - except BaseException as e: - exceptions.append(e) - - # Log if tasks were cancelled (helps debug root cause) - if cancelled_tasks: - logger.debug( - "tasks were cancelled", - cancelled_tasks=cancelled_tasks, - exception_count=len(exceptions), - genvm_id=genvm_id_cell[0], - ) + Split on newline bytes only. `str.splitlines` also breaks on U+2028, U+2029 + and U+0085, none of which json escapes and all of which therefore appear raw + inside a string value -- splitting there cuts a record in half and raises + `Unterminated string`. Model output reaches this log, so those codepoints do + turn up in practice. + """ + if not data: + return [] + return [json.loads(line) for line in data.split(b"\n") if line.strip()] + + +async def run_genvm( + handler: IHost, + *, + timeout: float | None = None, # noqa: ASYNC109 + manager_uri: str = "http://127.0.0.1:3999", + manager_client: ManagerClient, + ctx: Context, + is_sync: bool, + debug_mode: DebugMode = "disabled", + message: Message, + host_data: str = "", + gas_data: dict[str, str] | None = None, + host: str, + extra_args: collections.abc.Sequence[str] = (), + # default config fee buckets use bucket_no 0 and 1 + bucket_totals: list[int], + code: bytes | None = None, + calldata: bytes, + leader_public_data: bytes | None = None, + message_fee_allocation: collections.abc.Sequence[fees.MessageAllocationNode] = (), + unsafe_overrides: UnsafeOverrides | None = None, + request_extra: collections.abc.Mapping[ + str, gvm_calldata.Encodable + ] = types.MappingProxyType({}), + shutdown_early: asyncio.Event | None = None, + host_hello_data: collections.abc.Sequence[bytes] = (), + major: int | None = None, +) -> RunHostAndProgramRes: + logger = ctx.logger - if len(exceptions) > 0: - # Include cancelled tasks info in the exception message for debugging - error_details = { - "exceptions": [f"{type(e).__name__}: {e}" for e in exceptions], - "cancelled_tasks": cancelled_tasks, - "genvm_id": genvm_id_cell[0], + # `node` fee constants are an ExecutionData field. + effective_gas_data = DEFAULT_GAS_DATA if gas_data is None else gas_data + cancellation_event = asyncio.Event() + host_task = asyncio.create_task(host_loop(handler, cancellation_event, ctx=ctx)) + + client = manager_client + genvm_id: int | None = None + boot_id: int | None = None + cancel_task: asyncio.Task | None = None + terminal_task: asyncio.Task | None = None + terminal_received = False + try: + max_exec_mins = 20 + if timeout is not None: + max_exec_mins = int(max(max_exec_mins, (timeout * 1.5 + 59) // 60)) + timestamp = message.get("transaction_timestamp", "2024-11-26T06:42:42.424242Z") + deadline = _duration_string(timeout) + host_genvm_id = typing.cast(str | None, request_extra.get("host_genvm_id")) + if host_genvm_id is None: + host_genvm_id = f"{time.time_ns()}-{id(asyncio.current_task())}" + request_payload: dict[str, typing.Any] = { + "selector": { + "kind": "major", + "major": ( + await read_contract_major(handler, message) + if major is None + else major + ), + }, + "message": message, + "is_sync": is_sync, + "debug_mode": debug_mode, + "host_data": host_data, + "max_execution_minutes": max_exec_mins, + "timestamp": timestamp, + "host": host, + "extra_args": list(extra_args), + "code": code, + "calldata": calldata, + "leader_public_data": leader_public_data, + "bucket_totals": bucket_totals, + "gas_data": effective_gas_data, + "message_fee_allocation": list(message_fee_allocation), + "initial_time_units_allocation": math.ceil(timeout or 10 * 60), + "unsafe_overrides": ( + unsafe_overrides or UnsafeOverrides() + ).as_request_field(), + "host_genvm_id": host_genvm_id, + "host_hello_data": list(host_hello_data), + **request_extra, } - logger.error("genvm execution failed", **error_details) - raise Exception(f"genvm execution failed: {error_details}") from exceptions[0] - - # If all tasks were cancelled but no exceptions, something went wrong - if cancelled_tasks and len(exceptions) == 0: - error_msg = f"all genvm tasks cancelled without error: cancelled={cancelled_tasks}, genvm_id={genvm_id_cell[0]}" - logger.error(error_msg) - raise Exception(error_msg) - - genvm_id = genvm_id_cell[0] - if genvm_id is not None: - await _send_timeout( - manager_uri, - genvm_id, - ctx=ctx, - ) + if deadline is not None: + request_payload["deadline"] = deadline - status = await poll_status(genvm_id) - if status is None: - exceptions.append(Exception("execution failed: no status")) - elif isinstance(status, Exception): - exceptions.append(status) - if len(exceptions) > 0: - final_exception = Exception("execution failed", exceptions[1:]) - raise final_exception from exceptions[0] - - # Result was sent to manager via consume_result, get it from status - consumed_result_raw = ( - status.get("consumed_result") if isinstance(status, dict) else None + attempt_start = time.perf_counter() + try: + state = await client.run(request_payload) + except Exception as exc: + if isinstance(exc, ManagerConnectionLost): + retryable, reason = True, "manager_connection_lost" + elif isinstance(exc, ManagerSocketError): + retryable, reason = _classify_run_refusal(exc.message) + else: + retryable, reason = False, "manager_run_error" + ctx.add_stat( + "manager_run_attempt_0_error", + { + "attempt": 0, + "error_type": type(exc).__name__, + "duration_ms": round((time.perf_counter() - attempt_start) * 1000), + "retryable": retryable, + "reason": reason, + }, + ) + ctx.on_genvm_failure() + cancellation_event.set() + raise ManagerRunNotStarted( + str(exc), retryable=retryable, reason=reason + ) from exc + genvm_id = state.genvm_id + boot_id = state.boot_id + ctx.add_stat( + "manager_run_attempt_success", + { + "attempt": 0, + "duration_ms": round((time.perf_counter() - attempt_start) * 1000), + }, ) - if consumed_result_raw is not None: - consumed_result_bytes = bytes(consumed_result_raw) - result_kind = public_abi.ResultCode(consumed_result_bytes[0]) - decoded = gvm_calldata.decode(consumed_result_bytes[1:]) - execution_hash = decoded.get("execution_hash", b"") - result_data = decoded.get("data") - result_fingerprint = decoded.get("fingerprint") - result_storage_changes = decoded.get("storage_changes", []) - result_emissions = decoded.get("emissions", []) - nondet_results = decoded.get("nondet_results", []) + logger.debug("genvm manager socket run", genvm_id=genvm_id) + ctx.on_genvm_success() + started_at = time.time() + + async def cancel_on_shutdown(): + assert genvm_id is not None + if shutdown_early is None: + return + await shutdown_early.wait() + logger.debug("shutdown_early event set", genvm_id=genvm_id) + await client.cancel(boot_id, genvm_id) + + if shutdown_early is not None: + cancel_task = asyncio.create_task(cancel_on_shutdown()) + + terminal_task = asyncio.create_task(client.wait_terminal(state)) + while True: + done, _pending = await asyncio.wait( + [terminal_task, host_task], + return_when=asyncio.FIRST_COMPLETED, + ) + if terminal_task in done: + terminal = terminal_task.result() + terminal_received = True + break + if host_task.done(): + host_exc = host_task.exception() + if host_exc is not None: + await client.cancel(boot_id, genvm_id) + cancellation_event.set() + raise host_exc + terminal = await terminal_task + terminal_received = True + break + + cancellation_event.set() + if cancel_task is not None: + cancel_task.cancel() + with contextlib.suppress(BaseException): + await cancel_task + with contextlib.suppress(BaseException): + await host_task + + if "finished" in terminal: + status = terminal["finished"] + sizes = status.get("artifact_sizes") or {} + try: + stdout = ( + (await client.get_artifact(boot_id, genvm_id, "stdout")).decode() + if sizes.get("stdout", 0) + else "" + ) + stderr = ( + (await client.get_artifact(boot_id, genvm_id, "stderr")).decode() + if sizes.get("stderr", 0) + else "" + ) + genvm_log = ( + _decode_genvm_log( + await client.get_artifact(boot_id, genvm_id, "genvm_log") + ) + if sizes.get("genvm_log", 0) + else [] + ) + consumed = ConsumedResult.decode(status.get("consumed_result")) + except Exception as exc: + # The run already executed and finished; a failure retrieving or + # decoding its result must never look like an attempt that + # never happened. That distinction is what keeps the outer + # retry loop (`run_genvm_host`) from starting a second + # execution for a result that already exists. + raise TerminalResultUnavailable( + f"failed to retrieve/decode result for finished run: {exc}", + genvm_id=genvm_id, + ) from exc + if ( + status.get("cause") == "deadline" + and consumed.result_kind != host_fns.ResultCode.RETURN + ): + consumed.result_kind = host_fns.ResultCode.VM_ERROR + consumed.result_data = str(public_abi.VmError.timeout()) else: - execution_hash = b"" - result_kind = public_abi.ResultCode.INTERNAL_ERROR - result_data = "no_result" - result_fingerprint = None - result_storage_changes = [] - result_emissions = [] - nondet_results = [] - - if timeout_fired.is_set() and result_kind != public_abi.ResultCode.RETURN: - result_kind = public_abi.ResultCode.VM_ERROR - result_data = public_abi.VmError.TIMEOUT.value + # A failed_to_start terminal means the genvm was accepted but never + # ran, so surface it through the same not-started seam as a rejected + # RUN -- never as an INTERNAL_ERROR result the caller could mistake + # for a run that executed. + error = terminal["failed_to_start"]["error"] + retryable, reason = _classify_run_refusal(error) + ctx.add_stat( + "manager_run_start_failed", + {"reason": reason, "retryable": retryable}, + ) + # Symmetric with the client.run() rejection branch (and with the + # INTERNAL_ERROR result this used to return): a run that failed to + # start is a manager failure for health tracking. + ctx.on_genvm_failure() + raise ManagerRunNotStarted(error, retryable=retryable, reason=reason) vm_error_description: str | None = None - if result_kind == public_abi.ResultCode.VM_ERROR and isinstance( - result_data, str + if consumed.result_kind == host_fns.ResultCode.VM_ERROR and isinstance( + consumed.result_data, str ): try: async with aiohttp.request( "GET", f"{manager_uri}/vm-error/describe", - params={"error": result_data}, - timeout=_http_timeout(ctx, TimeoutAction.VMErrorDescribe), + params={"error": consumed.result_data}, + timeout=_http_timeout(ctx), ) as resp: if resp.status == 200: body = await resp.json() @@ -822,18 +1393,49 @@ async def prob_died(): logger.warning("failed to get vm error description", error=str(e)) return RunHostAndProgramRes( - stdout=status["stdout"], - stderr=status["stderr"], - genvm_log=status.get("genvm_log") or [], - execution_hash=execution_hash, - result_kind=result_kind, - result_data=result_data, - result_fingerprint=result_fingerprint, - result_storage_changes=result_storage_changes, - result_emissions=result_emissions, - result_nondet_results=nondet_results, + stdout=stdout, + stderr=stderr, + genvm_log=genvm_log, + metrics=status.get("metrics"), + execution_hash=consumed.execution_hash, + result_kind=consumed.result_kind, + result_data=consumed.result_data, + result_fingerprint=consumed.result_fingerprint, + result_storage_deltas=consumed.result_storage_deltas, + result_emissions=consumed.result_emissions, + result_leader_public_data=consumed.result_leader_public_data, + data_fees_remaining=consumed.data_fees_remaining, vm_error_description=vm_error_description, - execution_time=time.time() - started_at[0], + execution_time=time.time() - started_at, ) - - raise Exception("Execution failed") + finally: + if cancel_task is not None and not cancel_task.done(): + cancel_task.cancel() + with contextlib.suppress(BaseException): + await cancel_task + if terminal_task is not None and not terminal_task.done(): + terminal_task.cancel() + with contextlib.suppress(BaseException): + await terminal_task + if genvm_id is not None and terminal_received: + # ack only releases the manager's retention of a run that already + # produced its result and artifacts. Letting it fail out of here + # would turn a finished execution into an attempt error and send + # the caller back through the retry loop to run the contract again. + try: + await client.ack(boot_id, genvm_id) + except Exception as exc: + logger.warning( + "failed to ack a finished genvm run", + genvm_id=genvm_id, + error=str(exc), + ) + elif genvm_id is not None: + with contextlib.suppress(BaseException): + await client.cancel(boot_id, genvm_id) + if not cancellation_event.is_set(): + cancellation_event.set() + if not host_task.done(): + host_task.cancel() + with contextlib.suppress(BaseException): + await host_task diff --git a/backend/node/genvm/origin/calldata.py b/backend/node/genvm/origin/calldata.py index 4c75ba828..565457bce 100644 --- a/backend/node/genvm/origin/calldata.py +++ b/backend/node/genvm/origin/calldata.py @@ -1,19 +1,25 @@ -from ...types import Address - """ -This module is responsible for working with genvm calldata +GenVM calldata encoding and decoding module. + +This module provides: + +* ``encode``: Encode Python objects to calldata bytes +* ``decode``: Decode calldata bytes to Python objects +* ``to_str``: Human-readable string representation +* ``CalldataEncodable``: ABC for custom encoding +* Type aliases: ``Encodable``, ``Decoded``, ``EncodableWithDefault`` Calldata natively supports following types: #. Primitive types: - #. python built-in: :py:class:`bool`, :py:obj:`None`, :py:class:`int`, :py:class:`str`, :py:class:`bytes` - #. :py:meth:`~genlayer.py.types.Address` type + #. python built-in: :py:class:`bool`, :py:obj:`None`, :py:class:`int`, :py:class:`str`, :py:class:`bytes` + #. :py:meth:`~genlayer.types.Address` type #. Composite types: - #. :py:class:`list` (and any other :py:class:`collections.abc.Sequence`) - #. :py:class:`dict` with :py:class:`str` keys (and any other :py:class:`collections.abc.Mapping` with :py:class:`str` keys) + #. :py:class:`list` (and any other :py:class:`collections.abc.Sequence`) + #. :py:class:`dict` with :py:class:`str` keys (and any other :py:class:`collections.abc.Mapping` with :py:class:`str` keys) For full calldata specification see `genvm repo `_ """ @@ -23,18 +29,42 @@ "decode", "to_str", "Encodable", - "Encodable", "EncodableWithDefault", "Decoded", "CalldataEncodable", "DecodingError", ) -import typing +import abc import collections.abc +import contextlib import dataclasses -import abc import json +import typing + + +@contextlib.contextmanager +def context_notes(msg: str): + """ + Helper context manager to add context to exceptions + + .. warning:: + This is a temporary workaround for lack of exception chaining in Python 3.11 + """ + try: + yield + except BaseException as e: + e.add_note(msg) + raise + + +# GenLayer Address. This node already defines the canonical `Address` in +# `backend.node.types`; import it here so the calldata encoder/decoder and the +# node share a single type (a node `Address` in a message must encode as +# SPECIAL_ADDR, and `decode` yields the same type the node uses everywhere). +from backend.node.types import Address as Address + +Address.ZERO = Address(b"\x00" * 20) BITS_IN_TYPE = 3 @@ -65,7 +95,7 @@ def __to_calldata__(self) -> "Encodable": Override this method to return calldata-compatible type .. warning:: - returning ``self`` may lead to an infinite loop or an exception + returning ``self`` may lead to an infinite loop or an exception """ raise NotImplementedError() @@ -99,7 +129,8 @@ def __to_calldata__(self) -> "Encodable": def encode_default_parameter(b): if not dataclasses.is_dataclass(b): return b - assert not isinstance(b, type) + if isinstance(b, type): + raise TypeError(f"expected dataclass instance, got type {b!r}") return {field.name: getattr(b, field.name) for field in dataclasses.fields(b)} @@ -108,6 +139,7 @@ def encode[ T ]( x: EncodableWithDefault[T], + /, *, default: typing.Callable[ [EncodableWithDefault[T]], Encodable @@ -119,16 +151,17 @@ def encode[ :param default: function to be applied to each object recursively, it must return object encodable to calldata .. warning:: - All composite types in the end are coerced to :py:class:`dict` and :py:class:`list`, so custom type information is *not* be preserved. - Such types include: + All composite types in the end are coerced to :py:class:`dict` and :py:class:`list`, so custom type information is *not* be preserved. + Such types include: - #. :py:class:`CalldataEncodable` - #. :py:mod:`dataclasses` + #. :py:class:`CalldataEncodable` + #. :py:mod:`dataclasses` """ mem = bytearray() def append_uleb128(i): - assert i >= 0 + if i < 0: + raise ValueError(f"uleb128 requires non-negative integer, got {i}") if i == 0: mem.append(0) while i > 0: @@ -145,12 +178,13 @@ def impl_dict(b: collections.abc.Mapping): le = (le << 3) | TYPE_MAP append_uleb128(le) for k in keys: - if not isinstance(k, str): - raise TypeError(f"key is not string `{repr(k)}`") - bts = k.encode("utf-8") - append_uleb128(len(bts)) - mem.extend(bts) - impl(b[k]) + with context_notes(f"key {k!r}"): + if not isinstance(k, str): + raise TypeError(f"key is not string {type(k)}") + bts = k.encode("utf-8") + append_uleb128(len(bts)) + mem.extend(bts) + impl(b[k]) def impl(b: EncodableWithDefault[T]): b = default(b) @@ -207,6 +241,7 @@ class DecodingError(ValueError): def decode( mem0: collections.abc.Buffer, + /, *, memview2bytes: typing.Callable[[memoryview], typing.Any] = bytes, ) -> Decoded: @@ -280,7 +315,8 @@ def impl() -> typing.Any: f"unordered calldata keys: `{prev}` >= `{key}`" ) prev = key - assert key not in ret_dict + if key in ret_dict: + raise DecodingError(f"duplicate calldata map key `{key}`") ret_dict[key] = impl() return ret_dict raise DecodingError(f"invalid type {typ}") @@ -291,13 +327,13 @@ def impl() -> typing.Any: return res -def to_str(d: Encodable) -> str: +def to_str(d: Encodable, /) -> str: """ Transforms calldata DSL into human readable json-like format, should be used for debug purposes only """ buf: list[str] = [] - def impl(d: Encodable) -> None: + def impl(d: Encodable, /) -> None: if d is None: buf.append("null") elif d is True: diff --git a/backend/node/genvm/origin/fees.py b/backend/node/genvm/origin/fees.py new file mode 100644 index 000000000..4546185c5 --- /dev/null +++ b/backend/node/genvm/origin/fees.py @@ -0,0 +1,106 @@ +""" +Message-fee allocation tree types. + +Mirrors the executor's `genvm_common::domain::fees` module: the fee parameters +and the nested `MessageAllocationNode` tree that is passed alongside an +execution and matched against emitted messages. +""" + +import typing + +from .calldata import Address + + +class InternalMessageParams(typing.TypedDict): + leader_timeunits_allocation: int + validator_timeunits_allocation: int + execution_budget_per_round: int + # `appealRounds` is not carried here; the chain derives it as + # `len(rotations) - 1`, so `rotations` must be non-empty. + rotations: list[int] + max_price_gen_per_time_unit: int + storage_fee_max_gas_price: int + receipt_fee_max_gas_price: int + + +class ExternalMessageParams(typing.TypedDict): + gas_limit: int + max_gas_price: int + + +# Externally-tagged `MessageAllocationNodeParams` enum: exactly one of the keys. +class _InternalParams(typing.TypedDict): + Internal: InternalMessageParams + + +class _ExternalParams(typing.TypedDict): + External: ExternalMessageParams + + +MessageAllocationNodeParams = typing.Union[_InternalParams, _ExternalParams] + + +class MessageAllocationNode(typing.TypedDict): + recipient: Address | None + call_key: bytes | None + budget: int + # Lifecycle the node matches against (only meaningful for internal messages). + on: typing.Literal["finalized", "decided"] + fee_params: MessageAllocationNodeParams + # Nested allocation subtree; the chain receives this flattened to + # parent-pointer form. + children: list["MessageAllocationNode"] + + +DEFAULT_EXTERNAL_MESSAGE_ALLOC: MessageAllocationNode = { + "budget": 2**200, + "recipient": None, + "call_key": None, + # Unused for external messages (no acceptance/finalize lifecycle). + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": 2**200, + "max_gas_price": 0, + }, + }, + "children": [], +} + +DEFAULT_INTERNAL_DEC_MESSAGE_ALLOC: MessageAllocationNode = { + "budget": 2**200, + "recipient": None, + "call_key": None, + "on": "decided", + "fee_params": { + "Internal": { + "execution_budget_per_round": 2**10, + "rotations": [4] * 5, + "leader_timeunits_allocation": 5, + "validator_timeunits_allocation": 5, + "max_price_gen_per_time_unit": 2**200, + "storage_fee_max_gas_price": 2**200, + "receipt_fee_max_gas_price": 2**200, + }, + }, + "children": [], +} + +DEFAULT_INTERNAL_FIN_MESSAGE_ALLOC: MessageAllocationNode = { + "budget": 2**200, + "recipient": None, + "call_key": None, + "on": "finalized", + "fee_params": { + "Internal": { + "execution_budget_per_round": 2**10, + "rotations": [4] * 5, + "leader_timeunits_allocation": 5, + "validator_timeunits_allocation": 5, + "max_price_gen_per_time_unit": 2**200, + "storage_fee_max_gas_price": 20, + "receipt_fee_max_gas_price": 20, + }, + }, + "children": [], +} diff --git a/backend/node/genvm/origin/host_fns.py b/backend/node/genvm/origin/host_fns.py index cd7e5dd84..ac74c9ea1 100644 --- a/backend/node/genvm/origin/host_fns.py +++ b/backend/node/genvm/origin/host_fns.py @@ -1,22 +1,39 @@ # This file is auto-generated. Do not edit! +# fmt: off +# ruff: noqa + +import typing from enum import IntEnum class Methods(IntEnum): STORAGE_READ = 0 - STORAGE_WRITE = 1 - CONSUME_FUEL = 2 - ETH_CALL = 3 - GET_BALANCE = 4 - REMAINING_FUEL_AS_GEN = 5 - NOTIFY_NONDET_DISAGREEMENT = 6 - CONSUME_RESULT = 7 - NOTIFY_FINISHED = 8 + CONSUME_TIME_FEE_GEN_WEI = 1 + EXTERNAL_CALL = 2 + GET_BALANCE_GEN_WEI = 3 + GET_REMAINING_TIME_FEE_GEN_WEI = 4 + NOTIFY_NONDET_DISAGREEMENT = 5 + CONSUME_RESULT = 6 + RESOLVE_CALL_CONTRACT_EXECUTOR = 7 + RUN_NESTED = 8 + + +class ResultCode(IntEnum): + RETURN = 0 + USER_ERROR = 1 + VM_ERROR = 2 + INTERNAL_ERROR = 3 + FATAL_VM_ERROR = 4 class Errors(IntEnum): OK = 0 - ABSENT = 1 + EVM_REVERTED = 1 FORBIDDEN = 2 - OUT_OF_STORAGE_GAS = 3 + + +CURRENT_MAJOR: typing.Final[int] = 0 + + +CURRENT_MAJOR_STR: typing.Final[str] = "v0.0.0" diff --git a/backend/node/genvm/origin/keccak.py b/backend/node/genvm/origin/keccak.py new file mode 100644 index 000000000..959704c4b --- /dev/null +++ b/backend/node/genvm/origin/keccak.py @@ -0,0 +1,439 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# NOTE: source code from https://github.com/ctz/keccak +# some code was truncated for use in this project +""" +Implements keccak hash + +Source code is taken from `https://github.com/ctz/keccak `_ (Apache 2.0 license) +""" + +__all__ = ("Keccak256", "KeccakHash") + +import collections.abc +from copy import deepcopy +from functools import reduce +from math import log +from operator import xor + +# The Keccak-f round constants. +RoundConstants = [ + 0x0000000000000001, + 0x0000000000008082, + 0x800000000000808A, + 0x8000000080008000, + 0x000000000000808B, + 0x0000000080000001, + 0x8000000080008081, + 0x8000000000008009, + 0x000000000000008A, + 0x0000000000000088, + 0x0000000080008009, + 0x000000008000000A, + 0x000000008000808B, + 0x800000000000008B, + 0x8000000000008089, + 0x8000000000008003, + 0x8000000000008002, + 0x8000000000000080, + 0x000000000000800A, + 0x800000008000000A, + 0x8000000080008081, + 0x8000000000008080, + 0x0000000080000001, + 0x8000000080008008, +] + +RotationConstants = [ + [0, 1, 62, 28, 27], + [36, 44, 6, 55, 20], + [3, 10, 43, 25, 39], + [41, 45, 15, 21, 8], + [18, 2, 61, 56, 14], +] + +Masks = [(1 << i) - 1 for i in range(65)] + + +def bits2bytes(x): + return (int(x) + 7) // 8 + + +def rol(value, left, bits): + """ + Circularly rotate 'value' to the left, + treating it as a quantity of the given size in bits. + """ + top = value >> (bits - left) + bot = (value & Masks[bits - left]) << left + return bot | top + + +def ror(value, right, bits): + """ + Circularly rotate 'value' to the right, + treating it as a quantity of the given size in bits. + """ + top = value >> right + bot = (value & Masks[right]) << (bits - right) + return bot | top + + +def multirate_padding(used_bytes, align_bytes): + """ + The Keccak padding function. + """ + padlen = align_bytes - used_bytes + if padlen == 0: + padlen = align_bytes + # note: padding done in 'internal bit ordering', wherein LSB is leftmost + if padlen == 1: + return [0x81] + else: + return [0x01] + ([0x00] * (padlen - 2)) + [0x80] + + +def sha_padding(used_bytes, align_bytes): + """ + The SHA3 padding function + """ + padlen = align_bytes - (used_bytes % align_bytes) + if padlen == 1: + return [0x86] + elif padlen == 2: + return [0x06, 0x80] + else: + return [0x06] + ([0x00] * (padlen - 2)) + [0x80] + + +def shake_padding(used_bytes, align_bytes): + """ + The SHAKE padding function + """ + padlen = align_bytes - (used_bytes % align_bytes) + if padlen == 1: + return [0x9F] + elif padlen == 2: + return [0x1F, 0x80] + else: + return [0x1F] + ([0x00] * (padlen - 2)) + [0x80] + + +def keccak_f(state): + """ + This is Keccak-f permutation. It operates on and + mutates the passed-in KeccakState. It returns nothing. + """ + + def keccak_round(a, rc): + w, h = state.W, state.H + rangew, rangeh = state.rangeW, state.rangeH + lanew = state.lanew + zero = state.zero + + # theta + c = [reduce(xor, a[x]) for x in rangew] + d = [0] * w + for x in rangew: + d[x] = c[(x - 1) % w] ^ rol(c[(x + 1) % w], 1, lanew) + for y in rangeh: + a[x][y] ^= d[x] + + # rho and pi + b = zero() + for x in rangew: + for y in rangeh: + b[y % w][(2 * x + 3 * y) % h] = rol( + a[x][y], RotationConstants[y][x], lanew + ) + + # chi + for x in rangew: + for y in rangeh: + a[x][y] = b[x][y] ^ ((~b[(x + 1) % w][y]) & b[(x + 2) % w][y]) + + # iota + a[0][0] ^= rc + + nr = 12 + 2 * int(log(state.lanew, 2)) + + for ir in range(nr): + keccak_round(state.s, RoundConstants[ir]) + + +class KeccakState: + """ + A keccak state container. + + The state is stored as a 5x5 table of integers. + """ + + __slots__ = ("lanew", "bitrate", "b", "s", "bitrate_bytes") + + W = 5 + H = 5 + + rangeW = range(W) + rangeH = range(H) + + @staticmethod + def zero(): + """ + Returns an zero state table. + """ + return [[0] * KeccakState.W for _ in KeccakState.rangeH] + + @staticmethod + def format(st): + """ + Formats the given state as hex, in natural byte order. + """ + rows = [] + + def fmt(stx): + return "%016x" % stx + + for y in KeccakState.rangeH: + row = [] + for x in KeccakState.rangeW: + row.append(fmt(st[x][y])) + rows.append(" ".join(row)) + return "\n".join(rows) + + @staticmethod + def lane2bytes(s, w): + """ + Converts the lane s to a sequence of byte values, + assuming a lane is w bits. + """ + o = [] + for b in range(0, w, 8): + o.append((s >> b) & 0xFF) + return o + + @staticmethod + def bytes2lane(bb): + """ + Converts a sequence of byte values to a lane. + """ + r = 0 + for b in reversed(bb): + r = r << 8 | b + return r + + @staticmethod + def ilist2bytes(bb): + """ + Converts a sequence of byte values to a bytestring. + """ + return bytes(bb) + + @staticmethod + def bytes2ilist(ss): + """ + Converts a string or bytestring to a sequence of byte values. + """ + return map(ord, ss) if isinstance(ss, str) else list(ss) + + def __init__(self, bitrate, b): + self.bitrate = bitrate + self.b = b + + # only byte-aligned + assert self.bitrate % 8 == 0 + self.bitrate_bytes = bits2bytes(self.bitrate) + + assert self.b % 25 == 0 + self.lanew = self.b // 25 + + self.s = KeccakState.zero() + + def __str__(self): + return KeccakState.format(self.s) + + def absorb(self, bb): + """ + Mixes in the given bitrate-length string to the state. + """ + assert len(bb) == self.bitrate_bytes + + bb += [0] * bits2bytes(self.b - self.bitrate) + i = 0 + + for y in self.rangeH: + for x in self.rangeW: + self.s[x][y] ^= KeccakState.bytes2lane(bb[i : i + 8]) + i += 8 + + def squeeze(self): + """ + Returns the bitrate-length prefix of the state to be output. + """ + return self.get_bytes()[: self.bitrate_bytes] + + def get_bytes(self): + """ + Convert whole state to a byte string. + """ + out = [0] * bits2bytes(self.b) + i = 0 + for y in self.rangeH: + for x in self.rangeW: + v = KeccakState.lane2bytes(self.s[x][y], self.lanew) + out[i : i + 8] = v + i += 8 + return out + + def set_bytes(self, bb): + """ + Set whole state from byte string, which is assumed + to be the correct length. + """ + i = 0 + for y in self.rangeH: + for x in self.rangeW: + self.s[x][y] = KeccakState.bytes2lane(bb[i : i + 8]) + i += 8 + + +class KeccakSponge: + __slots__ = ("state", "padfn", "permfn", "buffer") + + def __init__(self, bitrate, width, padfn, permfn): + self.state = KeccakState(bitrate, width) + self.padfn = padfn + self.permfn = permfn + self.buffer = [] + + def copy(self): + return deepcopy(self) + + def absorb_block(self, bb): + assert len(bb) == self.state.bitrate_bytes + self.state.absorb(bb) + self.permfn(self.state) + + def absorb(self, s): + self.buffer += s + + while len(self.buffer) >= self.state.bitrate_bytes: + self.absorb_block(self.buffer[: self.state.bitrate_bytes]) + self.buffer = self.buffer[self.state.bitrate_bytes :] + + def absorb_final(self): + padded = self.buffer + self.padfn(len(self.buffer), self.state.bitrate_bytes) + self.absorb_block(padded) + self.buffer = [] + + def squeeze_once(self): + rc = self.state.squeeze() + self.permfn(self.state) + return rc + + def squeeze(self, l): + z = self.squeeze_once() + while len(z) < l: + z += self.squeeze_once() + return z[:l] + + +class KeccakHash: + """ + The Keccak hash function, with a hashlib-compatible interface. + """ + + __slots__ = ("sponge", "digest_size", "block_size") + + def __init__(self, bitrate_bits: int, capacity_bits: int, output_bits: int): + """ + Create a new Keccak hash instance. + + :param bitrate_bits: bitrate in bits + :param capacity_bits: capacity in bits + :param output_bits: output length in bits (must be divisible by 8) + """ + # our in-absorption sponge. this is never given padding + assert bitrate_bits + capacity_bits in (25, 50, 100, 200, 400, 800, 1600) + self.sponge = KeccakSponge( + bitrate_bits, bitrate_bits + capacity_bits, multirate_padding, keccak_f + ) + + # hashlib interface members + assert output_bits % 8 == 0 + self.digest_size = bits2bytes(output_bits) + self.block_size = bits2bytes(bitrate_bits) + + def __repr__(self): + inf = ( + self.sponge.state.bitrate, + self.sponge.state.b - self.sponge.state.bitrate, + self.digest_size * 8, + ) + return "" % inf + + def copy(self) -> "KeccakHash": + """Return a copy of this hash object.""" + return deepcopy(self) + + def update(self, s: collections.abc.Buffer, /) -> None: + """ + Feed data into the hash. + + :param s: bytes-like data to absorb + """ + self.sponge.absorb(s) + + def digest(self) -> bytes: + """ + Return the digest of the data fed so far. + + :returns: hash digest as bytes + """ + finalised = self.sponge.copy() + finalised.absorb_final() + digest = finalised.squeeze(self.digest_size) + return KeccakState.ilist2bytes(digest) + + def hexdigest(self) -> str: + """ + Return the hex-encoded digest of the data fed so far. + + :returns: hash digest as hex string + """ + return self.digest().hex() + + @staticmethod + def preset(bitrate_bits, capacity_bits, output_bits): + """ + Returns a factory function for the given bitrate, sponge capacity and output length. + The function accepts an optional initial input, ala hashlib. + """ + + def create(initial_input: collections.abc.Buffer | None = None): + h = KeccakHash(bitrate_bits, capacity_bits, output_bits) + if initial_input is not None: + h.update(initial_input) + return h + + return create + + +Keccak256 = KeccakHash.preset(1088, 512, 256) +""" +Default preset for Keccak hash that is the same as one used in eth +""" diff --git a/backend/node/genvm/origin/leader_public_data.py b/backend/node/genvm/origin/leader_public_data.py new file mode 100644 index 000000000..85b837c07 --- /dev/null +++ b/backend/node/genvm/origin/leader_public_data.py @@ -0,0 +1,98 @@ +import collections.abc +from dataclasses import dataclass +from typing import Self + +_PADDING = b"padded" + + +@dataclass +class LeaderPublicData: + nondet_block_outputs: list[bytes] + + def encode(self) -> bytes: + payload = b"".join( + _encode_bytes(value) for value in (*self.nondet_block_outputs, _PADDING) + ) + return _encode_length(len(payload), 0xC0, 0xF7) + payload + + @classmethod + def decode(cls, encoded: collections.abc.Buffer) -> Self: + data = bytes(encoded) + if not data: + return cls([]) + + payload_start, payload_len = _decode_length(data, 0, list_=True) + payload_end = payload_start + payload_len + if payload_end != len(data): + raise ValueError("trailing leader public data") + + outputs: list[bytes] = [] + cursor = payload_start + while cursor < payload_end: + value_start, value_len = _decode_length(data, cursor, list_=False) + value_end = value_start + value_len + if value_end > payload_end: + raise ValueError("leader public data item exceeds list") + outputs.append(data[value_start:value_end]) + cursor = value_end + + if not outputs or outputs[-1] != _PADDING: + raise ValueError("leader public data padding is missing") + return cls(outputs[:-1]) + + +def encode(value: LeaderPublicData) -> bytes: + return value.encode() + + +def decode(encoded: collections.abc.Buffer) -> LeaderPublicData: + return LeaderPublicData.decode(encoded) + + +def _encode_bytes(value: bytes) -> bytes: + if len(value) == 1 and value[0] < 0x80: + return value + return _encode_length(len(value), 0x80, 0xB7) + value + + +def _encode_length(length: int, short_base: int, long_base: int) -> bytes: + if length <= 55: + return bytes([short_base + length]) + length_bytes = length.to_bytes((length.bit_length() + 7) // 8, "big") + return bytes([long_base + len(length_bytes)]) + length_bytes + + +def _decode_length( + encoded: bytes, + offset: int, + *, + list_: bool, +) -> tuple[int, int]: + if offset >= len(encoded): + raise ValueError("truncated leader public data") + + prefix = encoded[offset] + short_base = 0xC0 if list_ else 0x80 + long_base = 0xF7 if list_ else 0xB7 + if not list_ and prefix < 0x80: + return offset, 1 + if prefix < short_base or prefix > long_base + 8: + raise ValueError("invalid RLP prefix") + if prefix <= long_base: + if not list_ and prefix == 0x81 and offset + 1 < len(encoded): + if encoded[offset + 1] < 0x80: + raise ValueError("non-canonical RLP string") + return offset + 1, prefix - short_base + + length_len = prefix - long_base + length_start = offset + 1 + length_end = length_start + length_len + if length_end > len(encoded): + raise ValueError("truncated RLP length") + length_bytes = encoded[length_start:length_end] + if length_bytes[0] == 0: + raise ValueError("non-canonical RLP length") + length = int.from_bytes(length_bytes, "big") + if length <= 55: + raise ValueError("non-canonical long RLP value") + return length_end, length diff --git a/backend/node/genvm/origin/log_asserts.py b/backend/node/genvm/origin/log_asserts.py new file mode 100644 index 000000000..4f955362d --- /dev/null +++ b/backend/node/genvm/origin/log_asserts.py @@ -0,0 +1,122 @@ +""" +Assertions over the executor's structured log records. + +The manager captures every executor-emitted log line and surfaces them as +``RunHostAndProgramRes.genvm_log`` — a list of JSON objects shaped like +``{"message": ..., "target": ..., ...}``. Capture is *unbounded* +under ``debug_mode >= safe-unbounded`` (integration tests run ``unsafe``), so no +record is evicted regardless of how chatty the run is. + +The load action emits one stable ``"runner load"`` record per load, carrying: + +- ``runner`` — the canonical runner id (``chain:``/``custom:``/``name:hash``); +- ``runner_load_cost`` — the flat per-load memory limiter constant; +- ``size`` — the charged content size (archive ``total_size``); +- ``status`` — ``"charged"`` (first load in this VM) or ``"cached"`` + (already in the VM's loaded set — free). + +This module is deliberately message-agnostic: a matcher's ``message`` defaults +to ``"runner load"`` but may name any message, so the same machinery serves +future charge-related log lines. + +Numeric fields (``size``, ``runner_load_cost``) are emitted by the executor as JSON +*strings* (the logger's default ``Display`` capture), so every comparison here +is done on the string form — an assertion may write ``4096`` or ``'4096'`` +interchangeably. + +Assertion schema entries may include ``match`` for subset matching over record +fields, ``runner_prefix`` to isolate runner ids such as ``custom:``, count bounds +(``count``/``min``/``max``), and ``size_is_code_len`` for init steps whose load +size must equal the raw contract code length. + +`check` returns a list of human-readable failure strings (empty == all passed). +`extract` is a convenience returning the matching records for eyeballing. +""" + +import typing + +DEFAULT_MESSAGE = "runner load" + + +def _record_matches(record: dict, crit: dict, runner_prefix: str | None) -> bool: + for k, v in crit.items(): + if str(record.get(k)) != str(v): + return False + if runner_prefix is not None: + runner = record.get("runner") + if not isinstance(runner, str) or not runner.startswith(runner_prefix): + return False + return True + + +def _select(genvm_log: list[dict], crit: dict, runner_prefix: str | None) -> list[dict]: + crit = dict(crit) + crit.setdefault("message", DEFAULT_MESSAGE) + return [r for r in genvm_log if _record_matches(r, crit, runner_prefix)] + + +def extract( + genvm_log: list[dict], + *, + match: dict | None = None, + runner_prefix: str | None = None, +) -> list[dict]: + """ + Return the ``genvm_log`` records matching ``match`` (defaulting to the + ``runner load`` message) and an optional ``runner`` prefix.""" + return _select(genvm_log, match or {}, runner_prefix) + + +def check( + genvm_log: list[dict], + asserts: list[dict], + *, + code_len: int | None = None, +) -> list[str]: + """Evaluate ``asserts`` against ``genvm_log``; return a list of failures.""" + errors: list[str] = [] + for i, a in enumerate(asserts): + crit = a.get("match", {}) + runner_prefix = a.get("runner_prefix") + matched = _select(genvm_log, crit, runner_prefix) + n = len(matched) + + label = f"assert[{i}] match={crit}" + if runner_prefix is not None: + label += f" runner_prefix={runner_prefix!r}" + + if "count" in a and n != a["count"]: + errors.append(f'{label}: expected count {a["count"]}, got {n}') + if "min" in a and n < a["min"]: + errors.append(f'{label}: expected at least {a["min"]}, got {n}') + if "max" in a and n > a["max"]: + errors.append(f'{label}: expected at most {a["max"]}, got {n}') + + if a.get("size_is_code_len"): + if code_len is None: + errors.append( + f"{label}: size_is_code_len set but this step ships no code" + ) + else: + bad = [r for r in matched if str(r.get("size")) != str(code_len)] + if bad: + got = [r.get("size") for r in bad] + errors.append( + f"{label}: expected size == code_len {code_len}, got {got}" + ) + return errors + + +def summarize(genvm_log: list[dict]) -> list[dict]: + """Compact view of every ``runner load`` record, for failure context.""" + out = [] + for r in _select(genvm_log, {}, None): + out.append( + { + "runner": r.get("runner"), + "runner_load_cost": r.get("runner_load_cost"), + "size": r.get("size"), + "status": r.get("status"), + } + ) + return typing.cast(list[dict], out) diff --git a/backend/node/genvm/origin/logger.py b/backend/node/genvm/origin/logger.py index c833441a0..9b429235f 100644 --- a/backend/node/genvm/origin/logger.py +++ b/backend/node/genvm/origin/logger.py @@ -1,4 +1,5 @@ import abc +import collections.abc import json import sys import traceback @@ -27,9 +28,6 @@ def with_keys(self, keys: dict) -> "Logger": return _WithKeysLogger(self, keys) -import collections.abc - - def _log_unwrap(x, seen: set[int]): if isinstance(x, bytes): return x.hex() @@ -38,13 +36,12 @@ def _log_unwrap(x, seen: set[int]): if isinstance(x, (str, int, float, bool)): return x if isinstance(x, BaseException): - tb = traceback.format_exception(x) return _log_unwrap( { "message": x.args[0] if len(x.args) == 1 else x.args, "type": x.__class__.__name__, "notes": getattr(x, "__notes__", []), - "traceback": tb, + "traceback": traceback.format_exception(x), }, seen, ) @@ -53,11 +50,13 @@ def _log_unwrap(x, seen: set[int]): return f"<{x_id}>" seen.add(x_id) if isinstance(x, dict): - return {k: _log_unwrap(v, seen) for k, v in x.items()} - if isinstance(x, collections.abc.Sequence): - return [_log_unwrap(v, seen) for v in x] + res = {k: _log_unwrap(v, seen) for k, v in x.items()} + elif isinstance(x, collections.abc.Sequence): + res = [_log_unwrap(v, seen) for v in x] + else: + res = repr(x) seen.remove(x_id) - return repr(x) + return res class _WithKeysLogger(Logger): diff --git a/backend/node/genvm/origin/manager_api.py b/backend/node/genvm/origin/manager_api.py new file mode 100644 index 000000000..73af758dd --- /dev/null +++ b/backend/node/genvm/origin/manager_api.py @@ -0,0 +1,30 @@ +# This file is auto-generated. Do not edit! + +# fmt: off +# ruff: noqa + +import typing +from enum import IntEnum + +class Methods(IntEnum): + ERROR = 0 + HELLO = 1 + EVENT = 2 + RUN = 3 + ATTACH = 4 + CANCEL = 5 + ACK = 6 + GET_ARTIFACT = 7 + + +class Errors(IntEnum): + INTERNAL = 0 + MALFORMED_FRAME = 1 + UNKNOWN_METHOD = 2 + UNKNOWN_ID = 3 + BOOT_ID_MISMATCH = 4 + BAD_REQUEST_ID = 5 + NOT_FINISHED = 6 + + +CURRENT_MAJOR: typing.Final[int] = 0 diff --git a/backend/node/genvm/origin/public_abi.py b/backend/node/genvm/origin/public_abi.py index 06b289e5d..e03cd475d 100644 --- a/backend/node/genvm/origin/public_abi.py +++ b/backend/node/genvm/origin/public_abi.py @@ -1,20 +1,22 @@ # This file is auto-generated. Do not edit! -from enum import IntEnum, StrEnum +# fmt: off +# ruff: noqa + import typing +from enum import IntEnum, StrEnum class ResultCode(IntEnum): RETURN = 0 USER_ERROR = 1 VM_ERROR = 2 - INTERNAL_ERROR = 3 -class StorageType(IntEnum): +class StorageView(IntEnum): DEFAULT = 0 - LATEST_FINAL = 1 - LATEST_NON_FINAL = 2 + LATEST_FINALIZED = 1 + LATEST_DECIDED = 2 class EntryKind(IntEnum): @@ -23,10 +25,21 @@ class EntryKind(IntEnum): CONSENSUS_STAGE = 2 -class MemoryLimiterConsts(IntEnum): - TABLE_ENTRY = 64 - FILE_MAPPING = 256 - FD_ALLOCATION = 96 +class Permissions(IntEnum): + CAN_USE_BALANCE_FOR_MESSAGE_FEES = 1 + + +class _RootOffsets(typing.NamedTuple): + MAJOR: int = 0 + CONTRACT: int = 1 + CODE: int = 2 + LOCKED_SLOTS: int = 3 + UPGRADERS: int = 4 + CODE_SLOT: int = 5 + PERMISSIONS: int = 37 + + +root_offsets: typing.Final = _RootOffsets() class SpecialMethod(StrEnum): @@ -34,19 +47,356 @@ class SpecialMethod(StrEnum): ERRORED_MESSAGE = "#error" -class VmError(StrEnum): - TIMEOUT = "timeout" - EXIT_CODE = "exit_code" - VALIDATOR_DISAGREES = "validator_disagrees" - VERSION_TOO_BIG = "version_too_big" - OOM = "OOM" - INVALID_CONTRACT = "invalid_contract" +class _VmErrorLeaderFaultNondetOutput: + @staticmethod + def absent() -> "VmError": + return VmError("leader_fault nondet_output absent") + @staticmethod + def malformed() -> "VmError": + return VmError("leader_fault nondet_output malformed") -EVENT_MAX_TOPICS: typing.Final[int] = 4 + @staticmethod + def uses_this_error() -> "_VmErrorLeaderFaultNondetOutputUsesThisError": + return _VmErrorLeaderFaultNondetOutputUsesThisError() + + @staticmethod + def extra() -> "_VmErrorLeaderFaultNondetOutputExtra": + return _VmErrorLeaderFaultNondetOutputExtra() + + +class _VmErrorLeaderFaultNondetOutputUsesThisError: + @staticmethod + def val_str(v: str) -> "VmError": + return VmError(f"leader_fault nondet_output uses_this_error {v}") + + +class _VmErrorLeaderFaultNondetOutputExtra: + @staticmethod + def val_str(v: str) -> "VmError": + return VmError(f"leader_fault nondet_output extra {v}") + + +class _VmErrorLeaderFault: + @staticmethod + def nondet_output() -> "_VmErrorLeaderFaultNondetOutput": + return _VmErrorLeaderFaultNondetOutput() + + +class _VmErrorWasmTrap: + @staticmethod + def val() -> "VmError": + return VmError("wasm_trap") + + @staticmethod + def unreachable() -> "VmError": + return VmError("wasm_trap unreachable") + + @staticmethod + def stack_overflow() -> "VmError": + return VmError("wasm_trap stack_overflow") + + @staticmethod + def memory_out_of_bounds() -> "VmError": + return VmError("wasm_trap memory_out_of_bounds") + + @staticmethod + def table_out_of_bounds() -> "VmError": + return VmError("wasm_trap table_out_of_bounds") + + @staticmethod + def indirect_call_to_null() -> "VmError": + return VmError("wasm_trap indirect_call_to_null") + + @staticmethod + def bad_signature() -> "VmError": + return VmError("wasm_trap bad_signature") + + @staticmethod + def integer_overflow() -> "VmError": + return VmError("wasm_trap integer_overflow") + + @staticmethod + def integer_divide_by_zero() -> "VmError": + return VmError("wasm_trap integer_divide_by_zero") + + @staticmethod + def bad_conversion_to_integer() -> "VmError": + return VmError("wasm_trap bad_conversion_to_integer") + + @staticmethod + def heap_misaligned() -> "VmError": + return VmError("wasm_trap heap_misaligned") + + @staticmethod + def atomic_wait_non_shared_memory() -> "VmError": + return VmError("wasm_trap atomic_wait_non_shared_memory") + + @staticmethod + def out_of_fuel() -> "VmError": + return VmError("wasm_trap out_of_fuel") + + @staticmethod + def interrupt() -> "VmError": + return VmError("wasm_trap interrupt") + + @staticmethod + def nondet_instruction() -> "VmError": + return VmError("wasm_trap nondet_instruction") + + @staticmethod + def fault() -> "VmError": + return VmError("wasm_trap fault") + + +class _VmErrorOutOfMemory: + @staticmethod + def val() -> "VmError": + return VmError("out_of memory") + + @staticmethod + def wasm_memory() -> "VmError": + return VmError("out_of memory wasm_memory") + + @staticmethod + def wasm_table() -> "VmError": + return VmError("out_of memory wasm_table") + + +class _VmErrorOutOfReceiptMessage: + @staticmethod + def val() -> "VmError": + return VmError("out_of receipt message") + + @staticmethod + def internal() -> "VmError": + return VmError("out_of receipt message # internal") + + +class _VmErrorOutOfReceipt: + @staticmethod + def nondet_output() -> "VmError": + return VmError("out_of receipt nondet_output") + + @staticmethod + def event() -> "VmError": + return VmError("out_of receipt event") + + @staticmethod + def message() -> "_VmErrorOutOfReceiptMessage": + return _VmErrorOutOfReceiptMessage() + + +class _VmErrorOutOfMessageFeeTotal: + @staticmethod + def val() -> "VmError": + return VmError("out_of message_fee total") + + @staticmethod + def internal() -> "VmError": + return VmError("out_of message_fee total # internal") + + @staticmethod + def external() -> "VmError": + return VmError("out_of message_fee total # external") + + +class _VmErrorOutOfMessageFeeAllocationBudget: + @staticmethod + def val() -> "VmError": + return VmError("out_of message_fee allocation_budget") + + @staticmethod + def internal() -> "VmError": + return VmError("out_of message_fee allocation_budget # internal") + + @staticmethod + def external() -> "VmError": + return VmError("out_of message_fee allocation_budget # external") -ABSENT_VERSION: typing.Final[str] = "v0.1.0" +class _VmErrorOutOfMessageFee: + @staticmethod + def total() -> "_VmErrorOutOfMessageFeeTotal": + return _VmErrorOutOfMessageFeeTotal() + @staticmethod + def allocation_budget() -> "_VmErrorOutOfMessageFeeAllocationBudget": + return _VmErrorOutOfMessageFeeAllocationBudget() -CODE_SLOT_OFFSET: typing.Final[int] = 1 + +class _VmErrorOutOf: + @staticmethod + def storage() -> "VmError": + return VmError("out_of storage") + + @staticmethod + def subvm_recursion() -> "VmError": + return VmError("out_of subvm_recursion") + + @staticmethod + def nondet_blocks() -> "VmError": + return VmError("out_of nondet_blocks") + + @staticmethod + def locked_slots() -> "VmError": + return VmError("out_of locked_slots") + + @staticmethod + def upgraders() -> "VmError": + return VmError("out_of upgraders") + + @staticmethod + def fds() -> "VmError": + return VmError("out_of fds") + + @staticmethod + def memory() -> "_VmErrorOutOfMemory": + return _VmErrorOutOfMemory() + + @staticmethod + def receipt() -> "_VmErrorOutOfReceipt": + return _VmErrorOutOfReceipt() + + @staticmethod + def message_fee() -> "_VmErrorOutOfMessageFee": + return _VmErrorOutOfMessageFee() + + +class _VmErrorFeeNoMatchingAllocation: + @staticmethod + def val() -> "VmError": + return VmError("fee no_matching_allocation") + + @staticmethod + def internal() -> "VmError": + return VmError("fee no_matching_allocation # internal") + + @staticmethod + def external() -> "VmError": + return VmError("fee no_matching_allocation # external") + + +class _VmErrorFee: + @staticmethod + def below_minimum() -> "VmError": + return VmError("fee below_minimum") + + @staticmethod + def too_many_rounds() -> "VmError": + return VmError("fee too_many_rounds") + + @staticmethod + def no_matching_allocation() -> "_VmErrorFeeNoMatchingAllocation": + return _VmErrorFeeNoMatchingAllocation() + + +class _VmErrorEvm: + @staticmethod + def reverted() -> "VmError": + return VmError("evm reverted") + + +class _VmErrorInvalidContractRunner: + @staticmethod + def absent() -> "VmError": + return VmError("invalid_contract runner absent") + + @staticmethod + def malformed() -> "VmError": + return VmError("invalid_contract runner malformed") + + +class _VmErrorInvalidContractWasm: + @staticmethod + def validating() -> "VmError": + return VmError("invalid_contract wasm validating") + + @staticmethod + def linking() -> "VmError": + return VmError("invalid_contract wasm linking") + + @staticmethod + def entrypoint() -> "VmError": + return VmError("invalid_contract wasm entrypoint") + + +class _VmErrorInvalidContract: + @staticmethod + def val() -> "VmError": + return VmError("invalid_contract") + + @staticmethod + def not_utf8_text() -> "VmError": + return VmError("invalid_contract not_utf8_text") + + @staticmethod + def major_mismatch() -> "VmError": + return VmError("invalid_contract major_mismatch") + + @staticmethod + def runner() -> "_VmErrorInvalidContractRunner": + return _VmErrorInvalidContractRunner() + + @staticmethod + def wasm() -> "_VmErrorInvalidContractWasm": + return _VmErrorInvalidContractWasm() + + +class _VmErrorExitCode: + @staticmethod + def val_i32(v: int) -> "VmError": + return VmError(f"exit_code {v}") + + +class VmError: + __slots__ = ("value",) + + def __init__(self, value: str): + self.value = value + + def __str__(self) -> str: + return self.value + + @staticmethod + def timeout() -> "VmError": + return VmError("timeout") + + @staticmethod + def malformed_entry() -> "VmError": + return VmError("malformed_entry") + + @staticmethod + def forbidden() -> "VmError": + return VmError("forbidden") + + @staticmethod + def leader_fault() -> "_VmErrorLeaderFault": + return _VmErrorLeaderFault() + + @staticmethod + def exit_code() -> "_VmErrorExitCode": + return _VmErrorExitCode() + + @staticmethod + def wasm_trap() -> "_VmErrorWasmTrap": + return _VmErrorWasmTrap() + + @staticmethod + def out_of() -> "_VmErrorOutOf": + return _VmErrorOutOf() + + @staticmethod + def fee() -> "_VmErrorFee": + return _VmErrorFee() + + @staticmethod + def evm() -> "_VmErrorEvm": + return _VmErrorEvm() + + @staticmethod + def invalid_contract() -> "_VmErrorInvalidContract": + return _VmErrorInvalidContract() + + +EVENT_MAX_TOPICS: typing.Final[int] = 4 diff --git a/backend/node/llm.lua b/backend/node/llm.lua index 1c996f2a6..f2f643d0e 100644 --- a/backend/node/llm.lua +++ b/backend/node/llm.lua @@ -42,7 +42,7 @@ llm.exec_prompt_template_transform = function(args) } end --- check https://github.com/genlayerlabs/genvm/blob/v0.1.2/executor/modules/implementation/scripting/llm-default.lua +-- check https://github.com/genlayerlabs/genvm-manager/blob/main/install/config/genvm-llm-default.lua -- Used to look up mock responses for testing -- It returns the response that is linked to a substring of the message diff --git a/backend/node/types.py b/backend/node/types.py index d49c83ca1..4ad98f1a8 100644 --- a/backend/node/types.py +++ b/backend/node/types.py @@ -29,8 +29,12 @@ class Address: _as_bytes: bytes _as_hex: str | None - def __init__(self, val: str | collections.abc.Buffer): + def __init__(self, val: "str | collections.abc.Buffer | Address"): self._as_hex = None + if isinstance(val, Address): + self._as_bytes = val._as_bytes + self._as_hex = val._as_hex + return if isinstance(val, str): if len(val) == 2 + Address.SIZE * 2 and val.startswith("0x"): # 0x-prefixed hex string (42 chars) @@ -155,6 +159,12 @@ def from_string(cls, value: str) -> "ExecutionResultStatus": raise ValueError(f"Invalid execution result status value: {value}") +def _int_from_serialized(value, default: int = 0) -> int: + if value is None or value == "": + return default + return int(value) + + @dataclass class PendingTransaction: address: str # Address of the contract to call @@ -166,6 +176,11 @@ class PendingTransaction: is_eth_send: bool = ( False # True for EthSend (simple value transfer, no contract call) ) + fee_params: bytes = b"" + declared_budget: int = 0 + call_key: str = "0x" + ("0" * 64) + allocation_subtree: list[dict] = field(default_factory=list) + gas_used: int = 0 def is_deploy(self) -> bool: return self.code is not None @@ -177,6 +192,11 @@ def to_dict(self): "is_eth_send": True, "on": self.on, "value": self.value, + "fee_params": str(base64.b64encode(self.fee_params), encoding="ascii"), + "declared_budget": self.declared_budget, + "call_key": self.call_key, + "allocation_subtree": self.allocation_subtree, + "gas_used": self.gas_used, } elif self.code is None: return { @@ -184,6 +204,11 @@ def to_dict(self): "calldata": str(base64.b64encode(self.calldata), encoding="ascii"), "on": self.on, "value": self.value, + "fee_params": str(base64.b64encode(self.fee_params), encoding="ascii"), + "declared_budget": self.declared_budget, + "call_key": self.call_key, + "allocation_subtree": self.allocation_subtree, + "gas_used": self.gas_used, } else: return { @@ -192,6 +217,11 @@ def to_dict(self): "salt_nonce": self.salt_nonce, "on": self.on, "value": self.value, + "fee_params": str(base64.b64encode(self.fee_params), encoding="ascii"), + "declared_budget": self.declared_budget, + "call_key": self.call_key, + "allocation_subtree": self.allocation_subtree, + "gas_used": self.gas_used, } @classmethod @@ -202,27 +232,42 @@ def from_dict(cls, input: dict) -> "PendingTransaction": calldata=b"", code=None, salt_nonce=0, - value=input.get("value", 0), + value=_int_from_serialized(input.get("value"), 0), on=input.get("on", "finalized"), is_eth_send=True, + fee_params=base64.b64decode(input.get("fee_params", "")), + declared_budget=_int_from_serialized(input.get("declared_budget"), 0), + call_key=input.get("call_key", "0x" + ("0" * 64)), + allocation_subtree=input.get("allocation_subtree", []), + gas_used=_int_from_serialized(input.get("gas_used"), 0), ) elif "code" in input: return cls( address="0x", calldata=base64.b64decode(input["calldata"]), code=base64.b64decode(input["code"]), - salt_nonce=input.get("salt_nonce", 0), - value=input.get("value", 0), + salt_nonce=_int_from_serialized(input.get("salt_nonce"), 0), + value=_int_from_serialized(input.get("value"), 0), on=input.get("on", "finalized"), + fee_params=base64.b64decode(input.get("fee_params", "")), + declared_budget=_int_from_serialized(input.get("declared_budget"), 0), + call_key=input.get("call_key", "0x" + ("0" * 64)), + allocation_subtree=input.get("allocation_subtree", []), + gas_used=_int_from_serialized(input.get("gas_used"), 0), ) else: return cls( address=input["address"], calldata=base64.b64decode(input["calldata"]), - value=input.get("value", 0), + value=_int_from_serialized(input.get("value"), 0), code=None, salt_nonce=0, on=input.get("on", "finalized"), + fee_params=base64.b64decode(input.get("fee_params", "")), + declared_budget=_int_from_serialized(input.get("declared_budget"), 0), + call_key=input.get("call_key", "0x" + ("0" * 64)), + allocation_subtree=input.get("allocation_subtree", []), + gas_used=_int_from_serialized(input.get("gas_used"), 0), ) diff --git a/backend/node/web.lua b/backend/node/web.lua index d46ceaa73..97581dd2d 100644 --- a/backend/node/web.lua +++ b/backend/node/web.lua @@ -9,15 +9,55 @@ function Render(ctx, payload) ---@cast payload WebRenderPayload web.check_url(payload.url) + -- Return mock render output if it exists and matches. + if ctx.host_data.mock_web_response and ctx.host_data.mock_web_response.nondet_web_render then + for url, mock_response_data in pairs(ctx.host_data.mock_web_response.nondet_web_render) do + if url == payload.url and (not mock_response_data.mode or payload.mode == mock_response_data.mode) then + lib.log{level = "debug", message = "executed with mock web render response", url = url} + local status = tonumber(mock_response_data.status or 200) + if not status_is_good(status) then + lib.rs.user_error({ + causes = {"WEBPAGE_LOAD_FAILED"}, + fatal = false, + ctx = { + url = payload.url, + status = status, + body = mock_response_data.body, + } + }) + end + if payload.mode == "screenshot" then + return { + image = mock_response_data.body + } + else + return { + text = mock_response_data.body, + } + end + end + end + -- Only log if no match was found, then fall through to real render. + lib.log{level = "debug", message = "no mock web render response match found, falling through to real render"} + end + local url_params = '?url=' .. lib.rs.url_encode(payload.url) .. '&mode=' .. payload.mode .. '&waitAfterLoaded=' .. tostring(payload.wait_after_loaded or 0) + -- The webdriver is a trusted internal endpoint (studio sets webdriver_host to + -- a Docker service hostname, e.g. http://webdriver:5001, which resolves to a + -- private address). The web module runs with the SSRF-filtering resolver, which + -- drops non-globally-routable addresses, so this internal request must opt out + -- via `unfiltered`. The contract-controlled `payload.url` is validated by + -- `web.check_url` above and only rendered by the webdriver, never fetched here. local result = lib.rs.request(ctx, { method = 'GET', url = web.rs.config.webdriver_host .. '/render' .. url_params, headers = {}, error_on_status = true, + response_body_max_size = payload.size_limit, + unfiltered = true, }) lib.log({ @@ -52,10 +92,13 @@ end function Request(ctx, payload) ---@cast payload WebRequestPayload - web.check_url(payload.url) + -- `check_url` returns true when the host is in `always_allow_hosts`; such + -- hosts are sent through the unfiltered client, everything else through the + -- SSRF-guarded resolver (see the real request call below). + local allowlisted = web.check_url(payload.url) -- Return mock response if it exists and matches - if ctx.host_data.mock_web_response then + if ctx.host_data.mock_web_response and ctx.host_data.mock_web_response.nondet_web_request then for url, mock_response_data in pairs(ctx.host_data.mock_web_response.nondet_web_request) do if url == payload.url and payload.method == mock_response_data.method then lib.log{level = "debug", message = "executed with mock web response", url = url} @@ -76,6 +119,8 @@ function Request(ctx, payload) headers = payload.headers, body = payload.body, sign = payload.sign, + response_body_max_size = payload.size_limit, + unfiltered = allowlisted, }) if success then diff --git a/backend/protocol_rpc/api_key_redaction.py b/backend/protocol_rpc/api_key_redaction.py new file mode 100644 index 000000000..5496b9cae --- /dev/null +++ b/backend/protocol_rpc/api_key_redaction.py @@ -0,0 +1,83 @@ +"""Keep path-supplied API keys out of logs and error reports. + +API keys can be passed as a path segment (`/api/glk_...`) because the EVM +toolchain only accepts a URL. The cost of that convenience is that the key +travels somewhere URLs habitually get written down: uvicorn's access log, +Sentry events and traces, proxy logs, browser history. + +We cannot do anything about logs outside this process, but anything this +process emits is ours to scrub. Without that, enabling path keys would be a +downgrade on the header-only design rather than an improvement — a key in +CloudWatch or in a third-party error tracker is a leaked key. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Optional + +# Matches a full key (`glk_` + 64 hex) and also partial/malformed ones, so a +# truncated or mistyped key still gets scrubbed rather than logged verbatim. +_API_KEY_RE = re.compile(r"glk_[0-9a-fA-F]{4,}") + +REDACTED = "glk_REDACTED" + + +def redact_api_keys(value: str) -> str: + return _API_KEY_RE.sub(REDACTED, value) + + +class ApiKeyRedactingFilter(logging.Filter): + """Scrubs API keys from log records, including uvicorn's access log. + + uvicorn formats the request line via record.args rather than the message, + so both have to be rewritten. + """ + + def filter(self, record: logging.LogRecord) -> bool: + if isinstance(record.msg, str) and "glk_" in record.msg: + record.msg = redact_api_keys(record.msg) + + if record.args: + if isinstance(record.args, tuple): + record.args = tuple( + redact_api_keys(a) if isinstance(a, str) else a for a in record.args + ) + elif isinstance(record.args, dict): + record.args = { + k: redact_api_keys(v) if isinstance(v, str) else v + for k, v in record.args.items() + } + return True + + +def install_log_redaction() -> None: + """Attach the filter to the loggers that render request paths.""" + log_filter = ApiKeyRedactingFilter() + for name in ("uvicorn.access", "uvicorn.error", "gunicorn.access"): + logging.getLogger(name).addFilter(log_filter) + # Root catches application logs that interpolate a URL. + logging.getLogger().addFilter(log_filter) + + +def _scrub(node: Any) -> Any: + if isinstance(node, str): + return redact_api_keys(node) + if isinstance(node, dict): + return {k: _scrub(v) for k, v in node.items()} + if isinstance(node, list): + return [_scrub(v) for v in node] + return node + + +def scrub_sentry_event(event: dict, _hint: Optional[dict] = None) -> dict: + """before_send / before_send_transaction hook. + + Sentry is configured with send_default_pii=True and full trace sampling, so + the request URL reaches it on every transaction, not just on errors. Scrub + the whole event rather than known fields — the URL is echoed into + `request.url`, the transaction name, breadcrumbs and span descriptions, and + missing one of those defeats the point. + """ + return _scrub(event) diff --git a/backend/protocol_rpc/app_lifespan.py b/backend/protocol_rpc/app_lifespan.py index 0688a5495..1d3d6cf17 100644 --- a/backend/protocol_rpc/app_lifespan.py +++ b/backend/protocol_rpc/app_lifespan.py @@ -20,6 +20,10 @@ DatabaseSessionManager, set_database_manager, ) +from backend.database_handler.terminal_snapshot_pruner import ( + TerminalSnapshotPrunerConfig, + run_terminal_snapshot_pruner_loop, +) from backend.protocol_rpc.transactions_parser import TransactionParser from backend.protocol_rpc.configuration import GlobalConfiguration from backend.protocol_rpc.fastapi_rpc_router import FastAPIRPCRouter @@ -303,6 +307,15 @@ def get_session() -> Session: "[STARTUP] RPC process will not run consensus loops (handled by worker services)" ) + snapshot_pruner_config = TerminalSnapshotPrunerConfig.from_environment() + if snapshot_pruner_config.enabled: + snapshot_pruner_config.validate_for_run() + logger.info("[STARTUP] Starting terminal contract snapshot pruner") + snapshot_pruner_task = asyncio.create_task( + run_terminal_snapshot_pruner_loop(get_session, snapshot_pruner_config) + ) + background_tasks.append(snapshot_pruner_task) + sql_db = _SQLAlchemyDBWrapper(db_manager) # Registers the RPC methods via decorators, injects dependencies, and orchestrates the invokes with logging for execution @@ -395,7 +408,7 @@ def get_session() -> Session: # Register handler for validator change events async def handle_validator_change(event_data): """Reload validators when they change.""" - logger.info(f"RPC worker reloading validators due to change event") + logger.info("RPC worker reloading validators due to change event") await validators_manager.restart() redis_subscriber.register_handler("validator_created", handle_validator_change) diff --git a/backend/protocol_rpc/contract_storage_quota.py b/backend/protocol_rpc/contract_storage_quota.py new file mode 100644 index 000000000..78d386826 --- /dev/null +++ b/backend/protocol_rpc/contract_storage_quota.py @@ -0,0 +1,242 @@ +"""Per-contract daily budget for contract_snapshot bytes. + +Studio copies live current_state into transactions.contract_snapshot on +every write. A slow writer with unbounded on-chain history can add tens +of GiB/day without tripping request or PENDING-queue caps. + +This module meters estimated snapshot bytes per contract per UTC day. +Unset MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY disables it (self-hosted +default). Redis failures fail open. The first write of the day is always +allowed so a contract whose single snapshot exceeds the daily budget is +not hard-locked. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any + +from eth_utils import to_checksum_address +from sqlalchemy import text + +from backend.protocol_rpc.exceptions import StorageQuotaExceeded + +logger = logging.getLogger(__name__) + +_REDIS_KEY_TTL_SECONDS = 48 * 3600 +_STORAGE_HELP = ( + "The public Studio is a shared sandbox with a per-contract daily " + "snapshot storage budget. For production-volume workloads, run a " + "self-hosted instance." +) + +_redis_client: Any = None +_redis_init_attempted = False + +_CONSUME_SCRIPT = """ +local used = tonumber(redis.call('GET', KEYS[1]) or '0') +if redis.call('EXISTS', KEYS[2]) == 1 then + return {1, used, 0} +end +local cost = tonumber(ARGV[1]) +local limit = tonumber(ARGV[2]) +if used > 0 and used + cost > limit then + return {0, used, 0} +end +local new_used = redis.call('INCRBY', KEYS[1], cost) +redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) +redis.call('SET', KEYS[2], cost, 'EX', tonumber(ARGV[3])) +return {1, new_used, 1} +""" + +_RELEASE_SCRIPT = """ +local cost = tonumber(redis.call('GET', KEYS[2]) or '0') +if cost == 0 then + return 0 +end +redis.call('DEL', KEYS[2]) +local used = tonumber(redis.call('GET', KEYS[1]) or '0') +local new_used = used - cost +if new_used <= 0 then + redis.call('DEL', KEYS[1]) + return 0 +end +redis.call('DECRBY', KEYS[1], cost) +return new_used +""" + + +@dataclass(frozen=True) +class StorageQuotaReservation: + redis_client: Any + quota_key: str + reservation_key: str + owned: bool + + def release(self) -> None: + """Refund this request's reservation after admission fails.""" + if not self.owned: + return + try: + self.redis_client.eval( + _RELEASE_SCRIPT, 2, self.quota_key, self.reservation_key + ) + except Exception: + logger.exception("Failed to refund contract storage quota reservation") + + +def reset_storage_quota_client_for_tests() -> None: + global _redis_client, _redis_init_attempted + _redis_client = None + _redis_init_attempted = False + + +def _parse_optional_positive_int(env_name: str) -> int | None: + raw = os.environ.get(env_name) + if raw is None or raw.strip() == "": + return None + try: + parsed = int(raw) + except (ValueError, TypeError): + return None + return parsed if parsed > 0 else None + + +def daily_byte_limit() -> int | None: + return _parse_optional_positive_int("MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY") + + +def snapshot_cost_bytes(live_state_column_size: int | None) -> int: + """Estimate the next snapshot from the already-complete live state row. + + ``current_state.data`` contains both accepted and finalized slots, matching + the shape copied into ``transactions.contract_snapshot``. Multiplying it + again would double-count the snapshot cost. + """ + if not live_state_column_size: + return 0 + return live_state_column_size + + +def redis_key(address: str, day: str | None = None) -> str: + day_token = day or datetime.now(timezone.utc).strftime("%Y%m%d") + return f"studio:contract-storage:{address}:{day_token}" + + +def reservation_key(address: str, transaction_hash: str, day: str | None = None) -> str: + return f"{redis_key(address, day)}:tx:{transaction_hash}" + + +def _get_redis(): + global _redis_client, _redis_init_attempted + if _redis_init_attempted: + return _redis_client + _redis_init_attempted = True + url = os.environ.get("REDIS_URL") + if not url: + logger.warning( + "MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY is set but REDIS_URL is empty; " + "storage quota check skipped" + ) + return None + try: + import redis + + _redis_client = redis.from_url(url, decode_responses=True) + except Exception: + logger.exception("Failed to connect Redis for contract storage quota") + _redis_client = None + return _redis_client + + +def live_state_column_size(session, address: str) -> int | None: + """Return pg_column_size(data) without hydrating JSONB. None if missing.""" + try: + normalized = to_checksum_address(address) + except Exception: + normalized = address + return session.execute( + text("SELECT pg_column_size(data) FROM current_state WHERE id = :addr"), + {"addr": normalized}, + ).scalar() + + +def try_consume_daily_bytes( + redis_client, + address: str, + cost: int, + limit: int, + transaction_hash: str, +) -> tuple[bool, int, StorageQuotaReservation | None]: + """Atomically reserve `cost` against `limit`. + + First write of the UTC day always succeeds. The transaction marker makes + concurrent submissions of the same hash idempotent. + """ + quota_key = redis_key(address) + tx_key = reservation_key(address, transaction_hash) + ok, used, owned = redis_client.eval( + _CONSUME_SCRIPT, + 2, + quota_key, + tx_key, + cost, + limit, + _REDIS_KEY_TTL_SECONDS, + ) + reservation = StorageQuotaReservation(redis_client, quota_key, tx_key, bool(owned)) + return bool(ok), int(used), reservation if ok else None + + +def enforce_contract_storage_quota( + session, to_address: str | None, transaction_hash: str +) -> StorageQuotaReservation | None: + """Raise StorageQuotaExceeded when the contract is over its daily budget.""" + limit = daily_byte_limit() + if limit is None or to_address is None: + return + + try: + to_address = to_checksum_address(to_address) + except Exception: + pass + + size = live_state_column_size(session, to_address) + cost = snapshot_cost_bytes(size) + if cost <= 0: + return + + redis_client = _get_redis() + if redis_client is None: + return + + try: + ok, used, reservation = try_consume_daily_bytes( + redis_client, to_address, cost, limit, transaction_hash + ) + except Exception: + logger.exception( + "Contract storage quota Redis error; failing open for %s", to_address + ) + return + + if ok: + return reservation + + raise StorageQuotaExceeded( + message=( + f"Contract {to_address} exceeded the daily snapshot storage " + f"budget ({used} of {limit} bytes used; this write costs {cost}). " + f"{_STORAGE_HELP}" + ), + data={ + "scope": "contract_storage", + "address": to_address, + "used": used, + "limit": limit, + "cost": cost, + }, + ) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index 493554822..a5ff3945d 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -1,4 +1,5 @@ # rpc/endpoints.py +import copy import random import json import time @@ -6,17 +7,24 @@ import logging from contextlib import asynccontextmanager from functools import partial, wraps -from typing import Any +from typing import Any, Final, get_args from backend.protocol_rpc.exceptions import ( JSONRPCError, NotFoundError, QueueDepthExceeded, ) +from backend.protocol_rpc.contract_storage_quota import ( + enforce_contract_storage_quota, + live_state_column_size, +) from sqlalchemy import Table, text from sqlalchemy.orm import Session import backend.validators as validators -from backend.database_handler.contract_snapshot import ContractSnapshot +from backend.database_handler.contract_snapshot import ( + ContractSnapshot, + fetch_deployed_code_b64, +) from backend.database_handler.llm_providers import LLMProviderRegistry from backend.rollup.consensus_service import ConsensusService from backend.database_handler.models import Base, TransactionStatus @@ -39,6 +47,21 @@ ) from backend.protocol_rpc.transactions_parser import TransactionParser +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + FeeValidationError, + StudioFeePolicy, + apply_fee_top_up, + create_fee_accounting, + get_leader_rounds, + normalize_fees_distribution, + record_appeal_bond, + record_execution_fee_consumption, + required_fee_deposit, + studio_fee_config, + validate_transaction_fee_deposit, +) +from backend.consensus.history import completed_consensus_round_index from backend.errors.errors import InvalidAddressError, InvalidTransactionError from backend.database_handler.errors import ContractNotFoundError @@ -49,7 +72,10 @@ logger = logging.getLogger(__name__) -from backend.node.base import Node, get_simulator_chain_id +TRANSACTION_NOT_FOUND_MESSAGE = "Transaction not found" +from backend.node.base import Node, _genvm_debug_mode, get_simulator_chain_id +from backend.node.genvm.base import is_valid_executor_selector +from backend.node.genvm.origin import base_host from backend.node.types import ExecutionMode, ExecutionResultStatus from backend.consensus.base import ConsensusAlgorithm from backend.protocol_rpc.call_interceptor import handle_consensus_data_call @@ -59,17 +85,20 @@ import os import secrets as secrets_module from backend.protocol_rpc.message_handler.types import LogEvent, EventType, EventScope -from backend.protocol_rpc.types import DecodedsubmitAppealDataArgs +from backend.protocol_rpc.types import ( + DecodedRollupTransaction, + DecodedTopUpFeesDataArgs, + DecodedsubmitAppealDataArgs, +) from backend.database_handler.snapshot_manager import SnapshotManager from backend.node.base import Manager as GenVMManager import asyncio # Limit concurrent GenVM executions on the jsonrpc path to prevent uvloop fd # conflicts and DB pool exhaustion while calls hold request-scoped sessions. -# Workers use asyncio.Semaphore(8) in consensus/base.py; keep the RPC path -# bounded too. +# Workers use CONSENSUS_VALIDATOR_MAX_CONCURRENT (default 8) in +# consensus/base.py; keep the RPC path bounded too. _GENVM_CONCURRENCY = int(os.environ.get("GENVM_MAX_CONCURRENT", "8")) -_genvm_semaphore = asyncio.Semaphore(_GENVM_CONCURRENCY) _genvm_admission_semaphore = asyncio.Semaphore(_GENVM_CONCURRENCY) # --------------------------------------------------------------------------- @@ -86,6 +115,47 @@ _address_request_log: dict[str, list[float]] = {} # {address: [timestamp, ...]} _rate_limit_logger = logging.getLogger(__name__ + ".rate_limit") +_gen_call_singleflight_logger = logging.getLogger(__name__ + ".singleflight") + +_GEN_CALL_SINGLEFLIGHT_ENABLED = os.environ.get( + "GEN_CALL_SINGLEFLIGHT_ENABLED", "true" +).lower() not in {"0", "false", "no", "off"} +_gen_call_singleflight_tasks: dict[str, asyncio.Task[str]] = {} +_gen_call_singleflight_lock = asyncio.Lock() + + +def _show_validator_private_keys_in_rpc() -> bool: + return os.getenv("SHOW_VALIDATOR_PRIVATE_KEYS_IN_RPC", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def _is_private_key_field(key: Any) -> bool: + if not isinstance(key, str): + return False + normalized = key.replace("_", "").replace("-", "").lower() + return normalized == "privatekey" or normalized.endswith("privatekey") + + +def _sanitize_rpc_private_keys(value: Any) -> Any: + """Return RPC data with private-key fields removed unless explicitly enabled.""" + if _show_validator_private_keys_in_rpc(): + return value + + if isinstance(value, dict): + return { + key: _sanitize_rpc_private_keys(item) + for key, item in value.items() + if not _is_private_key_field(key) + } + if isinstance(value, list): + return [_sanitize_rpc_private_keys(item) for item in value] + if isinstance(value, tuple): + return tuple(_sanitize_rpc_private_keys(item) for item in value) + return value def _check_rate_limit(address: str) -> None: @@ -130,6 +200,66 @@ async def _admit_genvm_call(method: str, to_address: str | None): _genvm_admission_semaphore.release() +def _gen_call_singleflight_key(params: dict) -> str | None: + if not _GEN_CALL_SINGLEFLIGHT_ENABLED: + return None + if not isinstance(params, dict) or params.get("type") != "read": + return None + + payload = json.dumps(params, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(payload.encode()).hexdigest() + + +async def _execute_gen_call_with_admission( + session: Session, + accounts_manager: AccountsManager, + msg_handler: IMessageHandler, + transactions_parser: TransactionParser, + validators_manager: validators.Manager, + genvm_manager: GenVMManager, + params: dict, +) -> str: + to_address = params.get("to") if isinstance(params, dict) else None + async with _admit_genvm_call("gen_call", to_address): + receipt = await _execute_call_with_snapshot( + session, + accounts_manager, + msg_handler, + transactions_parser, + validators_manager, + genvm_manager, + params, + ) + return eth_utils.hexadecimal.encode_hex(receipt.result[1:])[2:] + + +async def _run_singleflight_gen_call( + key: str, + session: Session, + accounts_manager: AccountsManager, + msg_handler: IMessageHandler, + transactions_parser: TransactionParser, + validators_manager: validators.Manager, + genvm_manager: GenVMManager, + params: dict, +) -> str: + try: + return await _execute_gen_call_with_admission( + session, + accounts_manager, + msg_handler, + transactions_parser, + validators_manager, + genvm_manager, + params, + ) + finally: + task = asyncio.current_task() + async with _gen_call_singleflight_lock: + if _gen_call_singleflight_tasks.get(key) is task: + _gen_call_singleflight_tasks.pop(key, None) + + # --------------------------------------------------------------------------- # Admission control on PENDING queue depth (eth_sendRawTransaction path). # @@ -163,7 +293,7 @@ def _parse_optional_positive_int(env_name: str) -> int | None: _QUEUE_DEPTH_HELP = ( "The public Studio is a shared sandbox with per-contract and " "per-sender PENDING transaction caps. For production-volume " - "workloads, run a self-hosted instance or use Rally." + "workloads, run a self-hosted instance." ) @@ -230,6 +360,10 @@ def _enforce_pending_queue_caps( ) +def get_studio_fee_config() -> dict[str, Any]: + return studio_fee_config(StudioFeePolicy.from_env()) + + ####### ADMIN ACCESS CONTROL ####### def require_admin_access(func): """ @@ -328,19 +462,28 @@ async def check_provider_is_available( url = provider.plugin_config["api_url"] plugin = provider.plugin key = provider.plugin_config["api_key_env_var"] - temperature = provider.config.get("temperature", 1) - use_max_completion_tokens = provider.config.get( - "use_max_completion_tokens", False - ) + config = provider.config or {} else: model = provider["model"] url = provider["plugin_config"]["api_url"] plugin = provider["plugin"] key = provider["plugin_config"]["api_key_env_var"] - temperature = provider["config"].get("temperature", 1) - use_max_completion_tokens = provider["config"].get( - "use_max_completion_tokens", False - ) + config = provider["config"] or {} + temperature = config.get("temperature", 1) + use_max_completion_tokens = config.get("use_max_completion_tokens", False) + max_tokens = config.get("max_tokens", 500) + known_config_keys = {"temperature", "max_tokens", "use_max_completion_tokens"} + extra = {k: v for k, v in config.items() if k not in known_config_keys} + prompt = { + "system_message": "", + "user_message": "respond with two letters 'ok' and nothing else. No quotes, no repetition", + "temperature": temperature, + "max_tokens": max_tokens, + "use_max_completion_tokens": use_max_completion_tokens, + "images": [], + } + if extra: + prompt["extra"] = extra key = f"${{ENV[{key}]}}" timeout_s = float( os.environ.get("LLM_PROVIDER_AVAILABILITY_TIMEOUT_SECONDS", "20") @@ -355,14 +498,7 @@ async def check_provider_is_available( "key": key, } ], - prompt={ - "system_message": "", - "user_message": "respond with two letters 'ok' and nothing else. No quotes, no repetition", - "temperature": temperature, - "max_tokens": 500, - "use_max_completion_tokens": use_max_completion_tokens, - "images": [], - }, + prompt=prompt, ), timeout=timeout_s, ) @@ -376,20 +512,20 @@ async def check_provider_is_available( if len(res) != 1: genvm_manager.logger.error( - f"LLM provider check failed", provider=provider, result=res + "LLM provider check failed", provider=provider, result=res ) return False res = res[0] if (text_response := res.get("response")) is None: genvm_manager.logger.error( - f"LLM provider check failed", provider=provider, result=res + "LLM provider check failed", provider=provider, result=res ) return False what_returned = text_response.strip().lower() if what_returned != "ok": genvm_manager.logger.error( - f"LLM provider check failed", provider=provider, text_response=text_response + "LLM provider check failed", provider=provider, text_response=text_response ) return False return True @@ -873,7 +1009,7 @@ def cancel_transaction( ) if not transaction: raise NotFoundError( - message="Transaction not found", + message=TRANSACTION_NOT_FOUND_MESSAGE, data={"transaction_hash": transaction_hash}, ) @@ -948,6 +1084,11 @@ def cancel_transaction( AccountsManager(session).refund_tx_value( transaction_hash, transaction.from_address ) + if transaction.from_address: + AccountsManager(session).cancel_tx_fee_accounting_once( + transaction_hash, transaction.from_address, "canceled" + ) + session.commit() # Notify frontend via WebSocket msg_handler.send_transaction_status_update(transaction_hash, "CANCELED") @@ -1039,13 +1180,12 @@ async def get_contract_schema_for_code( def get_contract_code(session: Session, contract_address: str) -> str: try: - contract_snapshot = ContractSnapshot(contract_address, session) + code_b64 = fetch_deployed_code_b64(session, contract_address) except ContractNotFoundError: raise NotFoundError( message=f"Contract {contract_address} not found", data={"contract_address": contract_address}, ) - code_b64 = contract_snapshot.extract_deployed_code_b64() if not code_b64: raise InvalidAddressError( contract_address, @@ -1170,6 +1310,28 @@ async def _execute_call_with_snapshot( return receipt +def _state_status_from_call_params(params: dict) -> str: + """Map public call state selectors to Studio's current internal buckets.""" + status = params.get("status") + if status is not None: + if status == "decided": + return "accepted" + if status == "finalized": + return "finalized" + raise JSONRPCError( + code=-32602, + message="Invalid status: must be 'decided' or 'finalized'", + data={}, + ) + + # Legacy Studio selector. Preserve old fallback semantics: only + # latest-final changes the bucket; all other/absent values read decided state. + transaction_hash_variant = params.get("transaction_hash_variant") + if transaction_hash_variant == "latest-final": + return "finalized" + return "accepted" + + async def gen_call( session: Session, accounts_manager: AccountsManager, @@ -1179,9 +1341,9 @@ async def gen_call( genvm_manager: GenVMManager, params: dict, ) -> str: - to_address = params.get("to") if isinstance(params, dict) else None - async with _admit_genvm_call("gen_call", to_address): - receipt = await _execute_call_with_snapshot( + singleflight_key = _gen_call_singleflight_key(params) + if singleflight_key is None: + return await _execute_gen_call_with_admission( session, accounts_manager, msg_handler, @@ -1190,7 +1352,30 @@ async def gen_call( genvm_manager, params, ) - return eth_utils.hexadecimal.encode_hex(receipt.result[1:])[2:] + + async with _gen_call_singleflight_lock: + task = _gen_call_singleflight_tasks.get(singleflight_key) + if task is None: + task = asyncio.create_task( + _run_singleflight_gen_call( + singleflight_key, + session, + accounts_manager, + msg_handler, + transactions_parser, + validators_manager, + genvm_manager, + params, + ) + ) + _gen_call_singleflight_tasks[singleflight_key] = task + else: + _gen_call_singleflight_logger.debug( + "Coalescing duplicate gen_call read for key %s", + singleflight_key[:12], + ) + + return await asyncio.shield(task) def sim_lint_contract(source_code: str, filename: str = "contract.py") -> dict: @@ -1229,7 +1414,91 @@ async def sim_call( genvm_manager, params, ) - return receipt.to_dict() + return TransactionsProcessor._json_safe_numbers(receipt.to_dict()) + + +async def sim_estimate_transaction_fees( + session: Session, + accounts_manager: AccountsManager, + msg_handler: IMessageHandler, + transactions_parser: TransactionParser, + validators_manager: validators.Manager, + genvm_manager: GenVMManager, + params: dict, +) -> dict: + estimate_params = _with_default_simulation_fees(params) + if isinstance(estimate_params, dict): + estimate_params = { + **estimate_params, + "_allow_low_execution_budget_for_estimate": True, + } + receipt = await sim_call( + session=session, + accounts_manager=accounts_manager, + msg_handler=msg_handler, + transactions_parser=transactions_parser, + validators_manager=validators_manager, + genvm_manager=genvm_manager, + params=estimate_params, + ) + genvm_result = receipt.get("genvm_result") or {} + fee_accounting = ( + genvm_result.get(FEE_ACCOUNTING_KEY) if isinstance(genvm_result, dict) else {} + ) or {} + return TransactionsProcessor._json_safe_numbers( + { + "scenario": _first_present(params, "scenario", "scenarioName") or "default", + "receipt": receipt, + "feeAccounting": fee_accounting, + "feeReport": fee_accounting.get("execution_fee_report") or {}, + "recommendedPreset": fee_accounting.get("recommended_fee_preset") or {}, + } + ) + + +def _with_default_simulation_fees(params: dict) -> dict: + if not isinstance(params, dict): + return params + fees = params.get("fees") if isinstance(params.get("fees"), dict) else {} + has_fee_params = any( + key in params + for key in ( + "fees_distribution", + "feesDistribution", + "message_allocations", + "messageAllocations", + "fee_value", + "feeValue", + ) + ) or any( + key in fees + for key in ( + "distribution", + "fees_distribution", + "feesDistribution", + "message_allocations", + "messageAllocations", + "fee_value", + "feeValue", + ) + ) + if has_fee_params: + return params + + updated = dict(params) + updated["fees"] = studio_fee_config(StudioFeePolicy.from_env())["defaultFees"] + return updated + + +def _stage_simulated_call_value( + contract_snapshot: ContractSnapshot, call_value: int +) -> None: + if call_value <= 0: + return + + contract_snapshot.balance = int( + getattr(contract_snapshot, "balance", 0) or 0 + ) + int(call_value) async def _gen_call_with_validator( @@ -1247,12 +1516,14 @@ async def _gen_call_with_validator( from_address = params["from"] origin_address = params.get("origin_address") call_value = int(params.get("value", "0x0"), 16) if params.get("value") else 0 - transaction_hash_variant = ( - params["transaction_hash_variant"] - if "transaction_hash_variant" in params - else None + simulation_fee_accounting = _simulation_fee_accounting( + params, + sender=from_address, + user_value=call_value, + ) + genvm_fee_accounting = _effective_simulation_fee_accounting_for_genvm( + simulation_fee_accounting ) - if not accounts_manager.is_valid_address(from_address): raise InvalidAddressError(from_address) @@ -1262,10 +1533,7 @@ async def _gen_call_with_validator( # Rate limit per contract address — reject early before acquiring resources _check_rate_limit(to_address) - if transaction_hash_variant == "latest-final": - state_status = "finalized" - else: - state_status = "accepted" + state_status = _state_status_from_call_params(params) # Get a validator if len(validators_snapshot.nodes) > 0: @@ -1284,6 +1552,8 @@ async def _gen_call_with_validator( message=f"Contract {to_address} not found", data={"contract_address": to_address}, ) + if type in {"write", "deploy"}: + _stage_simulated_call_value(contract_snapshot, call_value) node = Node( contract_snapshot=contract_snapshot, contract_snapshot_factory=partial(ContractSnapshot, session=session), @@ -1301,90 +1571,88 @@ async def _gen_call_with_validator( sim_config is not None and sim_config.genvm_datetime is not None ) - if _genvm_semaphore.locked(): - _rate_limit_logger.warning( - f"GenVM at capacity ({_GENVM_CONCURRENCY} concurrent) — rejecting gen_call to {to_address}" - ) - raise JSONRPCError( - code=-32006, - message=f"Server busy: all {_GENVM_CONCURRENCY} execution slots occupied, retry later", - data={"retry_after_seconds": 2}, + try: + if type == "read": + # Pre-parse timestamp override and map errors + txn_dt = None + if sim_config and override_transaction_datetime: + try: + txn_dt = sim_config.genvm_datetime_as_datetime + except ValueError as e: + raise JSONRPCError( + code=-32602, + message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", + data={}, + ) from e + decoded_data = transactions_parser.decode_method_call_data(data) + receipt = await node.get_contract_data( + from_address=from_address, + calldata=decoded_data.calldata, + state_status=state_status, + transaction_datetime=txn_dt, + origin_address=origin_address, + ) + elif type == "write": + txn_created_at = None + if sim_config and override_transaction_datetime: + try: + _ = sim_config.genvm_datetime_as_datetime # validation only + txn_created_at = sim_config.genvm_datetime + except ValueError as e: + raise JSONRPCError( + code=-32602, + message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", + data={}, + ) from e + decoded_data = transactions_parser.decode_method_send_data(data) + receipt = await node.run_contract( + from_address=from_address, + calldata=decoded_data.calldata, + transaction_created_at=txn_created_at, + value=call_value, + origin_address=origin_address, + fee_accounting=genvm_fee_accounting, + ) + elif type == "deploy": + txn_created_at = None + if sim_config and override_transaction_datetime: + try: + _ = sim_config.genvm_datetime_as_datetime # validation only + txn_created_at = sim_config.genvm_datetime + except ValueError as e: + raise JSONRPCError( + code=-32602, + message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", + data={}, + ) from e + decoded_data = transactions_parser.decode_deployment_data(data) + receipt = await node.deploy_contract( + from_address=from_address, + code_to_deploy=decoded_data.contract_code, + calldata=decoded_data.calldata, + transaction_created_at=txn_created_at, + value=call_value, + origin_address=origin_address, + fee_accounting=genvm_fee_accounting, + ) + else: + raise JSONRPCError( + code=-32602, + message=f"Invalid type '{type}': must be 'read', 'write', or 'deploy'", + ) + except ContractNotFoundError as e: + raise NotFoundError( + message=f"Contract {e.address} not found", + data={"contract_address": e.address}, + ) from e + + if simulation_fee_accounting is not None: + receipt.genvm_result = dict(receipt.genvm_result or {}) + receipt.genvm_result["fee_accounting"] = record_execution_fee_consumption( + simulation_fee_accounting, + receipt, ) - async with _genvm_semaphore: - try: - if type == "read": - # Pre-parse timestamp override and map errors - txn_dt = None - if sim_config and override_transaction_datetime: - try: - txn_dt = sim_config.genvm_datetime_as_datetime - except ValueError as e: - raise JSONRPCError( - code=-32602, - message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", - data={}, - ) from e - decoded_data = transactions_parser.decode_method_call_data(data) - receipt = await node.get_contract_data( - from_address=from_address, - calldata=decoded_data.calldata, - state_status=state_status, - transaction_datetime=txn_dt, - origin_address=origin_address, - ) - elif type == "write": - txn_created_at = None - if sim_config and override_transaction_datetime: - try: - _ = sim_config.genvm_datetime_as_datetime # validation only - txn_created_at = sim_config.genvm_datetime - except ValueError as e: - raise JSONRPCError( - code=-32602, - message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", - data={}, - ) from e - decoded_data = transactions_parser.decode_method_send_data(data) - receipt = await node.run_contract( - from_address=from_address, - calldata=decoded_data.calldata, - transaction_created_at=txn_created_at, - value=call_value, - origin_address=origin_address, - ) - elif type == "deploy": - txn_created_at = None - if sim_config and override_transaction_datetime: - try: - _ = sim_config.genvm_datetime_as_datetime # validation only - txn_created_at = sim_config.genvm_datetime - except ValueError as e: - raise JSONRPCError( - code=-32602, - message=f"Invalid sim_config.genvm_datetime: {sim_config.genvm_datetime}", - data={}, - ) from e - decoded_data = transactions_parser.decode_deployment_data(data) - receipt = await node.deploy_contract( - from_address=from_address, - code_to_deploy=decoded_data.contract_code, - calldata=decoded_data.calldata, - transaction_created_at=txn_created_at, - value=call_value, - origin_address=origin_address, - ) - else: - raise JSONRPCError( - code=-32602, - message=f"Invalid type '{type}': must be 'read', 'write', or 'deploy'", - ) - except ContractNotFoundError as e: - raise NotFoundError( - message=f"Contract {e.address} not found", - data={"contract_address": e.address}, - ) from e - # Return the result of the write method if receipt.execution_result != ExecutionResultStatus.SUCCESS: raise JSONRPCError( @@ -1420,7 +1688,7 @@ def get_transaction_by_hash( sim_config: dict | None = None, ) -> dict: transaction = transactions_processor.get_transaction_by_hash( - transaction_hash, sim_config + transaction_hash, sim_config, include_contract_snapshot=False ) if transaction is None: @@ -1428,7 +1696,7 @@ def get_transaction_by_hash( message=f"Transaction {transaction_hash} not found", data={"hash": transaction_hash}, ) - return transaction + return _sanitize_rpc_private_keys(transaction) def get_studio_transaction_by_hash( @@ -1445,12 +1713,26 @@ def get_studio_transaction_by_hash( message=f"Transaction {transaction_hash} not found", data={"hash": transaction_hash}, ) - return transaction + return _sanitize_rpc_private_keys(transaction) def get_transaction_status( transactions_processor: TransactionsProcessor, transaction_hash: str ) -> str: + status = transactions_processor.get_transaction_status(transaction_hash) + if status is None: + raise NotFoundError( + message=f"Transaction {transaction_hash} not found", + data={"hash": transaction_hash}, + ) + # Compatibility contract for deployed apps (Rally): this legacy RPC returns + # the status string only. Extensions belong in gen_getTransactionStatusDetails. + return status["status"] + + +def get_transaction_status_details( + transactions_processor: TransactionsProcessor, transaction_hash: str +) -> dict: status = transactions_processor.get_transaction_status(transaction_hash) if status is None: raise NotFoundError( @@ -1546,6 +1828,379 @@ async def eth_call( return eth_utils.hexadecimal.encode_hex(receipt.result[1:]) +def _fee_metadata(decoded_rollup_transaction: DecodedRollupTransaction) -> dict: + if ( + decoded_rollup_transaction.data is None + or isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) + or isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) + or not hasattr(decoded_rollup_transaction.data, "args") + or decoded_rollup_transaction.data.args is None + ): + return {} + + args = decoded_rollup_transaction.data.args + if args.fees_distribution is None and decoded_rollup_transaction.fee_value == 0: + return {} + + metadata = { + "fee_value": decoded_rollup_transaction.fee_value, + "user_value": args.user_value, + "valid_until": args.valid_until, + "salt_nonce": args.salt_nonce, + "fees_distribution": args.fees_distribution, + "message_allocations_count": args.message_allocations_count, + } + metadata[FEE_ACCOUNTING_KEY] = create_fee_accounting( + fees_distribution=args.fees_distribution, + message_allocations=args.message_allocations, + num_of_validators=args.num_of_initial_validators, + submitted_value=decoded_rollup_transaction.total_spend, + user_value=int(args.user_value or 0), + sender=decoded_rollup_transaction.from_address, + policy=StudioFeePolicy.from_env(), + ) + return metadata + + +# `DebugMode` is ordered least- to most-permissive, and the manager gates +# `reroute_to` on `debug_mode >= Safe` (implementation/src/manager/run.rs). +# Comparing positions states that rule directly instead of enumerating the +# levels above it, which is how `safe-unbounded` -- the level studio's own run +# path resolves to -- gets left out of a hand-written list. +_DEBUG_MODE_ORDER: Final = get_args(base_host.DebugMode) +_MIN_REROUTE_TO_DEBUG_MODE: Final = _DEBUG_MODE_ORDER.index("safe") + + +def _genvm_executor_selector_is_present(sim_config: dict | None) -> bool: + """A missing key, `None`, or `""` all mean "unset" and are ignored. + + Anything else -- including a non-string falsy value like `0`, `False`, + `[]`, or `{}` -- counts as present, so it reaches the type/grammar checks + below instead of silently being treated the same as "unset". + """ + if not sim_config: + return False + value = sim_config.get("genvm_executor_selector") + if value is None: + return False + if isinstance(value, str) and not value: + return False + return True + + +def _validate_genvm_executor_selector(sim_config: dict | None) -> None: + """`sim_config.genvm_executor_selector` pins the contract to a GenVM + executor version or `re:` selector. + + The manager honors it only under `debug_mode >= safe` and ignores it + silently otherwise, so reject the transaction instead of running it on + an executor the caller did not ask for. + """ + if not _genvm_executor_selector_is_present(sim_config): + return + genvm_executor_selector = sim_config["genvm_executor_selector"] + debug_mode = _genvm_debug_mode() + if ( + debug_mode not in _DEBUG_MODE_ORDER + or _DEBUG_MODE_ORDER.index(debug_mode) < _MIN_REROUTE_TO_DEBUG_MODE + ): + raise JSONRPCError( + code=-32602, + message=( + "sim_config.genvm_executor_selector requires genvm debug mode " + "(GENVM_DEBUG_MODE)" + ), + data={"genvm_executor_selector": genvm_executor_selector}, + ) + # The pin is persisted on the contract and only read back much later, by + # `resolve_call_contract_executor` in the middle of a run. Validating it here + # turns an unusable pin into a rejected transaction instead of a contract + # that fails every call it takes part in. + # `fullmatch`, not `match`: `$` also matches in front of a final newline, so + # an anchored `match` would let `"v0.2.17\n"` through as a directory name. + if not isinstance(genvm_executor_selector, str) or not is_valid_executor_selector( + genvm_executor_selector + ): + raise JSONRPCError( + code=-32602, + message=( + "sim_config.genvm_executor_selector is not a valid executor " + "version or selector" + ), + data={"genvm_executor_selector": genvm_executor_selector}, + ) + + +def _reject_genvm_executor_selector_unless_deploy( + sim_config: dict | None, *, is_deploy: bool +) -> None: + """`sim_config.genvm_executor_selector` pins the *deployment's* executor; + the manager ignores it for every other transaction type. Silently + accepting it elsewhere would look like the pin took effect when it never + reached the manager at all, so reject it instead of dropping it on the + floor. + """ + if is_deploy or not _genvm_executor_selector_is_present(sim_config): + return + raise JSONRPCError( + code=-32602, + message=( + "sim_config.genvm_executor_selector is only valid for contract " + "deployment transactions" + ), + data={"genvm_executor_selector": sim_config["genvm_executor_selector"]}, + ) + + +def _validate_fee_envelope( + decoded_rollup_transaction: DecodedRollupTransaction, +) -> None: + if ( + decoded_rollup_transaction.data is None + or isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) + or isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) + or not hasattr(decoded_rollup_transaction.data, "args") + or decoded_rollup_transaction.data.args is None + ): + return + + args = decoded_rollup_transaction.data.args + if args.fees_distribution is None: + return + + try: + validate_transaction_fee_deposit( + fees_distribution=args.fees_distribution, + message_allocations=args.message_allocations, + num_of_validators=args.num_of_initial_validators, + submitted_value=decoded_rollup_transaction.total_spend, + user_value=int(args.user_value or 0), + policy=StudioFeePolicy.from_env(), + ) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + + +def _sandbox_debit_sender( + accounts_manager: AccountsManager, from_address: str, amount: int +) -> None: + if amount <= 0: + return + sender_balance = accounts_manager.get_account_balance(from_address) + if sender_balance < amount: + accounts_manager.credit_account_balance(from_address, amount - sender_balance) + accounts_manager.debit_account_balance(from_address, amount) + + +def _handle_top_up_fees( + *, + accounts_manager: AccountsManager, + transactions_processor: TransactionsProcessor, + decoded_rollup_transaction: DecodedRollupTransaction, +) -> str: + assert isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs) + tx_id = _tx_id_to_hex(decoded_rollup_transaction.data.tx_id) + tx = transactions_processor.get_transaction_by_hash(tx_id) + if tx is None: + raise NotFoundError(message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id}) + + status = tx.get("status") + if status in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.FINALIZED.value, + TransactionStatus.CANCELED.value, + }: + raise InvalidTransactionError("InvalidTransactionStatus") + + fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting is None: + raise InvalidTransactionError("FeeAccountingMissing") + + try: + updated = apply_fee_top_up( + fee_accounting, + fees_distribution=decoded_rollup_transaction.data.fees_distribution, + amount=decoded_rollup_transaction.total_spend, + sender=decoded_rollup_transaction.from_address, + num_of_validators=int(tx.get("num_of_initial_validators") or 5), + policy=StudioFeePolicy.from_env(), + ) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + + _sandbox_debit_sender( + accounts_manager, + decoded_rollup_transaction.from_address, + decoded_rollup_transaction.total_spend, + ) + transactions_processor.update_transaction_fee_accounting(tx_id, updated) + return tx_id + + +def _handle_appeal_or_top_up_and_submit( + *, + accounts_manager: AccountsManager, + transactions_processor: TransactionsProcessor, + msg_handler: IMessageHandler, + decoded_rollup_transaction: DecodedRollupTransaction, +) -> str: + assert isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs) + tx_id = _tx_id_to_hex(decoded_rollup_transaction.data.tx_id) + tx = transactions_processor.get_transaction_by_hash(tx_id) + if tx is None: + raise NotFoundError(message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id}) + + fee_accounting = (tx.get("data") or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting is not None: + try: + updated = record_appeal_bond( + fee_accounting, + amount=decoded_rollup_transaction.total_spend, + appealer=decoded_rollup_transaction.from_address, + current_round=_current_fee_round(tx.get("consensus_history")), + status=str(tx.get("status") or ""), + fees_distribution=decoded_rollup_transaction.data.fees_distribution, + top_up_and_submit=decoded_rollup_transaction.data.top_up_and_submit, + ) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + _sandbox_debit_sender( + accounts_manager, + decoded_rollup_transaction.from_address, + decoded_rollup_transaction.total_spend, + ) + transactions_processor.update_transaction_fee_accounting(tx_id, updated) + + transactions_processor.set_transaction_appeal(tx_id, True) + msg_handler.send_message( + log_event=LogEvent( + "transaction_appeal_updated", + EventType.INFO, + EventScope.CONSENSUS, + "Set transaction appealed", + { + "hash": tx_id, + }, + ), + log_to_terminal=False, + ) + return tx_id + + +def _tx_id_to_hex(tx_id: str | bytes) -> str: + return "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id + + +def _current_fee_round(consensus_history: dict | None) -> int: + return completed_consensus_round_index(consensus_history) + + +def _simulation_fee_accounting( + params: dict, + *, + sender: str, + user_value: int, +) -> dict | None: + fees = params.get("fees") if isinstance(params.get("fees"), dict) else {} + fees_distribution = _first_present( + params, + "fees_distribution", + "feesDistribution", + ) or _first_present(fees, "distribution", "fees_distribution", "feesDistribution") + message_allocations = _first_present( + params, + "message_allocations", + "messageAllocations", + ) + if message_allocations is None: + message_allocations = _first_present( + fees, + "message_allocations", + "messageAllocations", + ) + raw_fee_value = _first_present(params, "fee_value", "feeValue") + if raw_fee_value is None: + raw_fee_value = _first_present(fees, "fee_value", "feeValue") + + if fees_distribution is None and not message_allocations and raw_fee_value is None: + return None + + fees_distribution = fees_distribution or {} + message_allocations = message_allocations or [] + num_of_initial_validators = _int_param( + _first_present(params, "num_of_initial_validators", "numOfInitialValidators"), + 5, + ) + policy = StudioFeePolicy.from_env() + fee_value = _int_param(raw_fee_value, None) + if fee_value is None: + fee_value = required_fee_deposit( + fees_distribution, + num_of_initial_validators, + policy, + ) + + try: + return create_fee_accounting( + fees_distribution=fees_distribution, + message_allocations=message_allocations, + num_of_validators=num_of_initial_validators, + submitted_value=int(user_value) + int(fee_value), + user_value=int(user_value), + sender=sender, + policy=policy, + allow_low_execution_budget=bool( + params.get("_allow_low_execution_budget_for_estimate") + ), + ) + except FeeValidationError as exc: + raise JSONRPCError(code=-32602, message=str(exc), data={}) from exc + + +def _effective_simulation_fee_accounting_for_genvm( + accounting: dict | None, +) -> dict | None: + if not accounting: + return accounting + + snapshot = accounting.get("policy_snapshot") + policy = ( + StudioFeePolicy.from_snapshot(snapshot) + if isinstance(snapshot, dict) + else StudioFeePolicy.from_env() + ) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + execution_budget_per_round = int(fees["executionBudgetPerRound"]) + floor = policy.message_fee_params_budget_floor() + if execution_budget_per_round <= 0 or execution_budget_per_round >= floor: + return accounting + + adjusted = copy.deepcopy(accounting) + adjusted_fees = dict(fees) + adjusted_fees["executionBudgetPerRound"] = floor + adjusted["fees_distribution"] = adjusted_fees + adjusted["execution_budget_total"] = floor * get_leader_rounds(adjusted_fees) + return adjusted + + +def _first_present(source: dict | None, *keys: str): + if not isinstance(source, dict): + return None + for key in keys: + if key in source: + return source[key] + return None + + +def _int_param(value: Any, default: int | None = None) -> int | None: + if value is None: + return default + if isinstance(value, str): + return int(value, 16) if value.startswith("0x") else int(value) + return int(value) + + def send_raw_transaction( session: Session, msg_handler: IMessageHandler, @@ -1555,6 +2210,8 @@ def send_raw_transaction( sim_config: dict | None = None, ) -> str: """Persist a raw transaction using a request-scoped session.""" + _validate_genvm_executor_selector(sim_config) + accounts_manager = AccountsManager(session) transactions_processor = TransactionsProcessor(session) @@ -1570,6 +2227,7 @@ def send_raw_transaction( from_address = decoded_rollup_transaction.from_address value = decoded_rollup_transaction.value + total_spend = getattr(decoded_rollup_transaction, "total_spend", value) if not accounts_manager.is_valid_address(from_address): raise InvalidAddressError( @@ -1587,32 +2245,36 @@ def send_raw_transaction( raise InvalidTransactionError("Transaction signature verification failed") if isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs): - tx_id = decoded_rollup_transaction.data.tx_id - tx_id_hex = "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id - transactions_processor.set_transaction_appeal(tx_id_hex, True) - msg_handler.send_message( - log_event=LogEvent( - "transaction_appeal_updated", - EventType.INFO, - EventScope.CONSENSUS, - "Set transaction appealed", - { - "hash": tx_id_hex, - }, - ), - log_to_terminal=False, + _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) + return _handle_appeal_or_top_up_and_submit( + accounts_manager=accounts_manager, + transactions_processor=transactions_processor, + msg_handler=msg_handler, + decoded_rollup_transaction=decoded_rollup_transaction, + ) + elif isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs): + _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) + return _handle_top_up_fees( + accounts_manager=accounts_manager, + transactions_processor=transactions_processor, + decoded_rollup_transaction=decoded_rollup_transaction, ) - return tx_id_hex else: + _validate_fee_envelope(decoded_rollup_transaction) transaction_hash = consensus_service.generate_transaction_hash( signed_rollup_transaction ) to_address = decoded_rollup_transaction.to_address nonce = decoded_rollup_transaction.nonce value = decoded_rollup_transaction.value + total_spend = getattr(decoded_rollup_transaction, "total_spend", value) genlayer_transaction = transactions_parser.get_genlayer_transaction( decoded_rollup_transaction ) + _reject_genvm_executor_selector_unless_deploy( + sim_config, + is_deploy=genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT, + ) transaction_data = {} leader_only = False @@ -1659,6 +2321,8 @@ def send_raw_transaction( "contract_code": genlayer_transaction.data.contract_code, "calldata": genlayer_transaction.data.calldata, } + if fee_metadata := _fee_metadata(decoded_rollup_transaction): + transaction_data.update(fee_metadata) to_address = new_contract_address elif genlayer_transaction.type == TransactionType.RUN_CONTRACT: # Contract Call @@ -1668,13 +2332,17 @@ def send_raw_transaction( to_address, f"Invalid address to_address: {to_address}" ) - if accounts_manager.get_account(to_address) is None: + # Size-only lookup: do not hydrate current_state.data (can be + # tens of MB) just to test existence. + if live_state_column_size(session, to_address) is None: raise NotFoundError( message="Contract not found", data={"address": to_address}, ) transaction_data = {"calldata": genlayer_transaction.data.calldata} + if fee_metadata := _fee_metadata(decoded_rollup_transaction): + transaction_data.update(fee_metadata) # Check for duplicate before debit+insert to avoid TOCTOU races is_duplicate = transactions_processor.get_transaction_by_hash(transaction_hash) @@ -1689,44 +2357,50 @@ def send_raw_transaction( # Skip duplicates (resubmission of an already-known hash is benign) # and SEND txs (faucet/transfer; not subject to per-contract pile-up # because to_address is a user account, not a contract). + storage_reservation = None if is_duplicate is None and genlayer_transaction.type != TransactionType.SEND: _enforce_pending_queue_caps( transactions_processor=transactions_processor, to_address=to_address, from_address=from_address, ) + if genlayer_transaction.type == TransactionType.RUN_CONTRACT: + storage_reservation = enforce_contract_storage_quota( + session, to_address, transaction_hash + ) - # Debit sender BEFORE insert. Mint on demand if insufficient (Studio sandbox). - # Skip for SEND (execute_transfer handles it) and duplicates. - if ( - value > 0 - and from_address - and genlayer_transaction.type != TransactionType.SEND - and is_duplicate is None - ): - sender_balance = accounts_manager.get_account_balance(from_address) - if sender_balance < value: - shortfall = value - sender_balance - accounts_manager.credit_account_balance(from_address, shortfall) - accounts_manager.debit_account_balance(from_address, value) - - # Insert transaction into the database - transactions_processor.insert_transaction( - genlayer_transaction.from_address, - to_address, - transaction_data, - value, - genlayer_transaction.type.value, - nonce, - leader_only, - genlayer_transaction.max_rotations, - None, - transaction_hash, - genlayer_transaction.num_of_initial_validators, - sim_config, - None, # triggered_on - execution_mode, - ) + try: + # Debit sender BEFORE insert. Mint on demand if insufficient (Studio sandbox). + # Skip for SEND (execute_transfer handles it) and duplicates. + if ( + total_spend > 0 + and from_address + and genlayer_transaction.type != TransactionType.SEND + and is_duplicate is None + ): + _sandbox_debit_sender(accounts_manager, from_address, total_spend) + + # Insert transaction into the database + transactions_processor.insert_transaction( + genlayer_transaction.from_address, + to_address, + transaction_data, + value, + genlayer_transaction.type.value, + nonce, + leader_only, + genlayer_transaction.max_rotations, + None, + transaction_hash, + genlayer_transaction.num_of_initial_validators, + sim_config, + None, # triggered_on + execution_mode, + ) + except Exception: + if storage_reservation is not None: + storage_reservation.release() + raise # Post-insert verification: ensure the transaction is visible immediately try: @@ -1773,8 +2447,10 @@ def get_transactions_for_address( if not accounts_manager.is_valid_address(address): raise InvalidAddressError(address) - return transactions_processor.get_transactions_for_address( - address, TransactionAddressFilter(filter) + return _sanitize_rpc_private_keys( + transactions_processor.get_transactions_for_address( + address, TransactionAddressFilter(filter) + ) ) @@ -1825,7 +2501,9 @@ def get_block_by_number( ) block_details = transactions_processor.get_transactions_for_block( - block_number_int, include_full_tx=full_tx + block_number_int, + include_full_tx=full_tx, + include_contract_snapshot=False, ) if not block_details: @@ -1857,7 +2535,7 @@ def get_block_by_number( "uncles": [], } - return block_details + return _sanitize_rpc_private_keys(block_details) def get_gas_price() -> str: @@ -1876,7 +2554,9 @@ def get_transaction_receipt( transaction_hash: str, ) -> dict | None: - transaction = transactions_processor.get_transaction_by_hash(transaction_hash) + transaction = transactions_processor.get_transaction_by_hash( + transaction_hash, include_contract_snapshot=False + ) if not transaction: return None @@ -1943,7 +2623,9 @@ def get_block_by_hash( full_tx: bool = False, ) -> dict | None: - transaction = transactions_processor.get_transaction_by_hash(block_hash) + transaction = transactions_processor.get_transaction_by_hash( + block_hash, include_contract_snapshot=False + ) if not transaction: return None @@ -1973,7 +2655,7 @@ def get_block_by_hash( else: block_details["transactions"].append(block_hash) - return block_details + return _sanitize_rpc_private_keys(block_details) def get_contract(consensus_service: ConsensusService, contract_name: str) -> dict: @@ -2110,7 +2792,7 @@ def update_transaction_status( code=-32602, message=f"Transaction not found: {transaction_hash}", data={} ) - return updated_transaction + return _sanitize_rpc_private_keys(updated_transaction) def dev_get_pool_status(sqlalchemy_db) -> dict: diff --git a/backend/protocol_rpc/exceptions.py b/backend/protocol_rpc/exceptions.py index 25405eb46..990460d6b 100644 --- a/backend/protocol_rpc/exceptions.py +++ b/backend/protocol_rpc/exceptions.py @@ -120,3 +120,17 @@ def __init__( self, message: str = "Queue depth exceeded", data: Optional[Any] = None ): super().__init__(code=-32030, message=message, data=data) + + +class StorageQuotaExceeded(JSONRPCError): + """Per-contract daily snapshot-byte budget reached. + + Distinct from RateLimitExceeded (RPC frequency) and QueueDepthExceeded + (in-flight PENDING count). This one meters estimated contract_snapshot + bytes a single contract can persist per UTC day. + """ + + def __init__( + self, message: str = "Storage quota exceeded", data: Optional[Any] = None + ): + super().__init__(code=-32031, message=message, data=data) diff --git a/backend/protocol_rpc/fastapi_endpoint_generator.py b/backend/protocol_rpc/fastapi_endpoint_generator.py index d1dbdd888..5d74ab3e5 100644 --- a/backend/protocol_rpc/fastapi_endpoint_generator.py +++ b/backend/protocol_rpc/fastapi_endpoint_generator.py @@ -489,6 +489,7 @@ def register(func, method_name=None): partial(endpoints.get_finality_window_time, consensus), "sim_getFinalityWindowTime", ) + register(endpoints.get_studio_fee_config, "sim_getFeeConfig") register( partial(endpoints.get_contract, accounts_manager), "sim_getConsensusContract" ) @@ -546,6 +547,18 @@ def register(func, method_name=None): ), "sim_call", ) + register( + partial( + endpoints.sim_estimate_transaction_fees, + request_session, + accounts_manager, + msg_handler, + transactions_parser, + validators_manager, + genvm_manager, + ), + "sim_estimateTransactionFees", + ) # Ethereum-compatible endpoints register(partial(endpoints.get_balance, accounts_manager), "eth_getBalance") diff --git a/backend/protocol_rpc/fastapi_server.py b/backend/protocol_rpc/fastapi_server.py index 20e0b67c7..c797e8b60 100644 --- a/backend/protocol_rpc/fastapi_server.py +++ b/backend/protocol_rpc/fastapi_server.py @@ -13,6 +13,10 @@ # Load environment variables early so SENTRY_DSN is available for initialization load_dotenv() +from backend.protocol_rpc.api_key_redaction import ( + install_log_redaction, + scrub_sentry_event, +) from backend.protocol_rpc.app_lifespan import RPCAppSettings, rpc_app_lifespan from backend.protocol_rpc.dependencies import ( get_rpc_router_optional, @@ -26,12 +30,19 @@ from backend.protocol_rpc.websocket import GLOBAL_CHANNEL, websocket_handler +install_log_redaction() + SENTRY_DSN = os.getenv("SENTRY_DSN", None) if SENTRY_DSN: import sentry_sdk sentry_sdk.init( dsn=SENTRY_DSN, + # API keys can arrive as a path segment, and with send_default_pii and + # full trace sampling below, request URLs reach Sentry on *every* + # transaction. Scrub keys out before anything leaves the process. + before_send=scrub_sentry_event, + before_send_transaction=scrub_sentry_event, # Add data like request headers and IP for users, # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info send_default_pii=True, @@ -61,18 +72,29 @@ async def lifespan(app: FastAPI): # Create FastAPI app app = FastAPI(title="GenLayer Studio RPC API", version="1.0.0", lifespan=lifespan) -# Add CORS middleware +# Rate limiting is inner so CORS decorates short-circuit responses such as 429s. +app.add_middleware(RateLimitMiddleware) + +# This public RPC uses header-based API keys and no cookie authentication. app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_credentials=True, + allow_credentials=False, allow_methods=["*"], allow_headers=["*"], + # Browsers hide non-simple response headers from JS unless they are listed + # here, so without this the rate limit headers are readable by curl but not + # by genlayer-js in the browser — the client that most needs to self-pace. + expose_headers=[ + "Retry-After", + "X-RateLimit-Bucket", + "X-RateLimit-Window", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ], ) -# Add rate limiting middleware (executes after CORS, before route handler) -app.add_middleware(RateLimitMiddleware) - # Include health check endpoints app.include_router(health_router) @@ -81,9 +103,22 @@ async def lifespan(app: FastAPI): # JSON-RPC endpoint (supports single and batch requests) +# +# `/api/{api_key}` is the same endpoint with the key in the URL, matching how +# every major RPC provider does it (Alchemy `/v2/`, Infura `/v3/`). +# The EVM toolchain takes a single URL string and has nowhere to put a custom +# header — MetaMask's "Add network" being the clearest case — so the header +# form alone makes Studio unusable from those tools without a proxy in front. +# +# The key is consumed by RateLimitMiddleware before the request reaches here; +# the path parameter exists only so the route matches. Anything that changes +# which paths this route accepts must change `_is_rpc_path` in that middleware +# to match, or the new paths become unlimited and unauthenticated. @app.post("/api") +@app.post("/api/{api_key}") async def jsonrpc_endpoint( request: Request, + api_key: str | None = None, rpc_router: FastAPIRPCRouter | None = Depends(get_rpc_router_optional), ): """Main JSON-RPC endpoint with JSON-RPC 2.0 batch support.""" diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py new file mode 100644 index 000000000..6d84752b4 --- /dev/null +++ b/backend/protocol_rpc/fees.py @@ -0,0 +1,3531 @@ +from __future__ import annotations + +import base64 +import copy +import os +from dataclasses import dataclass, fields +from typing import Any, Callable + +import rlp +from eth_abi import decode, encode + +from backend.consensus.history import ( + actual_leader_rotations_by_round, + completed_consensus_rounds, +) +from backend.consensus.types import ConsensusRound + + +VALIDATORS_PER_ROUND = ( + 5, + 7, + 11, + 13, + 23, + 25, + 47, + 49, + 95, + 97, + 191, + 193, + 383, + 385, + 767, + 769, + 1535, + 1537, +) + +MIN_RECEIPT_BYTES = 512 +PROPOSE_RECEIPT_SLOTS = 7 +MESSAGE_REVEAL_LENGTH_SLOTS = 32 +NONDET_OUTPUT_LENGTH_BYTES = 32 +NODE_ROOT_SENTINEL = (1 << 256) - 1 +CALL_KEY_WILDCARD = "0x" + ("0" * 64) +MESSAGE_TYPE_EXTERNAL = 0 +MESSAGE_TYPE_INTERNAL = 1 +FEE_ACCOUNTING_KEY = "fee_accounting" + +APPEAL_SUCCESS_ROUNDS = { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.LEADER_APPEAL_SUCCESSFUL.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, +} +APPEAL_FAILED_ROUNDS = { + ConsensusRound.VALIDATOR_APPEAL_FAILED.value, + ConsensusRound.LEADER_APPEAL_FAILED.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, +} +ROUND_LEADER_MULTIPLIERS = { + ConsensusRound.LEADER_TIMEOUT.value: (1, 2), +} + +INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256,uint256,uint256,uint256[])" +EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256)" +MESSAGE_ALLOCATION_NODE_ABI_TYPE = ( + "(uint8,bool,uint256,address,bytes32,uint256,bytes)[]" +) +SUBMITTED_MESSAGE_ABI_TYPE = ( + "(uint8,address,uint256,bytes,bool,uint256,bytes,uint256,bytes,bytes32)[]" +) + +WEI_PER_GEN = 10**18 +DEFAULT_GEN_PER_TIME_UNIT = WEI_PER_GEN // 1_000 +DEFAULT_STORAGE_UNIT_PRICE = 1 +DEFAULT_RECEIPT_GAS_PRICE = 1 +DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 500_000 +DEFAULT_LEADER_TIMEUNITS_ALLOCATION = 100 +DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION = 200 +DEFAULT_PRICE_CAP_HEADROOM_BPS = 12_000 +DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM = 10_000 +GENVM_UNMETERED_DATA_FEE_BUCKET = (1 << 256) - 1 + + +class FeeValidationError(ValueError): + pass + + +class InvalidNumOfValidators(FeeValidationError): + pass + + +class InvalidAppealRounds(FeeValidationError): + pass + + +class InsufficientFees(FeeValidationError): + pass + + +class BudgetTooLow(FeeValidationError): + pass + + +class MaxPriceExceeded(FeeValidationError): + pass + + +class MessageAllocationsNotEqualBudget(FeeValidationError): + pass + + +class AllocationTreeMalformed(FeeValidationError): + pass + + +class AllocationLifecycleBudgetInsufficient(FeeValidationError): + pass + + +class AllocationTreeBudgetInconsistent(FeeValidationError): + pass + + +class AllocationSubtreeMismatch(FeeValidationError): + pass + + +class AllocationDuplicateKey(FeeValidationError): + pass + + +class AllocationTreeTooDeep(FeeValidationError): + pass + + +class ExternalAllocationInvalid(FeeValidationError): + pass + + +class InvalidFeeParams(FeeValidationError): + pass + + +class Mode1MessageFeesRequireGenVMPerEmissionSupport(FeeValidationError): + """GenVM must expose per-emission feeParams/declaredBudget before Mode 1 is safe.""" + + +class InvalidAppealBond(FeeValidationError): + pass + + +class MessageDeclaredBudgetInsufficient(FeeValidationError): + pass + + +class MessageFeesReportMismatch(FeeValidationError): + pass + + +class MessageBudgetExceeded(FeeValidationError): + pass + + +def _with_cap_headroom( + value: int, headroom_bps: int = DEFAULT_PRICE_CAP_HEADROOM_BPS +) -> int: + if value <= 0: + return 0 + return (value * headroom_bps + 9_999) // 10_000 + + +def _with_padding(value: int, padding_bps: int) -> int: + if value <= 0: + return 0 + return (value * int(padding_bps) + 9_999) // 10_000 + + +class MessageNoMatchingAllocation(FeeValidationError): + pass + + +class MessageEmissionPhaseMismatch(FeeValidationError): + pass + + +class MessageFeeParamsMismatch(FeeValidationError): + pass + + +class TooManyMessages(FeeValidationError): + pass + + +@dataclass(frozen=True) +class StudioFeePolicy: + gen_per_time_unit: int = 0 + storage_unit_price: int = 0 + receipt_gas_price: int = 0 + intrinsic_gas: int = 21_000 + bootloader_overhead: int = 60_000 + gas_per_changed_slot: int = 1_000 + calldata_gas_per_byte: int = 16 + fixed_propose_receipt_gas: int = 210_000 + fixed_message_reveal_gas: int = 100_000 + receipt_wrapper_bytes: int = 1_024 + extra_exec_gas: int = 210_000 + max_allocation_tree_depth: int = 5 + max_messages_per_tx: int = 0 + + @classmethod + def from_env(cls) -> "StudioFeePolicy": + return cls( + gen_per_time_unit=_env_int( + "GENLAYER_STUDIO_GEN_PER_TIME_UNIT", DEFAULT_GEN_PER_TIME_UNIT + ), + storage_unit_price=_env_int( + "GENLAYER_STUDIO_STORAGE_UNIT_PRICE", DEFAULT_STORAGE_UNIT_PRICE + ), + receipt_gas_price=_env_int( + "GENLAYER_STUDIO_RECEIPT_GAS_PRICE", DEFAULT_RECEIPT_GAS_PRICE + ), + intrinsic_gas=_env_int("GENLAYER_STUDIO_INTRINSIC_GAS", 21_000), + bootloader_overhead=_env_int("GENLAYER_STUDIO_BOOTLOADER_OVERHEAD", 60_000), + gas_per_changed_slot=_env_int( + "GENLAYER_STUDIO_GAS_PER_CHANGED_SLOT", 1_000 + ), + calldata_gas_per_byte=_env_int("GENLAYER_STUDIO_CALLDATA_GAS_PER_BYTE", 16), + fixed_propose_receipt_gas=_env_int( + "GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS", 210_000 + ), + fixed_message_reveal_gas=_env_int( + "GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS", 100_000 + ), + receipt_wrapper_bytes=_env_int( + "GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES", 1_024 + ), + extra_exec_gas=_env_int("GENLAYER_STUDIO_EXTRA_EXEC_GAS", 210_000), + max_allocation_tree_depth=_env_int( + "GENLAYER_STUDIO_MAX_ALLOCATION_TREE_DEPTH", 5 + ), + max_messages_per_tx=_env_int("GENLAYER_STUDIO_MAX_MESSAGES_PER_TX", 0), + ) + + def estimate_propose_receipt_bytes(self, eq_outputs_length: int) -> int: + return self.receipt_wrapper_bytes + max(0, int(eq_outputs_length)) + + def estimate_propose_receipt_gas(self, receipt_bytes: int) -> int: + return ( + self.fixed_propose_receipt_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(receipt_bytes)) * self.calldata_gas_per_byte) + + (PROPOSE_RECEIPT_SLOTS * self.gas_per_changed_slot) + ) + + def estimate_message_reveal_gas( + self, + message_bytes: int, + message_count: int, + ) -> int: + return ( + self.fixed_message_reveal_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(message_bytes)) * self.calldata_gas_per_byte) + + ( + (MESSAGE_REVEAL_LENGTH_SLOTS + max(0, int(message_count))) + * self.gas_per_changed_slot + ) + ) + + def estimate_consensus_message_reveal_gas( + self, + message_bytes: int, + message_count: int, + ) -> int: + return self.estimate_receipt_gas( + measured_exec_gas=0, + calldata_length=message_bytes, + slots_changed=message_count, + ) + + def estimate_receipt_gas( + self, + measured_exec_gas: int = 0, + calldata_length: int = MIN_RECEIPT_BYTES, + slots_changed: int = 7, + ) -> int: + measured = max(0, int(measured_exec_gas)) + if measured > 0: + measured += self.extra_exec_gas + return ( + measured + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(calldata_length)) * self.calldata_gas_per_byte) + + (max(0, int(slots_changed)) * self.gas_per_changed_slot) + ) + + def estimate_nondet_output_start_gas(self) -> int: + return NONDET_OUTPUT_LENGTH_BYTES * self.calldata_gas_per_byte + + def message_fee_params_budget_floor(self) -> int: + return self.minimum_execution_budget_per_round() + + def minimum_execution_budget_per_round(self) -> int: + if self.receipt_gas_price <= 0: + return 0 + message_receipt_start_gas = ( + self.fixed_propose_receipt_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (PROPOSE_RECEIPT_SLOTS * self.gas_per_changed_slot) + + self.fixed_message_reveal_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (MESSAGE_REVEAL_LENGTH_SLOTS * self.gas_per_changed_slot) + ) + return ( + message_receipt_start_gas + self.estimate_nondet_output_start_gas() + ) * self.receipt_gas_price + + def fee_accounting_enabled(self) -> bool: + return ( + self.gen_per_time_unit > 0 + or self.storage_unit_price > 0 + or self.receipt_gas_price > 0 + ) + + def to_snapshot(self) -> dict[str, int]: + return {field.name: int(getattr(self, field.name)) for field in fields(self)} + + @classmethod + def from_snapshot(cls, snapshot: dict[str, Any]) -> "StudioFeePolicy": + return cls(**{field.name: int(snapshot[field.name]) for field in fields(cls)}) + + +def _accounting_policy( + accounting: dict[str, Any] | None, + override: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + if override is not None: + return override + snapshot = (accounting or {}).get("policy_snapshot") + if isinstance(snapshot, dict): + try: + return StudioFeePolicy.from_snapshot(snapshot) + except (KeyError, TypeError, ValueError): + pass + return StudioFeePolicy() + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + try: + return int(raw) + except ValueError as exc: + raise ValueError(f"{name} must be an integer, got {raw!r}") from exc + + +def _int_field(fees_distribution: dict[str, Any], field: str) -> int: + return int(fees_distribution.get(field, 0) or 0) + + +def normalize_fees_distribution( + fees_distribution: dict[str, Any], +) -> dict[str, int | list[int]]: + return { + "leaderTimeunitsAllocation": _int_field( + fees_distribution, "leaderTimeunitsAllocation" + ), + "validatorTimeunitsAllocation": _int_field( + fees_distribution, "validatorTimeunitsAllocation" + ), + "appealRounds": _int_field(fees_distribution, "appealRounds"), + "executionBudgetPerRound": _int_field( + fees_distribution, "executionBudgetPerRound" + ), + "executionConsumed": _int_field(fees_distribution, "executionConsumed"), + "totalMessageFees": _int_field(fees_distribution, "totalMessageFees"), + "rotations": [ + int(rotation) for rotation in fees_distribution.get("rotations", []) + ], + "maxPriceGenPerTimeUnit": _int_field( + fees_distribution, "maxPriceGenPerTimeUnit" + ), + "storageFeeMaxGasPrice": _int_field(fees_distribution, "storageFeeMaxGasPrice"), + "receiptFeeMaxGasPrice": _int_field(fees_distribution, "receiptFeeMaxGasPrice"), + } + + +def get_leader_rounds(fees_distribution: dict[str, Any]) -> int: + fees = normalize_fees_distribution(fees_distribution) + return sum(rotation + 1 for rotation in fees["rotations"]) + int( + fees["appealRounds"] + ) + + +def get_leader_rounds_through_round( + fees_distribution: dict[str, Any], + final_round: int, + consensus_history: dict[str, Any] | None = None, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + rotations = fees["rotations"] + if not isinstance(rotations, list) or len(rotations) == 0: + raise InvalidAppealRounds("InvalidAppealRounds") + + final_round = max(0, int(final_round)) + actual_rotations = actual_leader_rotations_by_round(consensus_history) + total = _leader_slots_for_round(rotations, 0, actual_rotations) + rotations_index = 1 + for offset in range(1, min(final_round, int(fees["appealRounds"]) * 2) + 1): + if offset % 2 == 1: + total += 1 + elif rotations_index < len(rotations): + total += _leader_slots_for_round( + rotations, rotations_index, actual_rotations, round_index=offset + ) + rotations_index += 1 + return total + + +def calculate_time_unit_fees_through_round( + fees_distribution: dict[str, Any], + num_of_validators: int, + final_round: int, + policy: StudioFeePolicy | None = None, + consensus_history: dict[str, Any] | None = None, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + policy = policy or StudioFeePolicy() + validator_index = _validator_index(num_of_validators) + rotations = fees["rotations"] + if not isinstance(rotations, list) or len(rotations) == 0: + raise InvalidAppealRounds("InvalidAppealRounds") + + capped_final_round = min(max(0, int(final_round)), int(fees["appealRounds"]) * 2) + if validator_index + capped_final_round >= len(VALIDATORS_PER_ROUND): + raise InvalidNumOfValidators("InvalidNumOfValidators") + + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + actual_rotations = actual_leader_rotations_by_round(consensus_history) + round_outcomes = _round_outcomes(consensus_history) + total = _calculate_fee_for_round( + VALIDATORS_PER_ROUND[validator_index], + _leader_slots_for_round(rotations, 0, actual_rotations), + leader_timeunits, + validator_timeunits, + leader_multiplier=_round_leader_multiplier(round_outcomes.get(0)), + ) + rotations_index = 1 + for offset in range(1, capped_final_round + 1): + if offset % 2 == 0 and rotations_index < len(rotations): + rotations_this_round = _leader_slots_for_round( + rotations, + rotations_index, + actual_rotations, + round_index=offset, + ) + rotations_index += 1 + else: + rotations_this_round = 1 + total += _calculate_fee_for_round( + VALIDATORS_PER_ROUND[validator_index + offset], + rotations_this_round, + leader_timeunits, + validator_timeunits, + leader_multiplier=_round_leader_multiplier(round_outcomes.get(offset)), + ) + + max_price = int(fees["maxPriceGenPerTimeUnit"]) + if policy.gen_per_time_unit > 0: + if max_price > 0 and policy.gen_per_time_unit > max_price: + raise MaxPriceExceeded("MaxPriceExceeded") + total *= policy.gen_per_time_unit + return total + + +def calculate_round_fees( + fees_distribution: dict[str, Any], + num_of_validators: int, + round: int = 0, + policy: StudioFeePolicy | None = None, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + policy = policy or StudioFeePolicy() + + if round == 0: + total = _calculate_initial_round_total(fees, num_of_validators) + else: + total = _calculate_appeal_round_total(fees, round) + + total = _apply_time_unit_price(total, int(fees["maxPriceGenPerTimeUnit"]), policy) + _enforce_gas_price_cap( + policy.storage_unit_price, int(fees["storageFeeMaxGasPrice"]) + ) + _enforce_gas_price_cap(policy.receipt_gas_price, int(fees["receiptFeeMaxGasPrice"])) + + if round == 0: + total += int(fees["executionBudgetPerRound"]) * get_leader_rounds(fees) + + return total + + +def required_fee_deposit( + fees_distribution: dict[str, Any], + num_of_validators: int, + policy: StudioFeePolicy | None = None, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + return calculate_round_fees(fees, num_of_validators, 0, policy) + int( + fees["totalMessageFees"] + ) + + +def default_transaction_fees_for_policy( + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, int | list[int]], int]: + policy = policy or StudioFeePolicy() + execution_budget_per_round = ( + max( + DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, + policy.message_fee_params_budget_floor(), + ) + if policy.storage_unit_price > 0 or policy.receipt_gas_price > 0 + else 0 + ) + distribution = _serializable_fees_distribution( + { + "leaderTimeunitsAllocation": ( + DEFAULT_LEADER_TIMEUNITS_ALLOCATION + if policy.gen_per_time_unit > 0 + else 0 + ), + "validatorTimeunitsAllocation": ( + DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION + if policy.gen_per_time_unit > 0 + else 0 + ), + "appealRounds": 0, + "executionBudgetPerRound": execution_budget_per_round, + "executionConsumed": 0, + "totalMessageFees": 0, + "rotations": [0], + "maxPriceGenPerTimeUnit": _with_cap_headroom(policy.gen_per_time_unit), + "storageFeeMaxGasPrice": _with_cap_headroom(policy.storage_unit_price), + "receiptFeeMaxGasPrice": _with_cap_headroom(policy.receipt_gas_price), + } + ) + fee_value = ( + required_fee_deposit(distribution, VALIDATORS_PER_ROUND[0], policy) + if policy.fee_accounting_enabled() + else 0 + ) + return distribution, fee_value + + +def studio_fee_config(policy: StudioFeePolicy | None = None) -> dict[str, Any]: + policy = policy or StudioFeePolicy.from_env() + distribution, fee_value = default_transaction_fees_for_policy(policy) + return { + "enabled": policy.fee_accounting_enabled(), + "policy": { + "genPerTimeUnit": str(policy.gen_per_time_unit), + "storageUnitPrice": str(policy.storage_unit_price), + "receiptGasPrice": str(policy.receipt_gas_price), + "intrinsicGas": str(policy.intrinsic_gas), + "bootloaderOverhead": str(policy.bootloader_overhead), + "gasPerChangedSlot": str(policy.gas_per_changed_slot), + "calldataGasPerByte": str(policy.calldata_gas_per_byte), + "fixedProposeReceiptGas": str(policy.fixed_propose_receipt_gas), + "fixedMessageRevealGas": str(policy.fixed_message_reveal_gas), + "receiptWrapperBytes": str(policy.receipt_wrapper_bytes), + "extraExecGas": str(policy.extra_exec_gas), + "messageFeeParamsBudgetFloor": str( + policy.message_fee_params_budget_floor() + ), + "maxAllocationTreeDepth": str(policy.max_allocation_tree_depth), + "maxMessagesPerTx": str(policy.max_messages_per_tx), + }, + "capabilities": { + "messageFees": { + "mode1": { + "accounting": True, + "genvmExecution": False, + }, + "mode2": { + "accounting": True, + "genvmExecution": True, + }, + "externalFinalization": { + "accounting": True, + "genvmExecution": True, + }, + } + }, + "defaultFees": { + "distribution": { + key: ( + [str(item) for item in value] + if isinstance(value, list) + else str(value) + ) + for key, value in distribution.items() + }, + "feeValue": str(fee_value), + }, + } + + +def validate_transaction_fee_deposit( + *, + fees_distribution: dict[str, Any], + message_allocations: list[dict[str, Any]] | None = None, + num_of_validators: int, + submitted_value: int, + user_value: int, + policy: StudioFeePolicy | None = None, + allow_low_execution_budget: bool = False, +) -> int: + policy = policy or StudioFeePolicy() + fees = normalize_fees_distribution(fees_distribution) + execution_budget_per_round = int(fees["executionBudgetPerRound"]) + if ( + not allow_low_execution_budget + and execution_budget_per_round > 0 + and execution_budget_per_round < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + + if submitted_value < user_value: + raise InsufficientFees("InsufficientFees") + + required_fee_value = required_fee_deposit(fees, num_of_validators, policy) + paid_fee_value = submitted_value - user_value + if paid_fee_value < required_fee_value: + raise InsufficientFees("InsufficientFees") + + validate_message_allocations( + message_allocations or [], + total_message_fees=int(fees["totalMessageFees"]), + policy=policy, + ) + + return required_fee_value + + +def create_fee_accounting( + *, + fees_distribution: dict[str, Any], + message_allocations: list[dict[str, Any]] | None = None, + num_of_validators: int, + submitted_value: int, + user_value: int, + sender: str | None = None, + policy: StudioFeePolicy | None = None, + allow_low_execution_budget: bool = False, +) -> dict[str, Any]: + policy = policy or StudioFeePolicy() + required = validate_transaction_fee_deposit( + fees_distribution=fees_distribution, + message_allocations=message_allocations or [], + num_of_validators=num_of_validators, + submitted_value=submitted_value, + user_value=user_value, + policy=policy, + allow_low_execution_budget=allow_low_execution_budget, + ) + fee_value = max(0, int(submitted_value) - int(user_value)) + return _new_fee_accounting( + fees_distribution=fees_distribution, + message_allocations=message_allocations or [], + num_of_validators=num_of_validators, + fee_value=fee_value, + required_fee_value=required, + user_value=user_value, + sender=sender, + source="submission", + policy=policy, + ) + + +def create_child_fee_accounting( + *, + message: dict[str, Any], + parent_fees_distribution: dict[str, Any] | None, + message_allocations: list[dict[str, Any]] | None = None, + sender: str | None = None, + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, Any], dict[str, Any]]: + policy = policy or StudioFeePolicy() + declared_budget = int(message.get("declaredBudget", 0) or 0) + if declared_budget <= 0: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) + capless_child_fees = _fees_distribution_from_internal_params( + fee_params, + total_message_fees=0, + parent_fees_distribution=normalize_fees_distribution({}), + ) + try: + child_primary = validate_transaction_fee_deposit( + fees_distribution=capless_child_fees, + message_allocations=[], + num_of_validators=VALIDATORS_PER_ROUND[0], + submitted_value=declared_budget, + user_value=0, + policy=policy, + ) + except InsufficientFees as exc: + raise MessageDeclaredBudgetInsufficient( + "MessageDeclaredBudgetInsufficient" + ) from exc + if declared_budget < child_primary: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + parent_fees = ( + normalize_fees_distribution(parent_fees_distribution) + if parent_fees_distribution + else normalize_fees_distribution({}) + ) + child_message_allocations = _child_allocations_from_message_subtree( + message, + message_allocations or [], + ) + # Mode 1 children have no allocation subtree but still receive the remainder + # of their declared budget as a message-fee bucket for their own children. + child_message_budget = declared_budget - child_primary + child_fees = _fees_distribution_from_internal_params( + fee_params, + total_message_fees=child_message_budget, + parent_fees_distribution=parent_fees, + ) + validate_message_allocations( + child_message_allocations, + total_message_fees=int(child_fees["totalMessageFees"]), + policy=policy, + ) + user_value = int(message.get("value", 0) or 0) + accounting = _new_fee_accounting( + fees_distribution=child_fees, + message_allocations=child_message_allocations, + num_of_validators=VALIDATORS_PER_ROUND[0], + fee_value=declared_budget, + required_fee_value=declared_budget, + user_value=user_value, + sender=sender, + source="internal_message", + policy=policy, + ) + return child_fees, accounting + + +def genvm_fee_context( + accounting: dict[str, Any] | None, + policy: StudioFeePolicy | None = None, +) -> tuple[list[int] | None, dict[str, str] | None]: + if not accounting: + return None, None + + policy = _accounting_policy(accounting, policy) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + bucket_total = int(fees["executionBudgetPerRound"]) + + gas_data = { + "storageUnitPrice": str(policy.storage_unit_price), + "receiptGasPerByte": str( + policy.receipt_gas_price * policy.calldata_gas_per_byte + ), + "gasPerChangedSlot": str( + policy.receipt_gas_price * policy.gas_per_changed_slot + ), + "intrinsicGas": str(policy.receipt_gas_price * policy.intrinsic_gas), + "bootloaderOverhead": str( + policy.receipt_gas_price * policy.bootloader_overhead + ), + "fixedProposeReceiptGas": str( + policy.receipt_gas_price * policy.fixed_propose_receipt_gas + ), + "fixedMessageRevealGas": str( + policy.receipt_gas_price * policy.fixed_message_reveal_gas + ), + "genPerTimeUnit": str(policy.gen_per_time_unit), + } + message_bucket_total = int(accounting.get("message_fee_budget", 0) or 0) + if bucket_total > 0 or message_bucket_total > 0: + data_bucket_total = ( + bucket_total if bucket_total > 0 else GENVM_UNMETERED_DATA_FEE_BUCKET + ) + bucket_totals = [ + data_bucket_total, + message_bucket_total, + GENVM_UNMETERED_DATA_FEE_BUCKET, + GENVM_UNMETERED_DATA_FEE_BUCKET, + ] + else: + bucket_totals = None + return bucket_totals, gas_data + + +def genvm_message_fee_allocation( + accounting: dict[str, Any] | None, + *, + address_factory: Callable[[str], Any] | None = None, +) -> list[dict[str, Any]]: + if not accounting: + return _genvm_unmetered_message_fee_allocation() + + if not accounting.get("message_allocations"): + if int(accounting.get("message_fee_budget", 0) or 0) > 0: + raise Mode1MessageFeesRequireGenVMPerEmissionSupport( + "Mode1MessageFeesRequireGenVMPerEmissionSupport: fee-bearing " + "GenVM messages require a message allocation tree" + ) + return [] + + fees_distribution = normalize_fees_distribution( + accounting.get("fees_distribution") or {} + ) + studio_nodes = [ + _serializable_message_allocation(raw_node) + for raw_node in accounting.get("message_allocations") or [] + ] + genvm_nodes = [ + _genvm_message_allocation_node( + node, + address_factory, + fees_distribution, + ) + for node in studio_nodes + ] + roots: list[dict[str, Any]] = [] + for index, node in enumerate(studio_nodes): + parent_index = int(node["parentIndex"]) + if parent_index == NODE_ROOT_SENTINEL: + roots.append(genvm_nodes[index]) + continue + if 0 <= parent_index < len(genvm_nodes): + genvm_nodes[parent_index]["children"].append(genvm_nodes[index]) + + if roots: + roots.append(_genvm_external_legacy_fallback_message_fee_allocation()) + return roots + + +def apply_fee_top_up( + accounting: dict[str, Any], + *, + fees_distribution: dict[str, Any], + amount: int, + sender: str | None = None, + num_of_validators: int = VALIDATORS_PER_ROUND[0], + perform_fee_checks: bool = True, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + policy = _accounting_policy(accounting, policy) + amount = int(amount) + incoming = normalize_fees_distribution(fees_distribution) + incoming_message_fees = int(incoming["totalMessageFees"]) + if incoming_message_fees > amount: + raise InsufficientFees("InsufficientFees") + + primary_amount = amount - incoming_message_fees + if perform_fee_checks: + required_primary = calculate_round_fees(incoming, num_of_validators, 0, policy) + if required_primary > primary_amount: + raise InsufficientFees("InsufficientFeesForRound") + + updated = copy.deepcopy(accounting) + merged = merge_fees_distribution(updated.get("fees_distribution") or {}, incoming) + if ( + int(merged["executionBudgetPerRound"]) > 0 + and int(merged["executionBudgetPerRound"]) + < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + + updated["fees_distribution"] = merged + updated["paid_fee_value"] = int(updated.get("paid_fee_value", 0)) + amount + updated["primary_fee_budget"] = ( + int(updated.get("primary_fee_budget", 0)) + primary_amount + ) + updated["message_fee_budget"] = ( + int(updated.get("message_fee_budget", 0)) + incoming_message_fees + ) + updated["execution_budget_total"] = int(merged["executionBudgetPerRound"]) * ( + get_leader_rounds(merged) + ) + updated.setdefault("top_ups", []).append( + { + "sender": sender, + "amount": amount, + "primaryAmount": primary_amount, + "messageFees": incoming_message_fees, + "feesDistribution": _serializable_fees_distribution(incoming), + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated + + +def record_appeal_bond( + accounting: dict[str, Any], + *, + amount: int, + appealer: str | None, + current_round: int = 0, + status: str | None = None, + round: int | None = None, + fees_distribution: dict[str, Any] | None = None, + top_up_and_submit: bool = False, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + policy = _accounting_policy(updated, policy) + amount = int(amount) + + min_required = 0 + if status is not None: + min_required = calculate_min_appeal_bond( + updated.get("fees_distribution") or {}, + current_round=current_round, + status=status, + policy=policy, + ) + if amount < min_required: + raise InvalidAppealBond("InvalidAppealBond") + + if top_up_and_submit: + updated["primary_fee_budget"] = ( + int(updated.get("primary_fee_budget", 0)) + amount + ) + updated["paid_fee_value"] = int(updated.get("paid_fee_value", 0)) + amount + merged = normalize_fees_distribution(updated.get("fees_distribution") or {}) + merged["appealRounds"] = int(merged["appealRounds"]) + 1 + updated["fees_distribution"] = merged + updated["execution_budget_total"] = int( + merged["executionBudgetPerRound"] + ) * get_leader_rounds(merged) + + updated["appeal_bonds_total"] = int(updated.get("appeal_bonds_total", 0)) + amount + updated.setdefault("appeal_bonds", []).append( + { + "appealer": appealer, + "amount": amount, + "round": current_round if round is None else round, + "status": status, + "minimumRequired": min_required, + "topUpAndSubmit": bool(top_up_and_submit), + "feesDistributionIgnored": fees_distribution is not None + and top_up_and_submit, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated + + +def calculate_min_appeal_bond( + fees_distribution: dict[str, Any], + *, + current_round: int, + status: str, + policy: StudioFeePolicy | None = None, +) -> int: + policy = policy or StudioFeePolicy() + fees = normalize_fees_distribution(fees_distribution) + current_round = max(0, int(current_round)) + status_value = str(status).upper() + if status_value in {"LEADER_TIMEOUT", "UNDETERMINED"}: + target_round = current_round + 2 + if target_round >= len(VALIDATORS_PER_ROUND): + raise InvalidNumOfValidators("InvalidNumOfValidators") + rotations = ( + int(fees["rotations"][target_round - 1]) + if target_round - 1 < len(fees["rotations"]) + else 0 + ) + total = _calculate_fee_for_round( + VALIDATORS_PER_ROUND[target_round], + rotations, + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + return ( + total * policy.gen_per_time_unit if policy.gen_per_time_unit > 0 else total + ) + + if status_value in {"VALIDATORS_TIMEOUT", "ACCEPTED"}: + target_round = current_round + 1 + if target_round >= len(VALIDATORS_PER_ROUND): + raise InvalidNumOfValidators("InvalidNumOfValidators") + total = VALIDATORS_PER_ROUND[target_round] * int( + fees["validatorTimeunitsAllocation"] + ) + return ( + total * policy.gen_per_time_unit if policy.gen_per_time_unit > 0 else total + ) + + return 0 + + +def fill_message_fee_payload_from_allocation( + accounting: dict[str, Any], + message: dict[str, Any], +) -> dict[str, Any]: + allocations = accounting.get("message_allocations") or [] + if not allocations: + return copy.deepcopy(message) + + resolved = _resolve_allocation(allocations, message) + if resolved is None: + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") + + index, allocation = resolved + updated = copy.deepcopy(message) + message_type = int(updated.get("messageType", MESSAGE_TYPE_INTERNAL)) + if message_type == MESSAGE_TYPE_EXTERNAL: + # External messages have no accepted/finalized lifecycle. GenVM main + # carries `on: finalized` on external allocation nodes only to satisfy + # the request schema, so do not phase-check them here. + if not _message_has_fee_params(updated): + updated["feeParams"] = allocation["feeParams"] + updated["callKey"] = _normalize_call_key( + updated.get("callKey", allocation["callKey"]) + ) + updated["messageFeeMode"] = "external" + return updated + + if bool(allocation["onAcceptance"]) != bool(message.get("onAcceptance", False)): + raise MessageEmissionPhaseMismatch("MessageEmissionPhaseMismatch") + + if int(updated.get("declaredBudget", 0) or 0) == 0: + updated["declaredBudget"] = int(allocation["budget"]) + if not _message_has_fee_params(updated): + updated["feeParams"] = allocation["feeParams"] + updated["callKey"] = _normalize_call_key( + updated.get("callKey", allocation["callKey"]) + ) + expected_subtree = _allocation_subtree(allocations, index) + if not updated.get("allocationSubtree"): + updated["allocationSubtree"] = expected_subtree + elif ( + _canonical_allocation_subtree(updated["allocationSubtree"]) != expected_subtree + ): + raise AllocationSubtreeMismatch("AllocationSubtreeMismatch") + updated["messageFeeMode"] = "mode2" + return updated + + +def consume_message_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + reported_total: int | None = None, + policy: StudioFeePolicy | None = None, + reimburse_external: bool = True, +) -> dict[str, Any]: + policy = _accounting_policy(accounting, policy) + if policy.max_messages_per_tx > 0 and len(messages) > policy.max_messages_per_tx: + raise TooManyMessages("TooManyMessages") + + updated = copy.deepcopy(accounting) + recalculated_total = 0 + external_reimbursement_total = 0 + + for message in messages: + message_type = _message_type_value(message) + if message_type == MESSAGE_TYPE_EXTERNAL: + external_reimbursement_total += _consume_external_message_fee( + updated, + message, + policy, + reimburse_external, + ) + continue + + if message_type == MESSAGE_TYPE_INTERNAL: + recalculated_total += _consume_internal_message_fee( + updated, + message, + policy, + ) + + if reported_total is not None and int(reported_total) < recalculated_total: + raise MessageFeesReportMismatch("MessageFeesReportMismatch") + + attempted = ( + int(updated.get("message_fee_consumed", 0)) + + recalculated_total + + external_reimbursement_total + ) + message_budget = int(updated.get("message_fee_budget", 0)) + if attempted > message_budget: + raise MessageBudgetExceeded("MessageBudgetExceeded") + + updated["message_fee_consumed"] = attempted + updated.setdefault("message_consumption_events", []).append( + { + "consumed": recalculated_total + external_reimbursement_total, + "internalConsumed": recalculated_total, + "externalReimbursed": external_reimbursement_total, + "remaining": message_budget - attempted, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated + + +def _message_type_value(message: dict[str, Any]) -> int: + return int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + + +def _consume_external_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], + policy: StudioFeePolicy, + reimburse_external: bool, +) -> int: + if int(message.get("declaredBudget", 0) or 0) != 0: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + return _reserve_external_execution( + accounting, + message, + policy, + reimburse=reimburse_external, + ) + + +def _consume_internal_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], + policy: StudioFeePolicy, +) -> int: + declared_budget = int(message.get("declaredBudget", 0) or 0) + fee_params = decode_internal_message_fee_params(message.get("feeParams", b"")) + _validate_internal_execution_budget_floor(fee_params, policy) + + min_required = min_message_primary_fees(fee_params, policy) + if declared_budget < min_required: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + _consume_against_allocation(accounting, message, declared_budget) + return declared_budget + + +def _validate_internal_execution_budget_floor( + fee_params: dict[str, Any], + policy: StudioFeePolicy, +) -> None: + execution_budget_per_round = int(fee_params["executionBudgetPerRound"]) + if ( + execution_budget_per_round > 0 + and execution_budget_per_round < policy.message_fee_params_budget_floor() + ): + raise BudgetTooLow("BudgetTooLow") + + +def record_reveal_message_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + reported_total: int | None = None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = consume_message_fees( + accounting, + messages, + reported_total=reported_total, + policy=policy, + reimburse_external=False, + ) + updated["message_fees_recorded_at_reveal"] = True + return updated + + +def record_external_message_execution_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + policy = _accounting_policy(updated, policy) + reimbursement_total = 0 + remainder_total = 0 + updated_any = False + + for message in messages: + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + != MESSAGE_TYPE_EXTERNAL + ): + continue + + event_index = _find_unexecuted_external_message_event(updated, message) + if event_index is None: + continue + + event = updated.setdefault("external_message_events", [])[event_index] + reservation = int(event.get("reservation", 0) or 0) + gas_limit = int(event.get("gasLimit", 0) or 0) + locked_price = int(event.get("lockedGasPrice", 0) or 0) + gas_used = int(message.get("gasUsed", 0) or 0) + effective_gas = min(gas_limit, gas_used) + reimbursement = min(reservation, effective_gas * locked_price) + remainder = reservation - reimbursement + + attempted = ( + int(updated.get("message_fee_consumed", 0)) + + reimbursement_total + + reimbursement + ) + message_budget = int(updated.get("message_fee_budget", 0)) + if attempted > message_budget: + raise MessageBudgetExceeded("MessageBudgetExceeded") + + event["gasUsed"] = gas_used + event["reimbursement"] = reimbursement + event["remainder"] = remainder + event["executionRecorded"] = True + reimbursement_total += reimbursement + remainder_total += remainder + updated_any = True + + if updated_any: + updated["message_fee_consumed"] = ( + int(updated.get("message_fee_consumed", 0)) + reimbursement_total + ) + updated["external_message_fee_reimbursed"] = ( + int(updated.get("external_message_fee_reimbursed", 0)) + reimbursement_total + ) + updated["external_message_fee_remainder"] = ( + int(updated.get("external_message_fee_remainder", 0)) + remainder_total + ) + updated.setdefault("message_consumption_events", []).append( + { + "consumed": reimbursement_total, + "internalConsumed": 0, + "externalReimbursed": reimbursement_total, + "remaining": max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)), + ), + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + + return updated + + +def refund_failed_external_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], +) -> dict[str, Any]: + if int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) != MESSAGE_TYPE_EXTERNAL: + return copy.deepcopy(accounting) + + updated = copy.deepcopy(accounting) + event_index = _find_unrefunded_external_message_event(updated, message) + if event_index is None: + return updated + + event = updated.setdefault("external_message_events", [])[event_index] + reservation = int(event.get("reservation", 0) or 0) + reimbursement = int(event.get("reimbursement", 0) or 0) + remainder = int(event.get("remainder", 0) or 0) + + # Execution-level failures still spent gas. Consensus reimburses the + # executor and leaves the external execution reservation consumed; only the + # external message value leg is refunded outside this fee-accounting helper. + event["failureRefunded"] = True + updated.setdefault("external_message_refund_events", []).append( + { + "recipient": event.get("recipient"), + "callKey": event.get("callKey"), + "allocationIndex": int(event.get("allocationIndex", 0) or 0), + "reservation": reservation, + "reimbursement": reimbursement, + "remainder": remainder, + "feeRefunded": 0, + } + ) + _refresh_message_fee_accounting_report_if_present(updated) + return updated + + +def unwind_reveal_message_fees( + accounting: dict[str, Any], + messages: list[dict[str, Any]], + *, + acceptance_dispatched: bool = False, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + internal_refund = 0 + external_unreserved = 0 + external_reimbursement_rolled_back = 0 + external_remainder_rolled_back = 0 + + for message in messages: + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + == MESSAGE_TYPE_EXTERNAL + ): + ( + reservation, + reimbursement, + remainder, + ) = _unreserve_external_message_fee(updated, message) + external_unreserved += reservation + external_reimbursement_rolled_back += reimbursement + external_remainder_rolled_back += remainder + continue + + if ( + int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + != MESSAGE_TYPE_INTERNAL + ): + continue + if acceptance_dispatched and bool(message.get("onAcceptance", False)): + continue + + declared_budget = int(message.get("declaredBudget", 0) or 0) + if declared_budget <= 0: + continue + internal_refund += declared_budget + _decrement_allocation_consumed(updated, message, declared_budget) + + if internal_refund > 0: + updated["message_fee_consumed"] = max( + 0, + int(updated.get("message_fee_consumed", 0)) - internal_refund, + ) + + if ( + internal_refund > 0 + or external_unreserved > 0 + or external_reimbursement_rolled_back > 0 + ): + updated.setdefault("message_fee_unwind_events", []).append( + { + "acceptanceDispatched": bool(acceptance_dispatched), + "internalRefunded": internal_refund, + "externalUnreserved": external_unreserved, + "externalReimbursementRolledBack": (external_reimbursement_rolled_back), + "externalRemainderRolledBack": external_remainder_rolled_back, + "remaining": max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)) + - int(updated.get("message_fee_refunded", 0)), + ), + } + ) + + # A re-reveal replaces or discards the previous message set. Keep the + # aggregate unwind event, but reopen receipt-based message consumption. + updated.pop("message_fees_recorded_from_receipt", None) + updated["message_consumption_events"] = [] + _refresh_message_fee_accounting_report_if_present(updated) + return updated + + +def record_execution_fee_consumption( + accounting: dict[str, Any], + receipt: Any | None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + policy = _accounting_policy(updated, policy) + message_payloads = _receipt_message_fee_payloads(updated, receipt) + reported_message_fees_total = _receipt_reported_message_fees_total(receipt) + if ( + message_payloads + and _receipt_messages_require_fee_validation(updated, message_payloads) + and not updated.get("message_fees_recorded_from_receipt") + and not updated.get("message_consumption_events") + ): + updated = consume_message_fees( + updated, + message_payloads, + reported_total=reported_message_fees_total, + policy=policy, + ) + updated["message_fees_recorded_from_receipt"] = True + if reported_message_fees_total is not None: + updated["reported_message_fees_total"] = reported_message_fees_total + + fee_report = _receipt_fee_report(receipt, policy, message_payloads) + if fee_report is not None: + updated["execution_fee_report"] = fee_report + _attach_message_fee_accounting_report(updated) + _attach_recommended_fee_preset(updated, policy) + consumed = _receipt_data_fees_consumed(receipt) + if consumed is None: + return updated + updated["genvm_fee_consumed_buckets"] = consumed + bucket_report = _genvm_fee_bucket_report( + consumed, + execution_budget_per_round=_execution_budget_per_round(updated), + ) + execution_consumed = _chargeable_execution_fee_buckets( + consumed, + fee_report, + policy, + receipt, + ) + execution_bucket_report = _genvm_fee_bucket_report( + execution_consumed, + execution_budget_per_round=_execution_budget_per_round(updated), + ) + updated["execution_fee_consumed"] = sum(execution_consumed) + updated["execution_fee_consumed_buckets"] = execution_consumed + updated["genvm_fee_bucket_report"] = bucket_report + execution_metering_report = _execution_metering_report( + chargeable_bucket_report=execution_bucket_report, + genvm_bucket_report=bucket_report, + ) + updated["execution_fee_report"] = { + **(updated.get("execution_fee_report") or {}), + "genvmBuckets": bucket_report, + "chargeableExecution": execution_bucket_report, + "executionMetering": execution_metering_report, + } + budget_exhaustion_reason = _receipt_budget_exhaustion_reason( + receipt, + execution_bucket_report, + ) + if budget_exhaustion_reason is not None: + updated["execution_fee_report"][ + "budgetExhaustionReason" + ] = budget_exhaustion_reason + if len(consumed) > 2: + updated["genvm_message_fee_consumed"] = int(consumed[2]) + _attach_message_fee_accounting_report(updated) + _attach_recommended_fee_preset(updated, policy) + return updated + + +def settle_fee_accounting( + accounting: dict[str, Any], + *, + receipt: Any | None = None, + reason: str = "finalized", + actual_final_round: int | None = None, + num_of_validators: int | None = None, + consensus_history: dict[str, Any] | None = None, + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, Any], int]: + policy = _accounting_policy(accounting, policy) + updated = record_execution_fee_consumption(accounting, receipt, policy) + if updated.get("status") in {"settled", "canceled"}: + return updated, 0 + + primary_budget = int(updated.get("primary_fee_budget", 0)) + execution_budget = int(updated.get("execution_budget_total", 0)) + primary_required = int(updated.get("primary_fee_required", 0)) + fees_distribution = updated.get("fees_distribution") or {} + if actual_final_round is not None: + validators = int( + num_of_validators or updated.get("num_of_initial_validators") or 0 + ) + time_unit_budget = calculate_time_unit_fees_through_round( + fees_distribution, + validators, + actual_final_round, + policy, + consensus_history=consensus_history, + ) + execution_budget = int( + normalize_fees_distribution(fees_distribution)["executionBudgetPerRound"] + ) * get_leader_rounds_through_round( + fees_distribution, + actual_final_round, + consensus_history=consensus_history, + ) + updated["actual_final_round"] = int(actual_final_round) + else: + time_unit_budget = max(0, primary_required - execution_budget) + execution_spent = min( + int(updated.get("execution_fee_consumed", 0)), execution_budget + ) + primary_spent = min(primary_budget, time_unit_budget + execution_spent) + primary_refund = max( + 0, primary_budget - primary_spent - int(updated.get("primary_fee_refunded", 0)) + ) + + message_refund = max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)) + - int(updated.get("message_fee_refunded", 0)), + ) + refund = primary_refund + message_refund + bond_settlements, bond_payout = _settle_appeal_bonds( + updated, + consensus_history=consensus_history, + cancel=False, + ) + + updated["status"] = "settled" + updated["settlement_reason"] = reason + updated["primary_fee_spent"] = primary_spent + updated["primary_fee_refunded"] = ( + int(updated.get("primary_fee_refunded", 0)) + primary_refund + ) + updated["message_fee_refunded"] = ( + int(updated.get("message_fee_refunded", 0)) + message_refund + ) + updated["total_refunded"] = int(updated.get("total_refunded", 0)) + refund + updated["appeal_bonds_payout_total"] = ( + int(updated.get("appeal_bonds_payout_total", 0)) + bond_payout + ) + updated["appeal_bond_settlements"] = bond_settlements + updated.setdefault("refunds", []).append( + { + "reason": reason, + "primary": primary_refund, + "message": message_refund, + "amount": refund, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated, refund + + +def cancel_fee_accounting( + accounting: dict[str, Any], + *, + reason: str = "canceled", +) -> tuple[dict[str, Any], int]: + updated = copy.deepcopy(accounting) + if updated.get("status") in {"settled", "canceled"}: + return updated, 0 + + primary_refund = max( + 0, + int(updated.get("primary_fee_budget", 0)) + - int(updated.get("primary_fee_spent", 0)) + - int(updated.get("primary_fee_refunded", 0)), + ) + message_refund = max( + 0, + int(updated.get("message_fee_budget", 0)) + - int(updated.get("message_fee_consumed", 0)) + - int(updated.get("message_fee_refunded", 0)), + ) + refund = primary_refund + message_refund + bond_settlements, bond_payout = _settle_appeal_bonds( + updated, + consensus_history=None, + cancel=True, + ) + updated["status"] = "canceled" + updated["settlement_reason"] = reason + updated["primary_fee_refunded"] = ( + int(updated.get("primary_fee_refunded", 0)) + primary_refund + ) + updated["message_fee_refunded"] = ( + int(updated.get("message_fee_refunded", 0)) + message_refund + ) + updated["total_refunded"] = int(updated.get("total_refunded", 0)) + refund + updated["appeal_bonds_payout_total"] = ( + int(updated.get("appeal_bonds_payout_total", 0)) + bond_payout + ) + updated["appeal_bond_settlements"] = bond_settlements + updated.setdefault("refunds", []).append( + { + "reason": reason, + "primary": primary_refund, + "message": message_refund, + "amount": refund, + } + ) + _refresh_message_fee_accounting_report_if_present(updated) + return updated, refund + + +def merge_fees_distribution( + current: dict[str, Any], incoming: dict[str, Any] +) -> dict[str, Any]: + current_fees = normalize_fees_distribution(current) + incoming_fees = normalize_fees_distribution(incoming) + is_initial = len(current_fees["rotations"]) == 0 + merged = dict(current_fees) + if is_initial: + merged["leaderTimeunitsAllocation"] = incoming_fees["leaderTimeunitsAllocation"] + merged["validatorTimeunitsAllocation"] = incoming_fees[ + "validatorTimeunitsAllocation" + ] + merged["appealRounds"] = incoming_fees["appealRounds"] + + merged["executionBudgetPerRound"] = int(merged["executionBudgetPerRound"]) + int( + incoming_fees["executionBudgetPerRound"] + ) + merged["totalMessageFees"] = int(merged["totalMessageFees"]) + int( + incoming_fees["totalMessageFees"] + ) + merged["rotations"] = list(merged["rotations"]) + list(incoming_fees["rotations"]) + for cap in ( + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", + ): + incoming_cap = int(incoming_fees[cap]) + if incoming_cap > 0 and ( + is_initial or (int(merged[cap]) > 0 and incoming_cap > int(merged[cap])) + ): + merged[cap] = incoming_cap + return _serializable_fees_distribution(merged) + + +def validate_message_allocations( + message_allocations: list[dict[str, Any]], + *, + total_message_fees: int, + policy: StudioFeePolicy | None = None, +) -> None: + if not message_allocations: + return + + policy = policy or StudioFeePolicy() + root_sum = 0 + root_keys: set[tuple[int, str, str]] = set() + external_keys: set[tuple[str, str]] = set() + min_required_by_index: dict[int, int] = {} + + for index, raw_node in enumerate(message_allocations): + root_sum += _validate_message_allocation_node( + index, + raw_node, + message_allocations, + root_keys, + external_keys, + min_required_by_index, + policy, + ) + + if root_sum != total_message_fees: + raise MessageAllocationsNotEqualBudget("MessageAllocationsNotEqualBudget") + + _validate_child_budget_consistency(message_allocations, min_required_by_index) + _validate_allocation_tree_depth(message_allocations, policy) + _validate_sibling_duplicates(message_allocations) + + +def _validate_message_allocation_node( + index: int, + raw_node: dict[str, Any], + message_allocations: list[dict[str, Any]], + root_keys: set[tuple[int, str, str]], + external_keys: set[tuple[str, str]], + min_required_by_index: dict[int, int], + policy: StudioFeePolicy, +) -> int: + node = _normalize_message_allocation(raw_node) + _validate_allocation_parent(index, node, message_allocations) + + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + _validate_external_allocation(node, external_keys) + return int(node["budget"]) + + if int(node["messageType"]) != MESSAGE_TYPE_INTERNAL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + min_required = _validate_internal_allocation_budget(node, policy) + min_required_by_index[index] = min_required + return _root_allocation_budget(node, root_keys) + + +def _validate_allocation_parent( + index: int, + node: dict[str, Any], + message_allocations: list[dict[str, Any]], +) -> None: + parent_index = int(node["parentIndex"]) + if parent_index == NODE_ROOT_SENTINEL: + return + if parent_index >= index: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + parent_node = _normalize_message_allocation(message_allocations[parent_index]) + if int(parent_node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + +def _validate_internal_allocation_budget( + node: dict[str, Any], + policy: StudioFeePolicy, +) -> int: + internal_fee_params = decode_internal_message_fee_params(node["feeParams"]) + min_required = _internal_allocation_min_required(node, internal_fee_params, policy) + if int(node["budget"]) < min_required: + raise AllocationLifecycleBudgetInsufficient( + "AllocationLifecycleBudgetInsufficient" + ) + + _validate_internal_execution_budget_floor(internal_fee_params, policy) + return min_required + + +def _internal_allocation_min_required( + node: dict[str, Any], + internal_fee_params: dict[str, Any], + policy: StudioFeePolicy, +) -> int: + min_primary = min_message_primary_fees(internal_fee_params, policy) + lifecycle_multiplier = ( + int(internal_fee_params["appealRounds"]) + 1 + if bool(node["onAcceptance"]) + else 1 + ) + return min_primary * lifecycle_multiplier + + +def _root_allocation_budget( + node: dict[str, Any], + root_keys: set[tuple[int, str, str]], +) -> int: + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + return 0 + + key = _allocation_key(node) + if key in root_keys: + raise AllocationDuplicateKey("AllocationDuplicateKey") + root_keys.add(key) + return int(node["budget"]) + + +def _validate_child_budget_consistency( + message_allocations: list[dict[str, Any]], + min_required_by_index: dict[int, int], +) -> None: + for index, raw_node in enumerate(message_allocations): + node = _normalize_message_allocation(raw_node) + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + continue + child_sum = _child_allocation_budget_sum(message_allocations, index) + if int(node["budget"]) < min_required_by_index[index] + child_sum: + raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") + + +def _child_allocation_budget_sum( + message_allocations: list[dict[str, Any]], + parent_index: int, +) -> int: + child_sum = 0 + for raw_child in message_allocations[parent_index + 1 :]: + child = _normalize_message_allocation(raw_child) + if int(child["parentIndex"]) == parent_index: + child_sum += int(child["budget"]) + return child_sum + + +def decode_internal_message_fee_params(fee_params: bytes | str) -> dict[str, Any]: + raw_fee_params = _fee_params_bytes(fee_params) + try: + decoded = decode([INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], raw_fee_params)[0] + except Exception as exc: + raise InvalidFeeParams("InvalidFeeParams") from exc + return { + "leaderTimeunitsAllocation": int(decoded[0]), + "validatorTimeunitsAllocation": int(decoded[1]), + "appealRounds": int(decoded[2]), + "executionBudgetPerRound": int(decoded[3]), + "rotations": [int(rotation) for rotation in decoded[4]], + } + + +def decode_external_message_fee_params(fee_params: bytes | str) -> dict[str, int]: + raw_fee_params = _fee_params_bytes(fee_params) + try: + decoded = decode([EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], raw_fee_params)[0] + except Exception as exc: + raise InvalidFeeParams("InvalidFeeParams") from exc + return { + "gasLimit": int(decoded[0]), + "maxGasPrice": int(decoded[1]), + } + + +def min_message_primary_fees( + internal_fee_params: dict[str, Any], + policy: StudioFeePolicy | None = None, +) -> int: + return calculate_round_fees( + { + "leaderTimeunitsAllocation": int( + internal_fee_params["leaderTimeunitsAllocation"] + ), + "validatorTimeunitsAllocation": int( + internal_fee_params["validatorTimeunitsAllocation"] + ), + "appealRounds": int(internal_fee_params["appealRounds"]), + "executionBudgetPerRound": int( + internal_fee_params["executionBudgetPerRound"] + ), + "executionConsumed": 0, + "totalMessageFees": 0, + "rotations": internal_fee_params["rotations"], + "maxPriceGenPerTimeUnit": 0, + "storageFeeMaxGasPrice": 0, + "receiptFeeMaxGasPrice": 0, + }, + VALIDATORS_PER_ROUND[0], + 0, + policy, + ) + + +def _calculate_initial_round_total( + fees: dict[str, int | list[int]], + num_of_validators: int, +) -> int: + validator_index = _validator_index(num_of_validators) + if int(fees["appealRounds"]) != len(fees["rotations"]) - 1: + raise InvalidAppealRounds("InvalidAppealRounds") + return _calculate_fees(fees, validator_index) + + +def _calculate_appeal_round_total( + fees: dict[str, int | list[int]], + round: int, +) -> int: + if round >= len(VALIDATORS_PER_ROUND): + raise InvalidNumOfValidators("InvalidNumOfValidators") + + rotations = ( + int(fees["rotations"][round - 1]) if round - 1 < len(fees["rotations"]) else 0 + ) + return _calculate_fee_for_round( + VALIDATORS_PER_ROUND[round], + rotations, + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + + +def _apply_time_unit_price( + total: int, + max_price: int, + policy: StudioFeePolicy, +) -> int: + if policy.gen_per_time_unit <= 0: + return total + if max_price > 0 and policy.gen_per_time_unit > max_price: + raise MaxPriceExceeded("MaxPriceExceeded") + return total * policy.gen_per_time_unit + + +def _enforce_gas_price_cap(actual_price: int, max_price: int) -> None: + if max_price > 0 and actual_price > max_price: + raise MaxPriceExceeded("MaxPriceExceeded") + + +def _validator_index(num_of_validators: int) -> int: + if num_of_validators > VALIDATORS_PER_ROUND[-1]: + raise InvalidNumOfValidators("InvalidNumOfValidators") + for index, validators in enumerate(VALIDATORS_PER_ROUND): + if validators >= num_of_validators: + if validators != num_of_validators: + raise InvalidNumOfValidators("InvalidNumOfValidators") + return index + raise InvalidNumOfValidators("InvalidNumOfValidators") + + +def _calculate_fees( + fees_distribution: dict[str, int | list[int]], validator_index: int +) -> int: + rotations = fees_distribution["rotations"] + if not isinstance(rotations, list) or len(rotations) == 0: + raise InvalidAppealRounds("InvalidAppealRounds") + + leader_timeunits = int(fees_distribution["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees_distribution["validatorTimeunitsAllocation"]) + calculated_fees = _calculate_fee_for_round( + VALIDATORS_PER_ROUND[validator_index], + int(rotations[0]) + 1, + leader_timeunits, + validator_timeunits, + ) + + rotations_index = 1 + rotations_this_round = 1 + appeal_rounds = int(fees_distribution["appealRounds"]) + if validator_index + (appeal_rounds * 2) >= len(VALIDATORS_PER_ROUND): + raise InvalidNumOfValidators("InvalidNumOfValidators") + for offset in range(1, (appeal_rounds * 2) + 1): + round_validators = VALIDATORS_PER_ROUND[validator_index + offset] + if offset % 2 == 0 and rotations_index < len(rotations): + rotations_this_round = int(rotations[rotations_index]) + 1 + rotations_index += 1 + elif offset % 2 == 1: + rotations_this_round = 1 + + calculated_fees += _calculate_fee_for_round( + round_validators, + rotations_this_round, + leader_timeunits, + validator_timeunits, + ) + + return calculated_fees + + +def _calculate_fee_for_round( + num_of_validators: int, + rotations: int, + leader_timeunits_allocation: int, + validator_timeunits_allocation: int, + leader_multiplier: tuple[int, int] = (1, 1), +) -> int: + leader_num, leader_den = leader_multiplier + leader_total = rotations * leader_timeunits_allocation + leader_fee = leader_total * leader_num // leader_den + validator_fee = rotations * (num_of_validators * validator_timeunits_allocation) + return leader_fee + validator_fee + + +def _leader_slots_for_round( + funded_rotations: list[int], + funded_index: int, + actual_rotations: dict[int, int], + *, + round_index: int | None = None, +) -> int: + funded_slots = int(funded_rotations[funded_index]) + 1 + if not actual_rotations: + return funded_slots + actual_round_index = funded_index if round_index is None else round_index + actual_slots = int(actual_rotations.get(actual_round_index, 0)) + 1 + return min(funded_slots, actual_slots) + + +def _round_outcomes(consensus_history: dict[str, Any] | None) -> dict[int, str]: + return { + index: str(entry.get("consensus_round") or "") + for index, entry in enumerate(completed_consensus_rounds(consensus_history)) + } + + +def _round_leader_multiplier(outcome: str | None) -> tuple[int, int]: + return ROUND_LEADER_MULTIPLIERS.get(str(outcome or ""), (1, 1)) + + +def _settle_appeal_bonds( + accounting: dict[str, Any], + *, + consensus_history: dict[str, Any] | None, + cancel: bool, +) -> tuple[list[dict[str, Any]], int]: + existing = accounting.get("appeal_bond_settlements") + if isinstance(existing, list) and existing: + return copy.deepcopy(existing), 0 + + outcomes = _round_outcomes(consensus_history) + settlements: list[dict[str, Any]] = [] + payout_total = 0 + for index, bond in enumerate(accounting.get("appeal_bonds") or []): + if not isinstance(bond, dict): + continue + amount = int(bond.get("amount", 0) or 0) + appealer = bond.get("appealer") + appealed_round = int(bond.get("round", 0) or 0) + outcome_index, outcome = _appeal_outcome_after_round(outcomes, appealed_round) + if cancel and outcome is None: + status = "returned" + payout = amount + elif outcome in APPEAL_SUCCESS_ROUNDS: + status = "successful" + payout = amount * 3 // 2 + else: + status = "forfeited" + payout = 0 + payout_total += payout + entry = { + "bondIndex": index, + "appealer": appealer, + "amount": amount, + "round": appealed_round, + "status": status, + "payout": payout, + } + if outcome is not None: + entry["outcomeRound"] = outcome_index + entry["outcome"] = outcome + if status == "forfeited": + entry["bond_forfeited"] = amount + settlements.append(entry) + return settlements, payout_total + + +def _appeal_outcome_after_round( + outcomes: dict[int, str], appealed_round: int +) -> tuple[int | None, str | None]: + for round_index in sorted(outcomes): + if round_index <= appealed_round: + continue + outcome = outcomes[round_index] + if outcome in APPEAL_SUCCESS_ROUNDS or outcome in APPEAL_FAILED_ROUNDS: + return round_index, outcome + return None, None + + +def _normalize_message_allocation(node: dict[str, Any]) -> dict[str, Any]: + return { + "messageType": int(node.get("messageType", 0)), + "onAcceptance": bool(node.get("onAcceptance", False)), + "parentIndex": int(node.get("parentIndex", 0)), + "recipient": str(node.get("recipient", "")).lower(), + "callKey": _normalize_call_key(node.get("callKey", CALL_KEY_WILDCARD)), + "budget": int(node.get("budget", 0)), + "feeParams": node.get("feeParams", b""), + } + + +def _validate_external_allocation( + node: dict[str, Any], + external_keys: set[tuple[str, str]], +) -> None: + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + external_fee_params = decode_external_message_fee_params(node["feeParams"]) + gas_limit = int(external_fee_params["gasLimit"]) + max_gas_price = int(external_fee_params["maxGasPrice"]) + if gas_limit == 0 or max_gas_price == 0: + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + + per_call = gas_limit * max_gas_price + budget = int(node["budget"]) + if budget == 0 or budget % per_call != 0: + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + + external_key = (str(node["recipient"]).lower(), str(node["callKey"]).lower()) + if external_key in external_keys: + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + external_keys.add(external_key) + + +def _validate_allocation_tree_depth( + message_allocations: list[dict[str, Any]], + policy: StudioFeePolicy, +) -> None: + depth: list[int] = [] + cap = policy.max_allocation_tree_depth or 5 + for index, raw_node in enumerate(message_allocations): + node = _normalize_message_allocation(raw_node) + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + depth.append(1) + continue + parent_index = int(node["parentIndex"]) + current_depth = ( + 1 if parent_index == NODE_ROOT_SENTINEL else depth[parent_index] + 1 + ) + if current_depth > cap: + raise AllocationTreeTooDeep("AllocationTreeTooDeep") + depth.append(current_depth) + + +def _validate_sibling_duplicates(message_allocations: list[dict[str, Any]]) -> None: + sibling_keys: set[tuple[int, int, str, str]] = set() + for raw_node in message_allocations: + node = _normalize_message_allocation(raw_node) + parent_index = int(node["parentIndex"]) + if parent_index == NODE_ROOT_SENTINEL: + continue + key = (parent_index, *_allocation_key(node)) + if key in sibling_keys: + raise AllocationDuplicateKey("AllocationDuplicateKey") + sibling_keys.add(key) + + +def _allocation_key(node: dict[str, Any]) -> tuple[int, str, str]: + return ( + int(node["messageType"]), + str(node["recipient"]).lower(), + str(node["callKey"]).lower(), + ) + + +def _fee_params_bytes(fee_params: bytes | str) -> bytes: + if isinstance(fee_params, str): + return bytes.fromhex(fee_params.removeprefix("0x")) + return bytes(fee_params) + + +def _new_fee_accounting( + *, + fees_distribution: dict[str, Any], + message_allocations: list[dict[str, Any]], + num_of_validators: int, + fee_value: int, + required_fee_value: int, + user_value: int, + sender: str | None, + source: str, + policy: StudioFeePolicy, +) -> dict[str, Any]: + fees = _serializable_fees_distribution(fees_distribution) + total_message_fees = int(fees["totalMessageFees"]) + execution_budget_total = int(fees["executionBudgetPerRound"]) * get_leader_rounds( + fees + ) + primary_required = max(0, int(required_fee_value) - total_message_fees) + return { + "version": 1, + "source": source, + "status": "active", + "policy_snapshot": policy.to_snapshot(), + "sender": sender, + "user_value": int(user_value), + "num_of_initial_validators": int(num_of_validators), + "paid_fee_value": int(fee_value), + "required_fee_value": int(required_fee_value), + "primary_fee_required": primary_required, + "primary_fee_budget": max(0, int(fee_value) - total_message_fees), + "primary_fee_spent": 0, + "primary_fee_refunded": 0, + "execution_budget_total": execution_budget_total, + "execution_fee_consumed": 0, + "execution_fee_consumed_buckets": [], + "genvm_fee_consumed_buckets": [], + "genvm_message_fee_consumed": 0, + "execution_fee_report": {}, + "message_fee_budget": total_message_fees, + "message_fee_consumed": 0, + "message_fee_refunded": 0, + "external_message_fee_reserved": 0, + "external_message_fee_reimbursed": 0, + "external_message_fee_remainder": 0, + "external_message_events": [], + "appeal_bonds": [], + "appeal_bonds_total": 0, + "total_refunded": 0, + "refunds": [], + "top_ups": [ + { + "sender": sender, + "amount": int(fee_value), + "primaryAmount": max(0, int(fee_value) - total_message_fees), + "messageFees": total_message_fees, + "feesDistribution": fees, + } + ], + "fees_distribution": fees, + "message_allocations": [ + _serializable_message_allocation(allocation) + for allocation in message_allocations + ], + "allocation_consumed": {}, + "message_consumption_events": [], + } + + +def _serializable_fees_distribution( + fees_distribution: dict[str, Any], +) -> dict[str, int | list[int]]: + return normalize_fees_distribution(fees_distribution) + + +def _serializable_message_allocation(node: dict[str, Any]) -> dict[str, Any]: + normalized = _normalize_message_allocation(node) + return { + "messageType": int(normalized["messageType"]), + "onAcceptance": bool(normalized["onAcceptance"]), + "parentIndex": int(normalized["parentIndex"]), + "recipient": str(normalized["recipient"]).lower(), + "callKey": _normalize_call_key(normalized["callKey"]), + "budget": int(normalized["budget"]), + "feeParams": _fee_params_hex(normalized["feeParams"]), + } + + +def _fees_distribution_from_internal_params( + fee_params: dict[str, Any], + *, + total_message_fees: int, + parent_fees_distribution: dict[str, Any], +) -> dict[str, Any]: + return { + "leaderTimeunitsAllocation": int(fee_params["leaderTimeunitsAllocation"]), + "validatorTimeunitsAllocation": int(fee_params["validatorTimeunitsAllocation"]), + "appealRounds": int(fee_params["appealRounds"]), + "executionBudgetPerRound": int(fee_params["executionBudgetPerRound"]), + "executionConsumed": 0, + "totalMessageFees": int(total_message_fees), + "rotations": [int(rotation) for rotation in fee_params["rotations"]], + "maxPriceGenPerTimeUnit": int( + parent_fees_distribution.get("maxPriceGenPerTimeUnit", 0) + ), + "storageFeeMaxGasPrice": int( + parent_fees_distribution.get("storageFeeMaxGasPrice", 0) + ), + "receiptFeeMaxGasPrice": int( + parent_fees_distribution.get("receiptFeeMaxGasPrice", 0) + ), + } + + +def _genvm_message_fee_params( + node: dict[str, Any], + fees_distribution: dict[str, Any], +) -> dict[str, Any]: + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + decoded = decode_external_message_fee_params(node["feeParams"]) + return { + "External": { + "gas_limit": int(decoded["gasLimit"]), + "max_gas_price": int(decoded["maxGasPrice"]), + }, + } + + decoded = decode_internal_message_fee_params(node["feeParams"]) + return { + "Internal": { + "leader_timeunits_allocation": int(decoded["leaderTimeunitsAllocation"]), + "validator_timeunits_allocation": int( + decoded["validatorTimeunitsAllocation"] + ), + "execution_budget_per_round": int(decoded["executionBudgetPerRound"]), + "rotations": [int(rotation) for rotation in decoded["rotations"]], + # Studio's chain-canonical per-node ABI carries only the first five + # internal fields. The three price caps live on fees_distribution, + # so add them only at the GenVM manager boundary. + "max_price_gen_per_time_unit": int( + fees_distribution.get("maxPriceGenPerTimeUnit", 0) + ), + "storage_fee_max_gas_price": int( + fees_distribution.get("storageFeeMaxGasPrice", 0) + ), + "receipt_fee_max_gas_price": int( + fees_distribution.get("receiptFeeMaxGasPrice", 0) + ), + }, + } + + +def _genvm_message_allocation_node( + node: dict[str, Any], + address_factory: Callable[[str], Any] | None, + fees_distribution: dict[str, Any], +) -> dict[str, Any]: + return { + "recipient": _genvm_recipient(node, address_factory), + "call_key": _genvm_call_key(node), + "budget": int(node["budget"]), + "on": _genvm_message_on(node), + "fee_params": _genvm_message_fee_params(node, fees_distribution), + "children": [], + } + + +def _genvm_message_on(node: dict[str, Any]) -> str: + # `decided` is GenVM's name for the lifecycle Studio calls `accepted`. + if int(node["messageType"]) == MESSAGE_TYPE_EXTERNAL: + return "finalized" + return "decided" if bool(node["onAcceptance"]) else "finalized" + + +def _genvm_recipient( + node: dict[str, Any], + address_factory: Callable[[str], Any] | None, +) -> Any | None: + recipient = str(node["recipient"]).lower() + if recipient == "": + return None + return address_factory(recipient) if address_factory else recipient + + +def _genvm_call_key(node: dict[str, Any]) -> bytes | None: + call_key = _normalize_call_key(node["callKey"]) + if call_key == CALL_KEY_WILDCARD: + return None + return bytes.fromhex(call_key.removeprefix("0x")) + + +def _genvm_unmetered_message_fee_allocation() -> list[dict[str, Any]]: + budget = 2**200 + internal_fee_params = { + "Internal": { + "leader_timeunits_allocation": 5, + "validator_timeunits_allocation": 5, + "execution_budget_per_round": 2**10, + "rotations": [4, 4, 4, 4, 4], + "max_price_gen_per_time_unit": 2**200, + "storage_fee_max_gas_price": 2**200, + "receipt_fee_max_gas_price": 2**200, + }, + } + return [ + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": 2**200, + "max_gas_price": 0, + }, + }, + "children": [], + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": { + "Internal": { + **internal_fee_params["Internal"], + "storage_fee_max_gas_price": 20, + "receipt_fee_max_gas_price": 20, + }, + }, + "children": [], + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "decided", + "fee_params": internal_fee_params, + "children": [], + }, + ] + + +def _genvm_external_legacy_fallback_message_fee_allocation() -> dict[str, Any]: + return { + "recipient": None, + "call_key": None, + "budget": 2**200, + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": 2**200, + "max_gas_price": 0, + }, + }, + "children": [], + } + + +def _allocation_subtree( + message_allocations: list[dict[str, Any]], + root_index: int, +) -> list[dict[str, Any]]: + root = copy.deepcopy( + _serializable_message_allocation(message_allocations[root_index]) + ) + root["parentIndex"] = NODE_ROOT_SENTINEL + old_to_new: dict[int, int] = {root_index: 0} + subtree: list[dict[str, Any]] = [root] + for index, raw_node in enumerate(message_allocations): + if index == root_index: + continue + node = _serializable_message_allocation(raw_node) + parent_index = int(node["parentIndex"]) + if parent_index not in old_to_new: + continue + + old_to_new[index] = len(subtree) + copied = copy.deepcopy(node) + copied["parentIndex"] = old_to_new[parent_index] + subtree.append(copied) + return subtree + + +def _child_allocations_from_message_subtree( + message: dict[str, Any], + allocation_subtree: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if not allocation_subtree: + return [] + + root = _serializable_message_allocation(allocation_subtree[0]) + if not _is_matched_root_allocation(message, root): + return [ + _serializable_message_allocation(allocation) + for allocation in allocation_subtree + ] + + child_allocations: list[dict[str, Any]] = [] + for raw_node in allocation_subtree[1:]: + node = _serializable_message_allocation(raw_node) + copied = copy.deepcopy(node) + parent_index = int(copied["parentIndex"]) + copied["parentIndex"] = ( + NODE_ROOT_SENTINEL if parent_index == 0 else parent_index - 1 + ) + child_allocations.append(copied) + return child_allocations + + +def _canonical_allocation_subtree( + allocation_subtree: list[dict[str, Any]], +) -> list[dict[str, Any]]: + canonical = [] + for allocation in allocation_subtree: + node = _submitted_allocation_node(allocation) + canonical.append( + { + "messageType": int(node[0]), + "onAcceptance": bool(node[1]), + "parentIndex": int(node[2]), + "recipient": str(node[3]).lower(), + "callKey": "0x" + bytes(node[4]).hex(), + "budget": int(node[5]), + "feeParams": "0x" + bytes(node[6]).hex(), + } + ) + return canonical + + +def _is_matched_root_allocation( + message: dict[str, Any], + allocation: dict[str, Any], +) -> bool: + if int(allocation["parentIndex"]) != NODE_ROOT_SENTINEL: + return False + if int(allocation["messageType"]) != int( + message.get("messageType", MESSAGE_TYPE_INTERNAL) + ): + return False + if bool(allocation["onAcceptance"]) != bool(message.get("onAcceptance", False)): + return False + if ( + str(allocation["recipient"]).lower() + != str(message.get("recipient", "")).lower() + ): + return False + if _normalize_call_key(allocation["callKey"]) != _normalize_call_key( + message.get("callKey", CALL_KEY_WILDCARD) + ): + return False + if _fee_params_hex(allocation["feeParams"]) != _fee_params_hex( + message.get("feeParams", b"") + ): + return False + return True + + +def _consume_against_allocation( + accounting: dict[str, Any], + message: dict[str, Any], + declared_budget: int, +) -> None: + allocations = accounting.get("message_allocations") or [] + if not allocations: + return + + resolved = _resolve_allocation(allocations, message) + if resolved is None: + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") + + index, allocation = resolved + if bool(allocation["onAcceptance"]) != bool(message.get("onAcceptance", False)): + raise MessageEmissionPhaseMismatch("MessageEmissionPhaseMismatch") + + if _fee_params_hex(allocation["feeParams"]) != _fee_params_hex( + message.get("feeParams", b"") + ): + raise MessageFeeParamsMismatch("MessageFeeParamsMismatch") + + key = str(index) + consumed = int(accounting.setdefault("allocation_consumed", {}).get(key, 0)) + attempted = consumed + declared_budget + if attempted > int(allocation["budget"]): + raise MessageBudgetExceeded("MessageBudgetExceeded") + accounting["allocation_consumed"][key] = attempted + + +def _reserve_external_execution( + accounting: dict[str, Any], + message: dict[str, Any], + policy: StudioFeePolicy, + *, + reimburse: bool = True, +) -> int: + if bool(message.get("onAcceptance", False)): + return 0 + + allocations = accounting.get("message_allocations") or [] + if not allocations: + return 0 + + resolved = _resolve_allocation(allocations, message) + if resolved is None: + return 0 + + index, allocation = resolved + if int(allocation["messageType"]) != MESSAGE_TYPE_EXTERNAL: + return 0 + + external_fee_params = decode_external_message_fee_params(allocation["feeParams"]) + gas_limit = int(external_fee_params["gasLimit"]) + max_gas_price = int(external_fee_params["maxGasPrice"]) + locked_price = ( + min(policy.receipt_gas_price, max_gas_price) + if policy.receipt_gas_price > 0 + else 0 + ) + reservation = gas_limit * locked_price + key = str(index) + consumed = int(accounting.setdefault("allocation_consumed", {}).get(key, 0)) + attempted = consumed + reservation + if attempted > int(allocation["budget"]): + raise MessageBudgetExceeded("MessageBudgetExceeded") + accounting["allocation_consumed"][key] = attempted + + gas_used = int(message.get("gasUsed", 0) or 0) + reimbursement = min(reservation, gas_used * locked_price) + remainder = reservation - reimbursement + accounting["external_message_fee_reserved"] = ( + int(accounting.get("external_message_fee_reserved", 0)) + reservation + ) + if reimburse: + accounting["external_message_fee_reimbursed"] = ( + int(accounting.get("external_message_fee_reimbursed", 0)) + reimbursement + ) + accounting["external_message_fee_remainder"] = ( + int(accounting.get("external_message_fee_remainder", 0)) + remainder + ) + accounting.setdefault("external_message_events", []).append( + { + "recipient": str(message.get("recipient", "")).lower(), + "callKey": _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)), + "allocationIndex": index, + "gasLimit": gas_limit, + "lockedGasPrice": locked_price, + "reservation": reservation, + "gasUsed": gas_used if reimburse else 0, + "reimbursement": reimbursement if reimburse else 0, + "remainder": remainder if reimburse else 0, + "executionRecorded": bool(reimburse), + } + ) + return reimbursement if reimburse else 0 + + +def _find_unrefunded_external_message_event( + accounting: dict[str, Any], + message: dict[str, Any], +) -> int | None: + recipient = str(message.get("recipient", "")).lower() + call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + for index, event in enumerate(accounting.get("external_message_events") or []): + if ( + event.get("failureRefunded") + or event.get("refunded") + or event.get("unreserved") + ): + continue + if str(event.get("recipient", "")).lower() != recipient: + continue + if _normalize_call_key(event.get("callKey", CALL_KEY_WILDCARD)) != call_key: + continue + return index + return None + + +def _find_unexecuted_external_message_event( + accounting: dict[str, Any], + message: dict[str, Any], +) -> int | None: + recipient = str(message.get("recipient", "")).lower() + call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + for index, event in enumerate(accounting.get("external_message_events") or []): + if event.get("executionRecorded") or event.get("unreserved"): + continue + if str(event.get("recipient", "")).lower() != recipient: + continue + if _normalize_call_key(event.get("callKey", CALL_KEY_WILDCARD)) != call_key: + continue + return index + return None + + +def _unreserve_external_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], +) -> tuple[int, int, int]: + event_index = _find_unrefunded_external_message_event(accounting, message) + if event_index is None: + return 0, 0, 0 + + event = accounting.setdefault("external_message_events", [])[event_index] + reservation = int(event.get("reservation", 0) or 0) + reimbursement = int(event.get("reimbursement", 0) or 0) + remainder = int(event.get("remainder", 0) or 0) + allocation_index = str(event.get("allocationIndex")) + + allocation_consumed = accounting.setdefault("allocation_consumed", {}) + consumed = int(allocation_consumed.get(allocation_index, 0) or 0) + allocation_consumed[allocation_index] = max(0, consumed - reservation) + accounting["message_fee_consumed"] = max( + 0, + int(accounting.get("message_fee_consumed", 0)) - reimbursement, + ) + accounting["external_message_fee_reserved"] = max( + 0, + int(accounting.get("external_message_fee_reserved", 0)) - reservation, + ) + accounting["external_message_fee_reimbursed"] = max( + 0, + int(accounting.get("external_message_fee_reimbursed", 0)) - reimbursement, + ) + accounting["external_message_fee_remainder"] = max( + 0, + int(accounting.get("external_message_fee_remainder", 0)) - remainder, + ) + event["unreserved"] = True + return reservation, reimbursement, remainder + + +def _decrement_allocation_consumed( + accounting: dict[str, Any], + message: dict[str, Any], + amount: int, +) -> None: + resolved = _resolve_allocation(accounting.get("message_allocations") or [], message) + if resolved is None: + return + index, _ = resolved + allocation_consumed = accounting.setdefault("allocation_consumed", {}) + key = str(index) + consumed = int(allocation_consumed.get(key, 0) or 0) + allocation_consumed[key] = max(0, consumed - int(amount)) + + +def _resolve_allocation( + allocations: list[dict[str, Any]], + message: dict[str, Any], +) -> tuple[int, dict[str, Any]] | None: + message_type = int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + recipient = str(message.get("recipient", "")).lower() + call_key = _normalize_call_key(message.get("callKey", CALL_KEY_WILDCARD)) + + for wanted_call_key in (call_key, CALL_KEY_WILDCARD): + for index, raw_allocation in enumerate(allocations): + allocation = _serializable_message_allocation(raw_allocation) + if int(allocation["parentIndex"]) != NODE_ROOT_SENTINEL: + continue + if int(allocation["messageType"]) != message_type: + continue + if str(allocation["recipient"]).lower() != recipient: + continue + if _normalize_call_key(allocation["callKey"]) == wanted_call_key: + return index, allocation + return None + + +def _receipt_message_fee_payloads( + accounting: dict[str, Any], + receipt: Any | None, +) -> list[dict[str, Any]]: + if receipt is None: + return [] + if not _receipt_execution_allows_messages(receipt): + return [] + + payloads: list[dict[str, Any]] = [] + for raw in _receipt_pending_transactions(receipt): + message = _receipt_pending_transaction_fee_payload(raw) + if accounting.get("message_allocations"): + message = fill_message_fee_payload_from_allocation(accounting, message) + payloads.append(message) + return payloads + + +def _receipt_execution_allows_messages(receipt: Any) -> bool: + status = _receipt_value(receipt, "execution_result") + if status is None: + status = _receipt_value(receipt, "executionResult") + if hasattr(status, "value"): + status = status.value + if _receipt_budget_exhaustion_reason(receipt) in { + "ExecutionBudgetExceeded", + "MessageBudgetExceeded", + }: + return False + if status is None: + return True + return str(status).replace("_", "").upper() in { + "SUCCESS", + "FINISHEDWITHRETURN", + "RETURN", + } + + +def _receipt_messages_require_fee_validation( + accounting: dict[str, Any], + messages: list[dict[str, Any]], +) -> bool: + if int(accounting.get("message_fee_budget", 0) or 0) > 0: + return True + if accounting.get("message_allocations"): + return True + return any(_message_has_fee_fields(message) for message in messages) + + +def _message_has_fee_fields(message: dict[str, Any]) -> bool: + if int(message.get("declaredBudget", 0) or 0) > 0: + return True + return _message_has_fee_params(message) + + +def _message_has_fee_params(message: dict[str, Any]) -> bool: + fee_params = message.get("feeParams", b"") + if isinstance(fee_params, str): + return fee_params not in {"", "0x"} + return bool(fee_params) + + +def _receipt_pending_transaction_fee_payload(raw: Any) -> dict[str, Any]: + message = _pending_transaction_dict(raw) + message_type = _message_type(message) + data = _bytes_field( + _message_field(message, "calldata", "data", b"") + or _message_field(message, "data", "calldata", b"") + ) + call_key = _message_field( + message, + "call_key", + "callKey", + CALL_KEY_WILDCARD, + ) + if message_type == MESSAGE_TYPE_EXTERNAL: + call_key = derive_external_message_call_key(call_key, data) + fee_params = b"" + else: + fee_params = _bytes_field( + _message_field(message, "fee_params", "feeParams", b"") + ) + return { + "messageType": message_type, + "recipient": _abi_address( + _message_field(message, "address", "recipient") + or _message_field(message, "recipient", "address") + ), + "value": int(message.get("value", 0) or 0), + "data": data, + "onAcceptance": _message_on_acceptance(message), + "saltNonce": int(_message_field(message, "salt_nonce", "saltNonce", 0) or 0), + "feeParams": fee_params, + "declaredBudget": int( + _message_field( + message, + "declared_budget", + "declaredBudget", + 0, + ) + or 0 + ), + "allocationSubtree": _message_field( + message, + "allocation_subtree", + "allocationSubtree", + [], + ), + "callKey": call_key, + "gasUsed": int(_message_field(message, "gas_used", "gasUsed", 0) or 0), + } + + +def _execution_fee_buckets(consumed: list[int]) -> list[int]: + if len(consumed) <= 2: + return consumed + return consumed[:2] + + +def _chargeable_execution_fee_buckets( + consumed: list[int], + fee_report: dict[str, Any] | None, + policy: StudioFeePolicy, + receipt: Any | None = None, +) -> list[int]: + storage_fee = _chargeable_storage_fee(receipt, consumed) + if policy.receipt_gas_price <= 0 or not isinstance(fee_report, dict): + return [ + _bucket_value(consumed, 0), + storage_fee, + ] + + return [ + _receipt_report_chargeable_fee(fee_report), + storage_fee, + ] + + +def _chargeable_storage_fee(receipt: Any | None, consumed: list[int]) -> int: + if receipt is not None and not _receipt_execution_allows_messages(receipt): + return 0 + return _bucket_value(consumed, 1) + + +def _receipt_report_chargeable_fee(fee_report: dict[str, Any]) -> int: + proposal = fee_report.get("proposalReceipt") + proposal_fee = int(proposal.get("fee", 0) or 0) if isinstance(proposal, dict) else 0 + message_reveal = fee_report.get("messageReveal") + message_fee = ( + int(message_reveal.get("consensusAdditionalFee", 0) or 0) + if isinstance(message_reveal, dict) + else 0 + ) + return max(0, proposal_fee + message_fee) + + +def _bucket_value(consumed: list[int], index: int) -> int: + return int(consumed[index]) if len(consumed) > index else 0 + + +def _execution_budget_per_round(accounting: dict[str, Any]) -> int: + try: + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + except FeeValidationError: + return 0 + return int(fees["executionBudgetPerRound"]) + + +def _genvm_fee_bucket_report( + consumed: list[int], + *, + execution_budget_per_round: int = 0, +) -> dict[str, Any]: + receipt_and_nondet_output = _bucket_value(consumed, 0) + storage = _bucket_value(consumed, 1) + message = _bucket_value(consumed, 2) + total_execution = receipt_and_nondet_output + storage + buckets = [ + { + "index": 0, + "name": "receiptAndNondetOutput", + "consumed": receipt_and_nondet_output, + }, + {"index": 1, "name": "storage", "consumed": storage}, + ] + if len(consumed) > 2: + buckets.append({"index": 2, "name": "message", "consumed": message}) + report = { + "receiptAndNondetOutput": receipt_and_nondet_output, + "storage": storage, + "message": message, + "totalExecution": total_execution, + "totalWithMessage": sum(int(value) for value in consumed), + "buckets": buckets, + } + overrun = max(0, total_execution - execution_budget_per_round) + report.update( + { + "executionBudgetPerRound": execution_budget_per_round, + "executionBudgetRemaining": max( + 0, execution_budget_per_round - total_execution + ), + "executionBudgetOverrun": overrun, + "executionBudgetExceeded": overrun > 0, + } + ) + return report + + +def _execution_metering_report( + *, + chargeable_bucket_report: dict[str, Any], + genvm_bucket_report: dict[str, Any], +) -> dict[str, int]: + chargeable = int(chargeable_bucket_report.get("totalExecution", 0) or 0) + genvm_reported = int(genvm_bucket_report.get("totalExecution", 0) or 0) + return { + "chargeableExecutionFee": chargeable, + "genvmReportedExecution": genvm_reported, + "genvmDeltaFromChargeable": genvm_reported - chargeable, + } + + +def _receipt_budget_exhaustion_reason( + receipt: Any | None, + bucket_report: dict[str, Any] | None = None, +) -> str | None: + genvm_result = _receipt_genvm_result(receipt) + if isinstance(genvm_result, dict): + for key in ("budgetExhaustionReason", "budget_exhaustion_reason"): + reason = genvm_result.get(key) + if reason not in (None, "", "None"): + return str(reason) + + error_code = genvm_result.get("error_code") or genvm_result.get("errorCode") + if error_code in {"ExecutionBudgetExceeded", "MessageBudgetExceeded"}: + return str(error_code) + + if bucket_report and bucket_report.get("executionBudgetExceeded"): + return "ExecutionBudgetExceeded" + + return None + + +def _message_fee_accounting_report(accounting: dict[str, Any]) -> dict[str, int]: + budget = int(accounting.get("message_fee_budget", 0) or 0) + total_consumed = int(accounting.get("message_fee_consumed", 0) or 0) + external_reserved = int(accounting.get("external_message_fee_reserved", 0) or 0) + external_reimbursed = int(accounting.get("external_message_fee_reimbursed", 0) or 0) + external_remainder = int(accounting.get("external_message_fee_remainder", 0) or 0) + declared_consumed = max(0, total_consumed - external_reimbursed) + declared_refunded = int(accounting.get("message_fee_refunded", 0) or 0) + genvm_metered_consumed = int(accounting.get("genvm_message_fee_consumed", 0) or 0) + report = { + "budget": budget, + "declaredConsumed": declared_consumed, + "genvmMeteredConsumed": genvm_metered_consumed, + "declaredRefunded": declared_refunded, + "remaining": max(0, budget - total_consumed - declared_refunded), + "meteringDelta": declared_consumed - genvm_metered_consumed, + } + if external_reserved or external_reimbursed or external_remainder: + report["externalReserved"] = external_reserved + report["externalReimbursed"] = external_reimbursed + report["externalRemainder"] = external_remainder + report["totalConsumed"] = total_consumed + if accounting.get("reported_message_fees_total") is not None: + report["reportedTotal"] = int(accounting["reported_message_fees_total"]) + return report + + +def _attach_message_fee_accounting_report(accounting: dict[str, Any]) -> None: + report = dict(accounting.get("execution_fee_report") or {}) + report["messageFees"] = _message_fee_accounting_report(accounting) + accounting["execution_fee_report"] = report + + +def _attach_recommended_fee_preset( + accounting: dict[str, Any], + policy: StudioFeePolicy, +) -> None: + accounting["recommended_fee_preset"] = recommended_fee_preset(accounting, policy) + + +def recommended_fee_preset( + accounting: dict[str, Any], + policy: StudioFeePolicy | None = None, + *, + padding_bps: int = DEFAULT_PRICE_CAP_HEADROOM_BPS, +) -> dict[str, Any]: + policy = _accounting_policy(accounting, policy) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + report = accounting.get("execution_fee_report") or {} + message_report = ( + report.get("messageFees") if isinstance(report.get("messageFees"), dict) else {} + ) + message_allocations = list(accounting.get("message_allocations") or []) + num_validators = int( + accounting.get("num_of_initial_validators") or VALIDATORS_PER_ROUND[0] + ) + emits_messages = bool(message_allocations) or int(fees["totalMessageFees"]) > 0 + execution_floor = policy.message_fee_params_budget_floor() + if emits_messages: + execution_floor += ( + policy.receipt_gas_price * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM + ) + + observed_execution = _observed_chargeable_execution_fee(accounting, report) + recommended_execution = int(fees["executionBudgetPerRound"]) + if observed_execution > 0: + recommended_execution = max( + _with_padding(observed_execution, padding_bps), + execution_floor, + ) + + declared_message = _int_report_field(message_report, "declaredConsumed") + external_reserved = int(accounting.get("external_message_fee_reserved", 0) or 0) + observed_message_budget = declared_message + external_reserved + recommended_message_budget = int(fees["totalMessageFees"]) + message_budget_mode = "current" + if message_allocations: + message_budget_mode = "allocation-preserved" + elif observed_message_budget > 0: + recommended_message_budget = _with_padding(observed_message_budget, padding_bps) + message_budget_mode = "observed" + + distribution = _serializable_fees_distribution( + { + **fees, + "rotations": _preset_rotations(fees), + "executionBudgetPerRound": recommended_execution, + "totalMessageFees": recommended_message_budget, + } + ) + fee_value = required_fee_deposit( + distribution, + num_validators, + policy, + ) + + return { + "source": "simulation", + "paddingBps": int(padding_bps), + "numOfInitialValidators": num_validators, + "distribution": distribution, + "feeValue": fee_value, + "messageAllocations": message_allocations, + "messageBudgetMode": message_budget_mode, + "observed": { + "executionFee": observed_execution, + "messageFeeBudget": observed_message_budget, + "declaredMessageFees": declared_message, + "externalMessageReserved": external_reserved, + "totalEstimatedFee": _int_report_field(report, "totalEstimatedFee"), + "totalStudioMeteredFee": _int_report_field(report, "totalStudioMeteredFee"), + }, + } + + +def _preset_rotations(fees: dict[str, Any]) -> list[int]: + appeal_rounds = int(fees.get("appealRounds", 0) or 0) + expected = appeal_rounds + 1 + rotations = [int(rotation) for rotation in fees.get("rotations", [])] + if len(rotations) >= expected: + return rotations[:expected] + return rotations + ([0] * (expected - len(rotations))) + + +def _observed_chargeable_execution_fee( + accounting: dict[str, Any], + report: dict[str, Any], +) -> int: + consumed = int(accounting.get("execution_fee_consumed", 0) or 0) + if consumed > 0: + return consumed + + chargeable = report.get("chargeableExecution") + if isinstance(chargeable, dict): + total = int(chargeable.get("totalExecution", 0) or 0) + if total > 0: + return total + + return _int_report_field(report, "totalEstimatedFee") + + +def _int_report_field(report: dict[str, Any], key: str) -> int: + try: + return int(report.get(key, 0) or 0) + except (TypeError, ValueError): + return 0 + + +def _refresh_message_fee_accounting_report_if_present( + accounting: dict[str, Any], + policy: StudioFeePolicy | None = None, +) -> None: + if accounting.get("execution_fee_report"): + policy = _accounting_policy(accounting, policy) + _attach_message_fee_accounting_report(accounting) + _attach_recommended_fee_preset(accounting, policy) + + +def _receipt_data_fees_consumed(receipt: Any | None) -> list[int] | None: + if receipt is None: + return None + genvm_result = ( + getattr(receipt, "genvm_result", None) + if not isinstance(receipt, dict) + else receipt.get("genvm_result") + ) + if not isinstance(genvm_result, dict): + return None + consumed = genvm_result.get("data_fees_consumed") + if consumed is not None: + return [int(value) for value in consumed] + totals = genvm_result.get("data_fee_bucket_totals") + remaining = genvm_result.get("data_fees_remaining") + if totals is None or remaining is None: + return None + return [max(0, int(total) - int(rest)) for total, rest in zip(totals, remaining)] + + +def _receipt_reported_message_fees_total(receipt: Any | None) -> int | None: + if receipt is None: + return None + for source in (receipt, _receipt_genvm_result(receipt) or {}): + for key in ( + "reported_message_fees_total", + "reportedMessageFeesTotal", + "message_fees_consumed", + "messageFeesConsumed", + ): + value = _receipt_value(source, key) + if value is not None: + return int(value) + return None + + +def _receipt_fee_report( + receipt: Any | None, + policy: StudioFeePolicy, + message_payloads: list[dict[str, Any]] | None = None, +) -> dict[str, Any] | None: + if receipt is None: + return None + + eq_outputs_length = _receipt_eq_blocks_outputs_length(receipt) + receipt_bytes = policy.estimate_propose_receipt_bytes(eq_outputs_length) + proposal_gas = policy.estimate_propose_receipt_gas(receipt_bytes) + proposal_fee = proposal_gas * policy.receipt_gas_price + report: dict[str, Any] = { + "receiptGasPrice": policy.receipt_gas_price, + "proposalReceipt": { + "eqBlocksOutputsLength": eq_outputs_length, + "receiptBytes": receipt_bytes, + "estimatedGas": proposal_gas, + "fee": proposal_fee, + }, + "totalEstimatedFee": proposal_fee, + "totalStudioMeteredFee": proposal_fee, + } + + submitted_messages, message_reports = _receipt_submitted_messages_and_reports( + receipt, + message_payloads, + ) + if submitted_messages: + message_bytes = len(encode([SUBMITTED_MESSAGE_ABI_TYPE], [submitted_messages])) + message_gas = policy.estimate_message_reveal_gas( + message_bytes, + len(submitted_messages), + ) + consensus_message_gas = policy.estimate_consensus_message_reveal_gas( + message_bytes, + len(submitted_messages), + ) + message_fee = message_gas * policy.receipt_gas_price + consensus_message_fee = consensus_message_gas * policy.receipt_gas_price + report["messageReveal"] = { + "messageBytes": message_bytes, + "messageCount": len(submitted_messages), + "estimatedGas": message_gas, + "fee": message_fee, + "consensusAdditionalGas": consensus_message_gas, + "consensusAdditionalFee": consensus_message_fee, + "studioFixedOverheadGas": max(0, message_gas - consensus_message_gas), + "studioFixedOverheadFee": max(0, message_fee - consensus_message_fee), + "messages": message_reports, + } + report["totalEstimatedFee"] += consensus_message_fee + report["totalStudioMeteredFee"] += message_fee + + return report + + +def _receipt_eq_blocks_outputs_length(receipt: Any) -> int: + genvm_result = _receipt_genvm_result(receipt) + if isinstance(genvm_result, dict): + explicit = genvm_result.get("eq_blocks_outputs_length") or genvm_result.get( + "eqBlocksOutputsLength" + ) + if explicit is not None: + return max(0, int(explicit)) + + explicit_outputs = _receipt_value(receipt, "eq_blocks_outputs") + if isinstance(explicit_outputs, str) and explicit_outputs.startswith("0x"): + return len(bytes.fromhex(explicit_outputs.removeprefix("0x"))) + + return len(_encode_eq_blocks_outputs(_receipt_eq_outputs(receipt))) + + +def _receipt_submitted_messages(receipt: Any) -> list[tuple[Any, ...]]: + submitted, _ = _receipt_submitted_messages_and_reports(receipt) + return submitted + + +def _receipt_submitted_messages_and_reports( + receipt: Any, + message_payloads: list[dict[str, Any]] | None = None, +) -> tuple[list[tuple[Any, ...]], list[dict[str, Any]]]: + submitted = [] + reports = [] + raw_messages = ( + message_payloads + if message_payloads is not None + else [ + _pending_transaction_dict(raw) + for raw in _receipt_pending_transactions(receipt) + ] + ) + for message in raw_messages: + message_type = _message_type(message) + recipient = _abi_address( + _message_field(message, "address", "recipient") + or _message_field(message, "recipient", "address") + ) + value = int(message.get("value", 0) or 0) + data = _bytes_field( + _message_field(message, "calldata", "data", b"") + or _message_field(message, "data", "calldata", b"") + ) + on_acceptance = _message_on_acceptance(message) + salt_nonce = int(_message_field(message, "salt_nonce", "saltNonce", 0) or 0) + fee_params = _bytes_field( + _message_field(message, "fee_params", "feeParams", b"") + ) + submitted_fee_params = fee_params + if message_type == MESSAGE_TYPE_EXTERNAL: + submitted_fee_params = b"" + declared_budget = int( + _message_field( + message, + "declared_budget", + "declaredBudget", + 0, + ) + or 0 + ) + allocation_subtree = _allocation_subtree_bytes( + _message_field( + message, + "allocation_subtree", + "allocationSubtree", + ) + ) + call_key_value = _message_field( + message, + "call_key", + "callKey", + CALL_KEY_WILDCARD, + ) + if message_type == MESSAGE_TYPE_EXTERNAL: + call_key_value = derive_external_message_call_key(call_key_value, data) + call_key = _bytes32_field(call_key_value) + submitted.append( + ( + message_type, + recipient, + value, + data, + on_acceptance, + salt_nonce, + submitted_fee_params, + declared_budget, + allocation_subtree, + call_key, + ) + ) + reports.append( + { + "messageFeeMode": _message_fee_mode( + message_type, + allocation_subtree, + message.get("messageFeeMode"), + ), + "messageType": ( + "External" if message_type == MESSAGE_TYPE_EXTERNAL else "Internal" + ), + "recipient": recipient, + "value": value, + "dataBytes": len(data), + "onAcceptance": on_acceptance, + "saltNonce": salt_nonce, + "feeParams": _fee_params_hex(fee_params), + "feeParamsDecoded": _message_fee_params_for_report( + message_type, + fee_params, + ), + "feeParamsBytes": len(fee_params), + "declaredBudget": declared_budget, + "allocationSubtree": "0x" + allocation_subtree.hex(), + "allocationSubtreeBytes": len(allocation_subtree), + "callKey": "0x" + call_key.hex(), + } + ) + return submitted, reports + + +def _message_fee_mode( + message_type: int, + allocation_subtree: bytes, + explicit: Any = None, +) -> str: + if explicit in {"mode1", "mode2", "external"}: + return str(explicit) + if message_type == MESSAGE_TYPE_EXTERNAL: + return "external" + return "mode2" if allocation_subtree else "mode1" + + +def _message_fee_params_for_report( + message_type: int, + fee_params: bytes, +) -> dict[str, Any] | None: + if not fee_params: + return None + try: + if message_type == MESSAGE_TYPE_EXTERNAL: + return decode_external_message_fee_params(fee_params) + return decode_internal_message_fee_params(fee_params) + except FeeValidationError: + return None + + +def _receipt_pending_transactions(receipt: Any) -> list[Any]: + pending = _receipt_value(receipt, "pending_transactions", []) + return pending if isinstance(pending, list) else list(pending or []) + + +def _pending_transaction_dict(pending_transaction: Any) -> dict[str, Any]: + if isinstance(pending_transaction, dict): + return pending_transaction + if hasattr(pending_transaction, "to_dict"): + return pending_transaction.to_dict() + return { + "address": getattr(pending_transaction, "address", ""), + "calldata": getattr( + pending_transaction, + "calldata", + getattr(pending_transaction, "data", b""), + ), + "code": getattr(pending_transaction, "code", b""), + "salt_nonce": getattr(pending_transaction, "salt_nonce", 0), + "on": getattr(pending_transaction, "on", "finalized"), + "value": getattr(pending_transaction, "value", 0), + "is_eth_send": getattr( + pending_transaction, + "is_eth_send", + getattr(pending_transaction, "isEthSend", False), + ), + "fee_params": getattr(pending_transaction, "fee_params", b""), + "declared_budget": getattr(pending_transaction, "declared_budget", 0), + "call_key": getattr(pending_transaction, "call_key", CALL_KEY_WILDCARD), + "allocation_subtree": getattr(pending_transaction, "allocation_subtree", []), + } + + +def _message_field( + message: dict[str, Any], + snake_key: str, + camel_key: str, + default: Any = None, +) -> Any: + if snake_key in message: + return message[snake_key] + return message.get(camel_key, default) + + +def _message_type(message: dict[str, Any]) -> int: + explicit = _message_field(message, "message_type", "messageType") + if explicit is not None: + if isinstance(explicit, str) and not explicit.isdigit(): + return ( + MESSAGE_TYPE_EXTERNAL + if explicit.lower() == "external" + else MESSAGE_TYPE_INTERNAL + ) + return int(explicit) + is_eth_send = bool(_message_field(message, "is_eth_send", "isEthSend", False)) + return MESSAGE_TYPE_EXTERNAL if is_eth_send else MESSAGE_TYPE_INTERNAL + + +def _message_on_acceptance(message: dict[str, Any]) -> bool: + explicit = _message_field(message, "on_acceptance", "onAcceptance") + if explicit is not None: + return bool(explicit) + phase = str(message.get("on", "finalized")).lower() + return phase == "accepted" or phase == "acceptance" + + +def _receipt_eq_outputs(receipt: Any) -> list[bytes]: + eq_outputs = _receipt_value(receipt, "eq_outputs") + if eq_outputs is None: + eq_outputs = _receipt_value(receipt, "eqOutputs") + if isinstance(eq_outputs, dict): + + def sort_key(item: tuple[Any, Any]) -> int: + try: + return int(item[0]) + except (TypeError, ValueError): + return 0 + + return [ + _eq_output_bytes(value) + for _, value in sorted(eq_outputs.items(), key=sort_key) + ] + if isinstance(eq_outputs, list): + return [_eq_output_bytes(value) for value in eq_outputs] + return [] + + +def _eq_output_bytes(value: Any) -> bytes: + if isinstance(value, dict): + value = value.get("data", value.get("output", value.get("value", b""))) + return _bytes_field(value) + + +def _encode_eq_blocks_outputs(eq_outputs: list[bytes]) -> bytes: + return rlp.encode([*eq_outputs, b"padded"]) + + +def _receipt_genvm_result(receipt: Any) -> dict[str, Any] | None: + genvm_result = _receipt_value(receipt, "genvm_result") + return genvm_result if isinstance(genvm_result, dict) else None + + +def _receipt_value(receipt: Any, key: str, default: Any = None) -> Any: + if isinstance(receipt, dict): + return receipt.get(key, default) + return getattr(receipt, key, default) + + +def _abi_address(value: Any) -> str: + raw = str(value or "").lower() + if raw.startswith("0x"): + raw = raw[2:] + if len(raw) == 40: + try: + bytes.fromhex(raw) + return "0x" + raw + except ValueError: + pass + return "0x" + ("0" * 40) + + +def _allocation_subtree_bytes(value: Any) -> bytes: + if value is None or value == []: + return b"" + if isinstance(value, list): + nodes = [_submitted_allocation_node(node) for node in value] + return encode([MESSAGE_ALLOCATION_NODE_ABI_TYPE], [nodes]) + if isinstance(value, dict): + return encode( + [MESSAGE_ALLOCATION_NODE_ABI_TYPE], + [[_submitted_allocation_node(value)]], + ) + return _bytes_field(value) + + +def _submitted_allocation_node(node: dict[str, Any]) -> tuple[Any, ...]: + return ( + int(node.get("messageType", node.get("message_type", MESSAGE_TYPE_INTERNAL))), + bool(node.get("onAcceptance", node.get("on_acceptance", False))), + int(node.get("parentIndex", node.get("parent_index", NODE_ROOT_SENTINEL))), + _abi_address(node.get("recipient")), + _bytes32_field(node.get("callKey", node.get("call_key", CALL_KEY_WILDCARD))), + int(node.get("budget", 0) or 0), + _bytes_field(node.get("feeParams", node.get("fee_params", b""))), + ) + + +def _bytes32_field(value: Any) -> bytes: + if isinstance(value, bytes): + return value.rjust(32, b"\x00")[-32:] + raw = str(value or "").removeprefix("0x").lower() + try: + return bytes.fromhex(raw.rjust(64, "0")[-64:]) + except ValueError: + return bytes(32) + + +def _bytes_field(value: Any) -> bytes: + if value is None: + return b"" + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, str): + return _bytes_from_string_field(value) + return bytes(value) + + +def _bytes_from_string_field(value: str) -> bytes: + raw = value.removeprefix("0x") + if value.startswith("0x"): + return _bytes_from_hex_field(raw) + if raw == "": + return b"" + return _bytes_from_encoded_text(raw) + + +def _bytes_from_encoded_text(raw: str) -> bytes: + try: + return base64.b64decode(raw, validate=True) + except Exception: + return _bytes_from_hex_or_utf8(raw) + + +def _bytes_from_hex_or_utf8(raw: str) -> bytes: + try: + return bytes.fromhex(raw) + except ValueError: + return raw.encode("utf-8") + + +def _bytes_from_hex_field(raw: str) -> bytes: + try: + return bytes.fromhex(raw) + except ValueError: + return b"" + + +def _fee_params_hex(fee_params: bytes | str) -> str: + if isinstance(fee_params, str): + return "0x" + fee_params.removeprefix("0x").lower() + return "0x" + bytes(fee_params).hex() + + +def _normalize_call_key(call_key: bytes | str) -> str: + if isinstance(call_key, bytes): + raw = call_key.hex() + else: + raw = str(call_key).removeprefix("0x").lower() + return "0x" + raw.rjust(64, "0")[-64:] + + +def derive_external_message_call_key( + call_key: bytes | str | None, calldata: Any +) -> str: + normalized = _normalize_call_key(call_key or CALL_KEY_WILDCARD) + if normalized != CALL_KEY_WILDCARD: + return normalized + + raw_calldata = _bytes_field(calldata) + if len(raw_calldata) < 4: + return CALL_KEY_WILDCARD + + return "0x" + raw_calldata[:4].hex().ljust(64, "0") diff --git a/backend/protocol_rpc/health.py b/backend/protocol_rpc/health.py index e28370b98..cac37fd1b 100644 --- a/backend/protocol_rpc/health.py +++ b/backend/protocol_rpc/health.py @@ -160,10 +160,12 @@ def _get_readiness_permit_jitter_seconds() -> float: return 2.0 +def _generate_readiness_jitter_seconds() -> float: + return random.random() * _get_readiness_permit_jitter_seconds() + + # Per-process stable jitter in [0, jitter_s] -_READINESS_JITTER_S: float = ( - random.Random(os.getpid()).random() * _get_readiness_permit_jitter_seconds() -) +_READINESS_JITTER_S: float = _generate_readiness_jitter_seconds() def _evaluate_permit_readiness( @@ -216,6 +218,26 @@ def _evaluate_permit_readiness( METRICS_SEND_INTERVAL = 6 _no_progress_scan_suppressed_until: float = 0.0 +# Statuses where the consensus state machine is actively working. +# The "head of queue stuck" check uses ONLY these: ACCEPTED-class +# statuses are post-consensus and live under the separate +# finalization-stall detector below. +CONSENSUS_ACTIVE_STATUSES_SQL = "'ACTIVATED','PROPOSING','COMMITTING','REVEALING'" +# Broader set including finalization-pending statuses. Used only +# in the "is any worker actively claiming on this contract" NOT +# EXISTS clause — a finalization worker counts as active work and +# should mask a stuck consensus head. +ANY_INFLIGHT_STATUSES_SQL = ( + "'ACTIVATED','PROPOSING','COMMITTING','REVEALING'," + "'ACCEPTED','UNDETERMINED','LEADER_TIMEOUT','VALIDATORS_TIMEOUT'" +) +FINALIZATION_ELIGIBLE_STATUSES_SQL = ( + "'ACCEPTED','UNDETERMINED','LEADER_TIMEOUT','VALIDATORS_TIMEOUT'" +) +PRE_CONSENSUS_BACKLOG_STATUSES_SQL = ( + "'PENDING','ACTIVATED','PROPOSING','COMMITTING','REVEALING'" +) + def get_health_check_interval() -> float: """Get health check interval from env (default 10s).""" @@ -311,9 +333,15 @@ async def _run_health_checks() -> None: "orphaned_transactions": consensus_health.get( "total_orphaned_transactions", 0 ), + "stuck_head_transactions": consensus_health.get( + "stuck_head_transactions", [] + ), "stuck_finalization_count": consensus_health.get( "stuck_finalization_count", 0 ), + "stuck_finalization_transactions": consensus_health.get( + "stuck_finalization_transactions", [] + ), "recovery_storm_count": consensus_health.get("recovery_storm_count", 0), "max_recovery_count": consensus_health.get("max_recovery_count", 0), "max_recovery_exhausted_count": consensus_health.get( @@ -635,25 +663,9 @@ async def _check_consensus_health() -> Dict[str, Any]: MAX_RECOVERY_EXHAUSTED_EVENT_LIMIT = int( os.environ.get("HEALTH_MAX_RECOVERY_EXHAUSTED_EVENT_LIMIT", "10") ) - - # Statuses where the consensus state machine is actively working. - # The "head of queue stuck" check uses ONLY these: ACCEPTED-class - # statuses are post-consensus and live under the separate - # finalization-stall detector below. - CONSENSUS_ACTIVE_STATUSES_SQL = "'ACTIVATED','PROPOSING','COMMITTING','REVEALING'" - # Broader set including finalization-pending statuses. Used only - # in the "is any worker actively claiming on this contract" NOT - # EXISTS clause — a finalization worker counts as active work and - # should mask a stuck consensus head. - ANY_INFLIGHT_STATUSES_SQL = ( - "'ACTIVATED','PROPOSING','COMMITTING','REVEALING'," - "'ACCEPTED','UNDETERMINED','LEADER_TIMEOUT','VALIDATORS_TIMEOUT'" - ) - FINALIZATION_ELIGIBLE_STATUSES_SQL = ( - "'ACCEPTED','UNDETERMINED','LEADER_TIMEOUT','VALIDATORS_TIMEOUT'" - ) - PRE_CONSENSUS_BACKLOG_STATUSES_SQL = ( - "'PENDING','ACTIVATED','PROPOSING','COMMITTING','REVEALING'" + STUCK_HEAD_EVENT_LIMIT = int(os.environ.get("HEALTH_STUCK_HEAD_EVENT_LIMIT", "10")) + STUCK_FINALIZATION_EVENT_LIMIT = int( + os.environ.get("HEALTH_STUCK_FINALIZATION_EVENT_LIMIT", "10") ) try: @@ -698,7 +710,7 @@ def _query_consensus(): # masks the alarm. # Returns the count of AFFECTED CONTRACTS, not the # count of queued txs behind them. - stuck_row = conn.execute( + stuck_rows = conn.execute( text( f""" WITH heads AS ( @@ -711,26 +723,52 @@ def _query_consensus(): WHERE status IN ({CONSENSUS_ACTIVE_STATUSES_SQL}) AND to_address IS NOT NULL ORDER BY to_address, created_at ASC, hash ASC + ), + stuck_heads AS ( + SELECT + h.to_address, + h.hash, + h.status, + h.created_at + FROM heads h + WHERE h.created_at < NOW() - make_interval(mins => :head_stuck_minutes) + AND NOT EXISTS ( + SELECT 1 + FROM transactions t2 + WHERE t2.to_address = h.to_address + AND t2.status IN ({ANY_INFLIGHT_STATUSES_SQL}) + AND t2.blocked_at IS NOT NULL + AND t2.blocked_at > NOW() - make_interval(mins => :claim_window) + ) ) - SELECT COUNT(*) AS stuck_heads - FROM heads h - WHERE h.created_at < NOW() - make_interval(mins => :head_stuck_minutes) - AND NOT EXISTS ( - SELECT 1 - FROM transactions t2 - WHERE t2.to_address = h.to_address - AND t2.status IN ({ANY_INFLIGHT_STATUSES_SQL}) - AND t2.blocked_at IS NOT NULL - AND t2.blocked_at > NOW() - make_interval(mins => :claim_window) - ) + SELECT + COUNT(*) OVER() AS total_count, + hash AS tx_hash, + to_address, + status, + EXTRACT(EPOCH FROM created_at)::bigint + AS created_at_epoch + FROM stuck_heads + ORDER BY created_at ASC, hash ASC + LIMIT :event_limit """ ), { "head_stuck_minutes": HEAD_STUCK_AFTER_MINUTES, "claim_window": CLAIM_WINDOW_MINUTES, + "event_limit": STUCK_HEAD_EVENT_LIMIT, }, - ).fetchone() - stuck_head_contracts = stuck_row.stuck_heads if stuck_row else 0 + ).fetchall() + stuck_head_contracts = stuck_rows[0].total_count if stuck_rows else 0 + stuck_head_transactions = [ + { + "hash": row.tx_hash, + "contract_address": row.to_address, + "status": row.status, + "created_at": row.created_at_epoch, + } + for row in stuck_rows + ] # Stuck finalizations: ACCEPTED-class txs waiting too # long to reach FINALIZED. Two paths: @@ -742,27 +780,82 @@ def _query_consensus(): # reached UNDETERMINED without ever stamping the # timestamp — invisible to claim_next_finalization # forever) - stuck_fin_row = conn.execute( + stuck_fin_rows = conn.execute( text( f""" - SELECT COUNT(*) AS n - FROM transactions - WHERE status IN ({FINALIZATION_ELIGIBLE_STATUSES_SQL}) - AND ( - (timestamp_awaiting_finalization IS NOT NULL - AND EXTRACT(EPOCH FROM NOW())::bigint - - timestamp_awaiting_finalization - > :stuck_seconds) - OR - (timestamp_awaiting_finalization IS NULL - AND created_at - < NOW() - make_interval(secs => :stuck_seconds)) - ) + WITH stuck_finalizations AS ( + SELECT + hash AS tx_hash, + to_address, + status, + created_at, + timestamp_awaiting_finalization, + blocked_at, + worker_id, + CASE + WHEN timestamp_awaiting_finalization IS NOT NULL + THEN EXTRACT(EPOCH FROM NOW())::bigint + - timestamp_awaiting_finalization + ELSE EXTRACT(EPOCH FROM NOW() - created_at)::bigint + END AS waiting_seconds + FROM transactions + WHERE status IN ({FINALIZATION_ELIGIBLE_STATUSES_SQL}) + AND ( + (timestamp_awaiting_finalization IS NOT NULL + AND EXTRACT(EPOCH FROM NOW())::bigint + - timestamp_awaiting_finalization + > :stuck_seconds) + OR + (timestamp_awaiting_finalization IS NULL + AND created_at + < NOW() - make_interval(secs => :stuck_seconds)) + ) + ) + SELECT + COUNT(*) OVER() AS total_count, + tx_hash, + to_address, + status, + EXTRACT(EPOCH FROM created_at)::bigint + AS created_at_epoch, + timestamp_awaiting_finalization, + EXTRACT(EPOCH FROM blocked_at)::bigint + AS blocked_at_epoch, + worker_id, + waiting_seconds + FROM stuck_finalizations + ORDER BY + COALESCE( + timestamp_awaiting_finalization, + EXTRACT(EPOCH FROM created_at)::bigint + ) ASC, + tx_hash ASC + LIMIT :event_limit """ ), - {"stuck_seconds": STUCK_FINALIZATION_AFTER_SECONDS}, - ).fetchone() - stuck_finalization_count = stuck_fin_row.n if stuck_fin_row else 0 + { + "stuck_seconds": STUCK_FINALIZATION_AFTER_SECONDS, + "event_limit": STUCK_FINALIZATION_EVENT_LIMIT, + }, + ).fetchall() + stuck_finalization_count = ( + stuck_fin_rows[0].total_count if stuck_fin_rows else 0 + ) + stuck_finalization_transactions = [ + { + "hash": row.tx_hash, + "contract_address": row.to_address, + "status": row.status, + "created_at": row.created_at_epoch, + "timestamp_awaiting_finalization": ( + row.timestamp_awaiting_finalization + ), + "blocked_at": row.blocked_at_epoch, + "worker_id": row.worker_id, + "waiting_seconds": row.waiting_seconds, + } + for row in stuck_fin_rows + ] # Recovery storm: a non-terminal transaction that has already # been reset several times is a high-confidence poison-tx @@ -1018,7 +1111,11 @@ def _query_consensus(): # external dashboard. Semantics: count of CONTRACTS # whose consensus head is stuck. "total_orphaned_transactions": stuck_head_contracts, + "stuck_head_transactions": stuck_head_transactions, "stuck_finalization_count": stuck_finalization_count, + "stuck_finalization_transactions": ( + stuck_finalization_transactions + ), "recovery_storm_count": recovery_storm_count, "max_recovery_count": max_recovery_count, "max_recovery_exhausted_count": max_recovery_exhausted_count, @@ -1798,8 +1895,7 @@ async def health_consensus( rpc_router: Optional[FastAPIRPCRouter] = Depends(get_rpc_router_optional), ) -> Dict[str, Any]: """Show consensus system status with detailed contract-level transaction metrics.""" - from datetime import datetime, timedelta, timezone - from backend.database_handler.models import Transactions + from datetime import datetime, timezone from backend.database_handler.session_factory import get_database_manager try: @@ -1807,23 +1903,18 @@ async def health_consensus( return {"status": "not_initialized", "error": "RPC router not available"} def _query_consensus_detail(): - # Get active worker IDs from recent transactions - db_manager = get_database_manager() - with db_manager.engine.connect() as worker_conn: - now = datetime.now(timezone.utc) - recent_threshold = now - timedelta(hours=1) + import os - from sqlalchemy import select, distinct, and_ - - worker_query = select(distinct(Transactions.worker_id)).where( - and_( - Transactions.worker_id.isnot(None), - Transactions.created_at > recent_threshold, - ) - ) - - worker_result = worker_conn.execute(worker_query) - active_workers = {row[0] for row in worker_result if row[0]} + db_manager = get_database_manager() + HEAD_STUCK_AFTER_MINUTES = int( + os.environ.get("HEALTH_HEAD_STUCK_AFTER_MINUTES", "15") + ) + CLAIM_WINDOW_MINUTES = int( + os.environ.get("TRANSACTION_TIMEOUT_MINUTES", "30") + ) + DEGRADED_AT_STUCK_HEADS = int( + os.environ.get("HEALTH_DEGRADED_AT_STUCK_HEADS", "3") + ) # Query transaction statistics by contract with db_manager.engine.connect() as conn: @@ -1831,38 +1922,90 @@ def _query_consensus_detail(): from sqlalchemy import text + active_workers_row = conn.execute( + text( + """ + SELECT COUNT(DISTINCT worker_id) AS n + FROM transactions + WHERE worker_id IS NOT NULL + AND blocked_at IS NOT NULL + AND blocked_at > NOW() - make_interval(mins => :claim_window) + """ + ), + {"claim_window": CLAIM_WINDOW_MINUTES}, + ).fetchone() + active_workers_count = active_workers_row.n if active_workers_row else 0 + query = text( - """ + f""" + WITH contract_stats AS ( + SELECT + to_address as contract_address, + COUNT(*) FILTER (WHERE status IN ({ANY_INFLIGHT_STATUSES_SQL})) as processing_count, + COUNT(*) FILTER (WHERE status = 'PENDING') as pending_count, + COUNT(*) FILTER (WHERE created_at > NOW() - interval '1 hour') as created_last_1h, + COUNT(*) FILTER (WHERE created_at > NOW() - interval '3 hours') as created_last_3h, + COUNT(*) FILTER (WHERE created_at > NOW() - interval '6 hours') as created_last_6h, + COUNT(*) FILTER (WHERE created_at > NOW() - interval '12 hours') as created_last_12h, + COUNT(*) FILTER (WHERE created_at > NOW() - interval '1 day') as created_last_1d, + MIN(blocked_at) as oldest_blocked_at, + MIN(created_at) FILTER ( + WHERE status = 'PENDING' + OR status IN ({ANY_INFLIGHT_STATUSES_SQL}) + ) as oldest_processing_created_at + FROM transactions + WHERE to_address IS NOT NULL + GROUP BY to_address + HAVING COUNT(*) FILTER ( + WHERE status = 'PENDING' + OR status IN ({ANY_INFLIGHT_STATUSES_SQL}) + ) > 0 + ), + heads AS ( + SELECT DISTINCT ON (to_address) + to_address, + hash, + status, + created_at + FROM transactions + WHERE status IN ({CONSENSUS_ACTIVE_STATUSES_SQL}) + AND to_address IS NOT NULL + ORDER BY to_address, created_at ASC, hash ASC + ), + stuck_heads AS ( + SELECT + h.to_address, + h.hash, + h.status, + h.created_at + FROM heads h + WHERE h.created_at < NOW() - make_interval(mins => :head_stuck_minutes) + AND NOT EXISTS ( + SELECT 1 + FROM transactions t2 + WHERE t2.to_address = h.to_address + AND t2.status IN ({ANY_INFLIGHT_STATUSES_SQL}) + AND t2.blocked_at IS NOT NULL + AND t2.blocked_at > NOW() - make_interval(mins => :claim_window) + ) + ) SELECT - to_address as contract_address, - COUNT(*) FILTER (WHERE status IN ('ACTIVATED', 'PROPOSING', 'COMMITTING', 'REVEALING', 'ACCEPTED', 'UNDETERMINED')) as processing_count, - COUNT(*) FILTER (WHERE status = 'PENDING') as pending_count, - COUNT(*) FILTER (WHERE created_at > :one_hour_ago) as created_last_1h, - COUNT(*) FILTER (WHERE created_at > :three_hours_ago) as created_last_3h, - COUNT(*) FILTER (WHERE created_at > :six_hours_ago) as created_last_6h, - COUNT(*) FILTER (WHERE created_at > :twelve_hours_ago) as created_last_12h, - COUNT(*) FILTER (WHERE created_at > :one_day_ago) as created_last_1d, - MIN(blocked_at) as oldest_blocked_at, - MIN(created_at) FILTER (WHERE status IN ('PENDING', 'ACTIVATED', 'PROPOSING', 'COMMITTING', 'REVEALING', 'ACCEPTED', 'UNDETERMINED')) as oldest_processing_created_at, - COUNT(*) FILTER (WHERE worker_id IS NOT NULL AND status IN ('PENDING', 'ACTIVATED', 'PROPOSING', 'COMMITTING', 'REVEALING', 'ACCEPTED', 'UNDETERMINED')) as blocked_count, - json_agg(DISTINCT jsonb_build_object('worker_id', worker_id, 'hash', hash)) - FILTER (WHERE worker_id IS NOT NULL AND status IN ('PENDING', 'ACTIVATED', 'PROPOSING', 'COMMITTING', 'REVEALING', 'ACCEPTED', 'UNDETERMINED')) as worker_transactions - FROM transactions - WHERE to_address IS NOT NULL - GROUP BY to_address - HAVING COUNT(*) FILTER (WHERE status IN ('PENDING', 'ACTIVATED', 'PROPOSING', 'COMMITTING', 'REVEALING', 'ACCEPTED', 'UNDETERMINED')) > 0 - ORDER BY processing_count DESC - """ + cs.*, + sh.hash AS stuck_head_hash, + sh.status AS stuck_head_status, + sh.created_at AS stuck_head_created_at + FROM contract_stats cs + LEFT JOIN stuck_heads sh + ON sh.to_address = cs.contract_address + ORDER BY cs.processing_count DESC, cs.pending_count DESC + """ ) result = conn.execute( query, { - "one_hour_ago": now - timedelta(hours=1), - "three_hours_ago": now - timedelta(hours=3), - "six_hours_ago": now - timedelta(hours=6), - "twelve_hours_ago": now - timedelta(hours=12), - "one_day_ago": now - timedelta(days=1), + "head_stuck_minutes": HEAD_STUCK_AFTER_MINUTES, + "claim_window": CLAIM_WINDOW_MINUTES, }, ) @@ -1907,28 +2050,42 @@ def _query_consensus_detail(): contract_data["oldest_processing_created_at"] = None contract_data["oldest_processing_elapsed"] = None - orphaned_tx_hashes = [] - if row.worker_transactions: - for tx_info in row.worker_transactions: - if ( - tx_info - and tx_info.get("worker_id") not in active_workers - ): - orphaned_tx_hashes.append(tx_info.get("hash")) - - contract_data["orphaned_transactions"] = len(orphaned_tx_hashes) - if orphaned_tx_hashes: + if row.stuck_head_hash: + orphaned_tx_hashes = [row.stuck_head_hash] + contract_data["orphaned_transactions"] = 1 contract_data["orphaned_transaction_hashes"] = ( orphaned_tx_hashes ) - total_orphaned += contract_data["orphaned_transactions"] + stuck_head_created_at = None + stuck_head_elapsed = None + if row.stuck_head_created_at: + stuck_head_created_at = ( + row.stuck_head_created_at.isoformat() + ) + elapsed = now - row.stuck_head_created_at + minutes = int(elapsed.total_seconds() / 60) + stuck_head_elapsed = ( + f"{minutes}m" if minutes < 60 else f"{minutes // 60}h" + ) + contract_data["stuck_head_transaction"] = { + "hash": row.stuck_head_hash, + "status": row.stuck_head_status, + "created_at": stuck_head_created_at, + "elapsed": stuck_head_elapsed, + } + total_orphaned += 1 + else: + contract_data["orphaned_transactions"] = 0 contracts.append(contract_data) total_processing = sum(c["processing_count"] for c in contracts) status = ( "healthy" - if total_processing < 100 and total_orphaned == 0 + if ( + total_processing < 100 + and total_orphaned < DEGRADED_AT_STUCK_HEADS + ) else "degraded" ) @@ -1936,7 +2093,7 @@ def _query_consensus_detail(): "status": status, "total_processing_transactions": total_processing, "total_orphaned_transactions": total_orphaned, - "active_workers": len(active_workers), + "active_workers": active_workers_count, "contracts": contracts, } diff --git a/backend/protocol_rpc/message_handler/base.py b/backend/protocol_rpc/message_handler/base.py index 1d2ed5eec..cba510c0e 100644 --- a/backend/protocol_rpc/message_handler/base.py +++ b/backend/protocol_rpc/message_handler/base.py @@ -1,3 +1,4 @@ +import asyncio import os import json import copy @@ -47,8 +48,23 @@ def __init__(self, socketio: SocketIO, config: GlobalConfiguration): self.socketio = socketio self.config = config self.client_session_id = None + self._pending_tasks: set[asyncio.Task] = set() # Logging is configured at app startup + def _track_background_task(self, task: asyncio.Task) -> None: + """Retain a background task and report failures until it completes.""" + self._pending_tasks.add(task) + task.add_done_callback(self._background_task_done) + + def _background_task_done(self, task: asyncio.Task) -> None: + self._pending_tasks.discard(task) + if task.cancelled(): + return + + exception = task.exception() + if exception is not None: + logger.opt(exception=exception).error("Background message delivery failed") + def with_client_session(self, client_session_id: str): new_msg_handler = MessageHandler(self.socketio, self.config) new_msg_handler.client_session_id = client_session_id @@ -317,7 +333,6 @@ def setup_loguru_config(): # Get log level from environment log_level = os.environ.get("LOG_LEVEL", "INFO").upper() - logging_env = os.environ.get("LOGCONFIG", "dev") # Console handler with colors logger.add( diff --git a/backend/protocol_rpc/message_handler/fastapi_handler.py b/backend/protocol_rpc/message_handler/fastapi_handler.py index 5e0af271d..8ac632ea2 100644 --- a/backend/protocol_rpc/message_handler/fastapi_handler.py +++ b/backend/protocol_rpc/message_handler/fastapi_handler.py @@ -30,9 +30,25 @@ def __init__(self, broadcast: Broadcast, config: GlobalConfiguration): self.broadcast = broadcast self.config = config self.client_session_id = None + self._pending_tasks: set[asyncio.Task] = set() + + def _track_background_task(self, task: asyncio.Task) -> None: + """Retain a background task and report failures until it completes.""" + self._pending_tasks.add(task) + task.add_done_callback(self._background_task_done) + + def _background_task_done(self, task: asyncio.Task) -> None: + self._pending_tasks.discard(task) + if task.cancelled(): + return + + exception = task.exception() + if exception is not None: + logger.opt(exception=exception).error("Background broadcast publish failed") def with_client_session(self, client_session_id: str): new_msg_handler = MessageHandler(self.broadcast, self.config) + new_msg_handler._pending_tasks = self._pending_tasks new_msg_handler.client_session_id = client_session_id return new_msg_handler @@ -83,7 +99,10 @@ def _publish(self, channel: str, payload: dict[str, Any]) -> None: if not loop.is_running(): return - loop.create_task(self.broadcast.publish(channel=channel, message=message)) + task = loop.create_task( + self.broadcast.publish(channel=channel, message=message) + ) + self._track_background_task(task) def _socket_emit(self, log_event: LogEvent) -> None: """Emit a log event via broadcast channels. diff --git a/backend/protocol_rpc/message_handler/redis_worker_handler.py b/backend/protocol_rpc/message_handler/redis_worker_handler.py index c9551eea2..a30138fdb 100644 --- a/backend/protocol_rpc/message_handler/redis_worker_handler.py +++ b/backend/protocol_rpc/message_handler/redis_worker_handler.py @@ -11,7 +11,7 @@ from loguru import logger from backend.protocol_rpc.message_handler.base import MessageHandler -from backend.protocol_rpc.message_handler.types import LogEvent +from backend.protocol_rpc.message_handler.types import EventScope, LogEvent from backend.protocol_rpc.configuration import GlobalConfiguration @@ -127,9 +127,9 @@ def _get_channel_for_event(self, log_event: LogEvent) -> str: Returns: The Redis channel name """ - if log_event.scope.value == "Transaction": + if log_event.scope == EventScope.TRANSACTION: return self.TRANSACTION_CHANNEL - elif log_event.scope.value == "Consensus": + elif log_event.scope == EventScope.CONSENSUS: return self.CONSENSUS_CHANNEL else: return self.GENERAL_CHANNEL @@ -182,7 +182,8 @@ def _socket_emit(self, log_event: LogEvent): loop = asyncio.get_event_loop() if loop.is_running(): # Schedule the async send operation - asyncio.create_task(self._publish_to_redis(log_event)) + task = asyncio.create_task(self._publish_to_redis(log_event)) + self._track_background_task(task) else: # If no loop is running, run it synchronously asyncio.run(self._publish_to_redis(log_event)) @@ -205,10 +206,10 @@ def send_message(self, log_event: LogEvent, log_to_terminal: bool = True): self._log_message(log_event) # Publish to Redis if it's an important event - if log_event.transaction_hash or log_event.scope.value in [ - "TRANSACTION", - "CONSENSUS", - ]: + if log_event.transaction_hash or log_event.scope in ( + EventScope.TRANSACTION, + EventScope.CONSENSUS, + ): self._socket_emit(log_event) async def send_message_async( @@ -226,10 +227,10 @@ async def send_message_async( self._log_message(log_event) # Publish to Redis if it's an important event and await completion - if log_event.transaction_hash or log_event.scope.value in [ - "TRANSACTION", - "CONSENSUS", - ]: + if log_event.transaction_hash or log_event.scope in ( + EventScope.TRANSACTION, + EventScope.CONSENSUS, + ): await self._publish_to_redis(log_event) async def close(self): diff --git a/backend/protocol_rpc/message_handler/worker_handler.py b/backend/protocol_rpc/message_handler/worker_handler.py index cd24a20b7..1e787f832 100644 --- a/backend/protocol_rpc/message_handler/worker_handler.py +++ b/backend/protocol_rpc/message_handler/worker_handler.py @@ -11,7 +11,7 @@ from loguru import logger from backend.protocol_rpc.message_handler.base import MessageHandler -from backend.protocol_rpc.message_handler.types import LogEvent +from backend.protocol_rpc.message_handler.types import EventScope, LogEvent from backend.protocol_rpc.configuration import GlobalConfiguration @@ -114,7 +114,8 @@ def _socket_emit(self, log_event: LogEvent): loop = asyncio.get_event_loop() if loop.is_running(): # Schedule the async send operation - asyncio.create_task(self._send_event_to_server(log_event)) + task = asyncio.create_task(self._send_event_to_server(log_event)) + self._track_background_task(task) else: # If no loop is running, try to run it synchronously asyncio.run(self._send_event_to_server(log_event)) @@ -137,10 +138,10 @@ def send_message(self, log_event: LogEvent, log_to_terminal: bool = True): self._log_message(log_event) # Forward to JSON-RPC server if it's an important event - if log_event.transaction_hash or log_event.scope.value in [ - "TRANSACTION", - "CONSENSUS", - ]: + if log_event.transaction_hash or log_event.scope in ( + EventScope.TRANSACTION, + EventScope.CONSENSUS, + ): self._socket_emit(log_event) async def close(self): diff --git a/backend/protocol_rpc/rate_limit_methods.py b/backend/protocol_rpc/rate_limit_methods.py new file mode 100644 index 000000000..0286d22e2 --- /dev/null +++ b/backend/protocol_rpc/rate_limit_methods.py @@ -0,0 +1,112 @@ +"""Classification of JSON-RPC methods for rate limiting. + +Requests are split into two buckets: + +- *cheap reads* — methods that never enter the GenVM and never touch an LLM. + These are served from Postgres (or are outright constants), so the cost of + serving one is orders of magnitude below a consensus round. They get their + own, much larger bucket. +- *everything else* — the default. Keeps the pre-existing limits untouched. + +The allowlist below is deliberately conservative and maintained by hand rather +than derived from any other list in the codebase. Two traps make that +necessary: + +- ``DISABLE_INFO_LOGS_ENDPOINTS`` (set in the deployment env) looks like the + natural source, but it contains ``eth_call`` — which runs contract code in + the GenVM and can fan out to LLM validators. Reusing that list would make the + single most expensive call in the system effectively free. +- ``gen_getContractSchema`` and ``gen_getContractSchemaForCode`` read like + metadata lookups, but both build a ``Node`` backed by a ``GenVMManager`` to + derive the schema from bytecode. + +Being too conservative is cheap: an omitted method simply keeps today's limits. +Being too liberal hands out free capacity on a path that costs real money. When +in doubt, leave a method out. +""" + +from __future__ import annotations + +import json +from typing import Any + +# Bodies larger than this are not parsed for classification — they are charged +# to the standard bucket. A cheap read is a handful of bytes; anything this +# large is a contract deployment or a batch, neither of which is cheap. +MAX_CLASSIFY_BODY_BYTES = 64 * 1024 + +CHEAP_READ_METHODS = frozenset( + { + # Constants / trivial responses + "ping", + "net_version", + "eth_chainId", + "eth_syncing", + "eth_gasPrice", + "eth_maxPriorityFeePerGas", + "eth_blockNumber", + "eth_feeHistory", + "eth_getCode", # returns a literal "0x" + "eth_estimateGas", # returns a constant + "sim_getFinalityWindowTime", + "sim_getConsensusContract", + "sim_getFeeConfig", + # Indexed database reads + "eth_getBalance", + "eth_getTransactionCount", + "eth_getTransactionByHash", + "eth_getTransactionReceipt", + "eth_getBlockByHash", + "eth_getBlockByNumber", + "gen_getContractCode", + "gen_getContractNonce", + "gen_getTransactionStatus", + "gen_getTransactionStatusDetails", + "gen_getStudioTransactionByHash", + "sim_getTransactionsForAddress", + } +) + +# Explicitly *not* cheap, recorded here so the reasoning survives future edits: +# eth_call, gen_call, sim_call -> execute contract code in the GenVM +# gen_getContractSchema -> builds a Node + GenVMManager +# gen_getContractSchemaForCode -> builds a Node + GenVMManager +# sim_lintContract -> runs the GenVM linter +# sim_estimateTransactionFees -> executes the contract via sim_call to +# measure fees. Note the contrast with +# eth_estimateGas, which is allowlisted +# because it returns a constant. The +# names are near-identical; the cost is +# not. +# eth_getLogs -> unbounded range scan +# eth_sendRawTransaction, sim_*, admin_*, dev_* -> writes / privileged + + +def is_cheap_read_payload(raw_body: bytes) -> bool: + """Return True if every call in this JSON-RPC body is a cheap read. + + Anything ambiguous — unparseable, oversized, empty, or a batch containing a + single expensive call — is reported as *not* cheap, so uncertainty charges + the stricter bucket rather than the looser one. + """ + if not raw_body or len(raw_body) > MAX_CLASSIFY_BODY_BYTES: + return False + + try: + payload = json.loads(raw_body) + except (ValueError, UnicodeDecodeError): + return False + + if isinstance(payload, list): + # A batch is only cheap if every member is. An empty batch is invalid + # JSON-RPC and is charged normally. + return bool(payload) and all(_is_cheap_call(call) for call in payload) + + return _is_cheap_call(payload) + + +def _is_cheap_call(call: Any) -> bool: + if not isinstance(call, dict): + return False + method = call.get("method") + return isinstance(method, str) and method in CHEAP_READ_METHODS diff --git a/backend/protocol_rpc/rate_limit_middleware.py b/backend/protocol_rpc/rate_limit_middleware.py index e9651ab91..8113967c1 100644 --- a/backend/protocol_rpc/rate_limit_middleware.py +++ b/backend/protocol_rpc/rate_limit_middleware.py @@ -12,10 +12,32 @@ from starlette.responses import JSONResponse, Response from backend.protocol_rpc.exceptions import RateLimitExceeded -from backend.protocol_rpc.rate_limiter import RateLimiterService +from backend.protocol_rpc.rate_limit_methods import is_cheap_read_payload +from backend.protocol_rpc.rate_limiter import RateLimiterService, RateLimitUsage logger = logging.getLogger(__name__) +# Keys may be supplied either as an X-API-Key header or as a single path +# segment (`/api/glk_...`). The path form exists because the EVM toolchain — +# MetaMask's "Add network", `foundry.toml`, `--rpc-url`, most viem/ethers +# setups — accepts one URL string and offers no way to attach a header. Every +# major RPC provider puts the key in the URL for that reason. Header-only +# forced at least one integrator to plan a reverse proxy whose entire job was +# turning a URL into a header. +API_PATH = "/api" +API_KEY_PREFIX = "glk_" + + +def _rpc_path_segment(path: str) -> Optional[str]: + """Return the single path segment under /api/, or None if not that shape.""" + if not path.startswith(API_PATH + "/"): + return None + segment = path[len(API_PATH) + 1 :] + if not segment or "/" in segment: + return None + return segment + + DEFAULT_TRUSTED_PROXY_CIDRS = ( "127.0.0.0/8", "10.0.0.0/8", # NOSONAR - RFC1918 private proxy range. @@ -51,8 +73,11 @@ def __init__(self, app, dispatch=None): self._trusted_proxy_networks = _load_trusted_proxy_networks() async def dispatch(self, request: Request, call_next) -> Response: - # Only rate-limit the JSON-RPC endpoint - if request.url.path != "/api" or request.method != "POST": + # Only rate-limit the JSON-RPC endpoint. This gate must cover every + # path the /api/{api_key} route can match, not just key-shaped ones — + # anything the route serves but this misses would be an unlimited, + # unauthenticated RPC endpoint. + if not self._is_rpc_path(request.url.path) or request.method != "POST": return await call_next(request) rate_limiter: Optional[RateLimiterService] = getattr( @@ -61,15 +86,16 @@ async def dispatch(self, request: Request, call_next) -> Response: if rate_limiter is None or not rate_limiter.enabled: return await call_next(request) - api_key = request.headers.get("X-API-Key") + api_key = self._api_key(request) client_ip = self._client_ip(request) + is_cheap_read = await self._is_cheap_read(request) + usage: Optional[RateLimitUsage] = None try: - await rate_limiter.check_rate_limit(api_key, client_ip) + usage = await rate_limiter.check_rate_limit( + api_key, client_ip, is_cheap_read=is_cheap_read + ) except RateLimitExceeded as exc: - retry_after = "60" - if exc.data and isinstance(exc.data, dict): - retry_after = str(exc.data.get("retry_after_seconds", 60)) return JSONResponse( status_code=429, content={ @@ -77,7 +103,7 @@ async def dispatch(self, request: Request, call_next) -> Response: "error": exc.to_dict(), "id": None, }, - headers={"Retry-After": retry_after}, + headers=self._denial_headers(exc), ) except Exception: logger.warning( @@ -85,7 +111,70 @@ async def dispatch(self, request: Request, call_next) -> Response: exc_info=True, ) - return await call_next(request) + response = await call_next(request) + if usage is not None: + response.headers.update(usage.as_headers()) + return response + + @staticmethod + def _is_rpc_path(path: str) -> bool: + return path == API_PATH or _rpc_path_segment(path) is not None + + @staticmethod + def _api_key(request: Request) -> Optional[str]: + """Resolve the API key from the path, falling back to the header. + + The path wins when both are present: it is what the caller typed into + their tool, whereas a header may have been injected by an intermediary + they cannot see. + + Only `glk_`-prefixed segments count as keys. A segment of some other + shape is treated as no key at all rather than as a bad one, so it falls + through to anonymous limits instead of erroring — while a mistyped real + key still fails loudly as invalid, which is the confusing case worth + being loud about. + """ + segment = _rpc_path_segment(request.url.path) + if segment and segment.startswith(API_KEY_PREFIX): + return segment + return request.headers.get("X-API-Key") + + async def _is_cheap_read(self, request: Request) -> bool: + """Classify the request body, charging the stricter bucket on any doubt. + + Reading the body here is safe because Starlette's BaseHTTPMiddleware + wraps the request in a _CachedRequest, which replays the buffered body + downstream. Calling request.stream() instead would starve the route + handler. + """ + try: + raw_body = await request.body() + except Exception: + logger.warning( + "Could not read request body to classify rate limit bucket", + exc_info=True, + ) + return False + return is_cheap_read_payload(raw_body) + + @staticmethod + def _denial_headers(exc: RateLimitExceeded) -> dict: + data = exc.data if isinstance(exc.data, dict) else {} + retry_after = data.get("retry_after_seconds", 60) + headers = {"Retry-After": str(retry_after)} + # An invalid API key is raised before any window is evaluated, so there + # is no usage to report — only the windowed denials carry limits. + if "limit" in data: + headers.update( + { + "X-RateLimit-Bucket": str(data.get("bucket", "standard")), + "X-RateLimit-Window": str(data.get("window", "")), + "X-RateLimit-Limit": str(data["limit"]), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(retry_after), + } + ) + return headers def _client_ip(self, request: Request) -> str: peer_host = request.client.host if request.client else "unknown" diff --git a/backend/protocol_rpc/rate_limiter.py b/backend/protocol_rpc/rate_limiter.py index 629aa044d..c33e7aec7 100644 --- a/backend/protocol_rpc/rate_limiter.py +++ b/backend/protocol_rpc/rate_limiter.py @@ -24,6 +24,15 @@ DEFAULT_ANON_PER_HOUR = 500 DEFAULT_ANON_PER_DAY = 5000 +# Cheap reads (see rate_limit_methods) are metered in their own bucket at this +# multiple of the tier's limits. Reads never reach the GenVM, so the tier +# numbers — which have to be sized for consensus rounds — are far stricter than +# a database read warrants. +DEFAULT_READ_MULTIPLIER = 10 + +STANDARD_BUCKET = "standard" +READ_BUCKET = "read" + # Lua script that atomically prunes, checks, and records in one round-trip. # This eliminates the TOCTOU race where concurrent requests could all read the # same stale count before any of them recorded, bypassing the limit. @@ -32,7 +41,14 @@ # ARGV: [now, member, minute_window, minute_limit, hour_window, hour_limit, # day_window, day_limit] # -# Returns: [0] on success, or [1, window_name, limit, count, retry_after] on denial. +# Returns [allowed, window_name, limit, count, reset_seconds] in both the +# allowed (allowed=0) and denied (allowed=1) case. The reported window is the +# one closest to exhaustion, which is what a client needs in order to pace +# itself — reporting all three would just make the caller compute this anyway. +# +# `reset_seconds` is the time until the oldest entry in that window ages out, +# i.e. when capacity actually frees up. For a sliding window that is the honest +# answer; the window length alone would overstate the wait. _CHECK_AND_RECORD_LUA = """ local now = tonumber(ARGV[1]) local member = ARGV[2] @@ -43,12 +59,24 @@ {key = KEYS[3], seconds = tonumber(ARGV[7]), limit = tonumber(ARGV[8]), name = "day"}, } +local function reset_seconds(w) + local oldest = redis.call('ZRANGE', w.key, 0, 0, 'WITHSCORES') + if not oldest[2] then + return w.seconds + end + local reset = math.ceil(tonumber(oldest[2]) + w.seconds - now) + if reset < 1 then + return 1 + end + return reset +end + -- Phase 1: Prune expired entries and check counts for _, w in ipairs(windows) do redis.call('ZREMRANGEBYSCORE', w.key, 0, now - w.seconds) - local count = redis.call('ZCARD', w.key) - if count >= w.limit then - return {1, w.name, w.limit, count, w.seconds} + w.count = redis.call('ZCARD', w.key) + if w.count >= w.limit then + return {1, w.name, w.limit, w.count, reset_seconds(w)} end end @@ -56,9 +84,18 @@ for _, w in ipairs(windows) do redis.call('ZADD', w.key, now, member) redis.call('EXPIRE', w.key, w.seconds + 60) + w.count = w.count + 1 end -return {0} +-- Phase 3: Report whichever window has the least headroom left +local tightest = windows[1] +for _, w in ipairs(windows) do + if (w.limit - w.count) < (tightest.limit - tightest.count) then + tightest = w + end +end + +return {0, tightest.name, tightest.limit, tightest.count, reset_seconds(tightest)} """ @@ -69,6 +106,34 @@ class TierLimits: rate_limit_hour: int rate_limit_day: int + def scaled(self, factor: int) -> "TierLimits": + return TierLimits( + name=self.name, + rate_limit_minute=self.rate_limit_minute * factor, + rate_limit_hour=self.rate_limit_hour * factor, + rate_limit_day=self.rate_limit_day * factor, + ) + + +@dataclass(frozen=True) +class RateLimitUsage: + """Headroom in the window closest to exhaustion, for X-RateLimit-* headers.""" + + bucket: str + window: str + limit: int + remaining: int + reset_seconds: int + + def as_headers(self) -> dict[str, str]: + return { + "X-RateLimit-Bucket": self.bucket, + "X-RateLimit-Window": self.window, + "X-RateLimit-Limit": str(self.limit), + "X-RateLimit-Remaining": str(self.remaining), + "X-RateLimit-Reset": str(self.reset_seconds), + } + class RateLimiterService: """Sliding-window rate limiter backed by Redis sorted sets.""" @@ -81,6 +146,7 @@ def __init__( anon_per_minute: int = DEFAULT_ANON_PER_MINUTE, anon_per_hour: int = DEFAULT_ANON_PER_HOUR, anon_per_day: int = DEFAULT_ANON_PER_DAY, + read_multiplier: int = DEFAULT_READ_MULTIPLIER, ): self._redis = redis_client self._get_session = get_session @@ -91,6 +157,9 @@ def __init__( rate_limit_hour=anon_per_hour, rate_limit_day=anon_per_day, ) + # A multiplier below 1 would make reads *stricter* than writes, which is + # never intended; clamp rather than trust the environment. + self._read_multiplier = max(1, read_multiplier) self._lua_sha: Optional[str] = None @classmethod @@ -112,26 +181,42 @@ def from_environment( anon_per_day=int( os.environ.get("RATE_LIMIT_ANON_PER_DAY", DEFAULT_ANON_PER_DAY) ), + read_multiplier=int( + os.environ.get("RATE_LIMIT_READ_MULTIPLIER", DEFAULT_READ_MULTIPLIER) + ), ) @property def enabled(self) -> bool: return self._enabled - async def check_rate_limit(self, api_key: Optional[str], client_ip: str) -> None: - """Check rate limits. Raises RateLimitExceeded if over limit.""" + async def check_rate_limit( + self, + api_key: Optional[str], + client_ip: str, + is_cheap_read: bool = False, + ) -> Optional[RateLimitUsage]: + """Check rate limits. Raises RateLimitExceeded if over limit. + + Returns the usage of whichever window is closest to exhaustion, or None + when limiting is disabled. + """ if not self._enabled: - return + return None if api_key: identity, limits = await self._resolve_api_key(api_key) - if identity is None: + if identity is None or limits is None: raise RateLimitExceeded(message="Invalid API key") else: identity = f"ip:{client_ip}" limits = self._anon_limits - await self._check_windows(identity, limits) + if is_cheap_read: + return await self._check_windows( + identity, limits.scaled(self._read_multiplier), READ_BUCKET + ) + return await self._check_windows(identity, limits, STANDARD_BUCKET) async def _resolve_api_key( self, raw_key: str @@ -192,15 +277,27 @@ async def _ensure_lua_loaded(self) -> str: self._lua_sha = await self._redis.script_load(_CHECK_AND_RECORD_LUA) return self._lua_sha - async def _check_windows(self, identity: str, limits: TierLimits) -> None: + async def _check_windows( + self, + identity: str, + limits: TierLimits, + bucket: str, + ) -> RateLimitUsage: """Atomically prune, check, and record using a Lua script.""" now = time.time() member = f"{now}:{uuid.uuid4().hex[:8]}" + # The standard bucket keeps its original key shape so that limits in + # flight at deploy time carry over instead of silently resetting. + prefix = ( + f"ratelimit:{identity}" + if bucket == STANDARD_BUCKET + else f"ratelimit:{identity}:{bucket}" + ) keys = [ - f"ratelimit:{identity}:minute", - f"ratelimit:{identity}:hour", - f"ratelimit:{identity}:day", + f"{prefix}:minute", + f"{prefix}:hour", + f"{prefix}:day", ] args = [ str(now), @@ -222,23 +319,31 @@ async def _check_windows(self, identity: str, limits: TierLimits) -> None: sha = await self._ensure_lua_loaded() result = await self._redis.evalsha(sha, len(keys), *keys, *args) + window_name = result[1].decode() if isinstance(result[1], bytes) else result[1] + max_requests = int(result[2]) + count = int(result[3]) + reset_after = int(result[4]) + if result[0] == 1: - window_name = ( - result[1].decode() if isinstance(result[1], bytes) else result[1] - ) - max_requests = int(result[2]) - count = int(result[3]) - retry_after = int(result[4]) raise RateLimitExceeded( message=f"Rate limit exceeded: {max_requests} requests per {window_name}", data={ + "bucket": bucket, "window": window_name, "limit": max_requests, "current": count, - "retry_after_seconds": retry_after, + "retry_after_seconds": reset_after, }, ) + return RateLimitUsage( + bucket=bucket, + window=window_name, + limit=max_requests, + remaining=max(0, max_requests - count), + reset_seconds=reset_after, + ) + async def invalidate_key_cache(self, key_hash: str) -> None: """Invalidate cached tier for an API key (call after deactivation).""" cache_key = f"ratelimit:tier:{key_hash}" diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index 1c8785850..2bbea44b0 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -169,6 +169,7 @@ async def update_validator( stake: int | None = None, provider: str | None = None, model: str | None = None, + config: dict | None = None, plugin: str | None = None, plugin_config: dict | None = None, session: Session = Depends(get_db_session), @@ -181,6 +182,7 @@ async def update_validator( stake=stake, provider=provider, model=model, + config=config, plugin=plugin, plugin_config=plugin_config, ) @@ -291,6 +293,11 @@ def get_finality_window_time( return impl.get_finality_window_time(consensus) +@rpc.method("sim_getFeeConfig", log_policy=LogPolicy.debug()) +def get_fee_config() -> dict: + return impl.get_studio_fee_config() + + @rpc.method("sim_getConsensusContract", log_policy=LogPolicy.debug()) def get_consensus_contract( contract_name: str, @@ -424,6 +431,27 @@ async def sim_call( ) +@rpc.method("sim_estimateTransactionFees") +async def sim_estimate_transaction_fees( + params: dict, + session: Session = Depends(get_db_session), + accounts_manager: AccountsManager = Depends(get_accounts_manager), + msg_handler=Depends(get_message_handler), + transactions_parser=Depends(get_transactions_parser), + validators_manager=Depends(get_validators_manager), + genvm_manager=Depends(get_genvm_manager), +) -> dict: + return await impl.sim_estimate_transaction_fees( + session=session, + accounts_manager=accounts_manager, + msg_handler=msg_handler, + transactions_parser=transactions_parser, + validators_manager=validators_manager, + genvm_manager=genvm_manager, + params=params, + ) + + # --------------------------------------------------------------------------- # Ethereum-compatible endpoints # --------------------------------------------------------------------------- @@ -479,6 +507,17 @@ def get_transaction_status( ) +@rpc.method("gen_getTransactionStatusDetails", log_policy=LogPolicy.debug()) +def get_transaction_status_details( + transaction_hash: str, + transactions_processor: TransactionsProcessor = Depends(get_transactions_processor), +) -> dict: + return impl.get_transaction_status_details( + transactions_processor=transactions_processor, + transaction_hash=transaction_hash, + ) + + @rpc.method("eth_call", log_policy=LogPolicy.debug()) async def eth_call( params: dict, diff --git a/backend/protocol_rpc/transactions_parser.py b/backend/protocol_rpc/transactions_parser.py index 3502d8d6d..1a0f85126 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -22,9 +22,181 @@ DecodedGenlayerTransaction, DecodedGenlayerTransactionData, DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, ZERO_ADDRESS, ) +FEE_AWARE_ADD_TRANSACTION_ABI = { + "inputs": [ + { + "components": [ + {"internalType": "address", "name": "sender", "type": "address"}, + {"internalType": "address", "name": "recipient", "type": "address"}, + { + "internalType": "uint256", + "name": "numOfInitialValidators", + "type": "uint256", + }, + {"internalType": "uint256", "name": "maxRotations", "type": "uint256"}, + {"internalType": "uint256", "name": "validUntil", "type": "uint256"}, + {"internalType": "uint256", "name": "saltNonce", "type": "uint256"}, + {"internalType": "uint256", "name": "userValue", "type": "uint256"}, + { + "components": [ + { + "internalType": "uint256", + "name": "leaderTimeunitsAllocation", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "validatorTimeunitsAllocation", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "appealRounds", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "executionBudgetPerRound", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "executionConsumed", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "totalMessageFees", + "type": "uint256", + }, + { + "internalType": "uint256[]", + "name": "rotations", + "type": "uint256[]", + }, + { + "internalType": "uint256", + "name": "maxPriceGenPerTimeUnit", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "storageFeeMaxGasPrice", + "type": "uint256", + }, + { + "internalType": "uint256", + "name": "receiptFeeMaxGasPrice", + "type": "uint256", + }, + ], + "internalType": "struct IFeeManager.FeesDistribution", + "name": "feesDistribution", + "type": "tuple", + }, + {"internalType": "bytes", "name": "txCalldata", "type": "bytes"}, + { + "components": [ + { + "internalType": "enum IMessages.MessageType", + "name": "messageType", + "type": "uint8", + }, + { + "internalType": "bool", + "name": "onAcceptance", + "type": "bool", + }, + { + "internalType": "uint256", + "name": "parentIndex", + "type": "uint256", + }, + { + "internalType": "address", + "name": "recipient", + "type": "address", + }, + { + "internalType": "bytes32", + "name": "callKey", + "type": "bytes32", + }, + { + "internalType": "uint256", + "name": "budget", + "type": "uint256", + }, + {"internalType": "bytes", "name": "feeParams", "type": "bytes"}, + ], + "internalType": "struct IMessages.MessageFeeAllocationNode[]", + "name": "messageAllocations", + "type": "tuple[]", + }, + ], + "internalType": "struct IConsensusMainWithFees.AddTransactionParams", + "name": "_params", + "type": "tuple", + } + ], + "name": "addTransaction", + "outputs": [], + "stateMutability": "payable", + "type": "function", +} + +FEE_AWARE_DEPLOY_SALTED_ABI = { + **FEE_AWARE_ADD_TRANSACTION_ABI, + "name": "deploySalted", +} + +FEE_AWARE_TOP_UP_FEES_ABI = { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + FEE_AWARE_ADD_TRANSACTION_ABI["inputs"][0]["components"][7] + | {"name": "_feesDistribution"}, + ], + "name": "topUpFees", + "outputs": [], + "stateMutability": "payable", + "type": "function", +} + +FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI = { + **FEE_AWARE_TOP_UP_FEES_ABI, + "name": "topUpAndSubmitAppeal", +} + +FEES_DISTRIBUTION_FIELDS = [ + "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation", + "appealRounds", + "executionBudgetPerRound", + "executionConsumed", + "totalMessageFees", + "rotations", + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", +] + +ADD_TRANSACTION_PARAMS_FIELDS = [ + "sender", + "recipient", + "numOfInitialValidators", + "maxRotations", + "validUntil", + "saltNonce", + "userValue", + "feesDistribution", + "txCalldata", + "messageAllocations", +] + class Boolean: """A sedes for booleans @@ -164,6 +336,8 @@ def _to_int(value: bytes) -> int: to_address = None nonce = signed_transaction_as_dict["nonce"] value = signed_transaction_as_dict["value"] + submitted_value = int(value) + fee_value = 0 # Some decoders return `data`, others return `input` input_raw = ( signed_transaction_as_dict.get("data") @@ -192,7 +366,7 @@ def _to_int(value: bytes) -> int: for abi_entry in contract_abi: if abi_entry["type"] == "function": # Calculate function selector from ABI - function_signature = f"{abi_entry['name']}({','.join([input['type'] for input in abi_entry['inputs']])})" + function_signature = f"{abi_entry['name']}({','.join([self._canonical_abi_type(input) for input in abi_entry['inputs']])})" calculated_selector = self.web3.keccak(text=function_signature)[ :4 ].hex() @@ -200,7 +374,8 @@ def _to_int(value: bytes) -> int: if calculated_selector == function_selector: # Decode parameters using the input types from ABI input_types = [ - input["type"] for input in abi_entry["inputs"] + self._canonical_abi_type(input) + for input in abi_entry["inputs"] ] decoded_params = self.web3.codec.decode( input_types, bytes.fromhex(parameters) @@ -219,27 +394,42 @@ def _to_int(value: bytes) -> int: ), } # Convert the decoded data into proper dataclasses - if decoded_data["function"] == "addTransaction": + if decoded_data["function"] in { + "addTransaction", + "deploySalted", + }: params = decoded_data["params"] - decoded_data = DecodedRollupTransactionData( - function_name=decoded_data["function"], - args=DecodedRollupTransactionDataArgs( - sender=to_checksum_address(params["_sender"]), - recipient=to_checksum_address( - params["_recipient"] - ), - num_of_initial_validators=params[ - "_numOfInitialValidators" - ], - max_rotations=params["_maxRotations"], - data=params["_txData"], - ), + decoded_data, value, fee_value = ( + self._decode_add_transaction_data( + decoded_data["function"], params, value + ) ) elif decoded_data["function"] == "submitAppeal": params = decoded_data["params"] decoded_data = DecodedsubmitAppealDataArgs( tx_id=params["_txId"], ) + elif decoded_data["function"] == "topUpFees": + params = decoded_data["params"] + decoded_data = DecodedTopUpFeesDataArgs( + tx_id=params["_txId"], + fees_distribution=self._fees_distribution_to_dict( + params["_feesDistribution"] + ), + ) + fee_value = int(value) + value = 0 + elif decoded_data["function"] == "topUpAndSubmitAppeal": + params = decoded_data["params"] + decoded_data = DecodedsubmitAppealDataArgs( + tx_id=params["_txId"], + fees_distribution=self._fees_distribution_to_dict( + params["_feesDistribution"] + ), + top_up_and_submit=True, + ) + fee_value = int(value) + value = 0 return DecodedRollupTransaction( from_address=sender, @@ -248,6 +438,8 @@ def _to_int(value: bytes) -> int: type=signed_transaction_as_dict.get("type", 0), nonce=nonce, value=value, + fee_value=fee_value, + submitted_value=submitted_value, ) except Exception as e: @@ -484,7 +676,97 @@ def _vrs_from(self, signed_transaction) -> tuple: def _get_contract_abi(self) -> list: # Get contract ABI from consensus service contract_data = self.consensus_service.load_contract("ConsensusMain") - return contract_data["abi"] if contract_data else [] + contract_abi = list(contract_data["abi"]) if contract_data else [] + contract_abi.extend( + [ + FEE_AWARE_ADD_TRANSACTION_ABI, + FEE_AWARE_DEPLOY_SALTED_ABI, + FEE_AWARE_TOP_UP_FEES_ABI, + FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI, + ] + ) + return contract_abi + + def _canonical_abi_type(self, abi_input: dict) -> str: + input_type = abi_input["type"] + if not input_type.startswith("tuple"): + return input_type + + suffix = input_type[5:] + component_types = ",".join( + self._canonical_abi_type(component) + for component in abi_input.get("components", []) + ) + return f"({component_types}){suffix}" + + def _decode_add_transaction_data( + self, function_name: str, params: dict, msg_value: int + ) -> tuple[DecodedRollupTransactionData, int, int]: + if "_params" in params: + add_params = dict(zip(ADD_TRANSACTION_PARAMS_FIELDS, params["_params"])) + user_value = int(add_params["userValue"]) + fee_value = max(0, int(msg_value) - user_value) + return ( + DecodedRollupTransactionData( + function_name=function_name, + args=DecodedRollupTransactionDataArgs( + sender=to_checksum_address(add_params["sender"]), + recipient=to_checksum_address(add_params["recipient"]), + num_of_initial_validators=int( + add_params["numOfInitialValidators"] + ), + max_rotations=int(add_params["maxRotations"]), + data=add_params["txCalldata"], + valid_until=int(add_params["validUntil"]), + salt_nonce=int(add_params["saltNonce"]), + user_value=user_value, + fees_distribution=self._fees_distribution_to_dict( + add_params["feesDistribution"] + ), + message_allocations=[ + self._message_allocation_to_dict(allocation) + for allocation in add_params["messageAllocations"] + ], + message_allocations_count=len(add_params["messageAllocations"]), + ), + ), + user_value, + fee_value, + ) + + return ( + DecodedRollupTransactionData( + function_name=function_name, + args=DecodedRollupTransactionDataArgs( + sender=to_checksum_address(params["_sender"]), + recipient=to_checksum_address(params["_recipient"]), + num_of_initial_validators=int(params["_numOfInitialValidators"]), + max_rotations=int(params["_maxRotations"]), + data=params["_txData"], + ), + ), + int(msg_value), + 0, + ) + + def _fees_distribution_to_dict(self, fees_distribution: tuple) -> dict: + result = dict(zip(FEES_DISTRIBUTION_FIELDS, fees_distribution)) + result["rotations"] = [int(rotation) for rotation in result["rotations"]] + for key, value in result.items(): + if key != "rotations": + result[key] = int(value) + return result + + def _message_allocation_to_dict(self, message_allocation: tuple) -> dict: + return { + "messageType": int(message_allocation[0]), + "onAcceptance": bool(message_allocation[1]), + "parentIndex": int(message_allocation[2]), + "recipient": to_checksum_address(message_allocation[3]), + "callKey": eth_utils.to_hex(message_allocation[4]), + "budget": int(message_allocation[5]), + "feeParams": bytes(message_allocation[6]), + } class DeploymentContractTransactionPayload(rlp.Serializable): diff --git a/backend/protocol_rpc/types.py b/backend/protocol_rpc/types.py index d90781081..05325c2c1 100644 --- a/backend/protocol_rpc/types.py +++ b/backend/protocol_rpc/types.py @@ -30,6 +30,14 @@ def to_json(self) -> dict[str]: @dataclass class DecodedsubmitAppealDataArgs: tx_id: str + fees_distribution: dict | None = None + top_up_and_submit: bool = False + + +@dataclass +class DecodedTopUpFeesDataArgs: + tx_id: str + fees_distribution: dict @dataclass @@ -39,6 +47,12 @@ class DecodedRollupTransactionDataArgs: num_of_initial_validators: int max_rotations: int data: str + valid_until: int | None = None + salt_nonce: int = 0 + user_value: int | None = None + fees_distribution: dict | None = None + message_allocations: list[dict] = field(default_factory=list) + message_allocations_count: int = 0 @dataclass @@ -51,10 +65,23 @@ class DecodedRollupTransactionData: class DecodedRollupTransaction: from_address: str to_address: str - data: DecodedRollupTransactionData | DecodedsubmitAppealDataArgs + data: ( + DecodedRollupTransactionData + | DecodedsubmitAppealDataArgs + | DecodedTopUpFeesDataArgs + | None + ) type: str nonce: int value: int + fee_value: int = 0 + submitted_value: int | None = None + + @property + def total_spend(self) -> int: + if self.submitted_value is not None: + return self.submitted_value + return self.value + self.fee_value @dataclass diff --git a/backend/protocol_rpc/validators_init.py b/backend/protocol_rpc/validators_init.py index 1f012af45..8bbab783e 100644 --- a/backend/protocol_rpc/validators_init.py +++ b/backend/protocol_rpc/validators_init.py @@ -92,6 +92,7 @@ async def initialize_validators( desired_hash = _desired_config_hash(validators_json) except (TypeError, KeyError) as e: # Bad config fields — fall through to the creation loop which gives a better error + logger.warning(f"Unable to hash validators config; validation will report: {e}") desired_hash = None current_hash = _current_config_hash(validators_manager.registry) diff --git a/backend/requirements.txt b/backend/requirements.txt index b2fe1b0d9..70aaed86d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -33,3 +33,5 @@ redis==7.2.1 prometheus-client==0.24.1 psutil==7.2.2 sentry-sdk==2.54.0 +boto3==1.43.30 +google-cloud-storage==2.18.2 diff --git a/backend/services/usage_metrics_service.py b/backend/services/usage_metrics_service.py index a6f4e1966..80297d2d3 100644 --- a/backend/services/usage_metrics_service.py +++ b/backend/services/usage_metrics_service.py @@ -1,8 +1,9 @@ # backend/services/usage_metrics_service.py -import os import asyncio -from typing import Optional +import math +import os +from typing import Any, Optional from datetime import datetime import aiohttp from loguru import logger @@ -92,22 +93,24 @@ async def send_system_health_metrics(self, health_cache) -> None: "pendingTransactions": health_cache.pending_transactions, "decisions": health_cache.total_decisions, "users": health_cache.total_users, - "memoryUsage": health_cache.services.get("memory", {}).get( - "percent", 0 + "memoryUsage": self._bounded_percentage( + health_cache.services.get("memory", {}).get("percent", 0) ), - "cpuUsage": health_cache.services.get("memory", {}).get( - "cpu_percent", 0 + "cpuUsage": self._bounded_percentage( + health_cache.services.get("memory", {}).get("cpu_percent", 0) ), } if mapped_status != "healthy": system_health["instanceHealthReasons"] = health_cache.issues + instance_health_events = [] + max_recovery_events = health_cache.services.get("consensus", {}).get( "max_recovery_exhausted_transactions", [] ) if max_recovery_events: - system_health["instanceHealthEvents"] = [ + instance_health_events.extend( { "type": "max_recovery_cycles_exhausted", "transactionHash": event.get("hash"), @@ -116,7 +119,25 @@ async def send_system_health_metrics(self, health_cache) -> None: "occurredAt": event.get("exhausted_at"), } for event in max_recovery_events - ] + ) + + stuck_head_events = health_cache.services.get("consensus", {}).get( + "stuck_head_transactions", [] + ) + if stuck_head_events: + instance_health_events.extend( + { + "type": "orphaned_transactions", + "transactionHash": event.get("hash"), + "contractAddress": event.get("contract_address"), + "status": event.get("status"), + "occurredAt": event.get("created_at"), + } + for event in stuck_head_events + ) + + if instance_health_events: + system_health["instanceHealthEvents"] = instance_health_events # Add pending contracts breakdown if available pending_contracts = getattr(health_cache, "pending_contracts", []) @@ -140,6 +161,19 @@ def _map_health_status(self, status: str) -> str: } return status_map.get(status, "down") + @staticmethod + def _bounded_percentage(value: Any) -> float: + """Return an API-safe percentage value in the inclusive 0-100 range.""" + try: + percentage = float(value) + except (TypeError, ValueError): + return 0.0 + + if not math.isfinite(percentage): + return 0.0 + + return min(max(percentage, 0.0), 100.0) + def _build_decision_payload( self, transaction: Transaction, finalization_data: dict ) -> dict: @@ -263,7 +297,9 @@ def _extract_execution_result( if consensus_data is not None and consensus_data.leader_receipt: first_receipt = consensus_data.leader_receipt[0] if first_receipt is not None: - execution_result = getattr(first_receipt, "execution_result", None) + execution_result = self._receipt_field( + first_receipt, "execution_result" + ) if execution_result is not None: # Handle both enum and string values if hasattr(execution_result, "value"): @@ -292,47 +328,141 @@ def _extract_llm_calls(self, consensus_data: Optional[ConsensusData]) -> list: # Process leader receipts if consensus_data.leader_receipt: for receipt in consensus_data.leader_receipt: - llm_call = self._extract_llm_call_from_receipt(receipt) - if llm_call: - llm_calls.append(llm_call) + llm_calls.extend(self._extract_llm_calls_from_receipt(receipt)) # Process validator receipts if consensus_data.validators: for receipt in consensus_data.validators: - llm_call = self._extract_llm_call_from_receipt(receipt) - if llm_call: - llm_calls.append(llm_call) + llm_calls.extend(self._extract_llm_calls_from_receipt(receipt)) return llm_calls - def _extract_llm_call_from_receipt(self, receipt) -> Optional[dict]: - """Extract LLM info from a single receipt.""" + def _extract_llm_calls_from_receipt(self, receipt) -> list[dict]: + """Extract one or more LLM call summaries from a single receipt.""" if receipt is None: - return None + return [] - node_config = getattr(receipt, "node_config", None) + node_config = self._receipt_field(receipt, "node_config") if node_config is None or not isinstance(node_config, dict): - return None + return [] primary_model = node_config.get("primary_model", {}) if not primary_model: + return [] + + token_metrics = self._extract_llm_token_metrics(receipt) + if token_metrics: + configured_by_key = self._configured_models_by_token_key(node_config) + calls = [] + for token_key, tokens in token_metrics.items(): + configured_model = configured_by_key.get(token_key) + if configured_model is None: + configured_model = self._configured_model_from_token_key( + primary_model, token_key + ) + call = self._build_llm_call(configured_model, tokens) + if call: + calls.append(call) + + if calls: + return calls + + call = self._build_llm_call(primary_model) + return [call] if call else [] + + def _extract_llm_call_from_receipt(self, receipt) -> Optional[dict]: + """Extract the first LLM call from a receipt for legacy callers.""" + calls = self._extract_llm_calls_from_receipt(receipt) + return calls[0] if calls else None + + def _build_llm_call( + self, model_config: dict | None, tokens: dict | None = None + ) -> Optional[dict]: + if not model_config: return None - provider = primary_model.get("provider", "unknown") - model = primary_model.get("model", "unknown") + provider = model_config.get("provider", "unknown") + model = model_config.get("model", "unknown") # Skip if both are unknown (no meaningful data) if provider == "unknown" and model == "unknown": return None + tokens = tokens if isinstance(tokens, dict) else {} + return { "provider": provider, "model": model, - "inputTokens": 0, # Not tracked yet - "outputTokens": 0, # Not tracked yet + "inputTokens": self._safe_int(tokens.get("input")), + "outputTokens": self._safe_int(tokens.get("output")), "costUsd": 0, # Not tracked yet } + def _configured_models_by_token_key(self, node_config: dict) -> dict[str, dict]: + configured = {} + + primary_model = node_config.get("primary_model") + primary_key = self._token_metric_key( + node_config.get("address"), + primary_model.get("model") if isinstance(primary_model, dict) else None, + ) + if primary_key and isinstance(primary_model, dict): + configured[primary_key] = primary_model + + secondary_model = node_config.get("secondary_model") + secondary_key = self._token_metric_key( + ( + secondary_model.get("address") + if isinstance(secondary_model, dict) + else None + ), + secondary_model.get("model") if isinstance(secondary_model, dict) else None, + ) + if secondary_key and isinstance(secondary_model, dict): + configured[secondary_key] = secondary_model + + return configured + + def _configured_model_from_token_key( + self, primary_model: dict, token_key: str + ) -> dict: + _, _, token_model = token_key.partition("/") + if not token_model: + token_model = primary_model.get("model", "unknown") + + return { + "provider": primary_model.get("provider", "unknown"), + "model": token_model, + } + + def _extract_llm_token_metrics(self, receipt) -> dict: + execution_stats = self._receipt_field(receipt, "execution_stats") + if not isinstance(execution_stats, dict): + return {} + + llm_stats = execution_stats.get("llm") + if not isinstance(llm_stats, dict): + return {} + + token_metrics = llm_stats.get("tokens") + return token_metrics if isinstance(token_metrics, dict) else {} + + def _receipt_field(self, receipt, field: str) -> Any: + if isinstance(receipt, dict): + return receipt.get(field) + return getattr(receipt, field, None) + + def _token_metric_key(self, address: Any, model: Any) -> str | None: + if not address or not model: + return None + return f"node-{address}/{model}" + + def _safe_int(self, value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + def _format_created_at(self, created_at) -> str: """Format created_at to ISO8601 string.""" if created_at is None: diff --git a/backend/validators/__init__.py b/backend/validators/__init__.py index 525d47c56..ec6013b85 100644 --- a/backend/validators/__init__.py +++ b/backend/validators/__init__.py @@ -1,4 +1,4 @@ -__all__ = ("Manager", "with_lock", "select_random_different_validator") +__all__ = ("Manager", "select_random_different_validator") import asyncio import typing @@ -252,6 +252,14 @@ async def snapshot(self): "Validators manager snapshot not initialized. " "Ensure restart() was called successfully." ) + # Wipe+recreate can leave the cache empty after the last delete's + # LLM restart while new validators are already committed. Create + # events sit behind those restarts; re-read before freezing a + # 0-node copy for exec. + if not self._cached_snapshot.nodes: + fresh = await self._get_snap_from_registry() + if fresh.nodes: + await self._change_providers_from_snapshot_locked(fresh) snap = deepcopy(self._cached_snapshot) yield snap diff --git a/decisions/002-share-genvm-image-layer.md b/decisions/002-share-genvm-image-layer.md new file mode 100644 index 000000000..19510f903 --- /dev/null +++ b/decisions/002-share-genvm-image-layer.md @@ -0,0 +1,52 @@ +# Share the GenVM image layer between backend services + +- Status: accepted +- Date: 2026-07-31 + +## Context + +Studio runs GenVM from both the JSON-RPC service and the consensus worker. The +services previously acquired and finalized the same GenVM tree in independent +Dockerfiles. Their application images shared the source-build blob but contained +different copies of the roughly 1.061 GB finalized runtime layer. A cold E2E +runner therefore transferred about 3.64 GB of unique image data before it could +start Studio. + +Studio supports three GenVM acquisition modes and must retain all of them: + +- `prebuilt` consumes the GenVM tree produced by the cross-repository E2E build. +- `source` builds the exact `GENVM_REF` with Nix. +- `release` downloads the exact `GENVM_TAG`, or the repository's default pin. + +## Decision + +JSON-RPC and the consensus worker are targets in `docker/Dockerfile.backend`. +They inherit one `genvm-runtime` stage and one `service-base` stage. GenVM is +acquired, finalized, and ownership-normalized once before either service target +diverges. Both targets use explicit UID/GID 999, preserving the existing cache +volume ownership while keeping the shared layer byte-identical. Each small +service target renames that account back to its existing public process identity +(`backend-user` or `worker-user`). + +Compose builds both targets in one BuildKit graph. The E2E publisher stores the +resulting images in the same ECR repository, where their common layer blobs are +content-addressed and stored once. Pulling both images onto one runner likewise +downloads each common blob once. + +A separate GenVM base image was rejected. It would require another published +image, tag-retention policy, release dependency, and local Compose bootstrap +step without improving E2E reuse over a common parent stage. + +## Consequences + +- A GenVM source SHA or release tag invalidates one shared acquisition stage. +- Backend source and Python dependency changes do not invalidate that GenVM + stage. +- Worker-only manager thread tuning remains a small child layer. +- No standalone ECR tag is added. Existing `cache-*` and `layercache-*` image + lifecycle rules continue to own all referenced blobs. +- Based on the measured images at the time of this decision, expected cold + unique transfer falls from about 3.64 GB to about 2.58 GB. +- Docker workflows must select `prod` or `consensus-worker` explicitly from the + shared Dockerfile. +- The two service images remain independently deployable and versioned. diff --git a/decisions/README.md b/decisions/README.md index 79e8b79fc..453059131 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -5,6 +5,7 @@ Greatly inspired by ADRs, this directory contains important decisions about the # Index - [Handle database migrations with SQLAlchemy + Alembic](001-handle-database-migrations.md) +- [Share the GenVM image layer between backend services](002-share-genvm-image-layer.md) # Useful documentation diff --git a/docker-compose.yml b/docker-compose.yml index 3e84651eb..edc831a2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,6 +111,13 @@ services: context: ./ dockerfile: ./docker/Dockerfile.backend target: prod + secrets: + - nix_netrc + args: + GENVM_TAG: ${GENVM_TAG:-} + GENVM_REF: ${GENVM_REF:-} + GENVM_SOURCE_MODE: ${GENVM_SOURCE_MODE:-} + GENVM_EXECUTOR_VERSION_NAME: ${GENVM_EXECUTOR_VERSION_NAME:-} environment: - PYTHONUNBUFFERED=1 - WEBDRIVERHOST=${WEBDRIVERHOST} @@ -125,18 +132,27 @@ services: - RATE_LIMIT_ANON_PER_MINUTE=${RATE_LIMIT_ANON_PER_MINUTE:-30} - RATE_LIMIT_ANON_PER_HOUR=${RATE_LIMIT_ANON_PER_HOUR:-500} - RATE_LIMIT_ANON_PER_DAY=${RATE_LIMIT_ANON_PER_DAY:-5000} + - RATE_LIMIT_READ_MULTIPLIER=${RATE_LIMIT_READ_MULTIPLIER:-10} + - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1000000000000000} + - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-1} + - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-1} + - GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS=${GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS:-210000} + - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} + - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} # Per-contract / per-sender PENDING tx caps (admission control on # eth_sendRawTransaction). Empty/unset = no cap. Set in shared # deployments to keep one heavy user from filling the queue. - MAX_PENDING_PER_CONTRACT_DEFAULT=${MAX_PENDING_PER_CONTRACT_DEFAULT:-} - MAX_PENDING_PER_SENDER_DEFAULT=${MAX_PENDING_PER_SENDER_DEFAULT:-} + - MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY=${MAX_CONTRACT_SNAPSHOT_BYTES_PER_DAY:-} ports: - - "${RPCPORT}:${RPCPORT}" + - "${RPCHOSTPORT:-4000}:${RPCPORT:-4000}" expose: - "${RPCPORT}" volumes: - ./.env:/app/.env - ./backend:/app/backend + - ${GENVM_CACHE_DIR:-genvm_cache}:/genvm-cache # - hardhat_artifacts:/app/hardhat/artifacts # - hardhat_deployments:/app/hardhat/deployments depends_on: @@ -246,7 +262,7 @@ services: image: postgres:16-alpine command: sh -c "if [ \"$REMOTE_DATABASE\" = \"true\" ]; then echo 'Postgres disabled in hosted environment' && exec tail -f /dev/null; else exec docker-entrypoint.sh postgres; fi" ports: - - "${DBPORT}:5432" + - "${DBHOSTPORT:-5432}:5432" environment: - POSTGRES_USER=${DBUSER} - POSTGRES_PASSWORD=${DBPASSWORD} @@ -287,8 +303,15 @@ services: consensus-worker: build: context: ./ - dockerfile: ./docker/Dockerfile.consensus-worker - target: ${CONSENSUS_BUILD_TARGET:-base} + dockerfile: ./docker/Dockerfile.backend + target: ${CONSENSUS_BUILD_TARGET:-consensus-worker} + secrets: + - nix_netrc + args: + GENVM_TAG: ${GENVM_TAG:-} + GENVM_REF: ${GENVM_REF:-} + GENVM_SOURCE_MODE: ${GENVM_SOURCE_MODE:-} + GENVM_EXECUTOR_VERSION_NAME: ${GENVM_EXECUTOR_VERSION_NAME:-} environment: - DBUSER=${DBUSER} - DBPASSWORD=${DBPASSWORD} @@ -301,9 +324,16 @@ services: - WEBDRIVERHOST=${WEBDRIVERHOST} - WEBDRIVERPORT=${WEBDRIVERPORT} - REDIS_URL=${REDIS_URL:-redis://redis:6379/0} + - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1000000000000000} + - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-1} + - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-1} + - GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS=${GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS:-210000} + - GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS=${GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS:-100000} + - GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES=${GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES:-1024} volumes: - ./.env:/app/.env - ./backend:/app/backend + - ${GENVM_CACHE_DIR:-genvm_cache}:/genvm-cache depends_on: database-migration: condition: service_completed_successfully @@ -375,7 +405,7 @@ services: redis: image: redis:8-alpine ports: - - "6379:6379" + - "${REDISPORT:-6379}:6379" volumes: - redis_data:/data healthcheck: @@ -422,5 +452,14 @@ volumes: # hardhat_deployments: ignition_deployments: # hardhat_snapshots: + genvm_cache: postgres_data: redis_data: + +# Credential for the private nix cache, consumed only by the GenVM source build +# in Dockerfile.backend. Defaults to /dev/null — an empty netrc — so a checkout +# without a token still builds, just without cache hits. CI points it at the +# netrc `nix-setup` wrote. +secrets: + nix_netrc: + file: ${NIX_NETRC_FILE:-/dev/null} diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 83bfe0c45..54f89da5d 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -1,27 +1,135 @@ -FROM ubuntu:24.04 AS base +FROM ubuntu:24.04 AS genvm-runner-pin + +SHELL ["/bin/bash", "-c"] + +# Collapse all contract/test inputs to the unique py-genlayer pin list. The +# acquisition stage copies only this normalized output, so unrelated source +# changes do not invalidate the large GenVM layer. +RUN set -euo pipefail ; \ + mkdir -p /studio/backend /studio/examples /studio/tests /genvm-pin ; \ + touch -d '@0' /studio/backend /studio/examples /studio/tests /studio /genvm-pin +RUN --mount=type=bind,source=backend,target=/studio/backend,ro \ + --mount=type=bind,source=examples,target=/studio/examples,ro \ + --mount=type=bind,source=tests,target=/studio/tests,ro \ + set -euo pipefail ; \ + { grep -rhoE 'py-genlayer:[a-z0-9]+' \ + /studio/backend /studio/examples /studio/tests 2>/dev/null \ + | sed 's/^py-genlayer://' | sort -u || true ; \ + } > /genvm-pin/py-genlayer-pins ; \ + touch -d '@0' /genvm-pin /genvm-pin/py-genlayer-pins + +FROM nixos/nix:2.30.2 AS genvm-source-build -ARG TARGETPLATFORM ARG TARGETARCH +ARG GENVM_SOURCE_MODE +ARG GENVM_TAG +ARG GENVM_REF +ARG GENVM_EXECUTOR_VERSION_NAME +# Nix `cores` for the GenVM build (0 = all). Cap on memory-constrained or +# emulated builders where a full-parallel Rust build can OOM. +ARG GENVM_BUILD_CORES=0 + +# nixos/nix ships bash only inside the default nix profile (no /bin/bash). +SHELL ["/nix/var/nix/profiles/default/bin/bash", "-x", "-c"] + +# CI or scripts/prepare-genvm-source-build.sh exports genvm-manager's +# `runners-all` closure here from a sandboxed Nix. +COPY .genvm-nix-closure/ /genvm-nix-closure/ +COPY docker/genvm-source-build.nix.conf /genvm-source-build.nix.conf + +# Source builds bind Studio to a genvm-manager branch/SHA. The flake target +# follows TARGETARCH (amd64-linux / arm64-linux); runners are included by +# genvm-manager's combined genvm package. +# +# The cache this build substitutes from is private, so the fetch needs a +# credential. `/etc/nix/netrc` is nix's default netrc-file, so mounting there +# means nothing has to point at it. The mount is torn down when the RUN ends and +# never reaches a layer — which is why it is a secret and not an ARG or a COPY. +# `required=false`: a build without the secret gets no credential, fetches +# nothing from the cache, and builds from source instead of failing. +RUN --mount=type=secret,id=nix_netrc,target=/etc/nix/netrc,required=false \ + set -euo pipefail ; \ + if [[ -n "$GENVM_TAG" && -n "$GENVM_REF" ]]; then \ + echo "ERROR: GENVM_TAG and GENVM_REF are mutually exclusive" ; exit 1 ; \ + fi ; \ + source_selected=0 ; \ + if [[ "$GENVM_SOURCE_MODE" == "source" || ( -z "$GENVM_SOURCE_MODE" && "$GENVM_REF" =~ ^.+:[0-9a-fA-F]{7,40}$ ) ]]; then \ + source_selected=1 ; \ + fi ; \ + if [[ "$source_selected" == "1" ]]; then \ + if [[ -z "$GENVM_REF" ]]; then \ + echo "ERROR: GENVM_SOURCE_MODE=source requires GENVM_REF" ; exit 1 ; \ + fi ; \ + SOURCE_REF="$GENVM_REF" ; \ + if [[ "$SOURCE_REF" =~ ^.+:([0-9a-fA-F]{7,40})$ ]]; then \ + SOURCE_REF="${BASH_REMATCH[1]}" ; \ + fi ; \ + # The runner tree is built from fixed-output derivations that compile C + # to wasm; without a sandbox they pick up host state and miss their + # pinned hashes. Import the closure built under a real sandbox so they + # are not rebuilt here. + CLOSURE_FILE="/genvm-nix-closure/runners-all-$SOURCE_REF.nar.gz" ; \ + if [[ ! -f "$CLOSURE_FILE" ]]; then \ + echo "ERROR: GenVM source builds require the sandboxed runners closure; run ./scripts/prepare-genvm-source-build.sh before building" ; exit 1 ; \ + fi ; \ + gzip -dc "$CLOSURE_FILE" | nix-store --import > /dev/null ; \ + cat /genvm-source-build.nix.conf >> /etc/nix/nix.conf ; \ + printf 'cores = %s\n' "$GENVM_BUILD_CORES" >> /etc/nix/nix.conf ; \ + git clone --recurse-submodules https://github.com/genlayerlabs/genvm-manager /src/genvm ; \ + cd /src/genvm ; \ + git checkout "$SOURCE_REF" ; \ + git submodule update --init --recursive ; \ + if [[ "$TARGETARCH" == "amd64" ]] || [[ -z "$TARGETARCH" ]]; then \ + GT="amd64-linux" ; \ + elif [[ "$TARGETARCH" == "arm64" ]]; then \ + GT="arm64-linux" ; \ + else \ + echo "ERROR: Unsupported TARGETARCH $TARGETARCH" ; exit 1 ; \ + fi ; \ + nix build -o /out-genvm ".?submodules=1#genvm-$GT" ; \ + mkdir -p /genvm-out ; \ + cp -rL --no-preserve=ownership /out-genvm/. /genvm-out/ ; \ + # Preserve executable modes while allowing post-install finalization. + chmod -R u+w /genvm-out ; \ + # Keep source and Nix build artifacts out of the exported runtime layer. + rm -f /out-genvm ; \ + rm -rf /src/genvm ; \ + nix-collect-garbage -d || true ; \ + else \ + mkdir -p /genvm-out ; \ + fi + +# jsonrpc and consensus-worker intentionally share this exact parent. In E2E, +# Compose builds both targets in one BuildKit graph, so GenVM is acquired once. +# Their ECR manifests then reference the same large content-addressed layers. +FROM ubuntu:24.04 AS genvm-runtime -ARG GENVM_TAG=v0.2.16 +ARG TARGETARCH +ARG GENVM_TAG +ARG GENVM_REF +ARG GENVM_SOURCE_MODE +ARG GENVM_EXECUTOR_VERSION_NAME ENV GENVM_TAG=$GENVM_TAG +ENV GENVM_REF=$GENVM_REF +ENV GENVM_EXECUTOR_VERSION_NAME=$GENVM_EXECUTOR_VERSION_NAME ARG path=/app +ARG STUDIO_UID=999 +ARG STUDIO_GID=999 WORKDIR $path SHELL ["/bin/bash", "-x", "-c"] -# Retry apt-get update — Ubuntu mirrors occasionally serve out-of-sync -# Packages.gz files mid-rotation, which causes "File has unexpected size" -# errors that persist for a few minutes. Retrying smooths over these. +# Ubuntu mirrors occasionally rotate indexes mid-request. Retry the index fetch +# before treating a transient mirror mismatch as a failed image build. RUN for i in 1 2 3 4 5; do \ apt-get update -y && break; \ echo "apt-get update failed (attempt $i/5), retrying in 15s..."; \ sleep 15; \ done \ && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y --no-install-recommends \ - curl unzip xz-utils ca-certificates python3.12 python3.12-venv python3-dev gcc libssl3 \ - musl musl-dev \ + curl unzip xz-utils ca-certificates python3.12 python3.12-venv python3.12-dev \ + gcc libssl3 musl musl-dev \ && mkdir -p "$HOME/.config/pip/" \ && printf "[global]\nbreak-system-packages = true\n" >> "$HOME/.config/pip/pip.conf" \ && update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 2 \ @@ -30,77 +138,175 @@ RUN for i in 1 2 3 4 5; do \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -ADD backend/requirements.txt backend/requirements.txt -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install --cache-dir=/root/.cache/pip -r backend/requirements.txt - -RUN groupadd -r backend-group \ - && useradd -r -g backend-group backend-user \ - && mkdir -p /home/backend-user/.cache/huggingface \ - && chown -R backend-user:backend-group /home/backend-user \ - && chown -R backend-user:backend-group $path \ - && mkdir -p /genvm - -ENV PYTHONPATH "${PYTHONPATH}:/${path}" -ENV HUGGINGFACE_HUB_CACHE /home/backend-user/.cache/huggingface +# Both old images used the first system UID/GID allocated on the same Ubuntu +# base (999). Make that contract explicit so existing cache-volume ownership is +# preserved while both final targets can consume the same GenVM layer. +RUN groupadd --system --gid "$STUDIO_GID" studio-group \ + && useradd --system --uid "$STUDIO_UID" --gid studio-group \ + --home-dir /home/studio-user --create-home --shell /bin/bash studio-user \ + && mkdir -p /home/studio-user/.cache/huggingface /genvm /genvm-cache \ + && chown -R studio-user:studio-group /home/studio-user /genvm /genvm-cache +ENV PYTHONPATH=$path +ENV HUGGINGFACE_HUB_CACHE=/home/studio-user/.cache/huggingface ENV RUST_BACKTRACE=1 -ADD \ - https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-linux-amd64.tar.xz \ - /genvm/genvm-linux-amd64.tar.xz - -ADD \ - https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-linux-arm64.tar.xz \ - /genvm/genvm-linux-arm64.tar.xz - -ADD \ - https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-universal.tar.xz \ - /genvm/genvm-universal.tar.xz - -# Extract and prepare GenVM binaries -RUN cd /genvm \ - && if [[ "$TARGETPLATFORM" == "linux/amd64" ]] ; \ - then \ - tar -xf genvm-linux-amd64.tar.xz ; \ - elif [[ "$TARGETPLATFORM" == "linux/arm64" ]] ; \ - then \ - tar -xf genvm-linux-arm64.tar.xz ; \ +# Acquire GenVM from an E2E prebuilt tree, a source build, or (by default) a +# genvm-manager release. This is the only acquisition/finalization path used by +# either backend service. +COPY --from=genvm-source-build /genvm-out/ /genvm/ +COPY .e2e-genvm-prebuilt/ /genvm-prebuilt/ +COPY third_party/genvm/version /genvm-default-version +COPY docker/scripts/download_genvm.sh /usr/local/bin/download-genvm +COPY --from=genvm-runner-pin /genvm-pin/py-genlayer-pins /genvm-runner-pins +RUN set -euo pipefail ; \ + if [[ -n "$GENVM_TAG" && -n "$GENVM_REF" ]]; then \ + echo "ERROR: GENVM_TAG and GENVM_REF are mutually exclusive" ; exit 1 ; \ + fi ; \ + case "$GENVM_SOURCE_MODE" in \ + ""|prebuilt|release|source) ;; \ + *) echo "ERROR: GENVM_SOURCE_MODE must be prebuilt, release, source, or empty (auto)" ; exit 1 ;; \ + esac ; \ + mode="$GENVM_SOURCE_MODE" ; \ + if [[ -z "$mode" ]]; then \ + if [[ -x /genvm-prebuilt/bin/genvm-modules ]]; then \ + mode=prebuilt ; \ + elif [[ "$GENVM_REF" =~ ^.+:[0-9a-fA-F]{7,40}$ ]]; then \ + mode=source ; \ + else \ + mode=release ; \ + fi ; \ + fi ; \ + if [[ "$mode" == "prebuilt" ]]; then \ + if [[ ! -x /genvm-prebuilt/bin/genvm-modules ]]; then \ + echo "ERROR: GENVM_SOURCE_MODE=prebuilt requires an executable /genvm-prebuilt/bin/genvm-modules; rebuild the E2E GenVM tree and include it in .e2e-genvm-prebuilt/" ; exit 1 ; \ + fi ; \ + echo "Using prebuilt GenVM runtime from build context" ; \ + find /genvm -mindepth 1 -maxdepth 1 -exec rm -rf {} + ; \ + cp -a /genvm-prebuilt/. /genvm/ ; \ + rm -f /genvm/.gitkeep ; \ + elif [[ "$mode" == "source" ]]; then \ + if [[ -z "$GENVM_REF" ]]; then \ + echo "ERROR: source mode requires GENVM_REF (a git ref/SHA or :)" ; exit 1 ; \ + fi ; \ + if [[ ! -x /genvm/bin/genvm-modules ]]; then \ + echo "ERROR: the GenVM source stage did not produce /genvm/bin/genvm-modules" ; exit 1 ; \ + fi ; \ + printf '%s\n' "$GENVM_REF" > /genvm/version ; \ + echo "Using GenVM built from source at $GENVM_REF" ; \ + else \ + if [[ -n "$GENVM_REF" ]]; then \ + echo "ERROR: release mode does not accept GENVM_REF; use GENVM_SOURCE_MODE=source" ; exit 1 ; \ + fi ; \ + release_version="${GENVM_TAG:-$(< /genvm-default-version)}" ; \ + release_arch="${TARGETARCH:-amd64}" ; \ + /usr/local/bin/download-genvm linux "$release_arch" "$release_version" \ + --out-dir /genvm --download-dir /tmp/genvm-download \ + --runner-pins-file /genvm-runner-pins --precompile false ; \ + fi ; \ + chown -R studio-user:studio-group /genvm ; \ + chmod -R u+w /genvm ; \ + if [[ "$mode" != "release" ]]; then \ + if [[ -x /genvm/bin/genvm-post-install ]]; then \ + post_install=/genvm/bin/genvm-post-install ; \ + elif [[ -f /genvm/bin/post-install.py ]]; then \ + post_install=/genvm/bin/post-install.py ; \ else \ - echo "Sorry, $TARGETPLATFORM is not supported yet" ; exit 1 ; \ - fi \ - && tar -xf genvm-universal.tar.xz \ - && rm *.tar.xz \ - && ls -R . \ - && chown -R backend-user:backend-group /genvm \ - && su - backend-user -c "/genvm/bin/post-install.py --precompile false" \ - && find /genvm -name genvm.yaml -exec sed -i 's|cache_dir:.*|cache_dir: /genvm-cache/|' {} + \ - && mkdir -p /genvm-cache && chown backend-user:backend-group /genvm-cache \ - && cd "$path" \ - && true - -# Set GenVM environment variables (GENVM_TAG is already set as ENV earlier) + echo "ERROR: GenVM has neither bin/genvm-post-install nor bin/post-install.py" ; exit 1 ; \ + fi ; \ + post_install_args="--precompile false --default-download false" ; \ + if [[ "$mode" == "source" ]]; then \ + post_install_args="$post_install_args --bin-patch false" ; \ + fi ; \ + su - studio-user -c "$post_install $post_install_args" ; \ + fi ; \ + find /genvm -name genvm.yaml -exec sed -i 's|cache_dir:.*|cache_dir: /genvm-cache/|' {} + ; \ + chown studio-user:studio-group /genvm-cache + ENV GENVMROOT=/genvm ENV PATH="/genvm/bin:${PATH}" +FROM genvm-runtime AS service-base + +ARG path=/app +WORKDIR $path + +ADD backend/requirements.txt backend/requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --cache-dir=/root/.cache/pip -r backend/requirements.txt + COPY backend $path/backend +RUN chown -R studio-user:studio-group $path + +FROM service-base AS consensus-worker + +# The worker is the only service that needs the high-throughput manager tuning. +RUN set -euo pipefail ; \ + config_file=/genvm/config/genvm-manager.yaml ; \ + if [[ -f "$config_file" ]]; then \ + sed -i 's/^threads:.*/threads: 8/' "$config_file" ; \ + sed -i 's/^blocking_threads:.*/blocking_threads: 48/' "$config_file" ; \ + cat "$config_file" ; \ + else \ + echo "WARNING: $config_file not found; skipping GenVM manager thread configuration." ; \ + fi ; \ + groupmod --new-name worker-group studio-group ; \ + usermod --login worker-user --home /home/worker-user --move-home studio-user + +COPY docker/entrypoint-consensus-worker.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh \ + && chown 999:999 /entrypoint.sh + +ENV HUGGINGFACE_HUB_CACHE=/home/worker-user/.cache/huggingface + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \ + CMD curl -f http://localhost:${WORKER_PORT:-4001}/health || exit 1 + +USER worker-user +WORKDIR /app +ENTRYPOINT ["/entrypoint.sh"] + +# Backwards-compatible alias for explicit CONSENSUS_BUILD_TARGET=base overrides. +FROM consensus-worker AS base + +FROM service-base AS debug + +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --cache-dir=/root/.cache/pip debugpy + +RUN groupmod --new-name backend-group studio-group \ + && usermod --login backend-user --home /home/backend-user --move-home studio-user + COPY asgi.py $path/asgi.py COPY uvicorn_config.py $path/uvicorn_config.py COPY docker/entrypoint-backend.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN chmod +x /entrypoint.sh \ + && chown 999:999 "$path/asgi.py" "$path/uvicorn_config.py" /entrypoint.sh -HEALTHCHECK --interval=1s --timeout=1s --retries=30 --start-period=120s CMD python3 backend/healthcheck.py --port ${RPCPORT:-4000} +ENV HUGGINGFACE_HUB_CACHE=/home/backend-user/.cache/huggingface -###########START NEW IMAGE : DEBUGGER ################### -FROM base AS debug -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install --cache-dir=/root/.cache/pip debugpy USER backend-user ENTRYPOINT ["/entrypoint.sh"] CMD ["python3", "-m", "debugpy", "--listen", "0.0.0.0:${RPCDEBUGPORT}", "-m", "uvicorn", "asgi:application", "--host", "0.0.0.0", "--port", "${RPCPORT:-4000}", "--reload", "--reload-dir", "/app/backend"] -###########START NEW IMAGE: PRODUCTION ################### -FROM base AS prod +# Keep the JSON-RPC production target last so a direct build without --target +# retains Dockerfile.backend's historical default image. +FROM service-base AS prod + +RUN groupmod --new-name backend-group studio-group \ + && usermod --login backend-user --home /home/backend-user --move-home studio-user + +COPY asgi.py $path/asgi.py +COPY uvicorn_config.py $path/uvicorn_config.py +COPY docker/entrypoint-backend.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh \ + && chown 999:999 "$path/asgi.py" "$path/uvicorn_config.py" /entrypoint.sh + +ENV HUGGINGFACE_HUB_CACHE=/home/backend-user/.cache/huggingface + +HEALTHCHECK --interval=1s --timeout=1s --retries=30 --start-period=120s \ + CMD python3 backend/healthcheck.py --port ${RPCPORT:-4000} + USER backend-user ENTRYPOINT ["/entrypoint.sh"] CMD ["python3", "-m", "backend.protocol_rpc.run_server"] diff --git a/docker/Dockerfile.consensus-worker b/docker/Dockerfile.consensus-worker deleted file mode 100644 index 337630548..000000000 --- a/docker/Dockerfile.consensus-worker +++ /dev/null @@ -1,124 +0,0 @@ -# Consensus worker Dockerfile - based on Ubuntu for compatibility -FROM ubuntu:24.04 AS base - -ARG TARGETPLATFORM -ARG TARGETARCH - -ARG GENVM_TAG=v0.2.16 -ENV GENVM_TAG=$GENVM_TAG - -ARG path=/app -WORKDIR $path - -SHELL ["/bin/bash", "-x", "-c"] -# Retry apt-get update — Ubuntu mirrors occasionally serve out-of-sync -# Packages.gz files mid-rotation, which causes "File has unexpected size" -# errors that persist for a few minutes. Retrying smooths over these. -RUN for i in 1 2 3 4 5; do \ - apt-get update -y && break; \ - echo "apt-get update failed (attempt $i/5), retrying in 15s..."; \ - sleep 15; \ - done \ - && DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get install -y --no-install-recommends \ - curl unzip xz-utils ca-certificates python3.12 python3.12-dev python3.12-venv libssl3 gcc musl \ - && mkdir -p "$HOME/.config/pip/" \ - && printf "[global]\nbreak-system-packages = true\n" >> "$HOME/.config/pip/pip.conf" \ - && update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 2 \ - && curl -sS https://bootstrap.pypa.io/get-pip.py | python3.12 \ - && python3.12 -m pip --version \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Install Python dependencies -ADD backend/requirements.txt backend/requirements.txt -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install --cache-dir=/root/.cache/pip -r backend/requirements.txt \ - && pip install --cache-dir=/root/.cache/pip uvicorn[standard] fastapi - -# Create user and directories -RUN groupadd -r worker-group \ - && useradd -r -g worker-group worker-user \ - && mkdir -p /home/worker-user/.cache/huggingface \ - && chown -R worker-user:worker-group /home/worker-user \ - && chown -R worker-user:worker-group $path \ - && mkdir -p /genvm - -ENV PYTHONPATH "${PYTHONPATH}:/${path}" -ENV HUGGINGFACE_HUB_CACHE /home/worker-user/.cache/huggingface -ENV RUST_BACKTRACE=1 - - -# Download and extract GenVM binaries (sequential for Docker compatibility) -RUN cd /genvm \ - && echo "=== Starting GenVM $GENVM_TAG download ===" \ - && if [[ "$TARGETPLATFORM" == "linux/amd64" ]] || [[ -z "$TARGETPLATFORM" ]]; then \ - ARCH_FILE="genvm-linux-amd64.tar.xz" ; \ - elif [[ "$TARGETPLATFORM" == "linux/arm64" ]]; then \ - ARCH_FILE="genvm-linux-arm64.tar.xz" ; \ - else \ - echo "ERROR: Unsupported platform $TARGETPLATFORM" ; exit 1 ; \ - fi \ - && echo "Platform: $TARGETPLATFORM -> Architecture file: $ARCH_FILE" \ - && echo "Downloading $ARCH_FILE..." \ - && curl -L --fail --retry 3 --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - --progress-bar \ - -o "$ARCH_FILE" \ - "https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/$ARCH_FILE" \ - && echo "✓ Downloaded $ARCH_FILE" \ - && echo "Downloading genvm-universal.tar.xz..." \ - && curl -L --fail --retry 3 --retry-delay 2 \ - --connect-timeout 10 --max-time 300 \ - --progress-bar \ - -o "genvm-universal.tar.xz" \ - "https://github.com/genlayerlabs/genvm/releases/download/$GENVM_TAG/genvm-universal.tar.xz" \ - && echo "✓ Downloaded genvm-universal.tar.xz" \ - && echo "Verifying downloads..." \ - && ls -lah *.tar.xz \ - && for f in *.tar.xz; do \ - if [ ! -s "$f" ]; then \ - echo "ERROR: $f is empty or missing" ; exit 1 ; \ - fi ; \ - done \ - && echo "Extracting archives..." \ - && tar -xf "$ARCH_FILE" \ - && tar -xf "genvm-universal.tar.xz" \ - && rm *.tar.xz \ - && echo "Configuring GenVM manager threads..." \ - && CONFIG_FILE="/genvm/config/genvm-manager.yaml" \ - && if [[ -f "$CONFIG_FILE" ]]; then \ - sed -i 's/^threads:.*/threads: 8/' "$CONFIG_FILE" \ - && sed -i 's/^blocking_threads:.*/blocking_threads: 48/' "$CONFIG_FILE" \ - && echo "Updated $CONFIG_FILE with threads=8 and blocking_threads=48" \ - && cat "$CONFIG_FILE" ; \ - else \ - echo "WARNING: $CONFIG_FILE not found; skipping GenVM manager thread configuration." ; \ - fi \ - && echo "GenVM installed successfully:" \ - && ls -la \ - && chown -R worker-user:worker-group /genvm \ - && su - worker-user -c "/genvm/bin/post-install.py --precompile false" \ - && find /genvm -name genvm.yaml -exec sed -i 's|cache_dir:.*|cache_dir: /genvm-cache/|' {} + \ - && mkdir -p /genvm-cache && chown worker-user:worker-group /genvm-cache - -# Set GenVM environment variables (GENVM_TAG is already set as ENV earlier) -ENV GENVMROOT=/genvm -ENV PATH="/genvm/bin:${PATH}" - -# Copy necessary files -COPY backend $path/backend -COPY docker/entrypoint-consensus-worker.sh /entrypoint.sh - -# Change ownership of app files -RUN chown -R worker-user:worker-group $path - -# Health check (start-period accounts for first-boot precompile ~60s) -HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=120s \ - CMD curl -f http://localhost:${WORKER_PORT:-4001}/health || exit 1 - -# Switch to non-root user -USER worker-user - -# Precompile GenVM for host CPU on startup, then start worker -WORKDIR /app -ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/entrypoint-backend.sh b/docker/entrypoint-backend.sh index eeefb54e8..7693f785e 100644 --- a/docker/entrypoint-backend.sh +++ b/docker/entrypoint-backend.sh @@ -1,13 +1,22 @@ #!/bin/bash -set -e +set -euo pipefail -CACHE_MARKER="/genvm-cache/pc/.precompiled" +GENVM_RESOLVED_PIN="" +if [[ -s /genvm/version ]]; then + GENVM_RESOLVED_PIN="$(head -n 1 /genvm/version)" +fi +GENVM_CACHE_VERSION="${GENVM_RESOLVED_PIN:-${GENVM_EXECUTOR_VERSION_NAME:-${GENVM_TAG:-unknown}}}" +CACHE_MARKER="/genvm-cache/pc/.precompiled-${GENVM_CACHE_VERSION}-$(uname -m)" if [ -f "$CACHE_MARKER" ]; then - echo "GenVM already precompiled for this host, skipping." + echo "GenVM ${GENVM_CACHE_VERSION} already precompiled for this host, skipping." else - echo "Precompiling GenVM for host CPU..." - /genvm/bin/post-install.py --default-steps false --precompile true + echo "Precompiling GenVM ${GENVM_CACHE_VERSION} for host CPU..." + if [[ -x /genvm/bin/genvm-post-install ]]; then + /genvm/bin/genvm-post-install --default-steps false --precompile true + else + /genvm/bin/post-install.py --default-steps false --precompile true + fi mkdir -p "$(dirname "$CACHE_MARKER")" touch "$CACHE_MARKER" echo "Precompilation complete." diff --git a/docker/entrypoint-consensus-worker.sh b/docker/entrypoint-consensus-worker.sh index fb261836f..a39254c79 100755 --- a/docker/entrypoint-consensus-worker.sh +++ b/docker/entrypoint-consensus-worker.sh @@ -1,13 +1,22 @@ #!/bin/bash -set -e +set -euo pipefail -CACHE_MARKER="/genvm-cache/pc/.precompiled" +GENVM_RESOLVED_PIN="" +if [[ -s /genvm/version ]]; then + GENVM_RESOLVED_PIN="$(head -n 1 /genvm/version)" +fi +GENVM_CACHE_VERSION="${GENVM_RESOLVED_PIN:-${GENVM_EXECUTOR_VERSION_NAME:-${GENVM_TAG:-unknown}}}" +CACHE_MARKER="/genvm-cache/pc/.precompiled-${GENVM_CACHE_VERSION}-$(uname -m)" if [ -f "$CACHE_MARKER" ]; then - echo "GenVM ${GENVM_TAG} already precompiled for this host, skipping." + echo "GenVM ${GENVM_CACHE_VERSION} already precompiled for this host, skipping." else - echo "Precompiling GenVM ${GENVM_TAG} for host CPU..." - /genvm/bin/post-install.py --default-steps false --precompile true + echo "Precompiling GenVM ${GENVM_CACHE_VERSION} for host CPU..." + if [[ -x /genvm/bin/genvm-post-install ]]; then + /genvm/bin/genvm-post-install --default-steps false --precompile true + else + /genvm/bin/post-install.py --default-steps false --precompile true + fi mkdir -p "$(dirname "$CACHE_MARKER")" touch "$CACHE_MARKER" echo "Precompilation complete." diff --git a/docker/genvm-source-build.nix.conf b/docker/genvm-source-build.nix.conf new file mode 100644 index 000000000..a8613dba6 --- /dev/null +++ b/docker/genvm-source-build.nix.conf @@ -0,0 +1,13 @@ +experimental-features = nix-command flakes + +# seccomp filter cannot load under qemu/Rosetta emulation; sandbox is off anyway +filter-syscalls = false + +extra-substituters = https://nix-cache.ygr.ai/genlayer +extra-trusted-public-keys = genlayer:hMvP8BkI2v8E3BtzMB9HQyIEZz/ahNdBZo/JEQ5hpVA= + +# substituter error recovery: retry, then build locally +download-attempts = 10 +connect-timeout = 15 +stalled-download-timeout = 60 +fallback = true diff --git a/docker/scripts/download_genvm.sh b/docker/scripts/download_genvm.sh new file mode 100755 index 000000000..17001140b --- /dev/null +++ b/docker/scripts/download_genvm.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Adapted from genlayer-node's taskfiles/genvm/scripts/download_genvm.sh. +# GenVM release assets have had both combined and split layouts, so this script +# deliberately detects the layout from the extracted tree instead of the tag. + +error_exit() { + echo >&2 + echo "ERROR: $1" >&2 + exit 1 +} + +cleanup() { + echo "Cleanup: removing downloaded GenVM assets..." + rm -rf "$DOWNLOAD_DIR" +} + +download_artifact() { + local url=$1 + local asset_name=${url##*/} + + echo "Downloading $asset_name from $url..." + if ! curl --retry 3 --retry-delay 2 --retry-max-time 60 \ + --fail --show-error --location --continue-at - \ + -H "Accept: application/octet-stream" \ + -o "$DOWNLOAD_DIR/$asset_name" "$url"; then + error_exit "Download of $asset_name failed. Check that the release and asset exist." + fi +} + +unpack_artifact() { + local file_path=$1 + local dest_dir=$2 + + echo "Unpacking $file_path to $dest_dir..." + if ! tar -xf "$file_path" -C "$(readlink -f "$dest_dir")"; then + error_exit "Unpacking $file_path failed." + fi +} + +find_post_install() { + if [[ -x "$INSTALL_DIR/bin/genvm-post-install" ]]; then + printf '%s\n' "$INSTALL_DIR/bin/genvm-post-install" + elif [[ -f "$INSTALL_DIR/bin/post-install.py" ]]; then + printf '%s\n' "$INSTALL_DIR/bin/post-install.py" + else + error_exit "Neither bin/genvm-post-install nor bin/post-install.py exists in the GenVM bundle." + fi +} + +usage() { + cat <<'EOF' +Usage: download_genvm.sh [options] + +Options: + --repo GitHub repository (default: genlayerlabs/genvm-manager) + --out-dir Extracted GenVM root (default: /genvm) + --download-dir Temporary download directory + --repo-root Studio checkout used to discover the pinned runner + --runner-pins-file + Precomputed unique Studio runner pins, one per line + --precompile Pass true/false to GenVM post-install (default: false) +EOF +} + +REPO="genlayerlabs/genvm-manager" +OUT_DIR="/genvm" +DOWNLOAD_DIR="/tmp/genvm-download" +REPO_ROOT="" +RUNNER_PINS_FILE="" +PRECOMPILE=false +POSITIONAL=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + REPO=${2:?"--repo requires a value"} + shift 2 + ;; + --out-dir) + OUT_DIR=${2:?"--out-dir requires a value"} + shift 2 + ;; + --download-dir) + DOWNLOAD_DIR=${2:?"--download-dir requires a value"} + shift 2 + ;; + --repo-root) + REPO_ROOT=${2:?"--repo-root requires a value"} + shift 2 + ;; + --runner-pins-file) + RUNNER_PINS_FILE=${2:?"--runner-pins-file requires a value"} + shift 2 + ;; + --precompile) + PRECOMPILE=${2:?"--precompile requires true or false"} + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + while [[ $# -gt 0 ]]; do + POSITIONAL+=("$1") + shift + done + ;; + -*) + error_exit "Unknown option: $1" + ;; + *) + POSITIONAL+=("$1") + shift + ;; + esac +done + +if [[ ${#POSITIONAL[@]} -ne 3 ]]; then + usage >&2 + error_exit "Expected ." +fi + +OS=${POSITIONAL[0]} +ARCH=${POSITIONAL[1]} +VERSION=${POSITIONAL[2]} +INSTALL_DIR=$OUT_DIR + +[[ "$OS" == "linux" || "$OS" == "macos" ]] \ + || error_exit "Invalid OS '$OS'; supported values are linux and macos." +[[ "$ARCH" == "amd64" || "$ARCH" == "arm64" ]] \ + || error_exit "Invalid architecture '$ARCH'; supported values are amd64 and arm64." +[[ "$PRECOMPILE" == "true" || "$PRECOMPILE" == "false" ]] \ + || error_exit "--precompile must be true or false." + +if [[ "$VERSION" == *-dev ]]; then + error_exit "Bare branch pin '$VERSION' is not supported. Pin an exact release tag (vX.Y.Z) or select source mode with ':'." +fi +if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + error_exit "Invalid release '$VERSION'. Expected vX.Y.Z, optionally followed by a prerelease suffix." +fi + +echo "Validated GenVM release: OS=$OS ARCH=$ARCH VERSION=$VERSION REPO=$REPO" +mkdir -p "$DOWNLOAD_DIR" +trap cleanup EXIT + +RELEASE_BASE="https://github.com/$REPO/releases/download/$VERSION" +ASSET_NAME="genvm-${ARCH}-${OS}.tar.xz" +UNIVERSAL_ASSET_NAME="genvm-universal.tar.xz" + +download_artifact "$RELEASE_BASE/$ASSET_NAME" +echo "Preparing install directory: $INSTALL_DIR" +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" +unpack_artifact "$DOWNLOAD_DIR/$ASSET_NAME" "$INSTALL_DIR" + +# v0.6.0-rc0 is combined. Later releases may omit runners from the platform +# archive and publish them in genvm-universal.tar.xz. Probe the tree, not the tag. +BUNDLE_DESC=$ASSET_NAME +if [[ ! -d "$INSTALL_DIR/runners" ]]; then + echo "No runners/ in $ASSET_NAME; fetching split-layout asset $UNIVERSAL_ASSET_NAME..." + download_artifact "$RELEASE_BASE/$UNIVERSAL_ASSET_NAME" + unpack_artifact "$DOWNLOAD_DIR/$UNIVERSAL_ASSET_NAME" "$INSTALL_DIR" + BUNDLE_DESC="$ASSET_NAME + $UNIVERSAL_ASSET_NAME" +fi + +# rc0 ships data/ read-only, while post-install must create files beneath it. +chmod -R u+w "$INSTALL_DIR" +printf '%s\n' "$VERSION" > "$INSTALL_DIR/version" + +EXECUTOR_BINARY=$(find "$INSTALL_DIR/executor" -name genvm -type f 2>/dev/null | head -n 1 || true) +if [[ -z "$EXECUTOR_BINARY" || ! -s "$EXECUTOR_BINARY" ]]; then + error_exit "No GenVM executor binary was found after extracting $ASSET_NAME." +fi + +# Studio embeds py-genlayer hashes in its intelligent contracts. Assert every +# pin is present so an asset/pin mismatch fails during acquisition. Contracts +# targeting an older executor line pin an older runner, which ships inside that +# executor's legacy-runners/ tree rather than the shared runners/ tree. +RUNNER_PINS="" +if [[ -n "$RUNNER_PINS_FILE" ]]; then + if [[ -f "$RUNNER_PINS_FILE" ]]; then + RUNNER_PINS=$(sed '/^$/d' "$RUNNER_PINS_FILE" | sort -u) + fi +elif [[ -n "$REPO_ROOT" ]]; then + RUNNER_PINS=$(grep -rhoE 'py-genlayer:[a-z0-9]+' \ + "$REPO_ROOT/backend" "$REPO_ROOT/examples" "$REPO_ROOT/tests" 2>/dev/null \ + | sed 's/^py-genlayer://' | sort -u || true) +fi + +runner_archive_for_pin() { + local pin=$1 candidate ext + # The shared tree moved from .tar to .zip in the v0.3.0-rc7 line; the + # legacy-runners tree of an older executor still ships .tar. + for ext in zip tar; do + candidate="$INSTALL_DIR/runners/py-genlayer/${pin:0:2}/${pin:2}.$ext" + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + for candidate in "$INSTALL_DIR"/executor/*/legacy-runners/py-genlayer/"${pin:0:2}"/"${pin:2}".tar; do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +if [[ -n "$RUNNER_PINS_FILE" || -n "$REPO_ROOT" ]]; then + RUNNER_PIN_COUNT=$(grep -c . <<<"$RUNNER_PINS" || true) + if [[ "$RUNNER_PIN_COUNT" -eq 0 ]]; then + # Studio always carries at least one pin, so zero means the context + # filtering or the grep itself broke -- exactly what this assertion + # exists to catch. Failing loudly beats silently skipping the check. + error_exit "No py-genlayer pin found in Studio sources; the runner assertion cannot run. This usually means the pin inputs were not present in the build context." + fi + if [[ ! -d "$INSTALL_DIR/runners" ]]; then + error_exit "No runners/ directory after extracting $BUNDLE_DESC at $VERSION; check the published release assets." + fi + while read -r RUNNER_PIN; do + [[ -n "$RUNNER_PIN" ]] || continue + if ! RUNNER_ARCHIVE=$(runner_archive_for_pin "$RUNNER_PIN"); then + error_exit "Pinned py-genlayer runner is missing: py-genlayer:$RUNNER_PIN (looked in $INSTALL_DIR/runners and $INSTALL_DIR/executor/*/legacy-runners)." + fi + echo "Runner pin OK: py-genlayer:$RUNNER_PIN -> ${RUNNER_ARCHIVE#"$INSTALL_DIR"/}" + done <<<"$RUNNER_PINS" +fi + +# The executable was renamed in newer bundles. Both variants accept the same +# post-install flags. The release already contains executor and runner assets, +# so downloading during finalization must remain disabled. +POST_INSTALL=$(find_post_install) +"$POST_INSTALL" \ + --precompile "$PRECOMPILE" \ + --default-download false \ + --error-on-missing-executor false + +# This marker is intentionally the final write: it records a completely +# acquired and finalized tree, never a partial extraction. +printf '%s\n' "${OS}-${ARCH}" > "$INSTALL_DIR/.complete" +echo "GenVM download, extraction, and finalization completed successfully." diff --git a/docker/scripts/precompile_genvm.sh b/docker/scripts/precompile_genvm.sh new file mode 100755 index 000000000..a8bd7936a --- /dev/null +++ b/docker/scripts/precompile_genvm.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! docker compose -f docker-compose.yml -f docker-compose.ci.yml \ + run --rm --no-deps --entrypoint /entrypoint.sh jsonrpc true; then + echo "::error::GenVM precompile failed" + exit 1 +fi diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md new file mode 100644 index 000000000..47c7bc32d --- /dev/null +++ b/docs/BRANCHING.md @@ -0,0 +1,58 @@ +# Branching and Release Trains + +This repo follows the GenLayer release-train model. + +## Current Train + +- Current stable branch: `v0.121` +- Active integration branch: `v0.123-dev` +- Next stable target: `v0.123` +- `main`: default/static branch alias for the active integration branch + +## Stable Branches + +Stable branches are long-lived release lines. For semver-zero packages, each +minor line is treated as the release line, for example `v0.121` or `v0.123`. + +PRs may target a stable branch directly when the merged result should be +releasable immediately. This is appropriate for bug fixes, small non-breaking +features, isolated release fixes, or a breaking change that is intentionally +shipping as the next version by itself. + +Stable branches must remain releasable. PRs into stable branches are expected to +pass the required cross-repo `E2E Tests` gate before merge. + +## Integration Branches + +Integration branches are optional. Use one when multiple changes need to +accumulate before release, especially for cross-repo work, dependent features, +breaking changes that must ship together, or a train that needs advisory E2E +while still expected to be red. + +Integration branches are named after the target stable branch plus `-dev`, for +example `v0.123-dev`. Feature PRs for that train target the integration branch. + +PRs into integration branches may run `E2E Tests` as advisory checks. They are +not the release gate. + +## Promotion and Release + +When an integration train is ready, open a promotion PR from the integration +branch to the matching stable branch, for example `v0.123-dev` to `v0.123`. + +That promotion PR is the release-readiness gate and must pass required +cross-repo `E2E Tests`. The actual release is cut from the stable branch using a +version tag after the stable branch is ready. + +## `main` + +`main` exists for GitHub UX and tools that require a stable default branch. It is +not a release branch and it is not the integration target. + +This repo keeps `main` forwarded to the active integration branch using +automation. PRs opened against `main` are automatically retargeted to the branch +listed in `support/ci/ACTIVE_DEV_BRANCH`. + +When changing the active integration branch, update +`support/ci/ACTIVE_DEV_BRANCH`, the repo docs, and the corresponding +`genlayer-e2e` release-train matrix in the same change set. diff --git a/docs/terminal-contract-snapshot-pruning.md b/docs/terminal-contract-snapshot-pruning.md new file mode 100644 index 000000000..2f216f4b4 --- /dev/null +++ b/docs/terminal-contract-snapshot-pruning.md @@ -0,0 +1,593 @@ +# Terminal Contract Snapshot Archiving + +Studio can archive terminal transaction `contract_snapshot` payloads to cheap +object storage before pruning them from the hot `transactions` table. Direct +transaction reads can hydrate archived snapshots back from the archive, while +list/history endpoints avoid object-storage fanout. + +The feature is disabled by default. + +## Backends + +Supported archive backends: + +- `file`: local filesystem backend for development and tests. +- `gcs`: Google Cloud Storage, intended while production Studio is still on GCP. +- `s3`: Amazon S3, intended after the AWS migration. + +All backends use the same deterministic gzip JSON object format: + +```text +/v1//.contract_snapshot.json.gz +``` + +An archive index row is written to `transaction_snapshot_archives` with the +backend, object URI, uncompressed/compressed byte counts, and SHA-256 checksums. +The fastest production drain should run as a three-phase pipeline: + +1. Archive snapshots and write archive index rows. +2. Verify archived objects by reading them back and setting `verified_at`. +3. Prune only hot snapshots whose archive row has been verified. + +The legacy/default `full` CLI phase still performs archive, read-back +verification, and pruning in one pass for small/manual runs. + +## Runtime Auto-Pruner + +GCS example for current GCP production: + +```bash +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ENABLED=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_ENABLED=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_VERIFY_ARCHIVE=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND=gcs +STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_BUCKET= +STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_PREFIX=studio/terminal-contract-snapshots +STUDIO_CONTRACT_SNAPSHOT_PRUNER_GCS_STORAGE_CLASS=NEARLINE +STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_RETENTION_HOURS=24 +STUDIO_CONTRACT_SNAPSHOT_PRUNER_BATCH_SIZE=5 +STUDIO_CONTRACT_SNAPSHOT_PRUNER_INTERVAL_SECONDS=300 +``` + +S3 example for AWS: + +```bash +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ENABLED=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_ENABLED=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_VERIFY_ARCHIVE=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND=s3 +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_BUCKET= +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_PREFIX=studio/terminal-contract-snapshots +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_STORAGE_CLASS=STANDARD_IA +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_SSE=aws:kms +STUDIO_CONTRACT_SNAPSHOT_PRUNER_S3_KMS_KEY_ID= +STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED=true +``` + +Local development example: + +```bash +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ENABLED=true +STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_BACKEND=file +STUDIO_CONTRACT_SNAPSHOT_PRUNER_FILE_DIR=data/terminal-contract-snapshot-archive +STUDIO_CONTRACT_SNAPSHOT_PRUNER_VERIFY_ARCHIVE=true +STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED=true +``` + +The default `full` phase performs all work in one batch: + +1. Locks a small set of `FINALIZED` or `CANCELED` rows with + `contract_snapshot IS NOT NULL`. +2. Writes each snapshot to the configured backend as deterministic gzip JSON. +3. Reads the archived object back and verifies the compressed checksum, gzip + payload, and raw JSON checksum. +4. Stores object location, checksums, and `verified_at` in + `transaction_snapshot_archives`. +5. Sets `transactions.contract_snapshot = NULL` only after the archive write, + read-back verification, and index row succeed. + +If the archive write or read-back verification fails, the row is not pruned. + +`STUDIO_CONTRACT_SNAPSHOT_PRUNER_ARCHIVE_ENABLED=false` is treated as a lossy +mode and is rejected unless +`STUDIO_CONTRACT_SNAPSHOT_PRUNER_ALLOW_LOSSY_PRUNE=true` is also set. + +## One-Time Historical Drain + +For large one-time cleanup, prefer the split pipeline. Keep the background +pruner disabled while these commands run. + +Archive phase, no deletion: + +```bash +python -m backend.database_handler.prune_terminal_snapshots \ + --phase archive \ + --batch-size 25 \ + --retention-hours 24 \ + --workers 4 \ + --max-batches 100 \ + --sleep-seconds 0 +``` + +Verify phase, no deletion: + +```bash +python -m backend.database_handler.prune_terminal_snapshots \ + --phase verify \ + --batch-size 25 \ + --workers 4 \ + --max-batches 100 \ + --sleep-seconds 0 +``` + +Prune phase, deletes only verified archive rows: + +```bash +python -m backend.database_handler.prune_terminal_snapshots \ + --phase prune \ + --batch-size 100 \ + --retention-hours 24 \ + --workers 4 \ + --max-batches 100 \ + --sleep-seconds 0 +``` + +Use the legacy all-in-one phase for tiny/manual checks: + +```bash +python -m backend.database_handler.prune_terminal_snapshots \ + --phase full \ + --batch-size 5 \ + --retention-hours 24 +``` + +Dry-run without object-storage credentials: + +```bash +STUDIO_CONTRACT_SNAPSHOT_PRUNER_DRY_RUN=true \ +python -m backend.database_handler.prune_terminal_snapshots --dry-run --max-batches 10 +``` + +Set `--max-batches` for controlled partial runs. Omit it or pass `0` to continue +until no eligible rows remain. + +Use `--workers` for bounded parallel one-time drains: + +```bash +python -m backend.database_handler.prune_terminal_snapshots \ + --phase archive \ + --batch-size 5 \ + --retention-hours 24 \ + --workers 4 \ + --max-batches 100 \ + --sleep-seconds 0 +``` + +Each worker uses its own database session and the shared queue queries use +`FOR UPDATE SKIP LOCKED`, so workers claim different rows/archive rows. The CLI +caps its database connection pool to the worker count. + +## Read-Through Retrieval + +Set `STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED=true` on RPC services to +hydrate archived snapshots on direct transaction reads. The read path verifies +the compressed object checksum, decompresses the payload, verifies the raw JSON +checksum, and returns the snapshot as if it had been read from Postgres. + +This read-through is intentionally limited to direct transaction reads. Broad +transaction listings do not retrieve archived snapshots. + +## Stable 121.x RPC State Scope + +The stable `v0.121` line should keep the transaction response shape stable while +removing accidental large state payloads from ETH-compatible reads. + +Current stable scope: + +- `gen_call` accepts an additive `status` parameter with public values + `decided` and `finalized`. +- `status=decided` maps to Studio's current internal accepted/decided state + bucket. `status=finalized` maps to finalized state. +- The legacy Studio `transaction_hash_variant` selector remains accepted for + compatibility. `latest-final` maps to finalized state; omitted or other + values keep the previous decided-state behavior. +- ETH-compatible responses do not include Studio `contract_snapshot` payloads: + `eth_getTransactionByHash`, `eth_getTransactionReceipt`, + `eth_getBlockByNumber`, and `eth_getBlockByHash`. +- Explicit Studio/debug direct reads can still request and hydrate archived + `contract_snapshot` data where that API is intended to expose full state. + +Do not add broad historical state semantics to `v0.121` as a compatibility +patch. A stable patch can remove accidental state payloads and add the correct +new selector, but it should not change execution semantics or require client +library shape changes. + +## Next-Version Historical State Scope + +The next version should make historical state behavior explicit instead of +relying on the current mutable `current_state` lookup. + +Target behavior: + +- A transaction records the activation block/state point used for execution. +- Cross-contract reads during execution resolve against that locked activation + block so validators read the same historical view. +- `gen_call` supports calling at a past block/state point, with `status` + constrained to `decided` or `finalized`. +- Node, Studio, CLI, and client libraries should converge on `decided` and + `finalized`; node's older `accepted` selector should be migrated in the next + release. +- The historical resolver should work against hot state first and archived + state second, with read-through hidden behind the storage abstraction. + +## Throughput Sizing + +The current pruner is correctness-first. Each batch locks candidate rows and +then processes each row sequentially: + +1. Read `contract_snapshot::text` and `pg_column_size(contract_snapshot)` from + Postgres. +2. Serialize and gzip the snapshot. +3. Write the gzip object to the archive backend. +4. Read the object back for verification. +5. Insert or update the archive index row. +6. Set `transactions.contract_snapshot = NULL`. + +That means a large one-time drain is bounded by Postgres read throughput, +compression throughput, S3 PUT latency, S3 GET verification latency, and the +final Postgres update volume. The verify step intentionally doubles object-store +read/write traffic for compressed bytes, but it does not double the Postgres +read volume. + +Rough 2 TB logical hot-state drain estimates: + +```text +Sustained logical throughput Approximate wall time +10 MB/s 56 hours +25 MB/s 22 hours +50 MB/s 11 hours +100 MB/s 5.6 hours +``` + +Object count can dominate if snapshots are small. At 100 ms of sequential +archive/verify overhead per object, one million snapshots adds about 28 hours +before accounting for bytes. For Rally-scale drains, measure row count and size +distribution before deciding whether the one-worker implementation is enough or +whether to add bounded parallel archive workers. + +The one-time CLI logs per-batch and total elapsed time plus logical and +compressed throughput. For production-size drains, use `--sleep-seconds 0` only +inside an approved maintenance/controlled run; the default sleep is intentionally +gentle and can add meaningful wall time across many batches. + +Recommended Rally measurement before a production drain: + +```sql +SELECT + count(*) AS eligible_rows, + pg_size_pretty(sum(pg_column_size(contract_snapshot))) AS logical_size, + percentile_disc(0.50) WITHIN GROUP (ORDER BY pg_column_size(contract_snapshot)) AS p50_bytes, + percentile_disc(0.90) WITHIN GROUP (ORDER BY pg_column_size(contract_snapshot)) AS p90_bytes, + percentile_disc(0.99) WITHIN GROUP (ORDER BY pg_column_size(contract_snapshot)) AS p99_bytes, + max(pg_column_size(contract_snapshot)) AS max_bytes +FROM transactions +WHERE contract_snapshot IS NOT NULL + AND status IN ('FINALIZED', 'CANCELED'); +``` + +Also sample real compression ratio on production-like rows before estimating S3 +bytes and cost. The safe default is still `VERIFY_ARCHIVE=true`; if the drain is +too slow, optimize with measured parallelism rather than removing verification +as the first lever. + +### Rally Production Measurement 2026-06-19 + +Read-only measurements against Rally production on 2026-06-19: + +- Cloud SQL instance: PostgreSQL 17, regional, PD_SSD, 3850 GB allocated. +- `transactions` table total size: about 3.80 TB. +- `transactions` TOAST size: about 3.80 TB. +- Live rows: about 227k. +- Eligible terminal rows with snapshots: 227,684. +- Eligible `pg_column_size(contract_snapshot)` total: 2,429,083,876,044 bytes + (about 2.21 TiB). +- Snapshot size distribution by `pg_column_size`: p50 5.3 MB, p90 27.4 MB, + p99 71.2 MB, max 87.3 MB. +- Sampled gzip archive ratio: about 0.61 of `pg_column_size`, implying roughly + 1.35 TiB of compressed archive objects before verification reads. +- Single 4-CPU JSON-RPC pod sample: DB fetch plus gzip/checksum was about + 10.8 MiB/s against `pg_column_size`; gzip/checksum alone was about + 14.7 MiB/s. +- Read-only parallel fetch plus gzip/checksum probe against 48 sampled snapshots: + 1 worker 11.2 MiB/s, 2 workers 20.6 MiB/s, 4 workers 37.5 MiB/s, 8 workers + 34.8 MiB/s. The 8-worker run showed higher summed fetch time, so 4 workers + looked like the local knee for this pod/sample. +- A second 4-worker probe biased to snapshots above 5 MB measured 37.1 MiB/s. + +Implications: + +- One-object-per-snapshot is not obviously wasteful for Rally because compressed + objects average several MB, well above small-object minimum billing thresholds. +- A single sequential worker is likely a multi-day drain. The read-only probe + implies about 58 hours at 1 worker and about 17 hours at 4 workers before + object-store write/read-back overhead. Use controlled parallel workers/jobs + after the candidate index is deployed and remeasure actual archive/prune + throughput before going wider than 4 workers. +- Use the one-time CLI with `--workers`, `--max-batches`, and `--sleep-seconds 0` + for the benchmark ladder. Keep each first run small enough that rollback is + operationally boring, then scale only while DB CPU, DB IO, object-store errors, + and RPC latency remain healthy. +- The current production database will not immediately return allocated disk + after pruning. The AWS migration/fresh restore is the right time to materialize + the smaller database size. + +### Studio Dev Full Drain Validation 2026-06-19 + +`studio-dev` was validated with a full one-time archive/verify/prune drain on +2026-06-19. Background pruning remained disabled. + +Runtime configuration: + +- Backend: `s3` +- Bucket: `devexp-dev-studio-snapshot-archives` +- Prefix: `studio-dev/terminal-contract-snapshots` +- Storage class: `STANDARD_IA` +- Archive verification: enabled +- Retrieval/read-through: enabled + +Command: + +```bash +python3 -m backend.database_handler.prune_terminal_snapshots \ + --batch-size 5 \ + --retention-hours 0 \ + --sleep-seconds 0 +``` + +Result: + +- Before run: 17 eligible terminal snapshots, 2,720 logical bytes. +- Run completed in 4 batches: 17 candidates, 17 archived, 17 pruned. +- Written compressed bytes: 2,159. +- After run: 0 remaining eligible terminal snapshots. +- All 17 hot transaction rows had `contract_snapshot IS NULL`. +- All 17 archive rows had `archive_status='pruned'` and `backend='s3'`. +- All 17 S3 objects were fetched, gzip-decoded, and verified against archive + row checksums. +- `eth_getTransactionByHash` for a pruned transaction hydrated the archived + snapshot through the read path. +- `/health` was healthy and `eth_chainId` returned `0xf22d` after the run. + +## GCP to AWS Migration + +For the current migration plan, prefer: + +1. Archive and prune in GCP using `gcs`. +2. Migrate the smaller Postgres database to AWS. +3. Keep AWS Studio reading the GCS archive temporarily. +4. Copy the GCS archive prefix to S3 in the background. +5. Update the archive index rows to point at the copied S3 objects. +6. Flip new archive writes to `s3` after the copy is verified. + +Because the object key format is stable across backends, the metadata backfill +can be a bounded SQL update after the object copy is verified: + +```sql +UPDATE transaction_snapshot_archives +SET backend = 's3', + bucket = '', + uri = 's3:///' || object_key +WHERE backend = 'gcs' + AND bucket = '' + AND object_key LIKE 'studio/terminal-contract-snapshots/%'; +``` + +This keeps the database migration smaller without coupling every GCP pruning +batch to cross-cloud object writes. + +## Production Rollout Checklist + +Use this checklist for each Studio namespace. Keep the background pruner disabled +until the one-time drain has been measured and the steady-state settings are +chosen. + +### 1. Preflight + +- Confirm the deployed image contains this pruning code and migrations. +- Confirm `transaction_snapshot_archives` exists. +- Confirm `idx_transactions_terminal_snapshot_archive_candidates` exists and is + valid. +- Confirm object-storage credentials from the JSON-RPC pod or one-time job: + write, read, and, if applicable, KMS encrypt/decrypt. +- Confirm RPC services have + `STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED=true`. +- Confirm background pruning is off: + `STUDIO_CONTRACT_SNAPSHOT_PRUNER_ENABLED=false`. +- Confirm object lifecycle/retention policy is intentional for the archive + bucket or prefix. +- Record the candidate count and byte distribution with the Rally measurement + query above. + +Index check: + +```sql +SELECT + indexrelid::regclass AS index_name, + indisvalid, + indisready +FROM pg_index +WHERE indexrelid::regclass::text = + 'idx_transactions_terminal_snapshot_archive_candidates'; +``` + +Archive table check: + +```sql +SELECT to_regclass('public.transaction_snapshot_archives') AS archive_table; +``` + +### 2. Rollout Ladder + +Start with lossless archive verification enabled. Do not set +`STUDIO_CONTRACT_SNAPSHOT_PRUNER_ALLOW_LOSSY_PRUNE=true` for production drains. + +1. Deploy code and migrations with pruning off. +2. Enable archive retrieval on RPC services. +3. Run a dry-run: + + ```bash + python -m backend.database_handler.prune_terminal_snapshots \ + --phase archive \ + --dry-run \ + --batch-size 5 \ + --retention-hours 24 \ + --max-batches 10 \ + --workers 1 \ + --sleep-seconds 0 + ``` + +4. Run one real batch: + + ```bash + python -m backend.database_handler.prune_terminal_snapshots \ + --phase full \ + --batch-size 1 \ + --retention-hours 24 \ + --max-batches 1 \ + --workers 1 \ + --sleep-seconds 0 + ``` + +5. Verify the pruned transaction end to end: + archive row, object metadata, checksum, hot row null, and direct read + hydration. +6. For the real historical drain, switch to the split pipeline: + + ```bash + python -m backend.database_handler.prune_terminal_snapshots \ + --phase archive \ + --batch-size 25 \ + --retention-hours 24 \ + --max-batches 100 \ + --workers 4 \ + --sleep-seconds 0 + + python -m backend.database_handler.prune_terminal_snapshots \ + --phase verify \ + --batch-size 25 \ + --max-batches 100 \ + --workers 4 \ + --sleep-seconds 0 + + python -m backend.database_handler.prune_terminal_snapshots \ + --phase prune \ + --batch-size 100 \ + --retention-hours 24 \ + --max-batches 100 \ + --workers 4 \ + --sleep-seconds 0 + ``` + +7. Run a small measured batch ladder while watching DB CPU/IO, object-store + errors, RPC latency, and application logs: + `workers=1`, then `workers=2`, then `workers=4`. Use dedicated one-off + Kubernetes Jobs for large drains instead of execing inside serving RPC pods. +8. Continue the one-time drain only at the highest worker/job count that remains + healthy. Keep verification and pruning behind the archive phase; do not prune + rows whose archive row lacks `verified_at`. +9. After the historical drain, enable the background pruner only if steady-state + pruning is desired. Use a conservative retention window and batch size first, + for example `retention_hours=24`, `batch_size=5`, `interval_seconds=300`. + +### 3. Per-Batch Validation + +Use these checks after a tiny real batch and periodically during larger drains. + +Archive row: + +```sql +SELECT + tx_hash, + archive_status, + backend, + bucket, + object_key, + snapshot_sha256, + compressed_sha256, + snapshot_bytes, + compressed_bytes, + archived_at, + pruned_at +FROM transaction_snapshot_archives +WHERE tx_hash = ''; +``` + +Hot row: + +```sql +SELECT + hash, + status, + contract_snapshot IS NULL AS snapshot_pruned +FROM transactions +WHERE hash = ''; +``` + +Progress: + +```sql +SELECT + count(*) AS remaining_rows, + pg_size_pretty(sum(pg_column_size(contract_snapshot))) AS remaining_logical_size +FROM transactions +WHERE contract_snapshot IS NOT NULL + AND status IN ('FINALIZED', 'CANCELED'); +``` + +Archive totals: + +```sql +SELECT + archive_status, + backend, + count(*) AS rows, + pg_size_pretty(sum(snapshot_bytes)) AS logical_size, + pg_size_pretty(sum(compressed_bytes)) AS compressed_size +FROM transaction_snapshot_archives +GROUP BY archive_status, backend +ORDER BY archive_status, backend; +``` + +### 4. Rollback And Stop Conditions + +Immediate stop switches: + +- Stop the one-time job. +- Keep or set `STUDIO_CONTRACT_SNAPSHOT_PRUNER_ENABLED=false`. +- If archive reads are causing user-visible RPC issues, set + `STUDIO_CONTRACT_SNAPSHOT_ARCHIVE_RETRIEVAL_ENABLED=false`. This disables + hydration but does not restore hot snapshots. + +Stop the drain and investigate if any of these happen: + +- Archive write or read-back verification errors. +- Checksum mismatch. +- Sustained object-store throttling or 5xx errors. +- DB CPU/IO saturation or material RPC latency regression. +- Pruned transaction cannot hydrate through the direct read path. +- Archive rows are missing for pruned hot rows. + +Rollback from a successful prune is restore-oriented: the lossless copy is the +archive object plus `transaction_snapshot_archives` metadata. If hot-state +restoration is required, write a targeted restore job that loads verified +archive objects and updates `transactions.contract_snapshot` for selected hashes. +Do not delete archive objects during or immediately after rollout. + +## Reclaiming Disk + +Pruning removes logical JSONB payloads and reduces future database growth, but +Postgres/Cloud SQL/RDS may not immediately return physical storage to the +provider. Plan a separate compaction/rebuild step if the goal is to reduce +allocated database storage on an existing instance. A database migration to a +fresh AWS instance is a natural opportunity to materialize the smaller size. diff --git a/examples/contracts/_hello_world.py b/examples/contracts/_hello_world.py index 702bd1193..273df4263 100644 --- a/examples/contracts/_hello_world.py +++ b/examples/contracts/_hello_world.py @@ -1,16 +1,16 @@ -# v0.2.16 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } # Always put above lines as first in the contract file -# v0.1.2 is genvm ABI version, lower versions may restrict some calls that were introduced in newer version (i.e. events) # In actual genlayer network `:latest` is not allowed and hash must be specified -# this imports all types into globals and `genlayer.std` as `gl` (will be imported lazily on first access) -from genlayer import * +# `gl` gives access to submodules and decorators, `genlayer.types` brings type aliases into scope +import genlayer as gl +from genlayer.types import * -# extend `gl.Contract` to mark class as a contract. There can be only one class that extends `gl.Contract` -class Storage(gl.Contract): +# extend `gl.contract.Contract` to mark class as a contract. There can be only one class that extends it +class Storage(gl.contract.Contract): # below you must declare all class fields that you are going to use # this fields persist between contract calls storage_str: str diff --git a/examples/contracts/faucet.py b/examples/contracts/faucet.py index 235d6b853..075eae651 100644 --- a/examples/contracts/faucet.py +++ b/examples/contracts/faucet.py @@ -1,7 +1,8 @@ -# v0.2.17 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } -from genlayer import * +import genlayer as gl +from genlayer.types import * @gl.evm.contract_interface @@ -13,13 +14,13 @@ class Write: pass -class Faucet(gl.Contract): +class Faucet(gl.contract.Contract): def __init__(self): pass @gl.public.write.payable def send(self, recipient: str) -> None: v = gl.message.value - if v == u256(0): + if v == 0: raise gl.vm.UserError("send some value") _Recipient(Address(recipient)).emit_transfer(value=v) diff --git a/examples/contracts/football_prediction_market.py b/examples/contracts/football_prediction_market.py index 4cc85c0c5..492c07bf2 100644 --- a/examples/contracts/football_prediction_market.py +++ b/examples/contracts/football_prediction_market.py @@ -1,13 +1,14 @@ -# v0.2.16 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } -from genlayer import * +import genlayer as gl +from genlayer.types import * import json import typing -class PredictionMarket(gl.Contract): +class PredictionMarket(gl.contract.Contract): has_resolved: bool team1: str team2: str @@ -37,7 +38,7 @@ def __init__(self, game_date: str, team1: str, team2: str): ) self.team1 = team1 self.team2 = team2 - self.winner = u256(0) + self.winner = 0 self.score = "" @gl.public.write diff --git a/examples/contracts/llm_erc20.py b/examples/contracts/llm_erc20.py index cdaab070e..419794527 100644 --- a/examples/contracts/llm_erc20.py +++ b/examples/contracts/llm_erc20.py @@ -1,16 +1,17 @@ -# v0.2.16 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } import json -from genlayer import * +import genlayer as gl +from genlayer.types import * -class LlmErc20(gl.Contract): - balances: TreeMap[Address, u256] +class LlmErc20(gl.contract.Contract): + balances: gl.storage.TreeMap[Address, u256] def __init__(self, total_supply: int) -> None: - self.balances[gl.message.sender_address] = u256(total_supply) + self.balances[gl.message.sender_address] = total_supply @gl.public.write def transfer(self, amount: int, to_address: str) -> None: diff --git a/examples/contracts/log_indexer.py b/examples/contracts/log_indexer.py index 5127795ab..b36391db9 100644 --- a/examples/contracts/log_indexer.py +++ b/examples/contracts/log_indexer.py @@ -1,20 +1,22 @@ -# v0.2.16 +# v0.3.0 # { # "Seq": [ -# { "Depends": "py-lib-genlayer-embeddings:09h0i209wrzh4xzq86f79c60x0ifs7xcjwl53ysrnw06i54ddxyi" }, -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# { "Depends": "py-lib-genlayer-embeddings:hqpree1t3470fnac2aeee1y5c2205k22bgk1p98sg8m3s1ndmxbg" }, +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } # ] # } import numpy as np -from genlayer import * +import genlayer as gl +from genlayer.types import * +from genlayer.storage import TreeMap import genlayer_embeddings as gle from dataclasses import dataclass import typing -@allow_storage +@gl.storage.allow @dataclass class StoreValue: log_id: u256 @@ -22,8 +24,13 @@ class StoreValue: # contract class -class LogIndexer(gl.Contract): - vector_store: gle.VecDB[np.float32, typing.Literal[384], StoreValue] +class LogIndexer(gl.contract.Contract): + # The v0.3 embeddings runner's VecDB takes an explicit metric type. + vector_store: gle.VecDB[ + np.float32, typing.Literal[384], StoreValue, gle.EuclideanDistance + ] + log_vector_ids: TreeMap[u256, u32] + removed_log_ids: TreeMap[u256, bool] def __init__(self): pass @@ -39,31 +46,50 @@ def get_embedding( @gl.public.view def get_closest_vector(self, text: str) -> dict | None: emb = self.get_embedding(text) - result = list(self.vector_store.knn(emb, 1)) - if len(result) == 0: - return None - result = result[0] - return { - "vector": list(str(x) for x in result.key), - "similarity": str(1 - result.distance), - "id": result.value.log_id, - "text": result.value.text, - } + for result in self.vector_store.knn(emb, len(self.vector_store)): + log_id = result.value.log_id + if log_id in self.removed_log_ids and self.removed_log_ids[log_id]: + continue + if log_id not in self.log_vector_ids: + continue + if self.log_vector_ids[log_id] != result.id: + continue + return { + "vector": list(str(x) for x in result.key), + "similarity": str(1 - result.distance), + "id": result.value.log_id, + "text": result.value.text, + } + return None @gl.public.write def add_log(self, log: str, log_id: int) -> None: + key = log_id + if key in self.log_vector_ids: + self.vector_store.get_by_id(self.log_vector_ids[key]).value = StoreValue( + text=log, log_id=key + ) + return + emb = self.get_embedding(log) - self.vector_store.insert(emb, StoreValue(text=log, log_id=u256(log_id))) + vector_id = self.vector_store.insert(emb, StoreValue(text=log, log_id=key)) + self.log_vector_ids[key] = vector_id @gl.public.write def update_log(self, log_id: int, log: str) -> None: + key = log_id + if key in self.log_vector_ids: + self.vector_store.get_by_id(self.log_vector_ids[key]).value = StoreValue( + text=log, log_id=key + ) + return + emb = self.get_embedding(log) - for elem in self.vector_store.knn(emb, 2): - if elem.value.text == log: - elem.value.log_id = u256(log_id) + vector_id = self.vector_store.insert(emb, StoreValue(text=log, log_id=key)) + self.log_vector_ids[key] = vector_id @gl.public.write def remove_log(self, id: int) -> None: - for el in self.vector_store: - if el.value.log_id == id: - el.remove() + key = id + if key in self.log_vector_ids: + self.removed_log_ids[key] = True diff --git a/examples/contracts/storage.py b/examples/contracts/storage.py index b5af2b626..6affe74b3 100644 --- a/examples/contracts/storage.py +++ b/examples/contracts/storage.py @@ -1,11 +1,11 @@ -# v0.2.16 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } -from genlayer import * +import genlayer as gl # contract class -class Storage(gl.Contract): +class Storage(gl.contract.Contract): storage: str # constructor diff --git a/examples/contracts/tip_jar.py b/examples/contracts/tip_jar.py index 99a335d89..b6c3e3226 100644 --- a/examples/contracts/tip_jar.py +++ b/examples/contracts/tip_jar.py @@ -1,21 +1,22 @@ -# v0.2.16 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } -from genlayer import * +import genlayer as gl +from genlayer.types import * -class TipJar(gl.Contract): +class TipJar(gl.contract.Contract): owner: Address total_tips: u256 def __init__(self): self.owner = gl.message.sender_address - self.total_tips = u256(0) + self.total_tips = 0 @gl.public.write.payable def tip(self) -> None: v = gl.message.value - if v == u256(0): + if v == 0: raise gl.vm.UserError("send some value") self.total_tips = self.total_tips + v diff --git a/examples/contracts/user_storage.py b/examples/contracts/user_storage.py index dd69116fb..227c67ffb 100644 --- a/examples/contracts/user_storage.py +++ b/examples/contracts/user_storage.py @@ -1,11 +1,12 @@ -# v0.2.16 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } -from genlayer import * +import genlayer as gl +from genlayer.types import * -class UserStorage(gl.Contract): - storage: TreeMap[Address, str] +class UserStorage(gl.contract.Contract): + storage: gl.storage.TreeMap[Address, str] # constructor def __init__(self): diff --git a/examples/contracts/wizard_of_coin.py b/examples/contracts/wizard_of_coin.py index fc29f7b33..947852cab 100644 --- a/examples/contracts/wizard_of_coin.py +++ b/examples/contracts/wizard_of_coin.py @@ -1,11 +1,11 @@ -# v0.2.16 -# { "Depends": "py-genlayer:1jb45aa8ynh2a9c9xn3b7qqh8sm5q93hwfp7jqmwsfhh8jpz09h6" } -from genlayer import * +# v0.3.0 +# { "Depends": "py-genlayer:5jycge4q8k23462jtb0b9fyey1s9qz928sz2nbrd9mg4sxqg2qng" } +import genlayer as gl import json -class WizardOfCoin(gl.Contract): +class WizardOfCoin(gl.contract.Contract): have_coin: bool def __init__(self, have_coin: bool): diff --git a/explorer/eslint.config.mjs b/explorer/eslint.config.mjs index 05e726d1b..56b53789e 100644 --- a/explorer/eslint.config.mjs +++ b/explorer/eslint.config.mjs @@ -5,6 +5,11 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + "react-hooks/set-state-in-effect": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/explorer/package-lock.json b/explorer/package-lock.json new file mode 100644 index 000000000..78edc7738 --- /dev/null +++ b/explorer/package-lock.json @@ -0,0 +1,8667 @@ +{ + "name": "studio-explorer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "studio-explorer", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "genlayer-js": "^0.20.3", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "next-themes": "^0.4.6", + "react": "19.2.4", + "react-day-picker": "^9.14.0", + "react-dom": "19.2.4", + "shiki": "^4.0.2", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^24.0.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9.39.4", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "license": "MIT" + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@shikijs/core": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz", + "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", + "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", + "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.0.2.tgz", + "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.0.2.tgz", + "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", + "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.1.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-config-next/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-config-next/node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-config-next/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/genlayer-js": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-0.20.3.tgz", + "integrity": "sha512-uekZauVA4FJ0aChrQJZABY5IbFYne5idTTtNUwUNGYH/Zeuimgk3AR+eRP/8P2tZR0T8wyLQScSAxMY60znFUg==", + "license": "MIT", + "dependencies": { + "eslint-plugin-import": "^2.30.0", + "typescript-parsec": "^0.3.4", + "viem": "^2.29.0" + } + }, + "node_modules/genlayer-js/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/genlayer-js/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/genlayer-js/node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/genlayer-js/node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/genlayer-js/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz", + "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ox": { + "version": "0.14.17", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.17.tgz", + "integrity": "sha512-jOzNb2Wlfzsr8z/GoCtd1bf6OSRuWuysvbhnHGD+7fV1WRbcBR6B0RYoe3xWnUedF7zp4l5APmS7CzAhUok/lA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.0.2.tgz", + "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.0.2", + "@shikijs/engine-javascript": "4.0.2", + "@shikijs/engine-oniguruma": "4.0.2", + "@shikijs/langs": "4.0.2", + "@shikijs/themes": "4.0.2", + "@shikijs/types": "4.0.2", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/typescript-parsec": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/typescript-parsec/-/typescript-parsec-0.3.4.tgz", + "integrity": "sha512-6RD4xOxp26BTZLopNbqT2iErqNhQZZWb5m5F07/UwGhldGvOAKOl41pZ3fxsFp04bNL+PbgMjNfb6IvJAC/uYQ==", + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/viem": { + "version": "2.48.1", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.48.1.tgz", + "integrity": "sha512-GJC3gKV1Hngeo1IB9YanJKHH2pcmoqDymyPxddmzDtG8boXA7eFw8qdnn1PSaToJ93f3LpOZPlLLJ9beAF/Lzg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.17", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/explorer/package.json b/explorer/package.json index 9e1687da6..9353ff02d 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test:fee-accounting": "node scripts/test-fee-accounting.mjs" }, "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", @@ -36,7 +37,7 @@ "@types/node": "^24.0.0", "@types/react": "^19", "@types/react-dom": "^19", - "eslint": "^10.0.0", + "eslint": "^9.39.4", "eslint-config-next": "16.1.6", "tailwindcss": "^4", "typescript": "^5" diff --git a/explorer/scripts/test-fee-accounting.mjs b/explorer/scripts/test-fee-accounting.mjs new file mode 100644 index 000000000..0ba5681e7 --- /dev/null +++ b/explorer/scripts/test-fee-accounting.mjs @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import Module from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const sourcePath = path.resolve(__dirname, '../src/lib/feeAccounting.ts'); +const source = fs.readFileSync(sourcePath, 'utf8'); +const compiled = ts.transpileModule(source, { + compilerOptions: { + esModuleInterop: true, + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + fileName: sourcePath, +}); + +const testModule = new Module(sourcePath); +testModule.filename = sourcePath; +testModule.paths = Module._nodeModulePaths(path.dirname(sourcePath)); +testModule._compile(compiled.outputText, sourcePath); + +const { + feeBucketRows, + feeDistributionRows, + feeMetricRows, + feeRecommendedObservedRows, + feeRecommendedPresetRows, + formatFeeAmount, + formatFeeParamsDecoded, + formatInteger, + getStudioFeeAccounting, + toBigIntAmount, +} = testModule.exports; + +function rowMap(rows) { + return Object.fromEntries(rows.map((row) => [row.label, row.value])); +} + +const WEI_PER_GEN = '1000000000000000000'; +const accounting = { + status: 'active', + paid_fee_value: '120000000000000000', + required_fee_value: '110000000000000000', + primary_fee_budget: '100000000000000000', + primary_fee_spent: '90000000000000000', + primary_fee_refunded: '10000000000000000', + execution_budget_total: '100000000000000000', + execution_fee_consumed: '90000000000000000', + genvm_message_fee_consumed: '1234', + message_fee_budget: '55000000000000000', + message_fee_consumed: '55000000000000000', + message_fee_refunded: '0', + external_message_fee_reserved: '700', + external_message_fee_reimbursed: '420', + external_message_fee_remainder: '280', + appeal_bonds_total: '1400000000000000000', + total_refunded: '10000000000000000', + fees_distribution: { + leaderTimeunitsAllocation: '100', + validatorTimeunitsAllocation: '200', + appealRounds: '1', + executionBudgetPerRound: '50000000000000000', + executionConsumed: '90000000000000000', + totalMessageFees: '55000000000000000', + rotations: ['0', '2'], + maxPriceGenPerTimeUnit: '1000000000000000', + storageFeeMaxGasPrice: '1', + receiptFeeMaxGasPrice: '1', + }, + recommended_fee_preset: { + feeValue: '132000000000000000', + paddingBps: '12000', + numOfInitialValidators: '5', + messageBudgetMode: 'allocation-preserved', + messageAllocations: [{ messageType: 1, budget: '55000000000000000' }], + distribution: { + leaderTimeunitsAllocation: '120', + validatorTimeunitsAllocation: '240', + appealRounds: '2', + executionBudgetPerRound: '60000000000000000', + executionConsumed: '0', + totalMessageFees: '55000000000000000', + rotations: ['0', '1', '1'], + maxPriceGenPerTimeUnit: '1000000000000000', + storageFeeMaxGasPrice: '1', + receiptFeeMaxGasPrice: '1', + }, + observed: { + executionFee: '90000000000000000', + messageFeeBudget: '55000000000000000', + declaredMessageFees: '55000000000000000', + externalMessageReserved: '700', + totalEstimatedFee: '145000000000000000', + totalStudioMeteredFee: '145000000000000000', + }, + }, +}; + +assert.equal(toBigIntAmount('42'), 42n); +assert.equal(toBigIntAmount(42.9), 42n); +assert.equal(toBigIntAmount('not-a-number'), null); +assert.equal(formatInteger('1000000'), '1,000,000'); +assert.equal(formatFeeAmount('999'), '999 wei'); +assert.equal( + formatFeeAmount('1000000000000000'), + '0.001 GEN (1,000,000,000,000,000 wei)', +); +assert.equal( + formatFeeAmount(WEI_PER_GEN), + '1 GEN (1,000,000,000,000,000,000 wei)', +); +assert.equal(formatFeeParamsDecoded(null), '-'); +assert.equal(formatFeeParamsDecoded({}), '-'); +assert.equal( + formatFeeParamsDecoded({ + leaderTimeunitsAllocation: 5, + validatorTimeunitsAllocation: 10, + appealRounds: 0, + executionBudgetPerRound: 0, + rotations: [0, 1], + }), + 'Leader 5, Validator 10, Appeals 0, Exec budget 0 wei, Rotations 0 / 1', +); +assert.equal( + formatFeeParamsDecoded({ + gasLimit: '21000', + maxGasPrice: '1000000000000000', + }), + 'Gas limit 21,000, Max gas price 0.001 GEN (1,000,000,000,000,000 wei)', +); +assert.equal(formatFeeParamsDecoded({ zeta: 'x', alpha: 3 }), 'alpha 3, zeta x'); + +assert.deepEqual( + getStudioFeeAccounting({ + data: { fee_accounting: accounting }, + consensus_data: { fee_accounting: { status: 'ignored' } }, + }), + accounting, +); +assert.deepEqual( + getStudioFeeAccounting({ + data: {}, + consensus_data: { fee_accounting: accounting }, + }), + accounting, +); +assert.deepEqual( + getStudioFeeAccounting({ + data: {}, + consensus_data: { + leader_receipt: [{ genvm_result: { fee_accounting: accounting } }], + }, + }), + accounting, +); +assert.equal(getStudioFeeAccounting({ data: {}, consensus_data: {} }), null); + +const metrics = rowMap(feeMetricRows(accounting)); +assert.equal(metrics['Paid fee'], '0.120 GEN (120,000,000,000,000,000 wei)'); +assert.equal(metrics['Message budget'], '0.055 GEN (55,000,000,000,000,000 wei)'); +assert.equal(metrics['GenVM message meter'], '1,234 wei'); +assert.equal(metrics['External reimbursed'], '420 wei'); +assert.equal(metrics['Appeal bonds'], '1.400 GEN (1,400,000,000,000,000,000 wei)'); + +const distribution = rowMap(feeDistributionRows(accounting)); +assert.equal(distribution['Leader time units'], '100'); +assert.equal(distribution.Rotations, '0 / 2'); +assert.equal( + distribution['Execution budget per round'], + '0.050 GEN (50,000,000,000,000,000 wei)', +); +assert.equal(distribution['Max price per time unit'], '0.001 GEN (1,000,000,000,000,000 wei)'); + +const recommended = rowMap(feeRecommendedPresetRows(accounting)); +assert.equal(recommended['Fee value'], '0.132 GEN (132,000,000,000,000,000 wei)'); +assert.equal(recommended.Padding, '12,000 bps'); +assert.equal(recommended.Validators, '5'); +assert.equal(recommended['Message budget mode'], 'allocation-preserved'); +assert.equal(recommended['Message allocations'], '1'); + +const observed = rowMap(feeRecommendedObservedRows(accounting)); +assert.equal(observed['Execution fee'], '0.090 GEN (90,000,000,000,000,000 wei)'); +assert.equal(observed['External reserved'], '700 wei'); +assert.equal(observed['Studio metered fee'], '0.145 GEN (145,000,000,000,000,000 wei)'); + +const zeroBudgetBuckets = rowMap( + feeBucketRows({ + receiptAndNondetOutput: '1', + storage: '0', + message: '0', + totalExecution: '1', + totalWithMessage: '1', + executionBudgetPerRound: '0', + executionBudgetRemaining: '0', + executionBudgetOverrun: '1', + executionBudgetExceeded: true, + }), +); +assert.equal(zeroBudgetBuckets['Receipt/nondet used'], '1 wei'); +assert.equal(zeroBudgetBuckets['Execution budget'], '0 wei'); +assert.equal(zeroBudgetBuckets['Budget remaining'], '0 wei'); +assert.equal(zeroBudgetBuckets['Budget overrun'], '1 wei'); +assert.equal(zeroBudgetBuckets['Budget exceeded'], 'true'); +assert.equal(zeroBudgetBuckets['Message meter'], '0 wei'); + +console.log('feeAccounting helper tests passed'); diff --git a/explorer/src/app/address/[addr]/AddressContent.tsx b/explorer/src/app/address/[addr]/AddressContent.tsx index 2ffd921c8..8d6c970a3 100644 --- a/explorer/src/app/address/[addr]/AddressContent.tsx +++ b/explorer/src/app/address/[addr]/AddressContent.tsx @@ -7,8 +7,6 @@ import { Transaction, Validator, CurrentState } from '@/lib/types'; import { AddressTransactionTable } from '@/components/AddressTransactionTable'; import { CopyButton } from '@/components/CopyButton'; import { AddressDisplay } from '@/components/AddressDisplay'; -import { CodeBlock } from '@/components/CodeBlock'; -import { JsonViewer } from '@/components/JsonViewer'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; diff --git a/explorer/src/app/address/[addr]/page.tsx b/explorer/src/app/address/[addr]/page.tsx index e1b71547c..b4200f1e7 100644 --- a/explorer/src/app/address/[addr]/page.tsx +++ b/explorer/src/app/address/[addr]/page.tsx @@ -7,13 +7,18 @@ import { ArrowLeft } from 'lucide-react'; export default async function AddressPage({ params }: { params: Promise<{ addr: string }> }) { const { addr } = await params; + let data: AddressInfo | null = null; + let error: unknown = null; try { - const data = await fetchBackend( + data = await fetchBackend( `/address/${encodeURIComponent(addr)}`, ); - return ; } catch (err) { + error = err; + } + + if (error || !data) { return (
); } + + return ; } diff --git a/explorer/src/app/contracts/page.tsx b/explorer/src/app/contracts/page.tsx index 44cf6f6fd..00002e4aa 100644 --- a/explorer/src/app/contracts/page.tsx +++ b/explorer/src/app/contracts/page.tsx @@ -68,7 +68,7 @@ function StateContent() { } }; - const SortIcon = ({ column }: { column: string }) => { + const renderSortIcon = (column: string) => { if (sortBy !== column) return ; return sortOrder === 'asc' ? @@ -103,17 +103,17 @@ function StateContent() { Balance diff --git a/explorer/src/app/providers/page.tsx b/explorer/src/app/providers/page.tsx index dbc3a7555..61dd4d405 100644 --- a/explorer/src/app/providers/page.tsx +++ b/explorer/src/app/providers/page.tsx @@ -4,19 +4,27 @@ import { ProvidersContent } from './ProvidersContent'; import { Card, CardContent } from '@/components/ui/card'; export default async function ProvidersPage() { + let data: { providers: LLMProvider[] } | null = null; + let error: unknown = null; + try { - const data = await fetchBackend<{ providers: LLMProvider[] }>('/providers'); - return ; + data = await fetchBackend<{ providers: LLMProvider[] }>('/providers'); } catch (err) { + error = err; + } + + if (error || !data) { return (

Error loading providers

- {err instanceof Error ? err.message : 'Unknown error'} + {error instanceof Error ? error.message : 'Unknown error'}

); } + + return ; } diff --git a/explorer/src/app/tx/[hash]/components/OverviewTab.tsx b/explorer/src/app/tx/[hash]/components/OverviewTab.tsx index dc9a467ab..f33b77500 100644 --- a/explorer/src/app/tx/[hash]/components/OverviewTab.tsx +++ b/explorer/src/app/tx/[hash]/components/OverviewTab.tsx @@ -9,12 +9,16 @@ import { ConsensusJourney } from '@/components/ConsensusJourney'; import { InfoRow } from '@/components/InfoRow'; import { Badge } from '@/components/ui/badge'; import { JsonViewer } from '@/components/JsonViewer'; -import { getExecutionResult, getConsensusRoundResult } from '@/lib/transactionUtils'; +import { + getExecutionResult, + getConsensusRoundResult, +} from '@/lib/transactionUtils'; import { ConsensusResultBadge } from '@/components/ConsensusResultBadge'; import { resultStatusLabel, type DecodedResult } from '@/lib/resultDecoder'; import { InputDataPanel } from '@/components/InputDataPanel'; import { DataDecodePanel } from '@/components/DataDecodePanel'; import { formatGenValue } from '@/lib/formatters'; +import { FeeAccountingPanel } from '@/components/FeeAccountingPanel'; interface OverviewTabProps { transaction: Transaction; @@ -99,14 +103,15 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { const decodedResult = execResult?.decodedResult; const eqOutputs = execResult?.eqOutputs; - const dataObj = - tx.data && typeof tx.data === 'object' ? (tx.data as Record) : null; + const dataObj = tx.data && typeof tx.data === 'object' ? tx.data : null; const calldataB64 = (tx.type === 1 || tx.type === 2) && dataObj - ? (dataObj.calldata as string | undefined) + ? stringField(dataObj, 'calldata') : undefined; const contractCodeB64 = - tx.type === 1 && dataObj ? (dataObj.contract_code as string | undefined) : undefined; + tx.type === 1 && dataObj + ? stringField(dataObj, 'contract_code') + : undefined; return (
@@ -117,7 +122,10 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { label="From" value={ tx.from_address ? ( - + {tx.from_address} ) : ( @@ -131,7 +139,10 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { label="To" value={ tx.to_address ? ( - + {tx.to_address} ) : ( @@ -142,8 +153,14 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { copyText={tx.to_address || undefined} /> - - + + Leader Only + + Leader Only + ) : tx.execution_mode === 'LEADER_SELF_VALIDATOR' ? ( - Leader + Self Validator + + Leader + Self Validator + ) : ( - Normal + + Normal + ) } /> - - + + : '-'} + value={ + consensusRound ? ( + + ) : ( + '-' + ) + } /> {tx.worker_id && } + + {contractCodeB64 && dataObj && (
-

Input Data

+

+ Input Data +

{/* Deploy: show both constructor calldata and contract source */}
@@ -178,7 +217,9 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {calldataB64 && !contractCodeB64 && (
-

Input Data

+

+ Input Data +

)} @@ -187,16 +228,22 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {(executionResult || genvmResult || decodedResult) && ( <>
-

GenVM Execution

+

+ GenVM Execution +

{executionResult && ( SUCCESS + + SUCCESS + ) : ( - {executionResult} + + {executionResult} + ) } /> @@ -213,7 +260,11 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {genvmResult?.stdout !== undefined && ( (empty)} + value={ + genvmResult.stdout || ( + (empty) + ) + } copyable={!!genvmResult.stdout} copyText={genvmResult.stdout} /> @@ -247,19 +298,24 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) { {Object.entries(eqOutputs).map(([key, decoded]) => (
- {key} + + {key} +
{decoded.payload != null && (
{typeof decoded.payload === 'object' && decoded.payload !== null && - 'readable' in (decoded.payload as Record) ? ( + 'readable' in + (decoded.payload as Record) ? ( {(decoded.payload as { readable: string }).readable} ) : typeof decoded.payload === 'string' ? ( - {decoded.payload} + + {decoded.payload} + ) : ( )} @@ -278,3 +334,11 @@ export function OverviewTab({ transaction: tx }: OverviewTabProps) {
); } + +function stringField( + record: Record, + key: string, +): string | undefined { + const value = record[key]; + return typeof value === 'string' ? value : undefined; +} diff --git a/explorer/src/app/validators/page.tsx b/explorer/src/app/validators/page.tsx index a797d4f09..bd333a58b 100644 --- a/explorer/src/app/validators/page.tsx +++ b/explorer/src/app/validators/page.tsx @@ -4,19 +4,27 @@ import { ValidatorsContent } from './ValidatorsContent'; import { Card, CardContent } from '@/components/ui/card'; export default async function ValidatorsPage() { + let data: { validators: Validator[] } | null = null; + let error: unknown = null; + try { - const data = await fetchBackend<{ validators: Validator[] }>('/validators'); - return ; + data = await fetchBackend<{ validators: Validator[] }>('/validators'); } catch (err) { + error = err; + } + + if (error || !data) { return (

Error loading validators

- {err instanceof Error ? err.message : 'Unknown error'} + {error instanceof Error ? error.message : 'Unknown error'}

); } + + return ; } diff --git a/explorer/src/components/FeeAccountingPanel.tsx b/explorer/src/components/FeeAccountingPanel.tsx new file mode 100644 index 000000000..a3994cd2b --- /dev/null +++ b/explorer/src/components/FeeAccountingPanel.tsx @@ -0,0 +1,372 @@ +'use client'; + +import type { Transaction } from '@/lib/types'; +import { + feeBucketRows, + feeDistributionRows, + feeMetricRows, + feeRecommendedObservedRows, + feeRecommendedPresetRows, + formatFeeAmount, + formatFeeParamsDecoded, + formatInteger, + getStudioFeeAccounting, +} from '@/lib/feeAccounting'; +import { truncateAddress, truncateHash } from '@/lib/formatters'; + +interface FeeAccountingPanelProps { + readonly transaction: Transaction; +} + +export function FeeAccountingPanel({ transaction }: Readonly) { + const accounting = getStudioFeeAccounting(transaction); + if (!accounting) return null; + + const report = accounting.execution_fee_report; + const messages = report?.messageReveal?.messages ?? []; + const genvmBuckets = report?.genvmBuckets ?? accounting.genvm_fee_bucket_report; + const messageFees = report?.messageFees; + const executionMetering = report?.executionMetering; + const metricRows = feeMetricRows(accounting); + const distributionRows = feeDistributionRows(accounting); + const recommendedRows = feeRecommendedPresetRows(accounting); + const observedRows = feeRecommendedObservedRows(accounting); + const chargeableBucketRows = feeBucketRows(report?.chargeableExecution); + const genvmBucketRows = feeBucketRows(genvmBuckets); + + return ( +
+

Fees

+ +
+ {metricRows.map((row) => ( +
+
{row.label}
+
+ {row.value} +
+
+ ))} +
+ + {distributionRows.length > 0 && ( +
+ {distributionRows.map((row) => ( +
+
+ {row.label} +
+
+ {row.value} +
+
+ ))} +
+ )} + + {recommendedRows.length > 0 && ( +
+
+
+ Recommended Preset +
+ {recommendedRows.map((row) => ( + + ))} +
+ + {observedRows.length > 0 && ( +
+
+ Observed Usage +
+ {observedRows.map((row) => ( + + ))} +
+ )} +
+ )} + + {report && ( +
+ {report.proposalReceipt && ( +
+
+ Proposal Receipt +
+ + + +
+ )} + + {report.messageReveal && ( +
+
+ Message Reveal +
+ + + + + + + + +
+ )} + +
+
+ Execution Report +
+ + + {report.totalStudioMeteredFee !== undefined && ( + + )} + {report.budgetExhaustionReason && ( + + )} + {messageFees && ( + <> + + + {messageFees.genvmMeteredConsumed !== undefined && ( + + )} + {messageFees.externalReserved !== undefined && ( + + )} + {messageFees.externalReimbursed !== undefined && ( + + )} + {messageFees.externalRemainder !== undefined && ( + + )} + {messageFees.totalConsumed !== undefined && ( + + )} + {messageFees.reportedTotal !== undefined && ( + + )} + + + + + )} + {executionMetering && ( + <> + + + + + )} + {chargeableBucketRows.length > 0 && ( + <> + Chargeable Buckets + {chargeableBucketRows.map((row) => ( + + ))} + + )} + {genvmBucketRows.length > 0 && ( + <> + GenVM Raw Buckets + {genvmBucketRows.map((row) => ( + + ))} + + )} +
+
+ )} + + {messages.length > 0 && ( +
+ + + + + + + + + + + + + + + + + {messages.map((message, index) => ( + + + + + + + + + + + + + ))} + +
TypeModeRecipientValueDataFee ParamsDeclared BudgetAllocationOnCall Key
{message.messageType}{message.messageFeeMode ?? '-'} + {truncateAddress(message.recipient)} + + {formatFeeAmount(message.value)} + + {formatInteger(message.dataBytes)} B + + {formatInteger(message.feeParamsBytes)} B + {message.feeParams && message.feeParams !== '0x' && ( + + {truncateHash(message.feeParams)} + + )} + {formatFeeParamsDecoded(message.feeParamsDecoded) !== '-' && ( + + {formatFeeParamsDecoded(message.feeParamsDecoded)} + + )} + + {formatFeeAmount(message.declaredBudget)} + + {formatInteger(message.allocationSubtreeBytes)} B + {message.allocationSubtree && + message.allocationSubtree !== '0x' && ( + + {truncateHash(message.allocationSubtree)} + + )} + + {message.onAcceptance ? 'accepted' : 'finalized'} + + {truncateHash(message.callKey)} +
+
+ )} +
+ ); +} + +function ReportRow({ + label, + value, +}: Readonly<{ label: string; value: string }>) { + return ( +
+ {label} + {value} +
+ ); +} + +function SectionLabel({ children }: Readonly<{ children: string }>) { + return ( +
+ {children} +
+ ); +} diff --git a/explorer/src/components/GlobalSearch.tsx b/explorer/src/components/GlobalSearch.tsx index 65cbd55c6..43e5839ad 100644 --- a/explorer/src/components/GlobalSearch.tsx +++ b/explorer/src/components/GlobalSearch.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { StatusBadge } from '@/components/StatusBadge'; import { Badge } from '@/components/ui/badge'; import { truncateHash, truncateAddress } from '@/lib/formatters'; -import type { Transaction, CurrentState, Validator, TransactionStatus } from '@/lib/types'; +import type { Transaction, CurrentState, Validator } from '@/lib/types'; interface SearchResults { transactions: Transaction[]; diff --git a/explorer/src/lib/feeAccounting.ts b/explorer/src/lib/feeAccounting.ts new file mode 100644 index 000000000..d3153be7a --- /dev/null +++ b/explorer/src/lib/feeAccounting.ts @@ -0,0 +1,283 @@ +import type { StudioFeeAccounting, StudioGenvmFeeBucketReport, Transaction } from './types'; + +export type FeeAccountingRow = { + label: string; + value: string; +}; + +const amountDistributionLabels = new Set([ + 'Execution budget per round', + 'Message fee budget', + 'Max price per time unit', + 'Storage gas price', + 'Receipt gas price', +]); + +const recommendedPresetFeeLabels = new Set([ + 'Fee value', + 'Execution budget per round', + 'Message fee budget', + 'Max price per time unit', + 'Storage gas price', + 'Receipt gas price', +]); + +const feeParamsDecodedLabels: Record = { + leaderTimeunitsAllocation: 'Leader', + validatorTimeunitsAllocation: 'Validator', + appealRounds: 'Appeals', + executionBudgetPerRound: 'Exec budget', + rotations: 'Rotations', + gasLimit: 'Gas limit', + maxGasPrice: 'Max gas price', +}; + +const feeParamsDecodedOrder = Object.keys(feeParamsDecodedLabels); +const feeParamsDecodedFeeKeys = new Set(['executionBudgetPerRound', 'maxGasPrice']); +const feeParamsDecodedIntegerKeys = new Set([ + 'leaderTimeunitsAllocation', + 'validatorTimeunitsAllocation', + 'appealRounds', + 'gasLimit', + 'rotations', +]); + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function isNonEmptyRecord(value: unknown): value is Record { + const record = asRecord(value); + return Boolean(record && Object.keys(record).length > 0); +} + +export function getStudioFeeAccounting(tx: Transaction): StudioFeeAccounting | null { + const data = asRecord(tx.data); + const consensusData = asRecord(tx.consensus_data); + const leaderReceipts = consensusData?.leader_receipt; + const leaderReceipt = Array.isArray(leaderReceipts) ? asRecord(leaderReceipts[0]) : null; + const genvmResult = asRecord(leaderReceipt?.genvm_result); + const candidates = [ + data?.fee_accounting, + consensusData?.fee_accounting, + genvmResult?.fee_accounting, + ]; + const found = candidates.find(isNonEmptyRecord); + return found ?? null; +} + +export function toBigIntAmount(value: unknown): bigint | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'bigint') return value; + if (typeof value === 'number') { + return Number.isFinite(value) ? BigInt(Math.trunc(value)) : null; + } + if (typeof value === 'string') { + try { + return BigInt(value.trim()); + } catch { + return null; + } + } + return null; +} + +export function formatInteger(value: unknown): string { + const amount = toBigIntAmount(value); + return amount === null ? '-' : amount.toLocaleString(); +} + +function formatGenFromWei(wei: bigint): string { + const zero = BigInt(0); + const weiPerGen = BigInt('1000000000000000000'); + const negative = wei < zero; + const absWei = negative ? -wei : wei; + const whole = absWei / weiPerGen; + const remainder = absWei % weiPerGen; + const sign = negative ? '-' : ''; + + if (remainder === zero) return `${sign}${whole.toLocaleString()}`; + + const fraction = remainder.toString().padStart(18, '0'); + const trimmed = trimTrailingZeroes(fraction); + const decimals = Math.min(6, Math.max(3, trimmed.length)); + return `${sign}${whole.toLocaleString()}.${fraction.slice(0, decimals)}`; +} + +function trimTrailingZeroes(value: string): string { + let end = value.length; + while (end > 0 && value.charCodeAt(end - 1) === 48) { + end -= 1; + } + return value.slice(0, end); +} + +export function formatFeeAmount(value: unknown): string { + const amount = toBigIntAmount(value); + if (amount === null) return '-'; + + const raw = `${amount.toLocaleString()} wei`; + const zero = BigInt(0); + const absAmount = amount < zero ? -amount : amount; + if (absAmount < BigInt('1000000000000')) return raw; + + return `${formatGenFromWei(amount)} GEN (${raw})`; +} + +function formatFeeParamsDecodedValue(key: string, value: unknown): string { + if (Array.isArray(value)) { + return value + .map((item) => formatFeeParamsDecodedValue(key, item)) + .join(' / '); + } + + if (feeParamsDecodedFeeKeys.has(key)) return formatFeeAmount(value); + if (feeParamsDecodedIntegerKeys.has(key)) return formatInteger(value); + return String(value); +} + +export function formatFeeParamsDecoded(value: unknown): string { + const record = asRecord(value); + if (!record || Object.keys(record).length === 0) return '-'; + + const orderedKeys = [ + ...feeParamsDecodedOrder.filter((key) => key in record), + ...Object.keys(record) + .filter((key) => !(key in feeParamsDecodedLabels)) + .sort((left, right) => left.localeCompare(right)), + ]; + + const rows = orderedKeys + .filter((key) => record[key] !== undefined && record[key] !== null) + .map((key) => { + const label = feeParamsDecodedLabels[key] ?? key; + return `${label} ${formatFeeParamsDecodedValue(key, record[key])}`; + }); + + return rows.length > 0 ? rows.join(', ') : '-'; +} + +export function feeMetricRows(accounting: StudioFeeAccounting): FeeAccountingRow[] { + return [ + ['Paid fee', accounting.paid_fee_value], + ['Required fee', accounting.required_fee_value], + ['Primary budget', accounting.primary_fee_budget], + ['Primary spent', accounting.primary_fee_spent], + ['Primary refunded', accounting.primary_fee_refunded], + ['Execution budget', accounting.execution_budget_total], + ['Execution consumed', accounting.execution_fee_consumed], + ['GenVM message meter', accounting.genvm_message_fee_consumed], + ['Message budget', accounting.message_fee_budget], + ['Declared message spent', accounting.message_fee_consumed], + ['Declared message refunded', accounting.message_fee_refunded], + ['External reserved', accounting.external_message_fee_reserved], + ['External reimbursed', accounting.external_message_fee_reimbursed], + ['External remainder', accounting.external_message_fee_remainder], + ['Appeal bonds', accounting.appeal_bonds_total], + ['Total refunded', accounting.total_refunded], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: formatFeeAmount(value), + })); +} + +export function feeDistributionRows(accounting: StudioFeeAccounting): FeeAccountingRow[] { + const distribution = accounting.fees_distribution; + if (!distribution) return []; + return [ + ['Leader time units', distribution.leaderTimeunitsAllocation], + ['Validator time units', distribution.validatorTimeunitsAllocation], + ['Appeal rounds', distribution.appealRounds], + ['Rotations', distribution.rotations?.join(' / ')], + ['Execution budget per round', distribution.executionBudgetPerRound], + ['Message fee budget', distribution.totalMessageFees], + ['Max price per time unit', distribution.maxPriceGenPerTimeUnit], + ['Storage gas price', distribution.storageFeeMaxGasPrice], + ['Receipt gas price', distribution.receiptFeeMaxGasPrice], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: amountDistributionLabels.has(String(label)) + ? formatFeeAmount(value) + : String(value), + })); +} + +export function feeRecommendedPresetRows( + accounting: StudioFeeAccounting, +): FeeAccountingRow[] { + const preset = accounting.recommended_fee_preset; + const distribution = preset?.distribution; + if (!preset || !distribution) return []; + + return [ + ['Fee value', preset.feeValue], + ['Padding', preset.paddingBps ? `${formatInteger(preset.paddingBps)} bps` : null], + ['Validators', preset.numOfInitialValidators], + ['Leader time units', distribution.leaderTimeunitsAllocation], + ['Validator time units', distribution.validatorTimeunitsAllocation], + ['Appeal rounds', distribution.appealRounds], + ['Rotations', distribution.rotations?.join(' / ')], + ['Execution budget per round', distribution.executionBudgetPerRound], + ['Message fee budget', distribution.totalMessageFees], + ['Max price per time unit', distribution.maxPriceGenPerTimeUnit], + ['Storage gas price', distribution.storageFeeMaxGasPrice], + ['Receipt gas price', distribution.receiptFeeMaxGasPrice], + ['Message budget mode', preset.messageBudgetMode], + ['Message allocations', preset.messageAllocations?.length], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: recommendedPresetFeeLabels.has(String(label)) + ? formatFeeAmount(value) + : String(value), + })); +} + +export function feeRecommendedObservedRows( + accounting: StudioFeeAccounting, +): FeeAccountingRow[] { + const observed = accounting.recommended_fee_preset?.observed; + if (!observed) return []; + + return [ + ['Execution fee', observed.executionFee], + ['Message fee budget', observed.messageFeeBudget], + ['Declared message fees', observed.declaredMessageFees], + ['External reserved', observed.externalMessageReserved], + ['Estimated fee', observed.totalEstimatedFee], + ['Studio metered fee', observed.totalStudioMeteredFee], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value]) => ({ + label: String(label), + value: formatFeeAmount(value), + })); +} + +export function feeBucketRows( + bucketReport: StudioGenvmFeeBucketReport | null | undefined, +): FeeAccountingRow[] { + if (!bucketReport) return []; + + return [ + ['Receipt/nondet used', bucketReport.receiptAndNondetOutput, 'fee'], + ['Storage used', bucketReport.storage, 'fee'], + ['Total execution', bucketReport.totalExecution, 'fee'], + ['Execution budget', bucketReport.executionBudgetPerRound, 'fee'], + ['Budget remaining', bucketReport.executionBudgetRemaining, 'fee'], + ['Budget overrun', bucketReport.executionBudgetOverrun, 'fee'], + ['Budget exceeded', bucketReport.executionBudgetExceeded, 'boolean'], + ['Message meter', bucketReport.message, 'fee'], + ['Total with message', bucketReport.totalWithMessage, 'fee'], + ] + .filter(([, value]) => value !== undefined && value !== null) + .map(([label, value, kind]) => ({ + label: String(label), + value: kind === 'boolean' ? String(value) : formatFeeAmount(value), + })); +} diff --git a/explorer/src/lib/types.ts b/explorer/src/lib/types.ts index 01f7e5600..a5d4c9e18 100644 --- a/explorer/src/lib/types.ts +++ b/explorer/src/lib/types.ts @@ -52,6 +52,155 @@ export interface Transaction { worker_id: string | null; } +export interface StudioFeesDistribution { + leaderTimeunitsAllocation?: string | number; + validatorTimeunitsAllocation?: string | number; + appealRounds?: string | number; + executionBudgetPerRound?: string | number; + executionConsumed?: string | number; + totalMessageFees?: string | number; + rotations?: Array; + maxPriceGenPerTimeUnit?: string | number; + storageFeeMaxGasPrice?: string | number; + receiptFeeMaxGasPrice?: string | number; +} + +export interface StudioExecutionFeeReportMessage { + messageFeeMode?: 'mode1' | 'mode2' | 'external'; + messageType: string; + recipient: string; + value: string | number; + dataBytes: string | number; + onAcceptance: boolean; + saltNonce: string | number; + feeParams?: string; + feeParamsDecoded?: Record> | null; + feeParamsBytes: string | number; + declaredBudget: string | number; + allocationSubtree?: string; + allocationSubtreeBytes: string | number; + callKey: string; +} + +export interface StudioGenvmFeeBucket { + index?: string | number; + name?: string; + consumed?: string | number; +} + +export interface StudioGenvmFeeBucketReport { + receiptAndNondetOutput?: string | number; + storage?: string | number; + message?: string | number; + totalExecution?: string | number; + totalWithMessage?: string | number; + executionBudgetPerRound?: string | number; + executionBudgetRemaining?: string | number; + executionBudgetOverrun?: string | number; + executionBudgetExceeded?: boolean; + buckets?: StudioGenvmFeeBucket[]; +} + +export interface StudioExecutionFeeReport { + receiptGasPrice?: string | number; + budgetExhaustionReason?: string | null; + proposalReceipt?: { + eqBlocksOutputsLength?: string | number; + receiptBytes?: string | number; + estimatedGas?: string | number; + fee?: string | number; + }; + messageReveal?: { + messageBytes?: string | number; + messageCount?: string | number; + estimatedGas?: string | number; + fee?: string | number; + consensusAdditionalGas?: string | number; + consensusAdditionalFee?: string | number; + studioFixedOverheadGas?: string | number; + studioFixedOverheadFee?: string | number; + messages?: StudioExecutionFeeReportMessage[]; + }; + genvmBuckets?: StudioGenvmFeeBucketReport; + chargeableExecution?: StudioGenvmFeeBucketReport; + executionMetering?: { + chargeableExecutionFee?: string | number; + genvmReportedExecution?: string | number; + genvmDeltaFromChargeable?: string | number; + }; + messageFees?: { + budget?: string | number; + declaredConsumed?: string | number; + genvmMeteredConsumed?: string | number; + externalReserved?: string | number; + externalReimbursed?: string | number; + externalRemainder?: string | number; + totalConsumed?: string | number; + declaredRefunded?: string | number; + remaining?: string | number; + meteringDelta?: string | number; + reportedTotal?: string | number; + }; + totalEstimatedFee?: string | number; + totalStudioMeteredFee?: string | number; +} + +export interface StudioRecommendedFeePreset { + source?: string; + paddingBps?: string | number; + numOfInitialValidators?: string | number; + distribution?: StudioFeesDistribution; + feeValue?: string | number; + messageAllocations?: unknown[]; + messageBudgetMode?: + | 'current' + | 'observed' + | 'allocation-preserved' + | (string & {}); + observed?: { + executionFee?: string | number; + messageFeeBudget?: string | number; + declaredMessageFees?: string | number; + externalMessageReserved?: string | number; + totalEstimatedFee?: string | number; + totalStudioMeteredFee?: string | number; + }; +} + +export interface StudioFeeAccounting { + version?: string | number; + source?: string; + status?: string; + paid_fee_value?: string | number; + required_fee_value?: string | number; + primary_fee_required?: string | number; + primary_fee_budget?: string | number; + primary_fee_spent?: string | number; + primary_fee_refunded?: string | number; + execution_budget_total?: string | number; + execution_fee_consumed?: string | number; + execution_fee_consumed_buckets?: Array; + genvm_fee_consumed_buckets?: Array; + genvm_fee_bucket_report?: StudioGenvmFeeBucketReport; + genvm_message_fee_consumed?: string | number; + execution_fee_report?: StudioExecutionFeeReport; + recommended_fee_preset?: StudioRecommendedFeePreset; + message_fee_budget?: string | number; + message_fee_consumed?: string | number; + message_fee_refunded?: string | number; + external_message_fee_reserved?: string | number; + external_message_fee_reimbursed?: string | number; + external_message_fee_remainder?: string | number; + appeal_bonds_total?: string | number; + total_refunded?: string | number; + fees_distribution?: StudioFeesDistribution; + message_allocations?: unknown[]; + allocation_consumed?: Record; + message_consumption_events?: unknown[]; + refunds?: unknown[]; + top_ups?: unknown[]; +} + export interface ConsensusHistoryEntry { // Legacy format leader?: ValidatorVote; diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..9ca2a9ec1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,64 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": [ + "systems" + ] + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..3340d7064 --- /dev/null +++ b/flake.nix @@ -0,0 +1,32 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + systems = { + url = "github:nix-systems/default"; + }; + flake-utils = { + url = "github:numtide/flake-utils"; + inputs.systems.follows = "systems"; + }; + }; + + outputs = inputs@{ self, nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem + (system: + let + pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + python312 + pre-commit + nodejs + ]; + + shellHook = '' + ''; + }; + } + ); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f387ec1e8..91d0cc7e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "^1.1.1", + "genlayer-js": "^1.1.8", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", @@ -211,6 +211,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -680,7 +681,6 @@ "integrity": "sha512-B3e0XiZWHXgCPLRXk0dDGA2WN8eFk/MDprqRX1Xl4PPx1LAdzynGcGUg6rnidMrIQ/GSL+oelWDHdGbWtCOOoA==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@coinbase/cdp-sdk": "^1.0.0", "brotli-wasm": "^3.0.0", @@ -699,7 +699,6 @@ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -719,7 +718,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", @@ -877,6 +875,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -917,6 +916,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -927,6 +927,7 @@ "integrity": "sha512-wxr+2gpjKRZ1eVBLhQYJxImDsRukk0DvCsEElkTMyybP+7SamWRs48o3DYE6VLEgQJFZgOoUec3t5FM5s1J1ww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bs58": "^5.0.0" } @@ -954,6 +955,7 @@ "integrity": "sha512-NXUmQV1f7PQ5/M4gEDKZmjEwSD//MNMXloKRc7X08DV2mLkuKUMjdFS7Klby3sLPqfBomRIy6Tk3kvbRXCaV/A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1520,7 +1522,6 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", @@ -1544,7 +1545,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1560,15 +1560,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1579,7 +1577,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1592,7 +1589,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -1609,15 +1605,13 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1630,7 +1624,6 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "license": "MIT", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -2772,6 +2765,7 @@ "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.1.tgz", "integrity": "sha512-QORZRjcuTKgo++XP1Pc2c2gqwRydkaExrIRfRI9vFsPA3AzuHVn5Gfmbv1ic8y34e78mr5DMBvJlelUaeOuajg==", "license": "MIT", + "peer": true, "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", @@ -2875,6 +2869,7 @@ "integrity": "sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.48.2" }, @@ -4006,48 +4001,6 @@ } } }, - "node_modules/@solana/kit": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", - "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@solana/accounts": "5.5.1", - "@solana/addresses": "5.5.1", - "@solana/codecs": "5.5.1", - "@solana/errors": "5.5.1", - "@solana/functional": "5.5.1", - "@solana/instruction-plans": "5.5.1", - "@solana/instructions": "5.5.1", - "@solana/keys": "5.5.1", - "@solana/offchain-messages": "5.5.1", - "@solana/plugin-core": "5.5.1", - "@solana/programs": "5.5.1", - "@solana/rpc": "5.5.1", - "@solana/rpc-api": "5.5.1", - "@solana/rpc-parsed-types": "5.5.1", - "@solana/rpc-spec-types": "5.5.1", - "@solana/rpc-subscriptions": "5.5.1", - "@solana/rpc-types": "5.5.1", - "@solana/signers": "5.5.1", - "@solana/sysvars": "5.5.1", - "@solana/transaction-confirmation": "5.5.1", - "@solana/transaction-messages": "5.5.1", - "@solana/transactions": "5.5.1" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@solana/nominal-types": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@solana/nominal-types/-/nominal-types-5.5.1.tgz", @@ -4927,6 +4880,7 @@ "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -5868,17 +5822,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, "node_modules/@types/selenium-webdriver": { "version": "4.35.5", "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.35.5.tgz", @@ -6042,6 +5985,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -6573,6 +6517,7 @@ "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.18", "fflate": "^0.8.2", @@ -7106,6 +7051,7 @@ "resolved": "https://registry.npmjs.org/@wagmi/core/-/core-3.4.0.tgz", "integrity": "sha512-EU5gDsUp5t7+cuLv12/L8hfyWfCIKsBNiiBqpOqxZJxvAcAiQk4xFe2jMgaQPqApc3Omvxrk032M8AQ4N0cQeg==", "license": "MIT", + "peer": true, "dependencies": { "eventemitter3": "5.0.1", "mipd": "0.0.7", @@ -7324,21 +7270,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", @@ -7669,6 +7600,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8118,6 +8050,7 @@ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -8355,7 +8288,6 @@ "integrity": "sha512-U3K72/JAi3jITpdhZBqzSUq+DUY697tLxOuFXB+FpAE/Ug+5C3VZrv4uA674EUZHxNAuQ9wETXNqQkxZD6oL4A==", "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": ">=v18.0.0" } @@ -8525,6 +8457,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8598,21 +8531,6 @@ "node": ">=0.2.0" } }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -8789,7 +8707,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -10237,6 +10154,7 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10299,6 +10217,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.2.tgz", "integrity": "sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -10355,6 +10274,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -10471,6 +10391,7 @@ "integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -10654,6 +10575,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@ethersproject/abi": "5.8.0", "@ethersproject/abstract-provider": "5.8.0", @@ -11157,9 +11079,9 @@ } }, "node_modules/genlayer-js": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.1.tgz", - "integrity": "sha512-DNKfr/E0eDigBHZ6dUx3ViCm67UJzsA9qTEmsBBPP12EnNwslo8xBobjKG2SLEni0kWVDRo8aJGeg2TBgeUy7w==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.8.tgz", + "integrity": "sha512-qlqh8oqR9Ad7FVbIdqIrHfsMPLLJ24ZRHUZ2LGMpw6DX5ySjrEWdV1X93bVIHO44cu9CLGdx8m2ubkPv78/RLg==", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -11172,7 +11094,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -11187,7 +11108,6 @@ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0" }, @@ -11200,7 +11120,6 @@ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -11213,7 +11132,6 @@ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -11223,7 +11141,6 @@ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -11237,7 +11154,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11254,7 +11170,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -11286,7 +11201,6 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -11405,7 +11319,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -11422,7 +11335,6 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -11435,7 +11347,6 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -11452,8 +11363,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/genlayer-js/node_modules/minimatch": { "version": "3.1.5", @@ -11677,7 +11587,6 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -12012,21 +11921,6 @@ } } }, - "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -12158,7 +12052,6 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -12940,21 +12833,6 @@ "license": "MIT", "optional": true }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -12992,6 +12870,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -13152,21 +13031,6 @@ } } }, - "node_modules/jsdom/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/jsdom/node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -13491,8 +13355,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.sortby": { "version": "4.7.0", @@ -13600,6 +13463,7 @@ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -14148,7 +14012,6 @@ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", "license": "MIT", "optional": true, - "peer": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -14678,7 +14541,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", @@ -14704,7 +14566,6 @@ "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@noble/hashes": "1.8.0" }, @@ -14721,7 +14582,6 @@ "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -14735,7 +14595,6 @@ "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", "license": "MIT", "optional": true, - "peer": true, "funding": { "url": "https://github.com/sponsors/wevm" }, @@ -14810,7 +14669,6 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -15232,6 +15090,7 @@ "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -15291,6 +15150,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -15469,6 +15329,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -16169,7 +16030,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -16892,7 +16752,8 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/source-map": { "version": "0.6.1", @@ -17454,6 +17315,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -17687,6 +17549,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18551,6 +18414,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -18664,6 +18528,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19108,6 +18973,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -19119,7 +18985,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -19216,6 +19081,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -19340,6 +19206,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -19619,6 +19486,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19632,6 +19500,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -19736,6 +19605,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", @@ -19981,6 +19851,7 @@ "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-3.5.0.tgz", "integrity": "sha512-39uiY6Vkc28NiAHrxJzVTodoRgSVGG97EewwUxRf+jcFMTe8toAnaM8pJZA3Zw/6snMg4tSgWLJAtMnOacLe7w==", "license": "MIT", + "peer": true, "dependencies": { "@wagmi/connectors": "7.2.1", "@wagmi/core": "3.4.0", @@ -20080,21 +19951,6 @@ } } }, - "node_modules/whatwg-url/node_modules/@noble/hashes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", - "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -20350,6 +20206,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -20550,16 +20407,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "optional": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/zustand": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", @@ -20589,6 +20436,161 @@ "optional": true } } + }, + "node_modules/@solana/kit": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@solana/kit/-/kit-5.5.1.tgz", + "integrity": "sha512-irKUGiV2yRoyf+4eGQ/ZeCRxa43yjFEL1DUI5B0DkcfZw3cr0VJtVJnrG8OtVF01vT0OUfYOcUn6zJW5TROHvQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@solana/accounts": "5.5.1", + "@solana/addresses": "5.5.1", + "@solana/codecs": "5.5.1", + "@solana/errors": "5.5.1", + "@solana/functional": "5.5.1", + "@solana/instruction-plans": "5.5.1", + "@solana/instructions": "5.5.1", + "@solana/keys": "5.5.1", + "@solana/offchain-messages": "5.5.1", + "@solana/plugin-core": "5.5.1", + "@solana/programs": "5.5.1", + "@solana/rpc": "5.5.1", + "@solana/rpc-api": "5.5.1", + "@solana/rpc-parsed-types": "5.5.1", + "@solana/rpc-spec-types": "5.5.1", + "@solana/rpc-subscriptions": "5.5.1", + "@solana/rpc-types": "5.5.1", + "@solana/signers": "5.5.1", + "@solana/sysvars": "5.5.1", + "@solana/transaction-confirmation": "5.5.1", + "@solana/transaction-messages": "5.5.1", + "@solana/transactions": "5.5.1" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@types/react": { + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/html-encoding-sniffer/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/jsdom/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/whatwg-url/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/jayson/node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 59cddd934..e114bc534 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,7 +39,7 @@ "cross-env": "^10.0.0", "dexie": "^4.0.4", "floating-vue": "^5.2.2", - "genlayer-js": "^1.1.1", + "genlayer-js": "^1.1.8", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", diff --git a/frontend/src/components/Simulator/AccountSelect.vue b/frontend/src/components/Simulator/AccountSelect.vue index cfb007fc7..d5ab32793 100644 --- a/frontend/src/components/Simulator/AccountSelect.vue +++ b/frontend/src/components/Simulator/AccountSelect.vue @@ -6,6 +6,7 @@ import { Wallet, Droplets } from 'lucide-vue-next'; import { PlusIcon } from '@heroicons/vue/16/solid'; import { notify } from '@kyvg/vue3-notification'; import { useEventTracking, useWallet, useRpcClient } from '@/hooks'; +import { parseGenAmountToWei } from '@/utils/tokenAmount'; import { computed, ref, watch, onMounted } from 'vue'; const store = useAccountsStore(); @@ -72,21 +73,20 @@ const handleCreateNewAccount = async () => { const handleFundAccount = async () => { if (!store.selectedAccount?.address) return; - const amount = parseFloat(faucetAmount.value); - if (isNaN(amount) || amount <= 0) { + const weiAmount = parseGenAmountToWei(faucetAmount.value); + if (weiAmount === null || weiAmount <= 0n) { notify({ title: 'Enter a valid amount', type: 'error' }); return; } isFunding.value = true; try { - const weiAmount = BigInt(Math.floor(amount * 1e18)); await rpcClient.fundAccount( store.selectedAccount.address, - Number(weiAmount), + weiAmount.toString(), ); notify({ - title: `Funded ${amount} GEN`, + title: `Funded ${faucetAmount.value.trim()} GEN`, type: 'success', }); showFaucet.value = false; diff --git a/frontend/src/components/Simulator/ContractMethodItem.vue b/frontend/src/components/Simulator/ContractMethodItem.vue index 09d536d8b..969dc6e47 100644 --- a/frontend/src/components/Simulator/ContractMethodItem.vue +++ b/frontend/src/components/Simulator/ContractMethodItem.vue @@ -2,17 +2,27 @@ import type { ContractMethod } from 'genlayer-js/types'; import { abi } from 'genlayer-js'; import { TransactionHashVariant } from 'genlayer-js/types'; -import { ref } from 'vue'; +import { computed, ref } from 'vue'; import { Collapse } from 'vue-collapsed'; import { notify } from '@kyvg/vue3-notification'; import { ChevronDownIcon } from '@heroicons/vue/16/solid'; import { useEventTracking, useContractQueries } from '@/hooks'; import { unfoldArgsData, type ArgData } from './ContractParams'; import ContractParams from './ContractParams.vue'; -import type { ExecutionMode, ReadStateMode } from '@/types'; +import type { + ExecutionMode, + ReadStateMode, + StudioExecutionFeeReportMessage, + StudioFeeEstimateResult, +} from '@/types'; -const { callWriteMethod, callReadMethod, simulateWriteMethod, contract } = - useContractQueries(); +const { + callWriteMethod, + callReadMethod, + simulateWriteMethod, + estimateWriteMethodFees, + contract, +} = useContractQueries(); const { trackEvent } = useEventTracking(); const props = defineProps<{ @@ -27,12 +37,27 @@ const props = defineProps<{ const isExpanded = ref(false); const isCalling = ref(false); +const isEstimatingFees = ref(false); const responseMessage = ref(''); const responseMessageAccepted = ref(''); const responseMessageFinalized = ref(''); +const feeEstimateMessage = ref(''); +const feeEstimateResult = ref(null); const calldataArguments = ref({ args: [], kwargs: {} }); const payableValue = ref(''); +const WEI_PER_GEN = BigInt('1000000000000000000'); + +type FeeEstimateRow = { + label: string; + value: string; +}; + +function payableValueWei(): bigint | undefined { + return props.method.payable && payableValue.value + ? BigInt(payableValue.value) * WEI_PER_GEN + : undefined; +} const formatResponseIfNeeded = (response: string): string => { if (!response) { @@ -62,6 +87,309 @@ const formatResponseIfNeeded = (response: string): string => { return response; }; +const formatIntegerLike = ( + value: string | number | bigint | boolean | null | undefined, +): string => { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + const raw = String(value); + return /^-?\d+$/.test(raw) ? BigInt(raw).toLocaleString('en-US') : raw; +}; + +const formatFeeAmount = ( + value: string | number | bigint | null | undefined, +): string => { + const formatted = formatIntegerLike(value); + return formatted ? `${formatted} wei` : ''; +}; + +const formatPaddingBps = ( + value: string | number | bigint | null | undefined, +): string => { + if (value === undefined || value === null) { + return ''; + } + return `${formatIntegerLike(value)} bps`; +}; + +const formatRotations = (rotations: unknown): string => { + if (!Array.isArray(rotations)) { + return ''; + } + return rotations.map((rotation) => formatIntegerLike(rotation)).join(', '); +}; + +const feeParamsDecodedLabels: Record = { + leaderTimeunitsAllocation: 'Leader', + validatorTimeunitsAllocation: 'Validator', + appealRounds: 'Appeals', + executionBudgetPerRound: 'Exec budget', + rotations: 'Rotations', + gasLimit: 'Gas limit', + maxGasPrice: 'Max gas price', +}; + +const feeParamsDecodedOrder = Object.keys(feeParamsDecodedLabels); +const feeParamsDecodedFeeKeys = new Set([ + 'executionBudgetPerRound', + 'maxGasPrice', +]); +const feeParamsDecodedIntegerKeys = new Set([ + 'leaderTimeunitsAllocation', + 'validatorTimeunitsAllocation', + 'appealRounds', + 'gasLimit', + 'rotations', +]); + +const formatFeeParamsDecodedValue = (key: string, value: unknown): string => { + if (Array.isArray(value)) { + return value + .map((item) => formatFeeParamsDecodedValue(key, item)) + .join(' / '); + } + + if (feeParamsDecodedFeeKeys.has(key)) { + return formatFeeAmount(value as string | number | bigint); + } + if (feeParamsDecodedIntegerKeys.has(key)) { + return formatIntegerLike(value as string | number | bigint); + } + return String(value); +}; + +const formatFeeParamsDecoded = (value: unknown): string => { + if (!value || typeof value !== 'object') { + return ''; + } + + const record = value as Record; + const keys = Object.keys(record); + if (keys.length === 0) { + return ''; + } + + const orderedKeys = [ + ...feeParamsDecodedOrder.filter((key) => key in record), + ...keys + .filter((key) => !(key in feeParamsDecodedLabels)) + .sort((left, right) => left.localeCompare(right)), + ]; + return orderedKeys + .filter((key) => record[key] !== undefined && record[key] !== null) + .map((key) => { + const label = feeParamsDecodedLabels[key] ?? key; + return `${label} ${formatFeeParamsDecodedValue(key, record[key])}`; + }) + .join(', '); +}; + +const shortHex = (value: string | undefined, start = 8, end = 6): string => { + if (!value) { + return ''; + } + if (value.length <= start + end) { + return value; + } + return `${value.slice(0, start)}...${value.slice(-end)}`; +}; + +const addFeeEstimateRow = ( + rows: FeeEstimateRow[], + label: string, + value: string, +) => { + if (value !== '') { + rows.push({ label, value }); + } +}; + +const feeEstimateRows = computed(() => { + const result = feeEstimateResult.value; + if (!result) { + return []; + } + + const preset = result.recommendedPreset; + const distribution = preset?.distribution; + const observed = preset?.observed; + const report = result.feeReport; + const messageFees = report?.messageFees; + const metering = report?.executionMetering; + const chargeable = report?.chargeableExecution; + const proposalReceipt = report?.proposalReceipt; + const messageReveal = report?.messageReveal; + const rows: FeeEstimateRow[] = []; + + addFeeEstimateRow(rows, 'Scenario', result.scenario ?? ''); + addFeeEstimateRow( + rows, + 'Recommended fee value', + formatFeeAmount(preset?.feeValue), + ); + addFeeEstimateRow( + rows, + 'Execution budget / round', + formatFeeAmount(distribution?.executionBudgetPerRound), + ); + addFeeEstimateRow( + rows, + 'Leader time units', + formatIntegerLike(distribution?.leaderTimeunitsAllocation), + ); + addFeeEstimateRow( + rows, + 'Validator time units', + formatIntegerLike(distribution?.validatorTimeunitsAllocation), + ); + addFeeEstimateRow( + rows, + 'Message fee budget', + formatFeeAmount(distribution?.totalMessageFees), + ); + addFeeEstimateRow( + rows, + 'Appeal rounds', + formatIntegerLike(distribution?.appealRounds), + ); + addFeeEstimateRow( + rows, + 'Rotations', + formatRotations(distribution?.rotations), + ); + addFeeEstimateRow( + rows, + 'Max GEN / time unit', + formatFeeAmount(distribution?.maxPriceGenPerTimeUnit), + ); + addFeeEstimateRow( + rows, + 'Storage gas price', + formatFeeAmount(distribution?.storageFeeMaxGasPrice), + ); + addFeeEstimateRow( + rows, + 'Receipt gas price', + formatFeeAmount(distribution?.receiptFeeMaxGasPrice), + ); + addFeeEstimateRow( + rows, + 'Proposal receipt bytes', + formatIntegerLike(proposalReceipt?.receiptBytes), + ); + addFeeEstimateRow( + rows, + 'Proposal receipt gas', + formatIntegerLike(proposalReceipt?.estimatedGas), + ); + addFeeEstimateRow( + rows, + 'Message count', + formatIntegerLike(messageReveal?.messageCount), + ); + addFeeEstimateRow( + rows, + 'Message bytes', + formatIntegerLike(messageReveal?.messageBytes), + ); + addFeeEstimateRow( + rows, + 'Message reveal gas', + formatIntegerLike(messageReveal?.estimatedGas), + ); + addFeeEstimateRow(rows, 'Padding', formatPaddingBps(preset?.paddingBps)); + addFeeEstimateRow( + rows, + 'Message budget mode', + preset?.messageBudgetMode ?? '', + ); + addFeeEstimateRow( + rows, + 'Observed execution', + formatFeeAmount(observed?.executionFee), + ); + addFeeEstimateRow( + rows, + 'Observed message budget', + formatFeeAmount(observed?.messageFeeBudget), + ); + addFeeEstimateRow( + rows, + 'Observed external reserve', + formatFeeAmount(observed?.externalMessageReserved), + ); + addFeeEstimateRow( + rows, + 'Total estimated fee', + formatFeeAmount(report?.totalEstimatedFee), + ); + addFeeEstimateRow( + rows, + 'Chargeable execution', + formatFeeAmount(metering?.chargeableExecutionFee), + ); + addFeeEstimateRow( + rows, + 'Chargeable storage', + formatFeeAmount(chargeable?.storage), + ); + addFeeEstimateRow( + rows, + 'Chargeable receipt/non-det', + formatFeeAmount(chargeable?.receiptAndNondetOutput), + ); + addFeeEstimateRow( + rows, + 'Chargeable message', + formatFeeAmount(chargeable?.message), + ); + addFeeEstimateRow( + rows, + 'GenVM raw execution', + formatFeeAmount(metering?.genvmReportedExecution), + ); + addFeeEstimateRow( + rows, + 'Message fees spent', + formatFeeAmount(messageFees?.declaredConsumed), + ); + addFeeEstimateRow( + rows, + 'GenVM metered message', + formatFeeAmount(messageFees?.genvmMeteredConsumed), + ); + addFeeEstimateRow( + rows, + 'External message reserved', + formatFeeAmount(messageFees?.externalReserved), + ); + addFeeEstimateRow( + rows, + 'External message reimbursed', + formatFeeAmount(messageFees?.externalReimbursed), + ); + addFeeEstimateRow( + rows, + 'External message remainder', + formatFeeAmount(messageFees?.externalRemainder), + ); + addFeeEstimateRow( + rows, + 'Message fees remaining', + formatFeeAmount(messageFees?.remaining), + ); + + return rows; +}); + +const feeEstimateMessages = computed(() => { + return feeEstimateResult.value?.feeReport?.messageReveal?.messages ?? []; +}); + const handleCallReadMethod = async () => { responseMessage.value = ''; isCalling.value = true; @@ -107,11 +435,7 @@ const handleCallWriteMethod = async () => { responseMessageAccepted.value = ''; responseMessageFinalized.value = ''; - const WEI_PER_GEN = BigInt('1000000000000000000'); - const simValue = - props.method.payable && payableValue.value - ? BigInt(payableValue.value) * WEI_PER_GEN - : undefined; + const simValue = payableValueWei(); const result = await simulateWriteMethod({ method: props.name, args: unfoldArgsData({ @@ -137,11 +461,7 @@ const handleCallWriteMethod = async () => { } else { // Real transaction mode // User inputs GEN, convert to wei (1 GEN = 10^18 wei) - const WEI_PER_GEN = BigInt('1000000000000000000'); - const txValue = - props.method.payable && payableValue.value - ? BigInt(payableValue.value) * WEI_PER_GEN - : BigInt(0); + const txValue = payableValueWei() ?? BigInt(0); await callWriteMethod({ method: props.name, executionMode: props.executionMode, @@ -173,6 +493,52 @@ const handleCallWriteMethod = async () => { isCalling.value = false; } }; + +const handleEstimateFees = async () => { + isEstimatingFees.value = true; + feeEstimateMessage.value = ''; + feeEstimateResult.value = null; + + try { + const result = await estimateWriteMethodFees({ + method: props.name, + args: unfoldArgsData({ + args: calldataArguments.value.args, + kwargs: calldataArguments.value.kwargs, + }), + value: payableValueWei(), + }); + + feeEstimateResult.value = result; + feeEstimateMessage.value = JSON.stringify( + { + scenario: result.scenario, + feeReport: result.feeReport, + recommendedPreset: result.recommendedPreset, + }, + null, + 2, + ); + + notify({ + text: 'Fee estimate completed', + type: 'success', + }); + + trackEvent('estimated_write_method_fees', { + contract_name: contract.value?.name || '', + method_name: props.name, + }); + } catch (error) { + notify({ + title: 'Error', + text: (error as Error)?.message || 'Error estimating transaction fees', + type: 'error', + }); + } finally { + isEstimatingFees.value = false; + } +};