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..d31f966cd 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,6 @@ DBPASSWORD='postgres' DBPORT='5432' DATABASE_POOL_SIZE='20' # Database connection pool size DATABASE_MAX_OVERFLOW='10' # Maximum overflow connections -# Set the compose profile to 'hardhat' to use the hardhat network REMOTE_DATABASE='false' ######################################## @@ -16,6 +15,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 @@ -26,7 +26,6 @@ SERVER_NAME='studio.genlayer.com' ######################################## # Compose and Build -# Set the compose profile to 'hardhat' to use the hardhat network COMPOSE_PROFILES='' FRONTEND_BUILD_TARGET='dev' # change to 'dev' to run in dev mode BACKEND_BUILD_TARGET='debug' # change to 'prod' or remove to run in prod mode @@ -36,9 +35,52 @@ 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="./docker/empty.netrc" # 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='1' # v0.6 neutral time-unit multiplier +GENLAYER_STUDIO_STORAGE_UNIT_PRICE='250000000' # v0.6 default: 0.25 gwei +GENLAYER_STUDIO_RECEIPT_GAS_PRICE='250000000' # v0.6 default: 0.25 gwei +# Combined developer + DAO share of gross time-unit fees (15% by default). +GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS='1500' +# v0.6 Idleness phase bounds (PHASE_TIMEOUT_BOUNDS=30,600,30,600). +GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS='30' +GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS='600' +GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS='30' +GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS='600' +# Conservative gas cap used when transaction simulation discovers an external +# message whose exact downstream gas usage is unknowable at submission time. +GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT='500000' +# Governance-controlled external allocation gas-limit floor; 0 disables it. +GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT='0' +# Messages has an independent hard protocol ceiling (20 maximum); the optional +# FeeManager cap may lower it further when non-zero. +GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES='20' +GENLAYER_STUDIO_MAX_MESSAGES_PER_TX='0' +# Governance-controlled receipt/output admission caps; 0 disables them. +GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES='0' +GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES='0' +GENLAYER_STUDIO_FIXED_PROPOSE_RECEIPT_GAS='210000' +GENLAYER_STUDIO_FIXED_MESSAGE_REVEAL_GAS='100000' +GENLAYER_STUDIO_RECEIPT_WRAPPER_BYTES='1024' +# Studio's virtual v0.6 GhostFactory deployment. These defaults are stable +# across RPC replicas. Override all three only when intentionally mirroring a +# concrete Consensus deployment (GhostFactory, CreationPhase, bytecodeEVM). +GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS='0x130e09c996462963A6398CA04e1011e6ef9d68a6' +GENLAYER_STUDIO_GHOST_BYTECODE_HASH='0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de' +GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE='2' + # Ollama Server Configuration OLAMAPORT='11434' @@ -52,12 +94,13 @@ WEBDRIVER_TIMEOUT_MS="60000" WEB_RENDER_RETRIES="2" ######################################## -# Consensus Rollup -# Hardhat port -HARDHAT_URL='http://hardhat' +# Optional legacy Hardhat inspection bridge. Studio protocol state and ghost +# addresses are DB-authoritative and do not require this service. +HARDHAT_URL='' HARDHAT_PORT='8545' -HARDHAT_PRIVATE_KEY='0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' -GENLAYER_CHAIN_ID='61999' +# Keep this aligned with the SDK localnet preset. Existing pre-v0.123 .env +# files may still contain 61999 and must be updated before restarting Studio. +GENLAYER_CHAIN_ID='61127' ######################################## # LLM Providers Configuration @@ -96,12 +139,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) +# The per-recipient cap defaults to Consensus Queues' 20 even when empty; +# the Studio-only per-sender cap remains disabled unless explicitly set. +MAX_PENDING_PER_CONTRACT_DEFAULT='' # Empty = protocol default 20 +MAX_PENDING_PER_SENDER_DEFAULT='' # Empty = disabled +# 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 +194,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..91e3e6823 --- /dev/null +++ b/.github/scripts/install-python-dependencies.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +python -m pip install --only-binary :all: -r requirements.txt +# The prerelease qualification cut pins the SDK and test harness to immutable +# Git commits until their coordinated package versions are published. +python -m pip install -r requirements.test.txt +python -m pip install --only-binary :all: -r backend/requirements.txt diff --git a/.github/scripts/resolve-release-policy.sh b/.github/scripts/resolve-release-policy.sh new file mode 100755 index 000000000..df218cbcf --- /dev/null +++ b/.github/scripts/resolve-release-policy.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 " >&2 + echo "Fields: version-branch, channel, push-latest, deployment-event" >&2 + exit 2 +} + +tag="${1:-}" +field="${2:-}" + +if [[ -z "${tag}" || -z "${field}" ]]; then + usage +fi + +if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*)?$ ]]; then + echo "Invalid release tag: ${tag}" >&2 + exit 1 +fi + +release_branch="$(sed -E 's/^((v[0-9]+)\.([0-9]+))\..*$/\1/' <<< "${tag}")" + +if [[ "${tag}" == *-* ]]; then + version_branch="${release_branch}-dev" + channel="preview" + push_latest="false" + deployment_event="release-backend-preview" +else + version_branch="${release_branch}" + channel="stable" + push_latest="true" + deployment_event="release-backend" +fi + +case "${field}" in + version-branch) + echo "${version_branch}" + ;; + channel) + echo "${channel}" + ;; + push-latest) + echo "${push_latest}" + ;; + deployment-event) + echo "${deployment_event}" + ;; + *) + usage + ;; +esac diff --git a/.github/scripts/validate-branch-policy.sh b/.github/scripts/validate-branch-policy.sh new file mode 100755 index 000000000..04da22e5a --- /dev/null +++ b/.github/scripts/validate-branch-policy.sh @@ -0,0 +1,112 @@ +#!/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 + if ! grep -Fq 'release-backend-preview' .github/scripts/resolve-release-policy.sh || \ + ! grep -Fq 'push_latest' .github/workflows/release-from-tag.yml; then + error "Prereleases must use the preview deployment event and must control latest image publication." + 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 + if ! grep -Fq 'resolve-release-policy.sh' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must use the shared stable/prerelease branch policy." + 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..8fb16ef51 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1 +1,24 @@ -# 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` + +Release tags have two explicit channels: + +- stable `vX.Y.Z` tags must point at the current `vX.Y` branch head, publish + versioned and `latest` images, and enter the stable release lane +- prerelease `vX.Y.Z-` tags must point at the current `vX.Y-dev` + branch head, publish only versioned images, and update the preview deployment + +`manual-docker-release.yml` applies the same rules when it creates a tag. + +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..e5bdd97ab 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,9 @@ jobs: dockerhub_username: ${{ vars.DOCKERHUB_USERNAME }} ref: ${{ inputs.ref }} image_tag: ${{ inputs.image_tag }} + push_latest: ${{ inputs.push_latest }} + build_args: | + VITE_APP_VERSION=${{ inputs.image_tag }} secrets: dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} @@ -57,6 +74,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 +82,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 +101,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..ba509dd14 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,16 @@ jobs: "${DOCKERHUB_REPO}:${IMAGE_TAG}-arm64" docker manifest push "${DOCKERHUB_REPO}:${IMAGE_TAG}" + if [[ "${IMAGE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+- ]]; then + echo "Skipping latest manifest push for prerelease ${IMAGE_TAG}" + exit 0 + fi + + 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..75044aea6 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 runners (5jycge4q... and babwp9t3...) fail because their + # archives are not bundled. Skip them + # 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|babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0)" "$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|babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0)" "$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..17853618f 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 @@ -229,7 +352,7 @@ jobs: sleep 15 # Install required Python packages - pip install genlayer-py requests + pip install -r ../../requirements.test.txt # Deploy contracts one by one with delays echo "=== Contract Deployment Test ===" @@ -489,7 +612,7 @@ jobs: - name: Run State Integrity Test (lost update detection) run: | cd tests/load - pip install genlayer-py requests + pip install -r ../../requirements.test.txt python3 test_state_integrity.py http://localhost:4000/api --txs 20 - name: Dump Docker Compose logs diff --git a/.github/workflows/manual-docker-release.yml b/.github/workflows/manual-docker-release.yml index f1b4cc018..cde91af04 100644 --- a/.github/workflows/manual-docker-release.yml +++ b/.github/workflows/manual-docker-release.yml @@ -4,11 +4,11 @@ on: workflow_dispatch: inputs: branch: - description: 'Version branch to tag (e.g., v0.121)' + description: 'Version branch to tag (e.g., v0.123 or v0.123-dev for a prerelease)' required: true type: string version: - description: 'Version tag (e.g., v0.81.0)' + description: 'Version tag (e.g., v0.123.0 or v0.123.0-rc.1)' required: true type: string @@ -20,17 +20,18 @@ jobs: name: Create Release Tag runs-on: ubuntu-latest steps: + - name: Checkout release policy + uses: actions/checkout@v6 + with: + ref: ${{ github.sha }} + - name: Validate version format env: RELEASE_BRANCH: ${{ github.event.inputs.branch }} RELEASE_VERSION: ${{ github.event.inputs.version }} run: | set -euo pipefail - if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9-]+)?$ ]]; then - echo "Error: Version must be in format v*.*.*[-suffix] (e.g., v0.81.0 or v0.81.0-beta)" - exit 1 - fi - expected_branch="$(sed -E 's/^((v[0-9]+)\.([0-9]+))\..*$/\1/' <<< "$RELEASE_VERSION")" + expected_branch="$(./.github/scripts/resolve-release-policy.sh "$RELEASE_VERSION" version-branch)" if [[ "$RELEASE_BRANCH" != "$expected_branch" ]]; then echo "Error: version $RELEASE_VERSION must be created from branch ${expected_branch}" exit 1 @@ -66,11 +67,13 @@ jobs: fi release-from-tag: - name: Build Images and Trigger Release Lane + name: Build Images and Trigger Deployment needs: create-release-tag permissions: contents: read 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..da84557b9 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 @@ -34,6 +39,9 @@ jobs: tag: ${{ steps.validate.outputs.tag }} commit: ${{ steps.validate.outputs.commit }} version_branch: ${{ steps.validate.outputs.version_branch }} + release_channel: ${{ steps.validate.outputs.release_channel }} + push_latest: ${{ steps.validate.outputs.push_latest }} + deployment_event: ${{ steps.validate.outputs.deployment_event }} steps: - name: Checkout uses: actions/checkout@v6 @@ -48,12 +56,10 @@ jobs: set -euo pipefail tag="${INPUT_TAG:-${GITHUB_REF_NAME}}" - if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9-]+)?$ ]]; then - echo "Invalid release tag: $tag" >&2 - exit 1 - fi - - version_branch="$(sed -E 's/^((v[0-9]+)\.([0-9]+))\..*$/\1/' <<< "$tag")" + version_branch="$(./.github/scripts/resolve-release-policy.sh "$tag" version-branch)" + release_channel="$(./.github/scripts/resolve-release-policy.sh "$tag" channel)" + push_latest="$(./.github/scripts/resolve-release-policy.sh "$tag" push-latest)" + deployment_event="$(./.github/scripts/resolve-release-policy.sh "$tag" deployment-event)" git fetch --force origin \ "refs/heads/${version_branch}:refs/remotes/origin/${version_branch}" \ @@ -68,14 +74,20 @@ jobs: fi echo "Release tag: $tag" + echo "Release channel: $release_channel" echo "Version branch: origin/${version_branch}" echo "Tag commit: $tag_commit" echo "Version branch head: $branch_head" + echo "Publish latest: $push_latest" + echo "Deployment event: $deployment_event" { echo "tag=$tag" echo "commit=$tag_commit" echo "version_branch=$version_branch" + echo "release_channel=$release_channel" + echo "push_latest=$push_latest" + echo "deployment_event=$deployment_event" } >> "$GITHUB_OUTPUT" push-docker-images: @@ -87,10 +99,12 @@ jobs: with: ref: ${{ needs.validate-tag.outputs.tag }} image_tag: ${{ needs.validate-tag.outputs.tag }} - secrets: inherit + push_latest: ${{ needs.validate-tag.outputs.push_latest == 'true' }} + secrets: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} trigger-workload-release-lane: - name: Trigger AWS Release Lane + name: Trigger AWS deployment runs-on: ubuntu-latest permissions: contents: read @@ -108,7 +122,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 @@ -155,11 +169,12 @@ jobs: FRONTEND: ${{ steps.digests.outputs.frontend }} EXPLORER: ${{ steps.digests.outputs.explorer }} MIGRATION: ${{ steps.digests.outputs.migration }} + DEPLOYMENT_EVENT: ${{ needs.validate-tag.outputs.deployment_event }} run: | set -euo pipefail gh api repos/genlayerlabs/devexp-argocd-apps/dispatches \ --method POST \ - -f event_type=release-backend \ + -f "event_type=${DEPLOYMENT_EVENT}" \ -F "client_payload[image]=$JSONRPC" \ -F "client_payload[consensus_worker_image]=$WORKER" \ -F "client_payload[frontend_image]=$FRONTEND" \ 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..9c8a8061f 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: @@ -26,27 +31,11 @@ $ genlayer up ``` After executing those commands a new tab will open in your browser with the GenLayer Studio. Additional installation instructions can be found [here](https://docs.genlayer.com/simulator/installation) -### Enabling Hardhat Node -If you need to interact with a local Hardhat node for transaction processing, make sure to add the following to your `.env` file: - -``` -HARDHAT_URL=http://hardhat -HARDHAT_PORT=8545 -COMPOSE_PROFILES=hardhat -``` - -This will enable the Hardhat service when running `genlayer up`. - -### Disabling Hardhat Node -If you need to disable the Hardhat node, make sure to remove the following from your `.env` file: - -``` -HARDHAT_URL= -HARDHAT_PORT= -COMPOSE_PROFILES= -``` +### Hardhat protocol tests -This will disable the Hardhat service when running `genlayer up`. +Studio transaction processing no longer depends on a separate Hardhat node. +The contracts under `hardhat/` remain a protocol-parity test harness and can +be tested directly from that directory. ## 🚀 Key Features * 🖥️ **Test Locally:** Developers can test Intelligent Contracts in a local environment, replicating the GenLayer network without the need for deployment. This speeds up the development cycle and reduces the risk of errors in the live environment. diff --git a/backend/consensus/base.py b/backend/consensus/base.py index f6dad1fa1..0a7c0b340 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -1,6 +1,5 @@ # backend/consensus/base.py -DEFAULT_VALIDATORS_COUNT = 5 ACTIVATED_TRANSACTION_TIMEOUT = 900 MAX_IDLE_REPLACEMENTS = 5 DEFAULT_EXEC_TIMEOUT_SECONDS = 600 @@ -9,17 +8,20 @@ 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 from copy import deepcopy import json import base64 +import binascii -from eth_utils import to_checksum_address +from eth_utils import is_address, keccak, to_bytes, to_checksum_address from sqlalchemy import text from sqlalchemy.orm import Session +from backend.consensus.constants import DEFAULT_VALIDATORS_COUNT +from backend.consensus.errors import NoValidatorsAvailableError from backend.consensus.vrf import get_validators_for_transaction from backend.database_handler.chain_snapshot import ChainSnapshot from backend.database_handler.contract_snapshot import ContractSnapshot @@ -29,6 +31,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 ( @@ -38,7 +41,11 @@ LLMProvider, Validator, ) -from backend.node.base import Node +from backend.node.base import Node, get_simulator_chain_id +from backend.node.genvm.executor_selection import ( + legacy_executor_selector_for_code, + uses_legacy_storage, +) from backend.node.types import ( ExecutionMode, Receipt, @@ -52,11 +59,50 @@ EventType, EventScope, ) +from backend.protocol_rpc.types import ZERO_ADDRESS +from backend.protocol_rpc.ghost_factory import ( + GhostFactoryConfig, + genvm_salted_child_address, +) +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + VALIDATORS_PER_ROUND, + FeeValidationError, + StudioFeePolicy, + activate_fee_accounting, + consume_message_fees, + create_child_fee_accounting, + derive_external_message_call_key, + discard_active_message_generation, + execution_policy_for_accounting, + fill_message_fee_payload_from_allocation, + mark_message_effects_delivered, + message_effect_identities, + message_novelty_mask, + prepare_reveal_message_generation, + record_external_message_execution_fees, + refund_failed_internal_message_fee, + runtime_rotations_for_round, + stamp_receipt_execution_policy, + unwind_reveal_message_fees, + uses_unmetered_message_fee_pool, + validate_receipt_admission_caps, +) from backend.rollup.consensus_service import ConsensusService import backend.validators as validators -from backend.node.genvm.origin.public_abi import ResultCode -from backend.consensus.types import ConsensusResult, ConsensusRound +from backend.node.genvm.origin.host_fns import ResultCode +from backend.consensus.types import ( + ConsensusResult, + ConsensusRound, + consensus_vote_type_code, +) +from backend.consensus.history import ( + APPEAL_RECOVERY_SNAPSHOT_KEY, + TERMINAL_VALIDATOR_APPEAL_ROUNDS, + latest_decision_metadata, + logical_fee_round_entries, +) from backend.consensus.utils import determine_consensus_from_votes from backend.consensus.decisions import ( decide_undetermined, @@ -73,13 +119,41 @@ prepare_committing, decide_post_committing, should_rollback_after_accepted, - has_appeal_capacity, ) from backend.consensus.effect_executor import EffectExecutor from backend.node.genvm import get_code_slot from backend.node.genvm.error_codes import GenVMInternalError, GenVMErrorCode from backend.node.base import Manager as GenVMManager + +def _validators_in_frozen_selection_pool( + all_validators: list[dict], + fee_accounting: dict | None, +) -> list[dict]: + """Apply Consensus's activation-pinned selection-pool identity gate.""" + + frozen_pool = ( + fee_accounting.get("selection_pool_addresses") + if isinstance(fee_accounting, dict) + else None + ) + if not isinstance(frozen_pool, list) or not frozen_pool: + return list(all_validators) + frozen_addresses = {str(address).lower() for address in frozen_pool if address} + return [ + validator + for validator in all_validators + if str(validator.get("address") or "").lower() in frozen_addresses + ] + + +# 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, @@ -98,8 +172,8 @@ ] -class NoValidatorsAvailableError(Exception): - """Raised when no validators are available to process a transaction.""" +class InternalMessageEmissionError(RuntimeError): + """Raised when the helper EVM did not durably accept a message phase.""" def _redact_consensus_data_for_log(consensus_data_dict: dict) -> dict: @@ -306,6 +380,28 @@ def node_factory( ) +def transaction_genvm_executor_selector(transaction: Transaction) -> str | None: + """Select and persist the deployment's executor, honoring explicit pins.""" + explicit_selector = ( + transaction.sim_config.genvm_executor_selector + if transaction.sim_config + else None + ) + if explicit_selector: + return explicit_selector + if transaction.type != TransactionType.DEPLOY_CONTRACT or not transaction.data: + return None + code_b64 = transaction.data.get("contract_code") + if not isinstance(code_b64, (str, bytes)): + return None + try: + code = base64.b64decode(code_b64, validate=True) + except (binascii.Error, ValueError): + # Keep malformed payload handling on the existing execution path. + return None + return legacy_executor_selector_for_code(code) + + def contract_snapshot_factory( contract_address: str, session: Session, @@ -339,6 +435,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. Honor its explicit + # pin or select the legacy line from the deployment's source header. + ret.genvm_executor_selector = transaction_genvm_executor_selector(transaction) return ret # Return a ContractSnapshot instance for an existing contract @@ -460,6 +559,7 @@ def __init__( self.validator_nodes: list = [] self.validation_results: list = [] self.rotation_count: int = 0 + self.active_fee_round: int | None = None self.consensus_service = consensus_service self.leader: dict = {} # Shared for the lifetime of this transaction context (leader + validators). @@ -701,13 +801,14 @@ async def execute_transfer( is_triggered = transaction.triggered_by_hash is not None if not is_triggered and transaction.from_address is not None: - # Get the balance of the sender account - from_balance = accounts_manager.get_account_balance( - transaction.from_address + # The finalization workers use independent database sessions. A + # read/check/write balance update lets concurrent transfers spend + # the same stale balance. Keep the sufficiency check and debit in + # one conditional SQL update instead. + debited = accounts_manager.debit_account_balance( + transaction.from_address, transaction.value ) - - # Check if the sender has enough balance - if from_balance < transaction.value: + if not debited: # UNDETERMINED is finalization-eligible: claim_next_finalization # filters on timestamp_awaiting_finalization IS NOT NULL. # Without this stamp the row strands forever (16 such rows @@ -724,18 +825,11 @@ async def execute_transfer( return - # Update the balance of the sender account - accounts_manager.update_account_balance( - transaction.from_address, from_balance - transaction.value - ) - if transaction.to_address is not None: - # Get the balance of the recipient account - to_balance = accounts_manager.get_account_balance(transaction.to_address) - - # Update the balance of the recipient account - accounts_manager.update_account_balance( - transaction.to_address, to_balance + transaction.value + # Atomic increment prevents concurrent credits from overwriting + # each other when they target the same recipient. + accounts_manager.credit_account_balance( + transaction.to_address, transaction.value ) # Mark the tx as credited so a later retry (or duplicate sync path) @@ -793,13 +887,28 @@ def can_finalize_transaction( ] # Check if finalization criteria are met + decision = latest_decision_metadata( + getattr(transaction, "consensus_history", None) + ) + exact_deadline = None + if decision is not None: + try: + exact_deadline = int(decision.get("appealDeadline") or 0) + except (TypeError, ValueError): + exact_deadline = None time_based_finalization = ( - time.time() - - transaction.timestamp_awaiting_finalization - - transaction.appeal_processing_time - ) > self.finality_window_time * ( - (1 - self.finality_window_appeal_failed_reduction) - ** transaction.appeal_failed + time.time() >= exact_deadline + if exact_deadline + else ( + time.time() + - transaction.timestamp_awaiting_finalization + - transaction.appeal_processing_time + ) + >= self.finality_window_time + * ( + (1 - self.finality_window_appeal_failed_reduction) + ** transaction.appeal_failed + ) ) if immediate_finalization or time_based_finalization: @@ -819,6 +928,41 @@ def can_finalize_transaction( else: return False + async def repair_accepted_message_delivery( + self, + transaction: Transaction, + transactions_processor: TransactionsProcessor, + accounts_manager: AccountsManager, + contract_snapshot_factory: Callable[[str], ContractSnapshot], + contract_processor: ContractProcessor, + node_factory: NodeFactory, + ) -> None: + """Replay an agreed acceptance phase without finalizing the tx.""" + + context = TransactionContext( + transaction=transaction, + transactions_processor=transactions_processor, + chain_snapshot=None, + accounts_manager=accounts_manager, + contract_snapshot_factory=contract_snapshot_factory, + contract_processor=contract_processor, + node_factory=node_factory, + msg_handler=self.msg_handler, + consensus_service=self.consensus_service, + validators_snapshot=None, + genvm_manager=self.genvm_manager, + ) + leader_receipt = transaction.consensus_data.leader_receipt[0] + delivered = _dispatch_messages_for_phase(context, leader_receipt, "accepted") + if delivered: + await self.dispatch_transaction_status_update( + transactions_processor, + transaction.hash, + TransactionStatus.ACCEPTED, + self.msg_handler, + update_current_status_changes=False, + ) + async def process_finalization( self, transaction: Transaction, @@ -896,22 +1040,41 @@ async def process_leader_appeal( genvm_manager=self.genvm_manager, ) - transactions_processor.set_transaction_appeal(transaction.hash, False) - transaction.appealed = False - - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - context.transactions_processor.get_transaction_by_hash( - context.transaction.hash - )["consensus_history"] + consumed_addresses = ConsensusAlgorithm.get_consumed_validator_addresses( + context.transactions_processor.get_transaction_by_hash( + context.transaction.hash + )["consensus_history"], + transaction.consensus_data, + ) + fee_accounting = (transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + live_validators = [ + node.validator.to_dict() for node in validators_snapshot.nodes + ] + selection_pool = { + str(validator.get("address") or "").lower() + for validator in _validators_in_frozen_selection_pool( + live_validators, + fee_accounting, ) + if validator.get("address") + } + + logical_entries = logical_fee_round_entries(transaction.consensus_history) + current_round = logical_entries[-1][0] if logical_entries else 0 + required_fresh = VALIDATORS_PER_ROUND[ + min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1) + ] + available_fresh = max( + 0, + len(selection_pool - consumed_addresses), ) - if not has_appeal_capacity( - num_involved_validators=len(transaction.consensus_data.validators), - num_used_leader_addresses=len(used_leader_addresses), - num_total_validators=len(validators_snapshot.nodes), - ): + if available_fresh < required_fresh: + accounts_manager.abort_tx_appeal_admission_once( + transaction.hash, + "appeal_committee_unavailable", + ) + transaction.appealed = False self.msg_handler.send_message( LogEvent( "consensus_event", @@ -938,6 +1101,8 @@ async def process_leader_appeal( ) else: + transactions_processor.set_transaction_appeal(transaction.hash, False) + transaction.appealed = False # Appeal data member is used in the frontend for all types of appeals # Here the type is refined based on the status transactions_processor.set_transaction_appeal_undetermined( @@ -995,28 +1160,18 @@ async def process_leader_timeout_appeal( genvm_manager=self.genvm_manager, ) - transactions_processor.set_transaction_appeal(transaction.hash, False) - transaction.appealed = False - if context.transaction.appeal_undetermined: context.transactions_processor.set_transaction_appeal_undetermined( context.transaction.hash, False ) context.transaction.appeal_undetermined = False - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - context.transactions_processor.get_transaction_by_hash( - context.transaction.hash - )["consensus_history"] + if not transaction.leader_timeout_validators: + accounts_manager.abort_tx_appeal_admission_once( + transaction.hash, + "appeal_committee_unavailable", ) - ) - - if not has_appeal_capacity( - num_involved_validators=len(transaction.leader_timeout_validators), - num_used_leader_addresses=len(used_leader_addresses), - num_total_validators=len(validators_snapshot.nodes), - ): + transaction.appealed = False self.msg_handler.send_message( LogEvent( "consensus_event", @@ -1043,6 +1198,8 @@ async def process_leader_timeout_appeal( ) else: + transactions_processor.set_transaction_appeal(transaction.hash, False) + transaction.appealed = False # Appeal data member is used in the frontend for all types of appeals # Here the type is refined based on the status transaction.appeal_leader_timeout = ( @@ -1103,13 +1260,27 @@ async def process_validator_appeal( context.consensus_data.leader_receipt = ( transaction.consensus_data.leader_receipt ) + logical_entries = logical_fee_round_entries(transaction.consensus_history) + current_round = logical_entries[-1][0] if logical_entries else 0 + fee_accounting = (transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + eligible_validators = _validators_in_frozen_selection_pool( + [x.validator.to_dict() for x in validators_snapshot.nodes], + fee_accounting, + ) try: # Attempt to get extra validators for the appeal process _, context.remaining_validators = ConsensusAlgorithm.get_extra_validators( - [x.validator.to_dict() for x in validators_snapshot.nodes], + eligible_validators, transaction.consensus_history, transaction.consensus_data, transaction.appeal_failed, + required_extra_validators=VALIDATORS_PER_ROUND[ + min( + current_round + 1, + len(VALIDATORS_PER_ROUND) - 1, + ) + ], + allow_short=True, ) except ValueError as e: # When no validators are found, then the appeal failed @@ -1121,12 +1292,14 @@ 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, ) ) - context.transactions_processor.set_transaction_appeal( - context.transaction.hash, False + accounts_manager.abort_tx_appeal_admission_once( + context.transaction.hash, + "appeal_committee_unavailable", ) context.transaction.appealed = False self.msg_handler.send_message( @@ -1183,18 +1356,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 @@ -1202,6 +1423,17 @@ async def process_validator_appeal( context.transaction.hash, None ) + # The successful review is followed by one terminal normal + # recomputation, not another validator-appeal pass. + context.transactions_processor.set_transaction_appeal( + context.transaction.hash, False + ) + context.transaction.appealed = False + context.transactions_processor.set_transaction_appeal_validators_timeout( + context.transaction.hash, False + ) + context.transaction.appeal_validators_timeout = False + await ConsensusAlgorithm.dispatch_transaction_status_update( context.transactions_processor, context.transaction.hash, @@ -1222,57 +1454,65 @@ async def rollback_transactions(self, context: TransactionContext): context.transaction.hash ) for future_transaction in future_transactions: + future_hash = future_transaction["hash"] + future_data = deepcopy(future_transaction.get("data") or {}) + if bool(future_transaction.get("appealed")) or ( + APPEAL_RECOVERY_SNAPSHOT_KEY in future_data + ): + # The ancestor invalidates the descendant decision that this + # paid appeal targets. Refund its still-active admission before + # dropping the stale decision/worker state, then reload the + # accounting so the reset cannot resurrect the charged bond. + context.accounts_manager.abort_tx_appeal_admission_once( + future_hash, + "ancestor_rewind", + ) + refreshed = context.transactions_processor.get_transaction_by_hash( + future_hash + ) + future_data = deepcopy((refreshed or {}).get("data") or {}) + future_data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) + future_fee_accounting = future_data.get(FEE_ACCOUNTING_KEY) + if isinstance(future_fee_accounting, dict): + future_data[FEE_ACCOUNTING_KEY] = discard_active_message_generation( + future_fee_accounting + ) + context.transactions_processor.reset_transaction_for_recomputation( + future_hash, + future_data, + ) await ConsensusAlgorithm.dispatch_transaction_status_update( context.transactions_processor, - future_transaction["hash"], + future_hash, TransactionStatus.PENDING, context.msg_handler, ) - # Reset the contract snapshot for the transaction - context.transactions_processor.set_transaction_contract_snapshot( - future_transaction["hash"], None - ) - @staticmethod def get_extra_validators( all_validators: List[dict], consensus_history: dict, consensus_data: ConsensusData, appeal_failed: int, + required_extra_validators: int | None = None, + allow_short: bool = False, ): """ - Get extra validators for the appeal process according to the following formula: - - when appeal_failed = 0, add n + 2 validators - - when appeal_failed > 0, add (2 * appeal_failed * n + 1) + 2 validators - Note that for appeal_failed > 0, the returned set contains the old validators - from the previous appeal round and new validators. - - Selection of the extra validators: - appeal_failed | PendingState | Reused validators | Extra selected | Total - | validators | from the previous | validators for the | validators - | | appeal round | appeal | - ---------------------------------------------------------------------------------- - 0 | n | 0 | n+2 | 2n+2 - 1 | n | n+2 | n+1 | 3n+3 - 2 | n | 2n+3 | 2n | 5n+3 - 3 | n | 4n+3 | 2n | 7n+3 - └───────┬──────┘ └─────────┬────────┘ - │ | - Validators after the ◄────────┘ └──► Validators during the appeal - appeal. This equals for appeal_failed > 0 - the Total validators = (2*appeal_failed*n+1)+2 - of the row above, This is the formula from - and are in consensus_data. above and it is what is - For appeal_failed > 0 returned by this function - = (2*appeal_failed-1)*n+3 - This is used to calculate n + Select validators not already consumed by the transaction. + + Train callers pass ``required_extra_validators`` from the canonical + round schedule. Validator appeals also pass ``allow_short=True`` so a + pool with 1..K-1 fresh validators seats all of them; leader appeals + retain the exact-size rule. The historical ``appeal_failed`` formulas + remain only as a fallback for callers outside the train path. Args: all_validators (List[dict]): List of all validators. consensus_history (dict): Dictionary of consensus rounds results and status changes. consensus_data (ConsensusData): Data related to the consensus process. appeal_failed (int): Number of times the appeal has failed. + required_extra_validators: Exact scheduled fresh jury/committee size. + allow_short: Whether a non-empty capacity-limited selection is valid. Returns: list: List of current validators. @@ -1285,15 +1525,16 @@ def get_extra_validators( ) ) - # Remove used leaders from validator_map - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - consensus_history - ) + # Consensus excludes every consumed registry index from a fresh appeal + # selection. Do not rely on consensus_data retaining every prior jury: + # legacy rows and chained failures can leave receipts only in history. + consumed_addresses = ConsensusAlgorithm.get_consumed_validator_addresses( + consensus_history, + consensus_data, ) - for used_leader_address in used_leader_addresses: - if used_leader_address in validator_map: - validator_map.pop(used_leader_address) + for address in list(validator_map): + if address.lower() in consumed_addresses: + validator_map.pop(address) # Set not_used_validators to the remaining validators in validator_map not_used_validators = list(validator_map.values()) @@ -1302,8 +1543,23 @@ def get_extra_validators( raise ValueError("No validators found") nb_current_validators = len(current_validators) + 1 # including the leader - if appeal_failed == 0: - # Calculate extra validators when no appeal has failed + if required_extra_validators is not None: + # v0.6 appeal juries are independent fresh selections. Every + # address retained in consensus_data was consumed by an earlier + # round; history leaders are excluded above. A validator appeal + # may seat every remaining 1..K-1 validator, while a leader appeal + # passes allow_short=False and therefore retains its exact K rule. + requested = max(0, int(required_extra_validators)) + extra_validators = get_validators_for_transaction( + not_used_validators, requested + ) + if not allow_short and len(extra_validators) != requested: + raise ValueError( + f"Not enough fresh validators: required {requested}, " + f"available {len(extra_validators)}" + ) + elif appeal_failed == 0: + # Legacy caller fallback; train callers pass the explicit schedule. extra_validators = get_validators_for_transaction( not_used_validators, nb_current_validators + 2 ) @@ -1324,6 +1580,42 @@ def get_extra_validators( return current_validators, extra_validators + @staticmethod + def get_leader_replay_validators( + all_validators: List[dict], + consensus_history: dict, + consensus_data: ConsensusData, + target_committee_size: int, + ) -> list[dict]: + """Build an exact fee-plan-sized leader replay committee. + + The initial committee is caller-selectable, while later raw rounds use + the protocol's absolute committee ladder. Reusing every survivor and + then adding a fixed ladder increment over-seats a replay whenever the + initial committee was not five. Retain at most the target number of + survivors, then fill only the remaining seats with fresh validators. + """ + + current_validators, _ = ConsensusAlgorithm.get_validators_from_consensus_data( + all_validators, + consensus_data, + False, + ) + target = max(0, int(target_committee_size)) + retained = current_validators[:target] + required_fresh = target - len(retained) + if required_fresh == 0: + return retained + + _, fresh = ConsensusAlgorithm.get_extra_validators( + all_validators, + consensus_history, + consensus_data, + 0, + required_extra_validators=required_fresh, + ) + return retained + fresh + @staticmethod def get_validators_from_consensus_data( all_validators: List[dict], consensus_data: ConsensusData, include_leader: bool @@ -1344,8 +1636,12 @@ def get_validators_from_consensus_data( return [], {} # Create a dictionary to map addresses to a validator + # Solidity addresses are values, not case-sensitive strings. Studio's + # registry and serialized receipts can legitimately disagree only in + # checksum casing, so every identity comparison uses one normalized + # key. validator_map = { - validator["address"]: validator for validator in all_validators + str(validator["address"]).lower(): validator for validator in all_validators } # Extract address of the leader from consensus data @@ -1363,9 +1659,9 @@ def get_validators_from_consensus_data( # Return validators whose addresses are in the receipt addresses validators = [ - validator_map.pop(receipt_address) + validator_map.pop(str(receipt_address).lower()) for receipt_address in receipt_addresses - if receipt_address in validator_map + if str(receipt_address).lower() in validator_map ] return validators, validator_map @@ -1390,14 +1686,14 @@ def add_new_validator( raise ValueError("No more validators found to add a new validator") # Extract a set of addresses of validators and leaders - addresses = {validator["address"] for validator in validators} - addresses.update(leader_addresses) + addresses = {str(validator["address"]).lower() for validator in validators} + addresses.update(str(address).lower() for address in leader_addresses) # Get not used validators not_used_validators = [ validator for validator in all_validators - if validator["address"] not in addresses + if str(validator["address"]).lower() not in addresses ] # Get new validator @@ -1405,6 +1701,22 @@ def add_new_validator( return new_validator + validators + @staticmethod + def get_terminal_replacement_validators( + all_validators: List[dict], consensus_history: dict + ) -> list[dict]: + """Return the full terminal electorate minus prior normal leaders.""" + used_leaders = ( + ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( + consensus_history + ) + ) + return [ + validator + for validator in all_validators + if str(validator["address"]).lower() not in used_leaders + ] + @staticmethod def get_used_leader_addresses_from_consensus_history( consensus_history: dict, current_leader_receipt: Receipt | None = None @@ -1422,20 +1734,71 @@ def get_used_leader_addresses_from_consensus_history( used_leader_addresses = set() if consensus_history is not None and "consensus_results" in consensus_history: for consensus_round in consensus_history["consensus_results"]: - leader_receipt = consensus_round["leader_result"] - if leader_receipt: - used_leader_addresses.update( - [leader_receipt[0]["node_config"]["address"]] - ) + leader_receipt = consensus_round.get("leader_result") or [] + if not leader_receipt: + continue + address = ( + (leader_receipt[0].get("node_config") or {}).get("address") + if isinstance(leader_receipt[0], dict) + else None + ) + if address: + used_leader_addresses.add(str(address).lower()) # consensus_history does not contain the latest consensus_data if current_leader_receipt: used_leader_addresses.update( - [current_leader_receipt.node_config["address"]] + [str(current_leader_receipt.node_config["address"]).lower()] ) return used_leader_addresses + @staticmethod + def get_consumed_validator_addresses( + consensus_history: dict | None, + current_consensus_data: ConsensusData | dict | None = None, + ) -> set[str]: + """Return every address consumed by the transaction's fresh pool.""" + + consumed: set[str] = set() + + def add_receipts(value) -> None: + receipts = value if isinstance(value, list) else [value] + for receipt in receipts: + if receipt is None: + continue + node_config = ( + receipt.get("node_config") + if isinstance(receipt, dict) + else getattr(receipt, "node_config", None) + ) + if not isinstance(node_config, dict): + continue + address = node_config.get("address") + if address: + consumed.add(str(address).lower()) + + results = ( + consensus_history.get("consensus_results") + if isinstance(consensus_history, dict) + else None + ) + if isinstance(results, list): + for entry in results: + if not isinstance(entry, dict): + continue + add_receipts(entry.get("leader_result")) + add_receipts(entry.get("validator_results")) + + if isinstance(current_consensus_data, dict): + add_receipts(current_consensus_data.get("leader_receipt")) + add_receipts(current_consensus_data.get("validators")) + elif current_consensus_data is not None: + add_receipts(getattr(current_consensus_data, "leader_receipt", None)) + add_receipts(getattr(current_consensus_data, "validators", None)) + + return consumed + def set_finality_window_time(self, time: int): """ Set the finality window time. @@ -1475,125 +1838,1009 @@ async def handle( """ -class PendingState(TransactionState): - """ - Class representing the pending state of a transaction. - """ +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 + ) - async def handle(self, context): - # Pre-effects: timestamp + reset rotation count - pre_effects = decide_pending_pre( - tx_hash=context.transaction.hash, - appeal_leader_timeout=context.transaction.appeal_leader_timeout, - appeal_undetermined=context.transaction.appeal_undetermined, - ) - 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 - ) - ) +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 + ) - # Log executing message (unless appeal) - if ( - not context.transaction.appeal_leader_timeout - and not context.transaction.appeal_undetermined - ): - context.msg_handler.send_message( - LogEvent( - "consensus_event", - EventType.INFO, - EventScope.CONSENSUS, - "Executing transaction", - { - "transaction_hash": context.transaction.hash, - "transaction": _redact_transaction_for_log( - context.transaction.to_dict() - ), - }, - transaction_hash=context.transaction.hash, - ) - ) - # Transfer transactions - if context.transaction.type == TransactionType.SEND: - await ConsensusAlgorithm.execute_transfer( - context.transaction, - context.transactions_processor, - context.accounts_manager, - context.msg_handler, - ) - return None +def _use_balance_obligation_total( + pending_transactions: Iterable[PendingTransaction], +) -> tuple[int, int]: + fee_budget = 0 + value = 0 + for pending_transaction in pending_transactions: + if pending_transaction.is_eth_send or not pending_transaction.use_balance: + continue + fee_budget += max(0, int(pending_transaction.declared_budget or 0)) + value += max(0, int(pending_transaction.value or 0)) + return fee_budget, value + + +def _use_balance_obligation_for_phase( + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> int: + return sum( + max(0, int(pending_transaction.declared_budget or 0)) + + max(0, int(pending_transaction.value or 0)) + for pending_transaction in pending_transactions + if not pending_transaction.is_eth_send + and pending_transaction.use_balance + and pending_transaction.on == on + ) - # Get all validators BEFORE crediting — if no validators, tx will be - # canceled and we must not credit (otherwise refund_tx_value can't refund) - if context.validators_snapshot is None: - all_validators = None - else: - all_validators = [ - n.validator.to_dict() for n in context.validators_snapshot.nodes - ] - if not all_validators: - context.msg_handler.send_message( - LogEvent( - "consensus_event", - EventType.ERROR, - EventScope.CONSENSUS, - "No validators found to process transaction", - {"transaction_hash": context.transaction.hash}, - transaction_hash=context.transaction.hash, - ) - ) - raise NoValidatorsAvailableError( - f"No validators available for transaction {context.transaction.hash}" - ) +def _apply_external_message_freeze_check( + context: TransactionContext, + leader_receipt: Receipt, +) -> None: + if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: + return + + pending_transactions = _novel_pending_transactions( + context, + leader_receipt.pending_transactions, + ) + external_value = _external_message_value_total(pending_transactions) + use_balance_fee, use_balance_value = _use_balance_obligation_total( + pending_transactions + ) + declared_value = external_value + use_balance_fee + use_balance_value + if declared_value <= 0: + return - # Validator selection (impure: VRF, DB reads, static methods) - if ( - context.transaction.appealed - or context.transaction.appeal_validators_timeout - ): - if context.transaction.consensus_data is not None: - context.involved_validators, _ = ( - ConsensusAlgorithm.get_validators_from_consensus_data( - all_validators, context.transaction.consensus_data, False - ) - ) - else: - context.involved_validators = get_validators_for_transaction( - all_validators, context.transaction.num_of_initial_validators - ) + 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_name = ( + "ExternalMessageFreezeExceeded" + if use_balance_fee + use_balance_value == 0 + else "ContractMessageFreezeExceeded" + ) + error_message = ( + f"{error_name}: 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" + if use_balance_fee + use_balance_value == 0 + else "CONTRACT_MESSAGE_FREEZE_EXCEEDED" + ), + "error_description": error_message, + "external_message_freeze": { + "declaredValue": declared_value, + "availableLimit": available, + "balance": balance, + "reservedExternal": other_reserved, + **( + { + "externalValue": external_value, + "useBalanceFee": use_balance_fee, + "useBalanceValue": use_balance_value, + } + if use_balance_fee + use_balance_value > 0 + else {} + ), + }, + } - context.transactions_processor.set_transaction_appeal( - context.transaction.hash, False + +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") + + _use_balance_obligation_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_queue_order = ( + context.transactions_processor.session.query( + Transactions.queue_order, + ) + .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_queue_order is not None: + filters.append(Transactions.queue_order < int(current_queue_order)) + + 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", ) - context.transaction.appealed = False - context.transaction.appeal_validators_timeout = context.transactions_processor.set_transaction_appeal_validators_timeout( - context.transaction.hash, False + total += _use_balance_obligation_for_phase_from_raw( + _receipt_pending_transactions(receipt), + "finalized", ) + return total - elif context.transaction.appeal_undetermined: - if context.transaction.consensus_data is None: - context.transactions_processor.set_transaction_appeal_undetermined( - context.transaction.hash, False - ) - context.transaction.appeal_undetermined = False + +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 _use_balance_obligation_for_phase_from_raw( + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], +) -> int: + total = 0 + for pending_transaction in pending_transactions: + if isinstance(pending_transaction, PendingTransaction): + if ( + pending_transaction.is_eth_send + or not pending_transaction.use_balance + or pending_transaction.on != on + ): + continue + total += max(0, int(pending_transaction.declared_budget or 0)) + total += max(0, int(pending_transaction.value or 0)) + continue + + if not isinstance(pending_transaction, dict): + continue + message_type = pending_transaction.get( + "message_type", pending_transaction.get("messageType") + ) + if message_type in {0, "0", "External", "external"}: + continue + if not bool( + pending_transaction.get( + "use_balance", pending_transaction.get("useBalance", False) + ) + ): + continue + 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: + continue + total += max( + 0, + int( + pending_transaction.get( + "declared_budget", + pending_transaction.get("declaredBudget", 0), + ) + or 0 + ), + ) + total += max(0, int(pending_transaction.get("value", 0) or 0)) + return total + + +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 = sum( + int(pending_transaction.value or 0) + for pending_transaction in pending_transactions + if not pending_transaction.is_eth_send + and not pending_transaction.use_balance + and pending_transaction.on == on + and int(pending_transaction.value or 0) > 0 + ) + 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 _debit_use_balance_funding_for_phase( + context: TransactionContext, + pending_transactions: list[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> bool: + obligation = _use_balance_obligation_for_phase(pending_transactions, on) + if obligation <= 0: + return True + + available = _internal_message_value_cap(context, pending_transactions, on) + if obligation > available: + _log_message_value_debit_failure( + context, + on, + obligation, + "useBalance internal", + "Emitting the child without contract-funded value or fees.", + ) + return False + + debited = context.accounts_manager.debit_account_balance( + context.transaction.to_address, obligation + ) + if not debited: + _log_message_value_debit_failure( + context, + on, + obligation, + "useBalance internal", + "Emitting the child without contract-funded value or fees.", + ) + 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, + use_balance_funding_backed: bool, +) -> list[PendingTransaction]: + adjusted_pending_transactions = [] + for pending_transaction in pending_transactions: + if ( + pending_transaction.on == on + and pending_transaction.use_balance + and not use_balance_funding_backed + ): + # MessagePayments must not turn a failed contract-funded debit + # into an executable zero-fee child transaction. + continue + 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_untracked_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 + ) + use_balance_funding_backed = _debit_use_balance_funding_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 and use_balance_funding_backed: + return pending_list + + return _adjust_unbacked_message_values( + pending_list, + on, + external_value_backed=external_value_backed, + internal_value_backed=internal_value_backed, + use_balance_funding_backed=use_balance_funding_backed, + ) + + +def _message_value_effect_record( + pending_transaction: PendingTransaction, + descriptor: str, + on: Literal["accepted", "finalized"], + *, + external_value_backed: bool, + internal_value_backed: bool, + use_balance_funding_backed: bool, +) -> dict[str, Any]: + value = int(pending_transaction.value or 0) + include = True + adjusted_value = value + adjusted_budget = int(pending_transaction.declared_budget or 0) + + if pending_transaction.use_balance and not use_balance_funding_backed: + include = False + elif value > 0 and pending_transaction.is_eth_send and not external_value_backed: + include = False + elif ( + value > 0 and not pending_transaction.is_eth_send and not internal_value_backed + ): + adjusted_value = 0 + + return { + "descriptor": descriptor, + "phase": on, + "include": include, + "value": adjusted_value, + "declaredBudget": adjusted_budget, + } + + +def _apply_recorded_message_value_effects( + pending_transactions: list[PendingTransaction], + identities: list[tuple[str, str]], + records: dict[str, Any], + on: Literal["accepted", "finalized"], +) -> list[PendingTransaction]: + adjusted: list[PendingTransaction] = [] + for pending_transaction, (occurrence, descriptor) in zip( + pending_transactions, identities + ): + if pending_transaction.on != on: + adjusted.append(pending_transaction) + continue + + record = records.get(occurrence) + if not isinstance(record, dict): + raise RuntimeError(f"MessageValueEffectMissing({occurrence})") + if record.get("descriptor") != descriptor or record.get("phase") != on: + raise RuntimeError( + "MessageValueEffectDescriptorMismatch" + f"({occurrence},{record.get('descriptor')},{descriptor})" + ) + if not bool(record.get("include", False)): + continue + + value = int(record.get("value", 0) or 0) + declared_budget = int(record.get("declaredBudget", 0) or 0) + if value == int(pending_transaction.value or 0) and declared_budget == int( + pending_transaction.declared_budget or 0 + ): + adjusted.append(pending_transaction) + continue + + replayed = _pending_transaction_with_value(pending_transaction, value) + replayed.declared_budget = declared_budget + adjusted.append(replayed) + return adjusted + + +def _apply_message_value_withdrawals_for_phase( + context: TransactionContext, + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +) -> list[PendingTransaction]: + """Reserve message value once and replay the exact result after a crash. + + Studio's account database and helper EVM cannot share one atomic + transaction. The reservation therefore lives in the parent's locked fee + accounting row: a worker may retry the helper call, but it cannot debit the + contract a second time or change an earlier insufficient-balance outcome. + """ + + pending_list = list(pending_transactions) + parent_fee_accounting = (getattr(context.transaction, "data", None) or {}).get( + FEE_ACCOUNTING_KEY + ) + mutate_accounting = getattr( + context.transactions_processor, + "mutate_transaction_fee_accounting", + None, + ) + if not isinstance(parent_fee_accounting, dict) or not callable(mutate_accounting): + return _apply_untracked_message_value_withdrawals_for_phase( + context, pending_list, on + ) + + def reserve_latest(current_fee_accounting: dict[str, Any]) -> dict[str, Any]: + updated = deepcopy(current_fee_accounting) + payloads = _reveal_message_fee_payloads(updated, pending_list) + identities = message_effect_identities(context.transaction.hash, payloads) + records = updated.setdefault("message_value_effects", {}) + + unrecorded_indexes: set[int] = set() + for index, (pending_transaction, (occurrence, descriptor)) in enumerate( + zip(pending_list, identities) + ): + if pending_transaction.on != on: + continue + record = records.get(occurrence) + if record is None: + unrecorded_indexes.add(index) + continue + if ( + not isinstance(record, dict) + or record.get("descriptor") != descriptor + or record.get("phase") != on + ): + raise RuntimeError( + "MessageValueEffectDescriptorMismatch" + f"({occurrence}," + f"{record.get('descriptor') if isinstance(record, dict) else None}," + f"{descriptor})" + ) + + if unrecorded_indexes: + debit_candidates = [ + pending_transaction + for index, pending_transaction in enumerate(pending_list) + if pending_transaction.on != on or index in unrecorded_indexes + ] + external_value_backed = _debit_external_message_value_for_phase( + context, debit_candidates, on + ) + use_balance_funding_backed = _debit_use_balance_funding_for_phase( + context, debit_candidates, on + ) + internal_value_backed = _debit_internal_message_value_for_phase( + context, debit_candidates, on + ) + + for index in unrecorded_indexes: + pending_transaction = pending_list[index] + occurrence, descriptor = identities[index] + records[occurrence] = _message_value_effect_record( + pending_transaction, + descriptor, + on, + external_value_backed=external_value_backed, + internal_value_backed=internal_value_backed, + use_balance_funding_backed=use_balance_funding_backed, + ) + + return updated + + updated_accounting = mutate_accounting( + context.transaction.hash, + reserve_latest, + commit=False, + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting + payloads = _reveal_message_fee_payloads(updated_accounting, pending_list) + identities = message_effect_identities(context.transaction.hash, payloads) + return _apply_recorded_message_value_effects( + pending_list, + identities, + updated_accounting.get("message_value_effects") or {}, + on, + ) + + +def _pending_valid_until_expired( + transaction: Transaction, + *, + now: float | None = None, +) -> bool: + # Appeals/recomputation reuse Studio's PENDING state after the transaction + # has already activated. Consensus deliberately ignores validUntil on that + # re-entry path (the original activation deadline is not a lifetime cap). + if ( + bool(getattr(transaction, "consensus_history", None)) + or bool(getattr(transaction, "appealed", False)) + or bool(getattr(transaction, "appeal_undetermined", False)) + or bool(getattr(transaction, "appeal_leader_timeout", False)) + or bool(getattr(transaction, "appeal_validators_timeout", False)) + ): + return False + valid_until = int((transaction.data or {}).get("valid_until", 0) or 0) + # EVM block timestamps are integer seconds; equality remains valid. + return valid_until > 0 and int(time.time() if now is None else now) > valid_until + + +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 + ) + ) + context.active_fee_round = _active_execution_fee_round(context.transaction) + + # v0.6 validUntil is an activation deadline. Consensus leaves an + # expired transaction queued until it reaches the pending head, then + # terminalizes it without activating or executing it. Studio used to + # decode and persist this field but ignored it entirely. + if _pending_valid_until_expired(context.transaction): + value_sender = context.transaction.from_address + if value_sender: + context.accounts_manager.refund_tx_value( + context.transaction.hash, + value_sender, + ) + fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + fee_sender = ( + fee_accounting.get("sender") + if isinstance(fee_accounting, dict) + else None + ) or value_sender + if fee_sender: + context.accounts_manager.cancel_tx_fee_accounting_once( + context.transaction.hash, + fee_sender, + "valid_until_expired", + ) + await ConsensusAlgorithm.dispatch_transaction_status_update( + context.transactions_processor, + context.transaction.hash, + TransactionStatus.CANCELED, + context.msg_handler, + ) + return None + + # Pre-effects: timestamp + reset rotation count + pre_effects = decide_pending_pre( + tx_hash=context.transaction.hash, + appeal_leader_timeout=context.transaction.appeal_leader_timeout, + appeal_undetermined=context.transaction.appeal_undetermined, + ) + await EffectExecutor(context).execute(pre_effects) + + # Consensus reserves time-unit work at the submitted ceiling, then + # validates all three signed price caps and the live execution floor + # when the queued transaction activates. The complete activation-time + # execution policy is then immutable for this transaction. + fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting: + activation_result: dict[str, bool] = {} + + def activate_latest(current_fee_accounting): + updated, should_cancel = activate_fee_accounting( + current_fee_accounting, + StudioFeePolicy.from_env(), + selection_pool_count=( + len(context.validators_snapshot.nodes) + if context.validators_snapshot is not None + else None + ), + selection_pool_addresses=( + [ + node.validator.address + for node in context.validators_snapshot.nodes + ] + if context.validators_snapshot is not None + else None + ), + ) + activation_result["should_cancel"] = should_cancel + return updated + + activated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + activate_latest, + ) + ) + should_cancel = activation_result["should_cancel"] + context.transaction.data = { + **(context.transaction.data or {}), + FEE_ACCOUNTING_KEY: activated_accounting, + } + if should_cancel: + value_sender = context.transaction.from_address + if value_sender: + context.accounts_manager.refund_tx_value( + context.transaction.hash, + value_sender, + ) + fee_sender = activated_accounting.get("sender") or value_sender + if fee_sender: + context.accounts_manager.cancel_tx_fee_accounting_once( + context.transaction.hash, + fee_sender, + str( + activated_accounting.get("activation_cancel_reason") + or "activation_fee_policy_rejected" + ), + ) + await ConsensusAlgorithm.dispatch_transaction_status_update( + context.transactions_processor, + context.transaction.hash, + TransactionStatus.CANCELED, + context.msg_handler, + ) + return None + + # Log executing message (unless appeal) + if ( + not context.transaction.appeal_leader_timeout + and not context.transaction.appeal_undetermined + ): + context.msg_handler.send_message( + LogEvent( + "consensus_event", + EventType.INFO, + EventScope.CONSENSUS, + "Executing transaction", + { + "transaction_hash": context.transaction.hash, + "transaction": _redact_transaction_for_log( + context.transaction.to_dict() + ), + }, + transaction_hash=context.transaction.hash, + ) + ) + + # Transfer transactions + if context.transaction.type == TransactionType.SEND: + await ConsensusAlgorithm.execute_transfer( + context.transaction, + context.transactions_processor, + context.accounts_manager, + context.msg_handler, + ) + return None + + # Get all validators BEFORE crediting — if no validators, tx will be + # canceled and we must not credit (otherwise refund_tx_value can't refund) + if context.validators_snapshot is None: + all_validators = None + else: + all_validators = [ + n.validator.to_dict() for n in context.validators_snapshot.nodes + ] + + # Consensus executes every later round against the activation-pinned + # selection pool. Validators added afterwards are not eligible; a + # removed validator is simply unavailable and can reduce capacity. + fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if all_validators: + all_validators = _validators_in_frozen_selection_pool( + all_validators, + fee_accounting, + ) + + is_initial_activation = not ( + bool(context.transaction.consensus_data) + or bool(context.transaction.consensus_history) + or bool(context.transaction.appealed) + or bool(context.transaction.appeal_undetermined) + or bool(context.transaction.appeal_leader_timeout) + or bool(context.transaction.appeal_validators_timeout) + ) + requested_initial_validators = int( + context.transaction.num_of_initial_validators or DEFAULT_VALIDATORS_COUNT + ) + if ( + is_initial_activation + and len(all_validators or []) < requested_initial_validators + ): + # Consensus admission prices the exact caller-selected committee. + # Activation must not silently execute a cheaper/smaller round when + # that many frozen-pool validators are unavailable. Its canonical + # no-capacity outcome is Undetermined (Pending -> Undetermined). + context.msg_handler.send_message( + LogEvent( + "consensus_event", + EventType.WARNING, + EventScope.CONSENSUS, + "Requested initial validator committee is unavailable", + { + "transaction_hash": context.transaction.hash, + "requested_validators": requested_initial_validators, + "available_validators": len(all_validators or []), + }, + transaction_hash=context.transaction.hash, + ) + ) + return UndeterminedState() + + if not all_validators: + context.msg_handler.send_message( + LogEvent( + "consensus_event", + EventType.ERROR, + EventScope.CONSENSUS, + "No validators found to process transaction", + {"transaction_hash": context.transaction.hash}, + transaction_hash=context.transaction.hash, + ) + ) + raise NoValidatorsAvailableError( + f"No validators available for transaction {context.transaction.hash}" + ) + + # Validator selection (impure: VRF, DB reads, static methods) + if ( + context.transaction.appealed + or context.transaction.appeal_validators_timeout + ): + if context.transaction.consensus_data is not None: + context.involved_validators, _ = ( + ConsensusAlgorithm.get_validators_from_consensus_data( + all_validators, context.transaction.consensus_data, False + ) + ) + else: + context.involved_validators = get_validators_for_transaction( + all_validators, context.transaction.num_of_initial_validators + ) + + context.transactions_processor.set_transaction_appeal( + context.transaction.hash, False + ) + context.transaction.appealed = False + context.transaction.appeal_validators_timeout = context.transactions_processor.set_transaction_appeal_validators_timeout( + context.transaction.hash, False + ) + + elif context.transaction.appeal_undetermined: + if context.transaction.consensus_data is None: + context.transactions_processor.set_transaction_appeal_undetermined( + context.transaction.hash, False + ) + context.transaction.appeal_undetermined = False context.involved_validators = get_validators_for_transaction( all_validators, context.transaction.num_of_initial_validators ) else: - current_validators, extra_validators = ( - ConsensusAlgorithm.get_extra_validators( + context.involved_validators = ( + ConsensusAlgorithm.get_leader_replay_validators( all_validators, context.transaction.consensus_history, context.transaction.consensus_data, - 0, + VALIDATORS_PER_ROUND[ + min( + max(0, context.active_fee_round), + len(VALIDATORS_PER_ROUND) - 1, + ) + ], ) ) - context.involved_validators = current_validators + extra_validators context.consensus_service.emit_transaction_event( "emitAppealStarted", @@ -1605,28 +2852,40 @@ async def handle(self, context): ) elif context.transaction.appeal_leader_timeout: - used_leader_addresses = ( - ConsensusAlgorithm.get_used_leader_addresses_from_consensus_history( - context.transaction.consensus_history - ) - ) - assert context.validators_snapshot is not None - old_validators = [ - x.validator.to_dict() for x in context.validators_snapshot.nodes - ] - context.involved_validators = ConsensusAlgorithm.add_new_validator( - old_validators, - context.transaction.leader_timeout_validators, - used_leader_addresses, + # Consensus removes the timed-out leader and replays with the + # surviving committee; it does not select a replacement seat. + context.involved_validators = list( + context.transaction.leader_timeout_validators ) else: if context.transaction.consensus_data: - context.involved_validators, _ = ( - ConsensusAlgorithm.get_validators_from_consensus_data( - all_validators, context.transaction.consensus_data, True - ) + history_entries = logical_fee_round_entries( + context.transaction.consensus_history + ) + last_outcome = ( + str(history_entries[-1][1].get("consensus_round") or "") + if history_entries + else "" ) + if last_outcome in TERMINAL_VALIDATOR_APPEAL_ROUNDS: + # A successful validator review is followed by one + # terminal normal recomputation over the full frozen + # electorate, excluding only prior normal-round leaders. + # Prior validators and jurors are deliberately eligible + # again; this is not another fresh-jury selection. + context.involved_validators = ( + ConsensusAlgorithm.get_terminal_replacement_validators( + all_validators, + context.transaction.consensus_history, + ) + ) + else: + context.involved_validators, _ = ( + ConsensusAlgorithm.get_validators_from_consensus_data( + all_validators, context.transaction.consensus_data, True + ) + ) if not context.involved_validators: context.msg_handler.send_message( LogEvent( @@ -1651,7 +2910,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, @@ -1725,7 +2984,11 @@ def leader_timing_callback(step_name: str): leader_budget_seconds = _slot_budget_seconds(context.transaction, "leader") leader_deadline = asyncio.get_running_loop().time() + leader_budget_seconds - def _build_leader_timeout_receipt(leader_dict: dict) -> Receipt: + def _build_leader_timeout_receipt( + leader_dict: dict, + *, + detail: str | None = None, + ) -> Receipt: timeout_ms = int(leader_budget_seconds * 1000) return Receipt( result=bytes([ResultCode.VM_ERROR]) + b"timeout", @@ -1738,7 +3001,8 @@ def _build_leader_timeout_receipt(leader_dict: dict) -> Receipt: vote=None, genvm_result={ "stdout": "", - "stderr": f"Leader execution exceeded {leader_budget_seconds:.3f}s", + "stderr": detail + or f"Leader execution exceeded {leader_budget_seconds:.3f}s", "error_code": "CONSENSUS_LEADER_EXEC_TIMEOUT", "raw_error": { "causes": ["LEADER_EXEC_TIMEOUT"], @@ -1835,6 +3099,28 @@ def _build_leader_timeout_receipt(leader_dict: dict) -> Receipt: context.consensus_data.leader_receipt[0].result[0] == ResultCode.VM_ERROR and context.consensus_data.leader_receipt[0].result[1:] == b"timeout" ) + if not leader_receipt_timed_out: + try: + proposal_policy = stamp_receipt_execution_policy( + context.consensus_data.leader_receipt[0], + (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY), + StudioFeePolicy.from_env(), + ) + validate_receipt_admission_caps( + context.consensus_data.leader_receipt[0], + proposal_policy, + ) + except FeeValidationError as exc: + # The on-chain proposal/reveal call reverts on these caps. + # Studio advances to the equivalent leader-timeout path + # instead of accepting a receipt Consensus would never store. + context.consensus_data.leader_receipt = [ + _build_leader_timeout_receipt( + context.leader, + detail=str(exc), + ) + ] + leader_receipt_timed_out = True # Post-execution decision next_state, post_effects = decide_post_proposal( @@ -1922,7 +3208,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() @@ -2116,10 +3402,14 @@ def _is_quorum_reached(votes_so_far: list[str], total_votes: int) -> bool: Vote.AGREE.value, Vote.DISAGREE.value, Vote.TIMEOUT.value, + Vote.DETERMINISTIC_VIOLATION.value, Vote.IDLE.value, ) outcomes = { - determine_consensus_from_votes(votes_so_far + [pending_vote] * pending) + determine_consensus_from_votes( + votes_so_far + [pending_vote] * pending, + _terminal_decision_electorate_size(context), + ) for pending_vote in possible_pending_votes } if len(outcomes) != 1: @@ -2260,17 +3550,23 @@ async def handle(self, context): ) # Determine consensus result - consensus_result = determine_consensus_from_votes(list(context.votes.values())) + consensus_result = determine_consensus_from_votes( + list(context.votes.values()), + _terminal_decision_electorate_size(context), + ) - # Build vote reveal entries (IDLE→TIMEOUT for on-chain events) + # Build vote reveal entries with canonical v0.6 VoteType ordinals. vote_reveal_entries = [] for validation_result in context.validation_results: - chain_vote = ( - Vote.TIMEOUT - if validation_result.vote == Vote.IDLE - else validation_result.vote + vote_reveal_entries.append( + ( + validation_result.node_config, + consensus_vote_type_code( + validation_result.vote, + validation_result.execution_result, + ), + ) ) - vote_reveal_entries.append((validation_result.node_config, int(chain_vote))) # Leader receipt split if ( @@ -2306,7 +3602,10 @@ async def handle(self, context): appeal_validators_timeout=context.transaction.appeal_validators_timeout, appeal_undetermined=context.transaction.appeal_undetermined, rotation_count=context.rotation_count, - config_rotation_rounds=context.transaction.config_rotation_rounds, + config_rotation_rounds=_runtime_rotation_limit( + context.transaction, + context.active_fee_round, + ), vote_reveal_entries=vote_reveal_entries, consensus_data_dict=context.consensus_data.to_dict( strip_contract_state=True @@ -2404,6 +3703,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 @@ -2437,64 +3738,38 @@ async def handle(self, context): context.transaction.data.get("contract_address") if is_deploy else None ), code_slot_b64=( - base64.b64encode(get_code_slot()).decode("ascii") if is_deploy else None + base64.b64encode( + get_code_slot( + legacy=uses_legacy_storage( + transaction_genvm_executor_selector(context.transaction) + ) + ) + ).decode("ascii") + if is_deploy + else None ), 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) executor = EffectExecutor(context) await executor.execute(pre_effects) - # Impure: triggered transaction processing (needs DB reads for nonce/accounts) - # Cumulative: child emission happens on every acceptance round (including appeal re-acceptance) + # Consensus tombstones each tx-scoped logical message occurrence. The + # Studio bridge additionally makes the helper call, value reservation, + # and local child insertion replay-safe across worker/process failure. 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( + _dispatch_messages_for_phase( context, - pending_to_emit, + leader_receipt, "accepted", - ) - - rollup_receipt = context.consensus_service.emit_transaction_event( - "emitTransactionAccepted", - leader_receipt.node_config, - context.transaction.hash, - internal_messages_data, - ) - - _emit_messages( - context, insert_transactions_data, rollup_receipt, "accepted" + force_phase_event=True, ) # Execute post-effects (status update + appeal cleanup) @@ -2539,250 +3814,1170 @@ async def handle(self, context): ), ) - await EffectExecutor(context).execute(effects) + await EffectExecutor(context).execute(effects) + + # Context mutation: clear appeal_undetermined flag + if context.transaction.appeal_undetermined: + context.transaction.appeal_undetermined = False + + return None + + +class LeaderTimeoutState(TransactionState): + """ + Class representing the leader timeout state of a transaction. + """ + + async def handle(self, context): + effects, _ = decide_leader_timeout( + tx_hash=context.transaction.hash, + appeal_undetermined=context.transaction.appeal_undetermined, + appeal_leader_timeout=context.transaction.appeal_leader_timeout, + has_contract_snapshot=bool(context.transaction.contract_snapshot), + contract_snapshot_dict=( + context.contract_snapshot.to_dict() + if not context.transaction.contract_snapshot + else None + ), + leader_receipt=context.consensus_data.leader_receipt, + remaining_validators=context.remaining_validators, + leader=context.leader, + ) + + await EffectExecutor(context).execute(effects) + + return None + + +class ValidatorsTimeoutState(TransactionState): + """ + Class representing the validators timeout state of a transaction. + """ + + async def handle(self, context): + effects, _ = decide_validators_timeout( + tx_hash=context.transaction.hash, + appeal_undetermined=context.transaction.appeal_undetermined, + appeal_validators_timeout=context.transaction.appeal_validators_timeout, + appeal_leader_timeout=context.transaction.appeal_leader_timeout, + appeal_failed=context.transaction.appeal_failed, + has_contract_snapshot=bool(context.transaction.contract_snapshot), + contract_snapshot_dict=( + context.contract_snapshot.to_dict() + if not context.transaction.contract_snapshot + else None + ), + consensus_data_dict=context.consensus_data.to_dict( + strip_contract_state=True + ), + leader_receipt=context.consensus_data.leader_receipt, + validation_results=context.validation_results, + ) + + await EffectExecutor(context).execute(effects) + + # Context mutation: clear appeal_leader_timeout flag + if context.transaction.appeal_leader_timeout: + context.transaction.appeal_leader_timeout = False + + return None + + +class FinalizingState(TransactionState): + """ + Class representing the finalizing state of a transaction. + """ + + async def handle(self, context): + consensus_data = context.transaction.consensus_data + leader_receipts = ( + consensus_data.leader_receipt + if consensus_data is not None and consensus_data.leader_receipt + else [] + ) + leader_receipt = leader_receipts[0] if leader_receipts else None + + # Acceptance is a separate message lifecycle phase. If the helper EVM + # or worker died after the agreed decision was committed, repair that + # phase before allowing finalization to overtake it. + if leader_receipt is not None: + _dispatch_messages_for_phase(context, leader_receipt, "accepted") + + pre_effects, post_effects, should_finalize_contract = decide_finalizing( + tx_hash=context.transaction.hash, + tx_status_accepted=( + context.transaction.status == TransactionStatus.ACCEPTED + ), + execution_result_success=( + leader_receipt is not None + and leader_receipt.execution_result == ExecutionResultStatus.SUCCESS + ), + leader_node_config=( + leader_receipt.node_config if leader_receipt is not None else None + ), + ) + + executor = EffectExecutor(context) + await executor.execute(pre_effects) + + if not should_finalize_contract and int(context.transaction.value or 0) > 0: + value_beneficiary = context.transaction.from_address + if value_beneficiary: + refunded_activated = ( + context.accounts_manager.refund_activated_tx_value_once( + context.transaction.hash, + context.transaction.to_address, + value_beneficiary, + ) + ) + if not refunded_activated: + context.accounts_manager.refund_tx_value( + context.transaction.hash, + value_beneficiary, + ) + + # Impure: contract finalization + triggered transactions (needs DB reads) + if should_finalize_contract: + snapshot = context.contract_snapshot_factory(context.transaction.to_address) + if snapshot is None: + raise RuntimeError( + "Missing contract snapshot while finalizing a transaction" + ) + + accepted_state = snapshot.states.get("accepted") + if not accepted_state: + raise RuntimeError( + "Missing accepted contract state prior to finalization" + ) + + context.contract_processor.update_contract_state( + context.transaction.to_address, + finalized_state=accepted_state, + ) + + _dispatch_messages_for_phase( + context, + leader_receipt, + "finalized", + force_phase_event=True, + ) + + 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, + pending_transactions: Iterable[PendingTransaction], + on: Literal["accepted", "finalized"], +): + insert_transactions_data = [] + internal_messages_data = [] + message_fee_payloads = [] + parent_fee_accounting = (getattr(context.transaction, "data", None) 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_genlayer_transaction_count( + context.transaction.to_address + ) + phase_pending_transactions = list( + _pending_transactions_for_phase(pending_transactions, on) + ) + identity_payloads = ( + _reveal_message_fee_payloads(parent_fee_accounting, phase_pending_transactions) + if parent_fee_accounting + else [ + _pending_transaction_fee_payload(pending_transaction, on) + for pending_transaction in phase_pending_transactions + ] + ) + effect_identities = message_effect_identities( + context.transaction.hash, + identity_payloads, + ) + for nonce_offset, pending_transaction in enumerate(phase_pending_transactions): + nonce = base_nonce + nonce_offset + 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( + [ + ( + ZERO_ADDRESS + if pending_transaction.is_deploy() + else pending_transaction.address + ), + data, + transaction_type.value, + nonce, + pending_transaction.value, + effect_identities[nonce_offset][0], + identity_payloads[nonce_offset], + ] + ) + + if not pending_transaction.is_eth_send: + internal_messages_data.append( + _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]: + return ( + TransactionType.DEPLOY_CONTRACT, + { + # Consensus is the address authority for internal deployments. + # Keep the payload stable across retries and bind the local child + # to the returned CREATE/CREATE2 recipient after helper delivery. + "contract_address": ZERO_ADDRESS, + "contract_code": pending_transaction.code, + "calldata": pending_transaction.calldata, + }, + ) + + +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 or pending_transaction.use_balance: + 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 + and not pending_transaction.use_balance + and not parent_fee_accounting.get("message_allocations_from_grant") + ): + return + if not pending_transaction.use_balance and uses_unmetered_message_fee_pool( + parent_fee_accounting + ): + 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("_studioResolvedAllocationSubtree") + or message_payload.get("allocationSubtree") + or [] + ), + sender=( + context.transaction.to_address + if pending_transaction.use_balance + else next( + ( + contribution["depositor"] + for contribution in parent_fee_accounting.get("contributions") + or [] + if contribution.get("depositor") + ), + parent_fee_accounting.get("sender"), + ) + or context.transaction.origin_address + or context.transaction.from_address + ), + message_allocations_from_grant=bool( + parent_fee_accounting.get("message_allocations_from_grant") + ), + policy=execution_policy_for_accounting( + parent_fee_accounting, + 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, + } + ) + - # Context mutation: clear appeal_undetermined flag - if context.transaction.appeal_undetermined: - context.transaction.appeal_undetermined = False +def _internal_message_event_data( + context: TransactionContext, + pending_transaction: PendingTransaction, + data: dict, +) -> dict[str, Any]: + return { + "sender": context.transaction.to_address, + "recipient": ( + ZERO_ADDRESS + if pending_transaction.is_deploy() + else pending_transaction.address + ), + "saltNonce": pending_transaction.salt_nonce, + "data": json.dumps(_serializable_message_data(data)).encode(), + } - return None +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 -class LeaderTimeoutState(TransactionState): - """ - Class representing the leader timeout state of a transaction. - """ - async def handle(self, context): - effects, _ = decide_leader_timeout( - tx_hash=context.transaction.hash, - appeal_undetermined=context.transaction.appeal_undetermined, - appeal_leader_timeout=context.transaction.appeal_leader_timeout, - has_contract_snapshot=bool(context.transaction.contract_snapshot), - contract_snapshot_dict=( - context.contract_snapshot.to_dict() - if not context.transaction.contract_snapshot - else None +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 = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + lambda current: _consume_parent_message_fee_payloads( + current, + message_fee_payloads, + reveal_recorded, + _message_execution_fee_recipient(context), ), - leader_receipt=context.consensus_data.leader_receipt, - remaining_validators=context.remaining_validators, - leader=context.leader, + commit=False, ) + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - await EffectExecutor(context).execute(effects) - - return None +def _message_execution_fee_recipient(context: TransactionContext) -> str | None: + """Best Studio analogue of Consensus' tx.origin execution recipient. -class ValidatorsTimeoutState(TransactionState): - """ - Class representing the validators timeout state of a transaction. + Acceptance processing happens inside the leader's reveal transaction on + chain. Studio does not expose a separate public external-message flush EOA, + so finalization uses the leader attached to the accepted receipt as the + observable executor instead of incorrectly reimbursing the transaction + sender. """ - async def handle(self, context): - effects, _ = decide_validators_timeout( - tx_hash=context.transaction.hash, - appeal_undetermined=context.transaction.appeal_undetermined, - appeal_validators_timeout=context.transaction.appeal_validators_timeout, - appeal_leader_timeout=context.transaction.appeal_leader_timeout, - appeal_failed=context.transaction.appeal_failed, - has_contract_snapshot=bool(context.transaction.contract_snapshot), - contract_snapshot_dict=( - context.contract_snapshot.to_dict() - if not context.transaction.contract_snapshot - else None - ), - consensus_data_dict=context.consensus_data.to_dict( - strip_contract_state=True - ), - leader_receipt=context.consensus_data.leader_receipt, - validation_results=context.validation_results, + receipt_sources = [ + getattr(context, "consensus_data", None), + getattr(context.transaction, "consensus_data", None), + ] + for source in receipt_sources: + if source is None: + continue + leader_receipts = ( + source.get("leader_receipt", []) + if isinstance(source, dict) + else getattr(source, "leader_receipt", []) + ) + if isinstance(leader_receipts, dict): + leader_receipts = [leader_receipts] + if not leader_receipts: + continue + receipt = leader_receipts[0] + node_config = ( + receipt.get("node_config", {}) + if isinstance(receipt, dict) + else getattr(receipt, "node_config", {}) ) + address = ( + node_config.get("address") + if isinstance(node_config, dict) + else getattr(node_config, "address", None) + ) + if address: + return str(address) + return None - await EffectExecutor(context).execute(effects) - # Context mutation: clear appeal_leader_timeout flag - if context.transaction.appeal_leader_timeout: - context.transaction.appeal_leader_timeout = False +def _consume_parent_message_fee_payloads( + parent_fee_accounting: dict[str, Any], + message_fee_payloads: list[dict[str, Any]], + reveal_recorded: bool, + executor: str | None = None, +) -> dict[str, Any]: + try: + if reveal_recorded: + return record_external_message_execution_fees( + parent_fee_accounting, + message_fee_payloads, + executor=executor, + ) + return consume_message_fees( + parent_fee_accounting, + message_fee_payloads, + external_executor=executor, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc - return None +def _sync_reveal_message_fee_accounting( + context: TransactionContext, + leader_receipt: Receipt, +) -> None: + if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: + _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 + + def prepare_latest(current_fee_accounting): + message_fee_payloads = _reveal_message_fee_payloads( + current_fee_accounting, + leader_receipt.pending_transactions, + ) + try: + return prepare_reveal_message_generation( + current_fee_accounting, + context.transaction.hash, + message_fee_payloads, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + updated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + prepare_latest, + commit=False, + ) + ) -class FinalizingState(TransactionState): - """ - Class representing the finalizing state of a transaction. - """ + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - async def handle(self, context): - leader_receipt = context.transaction.consensus_data.leader_receipt[0] - pre_effects, post_effects, should_finalize_contract = decide_finalizing( - tx_hash=context.transaction.hash, - tx_status_accepted=( - context.transaction.status == TransactionStatus.ACCEPTED - ), - execution_result_success=( - leader_receipt.execution_result == ExecutionResultStatus.SUCCESS - ), - leader_node_config=leader_receipt.node_config, +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 and not pending_transaction.use_balance: + message_payload = fill_message_fee_payload_from_allocation( + parent_fee_accounting, + message_payload, + ) + message_fee_payloads.append(message_payload) + return message_fee_payloads - executor = EffectExecutor(context) - await executor.execute(pre_effects) - # Impure: contract finalization + triggered transactions (needs DB reads) - if should_finalize_contract: - snapshot = context.contract_snapshot_factory(context.transaction.to_address) - if snapshot is None: - raise RuntimeError( - "Missing contract snapshot while finalizing a transaction" - ) +def _novel_pending_transactions( + context: TransactionContext, + pending_transactions: Iterable[Any], +) -> list[PendingTransaction]: + pending_list = [ + _coerce_pending_transaction(pending_transaction) + for pending_transaction in pending_transactions + ] + parent_fee_accounting = (getattr(context.transaction, "data", None) or {}).get( + FEE_ACCOUNTING_KEY + ) + if not parent_fee_accounting or not pending_list: + return pending_list - accepted_state = snapshot.states.get("accepted") - if not accepted_state: - raise RuntimeError( - "Missing accepted contract state prior to finalization" - ) + payloads = _reveal_message_fee_payloads(parent_fee_accounting, pending_list) + try: + novelty = message_novelty_mask( + parent_fee_accounting, + context.transaction.hash, + payloads, + ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + return [ + pending_transaction + for pending_transaction, is_novel in zip(pending_list, novelty) + if is_novel + ] - context.contract_processor.update_contract_state( - context.transaction.to_address, - 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 +def _mark_message_phase_delivered( + context: TransactionContext, + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], +) -> None: + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if not parent_fee_accounting: + return + pending_list = list(pending_transactions) + + def mark_latest(current_fee_accounting): + payloads = _reveal_message_fee_payloads( + current_fee_accounting, + pending_list, + ) + try: + return mark_message_effects_delivered( + current_fee_accounting, + context.transaction.hash, + payloads, + on, ) - 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}" - ) + except FeeValidationError as exc: + raise RuntimeError(str(exc)) from exc + + updated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + mark_latest, + commit=False, + ) + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - # 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, - "finalized", - ) +def _message_phase_delivery_required( + context: TransactionContext, + pending_transactions: Iterable[Any], + on: Literal["accepted", "finalized"], + *, + force_phase_event: bool = False, +) -> bool: + if force_phase_event: + return True + + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + if not isinstance(parent_fee_accounting, dict): + return False + + pending_list = [ + _coerce_pending_transaction(pending_transaction) + for pending_transaction in pending_transactions + ] + if pending_list: + payloads = _reveal_message_fee_payloads(parent_fee_accounting, pending_list) + novelty = message_novelty_mask( + parent_fee_accounting, + context.transaction.hash, + payloads, + ) + if any( + is_novel and pending_transaction.on == on + for pending_transaction, is_novel in zip(pending_list, novelty) + ): + return True + + if on == "accepted": + generation = parent_fee_accounting.get("active_message_generation") + if isinstance(generation, dict): + pending_generation = bool( + generation.get("acceptanceDispatchRequired", False) + ) and not bool(generation.get("acceptanceDispatched", False)) + if pending_generation: + return True + if bool((parent_fee_accounting.get("message_phase_emitted") or {}).get(on, False)): + return False + return False - rollup_receipt = context.consensus_service.emit_transaction_event( - "emitTransactionFinalized", - leader_receipt.node_config, - context.transaction.hash, - internal_messages_data, - ) - _emit_messages( - context, insert_transactions_data, rollup_receipt, "finalized" - ) +def _dispatch_messages_for_phase( + context: TransactionContext, + leader_receipt: Receipt, + on: Literal["accepted", "finalized"], + *, + force_phase_event: bool = False, +) -> bool: + if leader_receipt.execution_result != ExecutionResultStatus.SUCCESS: + return False + if not _message_phase_delivery_required( + context, + leader_receipt.pending_transactions, + on, + force_phase_event=force_phase_event, + ): + return False - await executor.execute(post_effects) + novel_pending_transactions = _novel_pending_transactions( + context, + leader_receipt.pending_transactions, + ) + _internal_messages_data, insert_transactions_data = _get_messages_data( + context, + _apply_message_value_withdrawals_for_phase( + context, + novel_pending_transactions, + on, + ), + on, + ) + # Studio's normal topology intentionally has no Hardhat node. Make the DB + # transaction the sole authority for child ids, CREATE/CREATE2 recipients, + # and per-child admission failure instead of allowing an optional, older + # shadow deployment to decide protocol state differently across replicas. + rollup_receipt = _author_message_phase_locally( + context, + insert_transactions_data, + on, + ) + _emit_messages( + context, + insert_transactions_data, + rollup_receipt, + on, + ) + _mark_message_phase_delivered( + context, + leader_receipt.pending_transactions, + on, + ) + return True -def _get_messages_data( +def _author_message_phase_locally( context: TransactionContext, - pending_transactions: Iterable[PendingTransaction], + insert_transactions_data: list, on: Literal["accepted", "finalized"], -): - insert_transactions_data = [] - internal_messages_data = [] - base_nonce = context.transactions_processor.get_transaction_count( - context.transaction.to_address +) -> dict[str, list[str]]: + """Mirror MessagePayments + CreationPhase in the request DB transaction.""" + + context.transactions_processor.lock_ghost_factory() + factory = GhostFactoryConfig.from_env() + successful_deployments = ( + context.transactions_processor.get_successful_ghost_creation_count() ) - nonce_offset = 0 - for pending_transaction in filter(lambda t: t.on == on, pending_transactions): - 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 + batch_deployments: set[str] = set() + tx_ids: list[str] = [] + recipients: list[str] = [] + zero_tx_id = "0x" + ("00" * 32) + prepared_children: list[tuple[list, int, str, bool]] = [] + + try: + pending_cap = int(os.environ.get("MAX_PENDING_PER_CONTRACT_DEFAULT", "20")) + except (TypeError, ValueError): + pending_cap = 20 + if pending_cap <= 0: + pending_cap = 20 + + for insert_transaction_data in insert_transactions_data: + transaction_type = insert_transaction_data[2] + if transaction_type == TransactionType.SEND.value: + continue + + actual_recipient = insert_transaction_data[0] + message_payload = insert_transaction_data[6] + # MessagePayments rejects an empty internal message before calling + # CreationPhase. Keep it a per-child contained failure: no ghost, + # queue slot, child id, or factory nonce is consumed. + payload_data = message_payload.get("data") + creation_failed = payload_data in {b"", "", "0x", None} + if ( + not creation_failed + and transaction_type == TransactionType.DEPLOY_CONTRACT.value + ): + salt_nonce = int(message_payload.get("saltNonce", 0) or 0) + if salt_nonce == 0: + actual_recipient = factory.address_for( + salt_nonce, + successful_deployments, + namespace=context.transaction.to_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) + # A salted deployment returns its address during the parent's + # GenVM execution. Bind the accepted child to that exact + # contract-visible identity; independently allocating an EVM + # GhostFactory address leaves the parent pointing at a contract + # that Studio never materialized (GAP-016). + actual_recipient = genvm_salted_child_address( + context.transaction.to_address, + salt_nonce, + get_simulator_chain_id(), ) - 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 + normalized = actual_recipient.lower() + if ( + normalized in batch_deployments + or context.transactions_processor.is_genvm_contract_address( + actual_recipient ) - 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, - } + ): + creation_failed = True + else: + batch_deployments.add(normalized) + successful_deployments += 1 + elif ( + not creation_failed + and not context.transactions_processor.is_genvm_contract_address( + actual_recipient + ) + ): + creation_failed = True + + prepared_children.append( + ( + insert_transaction_data, + transaction_type, + actual_recipient, + creation_failed, + ) + ) - insert_transactions_data.append( - [ - pending_transaction.address, - data, - transaction_type.value, - nonce, - pending_transaction.value, - ] + # A single Consensus transaction serializes every Queues.enqueueNewPending + # call. Studio workers processing unrelated parents do not, so lock all + # recipient queues in deterministic order before observing their depths. + context.transactions_processor.lock_pending_recipients( + actual_recipient + for _data, _type, actual_recipient, creation_failed in prepared_children + if not creation_failed + ) + + batch_pending: dict[str, int] = {} + for ( + insert_transaction_data, + transaction_type, + actual_recipient, + creation_failed, + ) in prepared_children: + + normalized_recipient = str(actual_recipient).lower() + if not creation_failed: + pending = context.transactions_processor.get_pending_transaction_count_for_address( + actual_recipient + ) + batch_pending.get( + normalized_recipient, 0 + ) + if pending >= pending_cap: + creation_failed = True + + if creation_failed: + tx_ids.append(zero_tx_id) + recipients.append( + ZERO_ADDRESS + if transaction_type == TransactionType.DEPLOY_CONTRACT.value + else to_checksum_address(actual_recipient) + ) + continue + + batch_pending[normalized_recipient] = ( + batch_pending.get(normalized_recipient, 0) + 1 + ) + occurrence = insert_transaction_data[5] + tx_ids.append( + _studio_child_transaction_id( + context.transaction.hash, + on, + occurrence, + ) ) + recipients.append(to_checksum_address(actual_recipient)) - 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") + return {"tx_ids_hex": tx_ids, "recipients": recipients} - internal_messages_data.append( - { - "sender": context.transaction.to_address, - "recipient": pending_transaction.address, - "data": json.dumps(serializable_data).encode(), - } + +def _studio_child_transaction_id( + parent_tx_id: str, + on: Literal["accepted", "finalized"], + occurrence: str, +) -> str: + """Stable Studio tx id for one Consensus message occurrence.""" + + domain = keccak(text="GenLayer/Studio/child-tx/v1") + phase = b"\x01" if on == "accepted" else b"\x00" + return ( + "0x" + + keccak( + domain + + to_bytes(hexstr=parent_tx_id).rjust(32, b"\x00") + + phase + + to_bytes(hexstr=occurrence).rjust(32, b"\x00") + ).hex() + ) + + +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 isinstance(parent_fee_accounting.get("active_message_generation"), dict) + and not prior_receipts + ): + return + + def unwind_latest(current_fee_accounting): + if isinstance(current_fee_accounting.get("active_message_generation"), dict): + return discard_active_message_generation(current_fee_accounting) + if not prior_receipts: + return current_fee_accounting + message_fee_payloads = _reveal_message_fee_payloads( + current_fee_accounting, + _receipt_pending_transactions(prior_receipts[0]), + ) + if not message_fee_payloads: + return current_fee_accounting + updated = unwind_reveal_message_fees( + current_fee_accounting, + message_fee_payloads, + acceptance_dispatched=( + context.transaction.status == TransactionStatus.ACCEPTED + ), + ) + updated["message_fees_recorded_at_reveal"] = True + return updated + + updated_accounting = ( + context.transactions_processor.mutate_transaction_fee_accounting( + context.transaction.hash, + unwind_latest, + commit=False, ) + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting - return internal_messages_data, insert_transactions_data + +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 + # GenVM represents an internal deployment with the abbreviated ``0x`` + # address, while the transaction envelope and Consensus allocation tree + # encode its recipient as address(0). Match against that canonical form; + # otherwise a quote discovered by sim_estimateTransactionFees succeeds in + # GenVM but is rejected during reveal with MessageNoMatchingAllocation. + recipient = ( + ZERO_ADDRESS if pending_transaction.is_deploy() else pending_transaction.address + ) + 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": recipient, + "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, + "useBalance": pending_transaction.use_balance, + "gasUsed": pending_transaction.gas_used, + } + + +def _child_config_rotation_rounds( + parent_rotations: int | None, data: dict[str, Any] +) -> int: + """Clamp a triggered child's runtime rotations to its funded schedule. + + ``transactions.config_rotation_rounds`` is nullable, so a parent may carry + no explicit schedule at all — rows written before the column was claimed, + or any row that legitimately stored NULL. Resolve that to the same default + Transaction itself uses rather than raising, which would fail every child + insert and retry the parent until it was cancelled. + """ + + if parent_rotations is None: + parent_rotations = int(os.getenv("VITE_MAX_ROTATIONS", 3)) + fees = data.get("fees_distribution") + if not isinstance(fees, dict): + return max(0, int(parent_rotations)) + rotations = fees.get("rotations") + if not isinstance(rotations, list): + rotations = fees.get("rotationsList") + if not isinstance(rotations, list): + return max(0, int(parent_rotations)) + if not rotations: + return 0 + return min(max(0, int(parent_rotations)), max(max(0, int(v)) for v in rotations)) + + +def _active_execution_fee_round(transaction: Transaction) -> int: + """Resolve the raw round once, before appeal flags mutate during retries.""" + + entries = logical_fee_round_entries(transaction.consensus_history) + last_round = entries[-1][0] if entries else 0 + if transaction.appeal_undetermined or transaction.appeal_leader_timeout: + return last_round + 2 + if entries and last_round % 2 == 1: + # Successful validator review is followed by one terminal normal round. + return last_round + 1 + return last_round + + +def _terminal_decision_electorate_size(context: TransactionContext) -> int | None: + """Frozen threshold authority for a terminal normal recomputation.""" + + entries = logical_fee_round_entries( + getattr(context.transaction, "consensus_history", None) + ) + if not entries: + return None + last_outcome = str(entries[-1][1].get("consensus_round") or "") + if last_outcome not in TERMINAL_VALIDATOR_APPEAL_ROUNDS: + return None + + accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + frozen_count = accounting.get("selection_pool_count") + if frozen_count is not None: + return max(0, int(frozen_count)) + if context.validators_snapshot is not None: + return len(context.validators_snapshot.nodes) + return None + + +def _runtime_rotation_limit( + transaction: Transaction, + raw_round: int | None = None, +) -> int: + """Mirror Consensus' per-normal-round funded rotation allowance.""" + + if transaction.appealed or transaction.appeal_validators_timeout: + return 0 + if raw_round is None: + raw_round = _active_execution_fee_round(transaction) + + accounting = (transaction.data or {}).get(FEE_ACCOUNTING_KEY) or {} + fees_distribution = accounting.get("fees_distribution") + if not isinstance(fees_distribution, dict): + return max(0, int(transaction.config_rotation_rounds or 0)) + return runtime_rotations_for_round( + fees_distribution, + transaction.config_rotation_rounds, + raw_round, + ) + + +def _is_zero_transaction_hash(transaction_hash: Any) -> bool: + if not isinstance(transaction_hash, str): + return False + try: + return int(transaction_hash, 16) == 0 + except ValueError: + return False + + +def _refund_skipped_internal_message( + context: TransactionContext, + insert_transaction_data: list, + triggered_on: Literal["accepted", "finalized"], +) -> None: + occurrence = insert_transaction_data[5] + message_payload = insert_transaction_data[6] + refund_amount = int(insert_transaction_data[4] or 0) + if bool(message_payload.get("useBalance", False)): + refund_amount += int(message_payload.get("declaredBudget", 0) or 0) + + parent_fee_accounting = (context.transaction.data or {}).get(FEE_ACCOUNTING_KEY) + mutate_accounting = getattr( + context.transactions_processor, + "mutate_transaction_fee_accounting", + None, + ) + if not isinstance(parent_fee_accounting, dict) or not callable(mutate_accounting): + if refund_amount > 0: + context.accounts_manager.credit_account_balance( + context.transaction.to_address, + refund_amount, + ) + return + + expected_descriptor = message_effect_identities( + context.transaction.hash, + [message_payload], + )[0][1] + + def refund_latest(current_fee_accounting: dict[str, Any]) -> dict[str, Any]: + current_record = ( + current_fee_accounting.get("message_value_effects") or {} + ).get(occurrence) + if not isinstance(current_record, dict): + raise RuntimeError(f"MessageValueEffectMissing({occurrence})") + if ( + current_record.get("descriptor") != expected_descriptor + or current_record.get("phase") != triggered_on + ): + raise RuntimeError( + "MessageValueEffectDescriptorMismatch" + f"({occurrence},{current_record.get('descriptor')}," + f"{expected_descriptor})" + ) + if bool(current_record.get("skippedRefunded", False)): + return current_fee_accounting + + updated = refund_failed_internal_message_fee( + current_fee_accounting, + message_payload, + ) + updated_record = updated.setdefault("message_value_effects", {})[occurrence] + updated_record["skipped"] = True + updated_record["skippedRefunded"] = True + updated_record["skippedRefundAmount"] = refund_amount + if refund_amount > 0: + context.accounts_manager.credit_account_balance( + context.transaction.to_address, + refund_amount, + ) + return updated + + updated_accounting = mutate_accounting( + context.transaction.hash, + refund_latest, + commit=False, + ) + context.transaction.data = dict(context.transaction.data or {}) + context.transaction.data[FEE_ACCOUNTING_KEY] = updated_accounting def _emit_messages( @@ -2790,11 +4985,97 @@ def _emit_messages( insert_transactions_data: list, receipt: dict, triggered_on: Literal["accepted", "finalized"], + *, + rollup_skipped: bool = False, ): + helper_transaction_count = sum( + insert_transaction_data[2] != TransactionType.SEND.value + for insert_transaction_data in insert_transactions_data + ) + if rollup_skipped: + # No helper chain exists to mint child ids or deployment recipients. + # Preserve the pre-rollup fallback: the local insertion layer derives + # both, while external effects keep their durable occurrence id. + tx_ids = [None] * helper_transaction_count + recipients = [ + insert_transaction_data[0] + for insert_transaction_data in insert_transactions_data + if insert_transaction_data[2] != TransactionType.SEND.value + ] + else: + if not isinstance(receipt, dict): + raise InternalMessageEmissionError( + f"InternalMessageEmissionFailed({context.transaction.hash},{triggered_on})" + ) + tx_ids = receipt.get("tx_ids_hex") + if not isinstance(tx_ids, list) or len(tx_ids) != helper_transaction_count: + raise InternalMessageEmissionError( + "InternalMessageEmissionCountMismatch" + f"({context.transaction.hash},{triggered_on}," + f"{helper_transaction_count}," + f"{len(tx_ids) if isinstance(tx_ids, list) else 'missing'})" + ) + recipients = receipt.get("recipients") + if ( + not isinstance(recipients, list) + or len(recipients) != helper_transaction_count + ): + raise InternalMessageEmissionError( + "InternalMessageEmissionRecipientCountMismatch" + f"({context.transaction.hash},{triggered_on}," + f"{helper_transaction_count}," + f"{len(recipients) if isinstance(recipients, list) else 'missing'})" + ) + + helper_index = 0 for i, insert_transaction_data in enumerate(insert_transactions_data): - transaction_hash = ( - receipt["tx_ids_hex"][i] if receipt and "tx_ids_hex" in receipt else None - ) + is_external = insert_transaction_data[2] == TransactionType.SEND.value + if is_external: + # Consensus external messages are effects, not child consensus + # transactions. The logical occurrence is their durable local id. + transaction_hash = insert_transaction_data[5] + actual_recipient = insert_transaction_data[0] + else: + transaction_hash = tx_ids[helper_index] + actual_recipient = recipients[helper_index] + helper_index += 1 + is_deploy = insert_transaction_data[2] == TransactionType.DEPLOY_CONTRACT.value + if not (rollup_skipped and is_deploy) and ( + not isinstance(actual_recipient, str) or not is_address(actual_recipient) + ): + raise InternalMessageEmissionError( + "InternalMessageEmissionInvalidRecipient" + f"({context.transaction.hash},{triggered_on},{i})" + ) + if not (rollup_skipped and is_deploy): + actual_recipient = to_checksum_address(actual_recipient) + if not is_external and _is_zero_transaction_hash(transaction_hash): + _refund_skipped_internal_message( + context, + insert_transaction_data, + triggered_on, + ) + continue + if is_deploy and not rollup_skipped: + if actual_recipient == to_checksum_address(ZERO_ADDRESS): + raise InternalMessageEmissionError( + "InternalMessageEmissionZeroDeploymentRecipient" + f"({context.transaction.hash},{triggered_on},{i})" + ) + insert_transaction_data[1]["contract_address"] = actual_recipient + context.accounts_manager.create_new_account_with_address( + actual_recipient, + commit=False, + ) + elif ( + not is_deploy + and actual_recipient.lower() != str(insert_transaction_data[0]).lower() + ): + raise InternalMessageEmissionError( + "InternalMessageEmissionRecipientMismatch" + f"({context.transaction.hash},{triggered_on},{i}," + f"{insert_transaction_data[0]},{actual_recipient})" + ) # Determine execution_mode to cascade from parent transaction execution_mode_str = ( context.transaction.execution_mode.value @@ -2806,16 +5087,22 @@ def _emit_messages( context.transactions_processor.insert_transaction( context.transaction.to_address, # new calls are done by the contract - insert_transaction_data[0], + actual_recipient, insert_transaction_data[1], value=insert_transaction_data[4], type=insert_transaction_data[2], nonce=insert_transaction_data[3], leader_only=leader_only, # Backward compat - num_of_initial_validators=context.transaction.num_of_initial_validators, + # Consensus creates every internal child at the protocol's + # round-0 committee size. The child's minimum fee floor is priced + # on the same basis, independent of the parent's current size. + num_of_initial_validators=VALIDATORS_PER_ROUND[0], triggered_by_hash=context.transaction.hash, transaction_hash=transaction_hash, - config_rotation_rounds=context.transaction.config_rotation_rounds, + config_rotation_rounds=_child_config_rotation_rounds( + context.transaction.config_rotation_rounds, + insert_transaction_data[1], + ), sim_config=( context.transaction.sim_config.to_dict() if context.transaction.sim_config @@ -2824,4 +5111,8 @@ def _emit_messages( triggered_on=triggered_on, execution_mode=execution_mode_str, # Cascade execution mode origin_address=context.transaction.origin_address, + # MessagePayments creates the whole batch in one EVM transaction. + # Do not commit each Studio child independently; the worker commits + # the parent phase, child rows, accounts, values, and fees together. + commit=False, ) diff --git a/backend/consensus/constants.py b/backend/consensus/constants.py new file mode 100644 index 000000000..b2ae6410d --- /dev/null +++ b/backend/consensus/constants.py @@ -0,0 +1 @@ +DEFAULT_VALIDATORS_COUNT = 5 diff --git a/backend/consensus/decisions.py b/backend/consensus/decisions.py index 1aef1228a..4cac5c283 100644 --- a/backend/consensus/decisions.py +++ b/backend/consensus/decisions.py @@ -32,8 +32,11 @@ SetTimestampLastVoteEffect, Effect, ) -from backend.consensus.types import ConsensusResult, ConsensusRound - +from backend.consensus.types import ( + ConsensusResult, + ConsensusRound, + consensus_result_type_code, +) # ── UndeterminedState ────────────────────────────────────────────── @@ -321,6 +324,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 +423,7 @@ def decide_accepted( }, }, }, + "genvm_executor_selector": genvm_executor_selector, } pre_effects.append(RegisterContractEffect(contract_data=new_contract)) pre_effects.append( @@ -478,7 +483,7 @@ def decide_finalizing( tx_hash: str, tx_status_accepted: bool, execution_result_success: bool, - leader_node_config: dict, + leader_node_config: dict | None, ) -> tuple[list[Effect], list[Effect], bool]: """Decide effects for FinalizingState. @@ -501,7 +506,7 @@ def decide_finalizing( should_finalize_contract = tx_status_accepted and execution_result_success - if not should_finalize_contract: + if not should_finalize_contract and leader_node_config: # Emit rollup event with empty messages pre_effects.append( EmitRollupEventEffect( @@ -528,23 +533,18 @@ def merge_appeal_validators( current_validation_results: list, appeal_failed: int, ) -> tuple[dict, list]: - """Pure merge of appeal vote/validator data. + """Retain the transaction-wide consumed set after a fresh appeal jury. Returns: (merged_votes, merged_validators) """ merged_votes = existing_votes | current_votes - if appeal_failed == 0: - merged_validators = existing_validators + current_validation_results - elif appeal_failed == 1: - n = (len(existing_validators) - 1) // 2 - merged_validators = existing_validators[: n - 1] + current_validation_results - else: - n = len(current_validation_results) - (len(existing_validators) + 1) - merged_validators = existing_validators[: n - 1] + current_validation_results - - return merged_votes, merged_validators + # Consensus's selection authority permanently excludes every registry + # index consumed by earlier rounds. Studio stores the equivalent address + # set in consensus_data; never slice old validators back out after a failed + # appeal or a later jury could select them again. + return merged_votes, existing_validators + current_validation_results def decide_revealing( @@ -603,7 +603,7 @@ def decide_revealing( node_config["address"], chain_vote_int, is_last, - int(consensus_result) if is_last else int(ConsensusResult.IDLE), + consensus_result_type_code(consensus_result) if is_last else 0, ), ) ) @@ -656,6 +656,7 @@ def decide_revealing( elif consensus_result in ( ConsensusResult.MAJORITY_DISAGREE, ConsensusResult.NO_MAJORITY, + ConsensusResult.DETERMINISTIC_VIOLATION, ): if rotation_count >= config_rotation_rounds: return "undetermined", effects diff --git a/backend/consensus/errors.py b/backend/consensus/errors.py new file mode 100644 index 000000000..4597cafc5 --- /dev/null +++ b/backend/consensus/errors.py @@ -0,0 +1,2 @@ +class NoValidatorsAvailableError(Exception): + """Raised when no validators are available to process a transaction.""" diff --git a/backend/consensus/history.py b/backend/consensus/history.py new file mode 100644 index 000000000..d6d778dc5 --- /dev/null +++ b/backend/consensus/history.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import copy +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, +} + +# Studio records the verdict and execution of an appeal as one history item. +# Consensus, however, advances the fee round according to the appeal shape: +# validator appeals occupy the next odd round (skipping an even gap when +# chained), while leader/leader-timeout appeals create an appeal bookkeeping +# round and execute again in the following even round. Fee accounting must +# preserve those on-chain round numbers even though the UI history is compact. +LEADER_APPEAL_CONSENSUS_ROUNDS = { + ConsensusRound.LEADER_APPEAL_SUCCESSFUL.value, + ConsensusRound.LEADER_APPEAL_FAILED.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value, +} +VALIDATOR_APPEAL_CONSENSUS_ROUNDS = { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_APPEAL_FAILED.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, +} +TERMINAL_VALIDATOR_APPEAL_ROUNDS = { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, +} + +# Studio has no deployed TransactionManager, so persist the small part of its +# immutable DecisionRecord that governs exact-ID commands and appeal timing in +# consensus_history. Keeping it beside the round history makes snapshots and +# existing transaction reads carry the authority without a schema migration. +LATEST_DECISION_KEY = "latestDecision" +ACTIVE_APPEAL_BASIS_KEY = "activeAppealBasis" +# Studio processes an admitted appeal asynchronously, unlike the atomic +# on-chain call. Preserve the exact agreed transaction state so worker or +# process failures can retry the paid appeal without resetting the original +# decision or stranding its custody. +APPEAL_RECOVERY_SNAPSHOT_KEY = "appealRecoverySnapshot" +VALIDATOR_APPEAL_CONTEXT = "validatorAppeal" +LEADER_APPEAL_REPLAY_CONTEXT = "leaderAppealReplay" + + +def latest_decision_metadata( + consensus_history: dict[str, Any] | None, +) -> dict[str, Any] | None: + if not isinstance(consensus_history, dict): + return None + value = consensus_history.get(LATEST_DECISION_KEY) + return value if isinstance(value, dict) else None + + +def current_decision_id(consensus_history: dict[str, Any] | None) -> int: + """Return Studio's equivalent of the latest materialized DecisionId.""" + + metadata = latest_decision_metadata(consensus_history) + if metadata is not None: + try: + decision_id = int(metadata.get("decisionId") or 0) + except (TypeError, ValueError): + decision_id = 0 + if decision_id > 0: + return decision_id + round_index = completed_consensus_round_index(consensus_history) + return 1 + ((round_index + 1) // 2) + + +def prepare_appeal_decision_basis( + consensus_history: dict[str, Any] | None, + *, + expected_decision_id: int, + submitted_at: int, + appeal_deadline: int, + retention_bps: int, + appeal_context: str | None = None, +) -> dict[str, Any]: + """Freeze the appeal authority and validator-failure window at submission.""" + + updated = copy.deepcopy(consensus_history or {}) + decision_id = current_decision_id(updated) + if decision_id != int(expected_decision_id): + raise ValueError("DecisionBasisMismatch") + remaining = int(appeal_deadline) - int(submitted_at) + if remaining <= 0: + raise ValueError("CanNotAppeal") + if appeal_context is None: + decision = latest_decision_metadata(updated) or {} + decision_status = str(decision.get("status") or "").upper() + if not decision_status: + rounds = completed_consensus_rounds(updated) + if rounds: + decision_status = str(rounds[-1].get("consensus_round") or "").upper() + if decision_status in { + "ACCEPTED", + "VALIDATORS_TIMEOUT", + "VALIDATORS TIMEOUT", + }: + appeal_context = VALIDATOR_APPEAL_CONTEXT + elif decision_status in { + "UNDETERMINED", + "LEADER_TIMEOUT", + "LEADER TIMEOUT", + }: + appeal_context = LEADER_APPEAL_REPLAY_CONTEXT + if appeal_context not in { + VALIDATOR_APPEAL_CONTEXT, + LEADER_APPEAL_REPLAY_CONTEXT, + }: + raise ValueError("DecisionBasisMismatch") + retention_bps = min(10_000, max(0, int(retention_bps))) + next_window = (remaining * retention_bps) // 10_000 + if next_window == 0: + next_window = 1 + updated[ACTIVE_APPEAL_BASIS_KEY] = { + "decisionId": decision_id, + "context": appeal_context, + "submittedAt": int(submitted_at), + "nextAppealWindow": next_window, + } + return updated + + +def materialize_decision_metadata( + consensus_history: dict[str, Any] | None, + *, + status: str, + materialized_at: int, + default_appeal_window: int, +) -> dict[str, Any]: + """Advance DecisionId and bind one immutable Studio decision window.""" + + updated = copy.deepcopy(consensus_history or {}) + round_index = completed_consensus_round_index(updated) + decision_id = 1 + ((round_index + 1) // 2) + basis = updated.get(ACTIVE_APPEAL_BASIS_KEY) + if isinstance(basis, dict): + try: + basis_decision_id = int(basis.get("decisionId") or 0) + appeal_window = int(basis.get("nextAppealWindow") or 0) + except (TypeError, ValueError) as exc: + raise ValueError("DecisionBasisMismatch") from exc + if basis_decision_id != decision_id - 1 or appeal_window <= 0: + raise ValueError("DecisionBasisMismatch") + context = basis.get("context") + if context == LEADER_APPEAL_REPLAY_CONTEXT: + # Leader appeals clear the reduced-window override at submission, + # regardless of whether the replay changes the application result. + appeal_window = max(1, int(default_appeal_window)) + elif context == VALIDATOR_APPEAL_CONTEXT: + # Only a failed validator appeal resumes the incumbent with the + # submission-frozen reduced remainder. A successful challenge + # creates a new decision and therefore gets a fresh full window. + latest_appeal_round = next( + ( + str(entry.get("consensus_round") or "") + for entry in reversed(completed_consensus_rounds(updated)) + if str(entry.get("consensus_round") or "") + in VALIDATOR_APPEAL_CONSENSUS_ROUNDS + ), + None, + ) + if latest_appeal_round in TERMINAL_VALIDATOR_APPEAL_ROUNDS: + appeal_window = max(1, int(default_appeal_window)) + elif context is not None: + raise ValueError("DecisionBasisMismatch") + else: + appeal_window = max(1, int(default_appeal_window)) + + materialized_at = int(materialized_at) + updated[LATEST_DECISION_KEY] = { + "decisionId": decision_id, + "status": str(status).upper(), + "materializedAt": materialized_at, + "appealDeadline": materialized_at + appeal_window, + } + updated.pop(ACTIVE_APPEAL_BASIS_KEY, None) + return updated + + +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: + entries = logical_fee_round_entries(consensus_history) + return entries[-1][0] if entries else 0 + + +def has_terminal_validator_appeal( + consensus_history: dict[str, Any] | None, +) -> bool: + """Whether a successful validator review made the next normal round final.""" + + return any( + str(entry.get("consensus_round") or "") in TERMINAL_VALIDATOR_APPEAL_ROUNDS + for entry in completed_consensus_rounds(consensus_history) + ) + + +def _next_logical_fee_round(previous_round: int | None, outcome: str) -> int: + if previous_round is None: + return 0 + if outcome in LEADER_APPEAL_CONSENSUS_ROUNDS: + return previous_round + 2 + if outcome in VALIDATOR_APPEAL_CONSENSUS_ROUNDS: + return previous_round + (1 if previous_round % 2 == 0 else 2) + return previous_round + 1 + + +def logical_fee_round_entries( + consensus_history: dict[str, Any] | None, +) -> list[tuple[int, dict[str, Any]]]: + """Return compact Studio history entries keyed by on-chain fee round.""" + logical_entries: list[tuple[int, dict[str, Any]]] = [] + previous_round: int | None = None + for entry in completed_consensus_rounds(consensus_history): + outcome = str(entry.get("consensus_round") or "") + logical_round = _next_logical_fee_round(previous_round, outcome) + logical_entries.append((logical_round, entry)) + previous_round = logical_round + return logical_entries + + +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 + previous_round: int | None = None + 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 + round_index = _next_logical_fee_round(previous_round, event) + rotations[round_index] = pending_rotations + pending_rotations = 0 + previous_round = round_index + 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]], + *, + round_index: int | None = None, + 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) if round_index is None else round_index, + 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 + previous_round: int | None = None + + 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() + logical_round = _next_logical_fee_round(previous_round, consensus_round) + leader_used, validator_used = _record_round( + per_round, + round_index=logical_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 + previous_round = logical_round + + 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"]: + trailing_round = int(per_round[-1]["round"]) + 1 if per_round else 0 + leader_used, validator_used = _record_round( + per_round, + round_index=trailing_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/types.py b/backend/consensus/types.py index b96eb965d..1e10f0f54 100644 --- a/backend/consensus/types.py +++ b/backend/consensus/types.py @@ -1,5 +1,7 @@ from enum import Enum +from backend.node.types import ExecutionResultStatus, Vote + class ConsensusResult(Enum): IDLE = "IDLE" @@ -32,6 +34,46 @@ def __int__(self) -> int: return values[self] +def consensus_result_type_code(result: ConsensusResult) -> int: + """Return the canonical v0.6 ITransactions.ResultType ordinal.""" + + return { + ConsensusResult.IDLE: 0, + ConsensusResult.AGREE: 1, + ConsensusResult.MAJORITY_AGREE: 1, + ConsensusResult.DISAGREE: 2, + ConsensusResult.MAJORITY_DISAGREE: 2, + ConsensusResult.TIMEOUT: 3, + ConsensusResult.DETERMINISTIC_VIOLATION: 4, + ConsensusResult.NO_MAJORITY: 5, + }[result] + + +def consensus_vote_type_code( + vote: Vote | str, + execution_result: ExecutionResultStatus | str | None = None, +) -> int: + """Translate a Studio ballot to the canonical v0.6 VoteType ordinal.""" + + local_vote = Vote.from_string(vote) if isinstance(vote, str) else vote + if local_vote == Vote.NOT_VOTED: + return 0 + if local_vote == Vote.AGREE: + status = ( + execution_result.value + if isinstance(execution_result, ExecutionResultStatus) + else str(execution_result or "").upper() + ) + return 2 if status == ExecutionResultStatus.ERROR.value else 1 + if local_vote in {Vote.TIMEOUT, Vote.IDLE}: + return 3 + if local_vote == Vote.DISAGREE: + return 4 + if local_vote == Vote.DETERMINISTIC_VIOLATION: + return 5 + raise ValueError(f"Unsupported vote: {local_vote}") + + class ConsensusRound(Enum): ACCEPTED = "Accepted" LEADER_ROTATION = "Leader Rotation" diff --git a/backend/consensus/utils.py b/backend/consensus/utils.py index fc689df5f..8c9df4907 100644 --- a/backend/consensus/utils.py +++ b/backend/consensus/utils.py @@ -2,11 +2,13 @@ from backend.node.types import Vote -def determine_consensus_from_votes(votes_list: list[str]) -> ConsensusResult: +def determine_consensus_from_votes( + votes_list: list[str], electorate_size: int | None = None +) -> ConsensusResult: """ Determine consensus from a list of votes using actual majority (>50%). - IDLE votes count as DISAGREE (couldn't participate = didn't agree). + Studio's local IDLE sentinel is revealed on-chain as a Timeout ballot. Args: votes_list: List of vote strings @@ -14,22 +16,33 @@ def determine_consensus_from_votes(votes_list: list[str]) -> ConsensusResult: Returns: ConsensusResult: The consensus result """ - total = len(votes_list) + # Terminal normal rounds deliberately keep the strict-majority threshold + # of the full frozen electorate even though prior normal leaders are not + # seated. Ordinary rounds use their local committee size. + total = ( + len(votes_list) + if electorate_size is None + else max(len(votes_list), int(electorate_size)) + ) majority = total / 2 # need strictly more than half agree_count = votes_list.count(Vote.AGREE.value) disagree_count = votes_list.count(Vote.DISAGREE.value) timeout_count = votes_list.count(Vote.TIMEOUT.value) + deterministic_violation_count = votes_list.count(Vote.DETERMINISTIC_VIOLATION.value) idle_count = votes_list.count(Vote.IDLE.value) - # IDLE = couldn't participate → counts as disagree - effective_disagree = disagree_count + idle_count + # RevealingState classifies local IDLE as the protocol's Timeout vote for + # the rollup. The local decision must use that same classified ballot. + effective_timeout = timeout_count + idle_count if agree_count > majority: return ConsensusResult.MAJORITY_AGREE - elif effective_disagree > majority: + elif disagree_count > majority: return ConsensusResult.MAJORITY_DISAGREE - elif timeout_count > majority: + elif effective_timeout > majority: return ConsensusResult.TIMEOUT + elif deterministic_violation_count > majority: + return ConsensusResult.DETERMINISTIC_VIOLATION else: return ConsensusResult.NO_MAJORITY diff --git a/backend/consensus/vrf.py b/backend/consensus/vrf.py index 55c953600..96ecf942f 100644 --- a/backend/consensus/vrf.py +++ b/backend/consensus/vrf.py @@ -1,23 +1,32 @@ -from datetime import datetime import numpy as np -from backend.consensus.base import DEFAULT_VALIDATORS_COUNT +from numpy.random import Generator as RNG + +from backend.consensus.constants import DEFAULT_VALIDATORS_COUNT +from backend.consensus.errors import NoValidatorsAvailableError def get_validators_for_transaction( nodes: list[dict], num_validators: int | None = None, - rng=np.random.default_rng(seed=int(datetime.now().timestamp())), + rng: RNG | None = None, ) -> list[dict]: """ Returns subset of validators for a transaction. The selelction and order is given by a random sampling based on the stake of the validators. """ + if rng is None: + rng = np.random.default_rng() + if num_validators is None: num_validators = DEFAULT_VALIDATORS_COUNT num_validators = min(num_validators, len(nodes)) total_stake = sum(validator["stake"] for validator in nodes) + if total_stake <= 0: + raise NoValidatorsAvailableError( + "Cannot select validators: total validator stake must be positive." + ) probabilities = [validator["stake"] / total_stake for validator in nodes] selected_validators = rng.choice( diff --git a/backend/consensus/worker.py b/backend/consensus/worker.py index 1e6808508..c4d0232b0 100644 --- a/backend/consensus/worker.py +++ b/backend/consensus/worker.py @@ -11,10 +11,17 @@ 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 -from backend.consensus.base import ConsensusAlgorithm, NoValidatorsAvailableError +from backend.consensus.base import ( + ConsensusAlgorithm, + InternalMessageEmissionError, + NoValidatorsAvailableError, +) +from backend.consensus.history import APPEAL_RECOVERY_SNAPSHOT_KEY +from backend.protocol_rpc.fees import acceptance_dispatch_pending # Alias for use in context manager (avoids circular import issues) _NoValidatorsError = NoValidatorsAvailableError @@ -25,6 +32,105 @@ 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"}) + + +def _acceptance_dispatch_pending(transaction_data: dict[str, Any]) -> bool: + data = transaction_data.get("data") + if not isinstance(data, dict): + return False + accounting = data.get("fee_accounting") + if not isinstance(accounting, dict): + return False + return acceptance_dispatch_pending(accounting) + + +# --------------------------------------------------------------------------- +# 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"), + # Carried because the emission path clamps every triggered child to its + # parent's funded rotation schedule. Omitting it made + # Transaction.from_dict default the field to None, so each child insert + # raised TypeError and the parent retried until it was cancelled. + ("config_rotation_rounds", "config_rotation_rounds"), + ("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,20 +321,39 @@ 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 WHERE t.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') AND t.appealed = false AND ( + -- Repair an acceptance-phase helper emission before + -- the appeal deadline; finalization must not be the + -- first later opportunity to deliver accepted children. + (t.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatchRequired' = 'true' + AND COALESCE( + t.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatched', + 'false' + ) != 'true') + OR -- Normal: timestamp set and finality window elapsed (t.timestamp_awaiting_finalization IS NOT NULL AND ( t.execution_mode IN ('LEADER_ONLY', 'LEADER_SELF_VALIDATOR') OR ( - EXTRACT(EPOCH FROM NOW()) - t.timestamp_awaiting_finalization - COALESCE(t.appeal_processing_time, 0) - ) > :finality_window_seconds * POWER(1 - :appeal_failed_reduction, COALESCE(t.appeal_failed, 0)) + EXTRACT(EPOCH FROM NOW()) >= COALESCE( + NULLIF(t.consensus_history->'latestDecision'->>'appealDeadline', '')::double precision, + t.timestamp_awaiting_finalization + + COALESCE(t.appeal_processing_time, 0) + + :finality_window_seconds * POWER( + 1 - :appeal_failed_reduction, + COALESCE(t.appeal_failed, 0) + ) + ) + ) )) OR -- Defensive: timestamp NULL + row past stranded threshold @@ -241,7 +366,7 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: -- Ordering invariant: no older non-terminal tx on the same contract SELECT 1 FROM transactions earlier WHERE earlier.to_address IS NOT DISTINCT FROM t.to_address - AND earlier.created_at < t.created_at + AND earlier.queue_order < t.queue_order AND earlier.status NOT IN ('FINALIZED', 'CANCELED') AND earlier.hash != t.hash ) @@ -250,17 +375,20 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: SELECT 1 FROM transactions t2 WHERE t2.to_address IS NOT DISTINCT FROM t.to_address AND t2.blocked_at IS NOT NULL - AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash + AND ( + t2.status NOT IN ('FINALIZED', 'CANCELED') + OR t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) + ) ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) - ORDER BY t.created_at ASC + ORDER BY t.queue_order ASC FOR UPDATE SKIP LOCKED ), ready_for_finalization AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY to_address - ORDER BY created_at ASC + ORDER BY queue_order ASC ) as rn FROM locked_finalizations ), @@ -269,7 +397,7 @@ async def claim_next_finalization(self, session: Session) -> Optional[dict]: SELECT * FROM ready_for_finalization WHERE rn = 1 - ORDER BY created_at ASC + ORDER BY queue_order ASC LIMIT 1 ) UPDATE transactions @@ -277,13 +405,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 +427,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,31 +444,36 @@ 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 + SELECT t.hash, t.to_address, t.queue_order FROM transactions t WHERE t.appealed = true AND t.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') AND (t.blocked_at IS NULL OR t.blocked_at < NOW() - CAST(:timeout AS INTERVAL)) AND NOT EXISTS ( - -- Ensure no other appeal for same contract is being processed + -- An ancestor appeal can rewind every newer attempt on + -- the recipient. Never overlap it with any descendant + -- worker: otherwise that worker can publish ballots or + -- effects from the state generation being invalidated. SELECT 1 FROM transactions t2 WHERE t2.to_address IS NOT DISTINCT FROM t.to_address - AND t2.appealed = true AND t2.blocked_at IS NOT NULL - AND t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) AND t2.hash != t.hash + AND ( + t2.status NOT IN ('FINALIZED', 'CANCELED') + OR t2.blocked_at > NOW() - CAST(:timeout AS INTERVAL) + ) ) AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) - ORDER BY t.created_at ASC + ORDER BY t.queue_order ASC FOR UPDATE SKIP LOCKED ), available_appeals AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY to_address - ORDER BY created_at ASC + ORDER BY queue_order ASC ) as rn FROM locked_appeals ), @@ -377,7 +482,7 @@ async def claim_next_appeal(self, session: Session) -> Optional[dict]: SELECT * FROM available_appeals WHERE rn = 1 - ORDER BY created_at ASC + ORDER BY queue_order ASC LIMIT 1 ) UPDATE transactions @@ -385,16 +490,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 +506,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,9 +524,9 @@ 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 + SELECT t.hash, t.to_address, t.type, t.queue_order, t.recovery_count FROM transactions t WHERE t.status IN ('PENDING', 'ACTIVATED') AND (t.blocked_at IS NULL @@ -477,17 +547,39 @@ 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.queue_order < t.queue_order AND t3.status IN ('ACCEPTED', 'UNDETERMINED', 'LEADER_TIMEOUT', 'VALIDATORS_TIMEOUT') AND t3.appealed = false - AND t3.timestamp_awaiting_finalization IS NOT NULL AND ( - t3.execution_mode IN ('LEADER_ONLY', 'LEADER_SELF_VALIDATOR') + (t3.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatchRequired' = 'true' + AND COALESCE( + t3.data->'fee_accounting'->'active_message_generation' + ->>'acceptanceDispatched', + 'false' + ) != 'true') OR ( - EXTRACT(EPOCH FROM NOW()) - t3.timestamp_awaiting_finalization - COALESCE(t3.appeal_processing_time, 0) - ) > :finality_window_seconds * POWER(1 - :appeal_failed_reduction, COALESCE(t3.appeal_failed, 0)) + t3.timestamp_awaiting_finalization IS NOT NULL + AND ( + t3.execution_mode IN ('LEADER_ONLY', 'LEADER_SELF_VALIDATOR') + OR EXTRACT(EPOCH FROM NOW()) >= COALESCE( + NULLIF(t3.consensus_history->'latestDecision'->>'appealDeadline', '')::double precision, + t3.timestamp_awaiting_finalization + + COALESCE(t3.appeal_processing_time, 0) + + :finality_window_seconds * POWER( + 1 - :appeal_failed_reduction, + COALESCE(t3.appeal_failed, 0) + ) + ) + ) + ) ) AND (t3.blocked_at IS NULL OR t3.blocked_at < NOW() - CAST(:timeout AS INTERVAL)) @@ -498,14 +590,15 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: -- COALESCE handles NULL to_address (e.g. burn transactions) by -- falling back to the tx hash, giving each such tx its own lock. AND pg_try_advisory_xact_lock(hashtext(COALESCE(t.to_address, t.hash))) - ORDER BY CASE WHEN t.type = 3 THEN 0 ELSE 1 END, t.created_at ASC + ORDER BY CASE WHEN t.type = 3 THEN 0 ELSE 1 END, + t.queue_order ASC FOR UPDATE SKIP LOCKED ), oldest_per_contract AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY to_address ORDER BY CASE WHEN type = 3 THEN 0 ELSE 1 END, - created_at ASC + queue_order ASC ) as rn FROM candidate_transactions ), @@ -520,8 +613,7 @@ async def claim_next_transaction(self, session: Session) -> Optional[dict]: WHERE rn = 1 ORDER BY CASE WHEN recovery_count > 0 THEN 1 ELSE 0 END, CASE WHEN type = 3 THEN 0 ELSE 1 END, - created_at ASC, - hash ASC + queue_order ASC LIMIT 1 ) UPDATE transactions @@ -529,13 +621,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 +640,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 @@ -747,6 +811,11 @@ async def _transaction_context( Control to the processing logic """ transaction_reset = False + raw_transaction_data = tx_data.get("data") + protected_appeal_work = tx_type == "appeal" or ( + isinstance(raw_transaction_data, dict) + and APPEAL_RECOVERY_SNAPSHOT_KEY in raw_transaction_data + ) try: # Track current transaction for health monitoring self.current_transactions[tx_hash] = { @@ -762,11 +831,58 @@ async def _transaction_context( f"is_leader={e.is_leader}, message={e}, detail={e.detail}, ctx={e.ctx}" ) session.rollback() - - # Only reset transaction and stop worker for leader errors (or unknown origin) - # Validator errors can be handled by the consensus algorithm - # (continue with remaining validators) - if e.is_leader is False: + if protected_appeal_work: + self._restore_admitted_appeal_for_retry(tx_hash) + + # A paid appeal is already bound to an agreed decision. Reusing the + # ordinary transaction-reset path here would erase that decision and + # its consensus history while leaving the admitted appeal custody in + # ``transaction.data``. Preserve the appeal unchanged and release its + # claim so another worker can retry it instead. This is the Studio + # equivalent of an on-chain appeal call reverting before it mutates + # the transaction. + if protected_appeal_work: + if e.is_leader is False: + logger.warning( + f"[Worker {self.worker_id}] GenVM internal error in appeal validator " + f"for {tx_hash}; preserving the admitted appeal for retry" + ) + elif e.is_fatal: + transient_causes = _TRANSIENT_LEADER_FATAL_CAUSES.intersection( + e.causes or [] + ) + if transient_causes: + await asyncio.sleep( + float( + os.environ.get("GENVM_TRANSIENT_FATAL_BACKOFF_S", "3") + ) + ) + logger.warning( + f"[Worker {self.worker_id}] Transient fatal GenVM error in appeal " + f"leader ({', '.join(sorted(transient_causes))}); preserving the " + f"admitted appeal and keeping the worker alive" + ) + else: + logger.warning( + f"[Worker {self.worker_id}] Fatal GenVM error in appeal leader; " + f"preserving the admitted appeal for another worker and stopping " + f"this worker" + ) + self.running = False + else: + logger.warning( + f"[Worker {self.worker_id}] Retryable GenVM error in appeal leader for " + f"{tx_hash}; preserving the admitted appeal for retry" + ) + await self._handle_generic_error_retry( + tx_hash, + e, + cancel_on_exhaustion=False, + ) + # Only reset an ordinary transaction for leader errors (or unknown + # origin). Validator errors can be handled by the consensus + # algorithm (continue with remaining validators). + elif e.is_leader is False: # Validator error - don't reset, consensus will continue with remaining validators logger.warning( f"[Worker {self.worker_id}] GenVM internal error in validator for {tx_hash}, " @@ -789,6 +905,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 +931,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 @@ -815,7 +965,16 @@ async def _transaction_context( f"[Worker {self.worker_id}] Error processing {tx_type} {tx_hash}: {e}" ) session.rollback() - await self._handle_generic_error_retry(tx_hash, e) + if protected_appeal_work: + self._restore_admitted_appeal_for_retry(tx_hash) + await self._handle_generic_error_retry( + tx_hash, + e, + cancel_on_exhaustion=( + not protected_appeal_work + and not isinstance(e, InternalMessageEmissionError) + ), + ) finally: # Clear current transaction tracking self.current_transactions.pop(tx_hash, None) @@ -830,6 +989,27 @@ async def _transaction_context( exc_info=True, ) + def _restore_admitted_appeal_for_retry(self, tx_hash: str) -> bool: + """Undo partial, independently committed appeal state transitions.""" + + try: + with self.get_session() as restore_session: + restored = TransactionsProcessor( + restore_session + ).restore_transaction_appeal_for_retry(tx_hash) + if restored: + restore_session.commit() + else: + restore_session.rollback() + return restored + except Exception as restore_error: + logger.error( + f"[Worker {self.worker_id}] Failed to restore admitted appeal " + f"{tx_hash} for retry: {restore_error}", + exc_info=True, + ) + return False + # Statuses where the consensus result has been agreed and the tx is # awaiting finalization. Recovery must NOT wipe consensus_data / # consensus_history for these — that state is what gets promoted to @@ -868,6 +1048,104 @@ async def recover_stuck_transactions(self, session: Session) -> int: Number of transactions reset (escalations are logged but not counted as recoveries). """ + # Step 0: an interrupted paid appeal must resume from the exact agreed + # decision captured at admission. Appeal state handlers commit their + # progress incrementally, so an ordinary PENDING reset here would erase + # the decision while leaving its bond custody behind. + restore_appeals_query = text( + """ + UPDATE transactions + SET status = CAST(data->'appealRecoverySnapshot'->>'status' AS transaction_status), + consensus_history = data->'appealRecoverySnapshot'->'consensusHistory', + consensus_data = NULLIF( + data->'appealRecoverySnapshot'->'consensusData', + 'null'::jsonb + ), + contract_snapshot = NULLIF( + data->'appealRecoverySnapshot'->'contractSnapshot', + 'null'::jsonb + ), + appealed = TRUE, + appeal_failed = COALESCE( + (data->'appealRecoverySnapshot'->>'appealFailed')::integer, + 0 + ), + appeal_undetermined = COALESCE( + (data->'appealRecoverySnapshot'->>'appealUndetermined')::boolean, + FALSE + ), + appeal_leader_timeout = COALESCE( + (data->'appealRecoverySnapshot'->>'appealLeaderTimeout')::boolean, + FALSE + ), + appeal_validators_timeout = COALESCE( + (data->'appealRecoverySnapshot'->>'appealValidatorsTimeout')::boolean, + FALSE + ), + appeal_processing_time = COALESCE( + (data->'appealRecoverySnapshot'->>'appealProcessingTime')::integer, + 0 + ), + rotation_count = COALESCE( + (data->'appealRecoverySnapshot'->>'rotationCount')::integer, + 0 + ), + leader_timeout_validators = NULLIF( + data->'appealRecoverySnapshot'->'leaderTimeoutValidators', + 'null'::jsonb + ), + timestamp_awaiting_finalization = ( + data->'appealRecoverySnapshot'->>'timestampAwaitingFinalization' + )::bigint, + timestamp_appeal = ( + data->'appealRecoverySnapshot'->>'timestampAppeal' + )::bigint, + worker_id = NULL, + blocked_at = NULL + WHERE data ? 'appealRecoverySnapshot' + AND status IN :consensus_statuses + AND ( + ( + blocked_at IS NOT NULL + AND blocked_at < NOW() - CAST(:timeout AS INTERVAL) + AND COALESCE( + ( + SELECT to_timestamp(MAX(progress.value::double precision)) + FROM jsonb_each_text( + COALESCE( + transactions.consensus_history->'current_monitoring', + '{}'::jsonb + ) + ) AS progress(key, value) + WHERE progress.value ~ '^[0-9]+(\\.[0-9]+)?$' + ), + blocked_at + ) < NOW() - CAST(:timeout AS INTERVAL) + ) + OR + (blocked_at IS NULL + AND status IN ('PROPOSING', 'COMMITTING', 'REVEALING') + AND created_at < NOW() - CAST(:orphan_timeout AS INTERVAL)) + ) + RETURNING hash, status; + """ + ).bindparams(bindparam("consensus_statuses", expanding=True)) + restored_appeals = session.execute( + restore_appeals_query, + { + "timeout": f"{self.transaction_timeout_minutes} minutes", + "orphan_timeout": "5 minutes", + "consensus_statuses": list(self._CONSENSUS_RECOVERABLE_STATUSES), + }, + ).fetchall() + if restored_appeals: + session.commit() + for row in restored_appeals: + logger.info( + f"[Worker {self.worker_id}] Restored interrupted paid appeal " + f"{row.hash} to admitted status {row.status}" + ) + # Step 1: escalate txs that have already hit the recovery limit. # These have been reset N times and keep getting stuck — cancel # them so the per-contract queue can drain. @@ -889,6 +1167,7 @@ async def recover_stuck_transactions(self, session: Session) -> int: ) WHERE recovery_count >= :max_cycles AND status IN :consensus_statuses + AND NOT (data ? 'appealRecoverySnapshot') AND ( ( blocked_at IS NOT NULL @@ -983,6 +1262,7 @@ async def recover_stuck_transactions(self, session: Session) -> int: recovery_count = recovery_count + 1 WHERE recovery_count < :max_cycles AND status IN :consensus_statuses + AND NOT (data ? 'appealRecoverySnapshot') AND ( -- Case 1: Transactions with expired blocks ( @@ -1077,7 +1357,7 @@ async def recover_stuck_transactions(self, session: Session) -> int: f"Likely finalization-starvation — investigate." ) - return len(recovered) + return len(restored_appeals) + len(recovered) async def process_transaction(self, transaction_data: dict, session: Session): """ @@ -1187,6 +1467,9 @@ async def process_transaction(self, transaction_data: dict, session: Session): validators_snapshot, ) + TransactionsProcessor( + session + ).clear_transaction_appeal_recovery_snapshot(transaction.hash) session.commit() logger.info( f"[Worker {self.worker_id}] Successfully processed transaction {transaction.hash}" @@ -1205,7 +1488,19 @@ async def process_transaction(self, transaction_data: dict, session: Session): logger.warning( f"[Worker {self.worker_id}] No validators available for transaction {transaction_data['hash']}" ) - await self._handle_no_validators_retry(transaction_data, session) + raw_data = transaction_data.get("data") + if isinstance(raw_data, dict) and APPEAL_RECOVERY_SNAPSHOT_KEY in raw_data: + session.rollback() + self._restore_admitted_appeal_for_retry(tx_hash) + await self._handle_generic_error_retry( + tx_hash, + NoValidatorsAvailableError( + "No validators available for appeal recomputation" + ), + cancel_on_exhaustion=False, + ) + else: + await self._handle_no_validators_retry(transaction_data, session) except ContractNotFoundError as e: # Handle contract not found - mark as ACCEPTED with ERROR execution result @@ -1297,6 +1592,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 @@ -1322,10 +1623,19 @@ async def _handle_no_validators_retry( f"next attempt in {backoff}s" ) - async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): + async def _handle_generic_error_retry( + self, + tx_hash: str, + error: Exception, + *, + cancel_on_exhaustion: bool = True, + ): """ Handle retry logic for generic errors during transaction processing. - Implements exponential backoff and cancels after MAX_GENERIC_ERROR_RETRIES. + Implements exponential backoff and normally cancels after + MAX_GENERIC_ERROR_RETRIES. Paid appeals disable cancellation because + an infrastructure failure must not invalidate an already-agreed + decision or strand its appeal custody. Args: tx_hash: Transaction hash @@ -1339,7 +1649,10 @@ async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): retry_info["last_error"] = str(error) self._generic_error_retries[tx_hash] = retry_info - if retry_info["count"] >= self.MAX_GENERIC_ERROR_RETRIES: + if ( + retry_info["count"] >= self.MAX_GENERIC_ERROR_RETRIES + and cancel_on_exhaustion + ): # Cancel the transaction after max retries logger.error( f"[Worker {self.worker_id}] Transaction {tx_hash} canceled after " @@ -1362,6 +1675,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 @@ -1376,11 +1697,21 @@ async def _handle_generic_error_retry(self, tx_hash: str, error: Exception): del self._generic_error_retries[tx_hash] else: backoff = self._generic_error_base_backoff * ( - 2 ** (retry_info["count"] - 1) + 2 + ** min( + retry_info["count"] - 1, + self.MAX_GENERIC_ERROR_RETRIES - 1, + ) ) logger.warning( f"[Worker {self.worker_id}] Generic error for {tx_hash}, " - f"retry {retry_info['count']}/{self.MAX_GENERIC_ERROR_RETRIES}, " + f"retry {retry_info['count']}" + + ( + f"/{self.MAX_GENERIC_ERROR_RETRIES}" + if cancel_on_exhaustion + else " (appeal preserved)" + ) + + ", " f"next attempt in {backoff}s - error: {error}" ) @@ -1489,6 +1820,7 @@ async def _process_upgrade_transaction( """ import base64 from backend.node.genvm import get_code_slot + from backend.node.genvm.executor_selection import uses_legacy_storage from backend.database_handler.models import CurrentState tx_hash = transaction_data["hash"] @@ -1535,7 +1867,11 @@ async def _process_upgrade_transaction( code_slot_value = base64.b64encode(code_len_prefix + code_bytes).decode( "ascii" ) - code_slot_key = base64.b64encode(get_code_slot()).decode("ascii") + code_slot_key = base64.b64encode( + get_code_slot( + legacy=uses_legacy_storage(contract.genvm_executor_selector) + ) + ).decode("ascii") # Update contract data - update BOTH accepted and finalized state # Since upgrade transactions bypass consensus and go directly to FINALIZED, @@ -1640,6 +1976,27 @@ async def process_finalization(self, finalization_data: dict, session: Session): transactions_processor = transactions_processor_factory(session) + if _acceptance_dispatch_pending(finalization_data): + logger.info( + f"[Worker {self.worker_id}] Repairing accepted message delivery " + f"for transaction {transaction.hash}" + ) + await self.consensus_algorithm.repair_accepted_message_delivery( + transaction, + transactions_processor, + accounts_manager_factory(session), + lambda contract_address: contract_snapshot_factory( + contract_address, session, transaction + ), + contract_processor_factory(session), + node_factory, + ) + latest_transaction = transactions_processor.get_transaction_by_hash( + transaction.hash + ) + if latest_transaction is not None: + transaction = Transaction.from_dict(latest_transaction) + # Check if can finalize (appeal window expired) can_finalize = self.consensus_algorithm.can_finalize_transaction( transactions_processor, @@ -1698,6 +2055,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( @@ -1782,7 +2147,12 @@ async def process_appeal(self, appeal_data: dict, session: Session): validators_snapshot, ) + transactions_processor.clear_transaction_appeal_recovery_snapshot( + transaction.hash, include_pending=False + ) session.commit() + self._generic_error_retries.pop(transaction.hash, None) + self._leader_crash_retries.pop(transaction.hash, None) logger.info( f"[Worker {self.worker_id}] Successfully processed appeal for transaction {transaction.hash}" ) @@ -1795,6 +2165,16 @@ async def process_appeal(self, appeal_data: dict, session: Session): with self.get_session() as error_session: transactions_processor = TransactionsProcessor(error_session) + accounts_manager = AccountsManager(error_session) + + # The appeal never produced an outcome. Finalizing the original + # decision must not make its admitted bond look like a failed + # appeal: unwind the exact admission first, refunding both bond + # principal and induced-work funding to the appealer. + accounts_manager.abort_tx_appeal_admission_once( + tx_hash, + "contract_not_found_during_appeal", + ) await ConsensusAlgorithm.dispatch_transaction_status_update( transactions_processor, @@ -1802,6 +2182,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: + accounts_manager.settle_tx_fee_accounting_once( + tx_hash, + refund_recipient, + reason="finalized_contract_not_found_during_appeal", + ) error_session.commit() logger.info( @@ -1865,7 +2253,11 @@ def _is_in_backoff(self, transaction_data: dict) -> bool: generic_info = self._generic_error_retries.get(tx_hash) if generic_info: backoff = self._generic_error_base_backoff * ( - 2 ** (generic_info["count"] - 1) + 2 + ** min( + generic_info["count"] - 1, + self.MAX_GENERIC_ERROR_RETRIES - 1, + ) ) time_since_last = time.time() - generic_info["last_attempt"] if time_since_last < backoff: @@ -1906,23 +2298,29 @@ async def _try_claim_work(self, session: Session) -> bool: appeal_data = await self.claim_next_appeal(session) if appeal_data: - logger.debug( - f"[Worker {self.worker_id}] Claimed appeal for transaction {appeal_data['hash']}" - ) - task = asyncio.create_task(self._process_appeal_task(appeal_data)) - self._active_tasks.add(task) - return True + tx_hash = appeal_data["hash"] + if not self._is_in_backoff(appeal_data): + logger.debug( + f"[Worker {self.worker_id}] Claimed appeal for transaction {tx_hash}" + ) + task = asyncio.create_task(self._process_appeal_task(appeal_data)) + self._active_tasks.add(task) + return True + self.release_transaction(session, tx_hash) finalization_data = await self.claim_next_finalization(session) if finalization_data: - logger.debug( - f"[Worker {self.worker_id}] Claimed finalization for transaction {finalization_data['hash']}" - ) - task = asyncio.create_task( - self._process_finalization_task(finalization_data) - ) - self._active_tasks.add(task) - return True + tx_hash = finalization_data["hash"] + if not self._is_in_backoff(finalization_data): + logger.debug( + f"[Worker {self.worker_id}] Claimed finalization for transaction {tx_hash}" + ) + task = asyncio.create_task( + self._process_finalization_task(finalization_data) + ) + self._active_tasks.add(task) + return True + self.release_transaction(session, tx_hash) transaction_data = await self.claim_next_transaction(session) if transaction_data: 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..bd02ef034 100644 --- a/backend/database_handler/accounts_manager.py +++ b/backend/database_handler/accounts_manager.py @@ -3,8 +3,19 @@ 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, + abort_latest_appeal_admission, + cancel_fee_accounting, + settle_fee_accounting, +) +from backend.consensus.history import ( + ACTIVE_APPEAL_BASIS_KEY, + APPEAL_RECOVERY_SNAPSHOT_KEY, + completed_consensus_round_index, +) from sqlalchemy.orm import Session from sqlalchemy import text @@ -22,16 +33,21 @@ def _parse_account_data(self, account_data: CurrentState) -> dict: "updated_at": account_data.updated_at.isoformat(), } - def create_new_account(self) -> Account: + def create_new_account(self, *, commit: bool = True) -> Account: """ Used when generating intelligent contract's accounts or sending funds to a new account. Users should create their accounts client-side """ account = Account.create() - self.create_new_account_with_address(account.address) + self.create_new_account_with_address(account.address, commit=commit) return account - def create_new_account_with_address(self, address: str) -> Account: + def create_new_account_with_address( + self, + address: str, + *, + commit: bool = True, + ) -> Account: # Check if account already exists if not is_address(address): raise ValueError(f"Invalid address: {address}") @@ -50,18 +66,28 @@ def create_new_account_with_address(self, address: str) -> Account: # If account doesn't exist, create it account = CurrentState(id=address, data={}, balance=0) self.session.add(account) - self.session.commit() + if commit: + self.session.commit() + else: + self.session.flush() return account def is_valid_address(self, address: str) -> bool: return is_address(address) - def get_account(self, account_address: str) -> CurrentState | None: - """Private method to retrieve an account from the data base""" + @staticmethod + def _normalize_address(account_address: str) -> str: + """Normalize to the checksum form used as the primary key, matching + get_account()/create_new_account_with_address(). Falls back to the + original value if checksumming fails.""" try: - normalized = to_checksum_address(account_address) + return to_checksum_address(account_address) except Exception: - normalized = account_address + return account_address + + def get_account(self, account_address: str) -> CurrentState | None: + """Private method to retrieve an account from the data base""" + normalized = self._normalize_address(account_address) account = ( self.session.query(CurrentState) .filter(CurrentState.id == normalized) @@ -100,7 +126,7 @@ def debit_account_balance(self, account_address: str, amount: int) -> bool: "UPDATE current_state SET balance = balance - :amount " "WHERE id = :addr AND balance >= :amount" ), - {"amount": amount, "addr": account_address}, + {"amount": amount, "addr": self._normalize_address(account_address)}, ) return result.rowcount > 0 @@ -108,6 +134,7 @@ def credit_account_balance(self, account_address: str, amount: int): """Atomic credit. Creates account if it doesn't exist.""" if amount <= 0: return + account_address = self._normalize_address(account_address) self.session.execute( text( "INSERT INTO current_state (id, data, balance) " @@ -131,6 +158,7 @@ def credit_tx_value_once( Sets value_credited=true atomically. Returns True if credit was applied.""" if amount <= 0: return False + target_address = self._normalize_address(target_address) # Ensure target account exists self.session.execute( text( @@ -166,14 +194,226 @@ def refund_tx_value(self, tx_hash: str, sender_address: str) -> bool: Returns True if refund was applied.""" result = self.session.execute( text( - "SELECT value, value_credited FROM transactions " "WHERE hash = :hash" + "UPDATE transactions " + "SET data = jsonb_set(COALESCE(data, '{}'::jsonb), " + "'{uncreditedValueRefunded}', 'true'::jsonb, true) " + "WHERE hash = :hash AND value_credited = false AND value > 0 " + "AND COALESCE(data ->> 'uncreditedValueRefunded', 'false') <> 'true' " + "RETURNING value" ), {"hash": tx_hash}, ) row = result.first() - if row is None or row.value is None or row.value <= 0: + if row is None: return False - if row.value_credited: - return False # target already received funds, can't refund self.credit_account_balance(sender_address, row.value) + self.session.expire_all() return True + + def refund_activated_tx_value_once( + self, + tx_hash: str, + target_address: str, + beneficiary_address: str, + ) -> bool: + """Return an activated transaction's value to its principal owner once. + + Activation credits the destination before consensus has a terminal + result. A no-result/error finalization must reverse that provisional + credit. For an internal child the owner is ``from_address`` (the + emitting contract), never the outer transaction origin. + """ + + result = self.session.execute( + text( + "UPDATE transactions " + "SET data = jsonb_set(COALESCE(data, '{}'::jsonb), " + "'{activatedValueRefunded}', 'true'::jsonb, true) " + "WHERE hash = :hash AND value_credited = true AND value > 0 " + "AND COALESCE(data ->> 'activatedValueRefunded', 'false') <> 'true' " + "RETURNING value" + ), + {"hash": tx_hash}, + ) + row = result.first() + if row is None: + return False + + amount = int(row.value) + if not self.debit_account_balance(target_address, amount): + # Raising keeps the status update, marker, debit, and refund in one + # database rollback domain. A retry can therefore repair the + # lifecycle instead of silently minting or misdirecting value. + raise RuntimeError( + f"ActivatedValueRefundInsufficientBalance({tx_hash},{target_address},{amount})" + ) + self.credit_account_balance(beneficiary_address, amount) + self.session.expire_all() + 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) + .populate_existing() + .with_for_update() + .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 + sender_address = accounting.get("sender") or sender_address + was_terminal = accounting.get("status") in {"settled", "canceled"} + updated, refund = cancel_fee_accounting(accounting, reason=reason) + data[FEE_ACCOUNTING_KEY] = updated + data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) + transaction.data = data + if refund > 0: + self._credit_fee_refund_settlements(updated, sender_address, refund) + if not was_terminal: + self._credit_external_message_fee_payouts(updated) + self._credit_appeal_bond_payouts(updated) + return refund + + def abort_tx_appeal_admission_once( + self, + tx_hash: str, + reason: str = "appeal_committee_unavailable", + ) -> int: + """Atomically undo a paid appeal that never formed its committee.""" + + transaction = ( + self.session.query(Transactions) + .filter_by(hash=tx_hash) + .populate_existing() + .with_for_update() + .one_or_none() + ) + if transaction is None or not isinstance(transaction.data, dict): + return 0 + data = dict(transaction.data) + accounting = data.get(FEE_ACCOUNTING_KEY) + if not isinstance(accounting, dict): + return 0 + updated, recipient, refund = abort_latest_appeal_admission( + accounting, + reason=reason, + ) + if not recipient: + return 0 + + data[FEE_ACCOUNTING_KEY] = updated + data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) + transaction.data = data + history = dict(transaction.consensus_history or {}) + history.pop(ACTIVE_APPEAL_BASIS_KEY, None) + transaction.consensus_history = history + transaction.appealed = False + transaction.timestamp_appeal = None + # Persist the ORM rollback before the raw-SQL credit and cache expiry; + # expiring an unflushed row would resurrect the paid appeal and make a + # retry refund it a second time. + self.session.flush() + if refund > 0: + self.credit_account_balance(str(recipient), refund) + self.session.expire_all() + 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) + .populate_existing() + .with_for_update() + .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 + sender_address = accounting.get("sender") or sender_address + 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, + execution_mode=( + transaction.execution_mode.value + if hasattr(transaction.execution_mode, "value") + else str(transaction.execution_mode or "NORMAL") + ), + ) + data[FEE_ACCOUNTING_KEY] = updated + data.pop(APPEAL_RECOVERY_SNAPSHOT_KEY, None) + transaction.data = data + if refund > 0: + self._credit_fee_refund_settlements(updated, sender_address, refund) + if not was_terminal: + self._credit_external_message_fee_payouts(updated) + self._credit_appeal_bond_payouts(updated) + return refund + + def _credit_fee_refund_settlements( + self, + accounting: dict, + fallback_recipient: str, + total_refund: int, + ) -> None: + settlements = accounting.get("fee_refund_settlements") or [] + credited = 0 + for settlement in settlements: + if not isinstance(settlement, dict): + continue + recipient = settlement.get("recipient") + amount = int(settlement.get("amount", 0) or 0) + if recipient and amount > 0: + self.credit_account_balance(recipient, amount) + credited += amount + remainder = max(0, int(total_refund) - credited) + if remainder > 0: + self.credit_account_balance( + accounting.get("sender") or fallback_recipient, + remainder, + ) + + 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 _credit_external_message_fee_payouts(self, accounting: dict) -> None: + for payout in accounting.get("external_message_fee_payouts") or []: + if not isinstance(payout, dict): + continue + recipient = payout.get("recipient") + amount = int(payout.get("amount", 0) or 0) + if amount > 0 and recipient: + self.credit_account_balance(recipient, amount) + + +def _infer_final_round(consensus_history: dict | None) -> int: + return completed_consensus_round_index(consensus_history) diff --git a/backend/database_handler/chain_snapshot.py b/backend/database_handler/chain_snapshot.py index 6afb8a6dc..8acc55daa 100644 --- a/backend/database_handler/chain_snapshot.py +++ b/backend/database_handler/chain_snapshot.py @@ -32,7 +32,7 @@ def _load_pending_transactions(self) -> List[dict]: Transactions.hash ) ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .all() ) return [ @@ -61,7 +61,7 @@ def _load_awaiting_finalization_transactions(self) -> dict[str, List[dict]]: Transactions.hash ) ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .all() ) 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..230323de2 100644 --- a/backend/database_handler/contract_snapshot.py +++ b/backend/database_handler/contract_snapshot.py @@ -1,10 +1,15 @@ # database_handler/contract_snapshot.py from .models import CurrentState from .errors import ContractNotFoundError +from sqlalchemy import case, func, select from sqlalchemy.orm import Session from typing import Optional, Dict import base64 import json +from backend.node.genvm.executor_selection import ( + LEGACY_EXECUTOR_SELECTOR, + uses_legacy_storage, +) class ContractSnapshot: @@ -17,6 +22,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 +33,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 +52,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 +63,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 +100,91 @@ 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(legacy=uses_legacy_storage(self.genvm_executor_selector)) + ) 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(*, legacy: bool = False) -> 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(legacy=legacy)).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. + """ + # Select only the correct code blob, retaining the narrow SQL read rather + # than fetching a large contract state to inspect its executor metadata. + slot = case( + ( + (CurrentState.genvm_executor_selector == LEGACY_EXECUTOR_SELECTOR) + | CurrentState.genvm_executor_selector.startswith("v0.2."), + _code_slot_b64(legacy=True), + ), + else_=_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/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py b/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py new file mode 100644 index 000000000..3cff678a2 --- /dev/null +++ b/backend/database_handler/migration/versions/e4f6a8c0d2b4_add_evm_envelope_ledger.py @@ -0,0 +1,71 @@ +"""add durable EVM envelope nonce and replay ledger + +Revision ID: e4f6a8c0d2b4 +Revises: d2e3f4a5b6c7 +Create Date: 2026-08-28 12:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "e4f6a8c0d2b4" +down_revision: Union[str, None] = "d2e3f4a5b6c7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "evm_envelopes", + sa.Column("hash", sa.String(length=66), nullable=False), + sa.Column("from_address", sa.String(length=255), nullable=False), + sa.Column("nonce", sa.Numeric(precision=78, scale=0), nullable=False), + sa.Column("result", sa.String(length=66), nullable=False), + sa.Column("to_address", sa.String(length=255), nullable=True), + sa.Column( + "success", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + sa.Column("error", sa.String(length=1024), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, + ), + sa.CheckConstraint("nonce >= 0", name="evm_envelopes_nonce_unsigned"), + sa.PrimaryKeyConstraint("hash", name="evm_envelopes_pkey"), + sa.UniqueConstraint( + "from_address", + "nonce", + name="evm_envelopes_from_address_nonce_key", + ), + ) + + # Existing top-level transaction rows were admitted through signed EVM + # envelopes. Preserve the highest observable nonce history while resolving + # old Studio duplicates deterministically (latest row wins per nonce). + op.execute( + """ + INSERT INTO evm_envelopes + (hash, from_address, nonce, result, success, created_at) + SELECT DISTINCT ON (lower(from_address), nonce) + hash, lower(from_address), nonce, hash, true, created_at + FROM transactions + WHERE triggered_by_hash IS NULL + AND from_address IS NOT NULL + AND nonce IS NOT NULL + AND nonce >= 0 + ORDER BY lower(from_address), nonce, created_at DESC NULLS LAST, hash DESC + ON CONFLICT DO NOTHING + """ + ) + + +def downgrade() -> None: + op.drop_table("evm_envelopes") diff --git a/backend/database_handler/migration/versions/f5a7c9e1b3d5_add_transaction_queue_order.py b/backend/database_handler/migration/versions/f5a7c9e1b3d5_add_transaction_queue_order.py new file mode 100644 index 000000000..06bdaf22f --- /dev/null +++ b/backend/database_handler/migration/versions/f5a7c9e1b3d5_add_transaction_queue_order.py @@ -0,0 +1,75 @@ +"""add durable transaction queue issuance order + +Revision ID: f5a7c9e1b3d5 +Revises: e4f6a8c0d2b4 +Create Date: 2026-08-28 15:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "f5a7c9e1b3d5" +down_revision: Union[str, None] = "e4f6a8c0d2b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE SEQUENCE transactions_queue_order_seq") + op.add_column( + "transactions", + sa.Column("queue_order", sa.BigInteger(), nullable=True), + ) + # Preserve the best order available for historical rows. New rows receive + # their sequence value at INSERT, after the recipient admission lock. + op.execute( + """ + WITH ordered AS ( + SELECT hash, + ROW_NUMBER() OVER ( + ORDER BY created_at ASC, + nonce ASC NULLS LAST, + hash ASC + ) AS queue_order + FROM transactions + ) + UPDATE transactions AS t + SET queue_order = ordered.queue_order + FROM ordered + WHERE t.hash = ordered.hash + """ + ) + op.execute( + """ + SELECT setval( + 'transactions_queue_order_seq', + COALESCE((SELECT MAX(queue_order) + 1 FROM transactions), 1), + false + ) + """ + ) + op.alter_column( + "transactions", + "queue_order", + existing_type=sa.BigInteger(), + nullable=False, + server_default=sa.text("nextval('transactions_queue_order_seq'::regclass)"), + ) + op.create_index( + "ix_transactions_to_address_queue_order", + "transactions", + ["to_address", "queue_order"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + "ix_transactions_to_address_queue_order", + table_name="transactions", + ) + op.drop_column("transactions", "queue_order") + op.execute("DROP SEQUENCE transactions_queue_order_seq") diff --git a/backend/database_handler/models.py b/backend/database_handler/models.py index 81a6ca1de..73680461f 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, @@ -84,6 +90,40 @@ class CurrentState(Base): ) +transactions_queue_order_seq = Sequence("transactions_queue_order_seq") + + +class EvmEnvelope(Base): + """Mined signed EVM envelopes, including execution reverts. + + Consensus relies on the execution-chain account nonce and transaction hash + for replay protection. Studio records the same boundary explicitly because + lifecycle calls do not create GenLayer ``transactions`` rows. + """ + + __tablename__ = "evm_envelopes" + __table_args__ = ( + PrimaryKeyConstraint("hash", name="evm_envelopes_pkey"), + UniqueConstraint( + "from_address", + "nonce", + name="evm_envelopes_from_address_nonce_key", + ), + CheckConstraint("nonce >= 0", name="evm_envelopes_nonce_unsigned"), + ) + + hash: Mapped[str] = mapped_column(String(66), primary_key=True) + from_address: Mapped[str] = mapped_column(String(255), nullable=False) + nonce: Mapped[int] = mapped_column(IntNumeric(), nullable=False) + result: Mapped[str] = mapped_column(String(66), nullable=False) + to_address: Mapped[Optional[str]] = mapped_column(String(255), default=None) + success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + error: Mapped[Optional[str]] = mapped_column(String(1024), default=None) + created_at: Mapped[Optional[datetime.datetime]] = mapped_column( + DateTime(True), server_default=func.current_timestamp(), init=False + ) + + class Transactions(Base): __tablename__ = "transactions" __table_args__ = ( @@ -115,6 +155,16 @@ class Transactions(Base): created_at: Mapped[Optional[datetime.datetime]] = mapped_column( DateTime(True), server_default=func.current_timestamp(), init=False ) + # Durable issuance order. Consensus queues are slot-ordered; timestamps are + # only metadata and PostgreSQL CURRENT_TIMESTAMP is fixed at transaction + # start, so it cannot safely order concurrent admissions. + queue_order: Mapped[int] = mapped_column( + BigInteger, + transactions_queue_order_seq, + server_default=transactions_queue_order_seq.next_value(), + nullable=False, + init=False, + ) leader_only: Mapped[bool] = mapped_column(Boolean) r: Mapped[Optional[int]] = mapped_column(Integer) s: Mapped[Optional[int]] = mapped_column(Integer) @@ -181,6 +231,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..92d3d6b52 100644 --- a/backend/database_handler/transactions_processor.py +++ b/backend/database_handler/transactions_processor.py @@ -4,21 +4,84 @@ import rlp import re import random -from sqlalchemy.orm import Session, selectinload -from sqlalchemy import or_, desc, and_, JSON, type_coerce, text +from sqlalchemy.orm import Session, defer, selectinload +from sqlalchemy import or_, desc, and_, func, JSON, type_coerce, text -from backend.node.types import Vote, Receipt, ExecutionResultStatus -from .models import Transactions, TransactionStatus +from backend.node.types import Receipt, ExecutionResultStatus +from .models import EvmEnvelope, Transactions, TransactionStatus from eth_utils import to_bytes, keccak, is_address, to_checksum_address import json import base64 import time +import os +import copy +from typing import Callable, Iterable from backend.domain.types import TransactionType from web3 import Web3 -from backend.consensus.types import ConsensusRound +from backend.consensus.types import ( + ConsensusResult, + ConsensusRound, + consensus_result_type_code, + consensus_vote_type_code, +) +from backend.consensus.history import ( + APPEAL_RECOVERY_SNAPSHOT_KEY, + LEADER_APPEAL_REPLAY_CONTEXT, + VALIDATOR_APPEAL_CONTEXT, + completed_consensus_round_index, + completed_consensus_rounds, + materialize_decision_metadata, + prepare_appeal_decision_basis, + time_unit_consumption, +) from backend.consensus.utils import determine_consensus_from_votes +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + acceptance_dispatch_pending, + apply_fee_top_up, + normalize_fees_distribution, + runtime_rotations_for_round, +) +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 + + +def _transaction_order_tuple(model=Transactions): + """Durable Studio queue-slot authority.""" + + return model.queue_order + + +def _transaction_order_value(transaction: Transactions): + return int(transaction.queue_order) + + +# Canonical v0.6 ITransactions.TransactionStatus ordinals (0-13). 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, + "VALIDATORS_TIMEOUT": 11, + "LEADER_TIMEOUT": 12, + "LEADER_REVEALING": 13, +} + class TransactionAddressFilter(Enum): ALL = "all" @@ -26,38 +89,54 @@ class TransactionAddressFilter(Enum): FROM = "from" -def get_validator_vote_hash(validator_address: str, vote_type: int, nonce: int) -> str: +def get_validator_vote_hash( + validator_address: str, + vote_type: int, + nonce: int, + other_execution_fields_hash: bytes = bytes(32), +) -> str: """ Generate a hash for validator vote data using Solidity keccak. Args: validator_address: Address of the validator - vote_type: Numeric vote type (1=AGREE, 2=DISAGREE, etc.) + vote_type: Canonical v0.6 ``ITransactions.VoteType`` ordinal. nonce: Transaction nonce Returns: str: Hex-encoded hash with 0x prefix """ vote_hash_bytes = Web3.solidity_keccak( - ["address", "uint8", "uint256"], [validator_address, vote_type, nonce] + ["address", "uint8", "bytes32", "uint256"], + [validator_address, vote_type, other_execution_fields_hash, nonce], ) return Web3.to_hex(vote_hash_bytes) -def get_tx_execution_hash(leader_address: str, vote_type: int) -> str: +def get_tx_execution_hash( + leader_address: str, + vote_type: int, + nonce: int, + messages_and_other_fields_hash: bytes = bytes(32), +) -> str: """ Generate a hash for transaction execution data using Solidity keccak. Args: leader_address: Address of the consensus leader - vote_type: Numeric vote type + vote_type: Canonical v0.6 ``ITransactions.VoteType`` ordinal. + nonce: Studio's deterministic compatibility salt for this transaction. + messages_and_other_fields_hash: Canonical bytes32 preimage field when + available. Studio currently has no Solidity reveal payload, so the + compatibility view uses the zero sentinel consistently for leader + and validator hashes. Returns: str: Hex-encoded hash with 0x prefix """ tx_execution_hash_bytes = Web3.solidity_keccak( ["address", "uint8", "bytes32", "uint256"], - [leader_address, vote_type, b"", 4444], + [leader_address, vote_type, messages_and_other_fields_hash, nonce], ) return Web3.to_hex(tx_execution_hash_bytes) @@ -66,14 +145,59 @@ 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() + # Only used for checksum normalisation here, which needs no rollup — + # and the rollup bridge is optional (HARDHAT_URL may be empty). + self.web3 = Web3ConnectionPool.get_for_utilities() + + @staticmethod + 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 _parse_transaction_data(transaction_data: Transactions) -> dict: + 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 +214,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, @@ -110,6 +248,11 @@ def _parse_transaction_data(transaction_data: Transactions) -> dict: "triggered_transactions": [ transaction.hash for transaction in transaction_data.triggered_transactions + # Consensus exposes only child consensus transactions through + # getTriggeredTransactionIds. Studio keeps external SEND rows + # as local execution effects, but they are not protocol child + # transactions and must not leak into that shared surface. + if transaction.type != TransactionType.SEND.value ], "appealed": transaction_data.appealed, "timestamp_awaiting_finalization": transaction_data.timestamp_awaiting_finalization, @@ -118,7 +261,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 +280,273 @@ 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" + genvm_result = receipt.get("genvm_result") + if isinstance(genvm_result, dict) and str( + genvm_result.get("error_code") or "" + ) in { + "CONSENSUS_LEADER_EXEC_TIMEOUT", + "CONSENSUS_VALIDATOR_EXEC_TIMEOUT", + }: + return 3, "TIMEOUT" + 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 _result_type_code(result: ConsensusResult) -> int: + """Map Studio's decision enum to v0.6 ITransactions.ResultType.""" + + return consensus_result_type_code(result) + + @staticmethod + def _rpc_raw_round( + transaction_data: dict, + completed_rounds: list[dict], + ) -> int: + """Return the raw round currently materialized by v0.6 Consensus.""" + + completed_raw = ( + completed_consensus_round_index(transaction_data.get("consensus_history")) + if completed_rounds + else 0 + ) + status = str(transaction_data.get("status") or "").upper() + executing = status in { + "PENDING", + "ACTIVATED", + "PROPOSING", + "COMMITTING", + "REVEALING", + "LEADER_REVEALING", + } + if not executing: + return completed_raw + + if transaction_data.get("appeal_undetermined") or transaction_data.get( + "appeal_leader_timeout" + ): + return completed_raw + 2 + if transaction_data.get("appealed") or transaction_data.get( + "appeal_validators_timeout" + ): + return completed_raw + (1 if completed_raw % 2 == 0 else 2) + + if completed_rounds: + last_outcome = str(completed_rounds[-1].get("consensus_round") or "") + if last_outcome in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + }: + return completed_raw + 1 + return completed_raw + + @staticmethod + def _appeal_bond_for_round(accounting: dict | None, raw_round: int) -> int: + """Project Studio appeal custody onto Consensus RoundData.appealBond.""" + + if not isinstance(accounting, dict): + return 0 + entries = accounting.get("appeal_bonds") + if not isinstance(entries, list): + return 0 + for entry in reversed(entries): + if not isinstance(entry, dict): + continue + source_round = int(entry.get("sourceRound", entry.get("round", 0)) or 0) + status = str(entry.get("status") or "").upper() + default_appeal_round = ( + source_round + (1 if source_round % 2 == 0 else 2) + if status in {"ACCEPTED", "VALIDATORS_TIMEOUT"} + else source_round + 2 + ) + appeal_round = int( + entry.get("appealRound", default_appeal_round) or default_appeal_round + ) + if status == "LEADER_TIMEOUT": + bond_rounds = {appeal_round - 1} + elif status == "UNDETERMINED": + bond_rounds = {appeal_round - 1, appeal_round} + else: + bond_rounds = {appeal_round} + if raw_round in bond_rounds: + return int(entry.get("amount", entry.get("minimumRequired", 0)) or 0) + return 0 + + @staticmethod + def _last_history_outcome(transaction_data: dict) -> str: + rounds = completed_consensus_rounds(transaction_data.get("consensus_history")) + return str(rounds[-1].get("consensus_round") or "") if rounds else "" + + @staticmethod + def _is_timeout_outcome(outcome: str) -> bool: + return outcome in { + ConsensusRound.LEADER_TIMEOUT.value, + ConsensusRound.VALIDATORS_TIMEOUT.value, + ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, + } + + @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 {} + chargeable = ( + report.get("chargeableExecution") if isinstance(report, dict) else {} + ) + if isinstance(chargeable, dict) and chargeable.get("storage") is not None: + return int(chargeable.get("storage", 0) or 0) + + chargeable_buckets = accounting.get("execution_fee_consumed_buckets") or [] + if len(chargeable_buckets) > 1: + return int(chargeable_buckets[1]) + + # Legacy Studio receipts exposed storage as a distinct raw GenVM + # bucket. Retain this final fallback for persisted pre-v0.123 data. + 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 isinstance(consumed_buckets, dict): + return 0 + 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 +579,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 @@ -235,10 +647,11 @@ def insert_transaction( triggered_on: str | None = None, # "accepted" or "finalized" execution_mode: str = "NORMAL", # "NORMAL", "LEADER_ONLY", or "LEADER_SELF_VALIDATOR" origin_address: str | None = None, + commit: bool = True, ) -> str: if transaction_hash is None: - current_nonce = self.get_transaction_count(from_address) + current_nonce = self.get_genlayer_transaction_count(from_address) transaction_hash = self._generate_transaction_hash( from_address, to_address, data, value, type, current_nonce ) @@ -297,79 +710,90 @@ def insert_transaction( self.session.add(new_transaction) self.session.flush() # So that `created_at` gets set - self.session.commit() # Persist the transaction to the database + if commit: + self.session.commit() # Persist the transaction to the database return transaction_hash def _process_round_data(self, transaction_data: dict) -> dict: """Process round data and prepare transaction data.""" - if ( - transaction_data["consensus_history"] is not None - and "consensus_results" in transaction_data["consensus_history"] - ): - transaction_data["num_of_rounds"] = str( - len(transaction_data["consensus_history"]["consensus_results"]) - ) - else: - transaction_data["num_of_rounds"] = "0" + vote_nonce = ( + 0 + if transaction_data.get("nonce") is None + else int(transaction_data["nonce"]) + ) + + completed_rounds = completed_consensus_rounds( + transaction_data.get("consensus_history") + ) + # Despite its historical name, ConsensusData.numOfRounds exposes the + # current raw round index, not a cardinality (round 0 -> 0, round 3 -> 3). + raw_round = self._rpc_raw_round(transaction_data, completed_rounds) + transaction_data["num_of_rounds"] = str(raw_round) validator_votes_name = [] validator_votes = [] validator_votes_hash = [] round_validators = [] - if ( - transaction_data["consensus_history"] is not None - and "consensus_results" in transaction_data["consensus_history"] - ): - round_number = str( - len(transaction_data["consensus_history"]["consensus_results"]) - 1 - ) - last_round = transaction_data["consensus_history"]["consensus_results"][-1] + round_number = str(raw_round) + last_round_outcome = "" + completed_raw = ( + completed_consensus_round_index(transaction_data["consensus_history"]) + if completed_rounds + else 0 + ) + if completed_rounds and raw_round == completed_raw: + # Ignore trailing in-round UI events such as Leader Rotation. They + # do not create a Solidity round and cannot be the lastRound view. + last_round = completed_rounds[-1] + last_round_outcome = str(last_round.get("consensus_round") or "") + 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"])) + vote_number = consensus_vote_type_code( + leader["vote"], leader.get("execution_result") + ) validator_votes.append(vote_number) leader_address = leader["node_config"]["address"] validator_votes_hash.append( - get_validator_vote_hash( - leader_address, vote_number, transaction_data["nonce"] - ) + get_validator_vote_hash(leader_address, vote_number, vote_nonce) ) round_validators.append(leader_address) - for validator in last_round["validator_results"]: + for validator in last_round.get("validator_results") or []: validator_votes_name.append(validator["vote"].upper()) - vote_number = int(Vote.from_string(validator["vote"])) + vote_number = consensus_vote_type_code( + validator["vote"], validator.get("execution_result") + ) validator_votes.append(vote_number) validator_address = validator["node_config"]["address"] validator_votes_hash.append( - get_validator_vote_hash( - validator_address, vote_number, transaction_data["nonce"] - ) + get_validator_vote_hash(validator_address, vote_number, vote_nonce) ) round_validators.append(validator_address) - else: - round_number = "0" - # Handle upgrade transactions specially - they bypass consensus # and have upgrade_result instead of votes - if ( + if self._is_timeout_outcome(last_round_outcome): + last_round_result = self._result_type_code(ConsensusResult.TIMEOUT) + elif ( transaction_data.get("type") == TransactionType.UPGRADE_CONTRACT and transaction_data.get("consensus_data") is not None and "upgrade_result" in transaction_data["consensus_data"] ): - from backend.consensus.types import ConsensusResult - if transaction_data["consensus_data"]["upgrade_result"] == "success": - last_round_result = int(ConsensusResult.MAJORITY_AGREE) + last_round_result = self._result_type_code( + ConsensusResult.MAJORITY_AGREE + ) else: - last_round_result = int(ConsensusResult.MAJORITY_DISAGREE) + last_round_result = self._result_type_code( + ConsensusResult.MAJORITY_DISAGREE + ) elif ( # Handle LEADER_ONLY mode specially - no validators, so no votes to count # If the transaction is ACCEPTED or FINALIZED, the leader execution was successful @@ -377,25 +801,42 @@ def _process_round_data(self, transaction_data: dict) -> dict: and transaction_data.get("status") in [TransactionStatus.ACCEPTED.value, TransactionStatus.FINALIZED.value] ): - from backend.consensus.types import ConsensusResult - - last_round_result = int(ConsensusResult.MAJORITY_AGREE) + last_round_result = self._result_type_code(ConsensusResult.MAJORITY_AGREE) else: - last_round_result = int( + last_round_result = self._result_type_code( determine_consensus_from_votes( [vote.lower() for vote in validator_votes_name] ) ) + fee_accounting = (transaction_data.get("data") or {}).get(FEE_ACCOUNTING_KEY) + fees_distribution = ( + fee_accounting.get("fees_distribution") + if isinstance(fee_accounting, dict) + else None + ) + if isinstance(fees_distribution, dict): + rotation_limit = runtime_rotations_for_round( + fees_distribution, + transaction_data.get("config_rotation_rounds"), + int(round_number), + ) + else: + rotation_limit = max( + 0, int(transaction_data.get("config_rotation_rounds") or 0) + ) + transaction_data["last_round"] = { "round": round_number, "leader_index": "0", "votes_committed": str(len(validator_votes_name)), "votes_revealed": str(len(validator_votes_name)), - "appeal_bond": "0", + "appeal_bond": str(self._appeal_bond_for_round(fee_accounting, raw_round)), "rotations_left": str( - (transaction_data.get("config_rotation_rounds") or 0) - - (transaction_data.get("rotation_count") or 0) + max( + 0, + rotation_limit - int(transaction_data.get("rotation_count") or 0), + ) ), "result": last_round_result, "round_validators": round_validators, @@ -411,7 +852,6 @@ def _prepare_basic_transaction_data(self, transaction_data: dict) -> dict: transaction_data["current_timestamp"] = str(round(time.time())) transaction_data["sender"] = transaction_data["from_address"] transaction_data["recipient"] = transaction_data["to_address"] - transaction_data["tx_slot"] = "0" transaction_data["created_timestamp"] = str( int(datetime.fromisoformat(transaction_data["created_at"]).timestamp()) ) @@ -426,26 +866,69 @@ def _prepare_basic_transaction_data(self, transaction_data: dict) -> dict: "processing_block": "0", "proposal_block": "0", } - if ( - 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"] + history_rounds = completed_consensus_rounds( + transaction_data.get("consensus_history") + ) + if history_rounds: + first_round = history_rounds[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 + def _transaction_issued_slot(self, transaction: Transactions) -> int: + """Return Consensus' recipient-scoped, zero-based issuance slot. + + ``Queues.enqueueNewPending`` assigns ``txSlot`` from the recipient's + monotonically increasing ``issuedTxCount``. Studio's durable + ``queue_order`` gives us the same serialization boundary. Local SEND + rows are execution effects rather than Consensus transactions, so they + must not advance the protocol slot. + """ + + if transaction.type == TransactionType.SEND.value: + return 0 + if transaction.to_address is None: + return 0 + return int( + self.session.query(func.count(Transactions.hash)) + .filter( + func.lower(Transactions.to_address) + == str(transaction.to_address).lower(), + Transactions.type != TransactionType.SEND.value, + Transactions.queue_order < transaction.queue_order, + ) + .scalar() + or 0 + ) + def _encode_transaction_data(self, transaction_data: dict) -> dict: to_encode = [] if transaction_data["data"] is not None: @@ -467,21 +950,32 @@ 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=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 + and leader_receipt.get("vote") is not None + ): + vote_type = self._execution_result_fields( + {"leader_receipt": leader_receipt} + )[0] + if vote_type == 0: + vote_type = consensus_vote_type_code( + leader_receipt["vote"], leader_receipt.get("execution_result") + ) 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"], + vote_type, + int(transaction_data.get("nonce") or 0), ) else: transaction_data["tx_execution_hash"] = "" @@ -498,46 +992,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 @@ -577,7 +1069,15 @@ def _process_queue(self, transaction_data: dict) -> dict: status_to_queue_type = { TransactionStatus.PENDING.value: "1", TransactionStatus.ACTIVATED.value: "1", + TransactionStatus.PROPOSING.value: "1", + TransactionStatus.COMMITTING.value: "1", + TransactionStatus.REVEALING.value: "1", + "LEADER_REVEALING": "1", + "APPEAL_COMMITTING": "1", + "APPEAL_REVEALING": "1", TransactionStatus.ACCEPTED.value: "2", + TransactionStatus.LEADER_TIMEOUT.value: "2", + TransactionStatus.VALIDATORS_TIMEOUT.value: "2", TransactionStatus.UNDETERMINED.value: "3", } transaction_data["queue_type"] = status_to_queue_type.get( @@ -588,6 +1088,12 @@ def _process_queue(self, transaction_data: dict) -> dict: return transaction_data def _process_result(self, transaction_data: dict) -> dict: + if self._is_timeout_outcome(self._last_history_outcome(transaction_data)): + consensus_result = ConsensusResult.TIMEOUT + transaction_data["result"] = self._result_type_code(consensus_result) + transaction_data["result_name"] = consensus_result.value + return transaction_data + # Handle upgrade transactions specially - they bypass consensus # and have upgrade_result instead of votes if ( @@ -595,13 +1101,11 @@ def _process_result(self, transaction_data: dict) -> dict: and transaction_data.get("consensus_data") is not None and "upgrade_result" in transaction_data["consensus_data"] ): - from backend.consensus.types import ConsensusResult - if transaction_data["consensus_data"]["upgrade_result"] == "success": consensus_result = ConsensusResult.MAJORITY_AGREE else: consensus_result = ConsensusResult.MAJORITY_DISAGREE - transaction_data["result"] = int(consensus_result) + transaction_data["result"] = self._result_type_code(consensus_result) transaction_data["result_name"] = consensus_result.value return transaction_data @@ -613,10 +1117,8 @@ def _process_result(self, transaction_data: dict) -> dict: TransactionStatus.ACCEPTED.value, TransactionStatus.FINALIZED.value, ]: - from backend.consensus.types import ConsensusResult - consensus_result = ConsensusResult.MAJORITY_AGREE - transaction_data["result"] = int(consensus_result) + transaction_data["result"] = self._result_type_code(consensus_result) transaction_data["result_name"] = consensus_result.value return transaction_data @@ -627,25 +1129,33 @@ def _process_result(self, transaction_data: dict) -> dict: else: votes_temp = [] consensus_result = determine_consensus_from_votes(votes_temp) - transaction_data["result"] = int(consensus_result) + transaction_data["result"] = self._result_type_code(consensus_result) transaction_data["result_name"] = consensus_result.value 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( @@ -671,6 +1181,7 @@ def get_transaction_by_hash( # Process for testnet transaction_data = self._prepare_basic_transaction_data(transaction_data) + transaction_data["tx_slot"] = str(self._transaction_issued_slot(transaction)) transaction_data = self._process_result(transaction_data) transaction_data = self._encode_transaction_data(transaction_data) transaction_data = self._process_execution_hash(transaction_data) @@ -692,6 +1203,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 +1413,144 @@ 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 mutate_transaction_fee_accounting( + self, + transaction_hash: str, + mutator: Callable[[dict], dict], + *, + commit: bool = True, + ) -> dict: + """Mutate the latest fee accounting while holding the transaction row. + + Consensus workers run asynchronously, while RPC top-ups can arrive at + any in-flight status. Every worker-side accounting transition must + therefore derive from the post-lock JSON value; writing a snapshot + captured before a concurrent top-up would erase paid budget. + """ + + row = ( + self.session.execute( + text("SELECT data FROM transactions WHERE hash = :hash FOR UPDATE"), + {"hash": transaction_hash}, + ) + .mappings() + .first() + ) + if row is None: + raise ValueError("TransactionNotFound") + data = dict(row["data"] or {}) + current = data.get(FEE_ACCOUNTING_KEY) + if not isinstance(current, dict): + raise ValueError("FeeAccountingMissing") + updated = mutator(current) + data[FEE_ACCOUNTING_KEY] = updated + self.session.execute( + text( + "UPDATE transactions SET data = CAST(:data AS jsonb)" + " WHERE hash = :hash" + ), + {"hash": transaction_hash, "data": json.dumps(data)}, + ) + if commit: + self.session.commit() + return updated + + def apply_transaction_fee_top_up( + self, + transaction_hash: str, + *, + fees_distribution: dict, + amount: int, + sender: str, + policy, + ) -> dict: + """Apply one top-up to the latest accounting snapshot under a row lock. + + The caller commits only after debiting the sender on this same session. + This gives Studio the serial execution semantics of FeeManager.topUpFees: + concurrent top-ups compose instead of both charging against one stale + read and allowing the last JSON write to erase the other contribution. + """ + + row = ( + self.session.execute( + text( + "SELECT status, data, num_of_initial_validators" + " FROM transactions WHERE hash = :hash FOR UPDATE" + ), + {"hash": transaction_hash}, + ) + .mappings() + .first() + ) + if row is None: + raise ValueError("TransactionNotFound") + if row["status"] in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + TransactionStatus.FINALIZED.value, + TransactionStatus.CANCELED.value, + }: + raise ValueError("InvalidTransactionStatus") + + data = dict(row["data"] or {}) + current = data.get(FEE_ACCOUNTING_KEY) + if current is None: + raise ValueError("FeeAccountingMissing") + updated = apply_fee_top_up( + current, + fees_distribution=fees_distribution, + amount=amount, + sender=sender, + num_of_validators=int(row["num_of_initial_validators"] or 5), + policy=policy, + ) + data[FEE_ACCOUNTING_KEY] = updated + self.session.execute( + text( + "UPDATE transactions SET data = CAST(:data AS jsonb)" + " WHERE hash = :hash" + ), + {"hash": transaction_hash, "data": json.dumps(data)}, + ) + return updated + def get_transaction_count(self, address: str) -> int: # Normalize address to checksum format try: @@ -907,14 +1558,201 @@ def get_transaction_count(self, address: str) -> int: except: checksum_address = address - # Always use database count as source of truth - # Our transactions are stored in PostgreSQL, not on Hardhat blockchain - count = ( + if address is not None: + ledger_address = str(checksum_address).lower() + highest_nonce = ( + self.session.query(func.max(EvmEnvelope.nonce)) + .filter(EvmEnvelope.from_address == ledger_address) + .scalar() + ) + return 0 if highest_nonce is None else int(highest_nonce) + 1 + + # Studio-only system transactions (funding) are not signed EVM + # envelopes and retain their historical database-count nonce. + return int( self.session.query(Transactions) - .filter(Transactions.from_address == checksum_address) + .filter(Transactions.from_address.is_(None)) .count() ) - return count + + def get_genlayer_transaction_count(self, address: str | None) -> int: + """Count Studio transaction rows authored by one logical sender. + + This is deliberately separate from ``eth_getTransactionCount``. Ghost + contracts do not submit signed EVM envelopes when MessagePayments + creates children, but Studio still needs a stable monotonically + increasing child metadata nonce and fallback-hash input. + """ + + if address is None: + return int( + self.session.query(Transactions) + .filter(Transactions.from_address.is_(None)) + .count() + ) + try: + address = self.web3.to_checksum_address(address) + except Exception: + pass + return int( + self.session.query(Transactions) + .filter(Transactions.from_address == address) + .count() + ) + + def begin_evm_envelope( + self, + transaction_hash: str, + from_address: str, + nonce: int, + ) -> str | None: + """Lock one sender and validate exact EVM nonce/replay semantics. + + Returns the prior successful response for an identical raw envelope. + The caller records a new success only after all protocol mutations have + completed, in the same DB transaction. + """ + + from_address = to_checksum_address(from_address).lower() + nonce = int(nonce) + if nonce < 0: + raise ValueError("InvalidNonce") + + bind = self.session.get_bind() + if bind.dialect.name == "postgresql": + self.session.execute( + text( + "SELECT pg_advisory_xact_lock(" "hashtextextended(:sender_key, 0))" + ), + {"sender_key": f"evm-sender:{from_address.lower()}"}, + ) + + existing = self.session.get(EvmEnvelope, transaction_hash) + if existing is not None: + return existing.result + + expected = self.get_transaction_count(from_address) + if nonce < expected: + raise ValueError(f"NonceTooLow(expected={expected},actual={nonce})") + if nonce > expected: + raise ValueError(f"NonceTooHigh(expected={expected},actual={nonce})") + return None + + def record_evm_envelope( + self, + transaction_hash: str, + from_address: str, + nonce: int, + result: str, + *, + to_address: str | None = None, + success: bool = True, + error: str | None = None, + ) -> None: + self.session.add( + EvmEnvelope( + hash=transaction_hash, + from_address=to_checksum_address(from_address).lower(), + nonce=int(nonce), + result=result, + to_address=(str(to_address).lower() if to_address else None), + success=bool(success), + error=(str(error)[:1024] if error else None), + ) + ) + self.session.flush() + + def get_evm_envelope(self, transaction_hash: str) -> EvmEnvelope | None: + return self.session.get(EvmEnvelope, transaction_hash) + + def lock_transaction_admission(self, transaction_hash: str) -> None: + """Serialize one raw transaction's admission across RPC workers. + + The lock is held by the request transaction until insert_transaction + commits. This keeps duplicate submissions from both authoring shadow + helper state before either worker can observe the canonical DB row. + """ + + bind = self.session.get_bind() + if bind.dialect.name != "postgresql": + return + self.session.execute( + text( + "SELECT pg_advisory_xact_lock(" + "hashtextextended(:transaction_hash, 0))" + ), + {"transaction_hash": transaction_hash}, + ) + + def lock_ghost_factory(self) -> None: + """Serialize Studio's virtual GhostFactory for this DB transaction.""" + + bind = self.session.get_bind() + if bind.dialect.name != "postgresql": + return + self.session.execute( + text( + "SELECT pg_advisory_xact_lock(" + "hashtextextended('studio-v0.6-ghost-factory', 0))" + ) + ) + + def lock_pending_recipients(self, addresses: Iterable[str]) -> None: + """Serialize child admission for every touched recipient queue. + + One Consensus EVM transaction processes a message batch serially. A + Studio worker can process unrelated parents concurrently, so acquire + the same recipient-scoped locks used by top-level RPC admission before + reading queue depth. Sorting the complete set keeps overlapping + multi-recipient batches deadlock-free. + """ + + bind = self.session.get_bind() + if bind.dialect.name != "postgresql": + return + for address in sorted( + { + str(address).lower() + for address in addresses + if isinstance(address, str) and address + } + ): + self.session.execute( + text("SELECT pg_advisory_xact_lock(" "hashtextextended(:lock_key, 0))"), + {"lock_key": f"pending-recipient:{address}"}, + ) + + def get_successful_ghost_creation_count(self) -> int: + """Return the virtual factory nonce offset. + + GhostFactory creates and registers the proxy during transaction + admission, before GenVM executes. A later failed or canceled deploy + therefore still consumed one successful CREATE/CREATE2 and remains a + registered ghost, so every admitted deployment row is counted. + """ + + return int( + self.session.query(Transactions) + .filter(Transactions.type == TransactionType.DEPLOY_CONTRACT.value) + .count() + ) + + def is_genvm_contract_address(self, address: str) -> bool: + """Whether Consensus would have registered ``address`` as a ghost.""" + + try: + address = to_checksum_address(address) + except (TypeError, ValueError): + return False + return ( + self.session.query(Transactions.hash) + .filter( + Transactions.type == TransactionType.DEPLOY_CONTRACT.value, + Transactions.to_address == address, + ) + .first() + is not None + ) def get_transactions_for_address( self, @@ -941,7 +1779,11 @@ def get_transactions_for_address( ) ) - transactions = query.order_by(Transactions.created_at.desc()).all() + transactions = query.order_by( + Transactions.created_at.desc(), + Transactions.nonce.desc().nullslast(), + Transactions.hash.desc(), + ).all() return [ self._parse_transaction_data(transaction) for transaction in transactions @@ -971,6 +1813,181 @@ def set_transaction_appeal(self, transaction_hash: str, appeal: bool): if result.rowcount > 0: self.session.commit() + def admit_transaction_appeal( + self, + transaction_hash: str, + *, + expected_decision_id: int, + submitted_at: int, + appeal_deadline: int, + retention_bps: int, + prepare_fee_accounting: Callable[[dict | None], tuple[dict | None, int]], + ) -> int: + """Atomically reserve one exact-decision appeal. + + The caller deliberately commits only after applying the matching + account debit/refund on this same session. This keeps Studio's appeal + admission equivalent to one payable Solidity transaction and makes a + concurrent second submission lose at the ``appealed = false`` gate. + """ + + row = ( + self.session.execute( + text( + "SELECT status, appealed, consensus_history, consensus_data," + " contract_snapshot, appeal_failed, appeal_undetermined," + " appeal_leader_timeout, appeal_validators_timeout," + " appeal_processing_time, rotation_count," + " leader_timeout_validators, timestamp_awaiting_finalization, data" + " FROM transactions" + " WHERE hash = :hash FOR UPDATE" + ), + {"hash": transaction_hash}, + ) + .mappings() + .first() + ) + if row is None: + raise ValueError("TransactionNotFound") + if row["status"] not in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } or bool(row["appealed"]): + raise ValueError("CanNotAppeal") + if acceptance_dispatch_pending((row["data"] or {}).get(FEE_ACCOUNTING_KEY)): + raise ValueError("CanNotAppeal") + # The RPC may have waited for this row lock. Consensus evaluates the + # deadline at transaction execution, not when the caller began + # submitting it, so re-sample after lock acquisition and reject an + # appeal whose window expired while queued. + admitted_at = max(int(submitted_at), int(time.time())) + updated = prepare_appeal_decision_basis( + row["consensus_history"], + expected_decision_id=expected_decision_id, + submitted_at=admitted_at, + appeal_deadline=appeal_deadline, + retention_bps=retention_bps, + appeal_context=( + LEADER_APPEAL_REPLAY_CONTEXT + if row["status"] + in { + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + } + else VALIDATOR_APPEAL_CONTEXT + ), + ) + data = dict(row["data"] or {}) + fee_accounting, surplus_refund = prepare_fee_accounting( + data.get(FEE_ACCOUNTING_KEY) + ) + if fee_accounting is not None: + data[FEE_ACCOUNTING_KEY] = fee_accounting + status_value = ( + row["status"].value + if hasattr(row["status"], "value") + else str(row["status"]) + ) + data[APPEAL_RECOVERY_SNAPSHOT_KEY] = { + "status": status_value, + "consensusHistory": copy.deepcopy(updated), + "consensusData": copy.deepcopy(row["consensus_data"]), + "contractSnapshot": copy.deepcopy(row["contract_snapshot"]), + "appealFailed": int(row["appeal_failed"] or 0), + "appealUndetermined": bool(row["appeal_undetermined"]), + "appealLeaderTimeout": bool(row["appeal_leader_timeout"]), + "appealValidatorsTimeout": bool(row["appeal_validators_timeout"]), + "appealProcessingTime": int(row["appeal_processing_time"] or 0), + "rotationCount": int(row["rotation_count"] or 0), + "leaderTimeoutValidators": copy.deepcopy(row["leader_timeout_validators"]), + "timestampAwaitingFinalization": row["timestamp_awaiting_finalization"], + "timestampAppeal": admitted_at, + } + self.session.execute( + text( + "UPDATE transactions" + " SET appealed = true," + " timestamp_appeal = :submitted_at," + " consensus_history = CAST(:history AS jsonb)," + " data = CAST(:data AS jsonb)" + " WHERE hash = :hash" + ), + { + "hash": transaction_hash, + "submitted_at": admitted_at, + "history": json.dumps(updated), + "data": json.dumps(data), + }, + ) + return int(surplus_refund) + + def restore_transaction_appeal_for_retry(self, transaction_hash: str) -> bool: + """Restore the exact admitted state after interrupted appeal work.""" + + transaction = ( + self.session.query(Transactions) + .filter_by(hash=transaction_hash) + .populate_existing() + .with_for_update() + .one_or_none() + ) + if transaction is None or not isinstance(transaction.data, dict): + return False + snapshot = transaction.data.get(APPEAL_RECOVERY_SNAPSHOT_KEY) + if not isinstance(snapshot, dict): + return False + try: + transaction.status = TransactionStatus(str(snapshot["status"])) + except (KeyError, TypeError, ValueError): + return False + + transaction.consensus_history = copy.deepcopy(snapshot.get("consensusHistory")) + transaction.consensus_data = copy.deepcopy(snapshot.get("consensusData")) + transaction.contract_snapshot = copy.deepcopy(snapshot.get("contractSnapshot")) + transaction.appealed = True + transaction.appeal_failed = int(snapshot.get("appealFailed", 0) or 0) + transaction.appeal_undetermined = bool( + snapshot.get("appealUndetermined", False) + ) + transaction.appeal_leader_timeout = bool( + snapshot.get("appealLeaderTimeout", False) + ) + transaction.appeal_validators_timeout = bool( + snapshot.get("appealValidatorsTimeout", False) + ) + transaction.appeal_processing_time = int( + snapshot.get("appealProcessingTime", 0) or 0 + ) + transaction.rotation_count = int(snapshot.get("rotationCount", 0) or 0) + transaction.leader_timeout_validators = copy.deepcopy( + snapshot.get("leaderTimeoutValidators") + ) + transaction.timestamp_awaiting_finalization = snapshot.get( + "timestampAwaitingFinalization" + ) + transaction.timestamp_appeal = int(snapshot.get("timestampAppeal", 0) or 0) + self.session.flush() + return True + + def clear_transaction_appeal_recovery_snapshot( + self, transaction_hash: str, *, include_pending: bool = True + ) -> None: + """Drop the transient snapshot once an appeal has completed.""" + + pending_guard = "" if include_pending else " AND status != 'PENDING'" + self.session.execute( + text( + "UPDATE transactions SET data = data - :snapshot_key" + " WHERE hash = :hash AND data ? :snapshot_key" + pending_guard + ), + { + "hash": transaction_hash, + "snapshot_key": APPEAL_RECOVERY_SNAPSHOT_KEY, + }, + ) + def set_transaction_timestamp_awaiting_finalization( self, transaction_hash: str, timestamp_awaiting_finalization: int = None ): @@ -1028,7 +2045,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 +2056,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 +2069,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] @@ -1085,10 +2115,10 @@ def get_newer_transactions(self, transaction_hash: str): self.session.query(Transactions) .options(selectinload(Transactions.triggered_transactions)) .filter( - Transactions.created_at > transaction.created_at, + _transaction_order_tuple() > _transaction_order_value(transaction), Transactions.to_address == transaction.to_address, ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .all() ) return [ @@ -1147,6 +2177,20 @@ def update_consensus_history( "current_status_changes": [], "current_monitoring": {}, } + if extra_status_change in { + TransactionStatus.ACCEPTED, + TransactionStatus.UNDETERMINED, + TransactionStatus.LEADER_TIMEOUT, + TransactionStatus.VALIDATORS_TIMEOUT, + }: + new_history = materialize_decision_metadata( + new_history, + status=extra_status_change.value, + materialized_at=int(time.time()), + default_appeal_window=int( + os.environ.get("VITE_FINALITY_WINDOW", "1800") + ), + ) self.session.execute( text( @@ -1167,6 +2211,52 @@ def reset_consensus_history(self, transaction_hash: str): ) self.session.commit() + def reset_transaction_for_recomputation( + self, + transaction_hash: str, + data: dict | None, + ) -> None: + """Invalidate every attempt-local field before an ancestor rewind. + + The transaction identity, escrow, queue order, and credited user value + remain durable. Consensus evidence and worker ownership belong to the + invalidated descendant attempt and must not survive into recomputation. + """ + + result = self.session.execute( + text( + """ + UPDATE transactions + SET status = 'PENDING'::transaction_status, + data = CAST(:data AS jsonb), + consensus_data = NULL, + consensus_history = '{}'::jsonb, + contract_snapshot = NULL, + appealed = false, + appeal_failed = 0, + appeal_undetermined = false, + appeal_leader_timeout = false, + appeal_validators_timeout = false, + timestamp_appeal = NULL, + appeal_processing_time = 0, + timestamp_awaiting_finalization = NULL, + last_vote_timestamp = NULL, + rotation_count = 0, + leader_timeout_validators = NULL, + blocked_at = NULL, + worker_id = NULL + WHERE hash = :hash + """ + ), + { + "hash": transaction_hash, + "data": json.dumps(data) if data is not None else None, + }, + ) + if result.rowcount == 0: + raise ValueError("TransactionNotFound") + self.session.commit() + def set_transaction_timestamp_appeal( self, transaction: Transactions | str, timestamp_appeal: int | None ): @@ -1234,7 +2324,10 @@ def transactions_in_process_by_contract(self) -> list[dict]: ), ) .distinct(Transactions.to_address) - .order_by(Transactions.to_address, Transactions.created_at.asc()) + .order_by( + Transactions.to_address, + Transactions.queue_order.asc(), + ) .all() ) @@ -1258,7 +2351,7 @@ def get_previous_transaction( return None filters = [ - Transactions.created_at < transaction.created_at, + _transaction_order_tuple() < _transaction_order_value(transaction), Transactions.to_address == transaction.to_address, ] if status is not None: @@ -1289,7 +2382,7 @@ def get_previous_transaction( closest_transaction = ( self.session.query(Transactions) .filter(*filters) - .order_by(desc(Transactions.created_at)) + .order_by(Transactions.queue_order.desc()) .first() ) @@ -1329,6 +2422,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 @@ -1375,7 +2469,10 @@ def set_transaction_appeal_validators_timeout( def get_pending_transaction_count_for_address(self, address: str) -> int: """ - Get the count of pending transactions for a given recipient address. + Get Consensus pending-queue membership for a recipient. + + The activated head remains in Queues.pending until it reaches a + decision, so all pre-decision processing states consume one slot. Args: address: The recipient address to count pending transactions for @@ -1385,8 +2482,8 @@ def get_pending_transaction_count_for_address(self, address: str) -> int: """ try: # Normalize address to checksum format - checksum_address = self.web3.to_checksum_address(address) - except ValueError: + checksum_address = to_checksum_address(address) + except (TypeError, ValueError): # If address normalization fails, use as-is checksum_address = address @@ -1394,20 +2491,53 @@ def get_pending_transaction_count_for_address(self, address: str) -> int: self.session.query(Transactions) .filter( Transactions.to_address == checksum_address, - Transactions.status == TransactionStatus.PENDING, + Transactions.status.in_( + [ + TransactionStatus.PENDING, + TransactionStatus.ACTIVATED, + TransactionStatus.PROPOSING, + TransactionStatus.COMMITTING, + TransactionStatus.REVEALING, + ] + ), ) .count() ) 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 is_transaction_finalization_head(self, transaction_hash: str) -> bool: + """Return whether no older non-terminal transaction blocks this decision. + + This mirrors the per-recipient ordering gate used by + ``claim_next_finalization`` so lifecycle projection never advertises a + Finalize action that the worker is not allowed to execute. + """ + transaction = ( + self.session.query(Transactions).filter_by(hash=transaction_hash).first() + ) + if transaction is None: + return False + return ( + not self.session.query(Transactions.hash) + .filter( + Transactions.to_address == transaction.to_address, + _transaction_order_tuple() < _transaction_order_value(transaction), + Transactions.hash != transaction.hash, + Transactions.status.notin_( + [TransactionStatus.FINALIZED, TransactionStatus.CANCELED] + ), + ) + .first() + ) def get_processing_transaction_for_contract( self, contract_address: str @@ -1463,7 +2593,7 @@ def get_oldest_pending_for_contract(self, contract_address: str) -> dict | None: Transactions.to_address == contract_address, Transactions.status == TransactionStatus.PENDING, ) - .order_by(Transactions.created_at) + .order_by(Transactions.queue_order) .first() ) diff --git a/backend/database_handler/validators_registry.py b/backend/database_handler/validators_registry.py index aea670f1f..029aaf2f0 100644 --- a/backend/database_handler/validators_registry.py +++ b/backend/database_handler/validators_registry.py @@ -1,5 +1,7 @@ # consensus/domain/state.py +import functools +import inspect from typing import List from sqlalchemy.orm import Session @@ -27,6 +29,31 @@ def to_dict(validator: Validators, include_private_key: bool = True) -> dict: } +def rollback_on_error(method): + # The RPC validators session is process-lived; a failure left un-rolled-back poisons it. + if inspect.iscoroutinefunction(method): + + @functools.wraps(method) + async def async_wrapper(self, *args, **kwargs): + try: + return await method(self, *args, **kwargs) + except BaseException: + self.session.rollback() + raise + + return async_wrapper + + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + try: + return method(self, *args, **kwargs) + except BaseException: + self.session.rollback() + raise + + return wrapper + + class ValidatorsRegistry: def __init__(self, session: Session): self.session = session @@ -49,6 +76,7 @@ def _get_validator_or_fail(self, validator_address: str) -> Validators: raise ValidatorNotFound(validator_address) return validator_data + @rollback_on_error def count_validators(self) -> int: # Expire all objects to ensure we get fresh data from the database self.session.expire_all() @@ -57,6 +85,7 @@ def count_validators(self) -> int: self.session.commit() return count + @rollback_on_error def get_all_validators(self, include_private_key: bool = True) -> List[dict]: # Expire all objects to ensure we get fresh data from the database self.session.expire_all() @@ -68,6 +97,7 @@ def get_all_validators(self, include_private_key: bool = True) -> List[dict]: self.session.commit() return result + @rollback_on_error def get_validator( self, validator_address: str, include_private_key: bool = True ) -> dict: @@ -82,12 +112,14 @@ def get_validator( class ModifiableValidatorsRegistry(ValidatorsRegistry): + @rollback_on_error async def create_validator(self, validator: Validator) -> dict: db_validator = _to_db_model(validator) self.session.add(db_validator) self.session.flush() # Ensure the validator is persisted and queryable return self.get_validator(validator.address, False) + @rollback_on_error async def update_validator( self, new_validator: Validator, @@ -102,18 +134,25 @@ 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 + @rollback_on_error 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() + @rollback_on_error 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() + @rollback_on_error async def batch_create_validators(self, validators: list[Validator]) -> list[dict]: """Create multiple validators in a single batch without triggering restarts per-validator.""" results = [] @@ -125,6 +164,7 @@ async def batch_create_validators(self, validators: list[Validator]) -> list[dic results.append(self.get_validator(validator.address, False)) return results + @rollback_on_error async def replace_all_validators(self, validators: list[Validator]) -> list[dict]: """Atomically delete all validators and create new ones in a single transaction. 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..3f4db62bb 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -15,12 +15,23 @@ 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.node.genvm.executor_selection import ( + legacy_executor_selector_for_code, + uses_legacy_storage, +) 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 +79,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]: @@ -168,11 +173,12 @@ def __init__( self._collect_metrics = collect_metrics # Primary contract fast path: # decode the full primary contract state once and then read by bytes slot key. - self._primary_decoded: dict[bytes, bytes] | None = None + # {state_status: {slot: decoded_value_bytes}} + self._primary_decoded: dict[str, dict[bytes, bytes]] = {} # Per-contract decoded slot cache: - # {contract_address: {base64_slot_key: decoded_value_bytes}} + # {(state_status, contract_address): {base64_slot_key: decoded_value_bytes}} # This is only used for non-primary contract lazy reads. - self._decoded_slots: dict[str, dict[str, bytes]] = {} + self._decoded_slots: dict[tuple[str, str], dict[str, bytes]] = {} self._slot_keys: dict[bytes, str] = {} # Execution-scoped metrics to quantify cross-contract read cold/warm behavior. if collect_metrics: @@ -234,16 +240,18 @@ def _slot_key(self, slot: bytes) -> str: self._slot_keys[slot] = slot_key return slot_key - def _get_contract_slot_cache(self, snap: ContractSnapshot) -> dict[str, bytes]: - return self._decoded_slots.setdefault(snap.contract_address, {}) + def _get_contract_slot_cache( + self, snap: ContractSnapshot, status: str + ) -> dict[str, bytes]: + return self._decoded_slots.setdefault((status, snap.contract_address), {}) - def _get_primary_decoded(self) -> dict[bytes, bytes]: - decoded = self._primary_decoded + def _get_primary_decoded(self, status: str) -> dict[bytes, bytes]: + decoded = self._primary_decoded.get(status) if decoded is not None: return decoded decoded = {} - state = self.snapshot.states.get(self.state_status, {}) + state = self.snapshot.states.get(status, {}) shared_cache = self._shared_decoded_value_cache for slot_key, raw in state.items(): slot = base64.b64decode(slot_key) @@ -268,14 +276,13 @@ def _get_primary_decoded(self) -> dict[bytes, bytes]: metrics["shared_decoded_cache_hits"] += 1 decoded[slot] = value - self._primary_decoded = decoded + self._primary_decoded[status] = decoded return decoded - def _read_primary_slot_value(self, slot: bytes) -> bytes: - return self._get_primary_decoded().get(slot, b"") - - def _read_cross_slot_value(self, snap: ContractSnapshot, slot: bytes) -> bytes: - slot_cache = self._get_contract_slot_cache(snap) + def _read_cross_slot_value( + self, snap: ContractSnapshot, slot: bytes, status: str + ) -> bytes: + slot_cache = self._get_contract_slot_cache(snap, status) slot_key = self._slot_key(slot) data = slot_cache.get(slot_key) if data is not None: @@ -287,7 +294,7 @@ def _read_cross_slot_value(self, snap: ContractSnapshot, slot: bytes) -> bytes: metrics = self._metrics if metrics is not None: metrics["decoded_cache_misses"] += 1 - raw = snap.states.get(self.state_status, {}).get(slot_key) + raw = snap.states.get(status, {}).get(slot_key) shared_cache = self._shared_decoded_value_cache if raw is not None and shared_cache is not None: data = shared_cache.get(raw) @@ -323,15 +330,25 @@ def get_metrics(self) -> dict[str, int]: def storage_read( self, account: Address, slot: bytes, index: int, le: int, / + ) -> bytes: + return self._storage_read(self.state_status, account, slot, index, le) + + def storage_read_finalized( + self, account: Address, slot: bytes, index: int, le: int, / + ) -> bytes: + return self._storage_read("finalized", account, slot, index, le) + + def _storage_read( + self, status: str, account: Address, slot: bytes, index: int, le: int ) -> bytes: if account == self.contract_address: metrics = self._metrics if metrics is not None: metrics["primary_contract_lookups"] += 1 - data = self._read_primary_slot_value(slot) + data = self._get_primary_decoded(status).get(slot, b"") else: snap = self._get_snapshot(account) - data = self._read_cross_slot_value(snap, slot) + data = self._read_cross_slot_value(snap, slot, status) end = index + le if end <= len(data): return data[index:end] @@ -351,7 +368,7 @@ def storage_write( assert not self.readonly slot_key = self._slot_key(slot) state_bucket = self.snapshot.states.setdefault(self.state_status, {}) - primary_decoded = self._get_primary_decoded() + primary_decoded = self._get_primary_decoded(self.state_status) existing = primary_decoded.get(slot, b"") data = bytearray(existing) mem = memoryview(got) @@ -371,6 +388,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 +495,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 +513,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 +541,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 +647,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 +672,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 +690,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 +737,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 +751,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" @@ -778,11 +803,13 @@ def _date_from_str( res = res.replace(tzinfo=datetime.UTC) return res - def _put_code_to(self, to: genvmbase.StateProxyWritable, code: bytes) -> None: + def _put_code_to( + self, to: genvmbase.StateProxyWritable, code: bytes, *, legacy: bool = False + ) -> None: """Write contract code directly to storage using the code slot.""" from backend.node.genvm import get_code_slot - code_slot = get_code_slot() + code_slot = get_code_slot(legacy=legacy) # Prefix with 4-byte little-endian length code_len_prefix = len(code).to_bytes(4, byteorder="little", signed=False) code_data = code_len_prefix + code @@ -797,12 +824,20 @@ 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 + # Simulated deployments (including fee estimates) do not pass through + # consensus's snapshot factory. Select the same legacy line here too. + if not self.contract_snapshot.genvm_executor_selector: + self.contract_snapshot.genvm_executor_selector = ( + legacy_executor_selector_for_code(code_to_deploy) + ) + transaction_datetime = self._date_from_str(transaction_created_at) if transaction_datetime is None: - transaction_datetime = datetime.datetime.now() + transaction_datetime = datetime.datetime.now(datetime.UTC) return await self._run_genvm( from_address, @@ -814,6 +849,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 +860,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 +871,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( @@ -898,38 +936,46 @@ async def _execution_finished( ) ) - async def get_contract_schema(self, code: bytes) -> str: + async def get_contract_schema( + self, code: bytes, *, genvm_executor_selector: str | None = None + ) -> str: + inferred_selector = legacy_executor_selector_for_code(code) + executor_selector = genvm_executor_selector or inferred_selector NO_ADDR = Address(b"\x00" * 20) message = { "is_init": False, "contract_address": NO_ADDR, "sender_address": NO_ADDR, "origin_address": NO_ADDR, + "signer_address": NO_ADDR, "value": 0, "chain_id": 0, } state_proxy = _StateProxyNone(NO_ADDR) - self._put_code_to(state_proxy, code) + self._put_code_to( + state_proxy, code, legacy=uses_legacy_storage(executor_selector) + ) start_time = time.time() result = await genvmbase.run_genvm_host( 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, manager_uri=self.manager.url, + genvm_executor_selector=executor_selector, ) result.processing_time = int((time.time() - start_time) * 1000) @@ -967,15 +1013,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 +1067,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 +1102,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 +1165,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 + and result.data_fee_bucket_totals.keys() + == result.data_fees_remaining.keys() + ): + data_fees_consumed = { + name: max(0, int(total) - int(result.data_fees_remaining[name])) + for name, total in result.data_fee_bucket_totals.items() + } result = Receipt( result=genvmbase.encode_result_to_bytes(result.result), @@ -1121,6 +1215,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..b1bd26feb 100644 --- a/backend/node/genvm/__init__.py +++ b/backend/node/genvm/__init__.py @@ -1,5 +1,12 @@ 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() + +def get_code_slot(*, legacy: bool = False) -> bytes: + # The retained v0.2 ABI has CODE=1, LOCKED_SLOTS=2. The v0.3 ABI added a + # contract indirection and moved them to 2 and 3 respectively. + offset = (1 if legacy else 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..3559442af 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,43 @@ GenVMInternalError, ) +GENVM_GASLESS_GAS_DATA: dict[str, str] = { + **genvmhost.DEFAULT_GAS_DATA, + "storageUnitPrice": "0", + "receiptGasPerByte": "0", + "gasPerChangedSlot": "0", + "intrinsicGas": "0", + "bootloaderOverhead": "0", + "fixedProposeReceiptGas": "0", + "fixedMessageRevealGas": "0", + "lockedReceiptGasPrice": "0", + "genPerTimeUnit": "0", +} + +INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = ( + "(uint256,uint256,uint256,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 +# Consensus reserves keccak256("") as the wildcard. bytes32(0) is the real +# call key for deploys and unnamed calls, so the two must never be aliased. +CALL_KEY_WILDCARD = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + + +@dataclass(frozen=True) +class GenVMFeeContext: + bucket_totals: dict[str, int] | None = None + gas_data: dict[str, str] | None = None + message_fee_allocation: list[dict] | None = None + @dataclass class ExecutionError: @@ -92,9 +140,26 @@ def storage_read( self, account: Address, slot: bytes, index: int, le: int, / ) -> bytes: ... + def storage_read_finalized( + self, account: Address, slot: bytes, index: int, le: int, / + ) -> bytes: + """Read from the latest finalized state; a slot never finalized reads as zeroes.""" + return bytes(le) + @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 +178,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 +288,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 +308,201 @@ class ExecutionResult: processing_time: int nondet_disagree: int | None execution_stats: dict | None = None + data_fee_bucket_totals: dict[str, int] | None = None + data_fees_remaining: dict[str, 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, + int(value.get("max_price_gen_per_time_unit", 0)), + int(value.get("storage_fee_max_gas_price", 0)), + int(value.get("receipt_fee_max_gas_price", 0)), + ) + ], + ) + 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] | str: + value = _emission_value(emission, "allocationSubtree") + if isinstance(value, list): + return value + + subtree = value if value is not None else _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: + # Consensus hashes the raw SubmittedMessage field even when later + # inheritance decoding is contained as a restricted child. Preserve + # those bytes instead of aliasing every malformed payload to empty. + return "0x" + raw.hex() + + 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]], + int(decoded[5]), + int(decoded[6]), + int(decoded[7]), + ) + ], + ) + + +def _emission_int(emission: dict, name: str) -> int: + return int(_emission_value(emission, name) or 0) + + +def _emission_declared_budget(emission: dict) -> int: + value = _emission_value(emission, "messageFee") + if value is None: + value = _emission_value(emission, "declaredBudget") + return int(value 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 +530,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 +625,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 +645,17 @@ 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_declared_budget(emission), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_allocation_subtree(emission), + use_balance=bool( + _emission_value(emission, "useBalance") or False + ), ) ) - case "DeployContract": + case "InternalDeployMessage": pending_transactions.append( PendingTransaction( address="0x", @@ -365,10 +663,17 @@ 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_declared_budget(emission), + call_key=_emission_hex(emission, "callKey"), + allocation_subtree=_emission_allocation_subtree(emission), + use_balance=bool( + _emission_value(emission, "useBalance") or False + ), ) ) - case "EthSend": + case "ExternalMessage": pending_transactions.append( PendingTransaction( address=emission["address"].as_hex, @@ -378,11 +683,26 @@ def provide_result( value=emission["value"], on="finalized", is_eth_send=True, + fee_params=_emission_external_fee_params(emission), + declared_budget=_emission_declared_budget(emission), + 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)} + eq_outputs = {} + if res.result_leader_public_data: + 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 +714,179 @@ 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 - return await asyncio.to_thread( - self._state_proxy.storage_read, Address(account), slot, index, le + read = ( + self._state_proxy.storage_read_finalized + if type == StorageView.LATEST_FINALIZED + else self._state_proxy.storage_read ) + return await asyncio.to_thread(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 +947,66 @@ 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 = ( + dict(fee_context.bucket_totals) + if fee_context.bucket_totals + else base_host.default_bucket_totals(3) + ) + effective_gas_data = dict(base_host.DEFAULT_GAS_DATA) + effective_gas_data.update(GENVM_GASLESS_GAS_DATA) + if fee_context.gas_data: + effective_gas_data.update(fee_context.gas_data) tmpdir = Path(tempfile.mkdtemp()) try: base_delay = 5 # seconds @@ -546,7 +1022,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 +1057,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 +1070,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 +1134,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 +1177,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/executor_selection.py b/backend/node/genvm/executor_selection.py new file mode 100644 index 000000000..dbc57dc74 --- /dev/null +++ b/backend/node/genvm/executor_selection.py @@ -0,0 +1,37 @@ +"""Compatibility routing for the retained v0.2 text-contract executor.""" + +import re + +# Match the selector used to backfill pre-existing v0.2 contracts. Let the +# manager choose the newest available patch in this line, not a hardcoded +# directory or the newest semver-major-0 executor (which is v0.3). +LEGACY_EXECUTOR_SELECTOR = r"re:^v0\.2\." +_LEGACY_HEADER = re.compile( + rb"(?:#|//|--)\s*v0\.2\.(?:[0-9]+|\*)(?:-[0-9A-Za-z.-]+)?\s*" +) + + +def legacy_executor_selector_for_code(code: bytes) -> str | None: + """Honor an explicit v0.2 version in a text contract's first comment. + + GenVM recognizes #, // and -- comments only at the start of the source, + with the version on the first line. Do not infer a version from a runner + hash, imports, or later comments. Other formats/versions keep the normal + manager selection, and GenVM remains responsible for validating code. + """ + first_line = code.partition(b"\n")[0] + if _LEGACY_HEADER.fullmatch(first_line): + return LEGACY_EXECUTOR_SELECTOR + return None + + +def uses_legacy_storage(selector: str | None) -> bool: + """Recognize retained-line pins written by deployments and migration. + + Explicit version pins and the canonical line selector are stable storage + identities. Do not guess the result of an arbitrary user regex: it may + match several executor lines. + """ + return bool(selector) and ( + selector == LEGACY_EXECUTOR_SELECTOR or selector.startswith("v0.2.") + ) 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..20b0a11ad 100644 --- a/backend/node/genvm/origin/base_host.py +++ b/backend/node/genvm/origin/base_host.py @@ -1,45 +1,100 @@ -""" -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", + "lockedReceiptGasPrice": "1", + # 0 disables the overlay unless a fee test opts into a specific chain split + "overlaySplitBps": "0", + "receiptWrapperBytes": "1024", + "minProposeTimeout": "1", + "maxProposeTimeout": "340282366920938463463374607431768211455", + "minCommitTimeout": "1", + "maxCommitTimeout": "340282366920938463463374607431768211455", + "genPerTimeUnit": "0", + # 0 = no per-round execution-budget floor for balance-funded messages. + "messageBudgetFloor": "0", +} + +_DEFAULT_BUCKET_TOTALS: dict[str, int] = { + "execution_data_gas": 2**200, + "message_fee": 2**200, + "nondet_outputs": 2**200, + "submitted_messages": 2**200, + "submitted_messages_count": 2**200, +} + + +def default_bucket_totals(executor_major: int) -> dict[str, int]: + totals = _DEFAULT_BUCKET_TOTALS.copy() + if executor_major >= 3: + totals["submitted_messages_count"] = 2**200 + return totals class Context(typing.Protocol): @@ -50,28 +105,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 +121,37 @@ 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`, other overrides from `unsafe`. With debugging + disabled none of them take effect. + """ + + reroute_to: str = "" + initial_recursion: int | None = None + allow_two_workers: bool | None = None + + def as_request_field(self) -> dict[str, typing.Any]: + return { + "reroute_to": self.reroute_to, + "initial_recursion": self.initial_recursion, + "allow_two_workers": self.allow_two_workers, + } + + 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 +164,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 +227,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 +302,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 +381,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 +408,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 +428,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 +506,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: dict[str, int] = field(default_factory=dict) + + @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 +597,816 @@ 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: dict[str, 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. They arrive as a `failed_to_start` +# error string, 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] = (), + bucket_totals: dict[str, 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 +1415,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..0f540bfa5 --- /dev/null +++ b/backend/node/genvm/origin/fees.py @@ -0,0 +1,124 @@ +""" +Message-fee allocation types. + +Mirrors the shared `genvm_modules_interfaces::fees` module: the fee parameters +and the flat allocations that are 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 | None + # Lifecycle the node matches against (only meaningful for internal messages). + on: typing.Literal["finalized", "decided"] + fee_params: MessageAllocationNodeParams + children_budget: int + subtree: bytes + + +UNMATCHED_EXTERNAL_GUARD_ALLOC: MessageAllocationNode = { + "recipient": None, + "call_key": None, + "budget": 0, + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": 1, + "max_gas_price": 2**256 - 1, + }, + }, + "children_budget": 0, + "subtree": b"", +} + + +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_budget": 0, + "subtree": b"", +} + +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_budget": 0, + "subtree": b"", +} + +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_budget": 0, + "subtree": b"", +} 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..c18469f9b --- /dev/null +++ b/backend/node/genvm/origin/leader_public_data.py @@ -0,0 +1,33 @@ +import collections.abc +from dataclasses import dataclass +from typing import Self + +from . import calldata as gvm_calldata + + +@dataclass +class LeaderPublicData: + nondet_block_outputs: list[bytes] + + def encode(self) -> bytes: + return gvm_calldata.encode({"nd_outs": self.nondet_block_outputs}) + + @classmethod + def decode(cls, encoded: collections.abc.Buffer) -> Self: + decoded = gvm_calldata.decode(bytes(encoded)) + if ( + not isinstance(decoded, dict) + or decoded.keys() != {"nd_outs"} + or not isinstance(decoded["nd_outs"], list) + or any(not isinstance(output, bytes) for output in decoded["nd_outs"]) + ): + raise ValueError("invalid leader public data") + return cls(decoded["nd_outs"]) + + +def encode(value: LeaderPublicData) -> bytes: + return value.encode() + + +def decode(encoded: collections.abc.Buffer) -> LeaderPublicData: + return LeaderPublicData.decode(encoded) 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..d43ed72a2 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,374 @@ 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") + + +class _VmErrorOutOfMessageFee: + @staticmethod + def total() -> "_VmErrorOutOfMessageFeeTotal": + return _VmErrorOutOfMessageFeeTotal() + + @staticmethod + def allocation_budget() -> "_VmErrorOutOfMessageFeeAllocationBudget": + return _VmErrorOutOfMessageFeeAllocationBudget() -ABSENT_VERSION: typing.Final[str] = "v0.1.0" +class _VmErrorOutOf: + @staticmethod + def storage() -> "VmError": + return VmError("out_of storage") + @staticmethod + def subvm_recursion() -> "VmError": + return VmError("out_of subvm_recursion") -CODE_SLOT_OFFSET: typing.Final[int] = 1 + @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 _VmErrorFeeDescendantGrant: + @staticmethod + def budget() -> "VmError": + return VmError("fee descendant_grant budget") + + @staticmethod + def tree() -> "VmError": + return VmError("fee descendant_grant tree") + + @staticmethod + def external() -> "VmError": + return VmError("fee descendant_grant 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() + + @staticmethod + def descendant_grant() -> "_VmErrorFeeDescendantGrant": + return _VmErrorFeeDescendantGrant() + + +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..93936096a 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,15 @@ 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) + # Decoded FlatArrays/HashCommitments nodes when well formed; otherwise the + # exact receipt bytes as a 0x string so descriptor hashing does not erase a + # malformed or Merkle-proof payload. + allocation_subtree: list[dict] | str = field(default_factory=list) + gas_used: int = 0 + use_balance: bool = False def is_deploy(self) -> bool: return self.code is not None @@ -177,6 +196,12 @@ 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, + "use_balance": self.use_balance, } elif self.code is None: return { @@ -184,6 +209,12 @@ 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, + "use_balance": self.use_balance, } else: return { @@ -192,6 +223,12 @@ 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, + "use_balance": self.use_balance, } @classmethod @@ -202,27 +239,45 @@ 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), + use_balance=bool(input.get("use_balance", False)), ) 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), + use_balance=bool(input.get("use_balance", False)), ) 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), + use_balance=bool(input.get("use_balance", False)), ) 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..9977c4768 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -1,22 +1,37 @@ # rpc/endpoints.py +import copy +import math import random import json +import re import time import eth_utils import logging from contextlib import asynccontextmanager +from dataclasses import dataclass from functools import partial, wraps -from typing import Any +from typing import Any, Final, NoReturn, get_args from backend.protocol_rpc.exceptions import ( + InvalidParams, JSONRPCError, NotFoundError, QueueDepthExceeded, ) +from backend.protocol_rpc.redaction import ( + sanitize_rpc_private_keys as _sanitize_rpc_private_keys, +) +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,17 +54,51 @@ ) from backend.protocol_rpc.transactions_parser import TransactionParser +from backend.protocol_rpc.fees import ( + FEE_ACCOUNTING_KEY, + GENVM_UNMETERED_DATA_FEE_BUCKET, + VALIDATORS_PER_ROUND, + FeeValidationError, + StudioFeePolicy, + acceptance_dispatch_pending, + calculate_appeal_charge, + calculate_round_fees, + decode_internal_message_fee_params, + create_fee_accounting, + fee_accounting_with_discovered_messages, + funding_policy_for_accounting, + get_leader_rounds, + normalize_fees_distribution, + min_message_primary_fees, + record_appeal_bond, + record_execution_fee_consumption, + required_fee_deposit, + studio_fee_config, + validate_transaction_fee_deposit, +) +from backend.protocol_rpc.ghost_factory import GhostFactoryConfig +from backend.consensus.history import ( + actual_leader_rotations_by_round, + completed_consensus_round_index, + current_decision_id as history_current_decision_id, + has_terminal_validator_appeal, + latest_decision_metadata, + logical_fee_round_entries, +) from backend.errors.errors import InvalidAddressError, InvalidTransactionError from backend.database_handler.errors import ContractNotFoundError from backend.database_handler.transactions_processor import ( + TRANSACTION_STATUS_CODES, TransactionAddressFilter, TransactionsProcessor, ) - 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,19 +108,45 @@ 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, + DecodedRollupTransactionData, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, + 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) + +class _EvmExecutionReverted(Exception): + """A valid signed protocol envelope was mined but its call reverted.""" + + def __init__( + self, + *, + transaction_hash: str, + from_address: str, + to_address: str | None, + nonce: int, + reason: Exception, + ) -> None: + super().__init__(str(reason)) + self.transaction_hash = transaction_hash + self.from_address = from_address + self.to_address = to_address + self.nonce = int(nonce) + self.reason = reason + + # --------------------------------------------------------------------------- # Per-address rate limiting for gen_call / sim_call # Prevents a single contract from monopolising all GenVM execution slots. @@ -86,6 +161,13 @@ _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 _check_rate_limit(address: str) -> None: @@ -130,17 +212,73 @@ 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). # -# Studio Prod is a shared sandbox. Without this, a single user can submit -# thousands of txs to one contract and starve everyone else's contracts -# behind a 5-day backlog. We cap PENDING per (to_address) and per -# (from_address) at submission time and return a structured error pointing -# users at non-shared deployments for production-volume workloads. -# -# Both caps default to unset (unlimited) — meaningful only when explicitly -# configured (the public studio deployment sets them; self-hosted does not). +# Consensus Queues initializes maxPendingTxsPerRecipient to 20. Studio uses the +# same per-recipient default; deployments may lower/raise it explicitly when +# they apply the matching governance setting on Consensus. The Studio-only +# sender cap remains optional. # --------------------------------------------------------------------------- def _parse_optional_positive_int(env_name: str) -> int | None: raw = os.environ.get(env_name) @@ -153,18 +291,14 @@ def _parse_optional_positive_int(env_name: str) -> int | None: return parsed if parsed > 0 else None -_MAX_PENDING_PER_CONTRACT = _parse_optional_positive_int( - "MAX_PENDING_PER_CONTRACT_DEFAULT" +_MAX_PENDING_PER_CONTRACT = ( + _parse_optional_positive_int("MAX_PENDING_PER_CONTRACT_DEFAULT") or 20 ) _MAX_PENDING_PER_SENDER = _parse_optional_positive_int("MAX_PENDING_PER_SENDER_DEFAULT") # Generic guidance text in the error response — directs heavy users to # non-shared deployments rather than retrying against the same instance. -_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." -) +_QUEUE_DEPTH_HELP = "The protocol limits each recipient's pending queue depth." def _enforce_pending_queue_caps( @@ -174,17 +308,29 @@ def _enforce_pending_queue_caps( ) -> None: """Raise QueueDepthExceeded if the per-contract or per-sender cap is hit. - Both checks are best-effort and racy by design — two concurrent - submissions can both pass the COUNT(*) check before either commits, - so the actual depth can transiently exceed the cap by a few. That's - fine; the goal is to prevent unbounded growth, not to enforce an - exact bound. + Postgres admissions take deterministic advisory locks before counting so + two concurrent submissions cannot both claim the final queue slot. This + mirrors the serialized Queues insertion performed by Consensus. """ + session = transactions_processor.session + bind = session.get_bind() + if bind.dialect.name == "postgresql": + lock_keys = [] + if _MAX_PENDING_PER_CONTRACT is not None and to_address is not None: + lock_keys.append(f"pending-recipient:{str(to_address).lower()}") + if _MAX_PENDING_PER_SENDER is not None and from_address is not None: + lock_keys.append(f"pending-sender:{str(from_address).lower()}") + for lock_key in sorted(lock_keys): + session.execute( + text("SELECT pg_advisory_xact_lock(" "hashtextextended(:lock_key, 0))"), + {"lock_key": lock_key}, + ) if _MAX_PENDING_PER_CONTRACT is not None and to_address is not None: - contract_pending = transactions_processor.session.execute( + contract_pending = session.execute( text( "SELECT COUNT(*) FROM transactions " - "WHERE to_address = :addr AND status = 'PENDING'" + "WHERE to_address = :addr AND status IN " + "('PENDING', 'ACTIVATED', 'PROPOSING', 'COMMITTING', 'REVEALING')" ), {"addr": to_address}, ).scalar() @@ -207,7 +353,7 @@ def _enforce_pending_queue_caps( ) if _MAX_PENDING_PER_SENDER is not None and from_address is not None: - sender_pending = transactions_processor.session.execute( + sender_pending = session.execute( text( "SELECT COUNT(*) FROM transactions " "WHERE from_address = :addr AND status = 'PENDING'" @@ -230,6 +376,87 @@ def _enforce_pending_queue_caps( ) +def _allocate_top_level_ghost_address( + transactions_processor: TransactionsProcessor, + salt_nonce: int, + namespace: str, +) -> str: + """Reserve the next virtual GhostFactory address until admission commits.""" + + transactions_processor.lock_ghost_factory() + deployment_count = transactions_processor.get_successful_ghost_creation_count() + address = GhostFactoryConfig.from_env().address_for( + salt_nonce, + deployment_count, + namespace=namespace, + ) + if transactions_processor.is_genvm_contract_address(address): + # GhostFactory rejects a reused CREATE2 address and the enclosing + # Consensus submission reverts. The factory nonce is unchanged. + raise InvalidTransactionError("GhostAlreadyDeployed") + return address + + +def get_studio_fee_config() -> dict[str, Any]: + return studio_fee_config(StudioFeePolicy.from_env()) + + +def sim_calculate_round_fees( + fees_distribution: dict[str, Any], + num_of_validators: int = 5, + round: int = 0, +) -> str: + """Expose Studio's canonical fee quote for cross-stack conformance tests.""" + try: + quote = calculate_round_fees( + fees_distribution, + int(num_of_validators), + int(round), + StudioFeePolicy.from_env(), + ) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + return str(quote) + + +def sim_min_message_primary_fees(fee_params: str) -> str: + """Quote the child primary-fee floor from canonical v0.6 ABI bytes.""" + try: + decoded = decode_internal_message_fee_params(fee_params) + quote = min_message_primary_fees(decoded, StudioFeePolicy.from_env()) + except FeeValidationError as exc: + raise InvalidTransactionError(str(exc)) from exc + return str(quote) + + +def sim_estimate_propose_receipt_gas(eq_outputs_length: int = 0) -> dict[str, str]: + """Expose the deterministic v0.6 proposed-receipt metering formula.""" + policy = StudioFeePolicy.from_env() + receipt_bytes = policy.estimate_propose_receipt_bytes(int(eq_outputs_length)) + gas = policy.estimate_propose_receipt_gas(receipt_bytes) + return { + "receiptBytes": str(receipt_bytes), + "gas": str(gas), + "fee": str(gas * policy.receipt_gas_price), + } + + +def sim_estimate_message_reveal_gas( + message_bytes: int, + message_count: int, +) -> dict[str, str]: + """Expose the deterministic v0.6 revealed-message write formula.""" + policy = StudioFeePolicy.from_env() + gas = policy.estimate_consensus_message_reveal_gas( + int(message_bytes), + int(message_count), + ) + return { + "gas": str(gas), + "fee": str(gas * policy.receipt_gas_price), + } + + ####### ADMIN ACCESS CONTROL ####### def require_admin_access(func): """ @@ -294,7 +521,7 @@ def clear_db_tables(session: Session, tables: list) -> None: def fund_account( session: Session, account_address: str, - amount: int, + amount: int | str, ) -> str: """Fund an account within a request-scoped database session.""" accounts_manager = AccountsManager(session) @@ -303,14 +530,45 @@ def fund_account( if not accounts_manager.is_valid_address(account_address): raise InvalidAddressError(account_address) + if isinstance(amount, bool) or not isinstance(amount, (int, str)): + raise InvalidParams(message="amount must be a positive integer") + if isinstance(amount, str): + if re.fullmatch(r"[0-9]+", amount): + normalized_amount = int(amount, 10) + elif re.fullmatch(r"0[xX][0-9a-fA-F]+", amount): + normalized_amount = int(amount, 16) + else: + raise InvalidParams(message="amount must be a positive integer") + else: + normalized_amount = amount + if normalized_amount <= 0: + raise InvalidParams(message="amount must be a positive integer") + + account_address = eth_utils.to_checksum_address(account_address) + import secrets nonce = transactions_processor.get_transaction_count(None) transaction_hash = "0x" + secrets.token_hex(32) transactions_processor.insert_transaction( - None, account_address, None, amount, 0, nonce, False, 0, None, transaction_hash + None, + account_address, + None, + normalized_amount, + 0, + nonce, + False, + 0, + None, + transaction_hash, ) - accounts_manager.credit_tx_value_once(transaction_hash, account_address, amount) + accounts_manager.credit_tx_value_once( + transaction_hash, account_address, normalized_amount + ) + # Release the current_state row lock before returning: sync handlers run on + # the event loop, so a concurrent caller blocking on this row would freeze + # the loop and prevent the request-scoped session from ever committing. + session.commit() return transaction_hash @@ -328,19 +586,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 +622,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 +636,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 @@ -495,6 +755,52 @@ async def create_validator( ) +@check_forbidden_method_in_hosted_studio +async def replace_validators( + session: Session, + validators_manager: validators.Manager, + validator_configs: list[dict], +) -> list[dict]: + """Atomically replace the simulator validator pool with one GenVM reload.""" + + accounts_manager = AccountsManager(session) + replacement_validators: list[Validator] = [] + + for validator_config in validator_configs: + stake = int(validator_config["stake"]) + provider = str(validator_config["provider"]) + model = str(validator_config["model"]) + config = validator_config.get("config") + plugin = validator_config.get("plugin") + plugin_config = validator_config.get("plugin_config") + + if config is None or plugin is None or plugin_config is None: + llm_provider = get_default_provider_for(provider, model) + else: + llm_provider = LLMProvider( + provider=provider, + model=model, + config=config, + plugin=plugin, + plugin_config=plugin_config, + ) + validate_provider(llm_provider) + + account = accounts_manager.create_new_account() + replacement_validators.append( + Validator( + address=account.address, + private_key=account.key, + stake=stake, + llmprovider=llm_provider, + ) + ) + + return await validators_manager.registry.replace_all_validators( + replacement_validators + ) + + @check_forbidden_method_in_hosted_studio async def create_random_validator( session: Session, @@ -873,7 +1179,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 +1254,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") @@ -998,7 +1309,10 @@ async def get_contract_schema( contract_snapshot_factory=None, manager=genvm_manager, ) - schema = await node.get_contract_schema(base64.b64decode(code_b64)) + schema = await node.get_contract_schema( + base64.b64decode(code_b64), + genvm_executor_selector=contract_snapshot.genvm_executor_selector, + ) return json.loads(schema) @@ -1039,13 +1353,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 +1483,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 +1514,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 +1525,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 +1587,94 @@ async def sim_call( genvm_manager, params, ) - return receipt.to_dict() + return TransactionsProcessor._json_safe_numbers( + _sanitize_rpc_private_keys(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, + "_discover_message_allocations_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,25 +1692,40 @@ 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, + ) + unmeter_execution_for_fee_estimate = bool( + params.get("_allow_low_execution_budget_for_estimate") + ) + genvm_fee_accounting = _effective_simulation_fee_accounting_for_genvm( + simulation_fee_accounting, + unmeter_execution=unmeter_execution_for_fee_estimate, ) - if not accounts_manager.is_valid_address(from_address): raise InvalidAddressError(from_address) + if type == "deploy": + deployment_count = ( + TransactionsProcessor(session).get_successful_ghost_creation_count() + if session is not None + else 0 + ) + to_address = GhostFactoryConfig.from_env().address_for( + int(params.get("salt_nonce", params.get("saltNonce", 0)) or 0), + deployment_count, + namespace=from_address, + ) + if not accounts_manager.is_valid_address(to_address): raise InvalidAddressError(to_address) # 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: @@ -1276,121 +1736,167 @@ async def _gen_call_with_validator( message="No validators available to execute the gen_call", ) - # Create validator node - try: - contract_snapshot = ContractSnapshot(to_address, session) - except ContractNotFoundError: - raise NotFoundError( - message=f"Contract {to_address} not found", - data={"contract_address": to_address}, - ) - node = Node( - contract_snapshot=contract_snapshot, - contract_snapshot_factory=partial(ContractSnapshot, session=session), - validator_mode=ExecutionMode.LEADER, - validator=validator, - leader_receipt=None, - msg_handler=msg_handler.with_client_session(get_client_session_id()), - validators_snapshot=validators_snapshot, - manager=genvm_manager, - ) - sc_raw = params.get("sim_config") + _validate_genvm_executor_selector(sc_raw) + _reject_genvm_executor_selector_unless_deploy( + sc_raw, + is_deploy=type == "deploy", + ) sim_config = SimConfig.from_dict(sc_raw) if sc_raw else None override_transaction_datetime: bool = ( 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}, + def create_node() -> Node: + # A fee estimate can execute twice: first to discover exact message + # keys and then to meter them. Each pass must start from the same DB + # snapshot; _SnapshotView writes through to its ContractSnapshot. + if type == "deploy": + contract_snapshot = ContractSnapshot(None, session) + contract_snapshot.contract_address = to_address + contract_snapshot.balance = 0 + contract_snapshot.states = {"accepted": {}, "finalized": {}} + contract_snapshot.genvm_executor_selector = ( + sim_config.genvm_executor_selector if sim_config else None + ) + else: + contract_snapshot = ContractSnapshot(to_address, session) + if type in {"write", "deploy"}: + _stage_simulated_call_value(contract_snapshot, call_value) + return Node( + contract_snapshot=contract_snapshot, + contract_snapshot_factory=partial(ContractSnapshot, session=session), + validator_mode=ExecutionMode.LEADER, + validator=validator, + leader_receipt=None, + msg_handler=msg_handler.with_client_session(get_client_session_id()), + validators_snapshot=validators_snapshot, + manager=genvm_manager, ) - 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 + async def execute(active_fee_accounting): + node = create_node() + 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) + return 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) + return 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=active_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) + return 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=active_fee_accounting, + ) + else: + raise JSONRPCError( + code=-32602, + message=f"Invalid type '{type}': must be 'read', 'write', or 'deploy'", + ) + + policy = StudioFeePolicy.from_env() + discover_message_allocations = bool( + params.get("_discover_message_allocations_for_estimate") + and type in {"write", "deploy"} + and simulation_fee_accounting is not None + and not simulation_fee_accounting.get("message_allocations") + and policy.fee_accounting_enabled() + ) + + try: + # The first pass reveals concrete recipient/call-key pairs without + # making the transaction envelope permissive. The second pass meters + # the same execution under the exact discovered allocation roots. + receipt = await execute( + None if discover_message_allocations else genvm_fee_accounting + ) + if ( + discover_message_allocations + and receipt.execution_result == ExecutionResultStatus.SUCCESS + ): + simulation_fee_accounting = fee_accounting_with_discovered_messages( + simulation_fee_accounting, + receipt, + policy, + ) + genvm_fee_accounting = _effective_simulation_fee_accounting_for_genvm( + simulation_fee_accounting, + unmeter_execution=unmeter_execution_for_fee_estimate, + ) + receipt = await execute(genvm_fee_accounting) + 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, + ) # Return the result of the write method if receipt.execution_result != ExecutionResultStatus.SUCCESS: raise JSONRPCError( code=-32000, message="execution failed", - data={"receipt": receipt.to_dict(), "params": params}, + data={ + "receipt": _sanitize_rpc_private_keys(receipt.to_dict()), + "params": params, + }, ) return receipt @@ -1420,15 +1926,40 @@ 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: + envelope = transactions_processor.get_evm_envelope(transaction_hash) + if envelope is not None: + # Lifecycle calls and post-admission execution reverts are mined + # EVM envelopes but do not create GenLayer transaction rows. Viem + # polls eth_getTransactionByHash before reading the receipt, so + # expose the durable envelope through the matching Ethereum + # endpoint instead of making a valid status-0/1 receipt + # undiscoverable. + return { + "blockHash": transaction_hash, + "blockNumber": hex(0), + "from": envelope.from_address, + "gas": hex(0), + "gasPrice": hex(0), + "hash": transaction_hash, + "input": "0x", + "nonce": hex(int(envelope.nonce)), + "r": "0x" + "0" * 64, + "s": "0x" + "0" * 64, + "to": envelope.to_address, + "transactionIndex": hex(0), + "type": hex(0), + "v": hex(0), + "value": hex(0), + } raise NotFoundError( 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 +1976,39 @@ 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 | dict +) -> str | dict: + return_details = isinstance(transaction_hash, dict) + if return_details: + transaction_hash = transaction_hash.get("txId") or transaction_hash.get("tx_id") + if not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError(code=-32602, message="txId is required", data={}) + elif not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError( + code=-32602, + message="transaction hash must be a string or an object containing txId", + data={}, + ) + + 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}, + ) + # Node v0.6 uses an object request and returns the canonical status payload. + # Keep the original positional-string response for deployed Studio clients + # (notably Rally) until they migrate to the train interface. + return status if return_details else status["status"] + + +def get_transaction_status_details( transactions_processor: TransactionsProcessor, transaction_hash: str -) -> str: +) -> dict: status = transactions_processor.get_transaction_status(transaction_hash) if status is None: raise NotFoundError( @@ -1460,17 +2018,368 @@ def get_transaction_status( return status -async def eth_call( - session: Session, - accounts_manager: AccountsManager, - msg_handler: IMessageHandler, - transactions_parser: TransactionParser, - validators_manager: validators.Manager, - genvm_manager: GenVMManager, - transactions_processor: TransactionsProcessor, - params: dict, - block_tag: str = "latest", -) -> str: +_DECISION_STATUSES = { + TransactionStatus.ACCEPTED.value, + TransactionStatus.UNDETERMINED.value, + TransactionStatus.LEADER_TIMEOUT.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, +} + + +@dataclass(frozen=True) +class _AppealAdmissionContext: + status: str + deadline: float | None + fee_accounting: dict[str, Any] + current_round: int + validator_count: int + normal_leader_count: int + available_appeal_validators: int | None + leader_timeout_live_seats: int | None + + +_PROTOCOL_TRANSACTION_STATUS_NAMES = ( + "Uninitialized", + "Pending", + "Proposing", + "Committing", + "Revealing", + "Accepted", + "Undetermined", + "Finalized", + "Canceled", + "AppealRevealing", + "AppealCommitting", + "ValidatorsTimeout", + "LeaderTimeout", + "LeaderRevealing", +) +_RESOLUTION_ACTION_NAMES = ( + "NoOp", + "Cancel", + "ReplaceActor", + "RotateLeader", + "ResolveAppeal", + "MaterializeDecision", + "Finalize", +) +_RESOLUTION_SOURCE_NAMES = ( + "Unspecified", + "ActivationInsufficientValidators", + "ProposalHanging", + "LeaderReceiptTimeout", + "CommitHanging", + "LeaderRevealHanging", + "FullReveal", + "RevealDeadline", + "AppealCommitHanging", + "AppealFullReveal", + "AppealRevealDeadline", + "SelectionDepleted", +) + + +def _transaction_appeal_deadline(transaction: dict) -> float | None: + started_at = transaction.get("timestamp_awaiting_finalization") + if started_at is not None and str(transaction.get("execution_mode") or "") in { + "LEADER_ONLY", + "LEADER_SELF_VALIDATOR", + }: + return float(started_at) + decision = latest_decision_metadata(transaction.get("consensus_history")) + if decision is not None: + try: + deadline = int(decision.get("appealDeadline") or 0) + except (TypeError, ValueError): + deadline = 0 + if deadline > 0: + return float(deadline) + if started_at is None: + return None + finality_window = int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) + failed_reduction = float( + os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0") + ) + failed_reduction = min(1.0, max(0.0, failed_reduction)) + return ( + float(started_at) + + float(transaction.get("appeal_processing_time") or 0) + + finality_window + * ((1.0 - failed_reduction) ** int(transaction.get("appeal_failed") or 0)) + ) + + +def _transaction_decision_id(transaction: dict) -> int: + return history_current_decision_id(transaction.get("consensus_history")) + + +def _appeal_admission_context( + transaction: dict, + *, + session: Session | None, + expected_decision_id: int | None = None, + require_decision_binding: bool = False, +) -> _AppealAdmissionContext: + """Resolve the eligibility inputs shared by appeal quote and admission.""" + status = str(transaction.get("status") or "") + if ( + status not in _DECISION_STATUSES + or bool(transaction.get("appealed")) + or has_terminal_validator_appeal(transaction.get("consensus_history")) + ): + raise InvalidTransactionError("CanNotAppeal") + + deadline = _transaction_appeal_deadline(transaction) + if deadline is not None and time.time() >= deadline: + raise InvalidTransactionError("CanNotAppeal") + if require_decision_binding and ( + expected_decision_id is None + or int(expected_decision_id) != _transaction_decision_id(transaction) + ): + raise InvalidTransactionError("CanNotAppeal") + + fee_accounting = (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) + if fee_accounting is None: + raise InvalidTransactionError("FeeAccountingMissing") + if acceptance_dispatch_pending(fee_accounting): + raise InvalidTransactionError("CanNotAppeal") + + frozen_pool_addresses = fee_accounting.get("selection_pool_addresses") + live_pool_addresses = None + if session is not None and isinstance(frozen_pool_addresses, list): + live_pool_addresses = [ + validator.get("address") + for validator in ValidatorsRegistry(session).get_all_validators( + include_private_key=False + ) + ] + frozen_pool_count = fee_accounting.get("selection_pool_count") + validator_count = ( + int(frozen_pool_count) + if frozen_pool_count is not None + else ( + ValidatorsRegistry(session).count_validators() + if session is not None + else int( + fee_accounting.get("num_of_initial_validators") + or transaction.get("num_of_initial_validators") + or 5 + ) + ) + ) + current_round = _current_fee_round(transaction.get("consensus_history")) + available_appeal_validators = _available_appeal_validator_count( + transaction, + validator_count, + frozen_pool_addresses=frozen_pool_addresses, + live_pool_addresses=live_pool_addresses, + ) + if ( + status + in { + TransactionStatus.ACCEPTED.value, + TransactionStatus.VALIDATORS_TIMEOUT.value, + } + and available_appeal_validators == 0 + ): + raise InvalidTransactionError("CanNotAppeal") + if ( + status == TransactionStatus.UNDETERMINED.value + and available_appeal_validators is not None + and available_appeal_validators + < VALIDATORS_PER_ROUND[min(current_round + 1, len(VALIDATORS_PER_ROUND) - 1)] + ): + raise InvalidTransactionError("CanNotAppeal") + + leader_timeout_live_seats = None + if status == TransactionStatus.LEADER_TIMEOUT.value: + live_validators = transaction.get("leader_timeout_validators") + if isinstance(live_validators, list): + # The removed leader changes replay membership, not the configured + # source committee used to price the leader-timeout bond. + leader_timeout_live_seats = len(live_validators) + 1 + + return _AppealAdmissionContext( + status=status, + deadline=deadline, + fee_accounting=fee_accounting, + current_round=current_round, + validator_count=validator_count, + normal_leader_count=_normal_leader_count(transaction.get("consensus_history")), + available_appeal_validators=available_appeal_validators, + leader_timeout_live_seats=leader_timeout_live_seats, + ) + + +def _transaction_resolution_source_code( + transaction: dict, *, status: str, current_round: int +) -> int: + if status == TransactionStatus.LEADER_TIMEOUT.value: + return 3 # LeaderReceiptTimeout + + logical_entries = logical_fee_round_entries(transaction.get("consensus_history")) + if status == TransactionStatus.UNDETERMINED.value and len(logical_entries) == 1: + _, entry = logical_entries[0] + if ( + str(entry.get("consensus_round") or "") == "Undetermined" + and not entry.get("leader_result") + and not entry.get("validator_results") + ): + # Studio's exact-capacity activation path records the same + # receipt-less Pending -> Undetermined decision as Consensus's + # ActivationInsufficientValidators trigger. + return 1 + + appeal_attempt = current_round % 2 == 1 + # Studio materializes these outcomes only after its RevealingState has + # tallied the complete receipt set. A validator execution timeout is a + # VoteType.Timeout ballot, not a Consensus RevealDeadline trigger. + return 9 if appeal_attempt else 6 + + +def get_transaction_lifecycle( + transactions_processor: TransactionsProcessor, + params: dict, +) -> dict: + """Project Studio's stored decision state through the v0.6 lifecycle ABI.""" + if not isinstance(params, dict): + raise JSONRPCError(code=-32602, message="params must be an object", data={}) + transaction_hash = params.get("txId") or params.get("tx_id") + if not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError(code=-32602, message="txId is required", data={}) + transaction = transactions_processor.get_transaction_by_hash(transaction_hash) + if transaction is None: + raise NotFoundError( + message=f"Transaction {transaction_hash} not found", + data={"hash": transaction_hash}, + ) + + requested_timestamp = params.get("timestamp") + try: + evaluated_at = ( + int(time.time()) + if requested_timestamp is None + else int(requested_timestamp) + ) + except (TypeError, ValueError) as exc: + raise JSONRPCError( + code=-32602, + message="timestamp must be a non-negative integer", + data={}, + ) from exc + if evaluated_at < 0: + raise JSONRPCError( + code=-32602, + message="timestamp must be a non-negative integer", + data={}, + ) + + status = str(transaction.get("status") or "UNINITIALIZED").upper() + stored_status_code = TRANSACTION_STATUS_CODES.get(status, 0) + decision_active = status in _DECISION_STATUSES and not bool( + transaction.get("appealed") + ) + effects_pending = acceptance_dispatch_pending( + (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) + ) + current_round = _current_fee_round(transaction.get("consensus_history")) + decision_id = _transaction_decision_id(transaction) if decision_active else None + + if not decision_active: + resolution_source_code = 0 + else: + resolution_source_code = _transaction_resolution_source_code( + transaction, + status=status, + current_round=current_round, + ) + + resolution_action_code = 0 + deadline = _transaction_appeal_deadline(transaction) + if ( + decision_active + and not effects_pending + and deadline is not None + and evaluated_at >= deadline + and transactions_processor.is_transaction_finalization_head(transaction_hash) + ): + resolution_action_code = 6 # Finalize + + return { + "storedStatus": _PROTOCOL_TRANSACTION_STATUS_NAMES[stored_status_code], + "storedStatusCode": stored_status_code, + # ResolutionKernel leaves a decision in its stored status until the + # separately reported Finalize action is actually committed. + "projectedStatus": _PROTOCOL_TRANSACTION_STATUS_NAMES[stored_status_code], + "projectedStatusCode": stored_status_code, + "resolutionAction": _RESOLUTION_ACTION_NAMES[resolution_action_code], + "resolutionActionCode": resolution_action_code, + "resolutionSource": _RESOLUTION_SOURCE_NAMES[resolution_source_code], + "resolutionSourceCode": resolution_source_code, + "decisionId": str(decision_id) if decision_id is not None else None, + "decisionActive": decision_active, + "evaluatedAt": evaluated_at, + } + + +def estimate_latest_appeal_charge( + transactions_processor: TransactionsProcessor, + params: dict, +) -> dict: + """Return the exact decision-bound appeal quote used by Studio admission.""" + if not isinstance(params, dict): + raise JSONRPCError(code=-32602, message="params must be an object", data={}) + transaction_hash = params.get("txId") or params.get("tx_id") + if not isinstance(transaction_hash, str) or not transaction_hash: + raise JSONRPCError(code=-32602, message="txId is required", data={}) + transaction = transactions_processor.get_transaction_by_hash(transaction_hash) + if transaction is None: + raise NotFoundError( + message=f"Transaction {transaction_hash} not found", + data={"hash": transaction_hash}, + ) + + context = _appeal_admission_context( + transaction, + session=getattr(transactions_processor, "session", None), + ) + charge = calculate_appeal_charge( + context.fee_accounting["fees_distribution"], + current_round=context.current_round, + status=context.status, + terminal_committee_upper_bound=max( + 0, + context.validator_count - context.normal_leader_count, + ), + available_appeal_validators=context.available_appeal_validators, + leader_timeout_live_seats=context.leader_timeout_live_seats, + policy=funding_policy_for_accounting( + context.fee_accounting, + StudioFeePolicy.from_env(), + ), + ) + decision_id = _transaction_decision_id(transaction) + return { + "decisionId": str(decision_id), + "bond": str(int(charge["bond"])), + "funding": str(int(charge["funding"])), + "appealDeadline": ( + str(math.ceil(context.deadline)) if context.deadline is not None else "0" + ), + } + + +async def eth_call( + session: Session, + accounts_manager: AccountsManager, + msg_handler: IMessageHandler, + transactions_parser: TransactionParser, + validators_manager: validators.Manager, + genvm_manager: GenVMManager, + transactions_processor: TransactionsProcessor, + params: dict, + block_tag: str = "latest", +) -> str: to_address = params.get("to") from_address = params.get("from") data = params.get("data") @@ -1541,11 +2450,590 @@ async def eth_call( if receipt.execution_result != ExecutionResultStatus.SUCCESS: raise JSONRPCError( - code=-32000, message="execution failed", data={"receipt": receipt.to_dict()} + code=-32000, + message="execution failed", + data={"receipt": _sanitize_rpc_private_keys(receipt.to_dict())}, ) 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 isinstance( + decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs + ) + 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 + + +def _funded_max_rotations( + decoded_rollup_transaction: DecodedRollupTransaction, + requested_max_rotations: int, +) -> int: + """Mirror Consensus' submission-time rotation-capacity clamp. + + ``initialRotations`` remains the transaction-wide ceiling. Consensus + clamps it to the largest funded entry so later normal rounds retain their + paid capacity; each round is separately bounded by its own schedule entry + at runtime. + """ + + data = decoded_rollup_transaction.data + if ( + data is None + or isinstance( + data, + ( + DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, + ), + ) + or not hasattr(data, "args") + or data.args is None + or data.args.fees_distribution is None + ): + return int(requested_max_rotations) + + rotations = normalize_fees_distribution(data.args.fees_distribution)["rotations"] + if not rotations: + return int(requested_max_rotations) + return min(int(requested_max_rotations), max(int(value) for value in rotations)) + + +# `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 isinstance( + decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs + ) + or not hasattr(decoded_rollup_transaction.data, "args") + or decoded_rollup_transaction.data.args is None + ): + return + + args = decoded_rollup_transaction.data.args + policy = StudioFeePolicy.from_env() + if ( + decoded_rollup_transaction.data.function_name == "deploySalted" + and int(args.salt_nonce or 0) == 0 + ): + raise InvalidTransactionError("InvalidDeploymentWithSalt") + if args.fees_distribution is None: + if policy.fee_accounting_enabled(): + raise InvalidTransactionError("FeesDistributionMissing") + 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=policy, + ) + 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) + session = getattr(transactions_processor, "session", None) + try: + transactions_processor.apply_transaction_fee_top_up( + tx_id, + fees_distribution=decoded_rollup_transaction.data.fees_distribution, + amount=decoded_rollup_transaction.total_spend, + sender=decoded_rollup_transaction.from_address, + policy=StudioFeePolicy.from_env(), + ) + _sandbox_debit_sender( + accounts_manager, + decoded_rollup_transaction.from_address, + decoded_rollup_transaction.total_spend, + ) + except FeeValidationError as exc: + if session is not None: + session.rollback() + raise InvalidTransactionError(str(exc)) from exc + except ValueError as exc: + if session is not None: + session.rollback() + if str(exc) == "TransactionNotFound": + raise NotFoundError( + message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id} + ) from exc + raise InvalidTransactionError(str(exc)) from exc + except Exception: + if session is not None: + session.rollback() + raise + return tx_id + + +def _handle_appeal_or_top_up_and_submit( + *, + accounts_manager: AccountsManager, + transactions_processor: TransactionsProcessor, + msg_handler: IMessageHandler, + decoded_rollup_transaction: DecodedRollupTransaction, + emit_event: bool = True, +) -> 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}) + + expected_decision_id = decoded_rollup_transaction.data.expected_decision_id + # v0.6 binds every appeal to one exact materialized decision. Older SDKs + # may still decode without this argument, but admission rejects them. + context = _appeal_admission_context( + tx, + session=getattr(accounts_manager, "session", None), + expected_decision_id=expected_decision_id, + require_decision_binding=True, + ) + # New Studio decisions persist their exact materialized ID. Legacy rows + # derive the same monotonic ordinal from alternating normal/appeal history. + current_decision_id = _transaction_decision_id(tx) + + def prepare_fee_accounting(current_fee_accounting): + if current_fee_accounting is None: + raise FeeValidationError("FeeAccountingMissing") + updated = record_appeal_bond( + current_fee_accounting, + amount=decoded_rollup_transaction.total_spend, + appealer=decoded_rollup_transaction.from_address, + current_round=context.current_round, + status=context.status, + fees_distribution=decoded_rollup_transaction.data.fees_distribution, + top_up_and_submit=decoded_rollup_transaction.data.top_up_and_submit, + terminal_committee_upper_bound=max( + 0, + context.validator_count - context.normal_leader_count, + ), + available_appeal_validators=context.available_appeal_validators, + replacement_rotations=None, + leader_timeout_live_seats=context.leader_timeout_live_seats, + policy=funding_policy_for_accounting( + current_fee_accounting, + StudioFeePolicy.from_env(), + ), + ) + return updated, int(updated["appeal_bonds"][-1]["surplusRefund"]) + + submitted_at = int(time.time()) + failed_reduction = float( + os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0") + ) + failed_reduction = min(1.0, max(0.0, failed_reduction)) + session = getattr(transactions_processor, "session", None) + try: + surplus_refund = transactions_processor.admit_transaction_appeal( + tx_id, + expected_decision_id=current_decision_id, + submitted_at=submitted_at, + appeal_deadline=( + int(context.deadline) + if context.deadline is not None + else submitted_at + int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) + ), + retention_bps=round((1.0 - failed_reduction) * 10_000), + prepare_fee_accounting=prepare_fee_accounting, + ) + _sandbox_debit_sender( + accounts_manager, + decoded_rollup_transaction.from_address, + decoded_rollup_transaction.total_spend, + ) + if surplus_refund > 0: + accounts_manager.credit_account_balance( + decoded_rollup_transaction.from_address, + surplus_refund, + ) + except FeeValidationError as exc: + if session is not None: + session.rollback() + raise InvalidTransactionError(str(exc)) from exc + except ValueError as exc: + if session is not None: + session.rollback() + raise InvalidTransactionError("CanNotAppeal") from exc + except Exception: + if session is not None: + session.rollback() + raise + if emit_event: + msg_handler.send_message( + log_event=_transaction_appeal_updated_event(tx_id), + log_to_terminal=False, + ) + return tx_id + + +def _transaction_appeal_updated_event(tx_id: str) -> LogEvent: + return LogEvent( + "transaction_appeal_updated", + EventType.INFO, + EventScope.CONSENSUS, + "Set transaction appealed", + {"hash": tx_id}, + ) + + +def _handle_finalize_transaction( + *, + transactions_processor: TransactionsProcessor, + decoded_rollup_transaction: DecodedRollupTransaction, +) -> str: + """Validate an exact-decision finalization request. + + Studio's worker owns the asynchronous state/contract commit. This boundary + mirrors v0.6 admission: it accepts only the active decision, at or after its + immutable deadline, and only at the recipient's finalization head. The + already-eligible row is then consumed by the worker without inventing a + second Studio-only lifecycle transition. + """ + assert isinstance( + decoded_rollup_transaction.data, DecodedFinalizeTransactionDataArgs + ) + if int(decoded_rollup_transaction.total_spend) != 0: + # ConsensusMain.finalizeTransaction is nonpayable. Do not silently + # accept value that an EVM call would reject (or leave it undebited). + raise InvalidTransactionError("NonPayableCall") + tx_id = _tx_id_to_hex(decoded_rollup_transaction.data.tx_id) + transaction = transactions_processor.get_transaction_by_hash(tx_id) + if transaction is None: + raise NotFoundError(message=TRANSACTION_NOT_FOUND_MESSAGE, data={"hash": tx_id}) + + status = str(transaction.get("status") or "") + if status not in _DECISION_STATUSES or bool(transaction.get("appealed")): + raise InvalidTransactionError("FinalizationNotAllowed") + if acceptance_dispatch_pending( + (transaction.get("data") or {}).get(FEE_ACCOUNTING_KEY) + ): + raise InvalidTransactionError("FinalizationNotAllowed") + current_decision_id = _transaction_decision_id(transaction) + expected_decision_id = decoded_rollup_transaction.data.expected_decision_id + if expected_decision_id is None or int(expected_decision_id) != current_decision_id: + raise InvalidTransactionError("FinalizationNotAllowed") + + deadline = _transaction_appeal_deadline(transaction) + if deadline is None or time.time() < deadline: + raise InvalidTransactionError("FinalizationNotAllowed") + if not transactions_processor.is_transaction_finalization_head(tx_id): + raise InvalidTransactionError("FinalizationNotAllowed") + 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 _normal_leader_count(consensus_history: dict | None) -> int: + # Consensus excludes every prior *normal-round* leader from the terminal + # replacement committee. Studio compacts a leader appeal's hidden appeal + # round and replay into one history entry, so raw list parity is not a safe + # way to identify normal rounds after the first leader appeal. + rotations_by_round = actual_leader_rotations_by_round(consensus_history) + return sum( + 1 + int(rotations_by_round.get(logical_round, 0)) + for logical_round, _entry in logical_fee_round_entries(consensus_history) + if logical_round % 2 == 0 + ) + + +def _available_appeal_validator_count( + transaction: dict, + frozen_pool_count: int, + *, + frozen_pool_addresses: list[str] | None = None, + live_pool_addresses: list[str] | None = None, +) -> int | None: + """Mirror Studio's fresh-juror pool; unknown legacy state stays conservative.""" + used_addresses = ConsensusAlgorithm.get_consumed_validator_addresses( + transaction.get("consensus_history"), + transaction.get("consensus_data"), + ) + + if not used_addresses: + return None + if isinstance(frozen_pool_addresses, list): + eligible = { + str(address).lower() for address in frozen_pool_addresses if address + } + if isinstance(live_pool_addresses, list): + eligible &= { + str(address).lower() for address in live_pool_addresses if address + } + return len(eligible - used_addresses) + return max(0, int(frozen_pool_count) - len(used_addresses)) + + +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, + *, + unmeter_execution: bool = False, +) -> 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"]) + # Consensus admits against the proposal-only receipt floor, while GenVM + # reserves proposal/reveal/nondeterministic-output start costs before user + # code. A fee estimate must additionally let the complete simulated write + # run so it can observe the required budget; capping that measurement at + # the startup floor makes any storage write fail exactly at the floor and + # prevents the estimator from returning its higher recommendation. Keep + # message allocations/budgets exact, but make the parent's execution bucket + # unmetered for this read-only measurement pass. + floor = ( + GENVM_UNMETERED_DATA_FEE_BUCKET + if unmeter_execution + else policy.genvm_start_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, @@ -1553,8 +3041,67 @@ def send_raw_transaction( consensus_service: ConsensusService, signed_rollup_transaction: str, sim_config: dict | None = None, +) -> str: + """Admit one signed envelope atomically with its protocol mutation.""" + + try: + return _send_raw_transaction_impl( + session, + msg_handler, + transactions_parser, + consensus_service, + signed_rollup_transaction, + sim_config, + ) + except _EvmExecutionReverted as reverted: + # Solidity execution reverts roll back the protocol mutation, but the + # execution-chain envelope is still mined and consumes the account + # nonce. Start a fresh DB transaction after the handler rollback and + # persist the status-0 receipt boundary. + rollback = getattr(session, "rollback", None) + if callable(rollback): + rollback() + transactions_processor = TransactionsProcessor(session) + try: + prior_result = transactions_processor.begin_evm_envelope( + reverted.transaction_hash, + reverted.from_address, + reverted.nonce, + ) + except ValueError as exc: + raise InvalidTransactionError(str(exc)) from exc + if prior_result is None: + transactions_processor.record_evm_envelope( + reverted.transaction_hash, + reverted.from_address, + reverted.nonce, + reverted.transaction_hash, + to_address=reverted.to_address, + success=False, + error=str(reverted.reason), + ) + commit = getattr(session, "commit", None) + if callable(commit): + commit() + return reverted.transaction_hash + except Exception: + rollback = getattr(session, "rollback", None) + if callable(rollback): + rollback() + raise + + +def _send_raw_transaction_impl( + session: Session, + msg_handler: IMessageHandler, + transactions_parser: TransactionParser, + consensus_service: ConsensusService, + signed_rollup_transaction: str, + 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) @@ -1568,165 +3115,327 @@ def send_raw_transaction( if decoded_rollup_transaction is None: raise InvalidTransactionError("Invalid transaction data") + transaction_chain_id = getattr(decoded_rollup_transaction, "chain_id", None) + if ( + transaction_chain_id is not None + and int(transaction_chain_id) != get_simulator_chain_id() + ): + raise InvalidTransactionError("InvalidChainId") + + protocol_data_types = ( + DecodedRollupTransactionData, + DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, + ) + lifecycle_data_types = ( + DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, + ) + decoded_protocol_call = isinstance( + getattr(decoded_rollup_transaction, "data", None), protocol_data_types + ) + decoded_lifecycle_call = isinstance( + getattr(decoded_rollup_transaction, "data", None), lifecycle_data_types + ) + consensus_address = consensus_service.public_consensus_main_address() + envelope_destination = getattr(decoded_rollup_transaction, "to_address", None) + unknown_consensus_selector = False + if decoded_protocol_call: + # Calldata only has protocol meaning when the EVM envelope targets + # ConsensusMain. Previously Studio decoded the selector regardless of + # ``to``, so the same signed transaction that was a harmless transfer + # (or a revert) on Consensus could create/finalize/appeal in Studio. + if ( + not consensus_address + or not envelope_destination + or str(envelope_destination).lower() != str(consensus_address).lower() + ): + raise InvalidTransactionError("InvalidConsensusDestination") + elif ( + envelope_destination + and consensus_address + and str(envelope_destination).lower() == str(consensus_address).lower() + and getattr(decoded_rollup_transaction, "raw_data", None) + ): + # ConsensusMain has no catch-all protocol entry point. Do not reinterpret + # an unknown selector as a plain Studio value transfer. It remains a + # valid signed EVM envelope, though, so classify the failure after nonce + # admission as a mined execution revert. + unknown_consensus_selector = True + elif envelope_destination is None: + # Raw EVM bytecode deployment is outside Studio's transaction model; + # intelligent-contract deployment must use deploySalted/addTransaction. + raise InvalidTransactionError("UnsupportedEvmDeployment") + 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( from_address, f"Invalid address from_address: {from_address}" ) - # Ensure sender account exists - if accounts_manager.get_account(from_address) is None: - accounts_manager.create_new_account_with_address(from_address) - transaction_signature_valid = transactions_parser.transaction_has_valid_signature( signed_rollup_transaction, decoded_rollup_transaction ) if not transaction_signature_valid: 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, + transaction_hash = consensus_service.generate_transaction_hash( + signed_rollup_transaction + ) + try: + prior_result = transactions_processor.begin_evm_envelope( + transaction_hash, + from_address, + decoded_rollup_transaction.nonce, ) - return tx_id_hex - else: - transaction_hash = consensus_service.generate_transaction_hash( - signed_rollup_transaction + except ValueError as exc: + raise InvalidTransactionError(str(exc)) from exc + if prior_result is not None: + return prior_result + + def execution_revert(reason: Exception) -> NoReturn: + """Classify a post-admission protocol rejection as a mined revert.""" + raise _EvmExecutionReverted( + transaction_hash=transaction_hash, + from_address=from_address, + to_address=envelope_destination, + nonce=decoded_rollup_transaction.nonce, + reason=reason, + ) from reason + + if unknown_consensus_selector: + execution_revert(InvalidTransactionError("UnknownConsensusSelector")) + + # A rejected or invalid signed envelope must not leave a new account row. + if accounts_manager.get_account(from_address) is None: + accounts_manager.create_new_account_with_address(from_address, commit=False) + + def finish_envelope(result: str) -> str: + transactions_processor.record_evm_envelope( + transaction_hash, + from_address, + decoded_rollup_transaction.nonce, + result, + to_address=envelope_destination, ) + commit = getattr(session, "commit", None) + if callable(commit): + commit() + return result + + if decoded_lifecycle_call: + post_commit_event = None + try: + _reject_genvm_executor_selector_unless_deploy(sim_config, is_deploy=False) + if isinstance(decoded_rollup_transaction.data, DecodedsubmitAppealDataArgs): + appealed_tx_id = _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, + emit_event=False, + ) + post_commit_event = _transaction_appeal_updated_event(appealed_tx_id) + elif isinstance(decoded_rollup_transaction.data, DecodedTopUpFeesDataArgs): + _handle_top_up_fees( + accounts_manager=accounts_manager, + transactions_processor=transactions_processor, + decoded_rollup_transaction=decoded_rollup_transaction, + ) + else: + assert isinstance( + decoded_rollup_transaction.data, + DecodedFinalizeTransactionDataArgs, + ) + _handle_finalize_transaction( + transactions_processor=transactions_processor, + decoded_rollup_transaction=decoded_rollup_transaction, + ) + except (InvalidTransactionError, InvalidAddressError, JSONRPCError) as exc: + raise _EvmExecutionReverted( + transaction_hash=transaction_hash, + from_address=from_address, + to_address=envelope_destination, + nonce=decoded_rollup_transaction.nonce, + reason=exc, + ) from exc + result = finish_envelope(transaction_hash) + if post_commit_event is not None: + try: + msg_handler.send_message( + log_event=post_commit_event, + log_to_terminal=False, + ) + except Exception: + # The protocol mutation and EVM envelope are already committed. + # A transient websocket/log transport failure must not turn a + # mined transaction into an RPC error or invite a duplicate. + logger.exception("Failed to publish committed appeal event") + return result + else: + try: + _validate_fee_envelope(decoded_rollup_transaction) + except InvalidTransactionError as exc: + # The fee tuple, deployment salt, and submitted value are checked + # by ConsensusMain after the signed EVM envelope is admitted. A + # rejection therefore consumes the EVM nonce and has a status-0 + # receipt; it is not an eth_sendRawTransaction preflight error. + execution_revert(exc) + # Raw transaction submission is idempotent. Resolve duplicates before + # reserving a virtual-factory sequence slot or touching balances. + transactions_processor.lock_transaction_admission(transaction_hash) + is_duplicate = transactions_processor.get_transaction_by_hash(transaction_hash) + if is_duplicate is not None: + return finish_envelope(transaction_hash) 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 ) + # CreationPhase treats an ordinary direct caller as authoritative and + # ignores a spoofed sender embedded in calldata. Bind Studio's durable + # transaction owner to the recovered signed-envelope sender as well; + # otherwise value debits and later refunds can name different parties. + genlayer_transaction.from_address = from_address + genlayer_transaction.max_rotations = _funded_max_rotations( + decoded_rollup_transaction, + genlayer_transaction.max_rotations, + ) + _reject_genvm_executor_selector_unless_deploy( + sim_config, + is_deploy=genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT, + ) + + # Complete every discoverable admission check before reserving storage, + # a virtual-factory sequence slot, or sender funds. + storage_reservation = None + if genlayer_transaction.type == TransactionType.RUN_CONTRACT: + to_address = genlayer_transaction.to_address + if not accounts_manager.is_valid_address(to_address): + raise InvalidAddressError( + to_address, f"Invalid address to_address: {to_address}" + ) + + if not transactions_processor.is_genvm_contract_address(to_address): + execution_revert(InvalidTransactionError("NonGenVMContract")) + + # 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}, + ) + + try: + _enforce_pending_queue_caps( + transactions_processor=transactions_processor, + to_address=to_address, + from_address=from_address, + ) + except QueueDepthExceeded as exc: + execution_revert(exc) + storage_reservation = enforce_contract_storage_quota( + session, to_address, transaction_hash + ) + elif genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT: + # A successful deployment always creates a fresh recipient (and a + # reused CREATE2 salt is rejected by the virtual factory), so only the + # sender cap can be non-zero before the authoritative address is + # known. + try: + _enforce_pending_queue_caps( + transactions_processor=transactions_processor, + to_address=None, + from_address=from_address, + ) + except QueueDepthExceeded as exc: + execution_revert(exc) transaction_data = {} leader_only = False execution_mode = "NORMAL" - rollup_transaction_details = None if genlayer_transaction.type != TransactionType.SEND: leader_only = genlayer_transaction.data.leader_only execution_mode = genlayer_transaction.data.execution_mode - rollup_transaction_details = consensus_service.add_transaction( - signed_rollup_transaction, from_address - ) # because hardhat accounts are not funded - - if ( - consensus_service.web3.is_connected() - and rollup_transaction_details is None - ): - # raise JSONRPCError( - # code=-32000, - # message="Failed to add transaction to consensus layer", - # data={}, - # ) - logger.warning( - "Failed to add transaction to consensus layer", - extra={ - "from_address": from_address, - "transaction_type": genlayer_transaction.type.name, - "leader_only": leader_only, - }, - ) if genlayer_transaction.type == TransactionType.DEPLOY_CONTRACT: - if ( - rollup_transaction_details is None - or not "recipient" in rollup_transaction_details - ): - new_account = accounts_manager.create_new_account() - new_contract_address = new_account.address - else: - new_contract_address = rollup_transaction_details["recipient"] - accounts_manager.create_new_account_with_address(new_contract_address) + try: + new_contract_address = _allocate_top_level_ghost_address( + transactions_processor, + int(decoded_rollup_transaction.data.args.salt_nonce or 0), + from_address, + ) + # Keep the address row in the transaction admission unit. A + # premature commit here would release the duplicate-admission + # and virtual-factory advisory locks before the raw tx exists. + accounts_manager.create_new_account_with_address( + new_contract_address, + commit=False, + ) + except InvalidTransactionError as exc: + if storage_reservation is not None: + storage_reservation.release() + execution_revert(exc) + except Exception: + if storage_reservation is not None: + storage_reservation.release() + raise transaction_data = { "contract_address": new_contract_address, "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 to_address = genlayer_transaction.to_address - if not accounts_manager.is_valid_address(to_address): - raise InvalidAddressError( - to_address, f"Invalid address to_address: {to_address}" - ) - - if accounts_manager.get_account(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) - - # Queue-depth admission control: refuse new submissions when a - # contract or a sender already has too many txs queued. Studio Prod - # is a shared sandbox; without this, one heavy user (e.g. an - # external oracle backend running batch verifications) can pile up - # thousands of PENDING txs on a single contract and starve the - # network for everyone else. - # - # 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). - 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, + try: + # Debit sender BEFORE insert. Mint on demand if insufficient (Studio sandbox). + # Skip for SEND (execute_transfer handles it). + if ( + total_spend > 0 + and from_address + and genlayer_transaction.type != TransactionType.SEND + ): + _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, + commit=False, ) - - # 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, - ) + except Exception: + if storage_reservation is not None: + storage_reservation.release() + raise # Post-insert verification: ensure the transaction is visible immediately try: @@ -1761,7 +3470,7 @@ def send_raw_transaction( log_to_terminal=False, ) - return transaction_hash + return finish_envelope(transaction_hash) def get_transactions_for_address( @@ -1773,8 +3482,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) + ) ) @@ -1788,8 +3499,8 @@ def set_finality_window_time(consensus: ConsensusAlgorithm, time: int) -> None: def get_finality_window_time(consensus: ConsensusAlgorithm) -> int: if consensus is None: - # Return default finality window time when consensus is not initialized - return os.environ.get("VITE_FINALITY_WINDOW", 1800) # Default to 60 seconds + # Preserve the RPC's numeric contract even while consensus is starting. + return int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) return consensus.finality_window_time @@ -1825,7 +3536,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 +3570,7 @@ def get_block_by_number( "uncles": [], } - return block_details + return _sanitize_rpc_private_keys(block_details) def get_gas_price() -> str: @@ -1876,42 +3589,73 @@ 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 + ) + envelope = transactions_processor.get_evm_envelope(transaction_hash) if not transaction: - return None - - event_signature = "NewTransaction(bytes32,address,address)" - event_signature_hash = eth_utils.keccak(text=event_signature).hex() - - to_addr = transaction.get("to_address") - from_addr = transaction.get("from_address") - - logs = [ - { - "address": to_addr, - "topics": [ - f"0x{event_signature_hash}", - transaction_hash, - ( - "0x000000000000000000000000" + to_addr.replace("0x", "") - if to_addr - else None - ), - ( - "0x000000000000000000000000" + from_addr.replace("0x", "") - if from_addr - else None - ), - ], - "data": "0x", - "blockNumber": 0, + if envelope is None: + return None + receipt = { "transactionHash": transaction_hash, - "transactionIndex": 0, + "transactionIndex": hex(0), "blockHash": transaction_hash, - "logIndex": 0, - "removed": False, + "blockNumber": hex(0), + "from": envelope.from_address, + "to": envelope.to_address, + "cumulativeGasUsed": hex(0), + "gasUsed": hex(0), + "effectiveGasPrice": "0x0", + "type": "0x0", + "contractAddress": None, + "logs": [], + "logsBloom": "0x" + "00" * 256, + "status": hex(1 if envelope.success else 0), } - ] + # Preserve the standard status-0 receipt boundary while giving SDKs a + # Studio-local equivalent of the revert data they normally learn from + # an execution-chain gas estimate. This is additive metadata; EVM + # clients that only understand the standard receipt fields ignore it. + if not envelope.success and envelope.error: + receipt["revertReason"] = envelope.error + return receipt + + protocol_to_addr = envelope.to_address if envelope is not None else None + to_addr = protocol_to_addr or transaction.get("to_address") + from_addr = transaction.get("from_address") + logs = [] + if int(transaction.get("type", TransactionType.SEND.value)) != int( + TransactionType.SEND.value + ): + # CreationPhase always emits CreatedTransaction for a Consensus + # transaction. It only additionally emits NewTransaction when this was + # the recipient queue head at admission. Studio does not persist that + # historical boolean, so expose the universal event instead of + # fabricating NewTransaction. Plain EVM value transfers are not created + # through CreationPhase and therefore emit neither event. + event_signature = "CreatedTransaction(bytes32,uint256)" + event_signature_hash = eth_utils.keccak(text=event_signature).hex() + try: + tx_slot = int(transaction.get("tx_slot", 0)) + except (TypeError, ValueError): + tx_slot = 0 + + logs.append( + { + "address": to_addr, + "topics": [ + f"0x{event_signature_hash}", + transaction_hash, + ], + "data": "0x" + tx_slot.to_bytes(32, "big").hex(), + "blockNumber": 0, + "transactionHash": transaction_hash, + "transactionIndex": 0, + "blockHash": transaction_hash, + "logIndex": 0, + "removed": False, + } + ) receipt = { "transactionHash": transaction_hash, @@ -1924,11 +3668,9 @@ def get_transaction_receipt( "gasUsed": hex(transaction.get("gas_used", 8000000)), "effectiveGasPrice": "0x0", "type": "0x0", - "contractAddress": ( - transaction.get("contract_address") - if transaction.get("contract_address") - else None - ), + # The signed EVM envelope calls ConsensusMain; deploySalted authors a + # GenVM ghost inside that call and is not EVM contract creation. + "contractAddress": None, "logs": logs, "logsBloom": "0x" + "00" * 256, "status": hex(1 if transaction.get("status", True) else 0), @@ -1943,7 +3685,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 +3717,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: @@ -1997,7 +3741,11 @@ def get_contract(consensus_service: ConsensusService, contract_name: str) -> dic ) return { - "address": contract["address"], + "address": ( + consensus_service.public_consensus_main_address() + if contract_name == "ConsensusMain" + else contract["address"] + ), "abi": contract["abi"], "bytecode": contract["bytecode"], } @@ -2110,7 +3858,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/explorer/queries.py b/backend/protocol_rpc/explorer/queries.py index 053c1a7d9..ff01718b6 100644 --- a/backend/protocol_rpc/explorer/queries.py +++ b/backend/protocol_rpc/explorer/queries.py @@ -16,6 +16,7 @@ TransactionStatus, Validators, ) +from backend.protocol_rpc.redaction import sanitize_rpc_private_keys def _serialize_tx( @@ -30,7 +31,7 @@ def _serialize_tx( "to_address": tx.to_address, "input_data": tx.input_data, "data": tx.data, - "consensus_data": tx.consensus_data, + "consensus_data": sanitize_rpc_private_keys(tx.consensus_data), "nonce": tx.nonce, "value": tx.value, "type": tx.type, @@ -42,14 +43,16 @@ def _serialize_tx( "s": tx.s, "v": tx.v, "appeal_failed": tx.appeal_failed, - "consensus_history": tx.consensus_history, + "consensus_history": sanitize_rpc_private_keys(tx.consensus_history), "timestamp_appeal": tx.timestamp_appeal, "appeal_processing_time": tx.appeal_processing_time, "config_rotation_rounds": tx.config_rotation_rounds, "num_of_initial_validators": tx.num_of_initial_validators, "last_vote_timestamp": tx.last_vote_timestamp, "rotation_count": tx.rotation_count, - "leader_timeout_validators": tx.leader_timeout_validators, + "leader_timeout_validators": sanitize_rpc_private_keys( + tx.leader_timeout_validators + ), "sim_config": tx.sim_config, "triggered_by_hash": tx.triggered_by_hash, "triggered_on": tx.triggered_on, diff --git a/backend/protocol_rpc/fastapi_endpoint_generator.py b/backend/protocol_rpc/fastapi_endpoint_generator.py index d1dbdd888..2c98e3205 100644 --- a/backend/protocol_rpc/fastapi_endpoint_generator.py +++ b/backend/protocol_rpc/fastapi_endpoint_generator.py @@ -489,6 +489,17 @@ def register(func, method_name=None): partial(endpoints.get_finality_window_time, consensus), "sim_getFinalityWindowTime", ) + register(endpoints.get_studio_fee_config, "sim_getFeeConfig") + register(endpoints.sim_calculate_round_fees, "sim_calculateRoundFees") + register(endpoints.sim_min_message_primary_fees, "sim_minMessagePrimaryFees") + register( + endpoints.sim_estimate_propose_receipt_gas, + "sim_estimateProposeReceiptGas", + ) + register( + endpoints.sim_estimate_message_reveal_gas, + "sim_estimateMessageRevealGas", + ) register( partial(endpoints.get_contract, accounts_manager), "sim_getConsensusContract" ) @@ -546,6 +557,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..0296bf6d4 100644 --- a/backend/protocol_rpc/fastapi_server.py +++ b/backend/protocol_rpc/fastapi_server.py @@ -1,5 +1,6 @@ # backend/protocol_rpc/fastapi_server.py +import logging import os from contextlib import asynccontextmanager from typing import Any @@ -13,6 +14,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 +31,20 @@ from backend.protocol_rpc.websocket import GLOBAL_CHANNEL, websocket_handler +install_log_redaction() +logger = logging.getLogger(__name__) + 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 +74,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 +105,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.""" @@ -99,12 +136,14 @@ async def jsonrpc_endpoint( return await rpc_router.handle_http_request(request) except ClientDisconnect: return Response(status_code=204) - except Exception as exc: - # Ensure JSON-RPC compliant error response instead of framework HTML pages + except Exception: + # Preserve the unexpected exception server-side without disclosing + # database, provider, or infrastructure details to an RPC caller. + logger.exception("Unhandled JSON-RPC request failure") error = { "code": -32603, "message": "Internal error", - "data": {"detail": str(exc)}, + "data": {"detail": "An unexpected server error occurred."}, } return JSONResponse(content={"jsonrpc": "2.0", "error": error, "id": None}) diff --git a/backend/protocol_rpc/fees.py b/backend/protocol_rpc/fees.py new file mode 100644 index 000000000..d148db2d1 --- /dev/null +++ b/backend/protocol_rpc/fees.py @@ -0,0 +1,7308 @@ +from __future__ import annotations + +import base64 +import copy +import os +from dataclasses import dataclass, fields, replace +from enum import Enum +from typing import Any, Callable + +import rlp +from eth_abi import decode, encode +from eth_hash.auto import keccak + +from backend.consensus.history import ( + LEADER_APPEAL_CONSENSUS_ROUNDS, + NON_ROUND_CONSENSUS_EVENTS, + VALIDATOR_APPEAL_CONSENSUS_ROUNDS, + actual_leader_rotations_by_round, + logical_fee_round_entries, +) +from backend.consensus.types import ConsensusRound +from backend.node.genvm.origin.fees import UNMATCHED_EXTERNAL_GUARD_ALLOC + +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 +MIN_TERMINAL_OUTPUT_BYTES = 64 +PROPOSE_RECEIPT_SLOTS = 7 +MESSAGE_REVEAL_LENGTH_SLOTS = 32 +MAX_ALLOCATED_MESSAGES_CAP = 20 +NONDET_OUTPUT_LENGTH_BYTES = 32 +GENVM_NONDET_OUTPUT_HEADER_BYTES = 64 +NODE_ROOT_SENTINEL = (1 << 256) - 1 +UINT256_MAX = (1 << 256) - 1 +MAX_CONTRIBUTION_SEGMENTS = 64 +EMPTY_CALL_KEY = "0x" + ("0" * 64) +# Consensus reserves keccak256("") as the allocation wildcard. bytes32(0) +# remains the call key for an empty method name and must not match every call. +CALL_KEY_WILDCARD = "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" +MESSAGE_TYPE_EXTERNAL = 0 +MESSAGE_TYPE_INTERNAL = 1 +FEE_ACCOUNTING_KEY = "fee_accounting" +FEE_POLICY_SNAPSHOT_KEY = "fee_policy_snapshot" + +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[],uint256,uint256,uint256)" +) +EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE = "(uint256,uint256)" +MESSAGE_ALLOCATION_NODE_ABI_TYPE = ( + "(uint8,bool,uint256,address,bytes32,uint256,bytes)[]" +) +CONTRACT_DESCENDANT_GRANT_ABI_TYPE = ( + "(uint8,uint8,uint256,(uint8,bool,uint256,address,bytes32,uint256,bytes)[])" +) +SUBMITTED_MESSAGE_ABI_TYPE = ( + "(uint8,address,uint256,bytes,bool,uint256,bytes,uint256,bytes,bytes32,bool)[]" +) +CONTRACT_DESCENDANT_GRANT_DOMAIN = keccak(b"genlayer.contract-descendant-grant") +CONTRACT_DESCENDANT_GRANT_VERSION = 1 +MAX_CONTRACT_DESCENDANT_GRANT_NODES = 24 +MAX_CONTRACT_DESCENDANT_FEE_PARAMS_BYTES = 1_024 +MAX_CONTRACT_DESCENDANT_GRANT_BYTES = 31_712 + +WEI_PER_GEN = 10**18 +# Keep the standalone Studio policy identical to the v0.6 deployment defaults. +# The multiplier is deliberately the neutral value 1 while the storage and +# receipt gas prices use the deployment's 0.25 gwei fallback price. +DEFAULT_GEN_PER_TIME_UNIT = 1 +DEFAULT_STORAGE_UNIT_PRICE = 250_000_000 +DEFAULT_RECEIPT_GAS_PRICE = 250_000_000 +DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND = 100_000_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 +# Conservative off-chain quote for an allocated EVM message. Consensus refunds +# unused reservation, while 21k only covers a plain value transfer and would +# make common contract calls (for example ERC-20 transfer) deterministically OOG. +DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT = 500_000 +DEFAULT_TIME_UNIT_OVERLAY_BPS = 1_500 +DEFAULT_MIN_PROPOSE_TIMEUNITS = 30 +DEFAULT_MAX_PROPOSE_TIMEUNITS = 600 +DEFAULT_MIN_COMMIT_TIMEUNITS = 30 +DEFAULT_MAX_COMMIT_TIMEUNITS = 600 +GENVM_UNMETERED_DATA_FEE_BUCKET = (1 << 256) - 1 +# GenVM v0.3's fee config deliberately shares one reservoir for all execution +# costs. The remaining fee bucket and metadata counters are stable parts of +# the manager API pinned in third_party/genvm/version: +# 0: storage + receipt + nondeterministic output + event costs +# 1: outbound-message fee reservations +# 2: raw nondeterministic-output bytes (metadata, not a fee) +# 3: canonical SubmittedMessage payload bytes (metadata, not a fee) +# 4: canonical SubmittedMessage count (metadata, not a fee) +GENVM_EXECUTION_FEE_BUCKET = 0 +GENVM_MESSAGE_FEE_BUCKET = 1 +GENVM_NONDET_OUTPUT_BYTES_BUCKET = 2 +GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET = 3 +GENVM_SUBMITTED_MESSAGE_COUNT_BUCKET = 4 +# GenVM addresses the same buckets by name once the manager API supports it. +GENVM_FEE_BUCKET_NAMES: tuple[str, ...] = ( + "execution_data_gas", + "message_fee", + "nondet_outputs", + "submitted_messages", + "submitted_messages_count", +) + +type FeeBucketConsumption = dict[str, int] | list[int] + + +class FeeValidationError(ValueError): + pass + + +class ArithmeticOverflow(FeeValidationError): + pass + + +class InvalidNumOfValidators(FeeValidationError): + pass + + +class InvalidAppealRounds(FeeValidationError): + pass + + +class InsufficientFees(FeeValidationError): + pass + + +class BudgetTooLow(FeeValidationError): + pass + + +class MaxPriceExceeded(FeeValidationError): + pass + + +class FeeValueMustBeNonZero(FeeValidationError): + pass + + +class PhaseTimeoutOutOfBounds(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 InvalidContractDescendantGrant(FeeValidationError): + pass + + +class InvalidAppealBond(FeeValidationError): + pass + + +class TopUpCannotExtendSchedule(FeeValidationError): + pass + + +class MessageDeclaredBudgetInsufficient(FeeValidationError): + pass + + +class MessageFeesReportMismatch(FeeValidationError): + pass + + +class MessageBudgetExceeded(FeeValidationError): + pass + + +class MessageAllocationsRestricted(FeeValidationError): + pass + + +class ContributionSegmentsFull(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 + + +class EqOutputsTooLarge(FeeValidationError): + pass + + +class SubmittedMessagesTooLarge(FeeValidationError): + pass + + +class MessageEffectDescriptorMismatch(FeeValidationError): + pass + + +class MessageAllocationPolicy(str, Enum): + OPEN = "open" + PINNED = "pinned" + CLOSED = "closed" + + +@dataclass(frozen=True) +class ContractDescendantGrant: + policy: MessageAllocationPolicy + budget: int + allocations: list[dict[str, Any]] + + +@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_allocated_messages: int = MAX_ALLOCATED_MESSAGES_CAP + max_messages_per_tx: int = 0 + min_external_gas_limit: int = 0 + default_external_gas_limit: int = DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT + max_eq_outputs_bytes: int = 0 + max_submitted_messages_bytes: int = 0 + time_unit_overlay_bps: int = 0 + enforce_v06_submission_config: bool = False + # Pure/test policies retain the permissive contract-initializer bounds. + # from_env() below uses the v0.6 deployment defaults (30..600). + min_propose_timeunits: int = 1 + max_propose_timeunits: int = (1 << 128) - 1 + min_commit_timeunits: int = 1 + max_commit_timeunits: int = (1 << 128) - 1 + + def __post_init__(self) -> None: + if not 0 <= int(self.max_allocated_messages) <= MAX_ALLOCATED_MESSAGES_CAP: + raise ValueError( + "invalid max allocated messages: " + f"{self.max_allocated_messages} (protocol cap " + f"{MAX_ALLOCATED_MESSAGES_CAP})" + ) + if not 0 <= int(self.time_unit_overlay_bps) < 10_000: + raise ValueError( + "invalid time-unit overlay bps: " + f"{self.time_unit_overlay_bps} (expected 0..9999)" + ) + if int(self.default_external_gas_limit) <= 0: + raise ValueError( + "invalid default external gas limit: " + f"{self.default_external_gas_limit} (expected > 0)" + ) + for minimum, maximum, label in ( + ( + self.min_propose_timeunits, + self.max_propose_timeunits, + "propose", + ), + ( + self.min_commit_timeunits, + self.max_commit_timeunits, + "commit", + ), + ): + if int(minimum) <= 0 or int(maximum) < int(minimum): + raise ValueError( + f"invalid {label} timeunit bounds: {minimum}..{maximum}" + ) + + @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_allocated_messages=_env_int( + "GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES", + MAX_ALLOCATED_MESSAGES_CAP, + ), + max_messages_per_tx=_env_int("GENLAYER_STUDIO_MAX_MESSAGES_PER_TX", 0), + min_external_gas_limit=_env_int( + "GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT", 0 + ), + default_external_gas_limit=_env_int( + "GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT", + DEFAULT_EXTERNAL_MESSAGE_GAS_LIMIT, + ), + max_eq_outputs_bytes=_env_int("GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES", 0), + max_submitted_messages_bytes=_env_int( + "GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES", 0 + ), + time_unit_overlay_bps=_env_int( + "GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS", + DEFAULT_TIME_UNIT_OVERLAY_BPS, + ), + enforce_v06_submission_config=True, + min_propose_timeunits=_env_int( + "GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS", + DEFAULT_MIN_PROPOSE_TIMEUNITS, + ), + max_propose_timeunits=_env_int( + "GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS", + DEFAULT_MAX_PROPOSE_TIMEUNITS, + ), + min_commit_timeunits=_env_int( + "GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS", + DEFAULT_MIN_COMMIT_TIMEUNITS, + ), + max_commit_timeunits=_env_int( + "GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS", + DEFAULT_MAX_COMMIT_TIMEUNITS, + ), + ) + + 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.fixed_message_reveal_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (max(0, int(message_bytes)) * self.calldata_gas_per_byte) + + (max(0, int(message_count)) * self.gas_per_changed_slot) + ) + + 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 ( + self.receipt_wrapper_bytes + GENVM_NONDET_OUTPUT_HEADER_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 + # FeeManager.messageFeeParamsBudgetFloor() reserves the receipt wrapper + # plus the smallest terminal output. Other per-round charges are not + # part of admission. + return ( + self.estimate_propose_receipt_gas( + self.estimate_propose_receipt_bytes(MIN_TERMINAL_OUTPUT_BYTES) + ) + * self.receipt_gas_price + ) + + def genvm_start_budget_floor(self) -> int: + """Budget GenVM must reserve before contract code begins executing.""" + if self.receipt_gas_price <= 0: + return 0 + start_gas = ( + self.fixed_propose_receipt_gas + + self.intrinsic_gas + + self.bootloader_overhead + + (PROPOSE_RECEIPT_SLOTS * self.gas_per_changed_slot) + + self.estimate_nondet_output_start_gas() + ) + return start_gas * self.receipt_gas_price + + def _legacy_genvm_start_budget_floor(self) -> int: + if self.receipt_gas_price <= 0: + return 0 + 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) + + (NONDET_OUTPUT_LENGTH_BYTES * self.calldata_gas_per_byte) + ) + return 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": + # Snapshots created before the overlay field was introduced remain + # valid and preserve the economics under which they were funded. + defaults = cls() + return cls( + **{ + field.name: int(snapshot.get(field.name, getattr(defaults, 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 _live_policy_for_accounting( + accounting: dict[str, Any] | None, + override: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Return the policy applicable to this transaction's next operation. + + Before activation an explicit/live policy is authoritative. Activation + commits the complete receipt formula, limits, and prices so later config + changes cannot reprice or invalidate work already admitted under escrow. + """ + if accounting and accounting.get("activation_prices_locked"): + return _accounting_policy(accounting) + if override is not None: + return override + return _accounting_policy(accounting) + + +def execution_policy_for_accounting( + accounting: dict[str, Any] | None, + live_policy: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Return the activation-committed execution policy when available.""" + return _live_policy_for_accounting(accounting, live_policy) + + +def funding_policy_for_accounting( + accounting: dict[str, Any] | None, + live_policy: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Return execution prices/config plus the transaction's funding-time split.""" + policy = _live_policy_for_accounting(accounting, live_policy) + return replace( + policy, + time_unit_overlay_bps=int( + (accounting or {}).get( + "funding_overlay_bps", + policy.time_unit_overlay_bps, + ) + ), + ) + + +def stamp_receipt_execution_policy( + receipt: Any, + accounting: dict[str, Any] | None, + live_policy: StudioFeePolicy | None = None, +) -> StudioFeePolicy: + """Persist the exact proposal-time metering inputs on a Studio receipt.""" + policy = execution_policy_for_accounting(accounting, live_policy) + genvm_result = _receipt_genvm_result(receipt) + if genvm_result is None: + genvm_result = {} + if isinstance(receipt, dict): + receipt["genvm_result"] = genvm_result + else: + setattr(receipt, "genvm_result", genvm_result) + genvm_result[FEE_POLICY_SNAPSHOT_KEY] = policy.to_snapshot() + return policy + + +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 _require_uint256(value: int) -> int: + value = int(value) + if value < 0: + raise InvalidFeeParams("InvalidFeeParams") + if value > UINT256_MAX: + raise ArithmeticOverflow("ArithmeticOverflow") + return value + + +def _u256_add(*values: int) -> int: + total = 0 + for value in values: + total += _require_uint256(value) + if total > UINT256_MAX: + raise ArithmeticOverflow("ArithmeticOverflow") + return total + + +def _u256_mul(*values: int) -> int: + total = 1 + for value in values: + value = _require_uint256(value) + if value != 0 and total > UINT256_MAX // value: + raise ArithmeticOverflow("ArithmeticOverflow") + total *= value + return total + + +def normalize_fees_distribution( + fees_distribution: dict[str, Any], +) -> dict[str, int | list[int]]: + normalized = { + "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"), + } + for field, value in normalized.items(): + if field == "rotations": + for rotation in value: + _require_uint256(rotation) + else: + _require_uint256(value) + return normalized + + +def get_leader_rounds(fees_distribution: dict[str, Any]) -> int: + fees = normalize_fees_distribution(fees_distribution) + rotations = fees["rotations"] + assert isinstance(rotations, list) + leader_attempts = 0 + for rotation in rotations: + leader_attempts = _u256_add(leader_attempts, _u256_add(rotation, 1)) + return _u256_add( + leader_attempts, + 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 = _u256_add(total, 1) + elif rotations_index < len(rotations): + total = _u256_add( + 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) + 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") + leader_timeunits = _u256_mul( + int(fees["leaderTimeunitsAllocation"]), policy.gen_per_time_unit + ) + validator_timeunits = _u256_mul( + int(fees["validatorTimeunitsAllocation"]), policy.gen_per_time_unit + ) + else: + 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 = _u256_add( + total, + _calculate_fee_for_round( + _validators_per_round_safe(offset), + rotations_this_round, + leader_timeunits, + validator_timeunits, + leader_multiplier=_round_leader_multiplier(round_outcomes.get(offset)), + ), + ) + + return total + + +def _calculate_settled_time_unit_fees( + fees_distribution: dict[str, Any], + num_of_validators: int, + final_round: int, + policy: StudioFeePolicy, + consensus_history: dict[str, Any] | None, + execution_mode: str = "NORMAL", + terminal_electorate_size: int | None = None, +) -> tuple[int, list[dict[str, Any]]]: + """Aggregate the fee-funded round payouts made by FeesProcessor. + + Submission quotes reserve every configured seat and leader attempt. At + settlement Consensus pays by round type instead: a bare leader timeout + pays only half a leader allocation, appeal rounds never pay a leader, and + a successful appeal retroactively skips the appealed round. Prior leader + rotations pay the non-leader committee plus half of the rotated leader. + """ + fees = normalize_fees_distribution(fees_distribution) + _validator_index(num_of_validators) + logical_entries = logical_fee_round_entries(consensus_history) + entries = {round_index: entry for round_index, entry in logical_entries} + if not entries: + return ( + calculate_time_unit_fees_through_round( + fees, + num_of_validators, + final_round, + policy, + consensus_history=consensus_history, + ), + [], + ) + + capped_final_round = min( + max(0, int(final_round)), + int(fees["appealRounds"]) * 2, + max(entries), + ) + outcomes = { + round_index: str(entry.get("consensus_round") or "") + for round_index, entry in entries.items() + } + previous_completed_round: dict[int, int] = { + logical_entries[index][0]: logical_entries[index - 1][0] + for index in range(1, len(logical_entries)) + } + skipped_rounds = set() + for round_index, outcome in outcomes.items(): + if outcome not in APPEAL_SUCCESS_ROUNDS: + continue + predecessor = previous_completed_round.get(round_index) + if predecessor is None: + continue + # A chained validator appeal is separated by an empty logical round. + # Consensus leaves the preceding appeal round's fee type intact; only + # a directly appealed execution round is retroactively skipped. + if outcomes.get(predecessor) in ( + VALIDATOR_APPEAL_CONSENSUS_ROUNDS | LEADER_APPEAL_CONSENSUS_ROUNDS + ): + continue + # A successful leader replay does not erase a raw deterministic- + # violation predecessor: those validators correctly established the + # separate fault and retain their fee treatment on-chain. + if ( + outcome == ConsensusRound.LEADER_APPEAL_SUCCESSFUL.value + and _fee_alignment_result(entries.get(predecessor)) + == "deterministic_violation" + ): + continue + skipped_rounds.add(predecessor) + validator_appeal_outcomes = { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_APPEAL_FAILED.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, + } + leader_appeal_outcomes = APPEAL_SUCCESS_ROUNDS.union(APPEAL_FAILED_ROUNDS) - ( + validator_appeal_outcomes + ) + rotations_by_round = actual_leader_rotations_by_round(consensus_history) + attempts_by_round = _consensus_attempt_entries_by_round(consensus_history) + outcomes = _round_outcomes(consensus_history) + terminal_rounds = { + round_index + for round_index, predecessor in previous_completed_round.items() + if outcomes.get(predecessor) + in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + } + } + if policy.gen_per_time_unit > 0: + leader_timeunits = _u256_mul( + int(fees["leaderTimeunitsAllocation"]), policy.gen_per_time_unit + ) + validator_timeunits = _u256_mul( + int(fees["validatorTimeunitsAllocation"]), policy.gen_per_time_unit + ) + else: + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + base_total = 0 + by_round: list[dict[str, Any]] = [] + leader_only = str(execution_mode).upper() == "LEADER_ONLY" + + for round_index in range(capped_final_round + 1): + round_electorate_size = ( + terminal_electorate_size if round_index in terminal_rounds else None + ) + alignment_result: str | None = None + outcome = outcomes.get(round_index, "") + is_leader_appeal_replay = outcome in LEADER_APPEAL_CONSENSUS_ROUNDS + is_leader_appeal_placeholder = ( + round_index + 1 in outcomes + and outcomes[round_index + 1] in LEADER_APPEAL_CONSENSUS_ROUNDS + ) + validators = ( + int(num_of_validators) + if round_index == 0 + else _validators_per_round_safe(round_index) + ) + rotations = max(0, int(rotations_by_round.get(round_index, 0))) + attempts = attempts_by_round.get(round_index, []) + prior_attempts = attempts[:rotations] + current_attempt = attempts[-1] if attempts else None + prior_rotation_fee = 0 + # FeesRecorder keeps a proposal-timeout rotation with an empty + # validator array. FeesProcessor therefore pays that rotated-out + # leader one half allocation but pays zero validator seats. Treat an + # explicitly recorded empty array as material evidence, not as missing + # history that should be expanded to a full committee. + for attempt in prior_attempts: + aligned = _aligned_validator_count( + attempt, + exclude_leader=True, + electorate_size=round_electorate_size, + ) + rotated_leader_fee = ( + 0 + if _fee_alignment_result(attempt, electorate_size=round_electorate_size) + == "deterministic_violation" + else leader_timeunits // 2 + ) + prior_rotation_fee += aligned * validator_timeunits + rotated_leader_fee + # Legacy/synthetic histories can expose only a rotation count. Preserve + # the configured upper-bound fallback for slots with no attempt record. + missing_rotation_attempts = max(0, rotations - len(prior_attempts)) + if missing_rotation_attempts: + prior_rotation_fee += missing_rotation_attempts * _calculate_fee_for_round( + max(0, validators - 1), + 1, + leader_timeunits, + validator_timeunits, + leader_multiplier=(1, 2), + ) + + if round_index not in entries and not is_leader_appeal_placeholder: + round_fee = 0 + rule = "empty_round" + elif is_leader_appeal_placeholder: + round_fee = 0 + rule = "leader_appeal" + elif round_index in skipped_rounds: + round_fee = 0 + rule = "successful_appeal_skip" + elif is_leader_appeal_replay: + replay_result = ( + _fee_alignment_result(current_attempt) + if current_attempt is not None + else "unknown" + ) + aligned = ( + _aligned_validator_count(current_attempt) + if current_attempt is not None + and _entry_validator_receipts(current_attempt) + else validators + ) + if outcome == ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value: + # The replay timed out again. Its leader is paid from the + # forfeited appeal bond, not from the sender's time-unit pool. + round_fee = prior_rotation_fee + rule = "post_timeout_appeal_repeated_timeout" + else: + leader_fee = ( + 0 + if replay_result == "deterministic_violation" + else leader_timeunits + ) + if ( + outcome == ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value + and replay_result != "deterministic_violation" + ): + leader_fee += leader_timeunits // 2 + rule = "post_timeout_appeal_150_percent" + elif replay_result == "deterministic_violation": + rule = "deterministic_violation_leader_withheld" + else: + rule = "leader_appeal_replay" + round_fee = ( + prior_rotation_fee + aligned * validator_timeunits + leader_fee + ) + elif outcome == ConsensusRound.LEADER_TIMEOUT.value: + previous = outcomes.get(round_index - 1, "") + # After a successful leader-timeout appeal, a repeated timeout's + # leader is paid from the prior appeal bond rather than fee money. + current_fee = ( + 0 + if previous == ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value + else leader_timeunits // 2 + ) + round_fee = prior_rotation_fee + current_fee + rule = "leader_timeout" + elif outcome in VALIDATOR_APPEAL_CONSENSUS_ROUNDS: + if ( + outcome in APPEAL_SUCCESS_ROUNDS + and current_attempt is not None + and _entry_validator_receipts(current_attempt) + ): + appeal_result = _fee_alignment_result(current_attempt) + aligned = _aligned_validator_count( + current_attempt, + forced_result=appeal_result, + ) + vindicated = 0 + previous_attempts = _previous_non_appeal_attempts( + round_index, + attempts_by_round, + outcomes, + ) + # Consensus pays vindication only when the successful appeal + # establishes one of the four concrete result buckets. A + # NoMajority appeal committee is itself paid equally, but it + # does not prove any side of the overturned round correct. + if ( + appeal_result + in { + "agree", + "disagree", + "timeout", + "deterministic_violation", + } + and previous_attempts + and _entry_validator_receipts(previous_attempts[-1]) + ): + vindicated = _aligned_validator_count( + previous_attempts[-1], + forced_result=appeal_result, + ) + round_fee = (aligned + vindicated) * validator_timeunits + rule = "validator_appeal_success_with_vindication" + else: + # An unsuccessful validator appeal redistributes the complete + # fee pool for the seats that actually revealed (plus the + # forfeited bond) among aligned voters. Consensus records + # validators on reveal, so unrecorded committee seats remain + # refundable rather than being charged from the sender pool. + receipts = ( + _entry_validator_receipts(current_attempt) + if current_attempt is not None + else [] + ) + revealed = len(receipts) if receipts else validators + aligned = ( + _aligned_validator_count(current_attempt) + if receipts + else validators + ) + round_fee = revealed * validator_timeunits if aligned > 0 else 0 + rule = "validator_appeal_failed_redistribution" + elif outcome in leader_appeal_outcomes: + round_fee = 0 + rule = "leader_appeal" + elif ( + outcome == ConsensusRound.UNDETERMINED.value + and current_attempt is not None + and not _entry_leader_address(current_attempt) + and not _entry_validator_receipts(current_attempt) + ): + # An activation-time committee shortfall materializes an explicit + # Undetermined round without selecting a leader or validators. + # Empty participant evidence is authoritative: charge no invented + # seats, while retaining any real prior-rotation work. + round_fee = prior_rotation_fee + rule = "committee_unavailable" + else: + current_result = ( + _fee_alignment_result( + current_attempt, + electorate_size=round_electorate_size, + ) + if current_attempt is not None + else "unknown" + ) + alignment_result = current_result + leader_fee = ( + 0 if current_result == "deterministic_violation" else leader_timeunits + ) + if ( + outcomes.get(round_index - 1, "") + == ConsensusRound.LEADER_TIMEOUT_APPEAL_SUCCESSFUL.value + ): + if current_result != "deterministic_violation": + leader_fee += leader_timeunits // 2 + rule = "post_timeout_appeal_150_percent" + elif current_result == "deterministic_violation": + rule = "deterministic_violation_leader_withheld" + else: + rule = "normal" + aligned = ( + _aligned_validator_count( + current_attempt, + forced_result=current_result, + electorate_size=round_electorate_size, + ) + if current_attempt is not None + and _entry_validator_receipts(current_attempt) + else (0 if leader_only else validators) + ) + round_fee = prior_rotation_fee + aligned * validator_timeunits + leader_fee + + base_total += round_fee + settlement_round = { + "round": round_index, + "outcome": outcome, + "rule": rule, + "rotations": rotations, + "timeUnitAmount": round_fee, + } + # Preserve the existing receipt shape for ordinary rounds. The + # terminal result is material because it was derived against the + # frozen transaction electorate rather than the local committee. + if round_electorate_size is not None and alignment_result is not None: + settlement_round["alignmentResult"] = alignment_result + by_round.append(settlement_round) + + return base_total, by_round + + +def _consensus_attempt_entries_by_round( + consensus_history: dict[str, Any] | None, +) -> dict[int, list[dict[str, Any]]]: + if not isinstance(consensus_history, dict): + return {} + results = consensus_history.get("consensus_results") + if not isinstance(results, list): + return {} + + grouped: dict[int, list[dict[str, Any]]] = {} + pending: list[dict[str, Any]] = [] + previous_round: int | None = None + for entry in results: + if not isinstance(entry, dict): + continue + outcome = str(entry.get("consensus_round") or "") + if outcome in NON_ROUND_CONSENSUS_EVENTS: + pending.append(entry) + continue + if previous_round is None: + round_index = 0 + elif outcome in LEADER_APPEAL_CONSENSUS_ROUNDS: + round_index = previous_round + 2 + elif outcome in VALIDATOR_APPEAL_CONSENSUS_ROUNDS: + round_index = previous_round + (1 if previous_round % 2 == 0 else 2) + else: + round_index = previous_round + 1 + grouped[round_index] = [*pending, entry] + pending = [] + previous_round = round_index + return grouped + + +def _previous_non_appeal_attempts( + round_index: int, + attempts_by_round: dict[int, list[dict[str, Any]]], + outcomes: dict[int, str], +) -> list[dict[str, Any]]: + """Mirror FeesProcessor._findPreviousOriginalRound for vindication.""" + appeal_outcomes = VALIDATOR_APPEAL_CONSENSUS_ROUNDS | LEADER_APPEAL_CONSENSUS_ROUNDS + for candidate in sorted( + (index for index in attempts_by_round if index < int(round_index)), + reverse=True, + ): + if outcomes.get(candidate) in appeal_outcomes: + continue + attempts = attempts_by_round.get(candidate) or [] + if attempts: + return attempts + return [] + + +def _entry_validator_receipts(entry: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(entry, dict): + return [] + receipts: list[dict[str, Any]] = [] + for key in ("leader_result", "validator_results"): + value = entry.get(key) + candidates = value if isinstance(value, list) else [value] + for receipt in candidates: + if not isinstance(receipt, dict): + continue + mode = receipt.get("mode") + if hasattr(mode, "value"): + mode = mode.value + if str(mode or "").lower() == "validator": + receipts.append(receipt) + return receipts + + +def _receipt_validator_address(receipt: dict[str, Any]) -> str: + node_config = receipt.get("node_config") or receipt.get("nodeConfig") or {} + if not isinstance(node_config, dict): + return "" + return str(node_config.get("address") or "").lower() + + +def _entry_leader_address(entry: dict[str, Any] | None) -> str: + if not isinstance(entry, dict): + return "" + value = entry.get("leader_result") + candidates = value if isinstance(value, list) else [value] + for receipt in candidates: + if not isinstance(receipt, dict): + continue + mode = receipt.get("mode") + if hasattr(mode, "value"): + mode = mode.value + if str(mode or "").lower() == "leader": + return _receipt_validator_address(receipt) + return "" + + +def _fee_alignment_result( + entry: dict[str, Any] | None, + *, + electorate_size: int | None = None, +) -> str: + votes = [ + str(receipt.get("vote") or "").lower() + for receipt in _entry_validator_receipts(entry) + ] + total = len(votes) + if total == 0: + return "unknown" + threshold_base = ( + total if electorate_size is None else max(total, int(electorate_size)) + ) + majority = threshold_base / 2 + if votes.count("agree") > majority: + return "agree" + if votes.count("disagree") > majority: + return "disagree" + # RevealingState classifies Studio's local IDLE sentinel as the on-chain + # Timeout ballot before emitting/recording it. Fee alignment must derive + # the result from that classified ballot too, not from Studio's separate + # state-transition convention where IDLE contributes to disagreement. + if votes.count("timeout") + votes.count("idle") > majority: + return "timeout" + if votes.count("deterministic_violation") > majority: + return "deterministic_violation" + return "no_majority" + + +def _aligned_validator_count( + entry: dict[str, Any] | None, + *, + exclude_leader: bool = False, + forced_result: str | None = None, + electorate_size: int | None = None, +) -> int: + receipts = _entry_validator_receipts(entry) + if exclude_leader: + leader = _entry_leader_address(entry) + removed = False + filtered: list[dict[str, Any]] = [] + for receipt in receipts: + if not removed and leader and _receipt_validator_address(receipt) == leader: + removed = True + continue + filtered.append(receipt) + receipts = filtered + + result = forced_result or _fee_alignment_result( + entry, electorate_size=electorate_size + ) + if result == "no_majority": + return len(receipts) + if result == "unknown": + return len(receipts) + + aligned = 0 + for receipt in receipts: + vote = str(receipt.get("vote") or "").lower() + # Studio emits an IDLE reveal to Consensus as Timeout. Its local state + # transition tally treats IDLE as disagreement, but strict fee + # alignment uses the classified on-chain ballot. + if vote == result or (result == "timeout" and vote == "idle"): + aligned += 1 + return aligned + + +def _settlement_storage_recipient_count( + consensus_history: dict[str, Any] | None, + settlement_rounds: list[dict[str, Any]], + fees_distribution: dict[str, Any], + policy: StudioFeePolicy, + execution_mode: str, + bond_settlements: list[dict[str, Any]] | None = None, + terminal_electorate_size: int | None = None, +) -> int: + """Count the unique fee-ledger recipients that share persistent storage. + + Consensus distributes storage over FeesProcessor's tracked validator set, + not the configured committee size. Rebuild the same tracking side effects: + paid recipients and nonzero penalties are tracked, as is a withheld DV + leader, while skipped/placeholder rounds and zero-value entries add nobody. + """ + attempts_by_round = _consensus_attempt_entries_by_round(consensus_history) + outcomes = _round_outcomes(consensus_history) + settlement_by_round = { + int(item.get("round", 0)): item for item in settlement_rounds + } + non_material_rules = { + "empty_round", + "successful_appeal_skip", + "leader_appeal", + } + normal_rules = { + "normal", + "deterministic_violation_leader_withheld", + "leader_appeal_replay", + "post_timeout_appeal_150_percent", + } + leader_timeout_rules = { + "leader_timeout", + "post_timeout_appeal_repeated_timeout", + } + fees = normalize_fees_distribution(fees_distribution) + price = int(policy.gen_per_time_unit) + validator_value = int(fees["validatorTimeunitsAllocation"]) * ( + price if price > 0 else 1 + ) + leader_value = int(fees["leaderTimeunitsAllocation"]) * (price if price > 0 else 1) + leader_only = str(execution_mode).upper() == "LEADER_ONLY" + recipients: set[str] = set() + + def bond_distribution_for_round(round_index: int, key: str) -> int: + return sum( + max(0, int(item.get(key, 0) or 0)) + for item in (bond_settlements or []) + if item.get("outcomeRound") is not None + and int(item["outcomeRound"]) == int(round_index) + ) + + def add_leader( + attempt: dict[str, Any], + *, + payable_value: int, + electorate_size: int | None = None, + ) -> None: + leader = _entry_leader_address(attempt) + if leader and ( + payable_value > 0 + or _fee_alignment_result(attempt, electorate_size=electorate_size) + == "deterministic_violation" + ): + recipients.add(leader) + + def validator_addresses( + attempt: dict[str, Any], + *, + aligned_only: bool = False, + exclude_leader: bool = False, + forced_result: str | None = None, + electorate_size: int | None = None, + ) -> list[str]: + receipts = _entry_validator_receipts(attempt) + if exclude_leader: + leader = _entry_leader_address(attempt) + removed = False + filtered: list[dict[str, Any]] = [] + for receipt in receipts: + if ( + not removed + and leader + and _receipt_validator_address(receipt) == leader + ): + removed = True + continue + filtered.append(receipt) + receipts = filtered + result = forced_result or _fee_alignment_result( + attempt, electorate_size=electorate_size + ) + addresses: list[str] = [] + for receipt in receipts: + vote = str(receipt.get("vote") or "").lower() + aligned = ( + result in {"no_majority", "unknown"} + or vote == result + or (result == "timeout" and vote == "idle") + ) + if aligned_only and not aligned: + continue + address = _receipt_validator_address(receipt) + if address: + addresses.append(address) + return addresses + + for round_index, attempts in attempts_by_round.items(): + settlement = settlement_by_round.get(round_index) or {} + rule = str(settlement.get("rule") or "") + outcome = outcomes.get(round_index, "") + if rule in non_material_rules: + continue + + current_attempt = attempts[-1] if attempts else None + rotation_attempts = attempts[:-1] if attempts else [] + predecessor = max( + (candidate for candidate in outcomes if candidate < round_index), + default=None, + ) + round_electorate_size = ( + terminal_electorate_size + if predecessor is not None + and outcomes.get(predecessor) + in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + } + else None + ) + if int(settlement.get("rotations", 0) or 0) > 0: + for attempt in rotation_attempts: + add_leader( + attempt, + payable_value=leader_value // 2, + electorate_size=round_electorate_size, + ) + if validator_value > 0: + recipients.update( + validator_addresses( + attempt, + exclude_leader=True, + electorate_size=round_electorate_size, + ) + ) + + if current_attempt is None: + continue + if rule in normal_rules: + alignment_result = settlement.get("alignmentResult") + add_leader( + current_attempt, + payable_value=leader_value, + electorate_size=round_electorate_size, + ) + if validator_value > 0 and not leader_only: + recipients.update( + validator_addresses( + current_attempt, + forced_result=( + str(alignment_result) if alignment_result else None + ), + electorate_size=round_electorate_size, + ) + ) + elif outcome == ConsensusRound.LEADER_APPEAL_FAILED.value: + # A failed leader appeal redistributes its forfeited bond to + # aligned replay voters. Even when validator time-unit work is + # explicitly zero, recipients enter FeesProcessor's index only + # when integer division actually gives them nonzero principal. + if bond_distribution_for_round(round_index, "bondDistributed") > 0: + recipients.update( + validator_addresses( + current_attempt, + aligned_only=True, + forced_result=( + str(alignment_result) if alignment_result else None + ), + electorate_size=round_electorate_size, + ) + ) + elif rule in leader_timeout_rules: + payable_value = ( + bond_distribution_for_round(round_index, "leaderPayout") + if rule == "post_timeout_appeal_repeated_timeout" + else leader_value // 2 + ) + add_leader(current_attempt, payable_value=payable_value) + elif rule == "validator_appeal_success_with_vindication": + if validator_value > 0: + recipients.update(validator_addresses(current_attempt)) + elif rule == "validator_appeal_failed_redistribution": + # With a nonzero validator allocation every revealer is either + # paid or penalized. If it is zero, only aligned recipients of the + # forfeited bond enter the distribution index. + if ( + validator_value > 0 + or bond_distribution_for_round(round_index, "bondDistributed") > 0 + ): + recipients.update( + validator_addresses( + current_attempt, + aligned_only=validator_value == 0, + ) + ) + + # A successful validator appeal also tracks each vindicated voter from the + # skipped original round, even when that address is not on the appeal jury. + for item in settlement_rounds: + if item.get("rule") != "validator_appeal_success_with_vindication": + continue + round_index = int(item.get("round", 0)) + appeal_attempts = attempts_by_round.get(round_index) or [] + original_attempts = _previous_non_appeal_attempts( + round_index, + attempts_by_round, + outcomes, + ) + if not appeal_attempts or not original_attempts: + continue + result = _fee_alignment_result(appeal_attempts[-1]) + if validator_value <= 0 or result not in { + "agree", + "disagree", + "timeout", + "deterministic_violation", + }: + continue + recipients.update( + validator_addresses( + original_attempts[-1], + aligned_only=True, + forced_result=result, + ) + ) + + return len(recipients) + + +def calculate_round_fees( + fees_distribution: dict[str, Any], + num_of_validators: int, + round: int = 0, + policy: StudioFeePolicy | None = None, + *, + enforce_gen_price_cap: bool = False, +) -> int: + fees = normalize_fees_distribution(fees_distribution) + policy = policy or StudioFeePolicy() + + if round == 0: + time_unit_work = _calculate_initial_round_total( + fees, + num_of_validators, + ) + appeal_profit_reserve = _calculate_appeal_profit_reserve( + fees, + gen_per_time_unit=int(fees["maxPriceGenPerTimeUnit"]), + ) + else: + time_unit_work = _calculate_appeal_round_total(fees, round) + appeal_profit_reserve = 0 + + max_price = int(fees["maxPriceGenPerTimeUnit"]) + priced_work = _apply_time_unit_price( + time_unit_work, + max_price, + policy, + enforce_cap=enforce_gen_price_cap, + ) + total = _u256_add( + priced_work, + _time_unit_overlay(priced_work, policy.time_unit_overlay_bps), + appeal_profit_reserve, + ) + _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 = _u256_add( + total, + _u256_mul(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 _u256_add( + 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() + enabled = policy.fee_accounting_enabled() + execution_budget_per_round = ( + max( + DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, + policy.message_fee_params_budget_floor(), + ) + if enabled + else 0 + ) + distribution = _serializable_fees_distribution( + { + "leaderTimeunitsAllocation": ( + DEFAULT_LEADER_TIMEUNITS_ALLOCATION if enabled else 0 + ), + "validatorTimeunitsAllocation": ( + DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION if enabled else 0 + ), + "appealRounds": 0, + "executionBudgetPerRound": execution_budget_per_round, + "executionConsumed": 0, + "totalMessageFees": 0, + "rotations": [0], + "maxPriceGenPerTimeUnit": ( + max(1, _with_cap_headroom(policy.gen_per_time_unit)) if enabled else 0 + ), + "storageFeeMaxGasPrice": ( + max(1, _with_cap_headroom(policy.storage_unit_price)) if enabled else 0 + ), + "receiptFeeMaxGasPrice": ( + max(1, _with_cap_headroom(policy.receipt_gas_price)) if enabled else 0 + ), + } + ) + 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() + ), + "genvmStartBudgetFloor": str(policy.genvm_start_budget_floor()), + "maxAllocationTreeDepth": str(policy.max_allocation_tree_depth), + "maxAllocatedMessages": str(policy.max_allocated_messages), + "maxMessagesPerTx": str(policy.max_messages_per_tx), + "minExternalGasLimit": str(policy.min_external_gas_limit), + "defaultExternalGasLimit": str(policy.default_external_gas_limit), + "maxEqOutputsBytes": str(policy.max_eq_outputs_bytes), + "maxSubmittedMessagesBytes": str(policy.max_submitted_messages_bytes), + "timeUnitOverlayBps": str(policy.time_unit_overlay_bps), + "minProposeTimeunits": str(policy.min_propose_timeunits), + "maxProposeTimeunits": str(policy.max_propose_timeunits), + "minCommitTimeunits": str(policy.min_commit_timeunits), + "maxCommitTimeunits": str(policy.max_commit_timeunits), + }, + "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 _require_nonzero_fee_config(fees_distribution: dict[str, Any]) -> None: + # ConsensusHelpers.requireNonZeroFeeConfig assigns these stable field + # indices to the six mandatory fields on every fee-aware user submission. + required_fields = ( + (1, "leaderTimeunitsAllocation"), + (2, "validatorTimeunitsAllocation"), + (3, "executionBudgetPerRound"), + (4, "maxPriceGenPerTimeUnit"), + (5, "storageFeeMaxGasPrice"), + (6, "receiptFeeMaxGasPrice"), + ) + for field_index, field_name in required_fields: + if int(fees_distribution[field_name]) <= 0: + raise FeeValueMustBeNonZero(f"FeeValueMustBeNonZero({field_index})") + + +def _validate_phase_timeout_bounds( + leader_timeunits: int, + validator_timeunits: int, + policy: StudioFeePolicy, + *, + allow_zero: bool = False, +) -> None: + checks = ( + ( + int(leader_timeunits), + int(policy.min_propose_timeunits), + int(policy.max_propose_timeunits), + ), + ( + int(validator_timeunits), + int(policy.min_commit_timeunits), + int(policy.max_commit_timeunits), + ), + ) + for value, minimum, maximum in checks: + if allow_zero and value == 0: + continue + if value < minimum or value > maximum: + raise PhaseTimeoutOutOfBounds( + f"PhaseTimeoutOutOfBounds({value},{minimum},{maximum})" + ) + + +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) + if policy.fee_accounting_enabled() and policy.enforce_v06_submission_config: + _require_nonzero_fee_config(fees) + _validate_phase_timeout_bounds( + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + policy, + ) + 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"]), + root_appeal_rounds=int(fees["appealRounds"]), + 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 activate_fee_accounting( + accounting: dict[str, Any], + policy: StudioFeePolicy | None = None, + *, + selection_pool_count: int | None = None, + selection_pool_addresses: list[str] | None = None, +) -> tuple[dict[str, Any], bool]: + """Validate ceilings and commit the complete v0.6 fee policy at activation.""" + updated = copy.deepcopy(accounting) + if updated.get("activation_prices_locked"): + return updated, False + + if selection_pool_addresses is not None: + # Consensus pins the activation selection-pool authority, including + # identities. Later registry additions must not become eligible for an + # already-activated transaction. + frozen_addresses = list( + dict.fromkeys( + str(address).lower() for address in selection_pool_addresses if address + ) + ) + updated["selection_pool_addresses"] = frozen_addresses + updated["selection_pool_count"] = len(frozen_addresses) + elif selection_pool_count is not None: + # Consensus appeal reservations use the activation epoch's frozen + # selection-pool authority, never the live staking population. + updated["selection_pool_count"] = max(0, int(selection_pool_count)) + + live_policy = policy or StudioFeePolicy.from_env() + fees = normalize_fees_distribution(updated.get("fees_distribution") or {}) + cap_checks = ( + ( + "genPerTimeUnit", + int(live_policy.gen_per_time_unit), + int(fees["maxPriceGenPerTimeUnit"]), + ), + ( + "storageUnitPrice", + int(live_policy.storage_unit_price), + int(fees["storageFeeMaxGasPrice"]), + ), + ( + "receiptGasPrice", + int(live_policy.receipt_gas_price), + int(fees["receiptFeeMaxGasPrice"]), + ), + ) + for cap_type, actual, maximum in cap_checks: + if maximum > 0 and actual > maximum: + updated["activation_price_cap_exceeded"] = { + "actual": actual, + "maximum": maximum, + } + updated["activation_price_cap_type"] = cap_type + updated["activation_cancel_reason"] = ( + "activation_price_cap_exceeded:" + cap_type + ) + return updated, True + + execution_budget_per_round = int(fees["executionBudgetPerRound"]) + activation_floor = int(live_policy.message_fee_params_budget_floor()) + if execution_budget_per_round > 0 and execution_budget_per_round < activation_floor: + updated["activation_budget_floor_not_met"] = { + "actual": execution_budget_per_round, + "minimum": activation_floor, + } + updated["activation_cancel_reason"] = "activation_budget_floor_not_met" + return updated, True + + # Receipt execution inputs are activation-pinned. The overlay split is a + # funding-ownership term and was already committed when escrow began. + locked_policy = replace( + live_policy, + time_unit_overlay_bps=int( + updated.get("funding_overlay_bps", live_policy.time_unit_overlay_bps) + ), + ) + updated["policy_snapshot"] = locked_policy.to_snapshot() + updated["activation_prices_locked"] = True + updated["locked_prices"] = { + "genPerTimeUnit": int(live_policy.gen_per_time_unit), + "storageUnitPrice": int(live_policy.storage_unit_price), + "receiptGasPrice": int(live_policy.receipt_gas_price), + } + return updated, False + + +def decode_contract_descendant_grant(value: Any) -> ContractDescendantGrant: + if isinstance(value, str) and value.startswith("0x"): + try: + bytes.fromhex(value[2:]) + except ValueError as exc: + raise InvalidContractDescendantGrant( + "InvalidContractDescendantGrant" + ) from exc + try: + raw = _allocation_subtree_bytes(value) + except Exception as exc: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") from exc + if not raw: + return ContractDescendantGrant( + policy=MessageAllocationPolicy.CLOSED, + budget=0, + allocations=[], + ) + if len(raw) > MAX_CONTRACT_DESCENDANT_GRANT_BYTES: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + + try: + domain, decoded = decode(["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], raw) + canonical = encode( + ["bytes32", CONTRACT_DESCENDANT_GRANT_ABI_TYPE], + [domain, decoded], + ) + except Exception as exc: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") from exc + + version, wire_policy, budget, decoded_nodes = decoded + policies = { + 0: MessageAllocationPolicy.CLOSED, + 1: MessageAllocationPolicy.OPEN, + 2: MessageAllocationPolicy.PINNED, + } + grant_policy = policies.get(int(wire_policy)) + if ( + bytes(domain) != CONTRACT_DESCENDANT_GRANT_DOMAIN + or int(version) != CONTRACT_DESCENDANT_GRANT_VERSION + or grant_policy is None + or canonical != raw + ): + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + + allocations = [ + { + "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(), + } + for node in decoded_nodes + ] + budget = int(budget) + valid_shape = ( + ( + grant_policy is MessageAllocationPolicy.CLOSED + and budget == 0 + and not allocations + ) + or ( + grant_policy is MessageAllocationPolicy.OPEN + and budget > 0 + and not allocations + ) + or ( + grant_policy is MessageAllocationPolicy.PINNED + and budget > 0 + and bool(allocations) + ) + ) + if not valid_shape: + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + return ContractDescendantGrant(grant_policy, budget, allocations) + + +def _is_contract_descendant_grant(value: Any) -> bool: + try: + raw = _allocation_subtree_bytes(value) + except Exception: + return False + return len(raw) >= 32 and raw[:32] == CONTRACT_DESCENDANT_GRANT_DOMAIN + + +def _validate_contract_descendant_grant( + grant: ContractDescendantGrant, + *, + child_fee_params: dict[str, Any], + policy: StudioFeePolicy, +) -> None: + if grant.policy is not MessageAllocationPolicy.PINNED: + return + + allocations = grant.allocations + if len(allocations) > MAX_CONTRACT_DESCENDANT_GRANT_NODES: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + children_budgets = [0] * len(allocations) + depths = [0] * len(allocations) + internal_params: list[dict[str, Any] | None] = [None] * len(allocations) + sibling_keys: set[tuple[int, int, str, str]] = set() + root_sum = 0 + + for index, raw_node in enumerate(allocations): + node = _normalize_message_allocation(raw_node) + message_type = int(node["messageType"]) + parent_index = int(node["parentIndex"]) + budget = _require_uint256(int(node["budget"])) + if budget == 0: + raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") + if message_type not in {MESSAGE_TYPE_EXTERNAL, MESSAGE_TYPE_INTERNAL}: + raise AllocationTreeMalformed("AllocationTreeMalformed") + if parent_index != NODE_ROOT_SENTINEL: + if parent_index >= index: + raise AllocationTreeMalformed("AllocationTreeMalformed") + parent = _normalize_message_allocation(allocations[parent_index]) + if int(parent["messageType"]) != MESSAGE_TYPE_INTERNAL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + key = (parent_index, *_allocation_key(node)) + if key in sibling_keys: + raise AllocationDuplicateKey("AllocationDuplicateKey") + sibling_keys.add(key) + + depths[index] = ( + 1 if parent_index == NODE_ROOT_SENTINEL else depths[parent_index] + 1 + ) + if depths[index] > int(policy.max_allocation_tree_depth or 5): + raise AllocationTreeTooDeep("AllocationTreeTooDeep") + + if parent_index == NODE_ROOT_SENTINEL: + root_sum = _u256_add(root_sum, budget) + else: + children_budgets[parent_index] = _u256_add( + children_budgets[parent_index], budget + ) + + raw_fee_params = _fee_params_bytes(node["feeParams"]) + if len(raw_fee_params) > MAX_CONTRACT_DESCENDANT_FEE_PARAMS_BYTES: + raise InvalidFeeParams("InvalidFeeParams") + if message_type == MESSAGE_TYPE_EXTERNAL: + _validate_contract_external_allocation(node, raw_fee_params, policy) + else: + internal_params[index] = _validate_contract_internal_fee_params( + raw_fee_params, + policy, + ) + + if root_sum != grant.budget: + raise MessageAllocationsNotEqualBudget("MessageAllocationsNotEqualBudget") + + for index, raw_node in enumerate(allocations): + node = _normalize_message_allocation(raw_node) + params = internal_params[index] + if params is None: + continue + parent_index = int(node["parentIndex"]) + parent_params = ( + child_fee_params + if parent_index == NODE_ROOT_SENTINEL + else internal_params[parent_index] + ) + if parent_params is None: + raise AllocationTreeMalformed("AllocationTreeMalformed") + multiplier = ( + int(parent_params["appealRounds"]) + 1 if node["onAcceptance"] else 1 + ) + required = _u256_mul( + _u256_add( + min_message_primary_fees(params, policy), + children_budgets[index], + ), + multiplier, + ) + if int(node["budget"]) < required: + raise AllocationTreeBudgetInconsistent("AllocationTreeBudgetInconsistent") + + +def _validate_contract_internal_fee_params( + raw_fee_params: bytes, + policy: StudioFeePolicy, +) -> dict[str, Any]: + params = decode_internal_message_fee_params(raw_fee_params) + try: + decoded = decode([INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], raw_fee_params)[0] + canonical = encode([INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], [decoded]) + except Exception as exc: + raise InvalidFeeParams("InvalidFeeParams") from exc + if canonical != raw_fee_params: + raise InvalidFeeParams("InvalidFeeParams") + + rotations = params["rotations"] + if not rotations or int(params["appealRounds"]) != len(rotations) - 1: + raise InvalidAppealRounds("InvalidAppealRounds") + leader = int(params["leaderTimeunitsAllocation"]) + validator = int(params["validatorTimeunitsAllocation"]) + if (leader == 0) != (validator == 0): + raise PhaseTimeoutOutOfBounds("PhaseTimeoutOutOfBounds") + if leader > 0: + _validate_phase_timeout_bounds(leader, validator, policy) + + _validate_internal_message_price_caps(params) + _validate_internal_execution_budget_floor(params, policy) + for field in ( + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", + "executionBudgetPerRound", + ): + if int(params[field]).bit_length() > 96: + raise InvalidFeeParams("InvalidFeeParams") + for value in (leader, validator, *rotations): + if int(value).bit_length() > 32: + raise InvalidFeeParams("InvalidFeeParams") + return params + + +def _validate_contract_external_allocation( + node: dict[str, Any], + raw_fee_params: bytes, + policy: StudioFeePolicy, +) -> None: + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + if bool(node["onAcceptance"]): + raise ExternalAllocationInvalid("ExternalOnAcceptanceNotSupported") + params = decode_external_message_fee_params(raw_fee_params) + if ( + encode( + [EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [(int(params["gasLimit"]), int(params["maxGasPrice"]))], + ) + != raw_fee_params + ): + raise InvalidFeeParams("InvalidFeeParams") + gas_limit = int(params["gasLimit"]) + max_gas_price = int(params["maxGasPrice"]) + if ( + gas_limit <= 0 + or max_gas_price <= 0 + or gas_limit < int(policy.min_external_gas_limit) + ): + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + per_call = _u256_mul(gas_limit, max_gas_price) + budget = int(node["budget"]) + if budget < per_call or budget % per_call != 0: + raise ExternalAllocationInvalid("ExternalAllocationInvalid") + + +def create_child_fee_accounting( + *, + message: dict[str, Any], + parent_fees_distribution: dict[str, Any] | None, + message_allocations: list[dict[str, Any]] | str | None = None, + message_allocations_from_grant: bool = False, + 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) + use_balance = bool(message.get("useBalance", False)) + # Consensus permits a zero-budget child when its complete calculated + # primary obligation and allocation subtree are also zero. Negative + # values are impossible in the ABI and remain invalid locally. + if declared_budget < 0: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + raw_fee_params = _fee_params_bytes(message.get("feeParams", b"")) + grant: ContractDescendantGrant | None = None + if use_balance: + grant = decode_contract_descendant_grant(message.get("allocationSubtree")) + fee_params = _validate_contract_internal_fee_params( + raw_fee_params, + policy, + ) + else: + fee_params = decode_internal_message_fee_params(raw_fee_params) + _validate_internal_message_price_caps(fee_params) + _validate_phase_timeout_bounds( + int(fee_params["leaderTimeunitsAllocation"]), + int(fee_params["validatorTimeunitsAllocation"]), + policy, + allow_zero=True, + ) + 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") + # Child funding uses its declared GEN ceiling; storage and receipt caps + # guard later work and do not participate in this quote. + child_primary = min_message_primary_fees( + fee_params, + policy, + ) + if declared_budget < child_primary: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + parent_fees = ( + normalize_fees_distribution(parent_fees_distribution) + if parent_fees_distribution + else normalize_fees_distribution({}) + ) + # Old GenVM receipts may expose the allocation subtree as its encoded hex + # form. Preserve that raw value in the receipt/hash path, but never iterate + # its characters as FlatArrays allocation nodes here. + if use_balance: + assert grant is not None + _validate_contract_descendant_grant( + grant, + child_fee_params=fee_params, + policy=policy, + ) + minimum = _u256_add(child_primary, grant.budget) + if declared_budget < minimum: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + child_message_allocations = grant.allocations + child_message_budget = grant.budget + allocation_policy = grant.policy + else: + if _is_contract_descendant_grant(message.get("allocationSubtree")): + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + allocation_nodes = ( + message_allocations if isinstance(message_allocations, list) else [] + ) + child_message_allocations = _child_allocations_from_message_subtree( + message, + allocation_nodes, + ) + child_message_budget = declared_budget - child_primary + allocation_policy = ( + MessageAllocationPolicy.PINNED + if child_message_allocations + else ( + MessageAllocationPolicy.CLOSED + if allocation_nodes + else MessageAllocationPolicy.OPEN + ) + ) + child_fees = _fees_distribution_from_internal_params( + fee_params, + total_message_fees=child_message_budget, + parent_fees_distribution=parent_fees, + ) + if not use_balance and not message_allocations_from_grant: + validate_message_allocations( + child_message_allocations, + total_message_fees=int(child_fees["totalMessageFees"]), + root_appeal_rounds=int(child_fees["appealRounds"]), + 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, + allocation_policy=allocation_policy, + ) + # Grant trees retain their admission rules when inherited by descendants. + accounting["message_allocations_from_grant"] = ( + grant is not None or message_allocations_from_grant + ) + return child_fees, accounting + + +def genvm_fee_context( + accounting: dict[str, Any] | None, + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, int] | None, dict[str, str] | None]: + if not accounting: + return None, None + + policy = execution_policy_for_accounting(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 + ), + "lockedReceiptGasPrice": str(policy.receipt_gas_price), + "receiptWrapperBytes": str(policy.receipt_wrapper_bytes), + "overlaySplitBps": str(policy.time_unit_overlay_bps), + "minProposeTimeout": str(policy.min_propose_timeunits), + "maxProposeTimeout": str(policy.max_propose_timeunits), + "minCommitTimeout": str(policy.min_commit_timeunits), + "maxCommitTimeout": str(policy.max_commit_timeunits), + "genPerTimeUnit": str(policy.gen_per_time_unit), + "messageBudgetFloor": str(policy.message_fee_params_budget_floor()), + } + message_bucket_total = int(accounting.get("message_fee_budget", 0) or 0) + data_bucket_total = ( + bucket_total if bucket_total > 0 else GENVM_UNMETERED_DATA_FEE_BUCKET + ) + bucket_totals = { + "execution_data_gas": data_bucket_total, + "message_fee": ( + GENVM_UNMETERED_DATA_FEE_BUCKET + if uses_unmetered_message_fee_pool(accounting) + else message_bucket_total + ), + "nondet_outputs": GENVM_UNMETERED_DATA_FEE_BUCKET, + "submitted_messages": GENVM_UNMETERED_DATA_FEE_BUCKET, + "submitted_messages_count": _message_count_cap(policy), + } + return bucket_totals, gas_data + + +def genvm_message_fee_allocation( + accounting: dict[str, Any] | None, + *, + address_factory: Callable[[str], Any] | None = None, + policy: StudioFeePolicy | None = None, +) -> list[dict[str, Any]]: + if not accounting: + return _genvm_unmetered_message_fee_allocation(None, policy) + + allocation_policy = _accounting_allocation_policy(accounting) + if allocation_policy is MessageAllocationPolicy.CLOSED: + return [UNMATCHED_EXTERNAL_GUARD_ALLOC] + + message_budget = int(accounting.get("message_fee_budget", 0) or 0) + if not accounting.get("message_allocations"): + if message_budget > 0 or accounting.get("message_allocations_from_grant"): + return _genvm_open_message_fee_allocation( + accounting, + message_budget, + ) + return _genvm_unmetered_message_fee_allocation(accounting, policy) + + 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 [] + ] + children_budgets = [0] * len(studio_nodes) + for index, node in enumerate(studio_nodes): + _validate_allocation_parent(index, node, studio_nodes) + parent_index = int(node["parentIndex"]) + if parent_index != NODE_ROOT_SENTINEL: + children_budgets[parent_index] = _u256_add( + children_budgets[parent_index], int(node["budget"]) + ) + + roots: list[dict[str, Any]] = [] + for index, node in enumerate(studio_nodes): + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + continue + allocation = _genvm_message_allocation_node( + node, address_factory, fees_distribution + ) + allocation["children_budget"] = children_budgets[index] + if int(node["messageType"]) == MESSAGE_TYPE_INTERNAL: + allocation["subtree"] = _allocation_subtree_bytes( + _allocation_subtree(studio_nodes, index) + ) + roots.append(allocation) + + roots.append(UNMATCHED_EXTERNAL_GUARD_ALLOC) + return roots + + +def uses_unmetered_message_fee_pool(accounting: dict[str, Any] | None) -> bool: + if not accounting: + return True + if accounting.get("message_allocations_from_grant"): + return False + if int(accounting.get("message_fee_budget", 0) or 0) > 0: + return False + if accounting.get("message_allocations"): + return False + if accounting.get("message_allocations_restricted"): + return False + return accounting.get("message_allocation_policy") in {None, "open"} + + +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]: + # After activation the complete fee formula and its budget floor are part + # of the transaction's committed economics. + policy = funding_policy_for_accounting(accounting, policy) + amount = int(amount) + if amount <= 0: + raise InsufficientFees("InsufficientFees") + incoming = normalize_fees_distribution(fees_distribution) + cap_owner = accounting.get("sender") + if sender and cap_owner and str(sender).lower() != str(cap_owner).lower(): + # Any account may fund an in-flight transaction, but Consensus pins + # price-cap authority to the first depositor. + for cap in ( + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", + ): + incoming[cap] = 0 + incoming_message_fees = int(incoming["totalMessageFees"]) + if incoming_message_fees > 0 and bool( + accounting.get("message_allocations_restricted", False) + ): + raise MessageAllocationsRestricted("MessageAllocationsRestricted") + if incoming_message_fees > amount: + raise InsufficientFees("InsufficientFees") + + primary_amount = amount - incoming_message_fees + updated = copy.deepcopy(accounting) + current = normalize_fees_distribution(updated.get("fees_distribution") or {}) + merged = merge_fees_distribution(current, incoming) + if perform_fee_checks: + quote_before = ( + calculate_round_fees( + current, + num_of_validators, + 0, + policy, + enforce_gen_price_cap=False, + ) + if current["rotations"] + else 0 + ) + quote_after = calculate_round_fees( + merged, + num_of_validators, + 0, + policy, + enforce_gen_price_cap=False, + ) + if quote_after - quote_before > primary_amount: + raise InsufficientFees("InsufficientFeesForRound") + + 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) + ) + current_overlay_reserve = int(updated.get("time_unit_overlay_budget", 0) or 0) + updated["time_unit_overlay_budget"] = _carve_time_unit_overlay_reserve( + current_reserve=current_overlay_reserve, + cumulative_primary=int(updated["primary_fee_budget"]), + execution_budget=int(updated["execution_budget_total"]), + incoming_primary=primary_amount, + split_bps=policy.time_unit_overlay_bps, + ) + overlay_reserve_delta = ( + int(updated["time_unit_overlay_budget"]) - current_overlay_reserve + ) + _record_fee_contribution( + updated, + sender, + primary=primary_amount - overlay_reserve_delta, + overlay=overlay_reserve_delta, + message=incoming_message_fees, + ) + 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, + terminal_committee_upper_bound: int | None = None, + available_appeal_validators: int | None = None, + replacement_rotations: int | None = None, + leader_timeout_live_seats: int | None = None, + time_unit_overlay_bps: int | None = None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + admission_rollback = { + key: copy.deepcopy(updated.get(key)) + for key in ( + "fees_distribution", + "execution_budget_total", + "primary_fee_budget", + "paid_fee_value", + "appeal_funding_total", + "time_unit_overlay_budget", + "appeal_bonds_total", + "contributions", + "untracked_contributions", + "untracked_contribution_pools", + ) + } + policy = funding_policy_for_accounting(updated, policy) + amount = int(amount) + + if status is None: + raise InvalidAppealBond("InvalidAppealBond") + charge = calculate_appeal_charge( + updated.get("fees_distribution") or {}, + current_round=current_round, + status=status, + terminal_committee_upper_bound=terminal_committee_upper_bound, + available_appeal_validators=available_appeal_validators, + replacement_rotations=replacement_rotations, + leader_timeout_live_seats=leader_timeout_live_seats, + policy=policy, + ) + bond = int(charge["bond"]) + funding = int(charge["funding"]) + total_required = bond + funding + if ( + str(status).upper() == "LEADER_TIMEOUT" + and leader_timeout_live_seats is not None + and int(leader_timeout_live_seats) <= 1 + ): + raise InvalidAppealBond("InvalidAppealBond") + # Zero is a valid economic bond when an internal child explicitly opts + # out of both phase-time allocations. Consensus still admits that appeal + # (and records zero-valued custody); only an underpayment is invalid. + if bond < 0 or amount < bond: + raise InvalidAppealBond("InvalidAppealBond") + if amount < total_required: + raise InsufficientFees("InsufficientFees") + + if bool(charge["extendsSchedule"]): + merged = normalize_fees_distribution(updated.get("fees_distribution") or {}) + merged["appealRounds"] = int(merged["appealRounds"]) + 1 + rotations = list(merged["rotations"]) + while len(rotations) < int(merged["appealRounds"]) + 1: + rotations.append(int(charge["replacementRotations"])) + merged["rotations"] = rotations + updated["fees_distribution"] = merged + updated["execution_budget_total"] = int( + merged["executionBudgetPerRound"] + ) * get_leader_rounds(merged) + + # Bond principal stays in its own custody. Only the typed funding leg is + # spendable fee budget; this prevents topUpAndSubmitAppeal from counting + # the same payment as both bond and primary fees. + updated["primary_fee_budget"] = int(updated.get("primary_fee_budget", 0)) + funding + updated["paid_fee_value"] = int(updated.get("paid_fee_value", 0)) + funding + updated["appeal_funding_total"] = ( + int(updated.get("appeal_funding_total", 0)) + funding + ) + updated["time_unit_overlay_budget"] = int( + updated.get("time_unit_overlay_budget", 0) or 0 + ) + int(charge["fundingBreakdown"]["overlay"]) + _record_fee_contribution( + updated, + appealer, + primary=funding - int(charge["fundingBreakdown"]["overlay"]), + overlay=int(charge["fundingBreakdown"]["overlay"]), + ) + updated["appeal_bonds_total"] = int(updated.get("appeal_bonds_total", 0)) + bond + surplus = amount - total_required + updated["appeal_charge_surplus_refunded"] = ( + int(updated.get("appeal_charge_surplus_refunded", 0)) + surplus + ) + updated.setdefault("appeal_bonds", []).append( + { + "appealer": appealer, + "amount": bond, + "submittedAmount": amount, + "funding": funding, + "fundingBreakdown": charge["fundingBreakdown"], + "requiredCharge": total_required, + "surplusRefund": surplus, + # Keep the legacy source-round field, and also persist the raw + # round that Consensus creates at admission. The RPC compatibility + # view needs the latter to expose RoundData.appealBond correctly. + "round": current_round if round is None else round, + "sourceRound": current_round, + "appealRound": int(charge["appealRound"]), + "juryCount": int(charge["juryCount"]), + "status": status, + "minimumRequired": bond, + "topUpAndSubmit": bool(top_up_and_submit), + "feesDistributionIgnored": fees_distribution is not None, + "extendsSchedule": bool(charge["extendsSchedule"]), + # Studio forms the actual jury asynchronously. If the frozen/live + # pool changes before the worker can start, Consensus would have + # reverted the atomic submission. Preserve the exact pre-admission + # accounting state so Studio can make that failure equally + # non-value-bearing instead of silently forfeiting the bond. + "admissionRollback": admission_rollback, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, policy) + return updated + + +def abort_latest_appeal_admission( + accounting: dict[str, Any], + *, + reason: str, +) -> tuple[dict[str, Any], str | None, int]: + """Undo an admitted appeal that never formed a committee.""" + + updated = copy.deepcopy(accounting) + bonds = updated.get("appeal_bonds") + if not isinstance(bonds, list) or not bonds: + return updated, None, 0 + bond = bonds[-1] + if not isinstance(bond, dict): + return updated, None, 0 + rollback = bond.get("admissionRollback") + if not isinstance(rollback, dict): + return updated, None, 0 + + recipient = bond.get("appealer") + refund = max(0, int(bond.get("amount", 0) or 0)) + max( + 0, int(bond.get("funding", 0) or 0) + ) + for key, value in rollback.items(): + updated[key] = copy.deepcopy(value) + updated["appeal_bonds"] = bonds[:-1] + updated.setdefault("aborted_appeals", []).append( + { + "appealer": recipient, + "amount": int(bond.get("amount", 0) or 0), + "funding": int(bond.get("funding", 0) or 0), + "sourceRound": int(bond.get("sourceRound", 0) or 0), + "reason": str(reason), + "refund": refund, + } + ) + updated["total_refunded"] = int(updated.get("total_refunded", 0) or 0) + refund + updated.setdefault("refunds", []).append( + { + "reason": str(reason), + "primary": int(bond.get("funding", 0) or 0), + "message": 0, + "appealBond": int(bond.get("amount", 0) or 0), + "amount": refund, + } + ) + _refresh_message_fee_accounting_report_if_present(updated) + return updated, recipient, refund + + +def calculate_appeal_charge( + fees_distribution: dict[str, Any], + *, + current_round: int, + status: str, + terminal_committee_upper_bound: int | None = None, + available_appeal_validators: int | None = None, + replacement_rotations: int | None = None, + leader_timeout_live_seats: int | None = None, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + """Quote the same bond + typed funding split used by Consensus admission.""" + policy = policy or StudioFeePolicy() + fees = normalize_fees_distribution(fees_distribution) + current_round = max(0, int(current_round)) + status_value = str(status).upper() + bond = calculate_min_appeal_bond( + fees, + current_round=current_round, + status=status_value, + leader_timeout_rotations_left=( + replacement_rotations if status_value == "LEADER_TIMEOUT" else None + ), + leader_timeout_live_seats=leader_timeout_live_seats, + policy=policy, + ) + + validator_appeal = status_value in {"VALIDATORS_TIMEOUT", "ACCEPTED"} + leader_appeal = status_value in {"LEADER_TIMEOUT", "UNDETERMINED"} + if not validator_appeal and not leader_appeal: + raise InvalidAppealBond("InvalidAppealBond") + + if validator_appeal: + appeal_round = ( + current_round + 1 if current_round % 2 == 0 else current_round + 2 + ) + scheduled_jury_count = _validators_per_round_safe(current_round + 1) + jury_count = ( + scheduled_jury_count + if available_appeal_validators is None + else min( + scheduled_jury_count, + max(0, int(available_appeal_validators)), + ) + ) + replacement_rotations = 0 + else: + appeal_round = current_round + 2 + jury_count = 0 + if replacement_rotations is None: + replacement_rotations = _appeal_rotation_allowance(fees, current_round) + replacement_rotations = max(0, int(replacement_rotations)) + + pre_funded = appeal_round + (appeal_round & 1) <= int(fees["appealRounds"]) * 2 + taxable_work = 0 + if leader_appeal: + if not pre_funded: + taxable_work = bond + else: + scheduled_replacement = _validators_per_round_safe(appeal_round + 1) + replacement_count = max( + 0, + int( + terminal_committee_upper_bound + if terminal_committee_upper_bound is not None + else scheduled_replacement + ), + ) + if pre_funded: + time_units = _u256_mul( + max(0, replacement_count - scheduled_replacement), + int(fees["validatorTimeunitsAllocation"]), + ) + else: + time_units = _u256_add( + _u256_mul(jury_count, int(fees["validatorTimeunitsAllocation"])), + _u256_mul( + replacement_count, + int(fees["validatorTimeunitsAllocation"]), + ), + int(fees["leaderTimeunitsAllocation"]), + ) + taxable_work = ( + _u256_mul(time_units, policy.gen_per_time_unit) + if policy.gen_per_time_unit > 0 + else _require_uint256(time_units) + ) + + appellant_profit = 0 if pre_funded else successful_appeal_profit(bond) + execution_slots = 0 + if not pre_funded: + execution_slots = _u256_add(replacement_rotations, 2) if leader_appeal else 2 + execution_backing = _u256_mul(execution_slots, int(fees["executionBudgetPerRound"])) + overlay = _time_unit_overlay_muldiv(taxable_work, policy.time_unit_overlay_bps) + funding = _u256_add(taxable_work, overlay, appellant_profit, execution_backing) + return { + "bond": bond, + "funding": funding, + "appealRound": appeal_round, + "juryCount": jury_count, + "replacementRotations": replacement_rotations, + "extendsSchedule": not pre_funded, + "fundingBreakdown": { + "bondPrincipal": bond, + "taxableWork": taxable_work, + "overlay": overlay, + "appellantProfit": appellant_profit, + "executionBacking": execution_backing, + }, + } + + +def calculate_min_appeal_bond( + fees_distribution: dict[str, Any], + *, + current_round: int, + status: str, + leader_timeout_rotations_left: int | None = None, + leader_timeout_live_seats: int | None = None, + 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 == "LEADER_TIMEOUT": + if ( + leader_timeout_live_seats is not None + and int(leader_timeout_live_seats) <= 1 + ): + # The live committee only gates whether a replacement leader can + # be induced. When it can, pricing still uses the configured round + # size below, even though the timed-out leader was removed. + return 0 + rotations_left = ( + _appeal_rotation_allowance(fees, current_round) + if leader_timeout_rotations_left is None + else max(0, int(leader_timeout_rotations_left)) + ) + total = _calculate_fee_for_round( + _validators_per_round_safe(current_round), + _u256_add(rotations_left, 1), + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + return ( + _u256_mul(total, policy.gen_per_time_unit) + if policy.gen_per_time_unit > 0 + else total + ) + + if status_value == "UNDETERMINED": + target_round = current_round + 2 + target_normal_index = target_round // 2 + rotations = ( + int(fees["rotations"][target_normal_index]) + if target_normal_index < len(fees["rotations"]) + else 0 + ) + total = _calculate_fee_for_round( + _validators_per_round_safe(target_round), + _u256_add(rotations, 1), + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + return ( + _u256_mul(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 + total = _validators_per_round_safe(target_round) * int( + fees["validatorTimeunitsAllocation"] + ) + return ( + _u256_mul(total, policy.gen_per_time_unit) + if policy.gen_per_time_unit > 0 + else total + ) + + return 0 + + +def _appeal_rotation_allowance( + fees: dict[str, int | list[int]], + current_round: int, +) -> int: + rotations = fees["rotations"] + if not isinstance(rotations, list) or not rotations: + return 0 + next_normal_index = max(0, int(current_round)) // 2 + 1 + if next_normal_index < len(rotations): + return int(rotations[next_normal_index]) + return 0 + + +def runtime_rotations_for_round( + fees_distribution: dict[str, Any], + transaction_rotation_cap: int | None, + raw_round: int, +) -> int: + """Return the exact funded runtime allowance for one raw normal round.""" + + raw_round = max(0, int(raw_round)) + if raw_round % 2 != 0: + return 0 + fees = normalize_fees_distribution(fees_distribution) + normal_ordinal = raw_round // 2 + rotations = fees["rotations"] + funded = ( + int(rotations[normal_ordinal]) + if isinstance(rotations, list) and normal_ordinal < len(rotations) + else 0 + ) + return min(max(0, int(transaction_rotation_cap or 0)), max(0, funded)) + + +def fill_message_fee_payload_from_allocation( + accounting: dict[str, Any], + message: dict[str, Any], +) -> dict[str, Any]: + if bool(message.get("useBalance", False)): + # Contract-funded messages carry their own canonical fee payload and + # bypass the sender's allocation tree in Consensus. + return copy.deepcopy(message) + 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 not _message_has_fee_params(updated): + updated["feeParams"] = allocation["feeParams"] + if int(updated.get("declaredBudget", 0) or 0) == 0: + # The allocation budget is a cumulative ceiling for every occurrence + # with this key. It is not the declared budget of each occurrence. + # GenVM/Consensus assigns an occurrence its minimum primary funding + # plus the allocation subtree it must carry into the child. Using the + # aggregate ceiling here overcharges the first message and makes a + # second same-key occurrence fail with MessageBudgetExceeded. + fee_params = decode_internal_message_fee_params(updated["feeParams"]) + policy = execution_policy_for_accounting(accounting) + updated["declaredBudget"] = _u256_add( + min_message_primary_fees(fee_params, policy), + _child_allocation_budget_sum(allocations, index), + ) + updated["callKey"] = _normalize_call_key( + updated.get("callKey", allocation["callKey"]) + ) + expected_subtree = _allocation_subtree(allocations, index) + # The deployed/default Consensus storage mode is FlatArrays. In that mode + # allocationSubtree is receipt data only: MessagePayments ignores it and + # inherits the canonical subtree from the parent's stored allocation tree. + # Keep the leader/GenVM bytes untouched for the SubmittedMessage descriptor, + # while carrying Studio's local FlatArrays resolution out-of-band for child + # materialization. In particular, a missing or malformed supplied subtree + # must not invalidate the reveal or skip a child. + updated["_studioResolvedAllocationSubtree"] = expected_subtree + 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, + external_executor: str | None = None, +) -> dict[str, Any]: + live_policy = _live_policy_for_accounting(accounting, policy) + execution_policy = execution_policy_for_accounting(accounting, live_policy) + if len(messages) > _message_count_cap(live_policy): + raise TooManyMessages("TooManyMessages") + + updated = copy.deepcopy(accounting) + recalculated_total = 0 + external_consumption_total = 0 + initial_external_reimbursed = int( + updated.get("external_message_fee_reimbursed", 0) or 0 + ) + initial_external_remainder = int( + updated.get("external_message_fee_remainder", 0) or 0 + ) + + # Attribute the independently consumed message pool deterministically: + # child escrows consume first, then external reservations in reveal order. + # This keeps contributor ownership independent from receipt message order. + for message in messages: + message_type = _message_type_value(message) + if message_type == MESSAGE_TYPE_INTERNAL: + recalculated_total += _consume_internal_message_fee( + updated, + message, + live_policy, + ) + + funding_offset = _next_external_funding_offset( + updated, + int(updated.get("message_fee_consumed", 0)) + recalculated_total, + ) + external_reserved_total = 0 + for message in messages: + if _message_type_value(message) != MESSAGE_TYPE_EXTERNAL: + continue + consumed, reserved = _consume_external_message_fee( + updated, + message, + execution_policy, + reimburse_external, + external_executor, + funding_offset=funding_offset + external_reserved_total, + ) + external_consumption_total += consumed + external_reserved_total += reserved + + 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_consumption_total + ) + message_budget = int(updated.get("message_fee_budget", 0)) + if attempted > message_budget: + raise MessageBudgetExceeded("MessageBudgetExceeded") + + updated["message_fee_consumed"] = attempted + consumption_event = { + "consumed": recalculated_total + external_consumption_total, + "internalConsumed": recalculated_total, + "externalReimbursed": int( + updated.get("external_message_fee_reimbursed", 0) or 0 + ) + - initial_external_reimbursed, + "remaining": message_budget - attempted, + } + external_remainder_settled = ( + int(updated.get("external_message_fee_remainder", 0) or 0) + - initial_external_remainder + ) + if external_remainder_settled > 0: + consumption_event["externalRemainderSettled"] = external_remainder_settled + updated.setdefault("message_consumption_events", []).append(consumption_event) + _refresh_message_fee_accounting_report_if_present(updated, execution_policy) + return updated + + +def _message_count_cap(policy: StudioFeePolicy) -> int: + cap = max(0, int(policy.max_allocated_messages)) + fee_cap = int(policy.max_messages_per_tx) + if fee_cap > 0: + cap = min(cap, fee_cap) + return cap + + +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, + external_executor: str | None, + *, + funding_offset: int, +) -> tuple[int, int]: + if int(message.get("declaredBudget", 0) or 0) != 0: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + allocation_policy = _accounting_allocation_policy(accounting) + event_count = len(accounting.get("external_message_events") or []) + consumed = _reserve_external_execution( + accounting, + message, + policy, + reimburse=reimburse_external, + executor=external_executor, + funding_offset=funding_offset, + ) + events = accounting.get("external_message_events") or [] + if len(events) == event_count: + if allocation_policy is not MessageAllocationPolicy.OPEN: + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") + return consumed, 0 + event = events[-1] + return consumed, int(event.get("reservation", 0) or 0) + + +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) + use_balance = bool(message.get("useBalance", False)) + descendant_budget = 0 + grant: ContractDescendantGrant | None = None + raw_fee_params = _fee_params_bytes(message.get("feeParams", b"")) + + if use_balance: + grant = decode_contract_descendant_grant(message.get("allocationSubtree")) + fee_params = _validate_contract_internal_fee_params( + raw_fee_params, + policy, + ) + _validate_contract_descendant_grant( + grant, + child_fee_params=fee_params, + policy=policy, + ) + descendant_budget = grant.budget + else: + fee_params = decode_internal_message_fee_params(raw_fee_params) + if _is_contract_descendant_grant(message.get("allocationSubtree")): + raise InvalidContractDescendantGrant("InvalidContractDescendantGrant") + if _accounting_allocation_policy(accounting) is MessageAllocationPolicy.CLOSED: + raise MessageAllocationsRestricted("MessageAllocationsRestricted") + _validate_internal_message_price_caps(fee_params) + _validate_internal_execution_budget_floor(fee_params, policy) + + min_required = min_message_primary_fees( + fee_params, + policy, + ) + if use_balance: + min_required = _u256_add(min_required, descendant_budget) + if declared_budget < min_required: + raise MessageDeclaredBudgetInsufficient("MessageDeclaredBudgetInsufficient") + + if use_balance: + # Consensus reserves this budget from the emitting contract (ghost), + # outside the sender-funded message bucket and allocation tree. + return 0 + + if uses_unmetered_message_fee_pool(accounting): + expected = _genvm_unmetered_internal_fee_params(accounting, policy) + expected_decoded = { + "leaderTimeunitsAllocation": expected["leader_timeunits_allocation"], + "validatorTimeunitsAllocation": expected["validator_timeunits_allocation"], + "appealRounds": len(expected["rotations"]) - 1, + "executionBudgetPerRound": expected["execution_budget_per_round"], + "rotations": expected["rotations"], + "maxPriceGenPerTimeUnit": expected["max_price_gen_per_time_unit"], + "storageFeeMaxGasPrice": expected["storage_fee_max_gas_price"], + "receiptFeeMaxGasPrice": expected["receipt_fee_max_gas_price"], + } + if fee_params != expected_decoded or declared_budget != min_required: + raise MessageBudgetExceeded("MessageBudgetExceeded") + return 0 + + _consume_against_allocation(accounting, message, declared_budget) + return declared_budget + + +def _accounting_allocation_policy( + accounting: dict[str, Any], +) -> MessageAllocationPolicy: + value = accounting.get("message_allocation_policy") + if value: + return MessageAllocationPolicy(value) + if accounting.get("message_allocations_restricted"): + return MessageAllocationPolicy.CLOSED + return ( + MessageAllocationPolicy.PINNED + if accounting.get("message_allocations") + else MessageAllocationPolicy.OPEN + ) + + +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]: + live_policy = _live_policy_for_accounting(accounting, policy) + _enforce_submitted_messages_cap(messages, live_policy) + updated = consume_message_fees( + accounting, + messages, + reported_total=reported_total, + policy=live_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, + executor: str | None = None, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + policy = execution_policy_for_accounting(updated, policy) + consumed_total = 0 + 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)) + consumed_total + reservation + ) + 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 + _record_external_message_fee_payouts(updated, event, executor) + consumed_total += reservation + reimbursement_total += reimbursement + remainder_total += remainder + updated_any = True + + if updated_any: + updated["message_fee_consumed"] = ( + int(updated.get("message_fee_consumed", 0)) + consumed_total + ) + updated["external_message_fee_settled"] = ( + int(updated.get("external_message_fee_settled", 0)) + consumed_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": consumed_total, + "internalConsumed": 0, + "externalReimbursed": reimbursement_total, + "externalRemainderSettled": remainder_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 refund_failed_internal_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], +) -> dict[str, Any]: + """Return a skipped internal child's allowance to the parent bucket. + + Consensus does not create a transaction for a non-ghost internal + recipient. Its declared budget therefore never moves to a child escrow. + Contract-funded (useBalance) budgets live outside the parent bucket and + are refunded by the account-value bridge instead. + """ + + updated = copy.deepcopy(accounting) + if int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) != MESSAGE_TYPE_INTERNAL: + return updated + if bool(message.get("useBalance", False)): + return updated + if uses_unmetered_message_fee_pool(accounting): + return updated + + declared_budget = int(message.get("declaredBudget", 0) or 0) + if declared_budget <= 0: + return updated + _decrement_allocation_consumed(updated, message, declared_budget) + updated["message_fee_consumed"] = max( + 0, + int(updated.get("message_fee_consumed", 0)) - declared_budget, + ) + updated.setdefault("failed_internal_message_refunds", []).append( + { + "recipient": str(message.get("recipient", "")).lower(), + "callKey": _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)), + "declaredBudget": declared_budget, + } + ) + _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 + unmetered_internal = uses_unmetered_message_fee_pool(accounting) + + 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 bool(message.get("useBalance", False)): + continue + if unmetered_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, + ) + _reindex_unexecuted_external_funding(updated) + + 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) + fallback_policy = execution_policy_for_accounting(updated, policy) + policy = _receipt_execution_policy(receipt, fallback_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 = _chargeable_execution_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 _has_bucket(consumed, GENVM_MESSAGE_FEE_BUCKET): + updated["genvm_message_fee_consumed"] = _bucket_value( + consumed, GENVM_MESSAGE_FEE_BUCKET + ) + _attach_message_fee_accounting_report(updated) + _attach_recommended_fee_preset(updated, policy) + return updated + + +def validate_receipt_admission_caps( + receipt: Any, + policy: StudioFeePolicy | None = None, +) -> None: + policy = policy or StudioFeePolicy.from_env() + eq_outputs_length = _receipt_eq_blocks_outputs_length(receipt) + if ( + policy.max_eq_outputs_bytes > 0 + and eq_outputs_length > policy.max_eq_outputs_bytes + ): + raise EqOutputsTooLarge( + f"EqOutputsTooLarge({eq_outputs_length},{policy.max_eq_outputs_bytes})" + ) + + message_payloads = _receipt_message_fee_payloads({}, receipt) + if message_payloads and _receipt_execution_allows_messages(receipt): + if len(message_payloads) > _message_count_cap(policy): + raise TooManyMessages("TooManyMessages") + _enforce_submitted_messages_cap(message_payloads, policy) + + +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, + execution_mode: str = "NORMAL", + policy: StudioFeePolicy | None = None, +) -> tuple[dict[str, Any], int]: + policy_override = policy + locked_policy = _accounting_policy( + accounting, + policy_override if not accounting.get("activation_prices_locked") else None, + ) + live_policy = _live_policy_for_accounting(accounting, policy_override) + execution_policy = execution_policy_for_accounting(accounting, live_policy) + updated = record_execution_fee_consumption(accounting, receipt, live_policy) + if updated.get("status") in {"settled", "canceled"}: + return updated, 0 + updated = _record_historical_execution_fee_consumption( + updated, + receipt=receipt, + consensus_history=consensus_history, + policy=execution_policy, + ) + + 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 {} + settlement_rounds: list[dict[str, Any]] = [] + if actual_final_round is not None: + validators = int( + num_of_validators or updated.get("num_of_initial_validators") or 0 + ) + time_unit_budget, settlement_rounds = _calculate_settled_time_unit_fees( + fees_distribution, + validators, + actual_final_round, + locked_policy, + consensus_history, + execution_mode, + terminal_electorate_size=( + int(updated["selection_pool_count"]) + if updated.get("selection_pool_count") is not None + else None + ), + ) + # Consensus settles storage + receipt/write consumption against the + # complete unified reserve quoted at submission: + # + # executionBudgetPerRound * all configured leader slots + # + # Unused future slots are a refund breakdown only; they do not shrink + # the cap available to real cumulative execution. In particular, an + # early round-0 finish may consume more than one per-round slice (for + # example through persistent storage) while remaining fully backed by + # the transaction's multi-round reserve. + updated["actual_final_round"] = int(actual_final_round) + if settlement_rounds: + updated["settlement_rounds"] = settlement_rounds + else: + time_unit_budget = max(0, primary_required - execution_budget) + execution_consumed = int(updated.get("execution_fee_consumed", 0)) + execution_spent = min(execution_consumed, execution_budget) + storage_fee = _bucket_value( + list(updated.get("execution_fee_consumed_buckets") or []), + 1, + ) + bond_settlements, bond_payout = _settle_appeal_bonds( + updated, + consensus_history=consensus_history, + cancel=False, + ) + storage_recipients = _settlement_storage_recipient_count( + consensus_history, + settlement_rounds if actual_final_round is not None else [], + fees_distribution, + locked_policy, + execution_mode, + bond_settlements=bond_settlements, + terminal_electorate_size=( + int(updated["selection_pool_count"]) + if updated.get("selection_pool_count") is not None + else None + ), + ) + if storage_recipients == 0 and time_unit_budget > 0 and not settlement_rounds: + # Legacy/synthetic histories without receipt identities retain the + # configured initial committee as the best available denominator. + storage_recipients = int( + num_of_validators or updated.get("num_of_initial_validators") or 0 + ) + capped_storage_fee = min(storage_fee, execution_budget) + storage_dust = ( + capped_storage_fee % storage_recipients + if storage_recipients > 0 + else capped_storage_fee + ) + execution_spent = max(0, execution_spent - storage_dust) + appeal_profit_requested = sum( + max(0, int(item.get("payout", 0)) - int(item.get("amount", 0))) + for item in bond_settlements + if item.get("status") == "successful" + ) + appeal_bond_sender_refund = sum( + int(item.get("senderRefund", 0) or 0) for item in bond_settlements + ) + current_overlay_bps = int( + updated.get("funding_overlay_bps", locked_policy.time_unit_overlay_bps) + ) + time_unit_overlay_requested = _time_unit_overlay( + time_unit_budget, + current_overlay_bps, + ) + overlay_budget = min( + primary_budget, + max(0, int(updated.get("time_unit_overlay_budget", 0) or 0)), + ) + primary_core_budget = max(0, primary_budget - overlay_budget) + time_unit_overlay_spent = min(time_unit_overlay_requested, overlay_budget) + primary_core_non_profit_spend = time_unit_budget + execution_spent + if primary_core_non_profit_spend > primary_core_budget: + raise InsufficientFees("InsufficientFeesForRound") + appeal_profit_spent = ( + appeal_profit_requested + if primary_core_non_profit_spend + appeal_profit_requested + <= primary_core_budget + else 0 + ) + if appeal_profit_requested > 0 and appeal_profit_spent == 0: + # Consensus never partially promises a successful-appeal profit. If + # legacy/malformed accounting cannot fund the complete profit leg, all + # successful payouts are clamped to principal and that fee money stays + # in the sender residual. + for item in bond_settlements: + if item.get("status") != "successful": + continue + amount = int(item.get("amount", 0) or 0) + payout = int(item.get("payout", 0) or 0) + bond_payout -= max(0, payout - amount) + item["payout"] = amount + item["profitFunded"] = False + primary_core_spent = primary_core_non_profit_spend + appeal_profit_spent + primary_spent = primary_core_spent + time_unit_overlay_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 + appeal_bond_sender_refund + updated["status"] = "settled" + updated["settlement_reason"] = reason + updated["primary_fee_spent"] = primary_spent + updated["time_unit_overlay_spent"] = time_unit_overlay_spent + updated["time_unit_overlay_requested"] = time_unit_overlay_requested + updated["time_unit_overlay_settlement_bps"] = current_overlay_bps + updated["appeal_profit_requested"] = appeal_profit_requested + updated["appeal_profit_spent"] = appeal_profit_spent + updated["storage_fee_recipient_count"] = storage_recipients + updated["storage_fee_dust_refunded"] = storage_dust + 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["appeal_bond_sender_refunded"] = ( + int(updated.get("appeal_bond_sender_refunded", 0)) + appeal_bond_sender_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 + primary_core_refund = max(0, primary_core_budget - primary_core_spent) + overlay_refund = max(0, overlay_budget - time_unit_overlay_spent) + updated["fee_refund_settlements"] = _allocate_fee_refund( + updated, + primary=primary_core_refund, + overlay=overlay_refund, + message=message_refund, + unattributed=( + appeal_bond_sender_refund + + max(0, primary_refund - primary_core_refund - overlay_refund) + ), + ) + updated.setdefault("refunds", []).append( + { + "reason": reason, + "primary": primary_refund, + "message": message_refund, + "appealBond": appeal_bond_sender_refund, + "amount": refund, + } + ) + _refresh_message_fee_accounting_report_if_present(updated, execution_policy) + return updated, refund + + +def _record_historical_execution_fee_consumption( + accounting: dict[str, Any], + *, + receipt: Any | None, + consensus_history: dict[str, Any] | None, + policy: StudioFeePolicy, +) -> dict[str, Any]: + attempts = _historical_leader_proposal_receipts( + consensus_history, + terminal_electorate_size=( + int(accounting["selection_pool_count"]) + if accounting.get("selection_pool_count") is not None + else None + ), + ) + if not attempts: + return accounting + + updated = copy.deepcopy(accounting) + receipt_total = 0 + attempt_report: list[dict[str, Any]] = [] + for ( + round_index, + attempt_index, + attempt_receipt, + deterministic_violation, + ) in attempts: + timed_out = _receipt_is_leader_timeout(attempt_receipt) + attempt_policy = _receipt_execution_policy(attempt_receipt, policy) + report = ( + None + if timed_out or deterministic_violation + else _receipt_fee_report(attempt_receipt, attempt_policy) + ) + charged = _receipt_report_chargeable_fee(report) if report is not None else 0 + receipt_total += charged + attempt_report.append( + { + "round": round_index, + "attempt": attempt_index, + "leaderTimeout": timed_out, + "deterministicViolation": deterministic_violation, + "receiptFee": charged, + "receiptGasPrice": int(attempt_policy.receipt_gas_price), + } + ) + + final_consumed = _receipt_data_fees_consumed(receipt) + final_fee_report = ( + _receipt_fee_report(receipt, policy) if final_consumed is not None else None + ) + storage_fee = ( + _chargeable_storage_fee(receipt, final_consumed, final_fee_report) + if final_consumed is not None + else _bucket_value( + list(updated.get("execution_fee_consumed_buckets") or []), + 1, + ) + ) + updated["execution_fee_consumed"] = receipt_total + storage_fee + updated["execution_fee_consumed_buckets"] = [receipt_total, storage_fee] + updated["historical_execution_attempts"] = attempt_report + updated["execution_fee_report"] = { + **(updated.get("execution_fee_report") or {}), + "historicalReceiptFees": { + "attempts": attempt_report, + "receiptFeeTotal": receipt_total, + "finalStorageFee": storage_fee, + "totalExecution": receipt_total + storage_fee, + }, + } + return updated + + +def _historical_leader_proposal_receipts( + consensus_history: dict[str, Any] | None, + *, + terminal_electorate_size: int | None = None, +) -> list[tuple[int, int, dict[str, Any], bool]]: + grouped = _consensus_attempt_entries_by_round(consensus_history) + outcomes = _round_outcomes(consensus_history) + proposals: list[tuple[int, int, dict[str, Any], bool]] = [] + for round_index in sorted(grouped): + predecessor = max( + (candidate for candidate in outcomes if candidate < round_index), + default=None, + ) + round_electorate_size = ( + terminal_electorate_size + if predecessor is not None + and outcomes.get(predecessor) + in { + ConsensusRound.VALIDATOR_APPEAL_SUCCESSFUL.value, + ConsensusRound.VALIDATOR_TIMEOUT_APPEAL_SUCCESSFUL.value, + } + else None + ) + for attempt_index, entry in enumerate(grouped[round_index]): + deterministic_violation = ( + _fee_alignment_result(entry, electorate_size=round_electorate_size) + == "deterministic_violation" + ) + value = entry.get("leader_result") + candidates = value if isinstance(value, list) else [value] + for candidate in candidates: + if not isinstance(candidate, dict): + continue + mode = candidate.get("mode") + if hasattr(mode, "value"): + mode = mode.value + if str(mode or "").lower() == "leader": + proposals.append( + ( + round_index, + attempt_index, + candidate, + deterministic_violation, + ) + ) + break + return proposals + + +def _receipt_is_leader_timeout(receipt: Any) -> bool: + genvm_result = _receipt_genvm_result(receipt) + if isinstance(genvm_result, dict) and genvm_result.get("error_code") == ( + "CONSENSUS_LEADER_EXEC_TIMEOUT" + ): + return True + + result = _receipt_value(receipt, "result") + if isinstance(result, str): + try: + result = base64.b64decode(result) + except (ValueError, TypeError): + result = b"" + return isinstance(result, (bytes, bytearray)) and bytes(result).endswith(b"timeout") + + +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["canceled_message_allocations"] = copy.deepcopy( + updated.get("message_allocations") or [] + ) + updated["message_allocations"] = [] + updated["allocation_consumed"] = {} + updated["message_allocations_invalidated"] = True + overlay_budget = min( + primary_refund, + max( + 0, + int(updated.get("time_unit_overlay_budget", 0) or 0) + - int(updated.get("time_unit_overlay_spent", 0) or 0), + ), + ) + updated["fee_refund_settlements"] = _allocate_fee_refund( + updated, + primary=primary_refund - overlay_budget, + overlay=overlay_budget, + message=message_refund, + ) + updated.setdefault("refunds", []).append( + { + "reason": reason, + "primary": primary_refund, + "message": message_refund, + "appealBond": 0, + "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["rotations"] = list(incoming_fees["rotations"]) + elif incoming_fees["rotations"] or int(incoming_fees["appealRounds"]) != 0: + # Consensus treats an established schedule as immutable. Appeals are + # the only operation allowed to append one appeal slot plus its paired + # rotation entry; ordinary top-ups are pure funding. + raise TopUpCannotExtendSchedule("TopUpCannotExtendSchedule") + + merged["executionBudgetPerRound"] = int(merged["executionBudgetPerRound"]) + int( + incoming_fees["executionBudgetPerRound"] + ) + merged["totalMessageFees"] = int(merged["totalMessageFees"]) + int( + incoming_fees["totalMessageFees"] + ) + 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, + root_appeal_rounds: int, + policy: StudioFeePolicy | None = None, +) -> None: + if not message_allocations: + return + + policy = policy or StudioFeePolicy() + root_appeal_rounds = _require_uint256(root_appeal_rounds) + 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] = {} + appeal_rounds_by_index: dict[int, int] = {} + emission_factors_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, + appeal_rounds_by_index, + emission_factors_by_index, + root_appeal_rounds, + policy, + ) + + if root_sum != total_message_fees: + raise MessageAllocationsNotEqualBudget("MessageAllocationsNotEqualBudget") + + _validate_child_budget_consistency( + message_allocations, min_required_by_index, emission_factors_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], + appeal_rounds_by_index: dict[int, int], + emission_factors_by_index: dict[int, int], + root_appeal_rounds: 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, policy) + return int(node["budget"]) + + if int(node["messageType"]) != MESSAGE_TYPE_INTERNAL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + + parent_index = int(node["parentIndex"]) + parent_appeal_rounds = ( + root_appeal_rounds + if parent_index == NODE_ROOT_SENTINEL + else appeal_rounds_by_index[parent_index] + ) + emission_factor = _internal_allocation_emission_factor( + bool(node["onAcceptance"]), parent_appeal_rounds + ) + internal_fee_params = decode_internal_message_fee_params(node["feeParams"]) + min_required = _validate_internal_allocation_budget( + node, internal_fee_params, emission_factor, policy + ) + min_required_by_index[index] = min_required + appeal_rounds_by_index[index] = _require_uint256( + internal_fee_params["appealRounds"] + ) + emission_factors_by_index[index] = emission_factor + 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 < 0 or 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], + internal_fee_params: dict[str, Any], + emission_factor: int, + policy: StudioFeePolicy, +) -> int: + _validate_phase_timeout_bounds( + int(internal_fee_params["leaderTimeunitsAllocation"]), + int(internal_fee_params["validatorTimeunitsAllocation"]), + policy, + allow_zero=True, + ) + min_required = _internal_allocation_min_required( + internal_fee_params, emission_factor, policy + ) + if int(node["budget"]) < min_required: + raise AllocationLifecycleBudgetInsufficient( + "AllocationLifecycleBudgetInsufficient" + ) + + _validate_internal_execution_budget_floor(internal_fee_params, policy) + return min_required + + +def _validate_internal_message_price_caps( + internal_fee_params: dict[str, Any], +) -> None: + # Consensus rejects a leader reveal whose child declares any zero price + # cap. Validate the identical canonical bytes at submission/allocation + # time too, so Studio cannot accept a message shape that v0.6 rejects. + for field_index, field in ( + (4, "maxPriceGenPerTimeUnit"), + (5, "storageFeeMaxGasPrice"), + (6, "receiptFeeMaxGasPrice"), + ): + if int(internal_fee_params[field]) <= 0: + raise FeeValueMustBeNonZero(f"FeeValueMustBeNonZero({field_index})") + + +def _internal_allocation_emission_factor( + on_acceptance: bool, parent_appeal_rounds: int +) -> int: + return _u256_add(parent_appeal_rounds, 1) if on_acceptance else 1 + + +def _internal_allocation_min_required( + internal_fee_params: dict[str, Any], + emission_factor: int, + policy: StudioFeePolicy, +) -> int: + min_primary = min_message_primary_fees(internal_fee_params, policy) + return _u256_mul(min_primary, emission_factor) + + +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], + emission_factors_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) + required = _u256_add( + min_required_by_index[index], + _u256_mul(child_sum, emission_factors_by_index[index]), + ) + if int(node["budget"]) < required: + 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]], + "maxPriceGenPerTimeUnit": int(decoded[5]), + "storageFeeMaxGasPrice": int(decoded[6]), + "receiptFeeMaxGasPrice": int(decoded[7]), + } + + +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": int( + internal_fee_params["maxPriceGenPerTimeUnit"] + ), + "storageFeeMaxGasPrice": 0, + "receiptFeeMaxGasPrice": 0, + }, + VALIDATORS_PER_ROUND[0], + 0, + policy, + enforce_gen_price_cap=False, + ) + + +def _calculate_initial_round_total( + fees: dict[str, int | list[int]], + num_of_validators: int, +) -> int: + validator_index = _validator_index(num_of_validators) + rotations = fees["rotations"] + if not isinstance(rotations, list) or not rotations: + raise InvalidAppealRounds("InvalidAppealRounds") + appeal_rounds = int(fees["appealRounds"]) + if appeal_rounds != len(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: + # Fee schedules index rotations by normal-round ordinal, whereas callers + # use raw consensus rounds (0, 2, 4, ... for normal rounds). + rotations_index = round // 2 + rotations = ( + int(fees["rotations"][rotations_index]) + if rotations_index < len(fees["rotations"]) + else 0 + ) + return _calculate_fee_for_round( + _validators_per_round_safe(round), + _u256_add(rotations, 1), + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + + +def _calculate_appeal_profit_reserve( + fees: dict[str, int | list[int]], + *, + gen_per_time_unit: int = 0, +) -> int: + rotations = fees["rotations"] + if not isinstance(rotations, list): + raise InvalidAppealRounds("InvalidAppealRounds") + + reserve = 0 + for appeal_ordinal in range(int(fees["appealRounds"])): + rotations_index = appeal_ordinal + 1 + funded_rotations = ( + int(rotations[rotations_index]) if rotations_index < len(rotations) else 0 + ) + next_normal_bond = _calculate_fee_for_round( + _validators_per_round_safe((appeal_ordinal + 1) * 2), + _u256_add(funded_rotations, 1), + int(fees["leaderTimeunitsAllocation"]), + int(fees["validatorTimeunitsAllocation"]), + ) + if gen_per_time_unit > 0: + next_normal_bond = _u256_mul(next_normal_bond, gen_per_time_unit) + reserve = _u256_add(reserve, successful_appeal_profit(next_normal_bond)) + return reserve + + +def _apply_time_unit_price( + total: int, + max_price: int, + policy: StudioFeePolicy, + *, + enforce_cap: bool = True, +) -> int: + if enforce_cap and max_price > 0 and policy.gen_per_time_unit > max_price: + raise MaxPriceExceeded("MaxPriceExceeded") + # Consensus reserves at the caller's ceiling. The live price is checked + # against that ceiling and locked separately for settlement. + return _u256_mul(total, max_price) if max_price > 0 else _require_uint256(total) + + +def _time_unit_overlay(time_unit_work: int, split_bps: int) -> int: + split_bps = int(split_bps) + if time_unit_work <= 0 or split_bps <= 0: + return 0 + if split_bps >= 10_000: + raise FeeValidationError("InvalidTimeUnitOverlayBps") + return _u256_mul(time_unit_work, split_bps) // (10_000 - split_bps) + + +def _time_unit_overlay_muldiv(time_unit_work: int, split_bps: int) -> int: + """Mirror AppealEconomics' full-precision Math.mulDiv overlay quote.""" + time_unit_work = _require_uint256(time_unit_work) + split_bps = int(split_bps) + if time_unit_work <= 0 or split_bps <= 0: + return 0 + if split_bps >= 10_000: + raise FeeValidationError("InvalidTimeUnitOverlayBps") + return _require_uint256(time_unit_work * split_bps // (10_000 - split_bps)) + + +def _carve_time_unit_overlay_reserve( + *, + current_reserve: int, + cumulative_primary: int, + execution_budget: int, + incoming_primary: int, + split_bps: int, +) -> int: + """Mirror FeeManagerHelpers.carveOverlayReserve for one funding call.""" + current_reserve = max(0, int(current_reserve)) + incoming_primary = max(0, int(incoming_primary)) + split_bps = int(split_bps) + if split_bps <= 0: + return current_reserve + if split_bps >= 10_000: + raise FeeValidationError("InvalidTimeUnitOverlayBps") + + taxable_time_unit = max( + 0, + int(cumulative_primary) - int(execution_budget), + ) + target_reserve = taxable_time_unit * split_bps // 10_000 + reserve_delta = max(0, target_reserve - current_reserve) + return current_reserve + min(reserve_delta, incoming_primary) + + +def successful_appeal_reward(appeal_bond: int) -> int: + appeal_bond = _require_uint256(appeal_bond) + # AppealEconomics deliberately arranges floor(5*bond/2) this way so an + # otherwise representable result does not overflow in bond * 5. + return _u256_add( + _u256_mul(appeal_bond, 2), + appeal_bond // 2, + ) + + +def successful_appeal_profit(appeal_bond: int) -> int: + return successful_appeal_reward(appeal_bond) - _require_uint256(appeal_bond) + + +def _validators_per_round_safe(round: int) -> int: + index = min(max(0, int(round)), len(VALIDATORS_PER_ROUND) - 1) + return VALIDATORS_PER_ROUND[index] + + +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], + _u256_add(int(rotations[0]), 1), + leader_timeunits, + validator_timeunits, + ) + + rotations_index = 1 + rotations_this_round = 1 + appeal_rounds = int(fees_distribution["appealRounds"]) + for offset in range(1, (appeal_rounds * 2) + 1): + # Consensus uses the configured round ladder by absolute round after + # the caller-selected initial committee. Rounds beyond the published + # ladder saturate at its final size. + round_validators = _validators_per_round_safe(offset) + if offset % 2 == 0 and rotations_index < len(rotations): + rotations_this_round = _u256_add(int(rotations[rotations_index]), 1) + rotations_index += 1 + elif offset % 2 == 1: + rotations_this_round = 1 + + calculated_fees = _u256_add( + 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 = _u256_mul(rotations, leader_timeunits_allocation) + leader_fee = _u256_mul(leader_total, leader_num) // leader_den + validator_fee = _u256_mul( + rotations, + num_of_validators, + validator_timeunits_allocation, + ) + return _u256_add(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 = _u256_add(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 = _u256_add(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 { + round_index: str(entry.get("consensus_round") or "") + for round_index, entry in logical_fee_round_entries(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) + entries = dict(logical_fee_round_entries(consensus_history)) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + locked_policy = _accounting_policy(accounting) + if locked_policy.gen_per_time_unit > 0: + validator_timeunits *= int(locked_policy.gen_per_time_unit) + 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 = successful_appeal_reward(amount) + 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 + if outcome == ConsensusRound.LEADER_TIMEOUT_APPEAL_FAILED.value: + leader_share = amount // 2 + entry["distribution"] = "leader-timeout-split" + entry["leaderPayout"] = leader_share + entry["senderRefund"] = amount - leader_share + elif outcome in { + ConsensusRound.VALIDATOR_APPEAL_FAILED.value, + ConsensusRound.VALIDATORS_TIMEOUT_APPEAL_FAILED.value, + }: + outcome_entry = entries.get(int(outcome_index or 0)) + receipts = _entry_validator_receipts(outcome_entry) + revealed = ( + len(receipts) + if receipts + else _validators_per_round_safe(int(outcome_index or 0)) + ) + aligned = ( + _aligned_validator_count(outcome_entry) if receipts else revealed + ) + pool = revealed * validator_timeunits + amount + dust = pool if aligned <= 0 else pool % aligned + sender_refund = min(amount, dust) + entry["distribution"] = "appeal-validators" + entry["bondDistributed"] = amount - sender_refund + if sender_refund: + entry["senderRefund"] = sender_refund + elif outcome == ConsensusRound.LEADER_APPEAL_FAILED.value: + outcome_entry = entries.get(int(outcome_index or 0)) + receipts = _entry_validator_receipts(outcome_entry) + aligned = _aligned_validator_count(outcome_entry) if receipts else 0 + sender_refund = amount if aligned <= 0 else amount % aligned + entry["distribution"] = "replay-validators" + entry["bondDistributed"] = amount - sender_refund + if sender_refund: + entry["senderRefund"] = sender_refund + 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]], + policy: StudioFeePolicy, +) -> None: + if int(node["parentIndex"]) != NODE_ROOT_SENTINEL: + raise AllocationTreeMalformed("AllocationTreeMalformed") + if bool(node["onAcceptance"]): + raise ExternalAllocationInvalid("ExternalOnAcceptanceNotSupported") + + 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") + if gas_limit < int(policy.min_external_gas_limit): + raise ExternalAllocationInvalid("ExternalGasLimitBelowMinimum") + + 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 _record_fee_contribution( + accounting: dict[str, Any], + depositor: str | None, + *, + primary: int = 0, + overlay: int = 0, + message: int = 0, +) -> None: + pools = { + "primary": max(0, int(primary)), + "overlay": max(0, int(overlay)), + "message": max(0, int(message)), + } + amount = sum(pools.values()) + if amount == 0: + return + if not depositor: + accounting["untracked_contributions"] = ( + int(accounting.get("untracked_contributions", 0) or 0) + amount + ) + untracked_pools = accounting.setdefault("untracked_contribution_pools", {}) + for pool, pool_amount in pools.items(): + untracked_pools[pool] = int(untracked_pools.get(pool, 0) or 0) + pool_amount + return + + contributions = accounting.setdefault("contributions", []) + if ( + contributions + and str(contributions[-1].get("depositor", "")).lower() + == str(depositor).lower() + and all(pool in contributions[-1] for pool in pools) + ): + contributions[-1]["amount"] = int(contributions[-1].get("amount", 0)) + amount + for pool, pool_amount in pools.items(): + contributions[-1][pool] = ( + int(contributions[-1].get(pool, 0) or 0) + pool_amount + ) + return + if len(contributions) >= MAX_CONTRIBUTION_SEGMENTS: + raise ContributionSegmentsFull("ContributionSegmentsFull") + contributions.append({"depositor": depositor, "amount": amount, **pools}) + + +def _allocate_fee_refund( + accounting: dict[str, Any], + *, + primary: int = 0, + overlay: int = 0, + message: int = 0, + unattributed: int = 0, +) -> list[dict[str, Any]]: + refunds_by_pool = { + "primary": max(0, int(primary)), + "overlay": max(0, int(overlay)), + "message": max(0, int(message)), + } + unattributed = max(0, int(unattributed)) + refundable = sum(refunds_by_pool.values()) + unattributed + if refundable == 0: + return [] + + raw_contributions = [ + item + for item in accounting.get("contributions") or [] + if isinstance(item, dict) and item.get("depositor") + ] + typed = bool(raw_contributions) and all( + all(pool in item for pool in refunds_by_pool) for item in raw_contributions + ) + if not typed: + return _allocate_legacy_fee_refund(accounting, refundable) + + settlements: list[dict[str, Any]] = [] + for pool, pool_refundable in refunds_by_pool.items(): + if pool_refundable <= 0: + continue + contributions = [ + { + "depositor": item.get("depositor"), + "amount": max(0, int(item.get(pool, 0) or 0)), + } + for item in raw_contributions + ] + tracked_total = sum(item["amount"] for item in contributions) + tracked_refundable = min(pool_refundable, tracked_total) + consumed = tracked_total - tracked_refundable + cumulative = 0 + for item in contributions: + end = cumulative + item["amount"] + amount = 0 + if cumulative >= consumed: + amount = item["amount"] + elif end > consumed: + amount = end - consumed + if amount > 0: + settlements.append( + { + "recipient": item["depositor"], + "amount": amount, + "source": f"{pool}-fifo", + } + ) + cumulative = end + remainder = refundable - sum(item["amount"] for item in settlements) + fallback = accounting.get("sender") + if remainder > 0 and fallback: + settlements.append( + {"recipient": fallback, "amount": remainder, "source": "remainder"} + ) + return settlements + + +def _allocate_legacy_fee_refund( + accounting: dict[str, Any], + refundable: int, +) -> list[dict[str, Any]]: + contributions = [ + { + "depositor": item.get("depositor"), + "amount": max(0, int(item.get("amount", 0) or 0)), + } + for item in accounting.get("contributions") or [] + if isinstance(item, dict) and item.get("depositor") + ] + tracked_total = sum(item["amount"] for item in contributions) + tracked_refundable = min(refundable, tracked_total) + consumed = tracked_total - tracked_refundable + settlements: list[dict[str, Any]] = [] + cumulative = 0 + for item in contributions: + end = cumulative + item["amount"] + amount = 0 + if cumulative >= consumed: + amount = item["amount"] + elif end > consumed: + amount = end - consumed + if amount > 0: + settlements.append( + {"recipient": item["depositor"], "amount": amount, "source": "fifo"} + ) + cumulative = end + + remainder = refundable - sum(item["amount"] for item in settlements) + fallback = accounting.get("sender") + if remainder > 0 and fallback: + settlements.append( + {"recipient": fallback, "amount": remainder, "source": "remainder"} + ) + return settlements + + +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, + allocation_policy: MessageAllocationPolicy | None = None, +) -> dict[str, Any]: + fees = _serializable_fees_distribution(fees_distribution) + # FeeManager owns this counter. A caller may include the field in the ABI + # struct, but addFeesDistribution deliberately never copies it into + # storage; only receipt/storage charging advances it later. + fees["executionConsumed"] = 0 + 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) + primary_budget = max(0, int(fee_value) - total_message_fees) + overlay_budget = _carve_time_unit_overlay_reserve( + current_reserve=0, + cumulative_primary=primary_budget, + execution_budget=execution_budget_total, + incoming_primary=primary_budget, + split_bps=policy.time_unit_overlay_bps, + ) + allocation_policy = allocation_policy or ( + MessageAllocationPolicy.PINNED + if message_allocations + else MessageAllocationPolicy.OPEN + ) + return { + "version": 2, + "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": primary_budget, + "primary_fee_spent": 0, + "primary_fee_refunded": 0, + "execution_budget_total": execution_budget_total, + "time_unit_overlay_budget": overlay_budget, + "funding_overlay_bps": int(policy.time_unit_overlay_bps), + "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_fee_settled": 0, + "external_message_events": [], + "external_message_fee_payouts": [], + "appeal_bonds": [], + "appeal_bonds_total": 0, + "appeal_bonds_payout_total": 0, + "appeal_bond_sender_refunded": 0, + "appeal_funding_total": 0, + "appeal_charge_surplus_refunded": 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, + } + ], + "contributions": ( + [ + { + "depositor": sender, + "amount": primary_budget + total_message_fees, + "primary": primary_budget - overlay_budget, + "overlay": overlay_budget, + "message": total_message_fees, + } + ] + if sender and primary_budget + total_message_fees > 0 + else [] + ), + "untracked_contributions": 0, + "untracked_contribution_pools": {}, + "fees_distribution": fees, + "message_allocations": [ + _serializable_message_allocation(allocation) + for allocation in message_allocations + ], + "message_allocation_policy": allocation_policy.value, + "message_allocations_restricted": ( + allocation_policy is MessageAllocationPolicy.CLOSED + ), + "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(fee_params["maxPriceGenPerTimeUnit"]), + "storageFeeMaxGasPrice": int(fee_params["storageFeeMaxGasPrice"]), + "receiptFeeMaxGasPrice": int(fee_params["receiptFeeMaxGasPrice"]), + } + + +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"]], + "max_price_gen_per_time_unit": int(decoded["maxPriceGenPerTimeUnit"]), + "storage_fee_max_gas_price": int(decoded["storageFeeMaxGasPrice"]), + "receipt_fee_max_gas_price": int(decoded["receiptFeeMaxGasPrice"]), + }, + } + + +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_budget": 0, + "subtree": b"", + } + + +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_internal_fee_params( + accounting: dict[str, Any] | None, + policy: StudioFeePolicy | None, +) -> dict[str, Any]: + if policy is None and not accounting: + policy = StudioFeePolicy.from_env() + policy = execution_policy_for_accounting(accounting, policy) + fees = normalize_fees_distribution( + (accounting or {}).get("fees_distribution") or {} + ) + + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + if not ( + int(policy.min_propose_timeunits) + <= leader_timeunits + <= int(policy.max_propose_timeunits) + ): + leader_timeunits = min( + max(DEFAULT_LEADER_TIMEUNITS_ALLOCATION, policy.min_propose_timeunits), + policy.max_propose_timeunits, + ) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + if not ( + int(policy.min_commit_timeunits) + <= validator_timeunits + <= int(policy.max_commit_timeunits) + ): + validator_timeunits = min( + max( + DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION, + policy.min_commit_timeunits, + ), + policy.max_commit_timeunits, + ) + + return { + "leader_timeunits_allocation": leader_timeunits, + "validator_timeunits_allocation": validator_timeunits, + "execution_budget_per_round": max( + int(fees["executionBudgetPerRound"]), + int(policy.message_fee_params_budget_floor()), + DEFAULT_TRANSACTION_EXECUTION_BUDGET_PER_ROUND, + ), + "rotations": [0], + "max_price_gen_per_time_unit": max( + int(fees["maxPriceGenPerTimeUnit"]), + _with_cap_headroom(policy.gen_per_time_unit), + 1, + ), + "storage_fee_max_gas_price": max( + int(fees["storageFeeMaxGasPrice"]), + _with_cap_headroom(policy.storage_unit_price), + 1, + ), + "receipt_fee_max_gas_price": max( + int(fees["receiptFeeMaxGasPrice"]), + _with_cap_headroom(policy.receipt_gas_price), + 1, + ), + } + + +def _genvm_unmetered_message_fee_allocation( + accounting: dict[str, Any] | None, + policy: StudioFeePolicy | None, +) -> list[dict[str, Any]]: + budget = 2**200 + internal_fee_params = { + "Internal": _genvm_unmetered_internal_fee_params(accounting, policy) + } + return [ + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": 2**200, + "max_gas_price": 0, + }, + }, + "children_budget": 0, + "subtree": b"", + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": internal_fee_params, + "children_budget": 0, + "subtree": b"", + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "decided", + "fee_params": internal_fee_params, + "children_budget": 0, + "subtree": b"", + }, + ] + + +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_budget": 0, + "subtree": b"", + } + + +def _genvm_open_message_fee_allocation( + accounting: dict[str, Any], + budget: int, +) -> list[dict[str, Any]]: + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + internal_fee_params = { + "Internal": { + "leader_timeunits_allocation": int(fees["leaderTimeunitsAllocation"]), + "validator_timeunits_allocation": int(fees["validatorTimeunitsAllocation"]), + "execution_budget_per_round": int(fees["executionBudgetPerRound"]), + "rotations": [int(rotation) for rotation in fees["rotations"]], + "max_price_gen_per_time_unit": int(fees["maxPriceGenPerTimeUnit"]), + "storage_fee_max_gas_price": int(fees["storageFeeMaxGasPrice"]), + "receipt_fee_max_gas_price": int(fees["receiptFeeMaxGasPrice"]), + } + } + return [ + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": { + "External": { + "gas_limit": budget, + "max_gas_price": 0, + } + }, + "children_budget": 0, + "subtree": b"", + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "finalized", + "fee_params": internal_fee_params, + "children_budget": 0, + "subtree": b"", + }, + { + "recipient": None, + "call_key": None, + "budget": budget, + "on": "decided", + "fee_params": internal_fee_params, + "children_budget": 0, + "subtree": b"", + }, + ] + + +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", EMPTY_CALL_KEY) + ): + 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, + executor: str | None = None, + funding_offset: int = 0, +) -> int: + if bool(message.get("onAcceptance", False)): + raise ExternalAllocationInvalid("ExternalOnAcceptanceNotSupported") + + allocations = accounting.get("message_allocations") or [] + if not allocations: + return 0 + + candidates = list(_matching_root_allocations(allocations, message)) + if not candidates: + raise MessageNoMatchingAllocation("MessageNoMatchingAllocation") + + selected: tuple[int, dict[str, Any], int, int, int] | None = None + for index, allocation in candidates: + 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 + ) + if locked_price <= 0: + raise ExternalAllocationInvalid("ExternalExecutionPriceUnavailable") + 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"]): + selected = (index, allocation, gas_limit, locked_price, reservation) + break + + # Consensus falls through exact -> wildcard when the exact allocation is + # exhausted. If records existed but neither has room, the reveal fails. + if selected is None: + raise MessageBudgetExceeded("MessageBudgetExceeded") + + index, _allocation, gas_limit, locked_price, reservation = selected + key = str(index) + consumed = int(accounting.setdefault("allocation_consumed", {}).get(key, 0)) + attempted = consumed + reservation + 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 + ) + event = { + "recipient": str(message.get("recipient", "")).lower(), + "callKey": _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)), + "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), + "fundingOffset": max(0, int(funding_offset)), + "fundingOwners": _message_pool_owner_slices( + accounting, + max(0, int(funding_offset)), + reservation, + ), + } + accounting.setdefault("external_message_events", []).append(event) + if reimburse: + accounting["external_message_fee_settled"] = ( + int(accounting.get("external_message_fee_settled", 0)) + reservation + ) + _record_external_message_fee_payouts(accounting, event, executor) + return reservation if reimburse else 0 + + +def _record_external_message_fee_payouts( + accounting: dict[str, Any], + event: dict[str, Any], + executor: str | None, +) -> None: + reimbursement = int(event.get("reimbursement", 0) or 0) + remainder = int(event.get("remainder", 0) or 0) + fallback_depositor = accounting.get("sender") + executor_recipient = executor or fallback_depositor + payouts = accounting.setdefault("external_message_fee_payouts", []) + if reimbursement > 0 and executor_recipient: + payouts.append( + { + "recipient": executor_recipient, + "amount": reimbursement, + "source": "external-executor-reimbursement", + } + ) + if remainder > 0: + owner_refunds = _external_remainder_owner_slices(event, reimbursement) + if not owner_refunds and fallback_depositor: + owner_refunds = [{"recipient": fallback_depositor, "amount": remainder}] + for owner_refund in owner_refunds: + recipient = owner_refund.get("recipient") + amount = int(owner_refund.get("amount", 0) or 0) + if recipient and amount > 0: + payouts.append( + { + "recipient": recipient, + "amount": amount, + "source": "external-execution-remainder", + } + ) + + +def _message_pool_owner_slices( + accounting: dict[str, Any], + offset: int, + amount: int, +) -> list[dict[str, Any]]: + """Resolve an exact message-pool range to its ordered funders.""" + remaining_offset = max(0, int(offset)) + remaining = max(0, int(amount)) + owners: list[dict[str, Any]] = [] + for contribution in accounting.get("contributions") or []: + if not isinstance(contribution, dict): + continue + pool_amount = max(0, int(contribution.get("message", 0) or 0)) + if remaining_offset >= pool_amount: + remaining_offset -= pool_amount + continue + available = pool_amount - remaining_offset + owned = min(remaining, available) + recipient = contribution.get("depositor") + if owned > 0 and recipient: + owners.append({"recipient": recipient, "amount": owned}) + remaining -= owned + remaining_offset = 0 + if remaining == 0: + break + + if remaining > 0 and accounting.get("sender"): + owners.append({"recipient": accounting["sender"], "amount": remaining}) + return owners + + +def _external_remainder_owner_slices( + event: dict[str, Any], + reimbursement: int, +) -> list[dict[str, Any]]: + """The executor consumes the reservation prefix; its tail returns to funders.""" + skip = max(0, int(reimbursement)) + refunds: list[dict[str, Any]] = [] + for owner in event.get("fundingOwners") or []: + if not isinstance(owner, dict): + continue + owned = max(0, int(owner.get("amount", 0) or 0)) + if skip >= owned: + skip -= owned + continue + refundable = owned - skip + skip = 0 + recipient = owner.get("recipient") + if refundable > 0 and recipient: + refunds.append({"recipient": recipient, "amount": refundable}) + return refunds + + +def _next_external_funding_offset( + accounting: dict[str, Any], + baseline: int, +) -> int: + next_offset = max(0, int(baseline)) + for event in accounting.get("external_message_events") or []: + if not isinstance(event, dict): + continue + if event.get("executionRecorded") or event.get("unreserved"): + continue + next_offset = max( + next_offset, + int(event.get("fundingOffset", 0) or 0) + + int(event.get("reservation", 0) or 0), + ) + return next_offset + + +def _reindex_unexecuted_external_funding(accounting: dict[str, Any]) -> None: + cursor = max(0, int(accounting.get("message_fee_consumed", 0) or 0)) + for event in accounting.get("external_message_events") or []: + if not isinstance(event, dict): + continue + if event.get("executionRecorded") or event.get("unreserved"): + continue + reservation = max(0, int(event.get("reservation", 0) or 0)) + event["fundingOffset"] = cursor + event["fundingOwners"] = _message_pool_owner_slices( + accounting, + cursor, + reservation, + ) + cursor += reservation + + +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", EMPTY_CALL_KEY)) + 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", EMPTY_CALL_KEY)) != 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", EMPTY_CALL_KEY)) + 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", EMPTY_CALL_KEY)) != call_key: + continue + return index + return None + + +def _unreserve_external_message_fee( + accounting: dict[str, Any], + message: dict[str, Any], +) -> tuple[int, int, int]: + # Reveal unwind applies only to a reservation that has not executed yet. + # Once execution is recorded Consensus consumes the whole reservation and + # routes its reimbursement/remainder payouts; rolling that event back here + # would leave those terminal payouts inconsistent with the fee ledger. + event_index = _find_unexecuted_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) + remaining = max(0, consumed - reservation) + if remaining > 0: + allocation_consumed[allocation_index] = remaining + else: + allocation_consumed.pop(allocation_index, None) + accounting["message_fee_consumed"] = max( + 0, + int(accounting.get("message_fee_consumed", 0)) - reservation, + ) + 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, + ) + accounting["external_message_fee_settled"] = max( + 0, + int(accounting.get("external_message_fee_settled", 0)) - reservation, + ) + event["unreserved"] = True + _reindex_unexecuted_external_funding(accounting) + 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) + remaining = max(0, consumed - int(amount)) + if remaining > 0: + allocation_consumed[key] = remaining + else: + allocation_consumed.pop(key, None) + + +def _resolve_allocation( + allocations: list[dict[str, Any]], + message: dict[str, Any], +) -> tuple[int, dict[str, Any]] | None: + return next(_matching_root_allocations(allocations, message), None) + + +def _matching_root_allocations( + allocations: list[dict[str, Any]], + message: dict[str, Any], +): + message_type = int(message.get("messageType", MESSAGE_TYPE_INTERNAL)) + recipient = str(message.get("recipient", "")).lower() + call_key = _normalize_call_key(message.get("callKey", EMPTY_CALL_KEY)) + + wanted_call_keys = [call_key] + if call_key != CALL_KEY_WILDCARD: + wanted_call_keys.append(CALL_KEY_WILDCARD) + for wanted_call_key in wanted_call_keys: + 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: + yield index, allocation + + +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") and not bool( + message.get("useBalance", False) + ): + 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", + EMPTY_CALL_KEY, + ) + 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, + "useBalance": bool(_message_field(message, "use_balance", "useBalance", False)), + "gasUsed": int(_message_field(message, "gas_used", "gasUsed", 0) or 0), + } + + +def discovered_message_fee_allocations( + receipt: Any, + fees_distribution: dict[str, Any], + policy: StudioFeePolicy | None = None, +) -> list[dict[str, Any]]: + """Build an exact root allocation tree from an unmetered simulation. + + GenVM must receive an allocation before it can emit a fee-aware message, + which otherwise makes a transaction-specific estimate circular. Studio's + estimate endpoint first executes without fee metering, then converts the + observed recipient/call-key pairs into the same FlatArrays roots accepted + by Consensus and reruns the transaction under those exact roots. + + This intentionally does not create a wildcard recipient or call key. The + returned preset therefore funds only the messages the representative + execution actually emitted. + """ + policy = policy or StudioFeePolicy() + fees = normalize_fees_distribution(fees_distribution) + observed_messages = [ + _receipt_pending_transaction_fee_payload(raw) + for raw in _receipt_pending_transactions(receipt) + ] + if not observed_messages: + return [] + if len(observed_messages) > int( + policy.max_allocated_messages or MAX_ALLOCATED_MESSAGES_CAP + ): + raise TooManyMessages("TooManyMessages") + # Balance-funded internal messages reserve their declared budget from the + # emitting contract. They are deliberately outside the sender-funded + # message bucket and must not gain a root allocation during discovery. + messages = [ + message + for message in observed_messages + if not ( + int(message["messageType"]) == MESSAGE_TYPE_INTERNAL + and bool(message.get("useBalance", False)) + ) + ] + if not messages: + return [] + + leader_timeunits = int(fees["leaderTimeunitsAllocation"]) + validator_timeunits = int(fees["validatorTimeunitsAllocation"]) + if not ( + int(policy.min_propose_timeunits) + <= leader_timeunits + <= int(policy.max_propose_timeunits) + ): + leader_timeunits = DEFAULT_LEADER_TIMEUNITS_ALLOCATION + if not ( + int(policy.min_commit_timeunits) + <= validator_timeunits + <= int(policy.max_commit_timeunits) + ): + validator_timeunits = DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION + + rotations = [int((fees.get("rotations") or [0])[0])] + # The allocation becomes the child transaction's complete per-round + # execution reservoir. Funding it at the GenVM startup floor leaves no + # budget for the child's first storage write (a constructor that stores one + # field therefore finalizes as ``out_of storage``). Apply the same 20% + # execution headroom used by recommended_fee_preset so discovered children + # can execute beyond startup rather than merely enter the VM. + execution_budget = _with_padding( + max( + int(fees["executionBudgetPerRound"]), + int(policy.message_fee_params_budget_floor()), + int(policy.genvm_start_budget_floor()), + ), + DEFAULT_PRICE_CAP_HEADROOM_BPS, + ) + max_gen_price = max( + int(fees["maxPriceGenPerTimeUnit"]), + _with_cap_headroom(int(policy.gen_per_time_unit)), + ) + storage_price_cap = max( + int(fees["storageFeeMaxGasPrice"]), + _with_cap_headroom(int(policy.storage_unit_price)), + ) + receipt_price_cap = max( + int(fees["receiptFeeMaxGasPrice"]), + _with_cap_headroom(int(policy.receipt_gas_price)), + ) + + grouped: dict[tuple[int, str, str], dict[str, Any]] = {} + for message in messages: + message_type = int(message["messageType"]) + recipient = str(message["recipient"]).lower() + call_key = _normalize_call_key(message["callKey"]) + on_acceptance = bool(message["onAcceptance"]) + key = (message_type, recipient, call_key) + existing = grouped.get(key) + if existing is not None: + if bool(existing["onAcceptance"]) != on_acceptance: + raise MessageEmissionPhaseMismatch("MessageEmissionPhaseMismatch") + existing["count"] += 1 + continue + grouped[key] = { + "messageType": message_type, + "recipient": recipient, + "callKey": call_key, + "onAcceptance": on_acceptance, + "count": 1, + } + + allocations: list[dict[str, Any]] = [] + for item in grouped.values(): + count = int(item.pop("count")) + if int(item["messageType"]) == MESSAGE_TYPE_EXTERNAL: + gas_limit = max( + int(policy.default_external_gas_limit), + int(policy.min_external_gas_limit), + ) + max_gas_price = receipt_price_cap + if max_gas_price <= 0: + raise ExternalAllocationInvalid("ExternalExecutionPriceUnavailable") + fee_params = encode( + [EXTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [(gas_limit, max_gas_price)], + ) + budget = gas_limit * max_gas_price * count + on_acceptance = False + else: + internal_params = { + "leaderTimeunitsAllocation": leader_timeunits, + "validatorTimeunitsAllocation": validator_timeunits, + "appealRounds": 0, + "executionBudgetPerRound": execution_budget, + "rotations": rotations, + "maxPriceGenPerTimeUnit": max_gen_price, + "storageFeeMaxGasPrice": storage_price_cap, + "receiptFeeMaxGasPrice": receipt_price_cap, + } + fee_params = encode( + [INTERNAL_MESSAGE_FEE_PARAMS_ABI_TYPE], + [ + ( + internal_params["leaderTimeunitsAllocation"], + internal_params["validatorTimeunitsAllocation"], + internal_params["appealRounds"], + internal_params["executionBudgetPerRound"], + internal_params["rotations"], + internal_params["maxPriceGenPerTimeUnit"], + internal_params["storageFeeMaxGasPrice"], + internal_params["receiptFeeMaxGasPrice"], + ) + ], + ) + per_message_budget = min_message_primary_fees(internal_params, policy) + on_acceptance = bool(item["onAcceptance"]) + emission_factor = _internal_allocation_emission_factor( + on_acceptance, int(fees["appealRounds"]) + ) + budget = _u256_mul(per_message_budget, count, emission_factor) + + allocations.append( + { + "messageType": int(item["messageType"]), + "onAcceptance": on_acceptance, + "parentIndex": NODE_ROOT_SENTINEL, + "recipient": item["recipient"], + "callKey": item["callKey"], + "budget": budget, + "feeParams": fee_params, + } + ) + + return allocations + + +def fee_accounting_with_discovered_messages( + accounting: dict[str, Any], + receipt: Any, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + """Rebuild simulation accounting around exact discovered allocations.""" + policy = _accounting_policy(accounting, policy) + fees = normalize_fees_distribution(accounting.get("fees_distribution") or {}) + allocations = discovered_message_fee_allocations(receipt, fees, policy) + if not allocations: + return accounting + + fees["totalMessageFees"] = sum( + int(allocation["budget"]) + for allocation in allocations + if int(allocation["parentIndex"]) == NODE_ROOT_SENTINEL + ) + fees["executionBudgetPerRound"] = max( + int(fees["executionBudgetPerRound"]), + int(policy.message_fee_params_budget_floor()) + + int(policy.receipt_gas_price) * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM, + int(policy.genvm_start_budget_floor()), + ) + fee_value = required_fee_deposit( + fees, + int(accounting.get("num_of_initial_validators") or VALIDATORS_PER_ROUND[0]), + policy, + ) + return create_fee_accounting( + fees_distribution=fees, + message_allocations=allocations, + num_of_validators=int( + accounting.get("num_of_initial_validators") or VALIDATORS_PER_ROUND[0] + ), + submitted_value=int(accounting.get("user_value", 0) or 0) + fee_value, + user_value=int(accounting.get("user_value", 0) or 0), + sender=accounting.get("sender"), + policy=policy, + allow_low_execution_budget=True, + ) + + +def _execution_fee_buckets(consumed: FeeBucketConsumption) -> list[int]: + return [_bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET)] + + +def _chargeable_execution_fee_buckets( + consumed: FeeBucketConsumption, + fee_report: dict[str, Any] | None, + policy: StudioFeePolicy, + receipt: Any | None = None, +) -> list[int]: + if not isinstance(fee_report, dict): + return [ + _bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET), + 0, + ] + + return [ + _receipt_report_chargeable_fee(fee_report), + _chargeable_storage_fee(receipt, consumed, fee_report), + ] + + +def _chargeable_storage_fee( + receipt: Any | None, + consumed: FeeBucketConsumption, + fee_report: dict[str, Any] | None = None, +) -> int: + if receipt is not None and not _receipt_execution_allows_messages(receipt): + return 0 + shared_execution = _bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET) + if not isinstance(fee_report, dict): + return shared_execution + # GenVM v0.3 shares receipt and storage charges in bucket 0. Its complete + # receipt charge includes the always-reserved empty message-reveal cost, + # whereas Consensus charges only the proposal and any actual reveal. The + # remainder is therefore the storage/event-write fee supplied to Consensus. + genvm_receipt_fee = int(fee_report.get("totalStudioMeteredFee", 0) or 0) + return max(0, shared_execution - genvm_receipt_fee) + + +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: FeeBucketConsumption, index: int) -> int: + if isinstance(consumed, dict): + return int(consumed.get(_bucket_name(index), 0) or 0) + return int(consumed[index]) if len(consumed) > index else 0 + + +def _has_bucket(consumed: FeeBucketConsumption, index: int) -> bool: + if isinstance(consumed, dict): + return _bucket_name(index) in consumed + return len(consumed) > index + + +def _bucket_name(index: int) -> str: + return GENVM_FEE_BUCKET_NAMES[index] if index < len(GENVM_FEE_BUCKET_NAMES) else "" + + +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: FeeBucketConsumption, + *, + execution_budget_per_round: int = 0, +) -> dict[str, Any]: + execution = _bucket_value(consumed, GENVM_EXECUTION_FEE_BUCKET) + message = _bucket_value(consumed, GENVM_MESSAGE_FEE_BUCKET) + nondet_output_bytes = _bucket_value(consumed, GENVM_NONDET_OUTPUT_BYTES_BUCKET) + submitted_message_bytes = _bucket_value( + consumed, GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET + ) + submitted_message_count = _bucket_value( + consumed, GENVM_SUBMITTED_MESSAGE_COUNT_BUCKET + ) + buckets = [] + bucket_definitions = ( + (GENVM_EXECUTION_FEE_BUCKET, "execution", "fee"), + (GENVM_MESSAGE_FEE_BUCKET, "message", "fee"), + ( + GENVM_NONDET_OUTPUT_BYTES_BUCKET, + "nondeterministicOutputBytes", + "bytes", + ), + ( + GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET, + "submittedMessageBytes", + "bytes", + ), + ( + GENVM_SUBMITTED_MESSAGE_COUNT_BUCKET, + "submittedMessageCount", + "count", + ), + ) + for index, name, unit in bucket_definitions: + if _has_bucket(consumed, index): + buckets.append( + { + "index": index, + "name": name, + "unit": unit, + "consumed": _bucket_value(consumed, index), + } + ) + report = { + "layout": "genvm-v0.3", + "execution": execution, + "message": message, + "nondeterministicOutputBytes": nondet_output_bytes, + "submittedMessageBytes": submitted_message_bytes, + "submittedMessageCount": submitted_message_count, + "totalExecution": execution, + "totalWithMessage": execution + message, + "buckets": buckets, + } + overrun = max(0, execution - execution_budget_per_round) + report.update( + { + "executionBudgetPerRound": execution_budget_per_round, + "executionBudgetRemaining": max(0, execution_budget_per_round - execution), + "executionBudgetOverrun": overrun, + "executionBudgetExceeded": overrun > 0, + } + ) + return report + + +def _chargeable_execution_bucket_report( + consumed: list[int], + *, + execution_budget_per_round: int = 0, +) -> dict[str, Any]: + receipt = _bucket_value(consumed, 0) + storage = _bucket_value(consumed, 1) + total_execution = receipt + storage + overrun = max(0, total_execution - execution_budget_per_round) + return { + "layout": "consensus-chargeable", + "receipt": receipt, + "storage": storage, + "totalExecution": total_execution, + "totalWithMessage": total_execution, + "buckets": [ + {"index": 0, "name": "receipt", "unit": "fee", "consumed": receipt}, + {"index": 1, "name": "storage", "unit": "fee", "consumed": storage}, + ], + "executionBudgetPerRound": execution_budget_per_round, + "executionBudgetRemaining": max( + 0, execution_budget_per_round - total_execution + ), + "executionBudgetOverrun": overrun, + "executionBudgetExceeded": overrun > 0, + } + + +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) + external_settled = int(accounting.get("external_message_fee_settled", 0) or 0) + declared_consumed = max(0, total_consumed - external_settled) + 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["externalSettled"] = external_settled + 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 + ) + execution_floor = max(execution_floor, policy.genvm_start_budget_floor()) + + observed_execution = _observed_chargeable_execution_fee(accounting, report) + genvm_execution_required = _int_report_field( + ( + report.get("genvmBuckets") + if isinstance(report.get("genvmBuckets"), dict) + else {} + ), + "execution", + ) + observed_budget_requirement = max( + observed_execution, + genvm_execution_required, + ) + recommended_execution = int(fees["executionBudgetPerRound"]) + if observed_budget_requirement > 0: + recommended_execution = max( + _with_padding(observed_budget_requirement, 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, + "genvmExecutionRequired": genvm_execution_required, + "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, +) -> FeeBucketConsumption | 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: + if isinstance(consumed, dict): + return {str(name): int(value) for name, value in consumed.items()} + 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 + if isinstance(totals, dict) != isinstance(remaining, dict): + return None + if isinstance(totals, dict): + if totals.keys() != remaining.keys(): + return None + return { + str(name): max(0, int(total) - int(remaining[name])) + for name, total in totals.items() + } + return [max(0, int(total) - int(rest)) for total, rest in zip(totals, remaining)] + + +def _receipt_execution_policy( + receipt: Any | None, + fallback: StudioFeePolicy, +) -> StudioFeePolicy: + genvm_result = _receipt_genvm_result(receipt) + snapshot = ( + genvm_result.get(FEE_POLICY_SNAPSHOT_KEY) + if isinstance(genvm_result, dict) + else None + ) + if isinstance(snapshot, dict): + try: + return StudioFeePolicy.from_snapshot(snapshot) + except (KeyError, TypeError, ValueError): + pass + return fallback + + +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) + validate_receipt_admission_caps(receipt, policy) + 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 + empty_message_reveal_fee = ( + policy.estimate_message_reveal_gas(0, 0) * 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, + # GenVM reserves an empty message reveal at startup even when the + # execution emits no messages. Keep this full meter value separate + # from the lower Consensus charge so shared-bucket storage can be + # recovered exactly. + "totalStudioMeteredFee": proposal_fee + empty_message_reveal_fee, + } + + submitted_messages, message_reports = ( + _receipt_submitted_messages_and_reports(receipt, message_payloads) + if _receipt_execution_allows_messages(receipt) + else ([], []) + ) + if submitted_messages: + message_bytes = len(encode([SUBMITTED_MESSAGE_ABI_TYPE], [submitted_messages])) + if ( + policy.max_submitted_messages_bytes > 0 + and message_bytes > policy.max_submitted_messages_bytes + ): + raise SubmittedMessagesTooLarge( + "SubmittedMessagesTooLarge" + f"({message_bytes},{policy.max_submitted_messages_bytes})" + ) + 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"] = proposal_fee + message_fee + + consumed = _receipt_data_fees_consumed(receipt) + if consumed is not None and _has_bucket( + consumed, GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET + ): + message_count = ( + _bucket_value(consumed, GENVM_SUBMITTED_MESSAGE_COUNT_BUCKET) + if _has_bucket(consumed, GENVM_SUBMITTED_MESSAGE_COUNT_BUCKET) + else len(submitted_messages) + ) + report["totalStudioMeteredFee"] = _genvm_receipt_metered_fee( + receipt, + policy, + consumed, + message_count=message_count, + ) + + return report + + +def _genvm_receipt_metered_fee( + receipt: Any, + policy: StudioFeePolicy, + consumed: FeeBucketConsumption, + *, + message_count: int, +) -> int: + """Reproduce the receipt-related part of GenVM v0.3 bucket 0. + + Storage, receipt writes, nondeterministic output, and event writes share + the same enforced reservoir. GenVM's metadata counters let Studio remove + only the receipt portion and pass the remaining storage/event-write charge + to Consensus without weakening the unified cap. + """ + + receipt_fee_per_byte = int(policy.receipt_gas_price) * int( + policy.calldata_gas_per_byte + ) + changed_slot_fee = int(policy.receipt_gas_price) * int(policy.gas_per_changed_slot) + if isinstance(consumed, list): + # Positional receipts retain the pre-named-bucket startup layout. + total = int(policy._legacy_genvm_start_budget_floor()) + + eq_outputs = _receipt_eq_outputs(receipt) + if eq_outputs: + total += sum( + (64 + ((len(output) + 31) // 32) * 32) * receipt_fee_per_byte + for output in eq_outputs + ) + else: + raw_output_bytes = _bucket_value(consumed, GENVM_NONDET_OUTPUT_BYTES_BUCKET) + if raw_output_bytes > 0: + total += ( + 64 + ((raw_output_bytes + 31) // 32) * 32 + ) * receipt_fee_per_byte + else: + total = int(policy.genvm_start_budget_floor()) + nondet_output_bytes = _bucket_value(consumed, GENVM_NONDET_OUTPUT_BYTES_BUCKET) + total += ( + max(0, nondet_output_bytes - GENVM_NONDET_OUTPUT_HEADER_BYTES) + * receipt_fee_per_byte + ) + if message_count > 0: + total += ( + policy.fixed_message_reveal_gas + + policy.intrinsic_gas + + policy.bootloader_overhead + ) * policy.receipt_gas_price + + submitted_message_bytes = _bucket_value( + consumed, GENVM_SUBMITTED_MESSAGE_BYTES_BUCKET + ) + total += submitted_message_bytes * receipt_fee_per_byte + total += max(0, int(message_count)) * changed_slot_fee + return total + + +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") + if explicit is None: + explicit = 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 _enforce_submitted_messages_cap( + messages: list[dict[str, Any]], + policy: StudioFeePolicy, +) -> int: + if not messages: + return 0 + submitted, _ = _receipt_submitted_messages_and_reports({}, messages) + message_bytes = len(encode([SUBMITTED_MESSAGE_ABI_TYPE], [submitted])) + if ( + policy.max_submitted_messages_bytes > 0 + and message_bytes > policy.max_submitted_messages_bytes + ): + raise SubmittedMessagesTooLarge( + "SubmittedMessagesTooLarge" + f"({message_bytes},{policy.max_submitted_messages_bytes})" + ) + return message_bytes + + +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", + EMPTY_CALL_KEY, + ) + 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, + bool(_message_field(message, "use_balance", "useBalance", False)), + ) + ) + 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(), + "useBalance": bool( + _message_field(message, "use_balance", "useBalance", False) + ), + } + ) + return submitted, reports + + +def _message_effect_identities( + tx_id: str, + messages: list[dict[str, Any]], +) -> list[tuple[str, str]]: + """Mirror Messages._logicalOccurrence and _effectDescriptor. + + The application key deliberately excludes fee/value/lifecycle fields. Any + drift in those fields is caught by the full SubmittedMessage descriptor for + the same stable occurrence, exactly as in Consensus. + """ + + submitted, _ = _receipt_submitted_messages_and_reports({}, messages) + tx_id_bytes = _bytes32_field(tx_id) + tuple_type = SUBMITTED_MESSAGE_ABI_TYPE.removesuffix("[]") + occurrence_counts: dict[bytes, int] = {} + identities: list[tuple[str, str]] = [] + zero_address = "0x" + ("0" * 40) + + for submitted_message in submitted: + message_type = int(submitted_message[0]) + recipient = str(submitted_message[1]).lower() + data_hash = keccak(bytes(submitted_message[3])) + deploy_salt = ( + int(submitted_message[5]) + if message_type == MESSAGE_TYPE_INTERNAL and recipient == zero_address + else 0 + ) + application_key = keccak( + encode( + ["uint8", "address", "bytes32", "uint256"], + [message_type, recipient, data_hash, deploy_salt], + ) + ) + ordinal = occurrence_counts.get(application_key, 0) + occurrence_counts[application_key] = ordinal + 1 + occurrence = keccak( + encode( + ["bytes32", "bytes32", "uint256"], + [tx_id_bytes, application_key, ordinal], + ) + ) + descriptor = keccak(encode([tuple_type], [submitted_message])) + identities.append(("0x" + occurrence.hex(), "0x" + descriptor.hex())) + + return identities + + +def message_effect_identities( + tx_id: str, + messages: list[dict[str, Any]], +) -> list[tuple[str, str]]: + """Return Consensus-compatible stable occurrence and descriptor pairs. + + Message delivery, value reservation, and fee accounting all need to share + one logical identity. Keeping that identity authority here prevents the + asynchronous Studio worker from inventing a second retry key. + """ + + return _message_effect_identities(tx_id, messages) + + +def acceptance_dispatch_pending(accounting: dict[str, Any] | None) -> bool: + """Whether the current agreed decision still owes its acceptance phase.""" + + if not isinstance(accounting, dict): + return False + generation = accounting.get("active_message_generation") + return ( + isinstance(generation, dict) + and bool(generation.get("acceptanceDispatchRequired", False)) + and not bool(generation.get("acceptanceDispatched", False)) + ) + + +def message_novelty_mask( + accounting: dict[str, Any], + tx_id: str, + messages: list[dict[str, Any]], +) -> list[bool]: + identities = _message_effect_identities(tx_id, messages) + descriptors = accounting.get("message_effect_descriptors") or {} + delivered = accounting.get("message_effect_delivered") or {} + novelty = [] + for occurrence, descriptor in identities: + expected = descriptors.get(occurrence) + if expected is not None and expected != descriptor: + raise MessageEffectDescriptorMismatch( + "MessageEffectDescriptorMismatch" + f"({tx_id},{occurrence},{expected},{descriptor})" + ) + novelty.append(not bool(delivered.get(occurrence, False))) + return novelty + + +def prepare_reveal_message_generation( + accounting: dict[str, Any], + tx_id: str, + messages: list[dict[str, Any]], + *, + policy: StudioFeePolicy | None = None, +) -> dict[str, Any]: + """Retire the prior reveal and charge only fresh logical occurrences.""" + + updated = copy.deepcopy(accounting) + prior_generation = updated.get("active_message_generation") + if isinstance(prior_generation, dict): + updated = unwind_reveal_message_fees( + updated, + list(prior_generation.get("messages") or []), + acceptance_dispatched=bool( + prior_generation.get("acceptanceDispatched", False) + ), + ) + + identities = _message_effect_identities(tx_id, messages) + novelty = message_novelty_mask(updated, tx_id, messages) + descriptors = updated.setdefault("message_effect_descriptors", {}) + for occurrence, descriptor in identities: + descriptors.setdefault(occurrence, descriptor) + + novel_messages = [ + message for message, is_novel in zip(messages, novelty) if is_novel + ] + updated = record_reveal_message_fees( + updated, + novel_messages, + policy=policy, + ) + updated["active_message_generation"] = { + "messages": _message_accounting_json_safe(messages), + "occurrences": [occurrence for occurrence, _ in identities], + "novelty": novelty, + # Acceptance itself is a durable helper-chain phase, including when + # the receipt contains no accepted children. The worker clears this + # only after the helper call and local child insertion both succeed. + "acceptanceDispatchRequired": True, + "acceptanceDispatched": False, + } + return updated + + +def _message_accounting_json_safe(value: Any) -> Any: + if isinstance(value, bytes): + return "0x" + value.hex() + if isinstance(value, bytearray): + return "0x" + bytes(value).hex() + if isinstance(value, list): + return [_message_accounting_json_safe(item) for item in value] + if isinstance(value, tuple): + return [_message_accounting_json_safe(item) for item in value] + if isinstance(value, dict): + return {key: _message_accounting_json_safe(item) for key, item in value.items()} + return value + + +def discard_active_message_generation( + accounting: dict[str, Any], +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + prior_generation = updated.pop("active_message_generation", None) + if not isinstance(prior_generation, dict): + return updated + updated = unwind_reveal_message_fees( + updated, + list(prior_generation.get("messages") or []), + acceptance_dispatched=bool(prior_generation.get("acceptanceDispatched", False)), + ) + updated.pop("active_message_generation", None) + updated["message_fees_recorded_at_reveal"] = True + return updated + + +def mark_message_effects_delivered( + accounting: dict[str, Any], + tx_id: str, + messages: list[dict[str, Any]], + on: str, +) -> dict[str, Any]: + updated = copy.deepcopy(accounting) + identities = _message_effect_identities(tx_id, messages) + # Reuse descriptor validation before installing an irreversible tombstone. + message_novelty_mask(updated, tx_id, messages) + delivered = updated.setdefault("message_effect_delivered", {}) + for message, (occurrence, descriptor) in zip(messages, identities): + if bool(message.get("onAcceptance", False)) != (on == "accepted"): + continue + updated.setdefault("message_effect_descriptors", {}).setdefault( + occurrence, descriptor + ) + delivered[occurrence] = True + + updated.setdefault("message_phase_emitted", {})[on] = True + generation = updated.get("active_message_generation") + if on == "accepted" and isinstance(generation, dict): + generation["acceptanceDispatched"] = True + return updated + + +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", EMPTY_CALL_KEY), + "allocation_subtree": getattr(pending_transaction, "allocation_subtree", []), + "use_balance": getattr(pending_transaction, "use_balance", False), + } + + +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 EMPTY_CALL_KEY) + if normalized == CALL_KEY_WILDCARD: + return normalized + if normalized != EMPTY_CALL_KEY: + return normalized + + raw_calldata = _bytes_field(calldata) + if len(raw_calldata) < 4: + return EMPTY_CALL_KEY + + return "0x" + raw_calldata[:4].hex().ljust(64, "0") diff --git a/backend/protocol_rpc/ghost_factory.py b/backend/protocol_rpc/ghost_factory.py new file mode 100644 index 000000000..f4a2218ab --- /dev/null +++ b/backend/protocol_rpc/ghost_factory.py @@ -0,0 +1,161 @@ +"""Deterministic Studio analogue of Consensus' GhostFactory deployment rules. + +Studio does not execute user submissions on an EVM. Its former fallback for +an unavailable shadow Hardhat node created a random account, which made salted +deployments non-deterministic and let RPC replicas disagree. This module keeps +the address calculation identical to GhostFactory's CREATE/CREATE2 mechanics +while giving Studio its own configurable virtual protocol deployment. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import os + +import rlp +from eth_utils import is_address, keccak, to_bytes, to_checksum_address + +UINT256_MAX = (1 << 256) - 1 + +# These identify Studio's virtual v0.6 protocol deployment. Operators that +# intentionally mirror a concrete EVM deployment can override the factory, +# bytecodeEVM hash, and initial factory nonce. +DEFAULT_GHOST_FACTORY_ADDRESS = "0x130e09c996462963A6398CA04e1011e6ef9d68a6" +DEFAULT_GHOST_BYTECODE_HASH = ( + "0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de" +) +DEFAULT_GHOST_FACTORY_INITIAL_NONCE = 2 + + +class InvalidGhostFactoryConfiguration(ValueError): + pass + + +@dataclass(frozen=True) +class GhostFactoryConfig: + factory_address: str + bytecode_hash: bytes + initial_nonce: int + + @classmethod + def from_env(cls) -> "GhostFactoryConfig": + factory_address = _configured_address( + "GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS", + DEFAULT_GHOST_FACTORY_ADDRESS, + ) + bytecode_hash = _configured_bytes32( + "GENLAYER_STUDIO_GHOST_BYTECODE_HASH", + DEFAULT_GHOST_BYTECODE_HASH, + ) + initial_nonce = _configured_uint256( + "GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE", + DEFAULT_GHOST_FACTORY_INITIAL_NONCE, + ) + return cls( + factory_address=factory_address, + bytecode_hash=bytecode_hash, + initial_nonce=initial_nonce, + ) + + def address_for( + self, + salt_nonce: int, + successful_deployments: int, + *, + namespace: str | None = None, + ) -> str: + """Return the next GhostFactory address without mutating state. + + ``successful_deployments`` is the number of ghosts already created by + this virtual factory. Every successful CREATE and CREATE2 increments + the EVM account nonce, while a reverted collision does not. + """ + + salt_nonce = _coerce_uint256("salt_nonce", salt_nonce) + successful_deployments = _coerce_uint256( + "successful_deployments", successful_deployments + ) + factory = to_bytes(hexstr=self.factory_address) + if salt_nonce == 0: + factory_nonce = self.initial_nonce + successful_deployments + if factory_nonce > UINT256_MAX: + raise OverflowError("GhostFactoryNonceOverflow") + digest = keccak(rlp.encode([factory, factory_nonce])) + else: + if namespace is None or not is_address(namespace): + raise InvalidGhostFactoryConfiguration( + "salted ghost deployment requires an EVM namespace" + ) + # The authenticated deployment sender owns the salt namespace. + # Using the shared CreationPhase address here reproduces the live + # cross-user collision defect and disagrees with public prediction. + namespace_bytes = to_bytes(hexstr=to_checksum_address(namespace)) + salt = keccak(namespace_bytes + salt_nonce.to_bytes(32, "big")) + digest = keccak(b"\xff" + factory + salt + self.bytecode_hash) + return to_checksum_address("0x" + digest[-20:].hex()) + + +def genvm_salted_child_address( + contract_address: str, + salt_nonce: int, + chain_id: int, +) -> str: + """Return the address exposed by GenVM for a salted child deployment. + + GenVM makes the address synchronously visible to the parent contract, so + Studio must materialize the child under that exact identity. This is the + runner's public ``create2_address(contract, salt, chain)`` formula; it is + intentionally separate from the EVM GhostFactory formula used to allocate + top-level deployments. + """ + + if not is_address(contract_address): + raise InvalidGhostFactoryConfiguration( + "salted child deployment requires a GenVM contract address" + ) + salt_nonce = _coerce_uint256("salt_nonce", salt_nonce) + if salt_nonce == 0: + raise InvalidGhostFactoryConfiguration( + "salted child deployment requires a non-zero salt_nonce" + ) + chain_id = _coerce_uint256("chain_id", chain_id) + digest = keccak( + b"\x01" + + to_bytes(hexstr=to_checksum_address(contract_address)) + + salt_nonce.to_bytes(32, "big") + + chain_id.to_bytes(32, "big") + ) + return to_checksum_address("0x" + digest[:20].hex()) + + +def _configured_address(name: str, default: str) -> str: + value = os.getenv(name, default) + if not is_address(value): + raise InvalidGhostFactoryConfiguration(f"{name} must be an EVM address") + return to_checksum_address(value) + + +def _configured_bytes32(name: str, default: str) -> bytes: + value = os.getenv(name, default) + try: + raw = to_bytes(hexstr=value) + except (TypeError, ValueError) as exc: + raise InvalidGhostFactoryConfiguration(f"{name} must be bytes32") from exc + if len(raw) != 32: + raise InvalidGhostFactoryConfiguration(f"{name} must be bytes32") + return raw + + +def _configured_uint256(name: str, default: int) -> int: + value = os.getenv(name, str(default)) + try: + return _coerce_uint256(name, int(value, 10)) + except (TypeError, ValueError) as exc: + raise InvalidGhostFactoryConfiguration(f"{name} must be uint256") from exc + + +def _coerce_uint256(name: str, value: int) -> int: + value = int(value) + if value < 0 or value > UINT256_MAX: + raise InvalidGhostFactoryConfiguration(f"{name} must be uint256") + return value diff --git a/backend/protocol_rpc/health.py b/backend/protocol_rpc/health.py index e28370b98..b451b12c7 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( @@ -607,12 +635,21 @@ async def _check_consensus_health() -> Dict[str, Any]: # top-level "issues" tag agree on what counts as degraded. DEGRADED_AT_STUCK_HEADS = int(os.environ.get("HEALTH_DEGRADED_AT_STUCK_HEADS", "3")) # Finalization-stall threshold: ACCEPTED/UNDETERMINED/*_TIMEOUT txs - # that haven't reached FINALIZED within this many seconds count as - # stuck. Default 600s (10 min) — finalization is supposed to be - # quick after the finality window opens. + # that remain unfinalized for this long *after their appeal deadline* + # count as stuck. Default 600s (10 min). STUCK_FINALIZATION_AFTER_SECONDS = int( os.environ.get("HEALTH_STUCK_FINALIZATION_AFTER_SECONDS", "600") ) + FINALITY_WINDOW_SECONDS = max( + 0, int(os.environ.get("VITE_FINALITY_WINDOW", "1800")) + ) + APPEAL_FAILED_REDUCTION = min( + 1.0, + max( + 0.0, + float(os.environ.get("VITE_FINALITY_WINDOW_APPEAL_FAILED_REDUCTION", "0")), + ), + ) DEGRADED_AT_STUCK_FINALIZATIONS = int( os.environ.get("HEALTH_DEGRADED_AT_STUCK_FINALIZATIONS", "3") ) @@ -635,25 +672,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 +719,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,58 +732,170 @@ 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: - # 1. timestamp_awaiting_finalization set and stale - # (normal "finalizer not running" case) + # 1. the exact DecisionRecord appeal deadline (or the + # legacy reconstructed deadline) is stale # 2. timestamp_awaiting_finalization NULL and the row # is old (catches future bugs like the May 2026 # insufficient-balance SEND path, where a tx # 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 finalization_windows 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, + CASE + WHEN timestamp_awaiting_finalization IS NOT NULL + AND execution_mode IN ( + 'LEADER_ONLY', + 'LEADER_SELF_VALIDATOR' + ) + THEN timestamp_awaiting_finalization::double precision + ELSE COALESCE( + NULLIF( + consensus_history->'latestDecision'->>'appealDeadline', + '' + )::double precision, + CASE + WHEN timestamp_awaiting_finalization IS NOT NULL + THEN timestamp_awaiting_finalization + + COALESCE(appeal_processing_time, 0) + + :finality_window_seconds * POWER( + 1 - :appeal_failed_reduction, + COALESCE(appeal_failed, 0) + ) + ELSE NULL + END + ) + END AS appeal_deadline + FROM transactions + WHERE status IN ({FINALIZATION_ELIGIBLE_STATUSES_SQL}) + AND appealed = false + ), + stuck_finalizations AS ( + SELECT * + FROM finalization_windows + WHERE ( + (appeal_deadline IS NOT NULL + AND EXTRACT(EPOCH FROM NOW()) + > appeal_deadline + :stuck_seconds) + OR + (appeal_deadline IS NULL + AND 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, + "finality_window_seconds": FINALITY_WINDOW_SECONDS, + "appeal_failed_reduction": APPEAL_FAILED_REDUCTION, + "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 +1151,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 +1935,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 +1943,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) - - 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, - ) - ) + import os - 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 +1962,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 +2090,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 +2133,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/redaction.py b/backend/protocol_rpc/redaction.py new file mode 100644 index 000000000..b6485abc0 --- /dev/null +++ b/backend/protocol_rpc/redaction.py @@ -0,0 +1,44 @@ +"""Redaction of validator private keys from RPC-facing payloads. + +Receipts carry the executing validator's ``node_config`` (including its +``private_key``); anything that returns a receipt to a client must pass +through :func:`sanitize_rpc_private_keys`. Shared by the JSON-RPC endpoints and +the explorer REST API. +""" + +import os +from typing import Any + + +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 diff --git a/backend/protocol_rpc/rpc_methods.py b/backend/protocol_rpc/rpc_methods.py index 1c8785850..8e6bddcd1 100644 --- a/backend/protocol_rpc/rpc_methods.py +++ b/backend/protocol_rpc/rpc_methods.py @@ -27,7 +27,6 @@ from backend.protocol_rpc.rpc_decorators import rpc from backend.protocol_rpc.rpc_endpoint_manager import LogPolicy - # --------------------------------------------------------------------------- # Simulator endpoints # --------------------------------------------------------------------------- @@ -49,7 +48,7 @@ def clear_db_tables( @rpc.method("sim_fundAccount") def fund_account( account_address: str, - amount: int, + amount: int | str, session: Session = Depends(get_db_session), ) -> str: return impl.fund_account( @@ -125,6 +124,19 @@ async def create_validator( ) +@rpc.method("sim_replaceValidators") +async def replace_validators( + validator_configs: list[dict], + session: Session = Depends(get_db_session), + validators_manager=Depends(get_validators_manager), +) -> list[dict]: + return await impl.replace_validators( + session=session, + validators_manager=validators_manager, + validator_configs=validator_configs, + ) + + @rpc.method("sim_createRandomValidator") async def create_random_validator( stake: int, @@ -169,6 +181,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 +194,7 @@ async def update_validator( stake=stake, provider=provider, model=model, + config=config, plugin=plugin, plugin_config=plugin_config, ) @@ -291,6 +305,42 @@ 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_calculateRoundFees", log_policy=LogPolicy.debug()) +def sim_calculate_round_fees( + fees_distribution: dict, + num_of_validators: int = 5, + round: int = 0, +) -> str: + return impl.sim_calculate_round_fees( + fees_distribution, + num_of_validators, + round, + ) + + +@rpc.method("sim_minMessagePrimaryFees", log_policy=LogPolicy.debug()) +def sim_min_message_primary_fees(fee_params: str) -> str: + return impl.sim_min_message_primary_fees(fee_params) + + +@rpc.method("sim_estimateProposeReceiptGas", log_policy=LogPolicy.debug()) +def sim_estimate_propose_receipt_gas(eq_outputs_length: int = 0) -> dict[str, str]: + return impl.sim_estimate_propose_receipt_gas(eq_outputs_length) + + +@rpc.method("sim_estimateMessageRevealGas", log_policy=LogPolicy.debug()) +def sim_estimate_message_reveal_gas( + message_bytes: int, + message_count: int, +) -> dict[str, str]: + return impl.sim_estimate_message_reveal_gas(message_bytes, message_count) + + @rpc.method("sim_getConsensusContract", log_policy=LogPolicy.debug()) def get_consensus_contract( contract_name: str, @@ -424,6 +474,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 # --------------------------------------------------------------------------- @@ -470,15 +541,48 @@ def get_studio_transaction_by_hash( @rpc.method("gen_getTransactionStatus", log_policy=LogPolicy.debug()) def get_transaction_status( - transaction_hash: str, + transaction_hash: str | dict, transactions_processor: TransactionsProcessor = Depends(get_transactions_processor), -) -> str: +) -> str | dict: return impl.get_transaction_status( transactions_processor=transactions_processor, transaction_hash=transaction_hash, ) +@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("gen_getTransactionLifecycle", log_policy=LogPolicy.debug()) +def get_transaction_lifecycle( + params: dict, + transactions_processor: TransactionsProcessor = Depends(get_transactions_processor), +) -> dict: + return impl.get_transaction_lifecycle( + transactions_processor=transactions_processor, + params=params, + ) + + +@rpc.method("gen_estimateLatestAppealCharge", log_policy=LogPolicy.debug()) +def estimate_latest_appeal_charge( + params: dict, + transactions_processor: TransactionsProcessor = Depends(get_transactions_processor), +) -> dict: + return impl.estimate_latest_appeal_charge( + transactions_processor=transactions_processor, + params=params, + ) + + @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..f2c4dbc73 100644 --- a/backend/protocol_rpc/transactions_parser.py +++ b/backend/protocol_rpc/transactions_parser.py @@ -5,11 +5,13 @@ from rlp.exceptions import DeserializationError, SerializationError from eth_account import Account from eth_account._utils.legacy_transactions import Transaction +from eth_account._utils.signing import extract_chain_id import eth_utils from eth_utils import to_checksum_address from hexbytes import HexBytes import os from backend.rollup.consensus_service import ConsensusService +from backend.rollup.web3_pool import Web3ConnectionPool from backend.domain.types import TransactionType from backend.protocol_rpc.types import ( @@ -22,9 +24,237 @@ DecodedGenlayerTransaction, DecodedGenlayerTransactionData, DecodedsubmitAppealDataArgs, + DecodedTopUpFeesDataArgs, + DecodedFinalizeTransactionDataArgs, 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_SUBMIT_APPEAL_ABI = { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "submitAppeal", + "outputs": [], + "stateMutability": "payable", + "type": "function", +} + +FEE_AWARE_LEGACY_SUBMIT_APPEAL_ABI = { + **FEE_AWARE_SUBMIT_APPEAL_ABI, + "inputs": [FEE_AWARE_SUBMIT_APPEAL_ABI["inputs"][0]], +} + +FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI = { + **FEE_AWARE_TOP_UP_FEES_ABI, + "inputs": [ + FEE_AWARE_TOP_UP_FEES_ABI["inputs"][0], + FEE_AWARE_SUBMIT_APPEAL_ABI["inputs"][1], + FEE_AWARE_TOP_UP_FEES_ABI["inputs"][1], + ], + "name": "topUpAndSubmitAppeal", +} + +# Decode transactions produced by older SDKs so the RPC boundary can return +# the canonical CanNotAppeal error. Admission still requires the exact +# DecisionId, matching v0.6 Consensus. +FEE_AWARE_LEGACY_TOP_UP_AND_SUBMIT_APPEAL_ABI = { + **FEE_AWARE_TOP_UP_FEES_ABI, + "name": "topUpAndSubmitAppeal", +} + +FEE_AWARE_FINALIZE_TRANSACTION_ABI = { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "finalizeTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", +} + +# Decode old SDK calls only so Studio returns the canonical guarded failure; +# admission below still requires the exact active DecisionId. +FEE_AWARE_LEGACY_FINALIZE_TRANSACTION_ABI = { + **FEE_AWARE_FINALIZE_TRANSACTION_ABI, + "inputs": [FEE_AWARE_FINALIZE_TRANSACTION_ABI["inputs"][0]], +} + +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 @@ -72,7 +302,11 @@ def deserialize(self, serial): class TransactionParser: def __init__(self, consensus_service: ConsensusService): self.consensus_service = consensus_service - self.web3 = consensus_service.web3 + # Decoding a signed envelope is pure keccak + ABI codec work. The + # rollup bridge is optional (HARDHAT_URL may be empty), so the + # service's Web3 can be None; falling back keeps every submission + # decodable instead of failing the whole RPC boundary. + self.web3 = consensus_service.web3 or Web3ConnectionPool.get_for_utilities() def decode_signed_transaction( self, raw_transaction: str @@ -163,7 +397,15 @@ def _to_int(value: bytes) -> int: else: to_address = None nonce = signed_transaction_as_dict["nonce"] + chain_id = signed_transaction_as_dict.get("chainId") + if chain_id is None and signed_transaction_as_dict.get("v") is not None: + raw_v = signed_transaction_as_dict["v"] + if isinstance(raw_v, (bytes, bytearray, HexBytes)): + raw_v = int.from_bytes(raw_v, byteorder="big") + chain_id, _ = extract_chain_id(int(raw_v)) 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") @@ -178,6 +420,7 @@ def _to_int(value: bytes) -> int: data = input_raw else: data = None + raw_data = f"0x{data.removeprefix('0x')}" if data else None decoded_data = None contract_abi = self._get_contract_abi() if data and contract_abi: @@ -192,7 +435,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 +443,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,26 +463,55 @@ 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"], + expected_decision_id=params.get( + "_expectedDecisionId" + ), + ) + 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"], + expected_decision_id=params.get( + "_expectedDecisionId" + ), + fees_distribution=self._fees_distribution_to_dict( + params["_feesDistribution"] + ), + top_up_and_submit=True, + ) + fee_value = int(value) + value = 0 + elif decoded_data["function"] == "finalizeTransaction": + params = decoded_data["params"] + decoded_data = DecodedFinalizeTransactionDataArgs( + tx_id=params["_txId"], + expected_decision_id=params.get( + "_expectedDecisionId" + ), ) return DecodedRollupTransaction( @@ -248,6 +521,10 @@ 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, + raw_data=raw_data, + chain_id=int(chain_id) if chain_id is not None else None, ) except Exception as e: @@ -320,7 +597,15 @@ def get_genlayer_transaction( ) sender = rollup_transaction.data.args.sender - recipient = rollup_transaction.data.args.recipient + # The v0.6 deploySalted selector is authoritative: Consensus ignores + # the tuple recipient and always passes address(0) into CreationPhase. + # Inferring only from the user-controlled recipient let a salted deploy + # be misclassified as a normal contract call in Studio. + recipient = ( + ZERO_ADDRESS + if rollup_transaction.data.function_name == "deploySalted" + else rollup_transaction.data.args.recipient + ) max_rotations = rollup_transaction.data.args.max_rotations type = self._get_genlayer_transaction_type(recipient) data = self._get_genlayer_transaction_data(type, rollup_transaction.data.args) @@ -412,17 +697,21 @@ def decode_method_send_data(self, data: str) -> DecodedMethodSendData: def decode_method_call_data(self, data: str) -> DecodedMethodCallData: raw_bytes = eth_utils.hexadecimal.decode_hex(data) - # Remove the null byte - if raw_bytes[-1] == 0: + # Newer clients send rlp([calldata, leader_only]). The boolean is a + # single literal byte in this encoding, for both false and true. + if len(raw_bytes) > 1 and raw_bytes[-1] in (0, 1): raw_bytes = raw_bytes[:-1] - # Try to decode the outer list first - if raw_bytes[0] >= 0xF8: # Long list - raw_bytes = raw_bytes[2:] # Skip list prefix and length - elif raw_bytes[0] >= 0xC0: # Short list + # Strip the complete outer list header. For a long list the prefix + # encodes the number of following length bytes; the header grows + # from two to three bytes once the payload reaches 256 bytes. + prefix = raw_bytes[0] + if prefix >= 0xF8: + raw_bytes = raw_bytes[1 + (prefix - 0xF7) :] + elif prefix >= 0xC0: # Short list raw_bytes = raw_bytes[1:] # Skip list prefix - # Now try to decode the inner string + # Now decode the inner calldata string. raw_bytes = rlp.decode(raw_bytes) return DecodedMethodCallData(raw_bytes) @@ -484,7 +773,102 @@ 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_SUBMIT_APPEAL_ABI, + FEE_AWARE_LEGACY_SUBMIT_APPEAL_ABI, + FEE_AWARE_TOP_UP_FEES_ABI, + FEE_AWARE_TOP_UP_AND_SUBMIT_APPEAL_ABI, + FEE_AWARE_LEGACY_TOP_UP_AND_SUBMIT_APPEAL_ABI, + FEE_AWARE_FINALIZE_TRANSACTION_ABI, + FEE_AWARE_LEGACY_FINALIZE_TRANSACTION_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..fef192271 100644 --- a/backend/protocol_rpc/types.py +++ b/backend/protocol_rpc/types.py @@ -29,7 +29,22 @@ def to_json(self) -> dict[str]: @dataclass class DecodedsubmitAppealDataArgs: - tx_id: str + tx_id: str | bytes + expected_decision_id: int | None = None + fees_distribution: dict | None = None + top_up_and_submit: bool = False + + +@dataclass +class DecodedTopUpFeesDataArgs: + tx_id: str | bytes + fees_distribution: dict + + +@dataclass +class DecodedFinalizeTransactionDataArgs: + tx_id: str | bytes + expected_decision_id: int | None = None @dataclass @@ -39,6 +54,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 +72,26 @@ class DecodedRollupTransactionData: class DecodedRollupTransaction: from_address: str to_address: str - data: DecodedRollupTransactionData | DecodedsubmitAppealDataArgs + data: ( + DecodedRollupTransactionData + | DecodedsubmitAppealDataArgs + | DecodedTopUpFeesDataArgs + | DecodedFinalizeTransactionDataArgs + | None + ) type: str nonce: int value: int + fee_value: int = 0 + submitted_value: int | None = None + raw_data: str | None = None + chain_id: 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/rollup/consensus_service.py b/backend/rollup/consensus_service.py index dd52e7847..eef5de084 100644 --- a/backend/rollup/consensus_service.py +++ b/backend/rollup/consensus_service.py @@ -10,6 +10,14 @@ ) from backend.rollup.web3_pool import Web3ConnectionPool +FEE_AWARE_SHADOW_SELECTORS = { + bytes.fromhex("35a251fb"), + bytes.fromhex("98863702"), +} +LEGACY_ADD_TRANSACTION_SELECTOR = keccak( + text="addTransaction(address,address,uint256,uint256,bytes)" +)[:4] + class ConsensusService: def __init__(self): @@ -19,6 +27,17 @@ def __init__(self): # Use singleton Web3 connection pool self.web3 = Web3ConnectionPool.get() + @staticmethod + def public_consensus_main_address() -> str: + """Return Studio's user-facing virtual ConsensusMain address. + + A connected Hardhat helper may be deployed at a different ephemeral + address. That helper is an internal shadow only; signed client + envelopes continue to target the stable address exported by the SDK. + """ + + return str(get_default_consensus_main_contract()["address"]) + def _get_contract(self, contract_name: str): """ Get a contract instance @@ -152,6 +171,81 @@ def forward_transaction(self, transaction: str | HexBytes) -> dict: receipt = self.web3.eth.wait_for_transaction_receipt(tx_hash) return receipt + @staticmethod + def _bind_shadow_sender_calldata( + calldata: str | HexBytes, + authoritative_sender: str | None, + ) -> str | HexBytes: + """Replace the user-controlled sender word with the recovered signer. + + Consensus' CreationPhase ignores a spoofed ``params.sender`` on an + ordinary EOA submission and attributes the transaction to the direct + caller. Studio relays through a funded Hardhat account, so the shadow + helper cannot derive that caller itself and must receive the already- + verified signer from the RPC boundary. + """ + + if authoritative_sender is None: + return calldata + payload = bytes(HexBytes(calldata)) + if len(payload) < 4: + return calldata + selector = payload[:4] + if selector in FEE_AWARE_SHADOW_SELECTORS: + sender_offset = 4 + 32 # selector + dynamic tuple offset word + elif selector == LEGACY_ADD_TRANSACTION_SELECTOR: + sender_offset = 4 + else: + return calldata + if len(payload) < sender_offset + 32: + raise RuntimeError("InvalidShadowTransactionEncoding") + sender = to_bytes(hexstr=authoritative_sender) + if len(sender) != 20: + raise RuntimeError("InvalidShadowTransactionSender") + bound = ( + payload[:sender_offset] + + (b"\x00" * 12) + + sender + + payload[sender_offset + 32 :] + ) + return to_hex(bound) + + def forward_shadow_transaction( + self, + calldata: str | HexBytes, + authoritative_sender: str | None = None, + ) -> dict: + """Submit verified user calldata through Hardhat's funded system account. + + Studio account balances and nonces live in Postgres, not in the helper + EVM. Replaying the user's raw signed envelope against Hardhat therefore + fails for ordinary Studio accounts and previously caused deployments + to fall back to an unrelated random address. The RPC layer has already + recovered the signer and validated the complete fee envelope; this + shadow call exists only to let the protocol helper author the child id + and CREATE/CREATE2 recipient. + """ + + consensus_main = self._get_contract("ConsensusMain") + if consensus_main is None: + raise RuntimeError("ConsensusMainUnavailable") + accounts = self.web3.eth.accounts + if not accounts: + raise RuntimeError("ConsensusShadowAccountUnavailable") + bound_calldata = self._bind_shadow_sender_calldata( + calldata, + authoritative_sender, + ) + tx_hash = self.web3.eth.send_transaction( + { + "from": accounts[0], + "to": consensus_main.address, + "data": bound_calldata, + "value": 0, + } + ) + return self.web3.eth.wait_for_transaction_receipt(tx_hash) + def wait_new_transaction_event(self, receipt: dict) -> dict: """ Wait for NewTransaction event from receipt @@ -184,20 +278,31 @@ def wait_new_transaction_event(self, receipt: dict) -> dict: return receipt def add_transaction( - self, transaction: dict, from_address: str, retry: bool = True - ) -> str: + self, + transaction: dict, + from_address: str, + retry: bool = True, + calldata: str | HexBytes | None = None, + ) -> Dict[str, Any] | None: """ Forward a transaction to the consensus rollup and wait for NewTransaction event """ - if not self.web3.is_connected(): + if self.web3 is None or not self.web3.is_connected(): # print( # "[CONSENSUS_SERVICE]: Not connected to Hardhat node, skipping transaction forwarding" # ) return None try: - receipt = self.forward_transaction(transaction) - return self.wait_new_transaction_event(receipt) + receipt = ( + self.forward_shadow_transaction(calldata, from_address) + if calldata is not None + else self.forward_transaction(transaction) + ) + details = self.wait_new_transaction_event(receipt) + if not isinstance(details, dict) or "tx_id" not in details: + raise RuntimeError("NewTransactionEventMissing") + return details except Exception as e: error_str = str(e) @@ -224,7 +329,10 @@ def add_transaction( if retry: return self.add_transaction( - transaction, from_address, retry=False + transaction, + from_address, + retry=False, + calldata=calldata, ) else: print( @@ -234,6 +342,19 @@ def add_transaction( print(f"[CONSENSUS_SERVICE]: Error forwarding transaction: {error_str}") return None + def transaction_forwarding_skipped(self, account: dict) -> bool: + """Reports whether emit_transaction_event is a deliberate no-op. + + Studio runs in deployments that have no rollup at all — the load-test + compose brings up only jsonrpc + consensus-worker, and hosted Studio + may hold an account with no private key. In both modes + emit_transaction_event returns None *by design*, which callers must + not confuse with a forwarding failure (which also returns None). + """ + if self.web3 is None or not self.web3.is_connected(): + return True + return account.get("private_key") is None + def emit_transaction_event(self, event_name: str, account: dict, *args): """ Generic method to emit transaction events @@ -243,20 +364,15 @@ def emit_transaction_event(self, event_name: str, account: dict, *args): account (dict): Account object containing address and private key *args: Arguments to pass to the event function """ - if not self.web3.is_connected(): - # print( - # "[CONSENSUS_SERVICE]: Not connected to Hardhat node, skipping transaction forwarding" - # ) + if self.transaction_forwarding_skipped(account): + if self.web3 is not None and self.web3.is_connected(): + print( + f"[CONSENSUS_SERVICE]: Error emitting {event_name}: Account object must contain private_key" + ) return None - if account.get("private_key") is not None: - account_address = account["address"] - account_private_key = account["private_key"] - else: - print( - f"[CONSENSUS_SERVICE]: Error emitting {event_name}: Account object must contain private_key" - ) - return None + account_address = account["address"] + account_private_key = account["private_key"] consensus_main_contract = self._get_contract("ConsensusMain") @@ -292,15 +408,53 @@ def emit_transaction_event(self, event_name: str, account: dict, *args): ) tx_ids_hex = [] + recipients = [] for new_tx_event in new_tx_events: tx_id = new_tx_event["args"]["txId"] tx_ids_hex.append( "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id ) + recipients.append(new_tx_event["args"]["recipient"]) + + # The Studio helper bridge makes each parent/phase/payload + # emission one-shot. A worker retry emits no new events, but + # it can recover the exact child ids stored by the first call. + # Always prefer that durable view when the deployed helper + # exposes it; the event-derived list remains compatible with + # older local deployments. + try: + stored_tx_ids = ( + consensus_main_contract.functions.getInternalMessageTxIds( + args[0], + event_name == "emitTransactionAccepted", + args[1], + ).call() + ) + tx_ids_hex = [ + "0x" + tx_id.hex() if isinstance(tx_id, bytes) else tx_id + for tx_id in stored_tx_ids + ] + except Exception: + pass + + # Deploy messages intentionally submit recipient zero. The + # helper returns the actual CREATE/CREATE2 ghost address, and + # retries recover that same address from durable storage. + try: + recipients = list( + consensus_main_contract.functions.getInternalMessageRecipients( + args[0], + event_name == "emitTransactionAccepted", + args[1], + ).call() + ) + except Exception: + pass return { "receipt": receipt, "tx_ids_hex": tx_ids_hex, + "recipients": recipients, } return receipt diff --git a/backend/rollup/default_contracts/consensus_main.py b/backend/rollup/default_contracts/consensus_main.py index 5ece85478..aba4346bd 100644 --- a/backend/rollup/default_contracts/consensus_main.py +++ b/backend/rollup/default_contracts/consensus_main.py @@ -2,7 +2,7 @@ import json DEFAULT_CONSENSUS_MAIN_ADDRESS = os.environ.get( - "DEFAULT_CONSENSUS_MAIN_ADDRESS", "0x0000000000000000000000000000000000000000" + "DEFAULT_CONSENSUS_MAIN_ADDRESS", "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" ) DEFAULT_CONSENSUS_MAIN_ABI = """[ { @@ -1332,9 +1332,230 @@ ]""" +_FEES_DISTRIBUTION_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"}, +] + +_MESSAGE_ALLOCATION_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"}, +] + +_ADD_TRANSACTION_PARAMS_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": _FEES_DISTRIBUTION_COMPONENTS, + "internalType": "struct IFeeManager.FeesDistribution", + "name": "feesDistribution", + "type": "tuple", + }, + {"internalType": "bytes", "name": "txCalldata", "type": "bytes"}, + { + "components": _MESSAGE_ALLOCATION_COMPONENTS, + "internalType": "struct IMessages.MessageFeeAllocationNode[]", + "name": "messageAllocations", + "type": "tuple[]", + }, +] + +_STUDIO_TRAIN_TRANSACTION_ABI = [ + { + "inputs": [ + { + "components": _ADD_TRANSACTION_PARAMS_COMPONENTS, + "internalType": "struct IConsensusMainWithFees.AddTransactionParams", + "name": "_params", + "type": "tuple", + } + ], + "name": function_name, + "outputs": [], + "stateMutability": "payable", + "type": "function", + } + for function_name in ("addTransaction", "deploySalted") +] + [ + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "components": _FEES_DISTRIBUTION_COMPONENTS, + "internalType": "struct IFeeManager.FeesDistribution", + "name": "_feesDistribution", + "type": "tuple", + }, + ], + "name": "topUpFees", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + { + "components": _FEES_DISTRIBUTION_COMPONENTS, + "internalType": "struct IFeeManager.FeesDistribution", + "name": "_feesDistribution", + "type": "tuple", + }, + ], + "name": "topUpAndSubmitAppeal", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "submitAppeal", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "bytes32", "name": "_txId", "type": "bytes32"}, + { + "internalType": "uint256", + "name": "_expectedDecisionId", + "type": "uint256", + }, + ], + "name": "finalizeTransaction", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, +] + +_STUDIO_TRAIN_EVENT_ABI = [ + { + "anonymous": False, + "inputs": [ + { + "indexed": True, + "internalType": "bytes32", + "name": "txId", + "type": "bytes32", + }, + { + "indexed": False, + "internalType": "uint256", + "name": "txSlot", + "type": "uint256", + }, + ], + "name": "CreatedTransaction", + "type": "event", + } +] + + +def _abi_function_signature(entry: dict) -> str: + """Canonical ``name(type,...)`` signature, i.e. the 4-byte selector identity.""" + + def canonical(component: dict) -> str: + component_type = component["type"] + if not component_type.startswith("tuple"): + return component_type + inner = ",".join( + canonical(nested) for nested in component.get("components", []) + ) + return f"({inner}){component_type[len('tuple'):]}" + + inputs = ",".join(canonical(entry_input) for entry_input in entry.get("inputs", [])) + return f"{entry['name']}({inputs})" + + +# Names kept at their pre-fee signature on the *served* surface. +# +# This ABI has two consumers with opposite requirements: +# +# * Decoding. TransactionParser matches an incoming selector against +# this ABI plus the FEE_AWARE_* overloads it appends itself, so it wants +# every generation of every entrypoint. That union lives in +# TransactionParser._get_contract_abi, not here. +# * Serving. sim_getConsensusContract hands this ABI to clients, and +# genlayer-py resolves entrypoints with web3's get_function_by_name(), +# which raises Web3ValueError on an overloaded name. The served surface +# must therefore be name-unique, and each shared name must carry the +# generation its callers actually encode. +# +# addTransaction is the one name where those callers disagree. Every client +# that reaches Studio through sim_getConsensusContract — released genlayer-py +# (so gltest and the load tests) and the train genlayer-py alike — builds five +# positional arguments and encodes them against contract_fn.argument_types. +# The single-tuple fee-aware form makes that raise, and it is also not a +# function Studio's deployed ConsensusMain.sol exposes. Train clients detect +# the pre-fee shape and fall back to the same five-argument path, so serving +# it satisfies both generations; the fee-aware form stays decodable through +# FEE_AWARE_ADD_TRANSACTION_ABI. +# +# submitAppeal and finalizeTransaction keep their v0.6 decision-bound form: +# no Studio client encodes them from the served ABI (genlayer-js embeds its +# own train ABI, and released genlayer-py's appeal path is unreachable here), +# while the train genlayer-py requires the DecisionId argument. +_SERVED_PRE_FEE_FUNCTION_NAMES = frozenset({"addTransaction"}) + + def get_default_consensus_main_contract(): + abi = json.loads(DEFAULT_CONSENSUS_MAIN_ABI) + served_train_abi = [ + entry + for entry in _STUDIO_TRAIN_TRANSACTION_ABI + if entry["name"] not in _SERVED_PRE_FEE_FUNCTION_NAMES + ] + served_train_names = {entry["name"] for entry in served_train_abi} + abi = [ + entry + for entry in abi + if entry.get("type") != "function" + or entry.get("name") not in served_train_names + ] + abi.extend(served_train_abi) + abi.extend(_STUDIO_TRAIN_EVENT_ABI) return { "address": DEFAULT_CONSENSUS_MAIN_ADDRESS, - "abi": json.loads(DEFAULT_CONSENSUS_MAIN_ABI), + "abi": abi, "bytecode": "0x", } diff --git a/backend/rollup/web3_pool.py b/backend/rollup/web3_pool.py index 44312550c..56a9c427e 100644 --- a/backend/rollup/web3_pool.py +++ b/backend/rollup/web3_pool.py @@ -18,6 +18,27 @@ class Web3ConnectionPool: _web3: ClassVar[Web3 | None] = None _lock: ClassVar[threading.Lock] = threading.Lock() _session: ClassVar[requests.Session | None] = None + _offline: ClassVar[Web3 | None] = None + + @classmethod + def get_for_utilities(cls) -> Web3: + """A Web3 usable for pure helpers even with no rollup configured. + + HARDHAT_URL is optional — Studio runs without the legacy Hardhat + bridge — so ``get()`` legitimately returns None. Hashing, ABI codec + and checksum helpers still need a Web3 object and none of them touch + the network, so they fall back to a provider-less instance rather + than dereferencing None. Callers that actually reach the chain must + keep using ``get()`` and handle None themselves. + """ + web3 = cls.get() + if web3 is not None: + return web3 + if cls._offline is None: + with cls._lock: + if cls._offline is None: + cls._offline = Web3() + return cls._offline @classmethod def get(cls) -> Web3 | None: 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..87620f8e3 100644 --- a/backend/validators/__init__.py +++ b/backend/validators/__init__.py @@ -1,9 +1,11 @@ -__all__ = ("Manager", "with_lock", "select_random_different_validator") +__all__ = ("Manager", "select_random_different_validator") import asyncio import typing import contextlib import dataclasses +import hashlib +import json import os import random @@ -80,6 +82,7 @@ def __init__(self, parent: "Manager", *args, **kwargs): self._parent = parent super().__init__(*args, **kwargs) + @vr.rollback_on_error async def create_validator(self, validator: vr.Validator) -> dict: async with self._parent.do_write(): res = await super().create_validator(validator) @@ -87,6 +90,7 @@ async def create_validator(self, validator: vr.Validator) -> dict: await self._parent._notify_validator_change("validator_created", res) return res + @vr.rollback_on_error async def update_validator( self, new_validator: vr.Validator, @@ -97,6 +101,7 @@ async def update_validator( await self._parent._notify_validator_change("validator_updated", res) return res + @vr.rollback_on_error async def delete_validator(self, validator_address): async with self._parent.do_write(): res = await super().delete_validator(validator_address) @@ -106,6 +111,7 @@ async def delete_validator(self, validator_address): ) return res + @vr.rollback_on_error async def delete_all_validators(self): async with self._parent.do_write(): res = await super().delete_all_validators() @@ -113,6 +119,7 @@ async def delete_all_validators(self): await self._parent._notify_validator_change("all_validators_deleted", {}) return res + @vr.rollback_on_error async def batch_create_validators( self, validators: list[vr.Validator] ) -> list[dict]: @@ -125,6 +132,7 @@ async def batch_create_validators( ) return res + @vr.rollback_on_error async def replace_all_validators( self, validators: list[vr.Validator] ) -> list[dict]: @@ -152,6 +160,34 @@ class Snapshot: from backend.node.base import LLMConfig, Manager as GenVMManager +def _registry_fingerprint(validators: list[dict]) -> str: + """Return a stable identity for the validator configuration GenVM consumes.""" + + relevant_fields = ( + "address", + "stake", + "provider", + "model", + "config", + "plugin", + "plugin_config", + ) + canonical = [ + {field: validator.get(field) for field in relevant_fields} + for validator in sorted( + validators, + key=lambda validator: str(validator.get("address") or "").lower(), + ) + ] + encoded = json.dumps( + canonical, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode() + return hashlib.sha256(encoded).hexdigest() + + class Manager: registry: vr.ModifiableValidatorsRegistry @@ -161,6 +197,7 @@ def __init__( self.genvm_manager = genvm_manager self._cached_snapshot = None + self._cached_registry_fingerprint: str | None = None self.registry = ModifiableValidatorsRegistryInterceptor( self, validators_registry_session @@ -172,12 +209,30 @@ async def restart(self): # creates the general Snapshot with: # - SingleValidatorSnapshot (validator, genvm_host_data) # - the genvm_config_path - new_validators = await self._get_snap_from_registry() - # Registers all the validators providers and models to the LLM module - await self._change_providers_from_snapshot(new_validators) - - async def _get_snap_from_registry(self) -> Snapshot: - cur_validators_as_dict = self.registry.get_all_validators() + async with self._restart_llm_lock: + # Read inside the lock. A delayed event must not overwrite a newer + # snapshot that another restart loaded while this coroutine waited. + registry_validators = self.registry.get_all_validators() + registry_fingerprint = _registry_fingerprint(registry_validators) + # Registers all the validators providers and models to the LLM + # module. Redis delivers one notification per mutation and those + # notifications can lag behind the database. Avoid stopping a + # healthy module for a state that is already loaded. + if ( + self._cached_snapshot is not None + and self._cached_registry_fingerprint == registry_fingerprint + ): + logger.info("Validator registry is already loaded; skipping restart") + return + new_validators = await self._get_snap_from_registry(registry_validators) + await self._change_providers_from_snapshot_locked(new_validators) + self._cached_registry_fingerprint = registry_fingerprint + + async def _get_snap_from_registry( + self, cur_validators_as_dict: list[dict] | None = None + ) -> Snapshot: + if cur_validators_as_dict is None: + cur_validators_as_dict = self.registry.get_all_validators() logger.info( f"ValidatorManager retrieved {len(cur_validators_as_dict)} validators from registry", ) @@ -252,6 +307,17 @@ async def snapshot(self): "Validators manager snapshot not initialized. " "Ensure restart() was called successfully." ) + # Validator events are asynchronous across RPC and consensus + # processes. The database commit is authoritative, so verify the + # entire cached configuration before freezing a transaction's + # selection pool. Checking only an empty cache misses partial + # wipe/recreate states (for example a cached 4-of-5 pool). + registry_validators = self.registry.get_all_validators() + registry_fingerprint = _registry_fingerprint(registry_validators) + if self._cached_registry_fingerprint != registry_fingerprint: + fresh = await self._get_snap_from_registry(registry_validators) + await self._change_providers_from_snapshot_locked(fresh) + self._cached_registry_fingerprint = registry_fingerprint snap = deepcopy(self._cached_snapshot) yield snap @@ -329,9 +395,7 @@ async def _change_providers_from_snapshot_locked(self, snap: Snapshot): @contextlib.asynccontextmanager async def do_write(self): yield - - new_validators = await self._get_snap_from_registry() - await self._change_providers_from_snapshot(new_validators) + await self.restart() async def _notify_validator_change(self, event_type: str, data: dict): """ 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..e838d06f2 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,40 @@ 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} - # 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:-} + - RATE_LIMIT_READ_MULTIPLIER=${RATE_LIMIT_READ_MULTIPLIER:-10} + - GENLAYER_STUDIO_GEN_PER_TIME_UNIT=${GENLAYER_STUDIO_GEN_PER_TIME_UNIT:-1} + - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-250000000} + - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-250000000} + - GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS=${GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS:-1500} + - GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT:-0} + - GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT:-500000} + - GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES=${GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES:-20} + - GENLAYER_STUDIO_MAX_MESSAGES_PER_TX=${GENLAYER_STUDIO_MAX_MESSAGES_PER_TX:-0} + - GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES=${GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES:-0} + - GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES=${GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES:-0} + - 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} + - GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS=${GENLAYER_STUDIO_GHOST_FACTORY_ADDRESS:-0x130e09c996462963A6398CA04e1011e6ef9d68a6} + - GENLAYER_STUDIO_GHOST_BYTECODE_HASH=${GENLAYER_STUDIO_GHOST_BYTECODE_HASH:-0x4249189346902cb6f0afc196d5719e1b4cbc861a700ae3b0c6dcbe51e5e566de} + - GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE=${GENLAYER_STUDIO_GHOST_FACTORY_INITIAL_NONCE:-2} + # Per-recipient defaults to Consensus Queues.maxPendingTxsPerRecipient + # (20). The Studio-only per-sender guard remains optional. + - MAX_PENDING_PER_CONTRACT_DEFAULT=${MAX_PENDING_PER_CONTRACT_DEFAULT:-20} - 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: @@ -197,7 +226,7 @@ services: traefik.http.routers.jsonrpc-health.service: jsonrpc webdriver: - image: yeagerai/genlayer-genvm-webdriver:0.0.9 + image: yeagerai/genlayer-genvm-webdriver:0.0.12 shm_size: 2gb environment: - PORT=${WEBDRIVERPORT:-4444} @@ -228,7 +257,7 @@ services: profiles: ["ollama"] image: ollama/ollama:0.17.6 ports: - - 11434:11434 + - "127.0.0.1:11434:11434" volumes: - ./.ollama:/root/.ollama container_name: ollama @@ -246,7 +275,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" + - "127.0.0.1:${DBHOSTPORT:-5432}:5432" environment: - POSTGRES_USER=${DBUSER} - POSTGRES_PASSWORD=${DBPASSWORD} @@ -287,8 +316,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 +337,27 @@ 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:-1} + - GENLAYER_STUDIO_STORAGE_UNIT_PRICE=${GENLAYER_STUDIO_STORAGE_UNIT_PRICE:-250000000} + - GENLAYER_STUDIO_RECEIPT_GAS_PRICE=${GENLAYER_STUDIO_RECEIPT_GAS_PRICE:-250000000} + - GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS=${GENLAYER_STUDIO_TIME_UNIT_OVERLAY_BPS:-1500} + - GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MIN_PROPOSE_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS=${GENLAYER_STUDIO_MAX_PROPOSE_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MIN_COMMIT_TIMEUNITS:-30} + - GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS=${GENLAYER_STUDIO_MAX_COMMIT_TIMEUNITS:-600} + - GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_MIN_EXTERNAL_GAS_LIMIT:-0} + - GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT=${GENLAYER_STUDIO_DEFAULT_EXTERNAL_GAS_LIMIT:-500000} + - GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES=${GENLAYER_STUDIO_MAX_ALLOCATED_MESSAGES:-20} + - GENLAYER_STUDIO_MAX_MESSAGES_PER_TX=${GENLAYER_STUDIO_MAX_MESSAGES_PER_TX:-0} + - GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES=${GENLAYER_STUDIO_MAX_EQ_OUTPUTS_BYTES:-0} + - GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES=${GENLAYER_STUDIO_MAX_SUBMITTED_MESSAGES_BYTES:-0} + - 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 +429,7 @@ services: redis: image: redis:8-alpine ports: - - "6379:6379" + - "127.0.0.1:${REDISPORT:-6379}:6379" volumes: - redis_data:/data healthcheck: @@ -422,5 +476,15 @@ 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 an empty netrc in the repo, so a checkout +# without a token still builds, just without cache hits; not /dev/null, as +# buildx bake refuses to read a file outside the project without --allow=fs.read. +# CI points it at the netrc `nix-setup` wrote. +secrets: + nix_netrc: + file: ${NIX_NETRC_FILE:-./docker/empty.netrc} diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend index 83bfe0c45..1ac1dd3d9 100644 --- a/docker/Dockerfile.backend +++ b/docker/Dockerfile.backend @@ -1,27 +1,140 @@ -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 +COPY third_party/genvm/version /genvm-default-version + +# 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 ; \ + effective_ref="$GENVM_REF" ; \ + if [[ -z "$GENVM_TAG" && -z "$effective_ref" ]]; then \ + effective_ref="$(< /genvm-default-version)" ; \ + fi ; \ + if [[ -n "$GENVM_TAG" && -n "$effective_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" && "$effective_ref" =~ ^.+:[0-9a-fA-F]{7,40}$ ) ]]; then \ + source_selected=1 ; \ + fi ; \ + if [[ "$source_selected" == "1" ]]; then \ + if [[ -z "$effective_ref" ]]; then \ + echo "ERROR: GENVM_SOURCE_MODE=source requires GENVM_REF" ; exit 1 ; \ + fi ; \ + SOURCE_REF="$effective_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 -ARG GENVM_TAG=v0.2.16 +# 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 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 +143,187 @@ 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 ; \ + effective_ref="$GENVM_REF" ; \ + release_version="$GENVM_TAG" ; \ + default_binding="$(< /genvm-default-version)" ; \ + if [[ -z "$release_version" && -z "$effective_ref" ]]; then \ + if [[ "$default_binding" =~ ^.+:[0-9a-fA-F]{7,40}$ ]]; then \ + effective_ref="$default_binding" ; \ + else \ + release_version="$default_binding" ; \ + fi ; \ + fi ; \ + if [[ -n "$release_version" && -n "$effective_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 [[ "$effective_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 "$effective_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' "$effective_ref" > /genvm/version ; \ + echo "Using GenVM built from source at $effective_ref" ; \ + else \ + if [[ -n "$effective_ref" ]]; then \ + echo "ERROR: release mode does not accept GENVM_REF; use GENVM_SOURCE_MODE=source" ; exit 1 ; \ + fi ; \ + if [[ -z "$release_version" ]]; then \ + echo "ERROR: release mode requires GENVM_TAG or a release tag in third_party/genvm/version" ; exit 1 ; \ + fi ; \ + 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/Dockerfile.frontend b/docker/Dockerfile.frontend index a977905a5..f6df457ea 100644 --- a/docker/Dockerfile.frontend +++ b/docker/Dockerfile.frontend @@ -14,6 +14,8 @@ RUN chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] FROM base AS builder +ARG VITE_APP_VERSION="" +ENV VITE_APP_VERSION=${VITE_APP_VERSION} ENV NODE_OPTIONS=--max-old-space-size=4096 RUN npm run build diff --git a/docker/empty.netrc b/docker/empty.netrc new file mode 100644 index 000000000..e69de29bb 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/entrypoint-frontend.sh b/docker/entrypoint-frontend.sh index 0db41029c..e15136b1e 100644 --- a/docker/entrypoint-frontend.sh +++ b/docker/entrypoint-frontend.sh @@ -36,7 +36,8 @@ window.__RUNTIME_CONFIG__ = { VITE_PLAUSIBLE_DOMAIN: "${VITE_PLAUSIBLE_DOMAIN:-studio.genlayer.com}", VITE_APPKIT_PROJECT_ID: "${VITE_APPKIT_PROJECT_ID:-}", VITE_EXPLORER_URL: "${VITE_EXPLORER_URL:-}", - VITE_LOCK_NETWORK: "${VITE_LOCK_NETWORK:-false}" + VITE_LOCK_NETWORK: "${VITE_LOCK_NETWORK:-false}", + VITE_APP_VERSION: "${VITE_APP_VERSION:-}" }; EOF 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..6761b4ba9 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } # 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..48f050edd 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } -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..26ec54708 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } -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..f1f73179c 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } 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: @@ -35,11 +36,11 @@ def transfer(self, amount: int, to_address: str) -> None: Update the balances based on the valid transactions only. Given the current balance in JSON format and the transaction provided, please provide the result of your calculation with the following format: -{{ +{ "transaction_success": bool, // Whether the transaction was successful "transaction_error": str, // Empty if transaction is successful "updated_balances": object // Updated balances after the transaction -}} +} It is mandatory that you respond only using the JSON format above, nothing else. Don't include any other words or characters, diff --git a/examples/contracts/log_indexer.py b/examples/contracts/log_indexer.py index 5127795ab..0952d60ec 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } # ] # } 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..4fa40adbb 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } -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..590e6182b 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } -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..78e02e011 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } -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..c4191d59b 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:babwp9t37jw9g4j2bccgn14g6x7x9s1f7dsmh7emvz735pz4zxa0" } +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..8802aad7d 100644 --- a/explorer/src/lib/types.ts +++ b/explorer/src/lib/types.ts @@ -52,6 +52,157 @@ 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 + | Record; + 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..8e3a2026a 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": "git+https://github.com/genlayerlabs/genlayer-js.git#bc1fc3203ded80bb601366412137fafbb0669fab", "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": "git+https://github.com/genlayerlabs/genlayer-js.git#bc1fc3203ded80bb601366412137fafbb0669fab", + "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", "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..e2f7e86c5 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": "git+https://github.com/genlayerlabs/genlayer-js.git#bc1fc3203ded80bb601366412137fafbb0669fab", "hash-sum": "^2.0.0", "jump.js": "^1.0.2", "lodash-es": "^4.17.21", diff --git a/frontend/src/components/Header.vue b/frontend/src/components/Header.vue index 02d09333b..0ac32fb1f 100644 --- a/frontend/src/components/Header.vue +++ b/frontend/src/components/Header.vue @@ -7,8 +7,10 @@ import Logo from '@/assets/images/logo.svg'; import GhostBtn from './global/GhostBtn.vue'; import NetworkSelector from './global/NetworkSelector.vue'; import AccountSelect from '@/components/Simulator/AccountSelect.vue'; +import { getRuntimeConfig } from '@/utils/runtimeConfig'; const uiStore = useUIStore(); +const appVersion = getRuntimeConfig('VITE_APP_VERSION'); const toggleMode = () => { uiStore.toggleMode(); @@ -23,16 +25,26 @@ const showTutorial = () => {
- - - +
+ + + + + {{ appVersion }} + +
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; + } +};