From 08c18fbde39e5bd89b57676e4d02626d5f99d2fe Mon Sep 17 00:00:00 2001 From: ozcnii Date: Sat, 29 Aug 2026 00:22:40 +0400 Subject: [PATCH] feat: harden indexing for production v1 --- .dockerignore | 1 + .env.example | 9 + .github/workflows/ci.yml | 128 ++++ .gitignore | 1 + CHANGELOG.md | 6 + Cargo.lock | 15 +- Cargo.toml | 2 +- Dockerfile | 10 +- README.md | 6 +- compose.production.yml | 70 ++ deploy/helm/parseon/.helmignore | 4 + deploy/helm/parseon/Chart.yaml | 7 + deploy/helm/parseon/NOTES.txt | 8 + deploy/helm/parseon/templates/_helpers.tpl | 24 + deploy/helm/parseon/templates/deployment.yaml | 114 ++++ deploy/helm/parseon/templates/service.yaml | 15 + deploy/helm/parseon/values.yaml | 45 ++ deploy/monitoring/parseon-alerts.yaml | 63 ++ deploy/monitoring/parseon-dashboard.json | 42 ++ deploy/monitoring/prometheus.yml | 11 + docs/adapters.md | 9 +- docs/operations.md | 199 ++++++ erpc.yaml | 16 - parseon-core/benches/worker_pipeline.rs | 9 +- parseon-core/src/commands.rs | 5 +- parseon-core/src/indexer.rs | 39 +- parseon-core/src/lib.rs | 6 +- parseon-core/src/model.rs | 45 +- parseon-core/src/ports/cache.rs | 4 + parseon-core/src/ports/mod.rs | 8 +- parseon-core/src/ports/source.rs | 29 +- parseon-core/src/ports/storage.rs | 85 ++- parseon-core/src/ports/telemetry.rs | 6 + parseon-core/src/status.rs | 87 +++ parseon-core/src/supervisor.rs | 71 +- parseon-core/src/testkit.rs | 49 +- parseon-core/src/views.rs | 48 +- parseon-core/src/worker/commit.rs | 24 +- parseon-core/src/worker/mod.rs | 221 ++++++- parseon-core/src/worker/prepare.rs | 35 +- parseon-core/src/worker/tests.rs | 319 ++++++++- parseon-memory-cache/src/lib.rs | 23 +- parseon-mongodb/Cargo.toml | 1 + parseon-mongodb/src/storage.rs | 626 ++++++++++++++++-- parseon-postgres/src/dyn_table.rs | 164 ++++- .../20260101000014_canonical_blocks.sql | 30 + ...000015_drop_redundant_block_hash_index.sql | 4 + parseon-postgres/src/storage.rs | 264 +++++++- parseon-rpc/src/fetch.rs | 76 ++- parseon-rpc/src/provider.rs | 187 +++++- parseon-rpc/src/transport.rs | 36 +- parseon-server/Cargo.toml | 2 +- parseon-server/src/api/dto.rs | 93 ++- parseon-server/src/api/handlers.rs | 89 ++- parseon-server/src/api/mod.rs | 160 ++++- parseon-server/src/api/openapi.rs | 21 +- parseon-server/src/api/routes.rs | 1 + parseon-server/src/config.rs | 64 +- parseon-server/src/main.rs | 14 +- parseon-server/src/metrics.rs | 46 ++ scripts/backup_postgres.sh | 21 + scripts/gen_erpc.py | 88 ++- scripts/restore_postgres.sh | 18 + 63 files changed, 3590 insertions(+), 333 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 compose.production.yml create mode 100644 deploy/helm/parseon/.helmignore create mode 100644 deploy/helm/parseon/Chart.yaml create mode 100644 deploy/helm/parseon/NOTES.txt create mode 100644 deploy/helm/parseon/templates/_helpers.tpl create mode 100644 deploy/helm/parseon/templates/deployment.yaml create mode 100644 deploy/helm/parseon/templates/service.yaml create mode 100644 deploy/helm/parseon/values.yaml create mode 100644 deploy/monitoring/parseon-alerts.yaml create mode 100644 deploy/monitoring/parseon-dashboard.json create mode 100644 deploy/monitoring/prometheus.yml create mode 100644 docs/operations.md create mode 100644 parseon-postgres/src/migrations/20260101000014_canonical_blocks.sql create mode 100644 parseon-postgres/src/migrations/20260101000015_drop_redundant_block_hash_index.sql create mode 100755 scripts/backup_postgres.sh create mode 100755 scripts/restore_postgres.sh diff --git a/.dockerignore b/.dockerignore index 07daeed..85ffafd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ target/ .env .env.example .dockerignore +.temp/ Dockerfile docker-compose.yml erpc.yaml diff --git a/.env.example b/.env.example index d3bb8e6..28c516e 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,14 @@ +# Required bearer token for admin/data API routes. +API_TOKEN=replace-with-a-long-random-token # Default `postgres-storage` build. A `mongodb-storage` build can instead use # mongodb://localhost:27017/?replicaSet=rs0 and STORAGE_DATABASE=parseon. STORAGE_URL=postgres://postgres:postgres@localhost:5432/parseon # STORAGE_DATABASE=parseon HTTP_LISTEN=0.0.0.0:8080 RUST_LOG=info,parseon=debug +# Comma-separated browser origins; empty keeps CORS disabled. +CORS_ORIGINS= +MAX_BODY_BYTES=1048576 POLL_INTERVAL_MS=2000 DEFAULT_BATCH_SIZE=10 # Set to 0 to disable Parseon's in-memory block cache. @@ -11,7 +16,11 @@ BLOCK_CACHE_SIZE=512 BLOCK_CONCURRENCY=4 RPC_REQUEST_CONCURRENCY=16 STORAGE_WRITE_CONCURRENCY=4 +CONFIRMATION_DEPTH=64 +ROLLBACK_RETENTION=256 RPC_BATCH_SIZE=20 +# Only enable for local RPC containers; keep false in production. +ALLOW_PRIVATE_RPC_NETWORKS=false # Required only when built with `webhook-sink`. # WEBHOOK_URL=http://localhost:9000/parseon # WEBHOOK_CONCURRENCY=16 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c77b78d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,128 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fmt: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Rust toolchain + run: rustup toolchain install 1.96.0 --profile minimal && rustup component add rustfmt --toolchain 1.96.0 + - run: cargo +1.96.0 fmt --all -- --check + + rust: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: postgres + features: parseon-server/postgres-storage + - name: postgres-webhook + features: parseon-server/postgres-storage,parseon-server/webhook-sink + - name: mongodb + features: parseon-server/mongodb-storage + - name: mongodb-webhook + features: parseon-server/mongodb-storage,parseon-server/webhook-sink + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Rust toolchain + run: rustup toolchain install 1.96.0 --profile minimal && rustup component add clippy --toolchain 1.96.0 + - run: cargo +1.96.0 clippy -q --workspace --all-targets --locked --no-default-features --features "${{ matrix.features }}" --message-format=short -- -D warnings + - run: cargo +1.96.0 test -q --workspace --all-targets --locked --no-default-features --features "${{ matrix.features }}" --message-format=short + - run: cargo +1.96.0 build -q -p parseon-server --release --locked --no-default-features --features "${{ matrix.features }}" + + docker: + runs-on: ubuntu-24.04 + strategy: + matrix: + include: + - name: postgres + features: postgres-storage + - name: postgres-webhook + features: postgres-storage,webhook-sink + - name: mongodb + features: mongodb-storage + - name: mongodb-webhook + features: mongodb-storage,webhook-sink + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Build image + run: docker build --build-arg PARSEON_FEATURES='${{ matrix.features }}' --tag parseon:ci . + - name: Generate SBOM when Docker SBOM support is installed + shell: bash + run: | + if docker sbom --help >/dev/null 2>&1; then + docker sbom parseon:ci > "sbom-${{ matrix.name }}.spdx.json" + else + echo 'docker sbom is unavailable on this runner; image SBOM is delegated to the registry scanner.' + fi + - name: Scan image when Docker Scout support is installed + shell: bash + run: | + if docker scout version >/dev/null 2>&1; then + docker scout cves --only-fixed parseon:ci + else + echo 'docker scout is unavailable on this runner; image scanning is delegated to the registry.' + fi + + release-gates: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Validate production Compose + shell: bash + run: | + export PARSEON_IMAGE=registry.example/parseon@sha256:$(printf 'a%.0s' {1..64}) + export STORAGE_URL='postgres://postgres:5432/parseon' + export POSTGRES_PASSWORD=test + export API_TOKEN=test-token + docker compose -f compose.production.yml config >/dev/null + if [[ ! "$PARSEON_IMAGE" =~ @sha256:[0-9a-fA-F]{64}$ ]]; then + echo 'PARSEON_IMAGE must be digest-pinned' >&2 + exit 1 + fi + - name: Validate Helm chart and immutable deployment policy + shell: bash + run: | + digest="sha256:$(printf 'a%.0s' {1..64})" + docker run --rm -v "$GITHUB_WORKSPACE:/work" -w /work alpine/helm:3.17@sha256:d899e6316789fec04ee95300a18e454b7942539cbb3d89bde3e0655d6ca2e895 lint deploy/helm/parseon --set image.digest="$digest" + docker run --rm -v "$GITHUB_WORKSPACE:/work" -w /work alpine/helm:3.17@sha256:d899e6316789fec04ee95300a18e454b7942539cbb3d89bde3e0655d6ca2e895 template parseon deploy/helm/parseon --set image.digest="$digest" >/dev/null + if docker run --rm -v "$GITHUB_WORKSPACE:/work" -w /work alpine/helm:3.17@sha256:d899e6316789fec04ee95300a18e454b7942539cbb3d89bde3e0655d6ca2e895 template parseon deploy/helm/parseon --set image.digest="$digest" --set replicaCount=2 >/dev/null 2>&1; then + echo 'replicaCount=2 was accepted' >&2 + exit 1 + fi + if docker run --rm -v "$GITHUB_WORKSPACE:/work" -w /work alpine/helm:3.17@sha256:d899e6316789fec04ee95300a18e454b7942539cbb3d89bde3e0655d6ca2e895 template parseon deploy/helm/parseon >/dev/null 2>&1; then + echo 'missing image digest was accepted' >&2 + exit 1 + fi + - name: Validate scripts and monitoring artifacts + shell: bash + run: | + bash -n scripts/backup_postgres.sh scripts/restore_postgres.sh + python3 -m py_compile scripts/gen_erpc.py + python3 -m json.tool deploy/monitoring/parseon-dashboard.json >/dev/null + secrets: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - name: Reject credential-bearing generated config + shell: bash + run: | + if git grep -n -I -i -E '://[^[:space:]/:@]+:[^[:space:]@]+@|(^|[?&])(api[_-]?key|access[_-]?token|token|secret|password)=[^[:space:]&]{8,}' -- ':!CHANGELOG.md' ':!docs/operations.md' ':!.env.example'; then + echo 'credential-bearing URL or query parameter found in tracked source' >&2 + exit 1 + fi + echo 'Historical credentials still require independent revoke/rotate evidence.' diff --git a/.gitignore b/.gitignore index 4f83806..03e1ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target /.env +/.temp diff --git a/CHANGELOG.md b/CHANGELOG.md index f6b3542..5212e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to Parseon are documented in this file. ### Added +- Add canonical block metadata, provisional/finalized lifecycle, bounded reorg rollback, and finalized promotion for both storage adapters. +- Add production Compose and Helm deployment artifacts, backup/restore operations runbook, Prometheus alerts, Grafana dashboard, and CI release gates. - Apply chain creation, enable/disable, and deletion to running workers without restarting Parseon; deletions stop the worker before removing its data. - Rotate a chain's RPC endpoint URL in place on the running worker via Alloy's `Http::set_url`, resetting endpoint capability probes while keeping the cached chain ID; sources that cannot rotate restart their worker instead. - Allow multiple monitors to target the same chain, contract, and function selector or event topic while retaining independent ranges, filters, cursors, and result storage. @@ -15,6 +17,8 @@ All notable changes to Parseon are documented in this file. ### Changed +- Require a bearer API token for protected HTTP routes, disable CORS by default, bound request bodies, add liveness/readiness probes, and reject unsafe RPC destinations by default. +- Treat credential-bearing eRPC URLs as operator-injected configuration instead of generated repository content. - Pin the Compose eRPC image to the `0.1.1` release tag, set a 3 GiB container memory limit with `GOMEMLIMIT=2700MiB`, add `restart: unless-stopped`, and invoke `/erpc-server` explicitly so the distroless image starts correctly. - Reuse matching RPC data and compatible ABI decoders across overlapping monitor targets before applying each monitor's filter and persisting its own results. - Isolate ABI decode failures to the affected monitor layout so an incompatible definition cannot stall other monitors on the chain. @@ -32,6 +36,8 @@ All notable changes to Parseon are documented in this file. ### Fixed - Keep persisted chain state and live workers ordered during concurrent mutations and startup reconciliation, and reject new worker starts once supervisor shutdown begins. +- Reject mixed-branch result identities, fail closed when a retained reorg ancestor is unavailable, preserve finalized state for monitors added over existing blocks, and reset rollback cursors below a monitor's start block. +- Pin validated RPC DNS addresses in the HTTP transport, verify generated endpoint chain IDs, expose worker freshness/state metrics, and require encrypted checksum-verified PostgreSQL backup artifacts. ### Breaking diff --git a/Cargo.lock b/Cargo.lock index 16418da..f36999c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3481,7 +3481,7 @@ dependencies = [ [[package]] name = "parseon-core" -version = "0.8.0" +version = "1.0.0" dependencies = [ "alloy", "anyhow", @@ -3503,7 +3503,7 @@ dependencies = [ [[package]] name = "parseon-memory-cache" -version = "0.8.0" +version = "1.0.0" dependencies = [ "moka", "parseon-core", @@ -3511,7 +3511,7 @@ dependencies = [ [[package]] name = "parseon-mongodb" -version = "0.8.0" +version = "1.0.0" dependencies = [ "alloy", "anyhow", @@ -3528,7 +3528,7 @@ dependencies = [ [[package]] name = "parseon-postgres" -version = "0.8.0" +version = "1.0.0" dependencies = [ "alloy", "anyhow", @@ -3544,7 +3544,7 @@ dependencies = [ [[package]] name = "parseon-rpc" -version = "0.8.0" +version = "1.0.0" dependencies = [ "alloy", "alloy-json-rpc", @@ -3563,7 +3563,7 @@ dependencies = [ [[package]] name = "parseon-server" -version = "0.8.0" +version = "1.0.0" dependencies = [ "anyhow", "axum", @@ -3594,7 +3594,7 @@ dependencies = [ [[package]] name = "parseon-webhook-sink" -version = "0.8.0" +version = "1.0.0" dependencies = [ "anyhow", "axum", @@ -5428,6 +5428,7 @@ dependencies = [ "bytes", "http", "http-body", + "http-body-util", "percent-encoding", "pin-project-lite", "tower-layer", diff --git a/Cargo.toml b/Cargo.toml index 391cdab..8c107ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ resolver = "3" [workspace.package] -version = "0.8.0" +version = "1.0.0" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/Dockerfile b/Dockerfile index 20c8215..e15dea1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 # ---- chef base: shared Rust toolchain + cargo-chef ---- -FROM rust:1.96-alpine AS chef +FROM rust:1.96-alpine@sha256:a41f7740f8b45d45795624eec13a8b42263cc700f19f7e4e86e04d3dda08a479 AS chef RUN apk add --no-cache musl-dev RUN cargo install cargo-chef --locked WORKDIR /app @@ -18,7 +18,7 @@ ARG PARSEON_FEATURES=postgres-storage ENV PARSEON_FEATURES=${PARSEON_FEATURES} RUN apk add --no-cache watchexec COPY --from=planner /app/recipe.json recipe.json -RUN cargo chef cook --recipe-path recipe.json --no-default-features --features "${PARSEON_FEATURES}" +RUN cargo chef cook --locked --recipe-path recipe.json --no-default-features --features "${PARSEON_FEATURES}" COPY . . EXPOSE 8080 CMD ["sh", "-c", "exec watchexec --restart --stop-signal SIGINT --exts rs,toml,lock,sql -- cargo run --no-default-features --features \"${PARSEON_FEATURES}\""] @@ -29,15 +29,15 @@ ARG PARSEON_FEATURES=postgres-storage COPY --from=planner /app/recipe.json recipe.json RUN --mount=type=cache,target=/root/.cargo/registry \ --mount=type=cache,target=/app/target \ - cargo chef cook --release --recipe-path recipe.json --no-default-features --features "${PARSEON_FEATURES}" + cargo chef cook --locked --release --recipe-path recipe.json --no-default-features --features "${PARSEON_FEATURES}" COPY . . RUN --mount=type=cache,target=/root/.cargo/registry \ --mount=type=cache,target=/app/target \ - cargo build --release --no-default-features --features "${PARSEON_FEATURES}" && \ + cargo build --release --locked --no-default-features --features "${PARSEON_FEATURES}" && \ cp /app/target/release/parseon /usr/local/bin/parseon # ---- runtime: minimal alpine, non-root, healthcheck ---- -FROM alpine:3.20 AS runtime +FROM alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc AS runtime RUN apk add --no-cache ca-certificates wget && \ adduser -D -u 1000 parseon COPY --from=builder /usr/local/bin/parseon /usr/local/bin/parseon diff --git a/README.md b/README.md index 8c52276..ab7ff7e 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,14 @@ parseon-server └── parseon-webhook-sink ────> parseon-core ``` -Start PostgreSQL with `docker compose up -d`, or start the MongoDB development replica set and eRPC gateway with `docker compose --profile mongodb --profile erpc up -d`. Configure the selected backend through `STORAGE_URL`, then register direct RPC or complete eRPC URLs through `POST /chains`. +Start PostgreSQL with `docker compose up -d`, or start the MongoDB development replica set and eRPC gateway with `docker compose --profile mongodb --profile erpc up -d`. Configure the selected backend through `STORAGE_URL` and set a non-empty `API_TOKEN`; protected API routes require `Authorization: Bearer `. Register direct RPC or complete eRPC URLs through `POST /chains`. Private/loopback RPC destinations are rejected unless `ALLOW_PRIVATE_RPC_NETWORKS=true` is explicitly enabled for local development. See [adapter configuration and guarantees](./docs/adapters.md) for feature builds, MongoDB requirements, eRPC smoke checks, the webhook JSON contract, and Compose profiles. +## Production deployment + +Use [`compose.production.yml`](./compose.production.yml) or the [`deploy/helm/parseon`](./deploy/helm/parseon) chart. Both require an externally managed `API_TOKEN` and storage URL, keep databases private, and expose liveness/readiness probes. Read the [production operations runbook](./docs/operations.md) before upgrading, restoring, or exposing the API through an ingress. + ## License Licensed under either the [Apache License, Version 2.0](./LICENSE-APACHE) or the [MIT license](./LICENSE-MIT), at your option. diff --git a/compose.production.yml b/compose.production.yml new file mode 100644 index 0000000..52a70c6 --- /dev/null +++ b/compose.production.yml @@ -0,0 +1,70 @@ +services: + postgres: + image: ${POSTGRES_IMAGE:-postgres:16@sha256:f1c3376c26f2609ab9f29f71f824103fe2fcd8ee0346485cb6122a4f93df6f94} + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-parseon} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-parseon} + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 12 + # No host port: PostgreSQL is reachable only by Parseon on the backend network. + + parseon: + image: ${PARSEON_IMAGE:?set PARSEON_IMAGE to an immutable Parseon image} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + STORAGE_URL: ${STORAGE_URL:?set STORAGE_URL} + API_TOKEN: ${API_TOKEN:?set API_TOKEN} + HTTP_LISTEN: 0.0.0.0:8080 + RUST_LOG: ${RUST_LOG:-info} + POLL_INTERVAL_MS: ${POLL_INTERVAL_MS:-2000} + DEFAULT_BATCH_SIZE: ${DEFAULT_BATCH_SIZE:-10} + BLOCK_CACHE_SIZE: ${BLOCK_CACHE_SIZE:-512} + BLOCK_CONCURRENCY: ${BLOCK_CONCURRENCY:-4} + RPC_REQUEST_CONCURRENCY: ${RPC_REQUEST_CONCURRENCY:-16} + STORAGE_WRITE_CONCURRENCY: ${STORAGE_WRITE_CONCURRENCY:-4} + CONFIRMATION_DEPTH: ${CONFIRMATION_DEPTH:-64} + ROLLBACK_RETENTION: ${ROLLBACK_RETENTION:-256} + RPC_BATCH_SIZE: ${RPC_BATCH_SIZE:-20} + ALLOW_PRIVATE_RPC_NETWORKS: "false" + CORS_ORIGINS: ${CORS_ORIGINS:-} + MAX_BODY_BYTES: ${MAX_BODY_BYTES:-1048576} + expose: + - "8080" + networks: + - backend + - egress + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + mem_limit: ${PARSEON_MEMORY_LIMIT:-768m} + cpus: ${PARSEON_CPUS:-2} + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"] + interval: 10s + timeout: 5s + start_period: 20s + retries: 6 + +volumes: + pgdata: + +networks: + backend: + internal: true + egress: diff --git a/deploy/helm/parseon/.helmignore b/deploy/helm/parseon/.helmignore new file mode 100644 index 0000000..9ef3e26 --- /dev/null +++ b/deploy/helm/parseon/.helmignore @@ -0,0 +1,4 @@ +.DS_Store +.git/ +.temp/ +*.swp diff --git a/deploy/helm/parseon/Chart.yaml b/deploy/helm/parseon/Chart.yaml new file mode 100644 index 0000000..5df6d5b --- /dev/null +++ b/deploy/helm/parseon/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: parseon +description: Production self-hosted Parseon EVM indexer +home: https://github.com/ozcnii/parseon-main +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/helm/parseon/NOTES.txt b/deploy/helm/parseon/NOTES.txt new file mode 100644 index 0000000..8ece9fe --- /dev/null +++ b/deploy/helm/parseon/NOTES.txt @@ -0,0 +1,8 @@ +Create the external secret before installing Parseon: + + kubectl create secret generic {{ .Values.existingSecret }} \ + --from-literal={{ .Values.secretKeys.storageUrl }}="$STORAGE_URL" \ + --from-literal={{ .Values.secretKeys.apiToken }}="$API_TOKEN" + +The chart intentionally runs one replica with Recreate strategy. Put the Service behind +an ingress that terminates TLS and restricts access to the authenticated API routes. diff --git a/deploy/helm/parseon/templates/_helpers.tpl b/deploy/helm/parseon/templates/_helpers.tpl new file mode 100644 index 0000000..3f3f68a --- /dev/null +++ b/deploy/helm/parseon/templates/_helpers.tpl @@ -0,0 +1,24 @@ +{{- define "parseon.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "parseon.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name (include "parseon.name" .) | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{- define "parseon.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +app.kubernetes.io/name: {{ include "parseon.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "parseon.selectorLabels" -}} +app.kubernetes.io/name: {{ include "parseon.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/helm/parseon/templates/deployment.yaml b/deploy/helm/parseon/templates/deployment.yaml new file mode 100644 index 0000000..07808a7 --- /dev/null +++ b/deploy/helm/parseon/templates/deployment.yaml @@ -0,0 +1,114 @@ +{{- if ne (int .Values.replicaCount) 1 }} +{{ fail "Parseon requires replicaCount=1; use leader election before scaling" }} +{{- end }} +{{- if not (regexMatch "^sha256:[0-9a-fA-F]{64}$" .Values.image.digest) }} +{{ fail "image.digest must be an immutable sha256:<64-hex> digest" }} +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "parseon.fullname" . }} + labels: + {{- include "parseon.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate + selector: + matchLabels: + {{- include "parseon.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "parseon.selectorLabels" . | nindent 8 }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + terminationGracePeriodSeconds: 30 + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: parseon + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}{{- if .Values.image.digest }}@{{ .Values.image.digest }}{{- end }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: STORAGE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: {{ .Values.secretKeys.storageUrl }} + - name: API_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: {{ .Values.secretKeys.apiToken }} + - name: HTTP_LISTEN + value: 0.0.0.0:8080 + - name: RUST_LOG + value: {{ .Values.config.rustLog | quote }} + - name: POLL_INTERVAL_MS + value: {{ .Values.config.pollIntervalMs | quote }} + - name: DEFAULT_BATCH_SIZE + value: {{ .Values.config.defaultBatchSize | quote }} + - name: BLOCK_CACHE_SIZE + value: {{ .Values.config.blockCacheSize | quote }} + - name: BLOCK_CONCURRENCY + value: {{ .Values.config.blockConcurrency | quote }} + - name: RPC_REQUEST_CONCURRENCY + value: {{ .Values.config.rpcRequestConcurrency | quote }} + - name: STORAGE_WRITE_CONCURRENCY + value: {{ .Values.config.storageWriteConcurrency | quote }} + - name: CONFIRMATION_DEPTH + value: {{ .Values.config.confirmationDepth | quote }} + - name: ROLLBACK_RETENTION + value: {{ .Values.config.rollbackRetention | quote }} + - name: RPC_BATCH_SIZE + value: {{ .Values.config.rpcBatchSize | quote }} + - name: ALLOW_PRIVATE_RPC_NETWORKS + value: "false" + - name: CORS_ORIGINS + value: {{ .Values.config.corsOrigins | quote }} + - name: MAX_BODY_BYTES + value: {{ .Values.config.maxBodyBytes | quote }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + readinessProbe: + httpGet: + path: /readyz + port: http + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + startupProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 30 + resources: + {{- toYaml .Values.resources | nindent 12 }} + nodeSelector: + {{- toYaml .Values.nodeSelector | nindent 8 }} + tolerations: + {{- toYaml .Values.tolerations | nindent 8 }} + affinity: + {{- toYaml .Values.affinity | nindent 8 }} diff --git a/deploy/helm/parseon/templates/service.yaml b/deploy/helm/parseon/templates/service.yaml new file mode 100644 index 0000000..77fd487 --- /dev/null +++ b/deploy/helm/parseon/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "parseon.fullname" . }} + labels: + {{- include "parseon.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "parseon.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/deploy/helm/parseon/values.yaml b/deploy/helm/parseon/values.yaml new file mode 100644 index 0000000..13ac3d7 --- /dev/null +++ b/deploy/helm/parseon/values.yaml @@ -0,0 +1,45 @@ +image: + repository: ghcr.io/ozcnii/parseon + tag: "1.0.0" + # Required at install time: sha256:<64 hex characters>. + digest: "" + pullPolicy: IfNotPresent + +# Parseon has one process-wide worker supervisor; scaling is intentionally blocked. +replicaCount: 1 + +existingSecret: parseon-secrets +secretKeys: + storageUrl: storage-url + apiToken: api-token + +service: + type: ClusterIP + port: 8080 + +config: + rustLog: info + pollIntervalMs: "2000" + defaultBatchSize: "10" + blockCacheSize: "512" + blockConcurrency: "4" + rpcRequestConcurrency: "16" + storageWriteConcurrency: "4" + confirmationDepth: "64" + rollbackRetention: "256" + rpcBatchSize: "20" + corsOrigins: "" + maxBodyBytes: "1048576" + +resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "2" + memory: 768Mi + +podAnnotations: {} +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/deploy/monitoring/parseon-alerts.yaml b/deploy/monitoring/parseon-alerts.yaml new file mode 100644 index 0000000..daa0f3b --- /dev/null +++ b/deploy/monitoring/parseon-alerts.yaml @@ -0,0 +1,63 @@ +groups: + - name: parseon + rules: + - alert: ParseonTargetDown + expr: up{job="parseon"} == 0 + for: 5m + labels: + severity: critical + annotations: + summary: Parseon metrics target is down + description: "The Parseon scrape target is unavailable." + - alert: ParseonWorkerLag + expr: parseon_worker_lag_blocks > 100 + for: 10m + labels: + severity: warning + annotations: + summary: Parseon indexing lag is high + description: "Chain {{ $labels.chain_id }} is more than 100 blocks behind." + - alert: ParseonRpcErrors + expr: | + sum by (chain_id) (rate(parseon_rpc_operations_total{outcome="error"}[5m])) + / + clamp_min(sum by (chain_id) (rate(parseon_rpc_operations_total[5m])), 1) + > 0.2 + for: 10m + labels: + severity: warning + annotations: + summary: Parseon RPC error rate is high + description: "More than 20% of RPC operations fail on chain {{ $labels.chain_id }}." + - alert: ParseonWorkerBlocked + expr: parseon_worker_state{state="blocked"} == 1 + for: 1m + labels: + severity: critical + annotations: + summary: Parseon worker is blocked + description: "Chain {{ $labels.chain_id }} crossed a finalized reorg boundary or lost its rollback ancestor." + - alert: ParseonWorkerDegraded + expr: parseon_worker_state{state="degraded"} == 1 + for: 5m + labels: + severity: warning + annotations: + summary: Parseon worker is degraded + description: "Chain {{ $labels.chain_id }} is not completing indexing polls." + - alert: ParseonWorkerStale + expr: time() - parseon_worker_last_successful_poll_timestamp_seconds > 30 + for: 5m + labels: + severity: critical + annotations: + summary: Parseon worker poll is stale + description: "Chain {{ $labels.chain_id }} has not completed a successful poll recently." + - alert: ParseonStorageCommitErrors + expr: rate(parseon_storage_commit_duration_seconds_count{outcome="error"}[5m]) > 0 + for: 5m + labels: + severity: critical + annotations: + summary: Parseon storage commits are failing + description: "Storage commit failures are reported for chain {{ $labels.chain_id }}." diff --git a/deploy/monitoring/parseon-dashboard.json b/deploy/monitoring/parseon-dashboard.json new file mode 100644 index 0000000..c8c6ae6 --- /dev/null +++ b/deploy/monitoring/parseon-dashboard.json @@ -0,0 +1,42 @@ +{ + "title": "Parseon", + "uid": "parseon", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "templating": { "list": [] }, + "panels": [ + { + "id": 1, + "type": "timeseries", + "title": "Worker lag (blocks)", + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [{ "expr": "parseon_worker_lag_blocks", "legendFormat": "{{chain_id}}" }], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 } + }, + { + "id": 2, + "type": "timeseries", + "title": "Worker state", + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [{ "expr": "parseon_worker_state", "legendFormat": "{{chain_id}} {{state}}" }], + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 } + }, + { + "id": 3, + "type": "timeseries", + "title": "Last successful poll", + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [{ "expr": "time() - parseon_worker_last_successful_poll_timestamp_seconds", "legendFormat": "{{chain_id}} age (s)" }], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 } + }, + { + "id": 4, + "type": "timeseries", + "title": "RPC error rate", + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [{ "expr": "sum by (chain_id) (rate(parseon_rpc_operations_total{outcome=\"error\"}[5m]))", "legendFormat": "{{chain_id}}" }], + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 } + } + ] +} diff --git a/deploy/monitoring/prometheus.yml b/deploy/monitoring/prometheus.yml new file mode 100644 index 0000000..f73b624 --- /dev/null +++ b/deploy/monitoring/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + +rule_files: + - parseon-alerts.yaml + +scrape_configs: + - job_name: parseon + metrics_path: /metrics + static_configs: + - targets: ["parseon:8080"] diff --git a/docs/adapters.md b/docs/adapters.md index aacfa22..3bf9303 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -1,6 +1,7 @@ # Adapters -Parseon selects exactly one storage adapter at compile time. PostgreSQL is the default; the memory block cache is not feature-gated and can be disabled by setting `BLOCK_CACHE_SIZE=0`. +Parseon selects exactly one storage adapter at compile time. For production deployment, +backup, restore and upgrade procedures see [`operations.md`](./operations.md). PostgreSQL is the default; the memory block cache is not feature-gated and can be disabled by setting `BLOCK_CACHE_SIZE=0`. ## Feature builds @@ -26,7 +27,7 @@ docker build --build-arg PARSEON_FEATURES=mongodb-storage,webhook-sink -t parseo MongoDB storage uses `chains`, `monitors`, `results`, and `counters` collections. Results share one document collection. Addresses and hashes are strings, byte values are BSON binary, 256-bit integers are decimal strings, and API reads retain Parseon's canonical JSON encoding. -Transactions require a replica set or sharded deployment. Parseon checks the deployment topology at startup and rejects standalone MongoDB before serving traffic. Monitor creation, atomic result/cursor commits, and chain/monitor cascade deletion use retryable transactions. Required unique identity and query-order indexes are created idempotently; there is no schema version or migration system. See the [MongoDB Rust transaction documentation](https://www.mongodb.com/docs/drivers/rust/current/crud/transactions/). +Transactions require a replica set or sharded deployment. Parseon checks the deployment topology at startup and rejects standalone MongoDB before serving traffic. Monitor creation, atomic result/cursor commits, and chain/monitor cascade deletion use retryable transactions. Required unique identity and query-order indexes are created idempotently. Startup writes and validates a schema marker and fails closed on unsupported or legacy documents; upgrade by backup, reset, and reindex according to the [operations runbook](./operations.md). See the [MongoDB Rust transaction documentation](https://www.mongodb.com/docs/drivers/rust/current/crud/transactions/). Start the persistent single-node development replica set and run the ignored integration test with: @@ -39,13 +40,15 @@ Use `STORAGE_URL=mongodb://localhost:27017/?replicaSet=rs0`. There is intentiona ## JSON-RPC block source +Parseon indexes the canonical latest head provisionally and promotes blocks only after the configured confirmation/finalized boundary. RPC destinations are validated against private-network SSRF by default; set `ALLOW_PRIVATE_RPC_NETWORKS=true` only for local development. + The Alloy adapter requests full transaction objects and rejects a block response when its number differs from the request or the endpoint returns hashes instead of full transactions. Call monitors request receipts only for transactions that match an indexed address and selector. Receipt outcomes must remain in the requested hash order; Parseon validates that invariant before decoding. For larger candidate sets, Parseon tries `eth_getBlockReceipts`, then JSON-RPC batches, then bounded individual receipt calls. An endpoint capability is cached as unsupported only when the RPC response identifies an incompatible method, parameters, or batch envelope. Authentication, transport, timeout, and rate-limit failures stop the poll without fanning out into more requests. Event monitors use inclusive `eth_getLogs` ranges across each contiguous worker window. Address and topic pairs remain exact: Parseon groups compatible pairs into independent filters and executes those groups within `RPC_REQUEST_CONCURRENCY`. When a provider reports a range or result-size limit, the adapter bisects the range and retries each half. A limit on a single block is terminal because splitting cannot make that query smaller. Successful results are sorted by block and log index before core decoding. -`RPC_REQUEST_CONCURRENCY` applies to every physical request, including split log queries and receipt fallbacks. RPC transport details are omitted from runtime status and logs so registered write-only endpoint URLs and credentials are not exposed. +`RPC_REQUEST_CONCURRENCY` applies to every physical request, including split log queries and receipt fallbacks. RPC transport details are omitted from runtime status and logs so registered write-only endpoint URLs and credentials are not exposed. HTTP redirects are disabled. Results are finalized by default; use the API's explicit finality selector to inspect provisional rows. ## eRPC gateway diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..88fa227 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,199 @@ +# Production operations + +This runbook applies to Parseon v1.0. Parseon is a single active process per storage +schema: deploy one replica with a `Recreate` strategy. Run it behind an ingress that +terminates TLS and restricts the service network. + +## Security boundary + +- Set a long random `API_TOKEN`. The bearer token is required for `/chains`, + `/monitors`, `/filters/preview`, result queries and `/status`. +- `/healthz`, `/readyz` and `/metrics` are unauthenticated probe endpoints; keep them + on an internal network or ingress allowlist. Swagger/OpenAPI metadata is public to + make the browser UI usable, so expose those paths only through an operator ingress. +- Keep `CORS_ORIGINS` empty unless a specific browser origin is required. Never use `*` + for an authenticated browser deployment. +- `ALLOW_PRIVATE_RPC_NETWORKS=false` in production. RPC URLs are validated before + probing; private/link-local/metadata destinations are rejected. Network egress + policy remains the final SSRF control. +- Use an ingress/network policy for TLS, client IP limits and rate limiting. Parseon + does not terminate TLS or provide user roles/RBAC. +- RPC URLs are write-only. Do not put provider credentials in Git, logs, URLs copied + into tickets, or generated artifacts. The historical credential previously present + in `erpc.yaml` must be revoked independently of this change. + +## Deployment + +### Docker Compose + +Copy the production environment into a secret-managed shell environment and set at +least `PARSEON_IMAGE` to an immutable image reference, `STORAGE_URL`, +`POSTGRES_PASSWORD`, and `API_TOKEN`: + +```bash +export PARSEON_IMAGE=registry.example/parseon@sha256: +export STORAGE_URL='postgres://parseon:@postgres:5432/parseon' +export POSTGRES_PASSWORD='' +export API_TOKEN='' +docker compose -f compose.production.yml config +docker compose -f compose.production.yml up -d +# The Compose file intentionally publishes no host port; probe from the service network. +docker compose -f compose.production.yml exec -T parseon wget -qO- http://127.0.0.1:8080/healthz +docker compose -f compose.production.yml exec -T parseon wget -qO- http://127.0.0.1:8080/readyz +# Use the configured ingress URL for external probes. +``` + +The production file has no host database ports. It uses a private database network and +an egress network so Parseon can reach configured RPC endpoints. Do not expose the +container port directly to the Internet. + +### Kubernetes / Helm + +Create the external Secret before installing; it must contain `storage-url` and +`api-token` (or override those key names in values): + +```bash +kubectl create secret generic parseon-secrets \ + --from-literal=storage-url="$STORAGE_URL" \ + --from-literal=api-token="$API_TOKEN" +helm upgrade --install parseon deploy/helm/parseon \ + --set image.repository=registry.example/parseon \ + --set image.tag=1.0.0 \ + --set image.digest=sha256: +``` + +The chart intentionally runs one replica with `Recreate`; scaling it without a +leader-election/lease design can duplicate workers and writes. Configure an ingress +for TLS and API access, and keep the Secret in an external secret manager in real +clusters. + +## Health and readiness + +- `/healthz` only proves that the process and HTTP listener are alive. +- `/readyz` checks storage connectivity and requires every enabled chain worker to be + running. A disabled-only registry is ready; starting, degraded, blocked, stale, or + unexpectedly exited worker tasks return `503`. +- A blocked worker means reorg recovery crossed a promoted finalized boundary or the + retained ancestor was unavailable. Do not restart-loop blindly; follow the recovery + procedure below. +- Alert on `parseon_worker_lag_blocks`, worker blocked/degraded status, increasing RPC + errors, storage commit errors and a stale `last_successful_poll_at`. + +## Finality and reorg recovery + +Blocks are indexed provisionally through the latest head. Promotion uses: + +```text +promotion_height = min(source_finalized_head, + latest_head - CONFIRMATION_DEPTH) +``` + +The defaults are `CONFIRMATION_DEPTH=64` and `ROLLBACK_RETENTION=256`. Results API +queries default to finalized rows; request `finality=provisional` or `finality=all` +only when an operator needs unstable data. + +A fork within retention is rolled back atomically: orphaned blocks/results are removed +and all affected monitor cursors (including completed monitors) are rewound. If the +fork reaches promoted finalized data, or no common ancestor is retained, the worker +blocks without deleting data. Resolve the source/finality incident, then take a +backup and reindex the affected chain/monitors according to the release procedure. + +Webhook delivery is finalized-only and best-effort. Storage commits are authoritative; +a webhook failure does not rewind indexing and delivery is not exactly-once. + +## PostgreSQL migrations and legacy data + +Migrations are embedded at build time and run at startup. Never edit an applied SQL +migration and never downgrade a running schema. The v1 migration refuses a populated +pre-ledger monitor/result state because old rows have no block hashes. Upgrade such a +release as follows: + +1. Stop Parseon and take a verified backup. +2. Restore the backup into a staging database and test it. +3. For a database still using the pre-v1 schema, export anything needed, then create + an empty Parseon database (or drop only the Parseon schema after confirming the + backup). Do not delete `_sqlx_migrations` in a production database as a shortcut. +4. Start the v1 image so migrations create the ledger, then recreate monitors from + their source definitions and reindex from their chosen start blocks. +5. Verify chain status, canonical/finalized heads, monitor cursors and result counts. + +There is no safe automatic hash backfill for old rows. MongoDB follows the same rule: +its schema marker is checked at startup and documents without v1 block identity require +an explicit backup/reset/reindex. There is no cross-storage importer or dual-write +migration. + +## Backups + +Target service objectives for the default self-hosted deployment are **RPO 15 minutes** +and **RTO 60 minutes**. Operators may tighten them, but must record the changed policy. +Backups must be encrypted, copied off-host, access-controlled and periodically tested. + +### PostgreSQL + +Quiesce writes by stopping Parseon before a logical backup (or use an established WAL +archiving policy for continuous recovery): + +```bash +BACKUP_DIR=${BACKUP_DIR:-./backups} +mkdir -p "$BACKUP_DIR" +# Use libpq service/password files; URLs and passwords stay out of process arguments. +export PGSERVICE=parseon +export PGSERVICEFILE=/run/secrets/pg_service.conf +export PGPASSFILE=/run/secrets/pgpass +export AGE_RECIPIENT='age1...' +./scripts/backup_postgres.sh +``` + +The script uses `pg_dump --format=custom` and never stores credentials in the +repository. Restore only into an isolated target first: + +```bash +export RESTORE_CONFIRM=YES +export PGSERVICE=parseon_restore +export PGSERVICEFILE=/run/secrets/pg_restore_service.conf +export PGPASSFILE=/run/secrets/pgpass_restore +export AGE_IDENTITY=/run/secrets/age_identity +./scripts/restore_postgres.sh backups/parseon-.dump.age +``` + +After restore, start the matching Parseon image, wait for `/readyz`, compare monitor +and result counts, and verify a known result plus canonical/finalized status. A restore +is not accepted until the checksum and application-level checks pass. + +### MongoDB + +Quiesce Parseon, then use a replica-set aware dump with oplog capture: + +```bash +mongodump --uri="$MONGODB_URI" --db="${STORAGE_DATABASE:-parseon}" \ + --out="$BACKUP_DIR/mongodb-$(date -u +%Y%m%dT%H%M%SZ)" --oplog +``` + +Restore to an isolated replica set with `mongorestore --drop --oplogReplay`, verify the +`schema_metadata` version, chain/monitor counts, canonical block continuity and a +known result, then point a matching Parseon image at the restored database. Never use a +standalone MongoDB deployment: transactions require a replica set or sharded cluster. + +## Incident checklist + +1. Check `/readyz`, `/status` (with bearer token), metrics and worker logs without + printing RPC URLs or credentials. +2. If a worker is degraded, fix provider connectivity/finality support first. +3. If blocked, preserve the database, capture a backup and identify the common ancestor + or finality violation before any reset. +4. If reset/reindex is required, stop Parseon, verify backup, perform it in staging + first, then recreate monitors and compare result counts. +5. Record the source endpoint/provider incident, affected chain/range, restore point, + replay start, and final verification in the incident log. + +## Release checklist + +- Build with `--locked`; run all four storage/webhook feature combinations and Compose + smoke checks. +- Scan tracked files and generated artifacts for credentials; revoke any exposed key. +- Verify the image digest, non-root runtime, liveness/readiness probes and resource + limits. +- Run a restore drill at least once before first production use and after migration + changes. +- Keep one release image and its migration notes available for rollback. Schema + downgrade is unsupported; rollback means restore the previous backup or reindex. diff --git a/erpc.yaml b/erpc.yaml index 6e74b02..769464f 100644 --- a/erpc.yaml +++ b/erpc.yaml @@ -99,10 +99,6 @@ projects: endpoint: 'https://ethereum.public.blockpi.network/v1/rpc/public' evm: chainId: 1 - - id: chain1-9 - endpoint: 'https://eth-mainnet.rpcfast.com?api_key=xbhWBI1Wkguk8SNMu1bvvLurPGLXmgwYeC4S6g2H7WdwFigZSmPWVZRxrskEQwIf' - evm: - chainId: 1 - id: chain1-10 endpoint: 'https://ethereum-public.nodies.app' evm: @@ -191,10 +187,6 @@ projects: endpoint: 'https://rpc.flashbots.net' evm: chainId: 1 - - id: chain1-32 - endpoint: 'https://eth-mainnet.nodereal.io/v1/1659dfb40aa24bbb8153a677b98064d7' - evm: - chainId: 1 - id: chain1-33 endpoint: 'https://eth.api.onfinality.io/public' evm: @@ -255,10 +247,6 @@ projects: endpoint: 'https://0.48.club' evm: chainId: 56 - - id: chain56-14 - endpoint: 'https://bsc-mainnet.rpcfast.com?api_key=xbhWBI1Wkguk8SNMu1bvvLurPGLXmgwYeC4S6g2H7WdwFigZSmPWVZRxrskEQwIf' - evm: - chainId: 56 - id: chain56-15 endpoint: 'https://bsc-mainnet.gateway.tatum.io' evm: @@ -291,10 +279,6 @@ projects: endpoint: 'https://bsc-dataseed4.bnbchain.org' evm: chainId: 56 - - id: chain56-23 - endpoint: 'https://bsc-mainnet.nodereal.io/v1/64a9df0874fb4a93b9d0a3849de012d3' - evm: - chainId: 56 - id: chain56-24 endpoint: 'https://bsc-dataseed1.bnbchain.org' evm: diff --git a/parseon-core/benches/worker_pipeline.rs b/parseon-core/benches/worker_pipeline.rs index 70568e5..893a913 100644 --- a/parseon-core/benches/worker_pipeline.rs +++ b/parseon-core/benches/worker_pipeline.rs @@ -9,7 +9,8 @@ use parseon_core::abi::{AbiParam, CallDecoder, decode_calldata}; use parseon_core::filter::{Filter, FilterContext, FilterDefinition, FilterExpression}; use parseon_core::pipeline; use parseon_core::{ - BlockTransaction, Bytes, CallTarget, DecodedValue, EventTarget, SourceBlock, Target, + BlockMetadata, BlockTransaction, Bytes, CallTarget, DecodedValue, EventTarget, SourceBlock, + Target, }; async fn run_pipeline(concurrency: usize) { @@ -173,6 +174,12 @@ fn benchmark(c: &mut Criterion) { let block = Arc::new(SourceBlock { number: 1, + metadata: BlockMetadata { + number: 1, + hash: B256::from([1; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, transactions: (0_u16..1_000) .map(|index| BlockTransaction { hash: B256::with_last_byte(index as u8), diff --git a/parseon-core/src/commands.rs b/parseon-core/src/commands.rs index 1e41acd..469e7c7 100644 --- a/parseon-core/src/commands.rs +++ b/parseon-core/src/commands.rs @@ -6,7 +6,7 @@ //! behavior — all validation lives in the services. use crate::filter::{FilterExpression, FilterSample}; -use crate::{Address, BlockNumber, ChainId, Url}; +use crate::{Address, BlockNumber, ChainId, Finality, Url}; /// Command to register a new chain: its RPC URL and initial enabled state. #[derive(Debug, Clone)] @@ -84,6 +84,9 @@ pub struct ResultQuery { pub limit: PageLimit, /// Number of results to skip before the first returned result. pub offset: u64, + /// Optional lifecycle filter. `None` returns both states for storage-level + /// callers; the HTTP API supplies finalized as its default. + pub finality: Option, } #[cfg(test)] diff --git a/parseon-core/src/indexer.rs b/parseon-core/src/indexer.rs index 1e3ba9e..2f7f993 100644 --- a/parseon-core/src/indexer.rs +++ b/parseon-core/src/indexer.rs @@ -239,6 +239,7 @@ pub(crate) fn decode_calls( })? { calls.push(DecodedCall { monitor_id: monitor.id, + block_hash: block.metadata.hash, block_number: block.number, transaction_hash: tx.hash, from: tx.from, @@ -279,6 +280,8 @@ pub(crate) fn decode_events( log.block_number == Some(block_number), "log has missing or incorrect block number" ); + let block_hash = + log.block_hash.ok_or_else(|| anyhow::anyhow!("log is missing block hash"))?; let transaction_hash = log .transaction_hash .ok_or_else(|| anyhow::anyhow!("log is missing transaction hash"))?; @@ -328,6 +331,7 @@ pub(crate) fn decode_events( })? { events.push(DecodedEvent { monitor_id: monitor.id, + block_hash, block_number, transaction_hash, log_index, @@ -350,7 +354,9 @@ mod tests { use crate::abi::AbiParam; use crate::filter::{Filter, FilterDefinition, FilterExpression}; use crate::monitor::Monitor; - use crate::{BlockTransaction, CallTarget, Cursor, DecodedValue, EventTarget, Target}; + use crate::{ + BlockMetadata, BlockTransaction, CallTarget, Cursor, DecodedValue, EventTarget, Target, + }; alloy::sol! { function transfer(address to, uint256 value) external returns (bool); @@ -442,6 +448,7 @@ mod tests { &[0], vec![SourceLog { block_number: Some(10), + block_hash: Some(B256::from([10; 32])), transaction_hash: Some(transaction_hash), log_index: Some(3), address, @@ -487,7 +494,16 @@ mod tests { enabled: true, filter: Filter::All, }; - let block = SourceBlock { number: 1, transactions: vec![transaction.clone()] }; + let block = SourceBlock { + number: 1, + metadata: BlockMetadata { + number: 1, + hash: B256::from([1; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, + transactions: vec![transaction.clone()], + }; let outcomes = vec![ ExecutionOutcome { transaction_hash: transaction.hash, succeeded: false }, ExecutionOutcome { transaction_hash: transaction.hash, succeeded: true }, @@ -550,7 +566,16 @@ mod tests { to: contract, input: call.abi_encode().into(), }; - let block = SourceBlock { number: 10, transactions: vec![transaction.clone()] }; + let block = SourceBlock { + number: 10, + metadata: BlockMetadata { + number: 10, + hash: B256::from([10; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, + transactions: vec![transaction.clone()], + }; let decoded = decode_calls( &block, &index, @@ -593,6 +618,7 @@ mod tests { &[0, 1, 2], vec![SourceLog { block_number: Some(10), + block_hash: Some(B256::from([10; 32])), transaction_hash: Some(B256::repeat_byte(9)), log_index: Some(3), address, @@ -637,6 +663,12 @@ mod tests { .unwrap(); let block = SourceBlock { number: 10, + metadata: BlockMetadata { + number: 10, + hash: B256::from([10; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, transactions: vec![ BlockTransaction { hash: B256::repeat_byte(1), @@ -696,6 +728,7 @@ mod tests { .unwrap(); let log = |index, address, topic0| SourceLog { block_number: Some(10), + block_hash: Some(B256::from([10; 32])), transaction_hash: Some(B256::repeat_byte(index)), log_index: Some(u64::from(index)), address, diff --git a/parseon-core/src/lib.rs b/parseon-core/src/lib.rs index 4ced7ae..3c48517 100644 --- a/parseon-core/src/lib.rs +++ b/parseon-core/src/lib.rs @@ -46,7 +46,7 @@ mod worker; pub mod model; pub use model::{ - Address, B256, BlockNumber, BlockTransaction, Bytes, CallTarget, Chain, ChainId, Cursor, - DecodedCall, DecodedEvent, DecodedResult, DecodedValue, EventTarget, ExecutionOutcome, - InvalidMonitorId, MonitorId, Selector, SourceBlock, SourceLog, Target, TxHash, Url, + Address, B256, BlockMetadata, BlockNumber, BlockTransaction, Bytes, CallTarget, Chain, ChainId, + Cursor, DecodedCall, DecodedEvent, DecodedResult, DecodedValue, EventTarget, ExecutionOutcome, + Finality, InvalidMonitorId, MonitorId, Selector, SourceBlock, SourceLog, Target, TxHash, Url, }; diff --git a/parseon-core/src/model.rs b/parseon-core/src/model.rs index 9cc09ee..2d4ba7b 100644 --- a/parseon-core/src/model.rs +++ b/parseon-core/src/model.rs @@ -133,6 +133,38 @@ pub enum DecodedValue { Bytes(Bytes), } +/// Lifecycle state of a canonical block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Finality { + /// Block is on the current branch but not yet final. + Provisional, + /// Block has crossed the configured finality boundary. + Finalized, +} + +impl Finality { + /// Stable storage and API spelling. + pub const fn as_str(self) -> &'static str { + match self { + Self::Provisional => "provisional", + Self::Finalized => "finalized", + } + } +} + +/// Canonical identity and timing metadata for a source block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlockMetadata { + /// Block number. + pub number: BlockNumber, + /// Canonical block hash. + pub hash: B256, + /// Parent block hash. + pub parent_hash: B256, + /// Unix timestamp reported by the source. + pub timestamp: u64, +} + /// One transaction in a fetched block, with the data the indexer needs. #[derive(Debug, Clone)] pub struct BlockTransaction { @@ -146,11 +178,14 @@ pub struct BlockTransaction { pub input: Bytes, } -/// A fetched finalized block and its transactions. +/// A fetched canonical block and its transactions. #[derive(Debug, Clone)] pub struct SourceBlock { - /// Block number. + /// Block number. Kept alongside `metadata` for cheap access in the hot path; + /// source adapters must keep the two values equal. pub number: BlockNumber, + /// Canonical identity and timing metadata. + pub metadata: BlockMetadata, /// Transactions in this block, in on-chain order. pub transactions: Vec, } @@ -169,6 +204,8 @@ pub struct ExecutionOutcome { pub struct DecodedCall { /// Monitor that produced this decoded call. pub monitor_id: MonitorId, + /// Canonical block hash. + pub block_hash: B256, /// Block the call was included in. pub block_number: BlockNumber, /// Transaction hash of the call. @@ -186,6 +223,8 @@ pub struct DecodedCall { pub struct SourceLog { /// Block number the log was emitted in, if the source provides it. pub block_number: Option, + /// Block hash the log was emitted in, if the source provides it. + pub block_hash: Option, /// Transaction hash that emitted the log, if the source provides it. pub transaction_hash: Option, /// Log index within the block, if the source provides it. @@ -207,6 +246,8 @@ pub struct SourceLog { pub struct DecodedEvent { /// Monitor that produced this decoded event. pub monitor_id: MonitorId, + /// Canonical block hash. + pub block_hash: B256, /// Block the event was emitted in. pub block_number: BlockNumber, /// Transaction hash that emitted the log. diff --git a/parseon-core/src/ports/cache.rs b/parseon-core/src/ports/cache.rs index a602068..5a3ebac 100644 --- a/parseon-core/src/ports/cache.rs +++ b/parseon-core/src/ports/cache.rs @@ -21,6 +21,10 @@ pub trait BlockCache: Send + Sync { /// Evicts every entry for `chain` whose block number is less than /// `block_number`. Called after the worker commits past a block. fn evict_before(&self, chain: Chain, block_number: BlockNumber); + /// Evicts every entry for `chain` whose block number is greater than + /// `block_number`. Called after a reorg rollback to prevent stale branch + /// data from being reused. + fn evict_after(&self, chain: Chain, block_number: BlockNumber); } /// Factory for per-worker [`BlockCache`] instances. diff --git a/parseon-core/src/ports/mod.rs b/parseon-core/src/ports/mod.rs index 0363160..17cea1d 100644 --- a/parseon-core/src/ports/mod.rs +++ b/parseon-core/src/ports/mod.rs @@ -6,7 +6,7 @@ //! - [`storage`]: repository and atomic-block-commit traits (`Storage`, //! `IndexStorage`, `ChainRepository`, `MonitorRepository`, //! `ResultRepository`) and the records they exchange. -//! - [`source`]: finalized EVM data access (`BlockSource`, +//! - [`source`]: canonical EVM data access (`BlockSource`, //! `BlockSourceFactory`) plus the bounded log-query primitives //! (`BlockRange`, `LogTarget`, `LogQuery`). //! - [`cache`]: short-lived block caching (`BlockCache`, `BlockCacheFactory`). @@ -29,9 +29,9 @@ pub use source::{ LogTarget, }; pub use storage::{ - BlockCommit, ChainRecord, ChainRepository, ChainUpdate, IndexStorage, MonitorKind, - MonitorRecord, MonitorRepository, NewChain, NewMonitor, RegisteredChain, ResultRecord, - ResultRepository, canonical_params, + BlockCommit, CanonicalBlock, ChainRecord, ChainRepository, ChainUpdate, IndexStorage, + MonitorKind, MonitorRecord, MonitorRepository, NewChain, NewMonitor, RegisteredChain, + ResultRecord, ResultRepository, canonical_params, }; pub use telemetry::{NoopTelemetry, Telemetry}; diff --git a/parseon-core/src/ports/source.rs b/parseon-core/src/ports/source.rs index ffded83..9cb14ab 100644 --- a/parseon-core/src/ports/source.rs +++ b/parseon-core/src/ports/source.rs @@ -1,14 +1,16 @@ -//! Finalized EVM data access ports. +//! Canonical EVM data access ports. //! //! [`BlockSource] is the read-side adapter contract implemented by -//! `parseon-rpc`. The worker uses it to fetch finalized blocks, transaction +//! `parseon-rpc`. The worker uses it to fetch canonical blocks, transaction //! execution outcomes, and exact-target event logs. use std::sync::Arc; use async_trait::async_trait; -use crate::{BlockNumber, ChainId, ExecutionOutcome, SourceBlock, SourceLog, TxHash, Url}; +use crate::{ + BlockMetadata, BlockNumber, ChainId, ExecutionOutcome, SourceBlock, SourceLog, TxHash, Url, +}; use alloy::primitives::{Address, B256}; /// Factory that constructs a connected [`BlockSource`] for a given RPC URL. @@ -43,7 +45,7 @@ impl BlockSourceRequestError { } } -/// Finalized EVM data required by the indexing application. +/// Canonical EVM data required by the indexing application. /// /// Implementations must return the exact requested block, complete full /// transaction data, and one execution outcome per requested transaction hash @@ -53,17 +55,30 @@ impl BlockSourceRequestError { pub trait BlockSource: Send + Sync { /// Returns the endpoint's EIP-155 chain ID. async fn chain_id(&self) -> anyhow::Result; + /// Returns the current latest head block number. Sources that only expose + /// finalized data may use the default finalized value. + async fn latest_head(&self) -> anyhow::Result { + self.finalized_head().await + } /// Returns the current finalized head block number. Must succeed only if /// the endpoint supports the `finalized` block tag. async fn finalized_head(&self) -> anyhow::Result; + /// Fetches canonical metadata for a block without requiring full transactions. + async fn fetch_block_header( + &self, + _block_number: BlockNumber, + ) -> anyhow::Result { + anyhow::bail!("block header fetching is not implemented") + } /// Fetches the full block at `block_number`, including every transaction's /// sender, recipient, and calldata. async fn fetch_block(&self, block_number: BlockNumber) -> anyhow::Result; /// Fetches execution outcomes for `transaction_hashes` in the same order - /// as the input slice. Used to skip reverted calls. + /// as the input slice. Each receipt must belong to `block`; used to skip + /// reverted calls. async fn fetch_execution_outcomes( &self, - block_number: BlockNumber, + block: &BlockMetadata, transaction_hashes: &[TxHash], ) -> anyhow::Result>; /// Fetches logs for an exact set of emitter-address and event-signature @@ -81,7 +96,7 @@ pub trait BlockSource: Send + Sync { } } -/// An inclusive, non-empty range of finalized EVM block numbers. +/// An inclusive, non-empty range of canonical EVM block numbers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BlockRange { start: BlockNumber, diff --git a/parseon-core/src/ports/storage.rs b/parseon-core/src/ports/storage.rs index ac2b419..e095f1e 100644 --- a/parseon-core/src/ports/storage.rs +++ b/parseon-core/src/ports/storage.rs @@ -13,7 +13,10 @@ use chrono::{DateTime, Utc}; use crate::abi::AbiParam; use crate::filter::FilterDefinition; use crate::monitor::Monitor; -use crate::{BlockNumber, Chain, DecodedResult, DecodedValue, MonitorId, Target, TxHash, Url}; +use crate::{ + BlockMetadata, BlockNumber, Chain, DecodedResult, DecodedValue, Finality, MonitorId, Target, + TxHash, Url, +}; /// One block's atomic commit payload: decoded results and the monitors whose /// cursors must advance together. @@ -22,11 +25,24 @@ use crate::{BlockNumber, Chain, DecodedResult, DecodedValue, MonitorId, Target, /// and update every covering monitor's cursor in a single transaction so that /// crashes between writes cannot leave gaps or duplicates. #[derive(Debug, Clone)] +pub struct CanonicalBlock { + /// Chain the block belongs to. + pub chain: Chain, + /// Canonical block identity and timing metadata. + pub metadata: BlockMetadata, + /// Current lifecycle state. + pub finality: Finality, +} + +/// One block's atomic commit payload. +#[derive(Debug, Clone)] pub struct BlockCommit { /// Chain the block belongs to. pub chain: Chain, - /// Block number being committed. - pub block_number: BlockNumber, + /// Canonical block identity and timing metadata. + pub metadata: BlockMetadata, + /// Lifecycle state at commit time. + pub finality: Finality, /// Monitors whose cursors must advance to this block. The worker passes /// only the monitors whose plan covered this block. pub monitors: Vec>, @@ -34,6 +50,29 @@ pub struct BlockCommit { pub results: Vec, } +impl BlockCommit { + /// Rejects payloads that mix results from a different block or chain. + pub fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.monitors.iter().all(|monitor| monitor.chain == self.chain), + "cross-chain monitor set rejected for chain {}", + self.chain.id + ); + for result in &self.results { + let (block_hash, block_number) = match result { + DecodedResult::Call(call) => (call.block_hash, call.block_number), + DecodedResult::Event(event) => (event.block_hash, event.block_number), + }; + anyhow::ensure!( + block_hash == self.metadata.hash && block_number == self.metadata.number, + "result block identity does not match commit metadata at block {}", + self.metadata.number + ); + } + Ok(()) + } +} + /// Per-chain indexing storage: monitor loading and atomic block commits. #[async_trait] pub trait IndexStorage: Send + Sync { @@ -41,9 +80,31 @@ pub trait IndexStorage: Send + Sync { /// `completed` state. The worker filters and indexes them per poll. async fn load_monitors(&self, chain: Chain) -> anyhow::Result>; - /// Atomically commits `commit`'s results and advances every covering - /// monitor's cursor to `commit.block_number`. + /// Returns the highest canonical block known for `chain`. + async fn canonical_tip(&self, chain: Chain) -> anyhow::Result>; + + /// Returns the canonical block at `block_number`, if retained. + async fn canonical_block( + &self, + chain: Chain, + block_number: BlockNumber, + ) -> anyhow::Result>; + + /// Atomically commits `commit`'s ledger row, results and monitor cursors. async fn commit_block(&self, commit: &BlockCommit) -> anyhow::Result<()>; + + /// Atomically removes canonical blocks/results above `ancestor` and rewinds + /// all affected monitors. Implementations must reject rollback across a + /// promoted finalized boundary without mutating state. + async fn rollback_to(&self, chain: Chain, ancestor: BlockNumber) -> anyhow::Result<()>; + + /// Promotes provisional blocks through `finalized_head` and returns + /// finalized-only sink batches reconstructed from persisted rows. + async fn promote_finalized( + &self, + chain: Chain, + finalized_head: BlockNumber, + ) -> anyhow::Result>; } /// A registered chain with its RPC URL and enabled state. The supervisor @@ -185,8 +246,16 @@ pub enum ResultRecord { Call { /// Transaction hash. tx_hash: TxHash, + /// Canonical block hash. + block_hash: crate::B256, /// Block the call was included in. block_number: BlockNumber, + /// Transaction sender. + from: crate::Address, + /// Transaction recipient. + to: crate::Address, + /// Lifecycle state of the result. + finality: Finality, /// Canonical JSON encoding of the decoded parameters. params: serde_json::Value, }, @@ -194,10 +263,16 @@ pub enum ResultRecord { Event { /// Transaction hash that emitted the log. tx_hash: TxHash, + /// Canonical block hash. + block_hash: crate::B256, /// Log index within the block. log_index: u64, /// Block the event was emitted in. block_number: BlockNumber, + /// Emitter address. + emitter: crate::Address, + /// Lifecycle state of the result. + finality: Finality, /// Canonical JSON encoding of the decoded parameters. params: serde_json::Value, }, diff --git a/parseon-core/src/ports/telemetry.rs b/parseon-core/src/ports/telemetry.rs index 6f4d940..a58072c 100644 --- a/parseon-core/src/ports/telemetry.rs +++ b/parseon-core/src/ports/telemetry.rs @@ -39,6 +39,10 @@ pub trait Telemetry: Send + Sync { ); /// Sets the current lag (finalized head minus committed head) for `chain_id`. fn set_worker_lag(&self, chain_id: ChainId, lag: BlockNumber); + /// Marks the current lifecycle state of `chain_id` for dashboards and alerts. + fn set_worker_state(&self, chain_id: ChainId, state: &'static str); + /// Records the Unix timestamp of the last successful poll for `chain_id`. + fn set_worker_last_successful_poll(&self, chain_id: ChainId, timestamp: i64); /// Adjusts the in-flight count for `stage` on `chain_id` by `delta` /// (positive on entry, negative on exit). fn adjust_in_flight(&self, chain_id: ChainId, stage: &'static str, delta: i64); @@ -64,6 +68,8 @@ impl Telemetry for NoopTelemetry { fn record_cache(&self, _: ChainId, _: bool) {} fn record_commit(&self, _: ChainId, _: u64, _: u64, _: &'static str, _: Duration) {} fn set_worker_lag(&self, _: ChainId, _: BlockNumber) {} + fn set_worker_state(&self, _: ChainId, _: &'static str) {} + fn set_worker_last_successful_poll(&self, _: ChainId, _: i64) {} fn adjust_in_flight(&self, _: ChainId, _: &'static str, _: i64) {} fn render(&self) -> anyhow::Result { Ok(String::new()) diff --git a/parseon-core/src/status.rs b/parseon-core/src/status.rs index b1bf512..3ef9b3a 100644 --- a/parseon-core/src/status.rs +++ b/parseon-core/src/status.rs @@ -24,6 +24,9 @@ pub enum WorkerState { Running, /// Source validation or the worker's latest poll failed. Degraded, + /// Reorg recovery crossed an already promoted finalized boundary and the + /// worker is halted until operator recovery. + Blocked, /// The chain is disabled and has no worker. Disabled, } @@ -35,8 +38,14 @@ pub struct ChainStatusSnapshot { pub chain_id: ChainId, /// Whether the chain runs a worker. pub enabled: bool, + /// Latest head observed by the worker, if any. + pub latest_head: Option, + /// Highest canonical block persisted by the worker, if any. + pub canonical_head: Option, /// Latest finalized head observed by the worker, if any. pub finalized_head: Option, + /// Highest block eligible for finalized promotion. + pub promotion_height: Option, /// Current worker lifecycle state. pub worker_state: WorkerState, /// When the worker's last poll succeeded, if ever. @@ -62,7 +71,10 @@ impl ChainStatus { Self::new(ChainStatusSnapshot { chain_id, enabled: true, + latest_head: None, + canonical_head: None, finalized_head, + promotion_height: None, worker_state: WorkerState::Starting, last_successful_poll_at: None, last_error: None, @@ -76,7 +88,10 @@ impl ChainStatus { Self::new(ChainStatusSnapshot { chain_id, enabled: true, + latest_head: Some(finalized_head), + canonical_head: Some(finalized_head), finalized_head: Some(finalized_head), + promotion_height: Some(finalized_head), worker_state: WorkerState::Running, last_successful_poll_at: Some(Utc::now()), last_error: None, @@ -89,7 +104,10 @@ impl ChainStatus { Self::new(ChainStatusSnapshot { chain_id, enabled: false, + latest_head: None, + canonical_head: None, finalized_head: None, + promotion_height: None, worker_state: WorkerState::Disabled, last_successful_poll_at: None, last_error: None, @@ -103,7 +121,10 @@ impl ChainStatus { Self::new(ChainStatusSnapshot { chain_id, enabled: true, + latest_head: None, + canonical_head: None, finalized_head: None, + promotion_height: None, worker_state: WorkerState::Degraded, last_successful_poll_at: None, last_error: Some(message.into()), @@ -117,11 +138,30 @@ impl ChainStatus { /// Atomically records a successful poll at `finalized_head`, clearing any /// prior error and transitioning to [`WorkerState::Running`]. pub fn record_success(&self, finalized_head: BlockNumber) { + self.record_success_heads( + finalized_head, + finalized_head, + finalized_head, + Some(finalized_head), + ); + } + + /// Records latest, canonical and finality heads after a successful poll. + pub fn record_success_heads( + &self, + latest_head: BlockNumber, + finalized_head: BlockNumber, + promotion_height: BlockNumber, + canonical_head: Option, + ) { let prev = self.inner.load(); let next = ChainStatusSnapshot { chain_id: prev.chain_id, enabled: prev.enabled, + latest_head: Some(latest_head), + canonical_head, finalized_head: Some(finalized_head), + promotion_height: Some(promotion_height), worker_state: WorkerState::Running, last_successful_poll_at: Some(Utc::now()), last_error: None, @@ -139,7 +179,10 @@ impl ChainStatus { let next = ChainStatusSnapshot { chain_id: prev.chain_id, enabled: prev.enabled, + latest_head: prev.latest_head, + canonical_head: prev.canonical_head, finalized_head: prev.finalized_head, + promotion_height: prev.promotion_height, worker_state: WorkerState::Degraded, last_successful_poll_at: prev.last_successful_poll_at, last_error: Some(message.clone()), @@ -148,6 +191,41 @@ impl ChainStatus { message } + /// Records that the worker task exited without an intentional cancellation. + /// This keeps readiness failed instead of leaving a stale `running` snapshot. + pub fn record_task_exit(&self) { + let prev = self.inner.load(); + self.inner.store(Arc::new(ChainStatusSnapshot { + chain_id: prev.chain_id, + enabled: prev.enabled, + latest_head: prev.latest_head, + canonical_head: prev.canonical_head, + finalized_head: prev.finalized_head, + promotion_height: prev.promotion_height, + worker_state: WorkerState::Degraded, + last_successful_poll_at: prev.last_successful_poll_at, + last_error: Some("worker task exited unexpectedly".to_string()), + })); + } + + /// Records a fail-closed recovery state that requires operator action. + pub fn record_blocked(&self, error: &anyhow::Error) -> String { + let message = safe_error_message(error); + let prev = self.inner.load(); + self.inner.store(Arc::new(ChainStatusSnapshot { + chain_id: prev.chain_id, + enabled: prev.enabled, + latest_head: prev.latest_head, + canonical_head: prev.canonical_head, + finalized_head: prev.finalized_head, + promotion_height: prev.promotion_height, + worker_state: WorkerState::Blocked, + last_successful_poll_at: prev.last_successful_poll_at, + last_error: Some(message.clone()), + })); + message + } + /// Loads the current snapshot. Lock-free. pub fn snapshot(&self) -> ChainStatusSnapshot { (**self.inner.load()).clone() @@ -241,4 +319,13 @@ mod tests { assert_eq!(status.record_error(&error), "block source request failed"); assert_eq!(status.snapshot().last_error.as_deref(), Some("block source request failed")); } + + #[test] + fn records_unexpected_task_exit() { + let status = ChainStatus::starting(1, None); + status.record_task_exit(); + let snapshot = status.snapshot(); + assert_eq!(snapshot.worker_state, WorkerState::Degraded); + assert_eq!(snapshot.last_error.as_deref(), Some("worker task exited unexpectedly")); + } } diff --git a/parseon-core/src/supervisor.rs b/parseon-core/src/supervisor.rs index 154fabf..e1d5510 100644 --- a/parseon-core/src/supervisor.rs +++ b/parseon-core/src/supervisor.rs @@ -12,9 +12,11 @@ use std::collections::HashMap; use std::num::{NonZeroU64, NonZeroUsize}; +use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::time::Duration; +use futures_util::FutureExt; use tokio::sync::{Mutex, MutexGuard, Semaphore}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -22,7 +24,7 @@ use tokio_util::sync::CancellationToken; use super::ports::{ BlockCacheFactory, BlockSource, BlockSourceFactory, RegisteredChain, Sink, Storage, Telemetry, }; -use super::status::{ChainStatus, RuntimeStatus}; +use super::status::{ChainStatus, RuntimeStatus, WorkerState}; use super::worker::{self, WorkerConfig, WorkerDependencies}; use super::{BlockNumber, ChainId, Url}; @@ -39,6 +41,10 @@ pub struct SupervisorConfig { pub block_concurrency: NonZeroUsize, /// Process-wide limit on concurrent storage commits across all workers. pub storage_write_concurrency: NonZeroUsize, + /// Number of latest blocks required before promotion. + pub confirmations: NonZeroU64, + /// Maximum canonical history retained for reorg recovery. + pub rollback_retention: NonZeroU64, } struct WorkerRuntime { @@ -167,9 +173,11 @@ impl SupervisorHandle { (false, true) => { let runtime = workers.remove(&chain_id).expect("worker is running"); stop_worker(runtime).await; + self.shared.telemetry.set_worker_state(chain_id, "disabled"); self.shared.statuses.replace(ChainStatus::disabled(chain_id)); } (false, false) => { + self.shared.telemetry.set_worker_state(chain_id, "disabled"); self.shared.statuses.replace(ChainStatus::disabled(chain_id)); } } @@ -225,6 +233,7 @@ impl SupervisorHandle { Ok(prepared) => prepared, Err(message) => { tracing::warn!(chain_id = chain.id, error = message, "worker start failed"); + self.shared.telemetry.set_worker_state(chain.id, "degraded"); self.shared.statuses.replace(ChainStatus::degraded(chain.id, message)); return; } @@ -232,24 +241,48 @@ impl SupervisorHandle { let status = ChainStatus::starting(chain.id, Some(finalized_head)); self.shared.statuses.replace(status.clone()); let cancel = CancellationToken::new(); - let handle = tokio::spawn(worker::run( - WorkerConfig { - chain, - batch_size: self.shared.config.batch_size, - block_concurrency: self.shared.config.block_concurrency, - poll_interval: self.shared.config.poll_interval, - }, - WorkerDependencies { - storage: self.shared.storage.clone(), - source: source.clone(), - cache: self.shared.cache_factory.create(), - storage_writes: self.shared.storage_writes.clone(), - sink: self.shared.sink.clone(), - telemetry: self.shared.telemetry.clone(), - }, - status, - cancel.clone(), - )); + let worker_config = WorkerConfig { + chain, + batch_size: self.shared.config.batch_size, + block_concurrency: self.shared.config.block_concurrency, + poll_interval: self.shared.config.poll_interval, + confirmations: self.shared.config.confirmations, + rollback_retention: self.shared.config.rollback_retention, + }; + let worker_dependencies = WorkerDependencies { + storage: self.shared.storage.clone(), + source: source.clone(), + cache: self.shared.cache_factory.create(), + storage_writes: self.shared.storage_writes.clone(), + sink: self.shared.sink.clone(), + telemetry: self.shared.telemetry.clone(), + }; + let worker_cancel = cancel.clone(); + let task_status = status.clone(); + let task_cancel = cancel.clone(); + let task_telemetry = self.shared.telemetry.clone(); + let chain_id = chain.id; + let handle = tokio::spawn(async move { + let result = AssertUnwindSafe(worker::run( + worker_config, + worker_dependencies, + status, + worker_cancel, + )) + .catch_unwind() + .await; + if task_cancel.is_cancelled() { + return; + } + if task_status.snapshot().worker_state == WorkerState::Blocked { + return; + } + if let Err(panic) = result { + tracing::error!(chain_id, ?panic, "worker task panicked"); + } + task_status.record_task_exit(); + task_telemetry.set_worker_state(chain_id, "exited"); + }); workers.insert( chain.id, WorkerRuntime { cancel, handle, rpc_url: registered.rpc_url, source }, diff --git a/parseon-core/src/testkit.rs b/parseon-core/src/testkit.rs index 5c427b8..2b2918a 100644 --- a/parseon-core/src/testkit.rs +++ b/parseon-core/src/testkit.rs @@ -25,7 +25,10 @@ use crate::ports::{ }; use crate::status::RuntimeStatus; use crate::supervisor::{Supervisor, SupervisorConfig, SupervisorDependencies, SupervisorHandle}; -use crate::{BlockNumber, Chain, ChainId, ExecutionOutcome, MonitorId, SourceBlock, TxHash, Url}; +use crate::{ + BlockMetadata, BlockNumber, Chain, ChainId, ExecutionOutcome, MonitorId, SourceBlock, TxHash, + Url, +}; /// Parses a test RPC URL. pub(crate) fn url(value: &str) -> Url { @@ -59,18 +62,30 @@ impl BlockSource for TestSource { Ok(self.chain_id) } + async fn latest_head(&self) -> anyhow::Result { + anyhow::ensure!(!self.fail_probe, "probe failure"); + Ok(self.finalized_head) + } + async fn finalized_head(&self) -> anyhow::Result { anyhow::ensure!(!self.fail_probe, "probe failure"); Ok(self.finalized_head) } + async fn fetch_block_header( + &self, + _block_number: BlockNumber, + ) -> anyhow::Result { + anyhow::bail!("unused in tests") + } + async fn fetch_block(&self, _block_number: BlockNumber) -> anyhow::Result { anyhow::bail!("unused in tests") } async fn fetch_execution_outcomes( &self, - _block_number: BlockNumber, + _block: &BlockMetadata, _transaction_hashes: &[TxHash], ) -> anyhow::Result> { anyhow::bail!("unused in tests") @@ -174,9 +189,36 @@ impl IndexStorage for TestStorage { Ok(Vec::new()) } + async fn canonical_tip( + &self, + _chain: Chain, + ) -> anyhow::Result> { + Ok(None) + } + + async fn canonical_block( + &self, + _chain: Chain, + _block_number: BlockNumber, + ) -> anyhow::Result> { + Ok(None) + } + async fn commit_block(&self, _commit: &BlockCommit) -> anyhow::Result<()> { anyhow::bail!("unused in tests") } + + async fn rollback_to(&self, _chain: Chain, _ancestor: BlockNumber) -> anyhow::Result<()> { + anyhow::bail!("unused in tests") + } + + async fn promote_finalized( + &self, + _chain: Chain, + _head: BlockNumber, + ) -> anyhow::Result> { + Ok(Vec::new()) + } } #[async_trait] @@ -293,6 +335,7 @@ impl BlockCache for TestCache { fn put(&self, _chain: Chain, _block: Arc) {} fn evict_before(&self, _chain: Chain, _block_number: BlockNumber) {} + fn evict_after(&self, _chain: Chain, _block_number: BlockNumber) {} } struct TestCacheFactory; @@ -348,6 +391,8 @@ impl TestContext { poll_interval: Duration::from_millis(5), block_concurrency: NonZeroUsize::new(2).expect("non-zero"), storage_write_concurrency: NonZeroUsize::new(2).expect("non-zero"), + confirmations: NonZeroU64::new(64).expect("non-zero"), + rollback_retention: NonZeroU64::new(256).expect("non-zero"), } } diff --git a/parseon-core/src/views.rs b/parseon-core/src/views.rs index d13e3f0..777426f 100644 --- a/parseon-core/src/views.rs +++ b/parseon-core/src/views.rs @@ -9,7 +9,7 @@ use chrono::{DateTime, Utc}; use crate::abi::AbiParam; use crate::filter::FilterExpression; use crate::ports::{ChainRecord, MonitorKind, MonitorRecord, ResultRecord}; -use crate::{Address, B256, BlockNumber, ChainId, MonitorId, Selector, Target, TxHash}; +use crate::{Address, B256, BlockNumber, ChainId, Finality, MonitorId, Selector, Target, TxHash}; /// API view of a registered chain. Omits the RPC URL, which is write-only. #[derive(Debug, Clone)] @@ -109,8 +109,16 @@ pub enum MonitorResultView { Call { /// Transaction hash. tx_hash: TxHash, + /// Canonical block hash. + block_hash: B256, /// Block the call was included in. block_number: BlockNumber, + /// Transaction sender. + from: Address, + /// Transaction recipient. + to: Address, + /// Lifecycle state. + finality: Finality, /// Canonical JSON-encoded parameters. params: serde_json::Value, }, @@ -118,10 +126,16 @@ pub enum MonitorResultView { Event { /// Transaction hash that emitted the log. tx_hash: TxHash, + /// Canonical block hash. + block_hash: B256, /// Log index within the block. log_index: u64, /// Block the event was emitted in. block_number: BlockNumber, + /// Emitter address. + emitter: Address, + /// Lifecycle state. + finality: Finality, /// Canonical JSON-encoded parameters. params: serde_json::Value, }, @@ -130,12 +144,32 @@ pub enum MonitorResultView { impl From for MonitorResultView { fn from(record: ResultRecord) -> Self { match record { - ResultRecord::Call { tx_hash, block_number, params } => { - Self::Call { tx_hash, block_number, params } - } - ResultRecord::Event { tx_hash, log_index, block_number, params } => { - Self::Event { tx_hash, log_index, block_number, params } - } + ResultRecord::Call { + tx_hash, + block_hash, + block_number, + from, + to, + finality, + params, + } => Self::Call { tx_hash, block_hash, block_number, from, to, finality, params }, + ResultRecord::Event { + tx_hash, + block_hash, + log_index, + block_number, + emitter, + finality, + params, + } => Self::Event { + tx_hash, + block_hash, + log_index, + block_number, + emitter, + finality, + params, + }, } } } diff --git a/parseon-core/src/worker/commit.rs b/parseon-core/src/worker/commit.rs index 168db24..3a73844 100644 --- a/parseon-core/src/worker/commit.rs +++ b/parseon-core/src/worker/commit.rs @@ -11,7 +11,8 @@ use super::prepare::PreparedBlock; use super::{BlockNumber, Chain, PollContext}; use crate::DecodedResult; -use crate::ports::{BlockCommit, InFlightGuard, SinkBatch}; +use crate::Finality; +use crate::ports::{BlockCommit, InFlightGuard}; /// Outcome of a commit attempt. pub(super) enum CommitOutcome { @@ -28,11 +29,12 @@ pub(super) enum CommitOutcome { /// /// `progress[i]` is the in-memory cursor for monitor `i` in the /// [`super::indexer::MonitorIndex`]. On success, every index in -/// `prepared.monitor_indices` is set to `Some(prepared.block_number)`. +/// `prepared.monitor_indices` is set to the prepared block number. pub(super) async fn commit_prepared( chain: Chain, context: PollContext<'_>, prepared: PreparedBlock, + finality: Finality, progress: &mut [Option], ) -> anyhow::Result { let calls = @@ -41,7 +43,8 @@ pub(super) async fn commit_prepared( let events = prepared.results.len() as u64 - calls; let commit = BlockCommit { chain, - block_number: prepared.block_number, + metadata: prepared.metadata, + finality, monitors: prepared.monitors, results: prepared.results, }; @@ -58,24 +61,11 @@ pub(super) async fn commit_prepared( match result { Ok(()) => { context.telemetry.record_commit(chain.id, calls, events, "success", started.elapsed()); - if context.sink.enabled() { - match SinkBatch::new(chain, commit.block_number, &commit.monitors, &commit.results) - { - Ok(Some(batch)) => context.sink.submit(batch), - Ok(None) => {} - Err(error) => tracing::warn!( - chain_id = chain.id, - block_number = commit.block_number, - %error, - "failed to encode committed sink batch" - ), - } - } for monitor_index in prepared.monitor_indices { let cursor = progress .get_mut(monitor_index) .ok_or_else(|| anyhow::anyhow!("block plan references unknown monitor"))?; - *cursor = Some(prepared.block_number); + *cursor = Some(prepared.metadata.number); } Ok(CommitOutcome::Committed(commit.results.len())) } diff --git a/parseon-core/src/worker/mod.rs b/parseon-core/src/worker/mod.rs index 1d15864..862eb08 100644 --- a/parseon-core/src/worker/mod.rs +++ b/parseon-core/src/worker/mod.rs @@ -1,9 +1,10 @@ -//! Per-chain worker runtime: polls finalized blocks, decodes calls and events, -//! and commits results atomically. +//! Per-chain worker runtime: polls canonical blocks, decodes calls and events, +//! and commits results atomically with provisional/finalized lifecycle. //! //! The worker is the heart of the indexing pipeline. One worker runs per //! enabled chain, polling the block source at `poll_interval` and processing -//! up to `batch_size` blocks per poll. Within a poll, blocks are prepared +//! up to `batch_size` blocks per poll. Canonical tips are checked before new +//! work so bounded reorgs can rollback and replay safely. Within a poll, blocks are prepared //! concurrently (capped by `block_concurrency`) and committed in block-number //! order so cursors never advance past a gap. //! @@ -24,6 +25,7 @@ use std::num::{NonZeroU64, NonZeroUsize}; use std::sync::Arc; use std::time::Duration; +use chrono::Utc; use futures_util::StreamExt; use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; @@ -31,7 +33,7 @@ use tokio_util::sync::CancellationToken; use super::indexer; use super::scheduler; use super::status::ChainStatus; -use super::{BlockNumber, Chain, Cursor}; +use super::{BlockNumber, Chain, Cursor, Finality}; use crate::ports::{BlockCache, BlockSource, IndexStorage, Sink, Storage, Telemetry}; /// Per-worker static configuration, set at supervisor startup. @@ -45,6 +47,10 @@ pub(crate) struct WorkerConfig { pub(crate) block_concurrency: NonZeroUsize, /// Delay between successful polls. pub(crate) poll_interval: Duration, + /// Number of latest blocks required before promotion. + pub(crate) confirmations: NonZeroU64, + /// Maximum number of canonical blocks retained for ancestor search. + pub(crate) rollback_retention: NonZeroU64, } /// Per-worker dependencies cloned from the supervisor. @@ -89,8 +95,14 @@ pub(crate) struct PollContext<'a> { /// Aggregate result of one successful poll. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PollResult { + /// Latest head observed at the start of the poll. + pub(crate) latest_head: BlockNumber, /// Finalized head observed at the start of the poll. pub(crate) finalized_head: BlockNumber, + /// Highest block safe to expose as finalized. + pub(crate) promotion_height: BlockNumber, + /// Highest canonical block retained after this poll. + pub(crate) canonical_head: Option, /// Number of decoded results committed during this poll. pub(crate) decoded: usize, } @@ -110,6 +122,8 @@ pub(crate) enum PollOutcome { pub(crate) struct SourceStatus { /// EIP-155 chain ID reported by the endpoint. pub(crate) chain_id: u64, + /// Current latest head block number. + pub(crate) latest_head: BlockNumber, /// Current finalized head block number. pub(crate) finalized_head: BlockNumber, } @@ -124,7 +138,8 @@ pub(crate) async fn probe_source(source: &dyn BlockSource) -> anyhow::Result status.record_success(poll.finalized_head), + Ok(PollOutcome::Completed(poll)) => { + status.record_success_heads( + poll.latest_head, + poll.finalized_head, + poll.promotion_height, + poll.canonical_head, + ); + dependencies.telemetry.set_worker_state(config.chain.id, "running"); + dependencies + .telemetry + .set_worker_last_successful_poll(config.chain.id, Utc::now().timestamp()); + } Ok(PollOutcome::Cancelled) => break, Err(error) => { - let message = status.record_error(&error); + let blocked = is_blocking_reorg_error(&error); + let message = if blocked { + dependencies.telemetry.set_worker_state(config.chain.id, "blocked"); + status.record_blocked(&error) + } else { + dependencies.telemetry.set_worker_state(config.chain.id, "degraded"); + status.record_error(&error) + }; tracing::warn!(chain_id = config.chain.id, error = %message, "worker tick failed"); + if blocked { + break; + } } } tokio::select! { @@ -182,11 +219,24 @@ pub(crate) async fn run_once( context: PollContext<'_>, ) -> anyhow::Result { let PollContext { storage, source, cache, telemetry, cancel, .. } = context; + let latest_head = tokio::select! { + biased; + _ = cancel.cancelled() => return Ok(PollOutcome::Cancelled), + latest_head = source.latest_head() => latest_head?, + }; let finalized_head = tokio::select! { biased; _ = cancel.cancelled() => return Ok(PollOutcome::Cancelled), finalized_head = source.finalized_head() => finalized_head?, }; + anyhow::ensure!( + finalized_head <= latest_head, + "source finalized head {finalized_head} exceeds latest head {latest_head}" + ); + let promotion_height = promotion_height(latest_head, finalized_head, config.confirmations); + if reconcile_canonical_tip(config, context, latest_head).await? { + return Ok(PollOutcome::Cancelled); + } let monitors = tokio::select! { biased; _ = cancel.cancelled() => return Ok(PollOutcome::Cancelled), @@ -199,10 +249,34 @@ pub(crate) async fn run_once( return Ok(PollOutcome::Cancelled); } telemetry.set_worker_lag(config.chain.id, 0); - return Ok(PollOutcome::Completed(PollResult { finalized_head, decoded: 0 })); + let batches = tokio::select! { + biased; + _ = cancel.cancelled() => return Ok(PollOutcome::Cancelled), + batches = storage.promote_finalized(config.chain, promotion_height) => batches?, + }; + for batch in batches { + if cancel.is_cancelled() { + return Ok(PollOutcome::Cancelled); + } + if !context.sink.enabled() { + break; + } + context.sink.submit(batch); + } + return Ok(PollOutcome::Completed(PollResult { + latest_head, + finalized_head, + promotion_height, + canonical_head: tokio::select! { + biased; + _ = cancel.cancelled() => return Ok(PollOutcome::Cancelled), + tip = storage.canonical_tip(config.chain) => tip?.map(|block| block.metadata.number), + }, + decoded: 0, + })); } - let plans = scheduler::plan_blocks(active, finalized_head, config.batch_size); + let plans = scheduler::plan_blocks(active, latest_head, config.batch_size); let mut decoded = 0; let mut progress = active.iter().map(|monitor| monitor.cursor.0).collect::>(); @@ -220,8 +294,16 @@ pub(crate) async fn run_once( ) => prepared?, }; for prepared in prepared { - match commit::commit_prepared(config.chain, context, prepared?, &mut progress) - .await? + let prepared = prepared?; + let finality = Finality::Provisional; + match commit::commit_prepared( + config.chain, + context, + prepared, + finality, + &mut progress, + ) + .await? { commit::CommitOutcome::Committed(count) => decoded += count, commit::CommitOutcome::Cancelled => return Ok(PollOutcome::Cancelled), @@ -254,8 +336,15 @@ pub(crate) async fn run_once( let Some(prepared) = next else { break }; let prepared = prepare::finish_prepared(prepared?, monitor_index.as_ref(), Vec::new())?; - match commit::commit_prepared(config.chain, context, prepared, &mut progress) - .await? + let finality = Finality::Provisional; + match commit::commit_prepared( + config.chain, + context, + prepared, + finality, + &mut progress, + ) + .await? { commit::CommitOutcome::Committed(count) => decoded += count, commit::CommitOutcome::Cancelled => return Ok(PollOutcome::Cancelled), @@ -276,7 +365,7 @@ pub(crate) async fn run_once( .iter() .zip(&progress) .map(|(monitor, cursor)| { - let target = monitor.end_block.unwrap_or(finalized_head).min(finalized_head); + let target = monitor.end_block.unwrap_or(latest_head).min(latest_head); match *cursor { Some(cursor) => target.saturating_sub(cursor), None if monitor.start_block <= target => { @@ -287,6 +376,108 @@ pub(crate) async fn run_once( }) .max() .unwrap_or(0); + let batches = tokio::select! { + biased; + _ = cancel.cancelled() => return Ok(PollOutcome::Cancelled), + batches = storage.promote_finalized(config.chain, promotion_height) => batches?, + }; + for batch in batches { + if cancel.is_cancelled() { + return Ok(PollOutcome::Cancelled); + } + if context.sink.enabled() { + context.sink.submit(batch); + } + } telemetry.set_worker_lag(config.chain.id, lag); - Ok(PollOutcome::Completed(PollResult { finalized_head, decoded })) + let canonical_head = tokio::select! { + biased; + _ = cancel.cancelled() => return Ok(PollOutcome::Cancelled), + tip = storage.canonical_tip(config.chain) => tip?.map(|block| block.metadata.number), + }; + Ok(PollOutcome::Completed(PollResult { + latest_head, + finalized_head, + promotion_height, + canonical_head, + decoded, + })) +} + +fn is_blocking_reorg_error(error: &anyhow::Error) -> bool { + let message = error.to_string(); + message.contains("promoted finalized boundary") + || message.contains("canonical ancestor not found within rollback retention") +} + +fn promotion_height( + latest_head: BlockNumber, + finalized_head: BlockNumber, + confirmations: NonZeroU64, +) -> BlockNumber { + finalized_head.min(latest_head.saturating_sub(confirmations.get())) +} + +async fn reconcile_canonical_tip( + config: &WorkerConfig, + context: PollContext<'_>, + latest_head: BlockNumber, +) -> anyhow::Result { + if context.cancel.is_cancelled() { + return Ok(true); + } + let tip = tokio::select! { + biased; + _ = context.cancel.cancelled() => return Ok(true), + tip = context.storage.canonical_tip(config.chain) => tip?, + }; + let Some(tip) = tip else { + return Ok(false); + }; + if latest_head < tip.metadata.number { + return Ok(false); + } + let mut number = tip.metadata.number; + let retention = config.rollback_retention.get(); + for distance in 0..=retention { + let source = tokio::select! { + biased; + _ = context.cancel.cancelled() => return Ok(true), + source = context.source.fetch_block_header(number) => source?, + }; + let stored = tokio::select! { + biased; + _ = context.cancel.cancelled() => return Ok(true), + stored = context.storage.canonical_block(config.chain, number) => stored?, + }; + if stored.is_some_and(|stored| { + stored.metadata.hash == source.hash && stored.metadata.parent_hash == source.parent_hash + }) { + if number < tip.metadata.number { + let rollback = tokio::select! { + biased; + _ = context.cancel.cancelled() => return Ok(true), + rollback = context.storage.rollback_to(config.chain, number) => rollback, + }; + if let Err(error) = rollback { + if error.to_string().contains("promoted finalized boundary") { + anyhow::bail!( + "reorg recovery crossed promoted finalized boundary: {error}" + ); + } + return Err(error); + } + if context.cancel.is_cancelled() { + return Ok(true); + } + context.cache.evict_after(config.chain, number); + } + return Ok(false); + } + if distance == retention || number == 0 { + break; + } + number -= 1; + } + anyhow::bail!("canonical ancestor not found within rollback retention") } diff --git a/parseon-core/src/worker/prepare.rs b/parseon-core/src/worker/prepare.rs index 054c8ec..0a139b5 100644 --- a/parseon-core/src/worker/prepare.rs +++ b/parseon-core/src/worker/prepare.rs @@ -26,13 +26,13 @@ use crate::pipeline; use crate::ports::{ BlockCache, BlockRange, BlockSource, InFlightGuard, LogQuery, LogTarget, Telemetry, }; -use crate::{BlockNumber, Chain, DecodedResult, Selector, SourceLog, Target}; +use crate::{Chain, DecodedResult, Selector, SourceLog, Target}; /// One block's prepared results together with the monitor indices that cover /// it and the resolved monitor Arcs needed for the commit. pub(super) struct PreparedBlock { - /// Block number being committed. - pub(super) block_number: BlockNumber, + /// Canonical identity of the block being committed. + pub(super) metadata: crate::BlockMetadata, /// Indices of monitors in [`MonitorIndex`] whose cursors must advance to /// `block_number`. pub(super) monitor_indices: Vec, @@ -46,6 +46,7 @@ pub(super) struct PreparedBlock { /// [`finish_prepared`]). pub(super) struct PreparedCalls { plan: scheduler::BlockPlan, + metadata: crate::BlockMetadata, results: Vec, } @@ -105,7 +106,7 @@ pub(super) fn log_query( /// outcomes, and decodes the matching calls. /// /// Returns an empty `results` vector if no monitor in `plan.monitor_indices` -/// targets a call (the block fetch is skipped in that case). +/// targets a call; event-only plans fetch a header for canonical identity. pub(super) async fn prepare_calls( chain: Chain, plan: scheduler::BlockPlan, @@ -121,7 +122,13 @@ pub(super) async fn prepare_calls( .is_some_and(|monitor| matches!(&monitor.target, Target::Call(_))) }); if !has_calls { - return Ok(PreparedCalls { plan, results: Vec::new() }); + let metadata = source.fetch_block_header(plan.block_number).await?; + anyhow::ensure!( + metadata.number == plan.block_number, + "block header does not match requested block {}", + plan.block_number + ); + return Ok(PreparedCalls { plan, metadata, results: Vec::new() }); } let block = match cache.get(chain, plan.block_number) { Some(block) => { @@ -135,6 +142,11 @@ pub(super) async fn prepare_calls( block } }; + anyhow::ensure!( + block.number == block.metadata.number && block.number == plan.block_number, + "block metadata does not match requested block {}", + plan.block_number + ); let candidates = block .transactions .iter() @@ -151,7 +163,7 @@ pub(super) async fn prepare_calls( let outcomes = if hashes.is_empty() { Vec::new() } else { - source.fetch_execution_outcomes(plan.block_number, &hashes).await? + source.fetch_execution_outcomes(&block.metadata, &hashes).await? }; let results = indexer::decode_calls( &block, @@ -163,7 +175,7 @@ pub(super) async fn prepare_calls( .into_iter() .map(DecodedResult::Call) .collect(); - Ok(PreparedCalls { plan, results }) + Ok(PreparedCalls { metadata: block.metadata, plan, results }) } /// Concurrently prepares calls for every block in a window while fetching the @@ -239,6 +251,13 @@ pub(super) fn finish_prepared( monitor_index: &MonitorIndex, logs: Vec, ) -> anyhow::Result { + for log in &logs { + anyhow::ensure!( + log.block_hash == Some(prepared.metadata.hash), + "log block hash does not match fetched block {}", + prepared.plan.block_number + ); + } prepared.results.extend( indexer::decode_events( prepared.plan.block_number, @@ -261,7 +280,7 @@ pub(super) fn finish_prepared( }) .collect::>()?; Ok(PreparedBlock { - block_number: prepared.plan.block_number, + metadata: prepared.metadata, monitor_indices: prepared.plan.monitor_indices, monitors, results: prepared.results, diff --git a/parseon-core/src/worker/tests.rs b/parseon-core/src/worker/tests.rs index 060db8b..edc48a4 100644 --- a/parseon-core/src/worker/tests.rs +++ b/parseon-core/src/worker/tests.rs @@ -20,7 +20,7 @@ use tokio_util::sync::CancellationToken; use super::{ BlockNumber, Chain, PollContext, PollOutcome, PollResult, SourceStatus, WorkerConfig, - probe_source, run_once, + probe_source, promotion_height, run_once, }; use crate::filter::Filter; use crate::monitor::Monitor; @@ -30,8 +30,8 @@ use crate::ports::{ }; use crate::status::ChainStatus; use crate::{ - BlockTransaction, CallTarget, Cursor, EventTarget, ExecutionOutcome, MonitorId, SourceBlock, - Target, + BlockMetadata, BlockTransaction, CallTarget, Cursor, EventTarget, ExecutionOutcome, MonitorId, + SourceBlock, Target, }; struct FakeStorage { @@ -66,10 +66,37 @@ impl IndexStorage for FakeStorage { Ok((self.monitor.chain == chain).then(|| self.monitor.clone()).into_iter().collect()) } + async fn canonical_tip( + &self, + _chain: Chain, + ) -> anyhow::Result> { + Ok(None) + } + + async fn canonical_block( + &self, + _chain: Chain, + _block_number: BlockNumber, + ) -> anyhow::Result> { + Ok(None) + } + async fn commit_block(&self, commit: &BlockCommit) -> anyhow::Result<()> { self.commits.lock().unwrap().push(commit.clone()); Ok(()) } + + async fn rollback_to(&self, _chain: Chain, _ancestor: BlockNumber) -> anyhow::Result<()> { + Ok(()) + } + + async fn promote_finalized( + &self, + _chain: Chain, + _head: BlockNumber, + ) -> anyhow::Result> { + Ok(Vec::new()) + } } #[async_trait] @@ -78,10 +105,37 @@ impl IndexStorage for MultiMonitorStorage { Ok(self.monitors.iter().filter(|monitor| monitor.chain == chain).cloned().collect()) } + async fn canonical_tip( + &self, + _chain: Chain, + ) -> anyhow::Result> { + Ok(None) + } + + async fn canonical_block( + &self, + _chain: Chain, + _block_number: BlockNumber, + ) -> anyhow::Result> { + Ok(None) + } + async fn commit_block(&self, commit: &BlockCommit) -> anyhow::Result<()> { self.commits.lock().unwrap().push(commit.clone()); Ok(()) } + + async fn rollback_to(&self, _chain: Chain, _ancestor: BlockNumber) -> anyhow::Result<()> { + Ok(()) + } + + async fn promote_finalized( + &self, + _chain: Chain, + _head: BlockNumber, + ) -> anyhow::Result> { + Ok(Vec::new()) + } } #[async_trait] @@ -90,9 +144,36 @@ impl IndexStorage for RejectingStorage { Ok((self.0.chain == chain).then(|| self.0.clone()).into_iter().collect()) } + async fn canonical_tip( + &self, + _chain: Chain, + ) -> anyhow::Result> { + Ok(None) + } + + async fn canonical_block( + &self, + _chain: Chain, + _block_number: BlockNumber, + ) -> anyhow::Result> { + Ok(None) + } + async fn commit_block(&self, _: &BlockCommit) -> anyhow::Result<()> { anyhow::bail!("storage commit failed") } + + async fn rollback_to(&self, _chain: Chain, _ancestor: BlockNumber) -> anyhow::Result<()> { + Ok(()) + } + + async fn promote_finalized( + &self, + _chain: Chain, + _head: BlockNumber, + ) -> anyhow::Result> { + Ok(Vec::new()) + } } struct FakeSource; @@ -118,6 +199,88 @@ struct ParallelSource { fail_at: Option, } +struct ReorgStorage { + tip: crate::ports::CanonicalBlock, + ancestor: crate::ports::CanonicalBlock, + rollback: Mutex>, +} + +struct ForkSource; + +#[async_trait] +impl IndexStorage for ReorgStorage { + async fn load_monitors(&self, _chain: Chain) -> anyhow::Result> { + Ok(Vec::new()) + } + + async fn canonical_tip( + &self, + _chain: Chain, + ) -> anyhow::Result> { + Ok(Some(self.tip.clone())) + } + + async fn canonical_block( + &self, + _chain: Chain, + number: BlockNumber, + ) -> anyhow::Result> { + Ok((number == self.ancestor.metadata.number).then(|| self.ancestor.clone())) + } + + async fn commit_block(&self, _commit: &BlockCommit) -> anyhow::Result<()> { + Ok(()) + } + + async fn rollback_to(&self, _chain: Chain, ancestor: BlockNumber) -> anyhow::Result<()> { + self.rollback.lock().unwrap().push(ancestor); + Ok(()) + } + + async fn promote_finalized( + &self, + _chain: Chain, + _head: BlockNumber, + ) -> anyhow::Result> { + Ok(Vec::new()) + } +} + +#[async_trait] +impl BlockSource for ForkSource { + async fn chain_id(&self) -> anyhow::Result { + Ok(1) + } + async fn latest_head(&self) -> anyhow::Result { + Ok(10) + } + async fn finalized_head(&self) -> anyhow::Result { + Ok(10) + } + async fn fetch_block_header(&self, number: BlockNumber) -> anyhow::Result { + Ok(BlockMetadata { + number, + hash: if number == 10 { + B256::repeat_byte(0xb) + } else { + B256::repeat_byte(number as u8) + }, + parent_hash: if number == 10 { B256::repeat_byte(9) } else { B256::ZERO }, + timestamp: number, + }) + } + async fn fetch_block(&self, _number: BlockNumber) -> anyhow::Result { + unreachable!() + } + async fn fetch_execution_outcomes( + &self, + _block: &BlockMetadata, + _hashes: &[B256], + ) -> anyhow::Result> { + unreachable!() + } +} + impl ParallelSource { fn new(fail_at: Option) -> Self { Self { current: AtomicUsize::new(0), maximum: AtomicUsize::new(0), fail_at } @@ -144,6 +307,13 @@ impl BlockCache for FakeCache { .unwrap() .retain(|(chain_id, number), _| *chain_id != chain.id || *number >= block_number); } + + fn evict_after(&self, chain: Chain, block_number: BlockNumber) { + self.blocks + .lock() + .unwrap() + .retain(|(chain_id, number), _| *chain_id != chain.id || *number <= block_number); + } } #[async_trait] @@ -157,12 +327,21 @@ impl BlockSource for FakeSource { } async fn fetch_block(&self, block_number: BlockNumber) -> anyhow::Result { - Ok(SourceBlock { number: block_number, transactions: Vec::new() }) + Ok(SourceBlock { + number: block_number, + metadata: BlockMetadata { + number: block_number, + hash: B256::from([block_number as u8; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, + transactions: Vec::new(), + }) } async fn fetch_execution_outcomes( &self, - _block_number: BlockNumber, + _block: &BlockMetadata, _transaction_hashes: &[B256], ) -> anyhow::Result> { Ok(Vec::new()) @@ -182,6 +361,12 @@ impl BlockSource for CandidateSource { async fn fetch_block(&self, block_number: BlockNumber) -> anyhow::Result { Ok(SourceBlock { number: block_number, + metadata: BlockMetadata { + number: block_number, + hash: B256::from([block_number as u8; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, transactions: vec![ BlockTransaction { hash: B256::repeat_byte(1), @@ -213,7 +398,7 @@ impl BlockSource for CandidateSource { async fn fetch_execution_outcomes( &self, - _block_number: BlockNumber, + _block: &BlockMetadata, transaction_hashes: &[B256], ) -> anyhow::Result> { self.candidates.lock().unwrap().extend_from_slice(transaction_hashes); @@ -241,7 +426,7 @@ impl BlockSource for UnsupportedFinalizedSource { async fn fetch_execution_outcomes( &self, - _block_number: BlockNumber, + _block: &BlockMetadata, _transaction_hashes: &[B256], ) -> anyhow::Result> { unreachable!() @@ -264,7 +449,7 @@ impl BlockSource for PendingSource { async fn fetch_execution_outcomes( &self, - _block_number: BlockNumber, + _block: &BlockMetadata, _transaction_hashes: &[B256], ) -> anyhow::Result> { unreachable!() @@ -281,13 +466,22 @@ impl BlockSource for RecordingLogSource { Ok(12) } + async fn fetch_block_header(&self, block_number: BlockNumber) -> anyhow::Result { + Ok(BlockMetadata { + number: block_number, + hash: B256::from([block_number as u8; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }) + } + async fn fetch_block(&self, _block_number: BlockNumber) -> anyhow::Result { unreachable!() } async fn fetch_execution_outcomes( &self, - _block_number: BlockNumber, + _block: &BlockMetadata, _transaction_hashes: &[B256], ) -> anyhow::Result> { unreachable!() @@ -318,23 +512,93 @@ impl BlockSource for ParallelSource { if self.fail_at == Some(block_number) { anyhow::bail!("block {block_number} failed") } - Ok(SourceBlock { number: block_number, transactions: Vec::new() }) + Ok(SourceBlock { + number: block_number, + metadata: BlockMetadata { + number: block_number, + hash: B256::from([block_number as u8; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, + transactions: Vec::new(), + }) } async fn fetch_execution_outcomes( &self, - _block_number: BlockNumber, + _block: &BlockMetadata, _transaction_hashes: &[B256], ) -> anyhow::Result> { Ok(Vec::new()) } } +#[test] +fn promotion_height_is_bounded_by_finality_and_confirmations() { + assert_eq!(promotion_height(100, 90, NonZeroU64::new(64).unwrap()), 36); + assert_eq!(promotion_height(10, 10, NonZeroU64::new(64).unwrap()), 0); + assert_eq!(promotion_height(u64::MAX, u64::MAX, NonZeroU64::new(64).unwrap()), u64::MAX - 64); +} + +#[tokio::test] +async fn rolls_back_to_the_bounded_common_ancestor() { + let chain = Chain::new(1); + let ancestor = crate::ports::CanonicalBlock { + chain, + metadata: BlockMetadata { + number: 9, + hash: B256::repeat_byte(9), + parent_hash: B256::ZERO, + timestamp: 9, + }, + finality: crate::Finality::Provisional, + }; + let storage = ReorgStorage { + tip: crate::ports::CanonicalBlock { + chain, + metadata: BlockMetadata { + number: 10, + hash: B256::repeat_byte(0xa), + parent_hash: B256::repeat_byte(8), + timestamp: 10, + }, + finality: crate::Finality::Provisional, + }, + ancestor, + rollback: Mutex::new(Vec::new()), + }; + let config = WorkerConfig { + chain, + batch_size: NonZeroU64::new(1).unwrap(), + block_concurrency: NonZeroUsize::new(1).unwrap(), + poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(1).unwrap(), + rollback_retention: NonZeroU64::new(2).unwrap(), + }; + let cancel = CancellationToken::new(); + super::reconcile_canonical_tip( + &config, + PollContext { + storage: &storage, + source: &ForkSource, + cache: &FakeCache::default(), + storage_writes: &Semaphore::new(1), + sink: &NoopSink, + telemetry: &NoopTelemetry, + cancel: &cancel, + }, + 10, + ) + .await + .unwrap(); + assert_eq!(*storage.rollback.lock().unwrap(), [9]); +} + #[tokio::test] async fn startup_probe_requires_finalized_tag_support() { assert_eq!( probe_source(&FakeSource).await.unwrap(), - SourceStatus { chain_id: 1, finalized_head: 10 } + SourceStatus { chain_id: 1, latest_head: 10, finalized_head: 10 } ); let error = probe_source(&UnsupportedFinalizedSource).await.unwrap_err(); @@ -366,6 +630,8 @@ async fn cancellation_interrupts_initial_source_io() { batch_size: NonZeroU64::new(1).unwrap(), block_concurrency: NonZeroUsize::new(1).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let cache = FakeCache::default(); let storage_writes = Semaphore::new(1); @@ -421,6 +687,8 @@ async fn commits_cursor_progress_when_block_has_no_matches() { batch_size: NonZeroU64::new(1).unwrap(), block_concurrency: NonZeroUsize::new(1).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let storage_writes = Semaphore::new(1); let telemetry = NoopTelemetry; @@ -445,7 +713,7 @@ async fn commits_cursor_progress_when_block_has_no_matches() { assert_eq!(poll.decoded, 0); let commits = storage.commits.lock().unwrap(); assert_eq!(commits.len(), 1); - assert_eq!(commits[0].block_number, 10); + assert_eq!(commits[0].metadata.number, 10); assert_eq!(commits[0].monitors[0].id.get(), 7); } @@ -475,6 +743,8 @@ async fn fetches_execution_only_for_indexed_call_targets() { batch_size: NonZeroU64::new(1).unwrap(), block_concurrency: NonZeroUsize::new(1).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; completed( @@ -525,6 +795,8 @@ async fn fetches_once_and_fans_out_overlapping_call_monitors() { batch_size: NonZeroU64::new(1).unwrap(), block_concurrency: NonZeroUsize::new(1).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let poll = completed( @@ -590,6 +862,8 @@ async fn fetches_contiguous_event_blocks_with_one_ranged_log_query() { batch_size: NonZeroU64::new(3).unwrap(), block_concurrency: NonZeroUsize::new(3).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let poll = completed( @@ -618,7 +892,7 @@ async fn fetches_contiguous_event_blocks_with_one_ranged_log_query() { } #[tokio::test] -async fn submits_non_empty_batches_only_after_successful_commit() { +async fn does_not_submit_provisional_batches_before_promotion() { let monitor = Monitor { id: MonitorId::new(7).unwrap(), chain: Chain::new(1), @@ -639,6 +913,8 @@ async fn submits_non_empty_batches_only_after_successful_commit() { batch_size: NonZeroU64::new(1).unwrap(), block_concurrency: NonZeroUsize::new(1).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let sink = RecordingSink::default(); let storage = FakeStorage { monitor: monitor.clone(), commits: Mutex::new(Vec::new()) }; @@ -659,8 +935,7 @@ async fn submits_non_empty_batches_only_after_successful_commit() { .unwrap(), ); assert_eq!(storage.commits.lock().unwrap().len(), 1); - assert_eq!(sink.0.lock().unwrap().len(), 1); - assert_eq!(sink.0.lock().unwrap()[0].results.len(), 1); + assert!(sink.0.lock().unwrap().is_empty()); let sink = RecordingSink::default(); assert!( @@ -707,6 +982,8 @@ async fn ignores_monitors_owned_by_another_chain() { batch_size: NonZeroU64::new(1).unwrap(), block_concurrency: NonZeroUsize::new(1).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let storage_writes = Semaphore::new(1); let telemetry = NoopTelemetry; @@ -758,6 +1035,8 @@ async fn status_keeps_last_head_and_degrades_after_rpc_failure() { batch_size: NonZeroU64::new(1).unwrap(), block_concurrency: NonZeroUsize::new(1).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let storage_writes = Semaphore::new(1); let telemetry = NoopTelemetry; @@ -828,6 +1107,8 @@ async fn prepares_concurrently_but_commits_in_block_order() { batch_size: NonZeroU64::new(3).unwrap(), block_concurrency: NonZeroUsize::new(3).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; completed( @@ -853,7 +1134,7 @@ async fn prepares_concurrently_but_commits_in_block_order() { .lock() .unwrap() .iter() - .map(|commit| commit.block_number) + .map(|commit| commit.metadata.number) .collect::>(); assert_eq!(committed, [10, 11, 12]); } @@ -884,6 +1165,8 @@ async fn never_commits_past_a_failed_block() { batch_size: NonZeroU64::new(3).unwrap(), block_concurrency: NonZeroUsize::new(3).unwrap(), poll_interval: Duration::from_millis(100), + confirmations: NonZeroU64::new(64).unwrap(), + rollback_retention: NonZeroU64::new(256).unwrap(), }; let error = run_once( @@ -907,7 +1190,7 @@ async fn never_commits_past_a_failed_block() { .lock() .unwrap() .iter() - .map(|commit| commit.block_number) + .map(|commit| commit.metadata.number) .collect::>(); assert_eq!(committed, [10]); } diff --git a/parseon-memory-cache/src/lib.rs b/parseon-memory-cache/src/lib.rs index 7cf9298..6a88ecf 100644 --- a/parseon-memory-cache/src/lib.rs +++ b/parseon-memory-cache/src/lib.rs @@ -36,6 +36,12 @@ impl BlockCache for MemoryBlockCache { .invalidate_entries_if(move |key, _| key.0 == chain.id && key.1 < block_number) .expect("invalidation closures are enabled for the block cache"); } + + fn evict_after(&self, chain: Chain, block_number: BlockNumber) { + self.inner + .invalidate_entries_if(move |key, _| key.0 == chain.id && key.1 > block_number) + .expect("invalidation closures are enabled for the block cache"); + } } pub struct MemoryBlockCacheFactory { @@ -67,16 +73,29 @@ impl BlockCache for DisabledBlockCache { fn put(&self, _chain: Chain, _block: Arc) {} fn evict_before(&self, _chain: Chain, _block_number: BlockNumber) {} + + fn evict_after(&self, _chain: Chain, _block_number: BlockNumber) {} } #[cfg(test)] mod tests { use std::thread; + use parseon_core::{B256, BlockMetadata}; + use super::*; fn block(number: BlockNumber) -> Arc { - Arc::new(SourceBlock { number, transactions: Vec::new() }) + Arc::new(SourceBlock { + number, + metadata: BlockMetadata { + number, + hash: B256::from([number as u8; 32]), + parent_hash: B256::ZERO, + timestamp: 0, + }, + transactions: Vec::new(), + }) } #[test] @@ -100,6 +119,7 @@ mod tests { cache.put(second_chain, block(11)); cache.put(mainnet, block(10)); cache.evict_before(second_chain, 11); + cache.evict_after(second_chain, 11); assert!(cache.get(second_chain, 10).is_none()); assert!(cache.get(second_chain, 11).is_some()); assert!(cache.get(mainnet, 10).is_some()); @@ -141,5 +161,6 @@ mod tests { cache.put(chain, block(10)); assert!(cache.get(chain, 10).is_none()); cache.evict_before(chain, 11); + cache.evict_after(chain, 11); } } diff --git a/parseon-mongodb/Cargo.toml b/parseon-mongodb/Cargo.toml index 32ec958..11fca1e 100644 --- a/parseon-mongodb/Cargo.toml +++ b/parseon-mongodb/Cargo.toml @@ -18,6 +18,7 @@ mongodb = "3.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tracing = "0.1" +tokio = { version = "1.52", features = ["time"] } [dev-dependencies] tokio = { version = "1.52", features = ["macros", "rt-multi-thread"] } diff --git a/parseon-mongodb/src/storage.rs b/parseon-mongodb/src/storage.rs index 5f76c83..c563f9c 100644 --- a/parseon-mongodb/src/storage.rs +++ b/parseon-mongodb/src/storage.rs @@ -8,6 +8,7 @@ use mongodb::bson::{self, Binary, Bson, Document, doc}; use mongodb::error::ErrorKind; use mongodb::options::{IndexOptions, ReturnDocument}; use mongodb::{Client, ClientSession, Collection, Database, IndexModel}; +use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use parseon_core::abi::{AbiParam, parse_abi_type}; @@ -15,12 +16,13 @@ use parseon_core::commands::ResultQuery; use parseon_core::filter::{Filter, FilterDefinition, FilterExpression}; use parseon_core::monitor::Monitor; use parseon_core::ports::{ - BlockCommit, ChainRecord, ChainRepository, ChainUpdate, IndexStorage, MonitorRecord, - MonitorRepository, NewChain, NewMonitor, RegisteredChain, ResultRecord, ResultRepository, + BlockCommit, CanonicalBlock, ChainRecord, ChainRepository, ChainUpdate, IndexStorage, + MonitorRecord, MonitorRepository, NewChain, NewMonitor, RegisteredChain, ResultRecord, + ResultRepository, }; use parseon_core::{ - Address, B256, CallTarget, Chain, Cursor, DecodedResult, DecodedValue, EventTarget, MonitorId, - Selector, Target, TxHash, Url, + Address, B256, BlockMetadata, CallTarget, Chain, Cursor, DecodedResult, DecodedValue, + EventTarget, Finality, MonitorId, Selector, Target, TxHash, Url, }; type AppResult = anyhow::Result; @@ -29,6 +31,8 @@ const LEGACY_MONITOR_TARGET_INDEX: &str = "monitors_target"; const MONITOR_TARGET_LOOKUP_INDEX: &str = "monitors_target_lookup"; const NAMESPACE_NOT_FOUND: i32 = 26; const INDEX_NOT_FOUND: i32 = 27; +const SCHEMA_VERSION: i64 = 1; +const MAX_TRANSACTION_ATTEMPTS: usize = 8; #[derive(Clone)] pub struct MongoStorage { @@ -43,8 +47,6 @@ struct ChainDocument { enabled: bool, created_at: bson::DateTime, updated_at: bson::DateTime, - #[serde(default)] - monitor_revision: i64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -74,14 +76,28 @@ struct MonitorDocument { updated_at: bson::DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CanonicalBlockDocument { + chain_id: i64, + block_number: i64, + block_hash: String, + parent_hash: String, + block_timestamp: i64, + finality: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct ResultDocument { chain_id: i64, monitor_id: i64, kind: String, tx_hash: String, + block_hash: String, log_index: Option, block_number: i64, + from: Option, + to: Option, + finality: String, params: Document, } @@ -95,6 +111,7 @@ impl MongoStorage { "MongoDB storage requires a replica set or sharded deployment" ); let storage = Self { database: client.database(database), client }; + storage.ensure_schema().await?; storage.create_indexes().await?; tracing::info!(database, "MongoDB storage initialized"); Ok(storage) @@ -112,18 +129,146 @@ impl MongoStorage { self.database.collection("results") } + fn blocks(&self) -> Collection { + self.database.collection("canonical_blocks") + } + fn counters(&self) -> Collection { self.database.collection("counters") } + async fn ensure_schema(&self) -> AppResult<()> { + self.ensure_required_fields( + "chains", + &["chain_id", "rpc_url", "enabled", "created_at", "updated_at"], + ) + .await?; + self.ensure_required_fields( + "monitors", + &[ + "id", + "chain_id", + "address", + "kind", + "signature_hash", + "param_schema", + "start_block", + "end_block", + "cursor", + "completed", + "enabled", + "created_at", + "updated_at", + ], + ) + .await?; + self.ensure_required_fields( + "results", + &[ + "chain_id", + "monitor_id", + "kind", + "tx_hash", + "block_hash", + "block_number", + "finality", + "params", + ], + ) + .await?; + self.ensure_required_fields( + "canonical_blocks", + &[ + "chain_id", + "block_number", + "block_hash", + "parent_hash", + "block_timestamp", + "finality", + ], + ) + .await?; + self.ensure_decodable::("chains").await?; + self.ensure_decodable::("monitors").await?; + self.ensure_decodable::("results").await?; + self.ensure_decodable::("canonical_blocks").await?; + let metadata = self.database.collection::("schema_metadata"); + let row = metadata.find_one(doc! { "_id": "parseon" }).await?; + if let Some(row) = row { + anyhow::ensure!( + row.get_i64("version")? == SCHEMA_VERSION, + "unsupported Parseon MongoDB schema version" + ); + } else { + metadata.insert_one(doc! { "_id": "parseon", "version": SCHEMA_VERSION }).await?; + } + Ok(()) + } + + async fn ensure_required_fields(&self, collection: &str, fields: &[&str]) -> AppResult<()> { + let missing = fields + .iter() + .map(|field| { + let mut condition = Document::new(); + condition.insert("$exists", false); + let mut clause = Document::new(); + clause.insert(*field, condition); + Bson::Document(clause) + }) + .collect::>(); + let legacy = self + .database + .collection::(collection) + .find_one(doc! { "$or": Bson::Array(missing) }) + .await?; + anyhow::ensure!( + legacy.is_none(), + "MongoDB collection {collection} contains pre-v1 documents with missing required fields; reset and reindex before upgrade" + ); + Ok(()) + } + + async fn ensure_decodable(&self, collection: &str) -> AppResult<()> + where + T: DeserializeOwned, + { + if let Some(document) = + self.database.collection::(collection).find_one(doc! {}).await? + { + bson::from_document::(document).map_err(|error| { + anyhow::anyhow!( + "MongoDB collection {collection} contains an invalid v1 document: {error}" + ) + })?; + } + Ok(()) + } + async fn create_indexes(&self) -> AppResult<()> { + match self.blocks().drop_index("blocks_chain_hash").await { + Ok(_) => {} + Err(error) if missing_index(&error) => {} + Err(error) => return Err(error.into()), + } self.drop_legacy_monitor_target_index().await?; + self.drop_legacy_result_indexes().await?; for (collection, index) in indexes() { self.database.collection::(collection).create_index(index).await?; } Ok(()) } + async fn drop_legacy_result_indexes(&self) -> AppResult<()> { + for name in ["results_call_identity", "results_event_identity"] { + match self.results().drop_index(name).await { + Ok(_) => {} + Err(error) if missing_index(&error) => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) + } + async fn drop_legacy_monitor_target_index(&self) -> AppResult<()> { match self .database @@ -144,30 +289,40 @@ impl MongoStorage { ) -> futures_util::future::BoxFuture<'a, AppResult>, ) -> AppResult { let mut session = self.client.start_session().await?; - 'transaction: loop { + 'transaction: for attempt in 0..MAX_TRANSACTION_ATTEMPTS { session.start_transaction().await?; let value = match operation(&mut session).await { Ok(value) => value, Err(error) => { drop(session.abort_transaction().await); - if transient(&error) { - continue; + if transient(&error) && attempt + 1 < MAX_TRANSACTION_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis( + 1_u64 << attempt.min(6), + )) + .await; + continue 'transaction; } return Err(error); } }; - loop { - match session.commit_transaction().await { - Ok(()) => return Ok(value), - Err(error) if error.contains_label("UnknownTransactionCommitResult") => {} - Err(error) if error.contains_label("TransientTransactionError") => { - drop(session.abort_transaction().await); - continue 'transaction; - } - Err(error) => return Err(error.into()), + match session.commit_transaction().await { + Ok(()) => return Ok(value), + Err(error) if error.contains_label("UnknownTransactionCommitResult") => { + return Err(error.into()); + } + Err(error) + if error.contains_label("TransientTransactionError") + && attempt + 1 < MAX_TRANSACTION_ATTEMPTS => + { + drop(session.abort_transaction().await); + tokio::time::sleep(std::time::Duration::from_millis(1_u64 << attempt.min(6))) + .await; + continue 'transaction; } + Err(error) => return Err(error.into()), } } + anyhow::bail!("MongoDB transaction exceeded retry limit") } fn target(row: &MonitorDocument) -> AppResult { @@ -217,6 +372,24 @@ impl MongoStorage { }) } + fn canonical(row: CanonicalBlockDocument) -> AppResult { + let finality = match row.finality.as_str() { + "provisional" => Finality::Provisional, + "finalized" => Finality::Finalized, + value => anyhow::bail!("invalid canonical block finality {value}"), + }; + Ok(CanonicalBlock { + chain: Chain::new(from_i64(row.chain_id, "chain id")?), + metadata: BlockMetadata { + number: from_i64(row.block_number, "block number")?, + hash: B256::from_str(&row.block_hash)?, + parent_hash: B256::from_str(&row.parent_hash)?, + timestamp: from_i64(row.block_timestamp, "block timestamp")?, + }, + finality, + }) + } + fn chain_record(row: ChainDocument) -> AppResult { Ok(ChainRecord { chain: Chain::new(from_i64(row.chain_id, "chain id")?), @@ -259,22 +432,41 @@ impl IndexStorage for MongoStorage { .await?; let mut monitors = Vec::new(); while let Some(row) = rows.try_next().await? { - let monitor = Self::monitor(&row)?; - if monitor.enabled && !monitor.completed { - monitors.push(monitor); - } + monitors.push(Self::monitor(&row)?); } Ok(monitors) } + async fn canonical_tip(&self, chain: Chain) -> AppResult> { + Ok(self + .blocks() + .find_one(doc! { "chain_id": to_i64(chain.id, "chain id")? }) + .sort(doc! { "block_number": -1 }) + .await? + .map(Self::canonical) + .transpose()?) + } + + async fn canonical_block( + &self, + chain: Chain, + block_number: u64, + ) -> AppResult> { + Ok(self + .blocks() + .find_one(doc! { + "chain_id": to_i64(chain.id, "chain id")?, + "block_number": to_i64(block_number, "block number")? + }) + .await? + .map(Self::canonical) + .transpose()?) + } + async fn commit_block(&self, commit: &BlockCommit) -> AppResult<()> { - anyhow::ensure!( - commit.monitors.iter().all(|monitor| monitor.chain == commit.chain), - "cross-chain monitor set rejected for chain {}", - commit.chain.id - ); + commit.validate()?; let chain_id = to_i64(commit.chain.id, "chain id")?; - let block_number = to_i64(commit.block_number, "block number")?; + let block_number = to_i64(commit.metadata.number, "block number")?; let mut ids = commit .monitors .iter() @@ -290,16 +482,66 @@ impl IndexStorage for MongoStorage { let documents = commit .results .iter() - .map(|result| result_document(chain_id, &monitors, result)) + .map(|result| result_document(chain_id, commit.finality, &monitors, result)) .collect::>>()?; let collection = self.monitors(); let results = self.results(); + let blocks = self.blocks(); + let block = CanonicalBlockDocument { + chain_id, + block_number, + block_hash: format!("{:#x}", commit.metadata.hash), + parent_hash: format!("{:#x}", commit.metadata.parent_hash), + block_timestamp: to_i64(commit.metadata.timestamp, "block timestamp")?, + finality: commit.finality.as_str().into(), + }; + let commit_finality = commit.finality; self.transaction(move |session| { let collection = collection.clone(); let results = results.clone(); + let blocks = blocks.clone(); + let block = block.clone(); let ids = ids.clone(); - let documents = documents.clone(); + let mut documents = documents.clone(); Box::pin(async move { + let existing = blocks + .find_one(doc! { "chain_id": chain_id, "block_number": block_number }) + .session(&mut *session) + .await?; + let effective_finality = if let Some(existing) = existing { + anyhow::ensure!( + existing.block_hash == block.block_hash + && existing.parent_hash == block.parent_hash, + "canonical block {} hash changed without rollback", + block_number + ); + anyhow::ensure!( + existing.finality == Finality::Provisional.as_str() + || existing.finality == Finality::Finalized.as_str(), + "canonical block {} has invalid finality", + block_number + ); + let effective = existing.finality == Finality::Finalized.as_str() + || block.finality == Finality::Finalized.as_str(); + if effective && existing.finality == Finality::Provisional.as_str() { + blocks + .update_one( + doc! { "chain_id": chain_id, "block_number": block_number }, + doc! { "$set": { "finality": Finality::Finalized.as_str() } }, + ) + .session(&mut *session) + .await?; + } + effective + } else { + drop(blocks.insert_one(block).session(&mut *session).await?); + matches!(commit_finality, Finality::Finalized) + }; + if effective_finality { + for document in &mut documents { + document.finality = Finality::Finalized.as_str().into(); + } + } let updated = collection .update_many( doc! { "id": { "$in": &ids }, "chain_id": chain_id }, @@ -327,6 +569,155 @@ impl IndexStorage for MongoStorage { .await?; Ok(()) } + + async fn rollback_to(&self, chain: Chain, ancestor: u64) -> AppResult<()> { + let chain_id = to_i64(chain.id, "chain id")?; + let ancestor = to_i64(ancestor, "ancestor block")?; + let blocks = self.blocks(); + let monitors = self.monitors(); + let results = self.results(); + self.transaction(move |session| { + let blocks = blocks.clone(); + let monitors = monitors.clone(); + let results = results.clone(); + Box::pin(async move { + let finalized = blocks + .find_one(doc! { + "chain_id": chain_id, + "block_number": { "$gt": ancestor }, + "finality": "finalized" + }) + .session(&mut *session) + .await?; + anyhow::ensure!(finalized.is_none(), "rollback crosses promoted finalized boundary"); + results + .delete_many(doc! { "chain_id": chain_id, "block_number": { "$gt": ancestor } }) + .session(&mut *session) + .await?; + monitors + .update_many( + doc! { "chain_id": chain_id }, + vec![doc! { "$set": { + "cursor": { "$cond": [ + { "$gt": ["$start_block", ancestor] }, + null, + { "$cond": [ + { "$or": [ { "$eq": ["$cursor", null] }, { "$lte": ["$cursor", ancestor] } ] }, + "$cursor", ancestor + ] } + ] }, + "completed": { "$cond": [ + { "$eq": ["$end_block", null] }, false, { "$lte": ["$end_block", ancestor] } + ] }, + "updated_at": bson::DateTime::now() + } }], + ) + .session(&mut *session) + .await?; + blocks + .delete_many(doc! { "chain_id": chain_id, "block_number": { "$gt": ancestor } }) + .session(&mut *session) + .await?; + Ok(()) + }) + }) + .await + } + + async fn promote_finalized( + &self, + chain: Chain, + finalized_head: u64, + ) -> AppResult> { + let chain_id = to_i64(chain.id, "chain id")?; + let head = to_i64(finalized_head, "finalized head")?; + let blocks = self.blocks(); + let monitors = self.monitors(); + let results = self.results(); + self.transaction(move |session| { + let blocks = blocks.clone(); + let monitors = monitors.clone(); + let results = results.clone(); + Box::pin(async move { + let mut cursor = blocks + .find(doc! { "chain_id": chain_id, "finality": "provisional", "block_number": { "$lte": head } }) + .sort(doc! { "block_number": 1 }) + .session(&mut *session) + .await?; + let mut promoted = Vec::new(); + while let Some(block) = cursor.next(&mut *session).await { + promoted.push(block?); + } + if promoted.is_empty() { + return Ok(Vec::new()); + } + blocks + .update_many( + doc! { "chain_id": chain_id, "finality": "provisional", "block_number": { "$lte": head } }, + doc! { "$set": { "finality": "finalized" } }, + ) + .session(&mut *session) + .await?; + results + .update_many( + doc! { "chain_id": chain_id, "finality": "provisional", "block_number": { "$lte": head } }, + doc! { "$set": { "finality": "finalized" } }, + ) + .session(&mut *session) + .await?; + let mut monitor_cursor = monitors + .find(doc! { "chain_id": chain_id }) + .session(&mut *session) + .await?; + let mut monitor_map = HashMap::new(); + while let Some(monitor) = monitor_cursor.next(&mut *session).await { + let monitor = monitor?; + monitor_map.insert(monitor.id, monitor); + } + let mut batches = Vec::new(); + for block in promoted { + let mut rows = results + .find(doc! { "chain_id": chain_id, "block_hash": &block.block_hash, "block_number": block.block_number, "finality": "finalized" }) + .session(&mut *session) + .await?; + let mut sink_results = Vec::new(); + while let Some(row) = rows.next(&mut *session).await { + let row = row?; + let monitor = monitor_map.get(&row.monitor_id).ok_or_else(|| anyhow::anyhow!("result references missing monitor {}", row.monitor_id))?; + let params = bson_params_to_json(row.params)?; + let tx_hash = TxHash::from_str(&row.tx_hash)?; + if row.kind == "call" { + sink_results.push(parseon_core::ports::SinkResult::Call { + monitor_id: u64::try_from(row.monitor_id)?, + tx_hash, + from: Address::from_str(row.from.as_deref().ok_or_else(|| anyhow::anyhow!("missing call sender"))?)?, + to: Address::from_str(row.to.as_deref().ok_or_else(|| anyhow::anyhow!("missing call recipient"))?)?, + params, + }); + } else { + sink_results.push(parseon_core::ports::SinkResult::Event { + monitor_id: u64::try_from(row.monitor_id)?, + tx_hash, + emitter: Address::from_str(&monitor.address)?, + log_index: from_i64(row.log_index.ok_or_else(|| anyhow::anyhow!("missing log index"))?, "log index")?, + params, + }); + } + } + if !sink_results.is_empty() { + batches.push(parseon_core::ports::SinkBatch { + version: 1, + chain_id: chain.id, + block_number: from_i64(block.block_number, "block number")?, + results: sink_results, + }); + } + } + Ok(batches) + }) + }) + .await + } } #[async_trait] @@ -352,7 +743,6 @@ impl ChainRepository for MongoStorage { enabled: input.enabled, created_at: now, updated_at: now, - monitor_revision: 0, }; drop(self.chains().insert_one(&row).await?); Self::chain_record(row) @@ -400,10 +790,12 @@ impl ChainRepository for MongoStorage { let chains = self.chains(); let monitors = self.monitors(); let results = self.results(); + let blocks = self.blocks(); self.transaction(move |session| { let chains = chains.clone(); let monitors = monitors.clone(); let results = results.clone(); + let blocks = blocks.clone(); Box::pin(async move { let deleted = chains.delete_one(doc! { "chain_id": chain_id }).session(&mut *session).await?; @@ -416,6 +808,10 @@ impl ChainRepository for MongoStorage { .delete_many(doc! { "chain_id": chain_id }) .session(&mut *session) .await?; + let _ = blocks + .delete_many(doc! { "chain_id": chain_id }) + .session(&mut *session) + .await?; Ok(()) }) }) @@ -480,13 +876,10 @@ impl MonitorRepository for MongoStorage { let mut row = template.clone(); Box::pin(async move { let owner = chains - .update_one( - doc! { "chain_id": row.chain_id }, - doc! { "$inc": { "monitor_revision": 1_i64 } }, - ) + .find_one(doc! { "chain_id": row.chain_id }) .session(&mut *session) .await?; - anyhow::ensure!(owner.matched_count == 1, "chain {} not found", row.chain_id); + anyhow::ensure!(owner.is_some(), "chain {} not found", row.chain_id); let counter = counters .find_one_and_update( doc! { "_id": "monitors" }, @@ -573,7 +966,10 @@ impl ResultRepository for MongoStorage { Target::Call(_) => "call", Target::Event(_) => "event", }; - let (filter, sort) = result_query(to_i64(monitor.id.get(), "monitor id")?, kind)?; + let (mut filter, sort) = result_query(to_i64(monitor.id.get(), "monitor id")?, kind)?; + if let Some(finality) = query.finality { + filter.insert("finality", finality.as_str()); + } let mut rows = self .results() .find(filter) @@ -581,21 +977,46 @@ impl ResultRepository for MongoStorage { .skip(query.offset) .limit(i64::from(query.limit.get())) .await?; + let emitter = match &monitor.target { + Target::Event(target) => target.address, + Target::Call(_) => Address::ZERO, + }; let mut result = Vec::new(); while let Some(row) = rows.try_next().await? { let tx_hash = TxHash::from_str(&row.tx_hash)?; let block_number = from_i64(row.block_number, "block number")?; + let block_hash = B256::from_str(&row.block_hash)?; + let finality = finality_from_str(&row.finality)?; let params = bson_params_to_json(row.params)?; result.push(if kind == "call" { - ResultRecord::Call { tx_hash, block_number, params } + ResultRecord::Call { + tx_hash, + block_hash, + block_number, + from: Address::from_str( + row.from + .as_deref() + .ok_or_else(|| anyhow::anyhow!("missing call sender"))?, + )?, + to: Address::from_str( + row.to + .as_deref() + .ok_or_else(|| anyhow::anyhow!("missing call recipient"))?, + )?, + finality, + params, + } } else { ResultRecord::Event { tx_hash, + block_hash, log_index: from_i64( row.log_index.ok_or_else(|| anyhow::anyhow!("missing log index"))?, "log index", )?, block_number, + emitter, + finality, params, } }); @@ -606,6 +1027,7 @@ impl ResultRepository for MongoStorage { fn result_document( chain_id: i64, + finality: Finality, monitors: &HashMap, result: &DecodedResult, ) -> AppResult { @@ -622,8 +1044,12 @@ fn result_document( monitor_id: to_i64(call.monitor_id.get(), "monitor id")?, kind: "call".into(), tx_hash: format!("{:#x}", call.transaction_hash), + block_hash: format!("{:#x}", call.block_hash), log_index: None, block_number: to_i64(call.block_number, "block number")?, + from: Some(format!("{:#x}", call.from)), + to: Some(format!("{:#x}", call.to)), + finality: finality.as_str().into(), params: bson_params(&target.inputs, &call.params)?, }) } @@ -639,8 +1065,12 @@ fn result_document( monitor_id: to_i64(event.monitor_id.get(), "monitor id")?, kind: "event".into(), tx_hash: format!("{:#x}", event.transaction_hash), + block_hash: format!("{:#x}", event.block_hash), log_index: Some(to_i64(event.log_index, "log index")?), block_number: to_i64(event.block_number, "block number")?, + from: None, + to: None, + finality: finality.as_str().into(), params: bson_params(&target.params, &event.params)?, }) } @@ -715,10 +1145,18 @@ fn indexes() -> Vec<(&'static str, IndexModel)> { ), ), ("monitors", index(doc! { "chain_id": 1, "id": 1 }, "monitors_chain_order", false, None)), + ( + "canonical_blocks", + index(doc! { "chain_id": 1, "block_number": 1 }, "blocks_chain_number", true, None), + ), + ( + "canonical_blocks", + index(doc! { "chain_id": 1, "block_hash": 1 }, "blocks_chain_hash", true, None), + ), ( "results", index( - doc! { "monitor_id": 1, "tx_hash": 1 }, + doc! { "monitor_id": 1, "tx_hash": 1, "block_hash": 1 }, "results_call_identity", true, Some(doc! { "kind": "call" }), @@ -727,7 +1165,7 @@ fn indexes() -> Vec<(&'static str, IndexModel)> { ( "results", index( - doc! { "monitor_id": 1, "tx_hash": 1, "log_index": 1 }, + doc! { "monitor_id": 1, "tx_hash": 1, "block_hash": 1, "log_index": 1 }, "results_event_identity", true, Some(doc! { "kind": "event" }), @@ -736,7 +1174,7 @@ fn indexes() -> Vec<(&'static str, IndexModel)> { ( "results", index( - doc! { "monitor_id": 1, "block_number": -1, "tx_hash": -1 }, + doc! { "monitor_id": 1, "block_number": -1, "block_hash": -1, "tx_hash": -1 }, "results_call_order", false, Some(doc! { "kind": "call" }), @@ -745,7 +1183,7 @@ fn indexes() -> Vec<(&'static str, IndexModel)> { ( "results", index( - doc! { "monitor_id": 1, "block_number": -1, "log_index": -1 }, + doc! { "monitor_id": 1, "block_number": -1, "block_hash": -1, "log_index": -1 }, "results_event_order", false, Some(doc! { "kind": "event" }), @@ -762,10 +1200,18 @@ fn missing_index(error: &mongodb::error::Error) -> bool { ) } +fn finality_from_str(value: &str) -> AppResult { + match value { + "provisional" => Ok(Finality::Provisional), + "finalized" => Ok(Finality::Finalized), + value => anyhow::bail!("invalid result finality {value}"), + } +} + fn result_query(monitor_id: i64, kind: &str) -> AppResult<(Document, Document)> { let sort = match kind { - "call" => doc! { "block_number": -1, "tx_hash": -1 }, - "event" => doc! { "block_number": -1, "log_index": -1 }, + "call" => doc! { "block_number": -1, "block_hash": -1, "tx_hash": -1 }, + "event" => doc! { "block_number": -1, "block_hash": -1, "log_index": -1 }, _ => anyhow::bail!("invalid monitor kind {kind}"), }; Ok((doc! { "monitor_id": monitor_id, "kind": kind }, sort)) @@ -803,12 +1249,19 @@ mod tests { use parseon_core::{DecodedCall, DecodedEvent}; use super::*; - use parseon_core::ports::Storage; - fn param(name: &str) -> AbiParam { AbiParam::new(name, parse_abi_type("uint256").unwrap()).unwrap() } + fn metadata(number: u64) -> BlockMetadata { + BlockMetadata { + number, + hash: B256::repeat_byte(number as u8), + parent_hash: B256::ZERO, + timestamp: 0, + } + } + #[test] fn bson_values_round_trip_to_canonical_json() { let schema = [ @@ -845,7 +1298,7 @@ mod tests { #[test] fn declares_non_unique_target_lookup_and_unique_result_indexes() { let indexes = indexes(); - assert_eq!(indexes.len(), 8); + assert_eq!(indexes.len(), 10); let names = indexes .iter() .map(|(_, index)| index.options.as_ref().unwrap().name.as_deref().unwrap()) @@ -880,12 +1333,12 @@ mod tests { result_query(7, "call").unwrap(), ( doc! { "monitor_id": 7_i64, "kind": "call" }, - doc! { "block_number": -1, "tx_hash": -1 } + doc! { "block_number": -1, "block_hash": -1, "tx_hash": -1 } ) ); assert_eq!( result_query(8, "event").unwrap().1, - doc! { "block_number": -1, "log_index": -1 } + doc! { "block_number": -1, "block_hash": -1, "log_index": -1 } ); assert!(result_query(7, "unknown").is_err()); } @@ -1001,6 +1454,7 @@ mod tests { let call = |monitor_id, hash: u8, block_number| { DecodedResult::Call(DecodedCall { monitor_id, + block_hash: metadata(block_number).hash, block_number, transaction_hash: B256::repeat_byte(hash), from: Address::repeat_byte(5), @@ -1015,14 +1469,21 @@ mod tests { monitors.push(Arc::clone(&runtime_duplicate)); results.push(call(duplicate_monitor.id, hash, block_number)); } - let commit = BlockCommit { chain, block_number, monitors, results }; + let commit = BlockCommit { + chain, + metadata: metadata(block_number), + finality: Finality::Provisional, + monitors, + results, + }; storage.commit_block(&commit).await.unwrap(); } assert_eq!(storage.get_monitor(monitor.id).await.unwrap().cursor, Some(12)); assert_eq!(storage.get_monitor(duplicate_monitor.id).await.unwrap().cursor, Some(10)); let duplicate_commit = BlockCommit { chain, - block_number: 13, + metadata: metadata(13), + finality: Finality::Provisional, monitors: vec![Arc::clone(&runtime_monitor)], results: vec![call(monitor.id, 3, 12)], }; @@ -1033,7 +1494,10 @@ mod tests { "duplicate result must roll back cursor advancement" ); let page = storage - .query_results(&monitor, ResultQuery { limit: PageLimit::new(1), offset: 1 }) + .query_results( + &monitor, + ResultQuery { limit: PageLimit::new(1), offset: 1, finality: None }, + ) .await .unwrap(); assert!(matches!(page.as_slice(), [ResultRecord::Call { block_number: 11, .. }])); @@ -1041,7 +1505,7 @@ mod tests { storage .query_results( &duplicate_monitor, - ResultQuery { limit: PageLimit::new(1), offset: 0 } + ResultQuery { limit: PageLimit::new(1), offset: 0, finality: None } ) .await .unwrap() @@ -1056,12 +1520,13 @@ mod tests { .await .unwrap() .iter() - .all(|monitor| monitor.id != duplicate_monitor.id) + .any(|monitor| monitor.id == duplicate_monitor.id && !monitor.enabled) ); assert!(storage.set_monitor_enabled(duplicate_monitor.id, true).await.unwrap().enabled); let cross_chain_commit = BlockCommit { chain: Chain::new(1), - block_number: 13, + metadata: metadata(13), + finality: Finality::Provisional, monitors: vec![Arc::clone(&runtime_monitor)], results: Vec::new(), }; @@ -1071,7 +1536,8 @@ mod tests { let committing = storage.clone(); let concurrent_commit = BlockCommit { chain, - block_number: 13, + metadata: metadata(13), + finality: Finality::Provisional, monitors: vec![Arc::clone(&runtime_monitor)], results: vec![call(monitor.id, 4, 13)], }; @@ -1112,13 +1578,15 @@ mod tests { ); let event_commit = BlockCommit { chain, - block_number: 14, + metadata: metadata(14), + finality: Finality::Provisional, monitors: vec![runtime_event], results: [1, 2] .into_iter() .map(|log_index| { DecodedResult::Event(DecodedEvent { monitor_id: event.id, + block_hash: metadata(14).hash, block_number: 14, transaction_hash: B256::repeat_byte(9), log_index, @@ -1130,13 +1598,55 @@ mod tests { storage.commit_block(&event_commit).await.unwrap(); assert!(matches!( storage - .query_results(&event, ResultQuery { limit: PageLimit::new(1), offset: 0 }) + .query_results( + &event, + ResultQuery { limit: PageLimit::new(1), offset: 0, finality: None } + ) .await .unwrap() .as_slice(), [ResultRecord::Event { log_index: 2, .. }] )); assert_eq!(storage.results().count_documents(doc! {}).await.unwrap(), 3); + + let promoted = storage.promote_finalized(chain, 11).await.unwrap(); + assert!(!promoted.is_empty(), "promotion must reconstruct finalized sink batches"); + assert!( + storage + .query_results( + &duplicate_monitor, + ResultQuery { + limit: PageLimit::new(10), + offset: 0, + finality: Some(Finality::Finalized), + }, + ) + .await + .unwrap() + .iter() + .all(|result| matches!( + result, + ResultRecord::Call { finality: Finality::Finalized, .. } + )) + ); + + let before_rejected_rollback = storage.results().count_documents(doc! {}).await.unwrap(); + assert!(storage.rollback_to(chain, 10).await.is_err()); + assert_eq!( + storage.results().count_documents(doc! {}).await.unwrap(), + before_rejected_rollback, + "rollback across finalized data must be atomic" + ); + storage.rollback_to(chain, 12).await.unwrap(); + assert_eq!( + storage + .results() + .count_documents(doc! { "block_number": { "$gt": 12 } }) + .await + .unwrap(), + 0, + "rollback must remove provisional fork results" + ); storage.delete_chain(chain).await.unwrap(); assert_eq!(storage.count_monitors().await.unwrap(), 0); assert_eq!(storage.results().count_documents(doc! {}).await.unwrap(), 0); diff --git a/parseon-postgres/src/dyn_table.rs b/parseon-postgres/src/dyn_table.rs index 2a8b687..c4d46cb 100644 --- a/parseon-postgres/src/dyn_table.rs +++ b/parseon-postgres/src/dyn_table.rs @@ -9,22 +9,31 @@ use sqlx::{PgConnection, PgPool, QueryBuilder, Row, Transaction}; use crate::{monitor_repo::StoredParam, pg_types}; use parseon_core::abi::parse_abi_type; use parseon_core::commands::PageLimit; -use parseon_core::{BlockNumber, DecodedValue, TxHash}; +use parseon_core::ports::{CanonicalBlock, SinkBatch, SinkResult}; +use parseon_core::{Address, B256, BlockNumber, DecodedValue, Finality, TxHash}; type AppResult = anyhow::Result; const BIND_LIMIT: usize = u16::MAX as usize; const CALL_COLUMNS: &[(&str, &str)] = &[ - ("tx_hash", "BYTEA NOT NULL PRIMARY KEY CHECK (octet_length(tx_hash) = 32)"), + ("tx_hash", "BYTEA NOT NULL CHECK (octet_length(tx_hash) = 32)"), + ("block_hash", "BYTEA NOT NULL CHECK (octet_length(block_hash) = 32)"), ("block_number", "BIGINT NOT NULL CHECK (block_number >= 0)"), + ("from_addr", "BYTEA NOT NULL CHECK (octet_length(from_addr) = 20)"), + ("to_addr", "BYTEA NOT NULL CHECK (octet_length(to_addr) = 20)"), + ("finality", "TEXT NOT NULL CHECK (finality IN ('provisional', 'finalized'))"), + ("PRIMARY KEY", "(tx_hash, block_hash)"), ]; const EVENT_COLUMNS: &[(&str, &str)] = &[ ("tx_hash", "BYTEA NOT NULL CHECK (octet_length(tx_hash) = 32)"), + ("block_hash", "BYTEA NOT NULL CHECK (octet_length(block_hash) = 32)"), ("log_index", "BIGINT NOT NULL CHECK (log_index >= 0)"), ("block_number", "BIGINT NOT NULL CHECK (block_number >= 0)"), - ("PRIMARY KEY", "(tx_hash, log_index)"), + ("finality", "TEXT NOT NULL CHECK (finality IN ('provisional', 'finalized'))"), + ("PRIMARY KEY", "(tx_hash, block_hash, log_index)"), ]; -const CALL_RESERVED: &[&str] = &["tx_hash", "block_number"]; -const EVENT_RESERVED: &[&str] = &["tx_hash", "log_index", "block_number"]; +const CALL_RESERVED: &[&str] = + &["tx_hash", "block_hash", "block_number", "from_addr", "to_addr", "finality"]; +const EVENT_RESERVED: &[&str] = &["tx_hash", "block_hash", "log_index", "block_number", "finality"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PgColumnType { @@ -148,13 +157,19 @@ pub(crate) async fn drop_result_table( pub(crate) struct CallResultInput<'a> { pub tx_hash: TxHash, + pub block_hash: TxHash, pub block_number: BlockNumber, + pub from: parseon_core::Address, + pub to: parseon_core::Address, + pub finality: Finality, pub params: &'a [DecodedValue], } pub(crate) struct EventResultInput<'a> { pub tx_hash: TxHash, + pub block_hash: TxHash, pub log_index: u64, pub block_number: BlockNumber, + pub finality: Finality, pub params: &'a [DecodedValue], } @@ -194,12 +209,12 @@ pub(crate) async fn insert_calls( inputs.iter().all(|input| params.len() == input.params.len()), "parameter count mismatch" ); - let width = params.len() + 2; + let width = params.len() + 6; anyhow::ensure!(width <= BIND_LIMIT, "result row exceeds PostgreSQL bind limit"); let table = Identifier::new(result_table_name(id)?)?; for chunk in inputs.chunks(BIND_LIMIT / width) { let mut qb = QueryBuilder::new("INSERT INTO "); - qb.push(table.clone()).push(" (tx_hash,block_number"); + qb.push(table.clone()).push(" (tx_hash,block_hash,block_number,from_addr,to_addr,finality"); push_param_columns(&mut qb, ¶ms)?; qb.push(") VALUES "); for (index, input) in chunk.iter().enumerate() { @@ -209,7 +224,15 @@ pub(crate) async fn insert_calls( qb.push("(") .push_bind(input.tx_hash.as_slice()) .push(",") - .push_bind(pg_types::to_i64(input.block_number, "block number")?); + .push_bind(input.block_hash.as_slice()) + .push(",") + .push_bind(pg_types::to_i64(input.block_number, "block number")?) + .push(",") + .push_bind(input.from.as_slice()) + .push(",") + .push_bind(input.to.as_slice()) + .push(",") + .push_bind(input.finality.as_str()); push_values(&mut qb, input.params)?; qb.push(")"); } @@ -229,12 +252,12 @@ pub(crate) async fn insert_events( inputs.iter().all(|input| params.len() == input.params.len()), "parameter count mismatch" ); - let width = params.len() + 3; + let width = params.len() + 5; anyhow::ensure!(width <= BIND_LIMIT, "result row exceeds PostgreSQL bind limit"); let table = Identifier::new(result_table_name(id)?)?; for chunk in inputs.chunks(BIND_LIMIT / width) { let mut qb = QueryBuilder::new("INSERT INTO "); - qb.push(table.clone()).push(" (tx_hash,log_index,block_number"); + qb.push(table.clone()).push(" (tx_hash,block_hash,log_index,block_number,finality"); push_param_columns(&mut qb, ¶ms)?; qb.push(") VALUES "); for (index, input) in chunk.iter().enumerate() { @@ -244,9 +267,13 @@ pub(crate) async fn insert_events( qb.push("(") .push_bind(input.tx_hash.as_slice()) .push(",") + .push_bind(input.block_hash.as_slice()) + .push(",") .push_bind(pg_types::to_i64(input.log_index, "log index")?) .push(",") - .push_bind(pg_types::to_i64(input.block_number, "block number")?); + .push_bind(pg_types::to_i64(input.block_number, "block number")?) + .push(",") + .push_bind(input.finality.as_str()); push_values(&mut qb, input.params)?; qb.push(")"); } @@ -259,18 +286,26 @@ pub(crate) async fn insert_events( pub(crate) struct SearchParams { pub limit: PageLimit, pub offset: u64, + pub finality: Option, } #[derive(Debug)] pub(crate) struct CallResultRecord { pub tx_hash: TxHash, + pub block_hash: B256, pub block_number: BlockNumber, + pub from: Address, + pub to: Address, + pub finality: Finality, pub params: serde_json::Value, } #[derive(Debug)] pub(crate) struct EventResultRecord { pub tx_hash: TxHash, + pub block_hash: B256, pub log_index: u64, pub block_number: BlockNumber, + pub emitter: Address, + pub finality: Finality, pub params: serde_json::Value, } #[derive(Debug)] @@ -312,6 +347,9 @@ pub(crate) async fn query_results( let params = postgres_params(kind, schema)?; let mut qb = QueryBuilder::new("SELECT * FROM "); qb.push(Identifier::new(result_table_name(id)?)?); + if let Some(finality) = search.finality { + qb.push(" WHERE finality = ").push_bind(finality.as_str()); + } if kind == "call" { qb.push(" ORDER BY block_number DESC, tx_hash DESC"); } else { @@ -325,13 +363,25 @@ pub(crate) async fn query_results( rows.into_iter() .map(|row| { let decoded = read_params(&row, ¶ms)?; + let finality = match row.try_get::("finality")?.as_str() { + "provisional" => Finality::Provisional, + "finalized" => Finality::Finalized, + value => anyhow::bail!("invalid result finality {value}"), + }; Ok(if kind == "call" { ResultRecord::Call(CallResultRecord { tx_hash: pg_types::b256( &row.try_get::, _>("tx_hash")?, "transaction hash", )?, + block_hash: pg_types::b256( + &row.try_get::, _>("block_hash")?, + "block hash", + )?, block_number: pg_types::from_i64(row.try_get("block_number")?, "block number")?, + from: pg_types::address(&row.try_get::, _>("from_addr")?)?, + to: pg_types::address(&row.try_get::, _>("to_addr")?)?, + finality, params: decoded, }) } else { @@ -340,8 +390,14 @@ pub(crate) async fn query_results( &row.try_get::, _>("tx_hash")?, "transaction hash", )?, + block_hash: pg_types::b256( + &row.try_get::, _>("block_hash")?, + "block hash", + )?, log_index: pg_types::from_i64(row.try_get("log_index")?, "log index")?, block_number: pg_types::from_i64(row.try_get("block_number")?, "block number")?, + emitter: Address::ZERO, + finality, params: decoded, }) }) @@ -349,6 +405,90 @@ pub(crate) async fn query_results( .collect() } +pub(crate) async fn delete_results_after( + conn: &mut PgConnection, + id: i64, + block_number: BlockNumber, +) -> AppResult<()> { + QueryBuilder::new("DELETE FROM ") + .push(Identifier::new(result_table_name(id)?)?) + .push(" WHERE block_number > ") + .push_bind(pg_types::to_i64(block_number, "block number")?) + .build() + .execute(&mut *conn) + .await?; + Ok(()) +} + +pub(crate) async fn promote_results( + conn: &mut PgConnection, + id: i64, + finalized_head: BlockNumber, +) -> AppResult<()> { + QueryBuilder::new("UPDATE ") + .push(Identifier::new(result_table_name(id)?)?) + .push(" SET finality = 'finalized' WHERE finality = 'provisional' AND block_number <= ") + .push_bind(pg_types::to_i64(finalized_head, "finalized head")?) + .build() + .execute(&mut *conn) + .await?; + Ok(()) +} + +pub(crate) async fn sink_batch_for_block( + conn: &mut PgConnection, + id: i64, + kind: &str, + schema: &[StoredParam], + block: &CanonicalBlock, + emitter: Address, +) -> AppResult> { + let params = postgres_params(kind, schema)?; + let table = Identifier::new(result_table_name(id)?)?; + let mut qb = QueryBuilder::new("SELECT * FROM "); + qb.push(table) + .push(" WHERE block_hash = ") + .push_bind(block.metadata.hash.as_slice()) + .push(" AND block_number = ") + .push_bind(pg_types::to_i64(block.metadata.number, "block number")?) + .push(" AND finality = 'finalized'"); + let rows = qb.build().fetch_all(&mut *conn).await?; + if rows.is_empty() { + return Ok(None); + } + let results = rows + .into_iter() + .map(|row| { + let params = read_params(&row, ¶ms)?; + let tx_hash = + pg_types::b256(&row.try_get::, _>("tx_hash")?, "transaction hash")?; + if kind == "call" { + Ok(SinkResult::Call { + monitor_id: u64::try_from(id)?, + tx_hash, + from: pg_types::address(&row.try_get::, _>("from_addr")?)?, + to: pg_types::address(&row.try_get::, _>("to_addr")?)?, + params, + }) + } else { + Ok(SinkResult::Event { + monitor_id: u64::try_from(id)?, + tx_hash, + emitter, + log_index: pg_types::from_i64(row.try_get("log_index")?, "log index")?, + params, + }) + } + }) + .collect::>>()?; + Ok(Some(SinkBatch { + version: 1, + chain_id: block.chain.id, + block_number: block.metadata.number, + results, + })) +} + #[derive(Debug, Clone)] struct Identifier(String); impl Identifier { @@ -394,7 +534,7 @@ mod tests { }; let call = postgres_params("call", &[param("log_index"), param("from_addr")]).unwrap(); assert_eq!(call[0].column, "log_index"); - assert_eq!(call[1].column, "from_addr"); + assert_eq!(call[1].column, "from_addr_param"); let event = postgres_params("event", &[param("log_index"), param("topics_raw")]).unwrap(); assert_eq!(event[0].column, "log_index_param"); diff --git a/parseon-postgres/src/migrations/20260101000014_canonical_blocks.sql b/parseon-postgres/src/migrations/20260101000014_canonical_blocks.sql new file mode 100644 index 0000000..8f9bf69 --- /dev/null +++ b/parseon-postgres/src/migrations/20260101000014_canonical_blocks.sql @@ -0,0 +1,30 @@ +-- v1 canonical ledger. Existing v0.8 result rows do not carry block hashes and +-- cannot be mapped safely to a fork, so fail closed instead of guessing. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM monitors) + OR EXISTS ( + SELECT 1 FROM pg_tables + WHERE schemaname = current_schema() + AND tablename ~ '^monitor_[0-9]+_results$' + ) THEN + RAISE EXCEPTION + 'v1 canonical ledger requires an empty legacy monitor/result state; backup and reset/reindex before upgrade'; + END IF; +END $$; + +CREATE TABLE canonical_blocks ( + chain_id BIGINT NOT NULL REFERENCES chains(chain_id) ON DELETE CASCADE, + block_number BIGINT NOT NULL CHECK (block_number >= 0), + block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32), + parent_hash BYTEA NOT NULL CHECK (octet_length(parent_hash) = 32), + block_timestamp BIGINT NOT NULL CHECK (block_timestamp >= 0), + finality TEXT NOT NULL CHECK (finality IN ('provisional', 'finalized')), + PRIMARY KEY (chain_id, block_number), + UNIQUE (chain_id, block_hash) +); + +CREATE INDEX canonical_blocks_chain_hash_idx + ON canonical_blocks (chain_id, block_hash); +CREATE INDEX canonical_blocks_finality_idx + ON canonical_blocks (chain_id, finality, block_number); diff --git a/parseon-postgres/src/migrations/20260101000015_drop_redundant_block_hash_index.sql b/parseon-postgres/src/migrations/20260101000015_drop_redundant_block_hash_index.sql new file mode 100644 index 0000000..fce2508 --- /dev/null +++ b/parseon-postgres/src/migrations/20260101000015_drop_redundant_block_hash_index.sql @@ -0,0 +1,4 @@ +-- The UNIQUE (chain_id, block_hash) constraint in migration 14 already owns +-- the required fork-safe lookup index. Remove the duplicate named index without +-- editing the applied canonical-ledger migration. +DROP INDEX IF EXISTS canonical_blocks_chain_hash_idx; diff --git a/parseon-postgres/src/storage.rs b/parseon-postgres/src/storage.rs index f5d12b1..e4c719d 100644 --- a/parseon-postgres/src/storage.rs +++ b/parseon-postgres/src/storage.rs @@ -6,15 +6,27 @@ use parseon_core::commands::ResultQuery; use parseon_core::filter::{Filter, FilterDefinition}; use parseon_core::monitor::Monitor; use parseon_core::ports::{ - BlockCommit, ChainRecord as CoreChainRecord, ChainRepository, ChainUpdate, IndexStorage, - MonitorRecord as CoreMonitorRecord, MonitorRepository, NewChain, NewMonitor, RegisteredChain, - ResultRecord as CoreResultRecord, ResultRepository, + BlockCommit, CanonicalBlock, ChainRecord as CoreChainRecord, ChainRepository, ChainUpdate, + IndexStorage, MonitorRecord as CoreMonitorRecord, MonitorRepository, NewChain, NewMonitor, + RegisteredChain, ResultRecord as CoreResultRecord, ResultRepository, +}; +use parseon_core::{ + Address, CallTarget, Chain, Cursor, DecodedResult, EventTarget, Finality, MonitorId, Target, }; -use parseon_core::{CallTarget, Chain, Cursor, DecodedResult, EventTarget, MonitorId, Target}; use super::dyn_table::{CallResultInput, EventResultInput, ResultRecord, SearchParams}; use super::{chain_repo, dyn_table, monitor_repo, pg_types}; +#[derive(Clone, sqlx::FromRow)] +struct CanonicalBlockRow { + chain_id: i64, + block_number: i64, + block_hash: Vec, + parent_hash: Vec, + block_timestamp: i64, + finality: String, +} + #[derive(Clone)] pub struct PostgresStorage { pool: PgPool, @@ -25,6 +37,24 @@ impl PostgresStorage { Self { pool } } + fn canonical(row: CanonicalBlockRow) -> anyhow::Result { + let finality = match row.finality.as_str() { + "provisional" => Finality::Provisional, + "finalized" => Finality::Finalized, + value => anyhow::bail!("invalid canonical block finality {value}"), + }; + Ok(CanonicalBlock { + chain: Chain::new(pg_types::from_i64(row.chain_id, "chain id")?), + metadata: parseon_core::BlockMetadata { + number: pg_types::from_i64(row.block_number, "block number")?, + hash: pg_types::b256(&row.block_hash, "block hash")?, + parent_hash: pg_types::b256(&row.parent_hash, "parent hash")?, + timestamp: pg_types::from_i64(row.block_timestamp, "block timestamp")?, + }, + finality, + }) + } + fn target(row: &monitor_repo::MonitorRecord) -> anyhow::Result { let address = pg_types::address(&row.address)?; let params = row @@ -119,13 +149,76 @@ impl IndexStorage for PostgresStorage { monitor_repo::list(&self.pool, Some(chain.id)).await?.iter().map(Self::to_monitor).collect() } + async fn canonical_tip(&self, chain: Chain) -> anyhow::Result> { + let row = sqlx::query_as::<_, CanonicalBlockRow>( + "SELECT * FROM canonical_blocks WHERE chain_id = $1 ORDER BY block_number DESC LIMIT 1", + ) + .bind(pg_types::to_i64(chain.id, "chain id")?) + .fetch_optional(&self.pool) + .await?; + row.map(Self::canonical).transpose() + } + + async fn canonical_block( + &self, + chain: Chain, + block_number: u64, + ) -> anyhow::Result> { + let row = sqlx::query_as::<_, CanonicalBlockRow>( + "SELECT * FROM canonical_blocks WHERE chain_id = $1 AND block_number = $2", + ) + .bind(pg_types::to_i64(chain.id, "chain id")?) + .bind(pg_types::to_i64(block_number, "block number")?) + .fetch_optional(&self.pool) + .await?; + row.map(Self::canonical).transpose() + } + async fn commit_block(&self, commit: &BlockCommit) -> anyhow::Result<()> { - anyhow::ensure!( - commit.monitors.iter().all(|monitor| monitor.chain == commit.chain), - "cross-chain monitor set rejected for chain {}", - commit.chain.id - ); + commit.validate()?; let mut tx = self.pool.begin().await?; + let block_number = pg_types::to_i64(commit.metadata.number, "block number")?; + let chain_id = pg_types::to_i64(commit.chain.id, "chain id")?; + let mut effective_finality = commit.finality; + let inserted = sqlx::query( + r#"INSERT INTO canonical_blocks + (chain_id, block_number, block_hash, parent_hash, block_timestamp, finality) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (chain_id, block_number) DO NOTHING"#, + ) + .bind(chain_id) + .bind(block_number) + .bind(commit.metadata.hash.as_slice()) + .bind(commit.metadata.parent_hash.as_slice()) + .bind(pg_types::to_i64(commit.metadata.timestamp, "block timestamp")?) + .bind(commit.finality.as_str()) + .execute(&mut *tx) + .await?; + if inserted.rows_affected() == 0 { + let existing = sqlx::query_as::<_, CanonicalBlockRow>( + "SELECT * FROM canonical_blocks WHERE chain_id = $1 AND block_number = $2 FOR UPDATE", + ) + .bind(chain_id) + .bind(block_number) + .fetch_one(&mut *tx) + .await?; + anyhow::ensure!( + existing.block_hash == commit.metadata.hash.as_slice() + && existing.parent_hash == commit.metadata.parent_hash.as_slice(), + "canonical block {} hash changed without rollback", + commit.metadata.number + ); + if existing.finality == Finality::Finalized.as_str() { + effective_finality = Finality::Finalized; + } else if matches!(commit.finality, Finality::Finalized) { + sqlx::query("UPDATE canonical_blocks SET finality = 'finalized' WHERE chain_id = $1 AND block_number = $2") + .bind(chain_id) + .bind(block_number) + .execute(&mut *tx) + .await?; + effective_finality = Finality::Finalized; + } + } let mut monitor_ids = commit .monitors .iter() @@ -134,8 +227,6 @@ impl IndexStorage for PostgresStorage { monitor_ids.sort_unstable(); monitor_ids.dedup(); - let block_number = pg_types::to_i64(commit.block_number, "block number")?; - let chain_id = pg_types::to_i64(commit.chain.id, "chain id")?; let rows = sqlx::query_as::<_, monitor_repo::MonitorRecord>( r#"WITH locked AS MATERIALIZED ( SELECT id FROM monitors @@ -158,7 +249,7 @@ impl IndexStorage for PostgresStorage { anyhow::ensure!( rows.len() == monitor_ids.len(), "monitor set changed before block {} could be committed", - commit.block_number + commit.metadata.number ); let rows = rows .into_iter() @@ -175,7 +266,11 @@ impl IndexStorage for PostgresStorage { anyhow::ensure!(row.kind == "call", "call result references event monitor"); calls.entry(call.monitor_id).or_insert_with(Vec::new).push(CallResultInput { tx_hash: call.transaction_hash, + block_hash: commit.metadata.hash, block_number: call.block_number, + from: call.from, + to: call.to, + finality: effective_finality, params: &call.params, }); } @@ -187,8 +282,10 @@ impl IndexStorage for PostgresStorage { events.entry(event.monitor_id).or_insert_with(Vec::new).push( EventResultInput { tx_hash: event.transaction_hash, + block_hash: commit.metadata.hash, log_index: event.log_index, block_number: event.block_number, + finality: effective_finality, params: &event.params, }, ); @@ -210,6 +307,115 @@ impl IndexStorage for PostgresStorage { tx.commit().await?; Ok(()) } + + async fn rollback_to(&self, chain: Chain, ancestor: u64) -> anyhow::Result<()> { + let chain_id = pg_types::to_i64(chain.id, "chain id")?; + let ancestor = pg_types::to_i64(ancestor, "ancestor block")?; + let mut tx = self.pool.begin().await?; + let finalized: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM canonical_blocks WHERE chain_id = $1 AND block_number > $2 AND finality = 'finalized')", + ) + .bind(chain_id) + .bind(ancestor) + .fetch_one(&mut *tx) + .await?; + anyhow::ensure!(!finalized, "rollback crosses promoted finalized boundary"); + let monitors = sqlx::query_as::<_, monitor_repo::MonitorRecord>( + "SELECT * FROM monitors WHERE chain_id = $1 FOR UPDATE", + ) + .bind(chain_id) + .fetch_all(&mut *tx) + .await?; + for monitor in &monitors { + dyn_table::delete_results_after(&mut tx, monitor.id, ancestor as u64).await?; + } + sqlx::query( + r#"UPDATE monitors + SET cursor = CASE + WHEN start_block > $2 THEN NULL + WHEN cursor IS NULL OR cursor <= $2 THEN cursor + ELSE $2 + END, + completed = CASE + WHEN end_block IS NULL THEN FALSE + ELSE end_block <= $2 + END, + updated_at = NOW() + WHERE chain_id = $1"#, + ) + .bind(chain_id) + .bind(ancestor) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM canonical_blocks WHERE chain_id = $1 AND block_number > $2") + .bind(chain_id) + .bind(ancestor) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + async fn promote_finalized( + &self, + chain: Chain, + finalized_head: u64, + ) -> anyhow::Result> { + let chain_id = pg_types::to_i64(chain.id, "chain id")?; + let mut tx = self.pool.begin().await?; + let blocks = sqlx::query_as::<_, CanonicalBlockRow>( + "SELECT * FROM canonical_blocks WHERE chain_id = $1 AND finality = 'provisional' AND block_number <= $2 ORDER BY block_number", + ) + .bind(chain_id) + .bind(pg_types::to_i64(finalized_head, "finalized head")?) + .fetch_all(&mut *tx) + .await?; + if blocks.is_empty() { + tx.commit().await?; + return Ok(Vec::new()); + } + let monitors = sqlx::query_as::<_, monitor_repo::MonitorRecord>( + "SELECT * FROM monitors WHERE chain_id = $1 FOR SHARE", + ) + .bind(chain_id) + .fetch_all(&mut *tx) + .await?; + sqlx::query( + "UPDATE canonical_blocks SET finality = 'finalized' WHERE chain_id = $1 AND finality = 'provisional' AND block_number <= $2", + ) + .bind(chain_id) + .bind(pg_types::to_i64(finalized_head, "finalized head")?) + .execute(&mut *tx) + .await?; + for monitor in &monitors { + dyn_table::promote_results(&mut tx, monitor.id, finalized_head).await?; + } + let mut batches = Vec::new(); + for block in blocks { + let metadata = Self::canonical(block.clone())?; + for monitor in &monitors { + let target = Self::target(monitor)?; + let emitter = match target { + Target::Event(event) => event.address, + Target::Call(_) => Address::ZERO, + }; + if let Some(batch) = dyn_table::sink_batch_for_block( + &mut tx, + monitor.id, + &monitor.kind, + &monitor.param_schema.0, + &metadata, + emitter, + ) + .await? + { + batches.push(batch); + } + } + } + tx.commit().await?; + Ok(batches) + } } #[async_trait] @@ -306,25 +512,40 @@ impl ResultRepository for PostgresStorage { indexed: param.indexed, }) .collect::>(); + let event_emitter = match &monitor.target { + Target::Event(target) => target.address, + Target::Call(_) => Address::ZERO, + }; Ok(dyn_table::query_results( &self.pool, pg_types::to_monitor_id(monitor.id)?, kind, &schema, - &SearchParams { limit: query.limit, offset: query.offset }, + &SearchParams { limit: query.limit, offset: query.offset, finality: query.finality }, ) .await? .into_iter() .map(|record| match record { ResultRecord::Call(record) => CoreResultRecord::Call { tx_hash: record.tx_hash, + block_hash: record.block_hash, block_number: record.block_number, + from: record.from, + to: record.to, + finality: record.finality, params: record.params, }, ResultRecord::Event(record) => CoreResultRecord::Event { tx_hash: record.tx_hash, + block_hash: record.block_hash, log_index: record.log_index, block_number: record.block_number, + emitter: if record.emitter == Address::ZERO { + event_emitter + } else { + record.emitter + }, + finality: record.finality, params: record.params, }, }) @@ -336,14 +557,13 @@ impl ResultRepository for PostgresStorage { mod tests { use std::sync::Arc; - use alloy::primitives::Address; + use alloy::primitives::{Address, B256}; + use parseon_core::BlockMetadata; use sqlx::postgres::PgPoolOptions; use super::*; use parseon_core::filter::Filter; use parseon_core::monitor::Monitor; - use parseon_core::ports::Storage; - fn monitor(chain_id: u64) -> Monitor { Monitor { id: MonitorId::new(1).unwrap(), @@ -364,13 +584,17 @@ mod tests { #[tokio::test] async fn rejects_cross_chain_commits_before_database_access() { - let pool = PgPoolOptions::new() - .connect_lazy("postgres://postgres:postgres@localhost/parseon") - .unwrap(); + let pool = PgPoolOptions::new().connect_lazy("postgres://localhost/parseon").unwrap(); let storage = PostgresStorage::new(pool); let commit = BlockCommit { chain: Chain::new(1), - block_number: 10, + metadata: BlockMetadata { + number: 10, + hash: B256::repeat_byte(1), + parent_hash: B256::ZERO, + timestamp: 0, + }, + finality: Finality::Provisional, monitors: vec![Arc::new(monitor(2))], results: Vec::new(), }; diff --git a/parseon-rpc/src/fetch.rs b/parseon-rpc/src/fetch.rs index 4ec3962..b95208b 100644 --- a/parseon-rpc/src/fetch.rs +++ b/parseon-rpc/src/fetch.rs @@ -7,7 +7,9 @@ use alloy_rpc_types_any::AnyTransactionReceipt; use anyhow::Context; use alloy::rpc::types::Filter; -use parseon_core::{BlockTransaction, ExecutionOutcome, SourceBlock, SourceLog, TxHash}; +use parseon_core::{ + BlockMetadata, BlockTransaction, ExecutionOutcome, SourceBlock, SourceLog, TxHash, +}; use crate::provider::HttpProvider; @@ -20,6 +22,29 @@ pub(crate) enum BlockReceiptsResponseError { } /// Fetch the transaction fields needed for monitor matching. +pub(crate) async fn fetch_block_header( + provider: &HttpProvider, + block_number: u64, +) -> anyhow::Result { + let block = provider + .get_block_by_number(BlockNumberOrTag::Number(block_number)) + .kind(BlockTransactionsKind::Hashes) + .await? + .with_context(|| format!("block {block_number} not found"))?; + let header = block.header(); + anyhow::ensure!( + header.number == block_number, + "block source returned block {} for request {block_number}", + header.number + ); + Ok(BlockMetadata { + number: header.number, + hash: header.hash, + parent_hash: header.parent_hash, + timestamp: header.timestamp, + }) +} + pub(crate) async fn fetch_block( provider: &HttpProvider, block_number: u64, @@ -34,6 +59,13 @@ pub(crate) async fn fetch_block( "block source returned block {} for request {block_number}", block.header().number ); + let header = block.header(); + let metadata = BlockMetadata { + number: header.number, + hash: header.hash, + parent_hash: header.parent_hash, + timestamp: header.timestamp, + }; let transactions = block .try_into_transactions() .map_err(|_| anyhow::anyhow!("block {block_number} did not contain full transactions"))?; @@ -49,7 +81,7 @@ pub(crate) async fn fetch_block( }); } - Ok(SourceBlock { number: block_number, transactions: out }) + Ok(SourceBlock { number: block_number, metadata, transactions: out }) } pub(crate) async fn fetch_logs( @@ -65,6 +97,7 @@ pub(crate) async fn fetch_logs( let (topics, data) = log.inner.data.split(); Ok(SourceLog { block_number: log.block_number, + block_hash: log.block_hash, transaction_hash: log.transaction_hash, log_index: log.log_index, address, @@ -76,8 +109,27 @@ pub(crate) async fn fetch_logs( .collect() } +fn receipt_outcome( + receipt: &AnyTransactionReceipt, + transaction_hash: TxHash, + block: &BlockMetadata, +) -> anyhow::Result { + anyhow::ensure!( + receipt.block_number() == Some(block.number), + "receipt block number does not match requested block {} for {transaction_hash}", + block.number + ); + anyhow::ensure!( + receipt.block_hash() == Some(block.hash), + "receipt block hash does not match requested block {} for {transaction_hash}", + block.number + ); + Ok(ExecutionOutcome { transaction_hash, succeeded: receipt.status() }) +} + pub(crate) async fn fetch_receipt( provider: &HttpProvider, + block: &BlockMetadata, transaction_hash: TxHash, ) -> anyhow::Result { let receipt = provider @@ -88,11 +140,12 @@ pub(crate) async fn fetch_receipt( receipt.transaction_hash() == transaction_hash, "receipt response hash does not match request {transaction_hash}" ); - Ok(ExecutionOutcome { transaction_hash, succeeded: receipt.status() }) + receipt_outcome(&receipt, transaction_hash, block) } pub(crate) async fn fetch_receipt_batch( provider: &HttpProvider, + block: &BlockMetadata, transaction_hashes: &[TxHash], ) -> anyhow::Result> { let mut batch = alloy::rpc::client::BatchRequest::new(provider.client()); @@ -117,24 +170,25 @@ pub(crate) async fn fetch_receipt_batch( receipt.transaction_hash() == transaction_hash, "receipt response hash does not match request {transaction_hash}" ); - out.push(ExecutionOutcome { transaction_hash, succeeded: receipt.status() }); + out.push(receipt_outcome(&receipt, transaction_hash, block)?); } Ok(out) } pub(crate) async fn fetch_block_receipts( provider: &HttpProvider, - block_number: u64, + block: &BlockMetadata, transaction_hashes: &[TxHash], ) -> anyhow::Result> { let receipts = provider - .get_block_receipts(BlockNumberOrTag::Number(block_number).into()) + .get_block_receipts(BlockNumberOrTag::Number(block.number).into()) .await? - .ok_or(BlockReceiptsResponseError::MissingBlock(block_number))?; - let mut statuses = receipts - .into_iter() - .map(|receipt| (receipt.transaction_hash(), receipt.status())) - .collect::>(); + .ok_or(BlockReceiptsResponseError::MissingBlock(block.number))?; + let mut statuses = std::collections::HashMap::with_capacity(receipts.len()); + for receipt in &receipts { + receipt_outcome(receipt, receipt.transaction_hash(), block)?; + statuses.insert(receipt.transaction_hash(), receipt.status()); + } transaction_hashes .iter() .copied() diff --git a/parseon-rpc/src/provider.rs b/parseon-rpc/src/provider.rs index 3a0eb09..9f59cff 100644 --- a/parseon-rpc/src/provider.rs +++ b/parseon-rpc/src/provider.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, ToSocketAddrs}; use std::num::NonZeroUsize; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Arc, OnceLock}; @@ -11,19 +12,20 @@ use alloy::network::BlockResponse; use alloy::providers::{Provider, RootProvider}; use alloy::rpc::client::RpcClient; use alloy::rpc::types::Filter; -use alloy::transports::http::reqwest::Client; use anyhow::Context; use async_trait::async_trait; use futures_util::{StreamExt, stream}; use tokio::sync::{Semaphore, SemaphorePermit}; use crate::fetch; -use crate::transport::RotatingHttp; +use crate::transport::{RotatingHttp, client_for_url}; use parseon_core::ports::{ BlockRange, BlockSource, BlockSourceFactory, BlockSourceRequestError, InFlightGuard, LogQuery, LogTarget, NoopTelemetry, Telemetry, }; -use parseon_core::{BlockNumber, ChainId, ExecutionOutcome, SourceBlock, SourceLog, TxHash, Url}; +use parseon_core::{ + BlockMetadata, BlockNumber, ChainId, ExecutionOutcome, SourceBlock, SourceLog, TxHash, Url, +}; pub(crate) type HttpProvider = RootProvider; @@ -35,6 +37,8 @@ const CAPABILITY_UNSUPPORTED: u8 = 2; pub struct RpcConfig { pub request_concurrency: NonZeroUsize, pub batch_size: NonZeroUsize, + /// Allow loopback/private RPC destinations for local development only. + pub allow_private_networks: bool, } impl Default for RpcConfig { @@ -42,6 +46,7 @@ impl Default for RpcConfig { Self { request_concurrency: NonZeroUsize::new(16).expect("16 is non-zero"), batch_size: NonZeroUsize::new(20).expect("20 is non-zero"), + allow_private_networks: false, } } } @@ -57,6 +62,7 @@ pub struct JsonRpcBlockSource { chain_id: OnceLock, batch_capability: AtomicU8, block_receipts_capability: AtomicU8, + allow_private_networks: bool, telemetry: Arc, } @@ -90,7 +96,8 @@ impl JsonRpcBlockSource { config: RpcConfig, telemetry: Arc, ) -> anyhow::Result { - let (provider, transport) = build(rpc_url)?; + validate_rpc_url(rpc_url, config.allow_private_networks)?; + let (provider, transport) = build(rpc_url, config.allow_private_networks)?; Ok(Self::with_transport(provider, Some(transport), config, telemetry)) } @@ -121,6 +128,7 @@ impl JsonRpcBlockSource { chain_id: OnceLock::new(), batch_capability: AtomicU8::new(CAPABILITY_UNKNOWN), block_receipts_capability: AtomicU8::new(CAPABILITY_UNKNOWN), + allow_private_networks: config.allow_private_networks, telemetry, } } @@ -158,12 +166,13 @@ impl JsonRpcBlockSource { async fn single_receipts( &self, + block: &BlockMetadata, transaction_hashes: &[TxHash], ) -> anyhow::Result> { let mut receipts = stream::iter(transaction_hashes.iter().copied().map(|transaction_hash| async move { self.observed("receipts", "single", async { - fetch::fetch_receipt(&self.provider, transaction_hash).await + fetch::fetch_receipt(&self.provider, block, transaction_hash).await }) .await })) @@ -177,6 +186,7 @@ impl JsonRpcBlockSource { async fn batched_receipts( &self, + block: &BlockMetadata, transaction_hashes: &[TxHash], ) -> anyhow::Result> { let ranges = (0..transaction_hashes.len()) @@ -186,7 +196,7 @@ impl JsonRpcBlockSource { let mut batches = stream::iter(ranges.into_iter().map(|range| async move { let chunk = &transaction_hashes[range]; self.observed("receipts", "batch", async { - fetch::fetch_receipt_batch(&self.provider, chunk).await + fetch::fetch_receipt_batch(&self.provider, block, chunk).await }) .await })) @@ -200,14 +210,14 @@ impl JsonRpcBlockSource { async fn optimized_receipts( &self, - block_number: BlockNumber, + block: &BlockMetadata, transaction_hashes: &[TxHash], ) -> anyhow::Result> { if transaction_hashes.is_empty() { return Ok(Vec::new()); } if transaction_hashes.len() == 1 { - return self.single_receipts(transaction_hashes).await; + return self.single_receipts(block, transaction_hashes).await; } if transaction_hashes.len() > self.batch_size @@ -215,8 +225,7 @@ impl JsonRpcBlockSource { { let block_receipts = self .observed("receipts", "block_receipts", async { - fetch::fetch_block_receipts(&self.provider, block_number, transaction_hashes) - .await + fetch::fetch_block_receipts(&self.provider, block, transaction_hashes).await }) .await; match block_receipts { @@ -236,7 +245,7 @@ impl JsonRpcBlockSource { } if self.batch_capability.load(Ordering::Relaxed) != CAPABILITY_UNSUPPORTED { - match self.batched_receipts(transaction_hashes).await { + match self.batched_receipts(block, transaction_hashes).await { Ok(receipts) => { self.batch_capability.store(CAPABILITY_SUPPORTED, Ordering::Relaxed); return Ok(receipts); @@ -251,7 +260,7 @@ impl JsonRpcBlockSource { Err(error) => return Err(error), } } - self.single_receipts(transaction_hashes).await + self.single_receipts(block, transaction_hashes).await } async fn adaptive_logs(&self, query: LogQuery) -> anyhow::Result> { @@ -354,12 +363,26 @@ impl BlockSource for JsonRpcBlockSource { } } + async fn latest_head(&self) -> anyhow::Result { + self.observed("latest_head", "single", async { latest_number(&self.provider).await }) + .await + .map_err(source_request_error) + } + async fn finalized_head(&self) -> anyhow::Result { self.observed("finalized_head", "single", async { finalized_number(&self.provider).await }) .await .map_err(source_request_error) } + async fn fetch_block_header(&self, block_number: BlockNumber) -> anyhow::Result { + self.observed("block_header", "single", async { + fetch::fetch_block_header(&self.provider, block_number).await + }) + .await + .map_err(source_request_error) + } + async fn fetch_block(&self, block_number: BlockNumber) -> anyhow::Result { self.observed("block", "single", async { fetch::fetch_block(&self.provider, block_number).await @@ -370,12 +393,10 @@ impl BlockSource for JsonRpcBlockSource { async fn fetch_execution_outcomes( &self, - block_number: BlockNumber, + block: &BlockMetadata, transaction_hashes: &[TxHash], ) -> anyhow::Result> { - self.optimized_receipts(block_number, transaction_hashes) - .await - .map_err(source_request_error) + self.optimized_receipts(block, transaction_hashes).await.map_err(source_request_error) } async fn fetch_logs(&self, query: LogQuery) -> anyhow::Result> { @@ -383,9 +404,10 @@ impl BlockSource for JsonRpcBlockSource { } fn set_rpc_url(&self, rpc_url: &Url) -> anyhow::Result<()> { + validate_rpc_url(rpc_url, self.allow_private_networks)?; let transport = self.transport.as_ref().context("RPC URL rotation requires a live HTTP transport")?; - transport.set_url(rpc_url.clone()); + transport.set_url(rpc_url.clone(), self.allow_private_networks)?; // Endpoint capabilities (batching, block receipts) may differ on the // new URL; re-probe them. The cached chain ID stays valid because // callers guarantee the new URL serves the same chain. @@ -450,11 +472,69 @@ fn unsupported_batch(error: &anyhow::Error) -> bool { }) } -pub(crate) fn build(rpc_url: &Url) -> anyhow::Result<(HttpProvider, RotatingHttp)> { - let client = Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .context("build HTTP client")?; +/// Rejects unsafe RPC destinations before any network probe is attempted. +fn validate_rpc_url(rpc_url: &Url, allow_private: bool) -> anyhow::Result<()> { + anyhow::ensure!(matches!(rpc_url.scheme(), "http" | "https"), "RPC URL must use http or https"); + let host = rpc_url.host_str().ok_or_else(|| anyhow::anyhow!("RPC URL must contain a host"))?; + let port = rpc_url + .port_or_known_default() + .ok_or_else(|| anyhow::anyhow!("RPC URL must contain a port"))?; + let addresses = (host, port) + .to_socket_addrs() + .map_err(|error| anyhow::anyhow!("RPC host resolution failed: {error}"))? + .collect::>(); + anyhow::ensure!(!addresses.is_empty(), "RPC host has no addresses"); + if !allow_private { + for address in addresses { + anyhow::ensure!(!is_private_address(address.ip()), "private RPC network is disabled"); + } + } + Ok(()) +} + +pub(crate) fn is_private_address(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let [a, b, c, _] = ip.octets(); + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_broadcast() + || ip.is_multicast() + || (a == 100 && (64..=127).contains(&b)) + || (a == 192 && b == 0 && c == 0) + || (a == 192 && b == 0 && c == 2) + || (a == 198 && (b == 18 || b == 19 || (b == 51 && c == 100))) + || (a == 203 && b == 0 && c == 113) + || a >= 240 + || ip.octets() == [169, 254, 169, 254] + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || (segments[..5] == [0, 0, 0, 0, 0] + && segments[5] == 0xffff + && is_private_address(IpAddr::V4(Ipv4Addr::new( + (segments[6] >> 8) as u8, + segments[6] as u8, + (segments[7] >> 8) as u8, + segments[7] as u8, + )))) + } + } +} + +pub(crate) fn build( + rpc_url: &Url, + allow_private: bool, +) -> anyhow::Result<(HttpProvider, RotatingHttp)> { + let client = client_for_url(rpc_url, allow_private).context("build HTTP client")?; let transport = RotatingHttp::new(client, rpc_url.clone()); let rpc_client = RpcClient::new(transport.clone(), transport.guess_local()); Ok((RootProvider::::new(rpc_client), transport)) @@ -464,6 +544,14 @@ pub(crate) async fn chain_id(provider: &HttpProvider) -> anyhow::Result { Ok(provider.get_chain_id().await?) } +pub(crate) async fn latest_number(provider: &HttpProvider) -> anyhow::Result { + let block = provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .context("latest block not found")?; + Ok(block.header().number) +} + pub(crate) async fn finalized_number(provider: &HttpProvider) -> anyhow::Result { let block = provider .get_block_by_number(BlockNumberOrTag::Finalized) @@ -487,10 +575,11 @@ mod tests { use alloy_json_rpc::ErrorPayload; use alloy_rpc_types_any::AnyTransactionReceipt; use parseon_core::ports::{BlockRange, BlockSource, LogQuery, LogTarget, NoopTelemetry}; + use parseon_core::{BlockMetadata, BlockNumber}; use super::{ CAPABILITY_SUPPORTED, CAPABILITY_UNKNOWN, CAPABILITY_UNSUPPORTED, JsonRpcBlockSource, - RpcConfig, exact_log_filters, + RpcConfig, Url, exact_log_filters, }; use crate::fetch; @@ -504,6 +593,7 @@ mod tests { RpcConfig { request_concurrency: NonZeroUsize::new(4).expect("non-zero"), batch_size: NonZeroUsize::new(batch_size).expect("non-zero"), + allow_private_networks: false, }, Arc::new(NoopTelemetry), ) @@ -520,7 +610,20 @@ mod tests { ErrorPayload { code, message: Cow::Borrowed(message), data: None } } + fn test_block(number: BlockNumber) -> BlockMetadata { + BlockMetadata { number, hash: B256::repeat_byte(9), parent_hash: B256::ZERO, timestamp: 0 } + } + fn receipt(transaction_hash: B256, succeeded: bool) -> AnyTransactionReceipt { + receipt_at(transaction_hash, succeeded, B256::repeat_byte(9), 10) + } + + fn receipt_at( + transaction_hash: B256, + succeeded: bool, + block_hash: B256, + block_number: u64, + ) -> AnyTransactionReceipt { serde_json::from_value(serde_json::json!({ "status": if succeeded { "0x1" } else { "0x0" }, "cumulativeGasUsed": "0x1", @@ -529,8 +632,8 @@ mod tests { "type": "0x0", "transactionHash": format!("{transaction_hash:#x}"), "transactionIndex": "0x0", - "blockHash": format!("{:#x}", B256::repeat_byte(9)), - "blockNumber": "0xa", + "blockHash": format!("{block_hash:#x}"), + "blockNumber": format!("0x{block_number:x}"), "gasUsed": "0x1", "effectiveGasPrice": "0x1", "from": format!("{:#x}", Address::ZERO), @@ -660,7 +763,9 @@ mod tests { let source = source(asserter); let outcomes = - fetch::fetch_block_receipts(&source.provider, 10, &[first, second]).await.unwrap(); + fetch::fetch_block_receipts(&source.provider, &test_block(10), &[first, second]) + .await + .unwrap(); assert_eq!(outcomes[0].transaction_hash, first); assert!(outcomes[0].succeeded); @@ -668,6 +773,17 @@ mod tests { assert!(!outcomes[1].succeeded); } + #[tokio::test] + async fn rejects_receipt_from_a_different_block() { + let asserter = Asserter::new(); + let hash = B256::repeat_byte(1); + asserter.push_success(&receipt_at(hash, true, B256::repeat_byte(8), 10)); + let source = source(asserter); + + let error = source.batched_receipts(&test_block(10), &[hash]).await.unwrap_err(); + assert!(error.to_string().contains("block hash does not match")); + } + #[tokio::test] async fn receipt_batches_preserve_order_across_concurrent_chunks() { let asserter = Asserter::new(); @@ -677,7 +793,7 @@ mod tests { } let source = source_with_batch_size(asserter, 2); - let outcomes = source.batched_receipts(&hashes).await.unwrap(); + let outcomes = source.batched_receipts(&test_block(10), &hashes).await.unwrap(); assert_eq!( outcomes.iter().map(|outcome| outcome.transaction_hash).collect::>(), @@ -695,7 +811,7 @@ mod tests { } let source = source_with_batch_size(asserter.clone(), 2); - let outcomes = source.optimized_receipts(10, &hashes).await.unwrap(); + let outcomes = source.optimized_receipts(&test_block(10), &hashes).await.unwrap(); assert_eq!(outcomes.len(), hashes.len()); assert_eq!( @@ -716,7 +832,7 @@ mod tests { } let source = source_with_batch_size(asserter.clone(), 2); - let outcomes = source.optimized_receipts(10, &hashes).await.unwrap(); + let outcomes = source.optimized_receipts(&test_block(10), &hashes).await.unwrap(); assert_eq!( outcomes.iter().map(|outcome| outcome.transaction_hash).collect::>(), @@ -734,7 +850,7 @@ mod tests { asserter.push_success(&receipt(hashes[0], true)); let source = source_with_batch_size(asserter.clone(), 2); - assert!(source.optimized_receipts(10, &hashes).await.is_err()); + assert!(source.optimized_receipts(&test_block(10), &hashes).await.is_err()); assert_eq!(asserter.read_q().len(), 1); } @@ -742,7 +858,7 @@ mod tests { async fn set_rpc_url_rotates_the_transport_and_resets_endpoint_capabilities() { let source = JsonRpcBlockSource::connect( &"http://localhost:8545".parse().unwrap(), - RpcConfig::default(), + RpcConfig { allow_private_networks: true, ..RpcConfig::default() }, Arc::new(NoopTelemetry), ) .unwrap(); @@ -764,6 +880,15 @@ mod tests { ); } + #[test] + fn rejects_private_rpc_destinations_by_default() { + let url: Url = "http://127.0.0.1:8545".parse().unwrap(); + assert!(super::validate_rpc_url(&url, false).is_err()); + assert!(super::validate_rpc_url(&url, true).is_ok()); + let unsupported: Url = "file:///tmp/rpc".parse().unwrap(); + assert!(super::validate_rpc_url(&unsupported, true).is_err()); + } + #[test] fn redacts_transport_errors_but_preserves_safe_response_errors() { let transport = anyhow::Error::new(alloy::transports::TransportErrorKind::custom_str( diff --git a/parseon-rpc/src/transport.rs b/parseon-rpc/src/transport.rs index 13d9b1b..7c73250 100644 --- a/parseon-rpc/src/transport.rs +++ b/parseon-rpc/src/transport.rs @@ -7,11 +7,12 @@ //! connected: clones share the inner transport, so one `set_url` call covers //! every request issued afterwards. +use std::net::ToSocketAddrs; use std::sync::Arc; use std::task::{Context, Poll}; use alloy::transports::http::Http; -use alloy::transports::http::reqwest::Client; +use alloy::transports::http::reqwest::{Client, redirect}; use alloy::transports::{TransportError, TransportFut}; use alloy_json_rpc::{RequestPacket, ResponsePacket}; use parking_lot::RwLock; @@ -37,11 +38,38 @@ impl RotatingHttp { /// Rotates the endpoint URL. In-flight requests finish against the old /// URL; subsequent requests use the new one. - pub(crate) fn set_url(&self, url: Url) { - self.inner.write().set_url(url); + pub(crate) fn set_url(&self, url: Url, allow_private: bool) -> anyhow::Result<()> { + let client = client_for_url(&url, allow_private)?; + *self.inner.write() = Http::with_client(client, url); + Ok(()) } } +pub(crate) fn client_for_url(url: &Url, allow_private: bool) -> anyhow::Result { + let host = url.host_str().ok_or_else(|| anyhow::anyhow!("RPC URL must contain a host"))?; + let port = url + .port_or_known_default() + .ok_or_else(|| anyhow::anyhow!("RPC URL must contain a port"))?; + let addresses = (host, port) + .to_socket_addrs() + .map_err(|error| anyhow::anyhow!("RPC host resolution failed: {error}"))? + .collect::>(); + anyhow::ensure!(!addresses.is_empty(), "RPC host has no addresses"); + if !allow_private { + for address in &addresses { + anyhow::ensure!( + !super::provider::is_private_address(address.ip()), + "private RPC network is disabled" + ); + } + } + Ok(Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(redirect::Policy::none()) + .resolve_to_addrs(host, &addresses) + .build()?) +} + impl Service for RotatingHttp { type Response = ResponsePacket; type Error = TransportError; @@ -70,7 +98,7 @@ mod tests { RotatingHttp::new(Client::new(), Url::parse("http://localhost:8545").unwrap()); let boxed_clone = transport.clone(); - transport.set_url(Url::parse("http://localhost:9545").unwrap()); + transport.set_url(Url::parse("http://localhost:9545").unwrap(), true).unwrap(); // The provider's boxed clone observes the rotation. assert_eq!(boxed_clone.inner.read().url(), "http://localhost:9545/"); diff --git a/parseon-server/Cargo.toml b/parseon-server/Cargo.toml index 98e90af..875c312 100644 --- a/parseon-server/Cargo.toml +++ b/parseon-server/Cargo.toml @@ -28,7 +28,7 @@ tokio = { version = "1.52", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } chrono = { version = "0.4", features = ["serde"] } axum = "0.8" -tower-http = { version = "0.7", features = ["cors", "trace"] } +tower-http = { version = "0.7", features = ["cors", "limit", "trace"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" utoipa = { version = "5.5", features = ["chrono", "url"] } diff --git a/parseon-server/src/api/dto.rs b/parseon-server/src/api/dto.rs index 6f469e0..e06c904 100644 --- a/parseon-server/src/api/dto.rs +++ b/parseon-server/src/api/dto.rs @@ -201,7 +201,13 @@ impl From for FilterPreviewResponse { #[derive(Debug, Serialize, ToSchema)] pub(crate) struct Health { pub status: &'static str, - pub monitors: usize, +} + +#[derive(Debug, Serialize, ToSchema)] +pub(crate) struct Readiness { + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<&'static str>, } #[derive(Debug, Serialize, ToSchema)] @@ -216,8 +222,14 @@ pub(crate) struct ChainStatusRow { pub enabled: bool, pub worker_state: &'static str, #[serde(skip_serializing_if = "Option::is_none")] + pub latest_head: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub canonical_head: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub finalized_head: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub promotion_height: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub last_successful_poll_at: Option>, pub last_error: Option, } @@ -231,9 +243,13 @@ impl From for ChainStatusRow { WorkerState::Starting => "starting", WorkerState::Running => "running", WorkerState::Degraded => "degraded", + WorkerState::Blocked => "blocked", WorkerState::Disabled => "disabled", }, + latest_head: snapshot.latest_head, + canonical_head: snapshot.canonical_head, finalized_head: snapshot.finalized_head, + promotion_height: snapshot.promotion_height, last_successful_poll_at: snapshot.last_successful_poll_at, last_error: snapshot.last_error, } @@ -250,16 +266,25 @@ pub(crate) struct ResultsQuery { /// Maximum number of results (default 50, clamped to 200). #[serde(default = "default_limit")] pub limit: u64, - /// Pagination offset (default 0). + /// Pagination offset (default 0, max 1,000,000). #[serde(default)] pub offset: u64, + /// Lifecycle filter; finalized is the safe default. + pub finality: Option, } #[derive(Debug, Serialize, ToSchema)] pub(crate) struct CallMonitorResult { #[schema(value_type = String, pattern = "^0x[0-9a-f]{64}$")] pub tx_hash: TxHash, + #[schema(value_type = String, pattern = "^0x[0-9a-f]{64}$")] + pub block_hash: B256, pub block_number: u64, + #[schema(value_type = String, pattern = "^0x[0-9a-fA-F]{40}$")] + pub from: Address, + #[schema(value_type = String, pattern = "^0x[0-9a-fA-F]{40}$")] + pub to: Address, + pub finality: String, #[schema(value_type = Object)] pub params: serde_json::Value, } @@ -268,8 +293,13 @@ pub(crate) struct CallMonitorResult { pub(crate) struct EventMonitorResult { #[schema(value_type = String, pattern = "^0x[0-9a-f]{64}$")] pub tx_hash: TxHash, + #[schema(value_type = String, pattern = "^0x[0-9a-f]{64}$")] + pub block_hash: B256, pub log_index: u64, pub block_number: u64, + #[schema(value_type = String, pattern = "^0x[0-9a-fA-F]{40}$")] + pub emitter: Address, + pub finality: String, #[schema(value_type = Object)] pub params: serde_json::Value, } @@ -284,12 +314,40 @@ pub(crate) enum MonitorResult { impl From for MonitorResult { fn from(record: MonitorResultView) -> Self { match record { - MonitorResultView::Call { tx_hash, block_number, params } => { - Self::Call(CallMonitorResult { tx_hash, block_number, params }) - } - MonitorResultView::Event { tx_hash, log_index, block_number, params } => { - Self::Event(EventMonitorResult { tx_hash, log_index, block_number, params }) - } + MonitorResultView::Call { + tx_hash, + block_hash, + block_number, + from, + to, + finality, + params, + } => Self::Call(CallMonitorResult { + tx_hash, + block_hash, + block_number, + from, + to, + finality: finality.as_str().into(), + params, + }), + MonitorResultView::Event { + tx_hash, + block_hash, + log_index, + block_number, + emitter, + finality, + params, + } => Self::Event(EventMonitorResult { + tx_hash, + block_hash, + log_index, + block_number, + emitter, + finality: finality.as_str().into(), + params, + }), } } } @@ -309,7 +367,7 @@ mod tests { #[test] fn chain_rpc_url_is_write_only() { let create: CreateChain = serde_json::from_value(serde_json::json!({ - "rpc_url": "https://user:secret@example.invalid" + "rpc_url": "https://example.invalid" })) .unwrap(); assert!(create.enabled); @@ -324,7 +382,6 @@ mod tests { ChainRow { chain_id: 1, enabled: true, created_at: Utc::now(), updated_at: Utc::now() }; let value = serde_json::to_value(row).unwrap(); assert!(value.get("rpc_url").is_none()); - assert!(!value.to_string().contains("secret")); } #[test] @@ -383,9 +440,14 @@ mod tests { #[test] fn serializes_minimal_results() { let call_hash = TxHash::repeat_byte(0x11); + let call_block_hash = B256::repeat_byte(0x33); let call = MonitorResult::from(MonitorResultView::Call { tx_hash: call_hash, + block_hash: call_block_hash, block_number: 10, + from: Address::repeat_byte(1), + to: Address::repeat_byte(2), + finality: parseon_core::Finality::Finalized, params: serde_json::json!({"value": "42"}), }); assert_eq!( @@ -393,16 +455,24 @@ mod tests { serde_json::json!({ "kind": "call", "tx_hash": call_hash.to_string(), + "block_hash": call_block_hash.to_string(), "block_number": 10, + "from": Address::repeat_byte(1).to_string(), + "to": Address::repeat_byte(2).to_string(), + "finality": "finalized", "params": {"value": "42"} }) ); let event_hash = TxHash::repeat_byte(0x22); + let event_block_hash = B256::repeat_byte(0x44); let event = MonitorResult::from(MonitorResultView::Event { tx_hash: event_hash, + block_hash: event_block_hash, log_index: 3, block_number: 11, + emitter: Address::repeat_byte(3), + finality: parseon_core::Finality::Provisional, params: serde_json::json!({"owner": "0x1"}), }); assert_eq!( @@ -410,8 +480,11 @@ mod tests { serde_json::json!({ "kind": "event", "tx_hash": event_hash.to_string(), + "block_hash": event_block_hash.to_string(), "log_index": 3, "block_number": 11, + "emitter": Address::repeat_byte(3).to_string(), + "finality": "provisional", "params": {"owner": "0x1"} }) ); diff --git a/parseon-server/src/api/handlers.rs b/parseon-server/src/api/handlers.rs index f16f52d..76f7f28 100644 --- a/parseon-server/src/api/handlers.rs +++ b/parseon-server/src/api/handlers.rs @@ -6,41 +6,77 @@ use axum::response::IntoResponse; use crate::api::AppState; use crate::api::dto::{ ChainRow, CreateChain, CreateMonitor, ErrorResponse, FilterPreviewRequest, - FilterPreviewResponse, Health, MonitorResult, MonitorRow, ResultsQuery, Status, UpdateChain, - UpdateMonitor, + FilterPreviewResponse, Health, MonitorResult, MonitorRow, Readiness, ResultsQuery, Status, + UpdateChain, UpdateMonitor, }; use crate::error::{AppError, AppResult}; -use parseon_core::MonitorId; use parseon_core::commands::{ CreateChain as CreateChainCommand, CreateMonitor as CreateMonitorCommand, PageLimit, PreviewFilter as PreviewFilterCommand, ResultQuery, UpdateChain as UpdateChainCommand, }; +use parseon_core::{Finality, MonitorId}; #[utoipa::path( get, path = "/healthz", tag = "health", responses( - (status = OK, description = "Service is healthy", body = Health), - (status = INTERNAL_SERVER_ERROR, description = "Database error", body = ErrorResponse) + (status = OK, description = "Process liveness", body = Health) ) )] -pub(crate) async fn healthz(State(state): State) -> AppResult> { - let monitors = state.monitors.count().await?; - Ok(Json(Health { status: "ok", monitors })) +pub(crate) async fn healthz() -> Json { + Json(Health { status: "ok" }) +} + +#[utoipa::path( + get, + path = "/readyz", + tag = "health", + responses( + (status = OK, description = "Storage and enabled workers are ready", body = Readiness), + (status = SERVICE_UNAVAILABLE, description = "Storage or an enabled worker is not ready", body = Readiness) + ) +)] +pub(crate) async fn readyz( + State(state): State, +) -> (axum::http::StatusCode, Json) { + if state.monitors.count().await.is_err() { + return ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + Json(Readiness { status: "not_ready", reason: Some("storage unavailable") }), + ); + } + let max_age = + chrono::Duration::from_std(state.readiness_max_age).unwrap_or(chrono::Duration::MAX); + let ready = state.runtime_status.snapshot().into_iter().all(|chain| { + !chain.enabled + || (matches!(chain.worker_state, parseon_core::status::WorkerState::Running) + && chain + .last_successful_poll_at + .is_some_and(|at| chrono::Utc::now() - at <= max_age)) + }); + if ready { + (axum::http::StatusCode::OK, Json(Readiness { status: "ready", reason: None })) + } else { + ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + Json(Readiness { status: "not_ready", reason: Some("worker unavailable") }), + ) + } } #[utoipa::path( get, path = "/status", tag = "health", + security(("bearerAuth" = [])), responses( - (status = OK, description = "Finalized indexing progress and worker state", body = Status) + (status = OK, description = "Canonical and finalized indexing progress and worker state", body = Status) ) )] pub(crate) async fn status(State(state): State) -> Json { Json(Status { - mode: "finalized", + mode: "canonical_with_finality", chains: state.runtime_status.snapshot().into_iter().map(Into::into).collect(), }) } @@ -64,6 +100,7 @@ pub(crate) async fn metrics(State(state): State) -> AppResult) -> AppResult, Query, description = "Maximum number of results (default 50, max 200)"), - ("offset" = Option, Query, description = "Pagination offset (default 0)") + ("offset" = Option, Query, description = "Pagination offset (default 0, max 1000000)"), + ("finality" = Option, Query, description = "Result lifecycle: finalized (default), provisional, or all") ), responses( (status = OK, description = "Decoded results ordered by block_number descending", body = [MonitorResult]), @@ -314,16 +363,32 @@ pub(crate) async fn list_monitor_results( Path(id): Path, Query(query): Query, ) -> AppResult>> { + if query.offset > 1_000_000 { + return Err(AppError::BadRequest("result offset exceeds 1000000".into())); + } let rows = state .monitors .results( monitor_id(id)?, - ResultQuery { limit: PageLimit::new(query.limit), offset: query.offset }, + ResultQuery { + limit: PageLimit::new(query.limit), + offset: query.offset, + finality: parse_finality(query.finality.as_deref().unwrap_or("finalized"))?, + }, ) .await?; Ok(Json(rows.into_iter().map(Into::into).collect())) } +fn parse_finality(value: &str) -> AppResult> { + match value { + "finalized" => Ok(Some(Finality::Finalized)), + "provisional" => Ok(Some(Finality::Provisional)), + "all" => Ok(None), + _ => Err(AppError::BadRequest("finality must be finalized, provisional, or all".into())), + } +} + fn monitor_id(id: u64) -> AppResult { MonitorId::new(id).map_err(|error| AppError::BadRequest(error.to_string())) } diff --git a/parseon-server/src/api/mod.rs b/parseon-server/src/api/mod.rs index 3e3eba7..82c23dd 100644 --- a/parseon-server/src/api/mod.rs +++ b/parseon-server/src/api/mod.rs @@ -3,7 +3,14 @@ pub(crate) mod handlers; pub(crate) mod openapi; pub(crate) mod routes; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use std::sync::Arc; +use std::time::Duration; use tower_http::cors::CorsLayer; +use tower_http::limit::RequestBodyLimitLayer; use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_axum::router::OpenApiRouter; @@ -21,6 +28,10 @@ pub(crate) struct AppState { pub monitors: MonitorService, pub runtime_status: RuntimeStatus, pub telemetry: std::sync::Arc, + pub api_token: Arc, + pub cors_origins: Arc, + pub max_body_bytes: usize, + pub readiness_max_age: Duration, } impl AppState { @@ -29,8 +40,20 @@ impl AppState { monitors: MonitorService, runtime_status: RuntimeStatus, telemetry: std::sync::Arc, + api_token: String, + cors_origins: String, + max_body_bytes: usize, ) -> Self { - Self { chains, monitors, runtime_status, telemetry } + Self { + chains, + monitors, + runtime_status, + telemetry, + api_token: Arc::from(api_token), + cors_origins: Arc::from(cors_origins), + max_body_bytes, + readiness_max_age: Duration::from_secs(30), + } } } @@ -42,13 +65,71 @@ pub(crate) fn router(state: AppState) -> axum::Router { .merge(routes::filter_routes()) .split_for_parts(); + let cors = cors_layer(&state.cors_origins); router .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi)) - .layer(CorsLayer::permissive()) + .layer(middleware::from_fn_with_state(state.clone(), authenticate)) + .layer(RequestBodyLimitLayer::new(state.max_body_bytes)) + .layer(cors) .layer(TraceLayer::new_for_http()) .with_state(state) } +fn cors_layer(origins: &str) -> CorsLayer { + let mut layer = CorsLayer::new() + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PATCH, + axum::http::Method::DELETE, + ]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]); + let origins = origins + .split(',') + .map(str::trim) + .filter(|origin| !origin.is_empty()) + .filter_map(|origin| origin.parse().ok()) + .collect::>(); + if !origins.is_empty() { + layer = layer.allow_origin(origins); + } + layer +} + +async fn authenticate( + axum::extract::State(state): axum::extract::State, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + if matches!(path, "/healthz" | "/readyz" | "/metrics") + || path == "/swagger-ui" + || path.starts_with("/swagger-ui/") + || path == "/api-docs/openapi.json" + { + return next.run(request).await; + } + let authorized = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|value| constant_time_equal(value.as_bytes(), state.api_token.as_bytes())); + if !authorized { + return (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Bearer")], "unauthorized") + .into_response(); + } + next.run(request).await +} + +fn constant_time_equal(left: &[u8], right: &[u8]) -> bool { + let mut diff = left.len() ^ right.len(); + for index in 0..left.len().max(right.len()) { + diff |= usize::from(u8::from(left.get(index) != right.get(index))); + } + diff == 0 +} + #[cfg(test)] mod tests { use std::num::{NonZeroU64, NonZeroUsize}; @@ -57,7 +138,7 @@ mod tests { use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode, header}; - use serde_json::Value; + use serde_json::{Value, json}; use sqlx::postgres::PgPoolOptions; use tower::ServiceExt; @@ -70,7 +151,7 @@ mod tests { fn test_router() -> axum::Router { let pool = PgPoolOptions::new() - .connect_lazy("postgres://postgres:postgres@localhost/parseon") + .connect_lazy("postgres://localhost/parseon") .expect("test database URL should be valid"); let statuses = RuntimeStatus::default(); let running = ChainStatus::starting(42, None); @@ -86,6 +167,8 @@ mod tests { poll_interval: Duration::from_secs(1), block_concurrency: NonZeroUsize::new(2).expect("non-zero"), storage_write_concurrency: NonZeroUsize::new(2).expect("non-zero"), + confirmations: NonZeroU64::new(64).expect("non-zero"), + rollback_retention: NonZeroU64::new(256).expect("non-zero"), }, Vec::new(), SupervisorDependencies { @@ -102,9 +185,27 @@ mod tests { MonitorService::new(storage, sources), statuses, telemetry, + "test-token".into(), + String::new(), + 1024 * 1024, )) } + #[tokio::test] + async fn protects_api_routes_but_keeps_liveness_public() { + let unauthorized = test_router() + .oneshot(Request::builder().uri("/status").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + + let health = test_router() + .oneshot(Request::builder().uri("/healthz").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(health.status(), StatusCode::OK); + } + #[tokio::test] async fn serves_complete_openapi_document() { let response = test_router() @@ -120,8 +221,9 @@ mod tests { let paths = document["paths"].as_object().unwrap(); let expected_operations = [ - ("/healthz", "get", &["200", "500"][..]), + ("/healthz", "get", &["200"][..]), ("/status", "get", &["200"][..]), + ("/readyz", "get", &["200", "503"][..]), ("/metrics", "get", &["200", "500"][..]), ("/chains", "get", &["200", "500"][..]), ("/chains", "post", &["200", "400", "500"][..]), @@ -146,6 +248,27 @@ mod tests { ); } } + for (path, method) in [ + ("/status", "get"), + ("/chains", "get"), + ("/chains", "post"), + ("/chains/{chain_id}", "get"), + ("/chains/{chain_id}", "patch"), + ("/chains/{chain_id}", "delete"), + ("/monitors", "get"), + ("/monitors", "post"), + ("/filters/preview", "post"), + ("/monitors/{id}", "get"), + ("/monitors/{id}", "patch"), + ("/monitors/{id}", "delete"), + ("/monitors/{id}/results", "get"), + ] { + assert_eq!(paths[path][method]["security"], json!([{ "bearerAuth": [] }])); + } + for (path, method) in [("/healthz", "get"), ("/readyz", "get"), ("/metrics", "get")] { + assert!(paths[path][method].get("security").is_none()); + } + assert_eq!(document["components"]["securitySchemes"]["bearerAuth"]["scheme"], "bearer"); let schemas = document["components"]["schemas"].as_object().unwrap(); for schema in [ @@ -160,6 +283,7 @@ mod tests { "FilterPreviewResponse", "FilterSampleInput", "Health", + "Readiness", "MonitorResult", "MonitorRow", "EventMonitorResult", @@ -190,24 +314,33 @@ mod tests { .iter() .map(|parameter| parameter["name"].as_str().unwrap()) .collect::>(); - assert_eq!(parameter_names, ["id", "limit", "offset"]); + assert_eq!(parameter_names, ["id", "limit", "offset", "finality"]); } #[tokio::test] async fn reports_finalized_runtime_status() { let response = test_router() - .oneshot(Request::builder().uri("/status").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/status") + .header(header::AUTHORIZATION, "Bearer test-token") + .body(Body::empty()) + .unwrap(), + ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let status: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(status["mode"], "finalized"); + assert_eq!(status["mode"], "canonical_with_finality"); assert_eq!(status["chains"].as_array().unwrap().len(), 2); assert_eq!(status["chains"][0]["chain_id"], 42); assert_eq!(status["chains"][0]["enabled"], true); + assert_eq!(status["chains"][0]["latest_head"], 20_000_000); + assert_eq!(status["chains"][0]["canonical_head"], 20_000_000); assert_eq!(status["chains"][0]["finalized_head"], 20_000_000); + assert_eq!(status["chains"][0]["promotion_height"], 20_000_000); assert_eq!(status["chains"][0]["worker_state"], "running"); assert!(status["chains"][0]["last_successful_poll_at"].is_string()); assert!(status["chains"][0]["last_error"].is_null()); @@ -216,6 +349,17 @@ mod tests { assert!(status["chains"][1].get("finalized_head").is_none()); } + #[tokio::test] + async fn serves_liveness_without_storage_access() { + let response = test_router() + .oneshot(Request::builder().uri("/healthz").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap()["status"], "ok"); + } + #[tokio::test] async fn serves_embedded_swagger_ui() { let app = test_router(); diff --git a/parseon-server/src/api/openapi.rs b/parseon-server/src/api/openapi.rs index 18a64f3..d355396 100644 --- a/parseon-server/src/api/openapi.rs +++ b/parseon-server/src/api/openapi.rs @@ -1,13 +1,28 @@ -use utoipa::OpenApi; +use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}; +use utoipa::openapi::{Components, OpenApi}; +use utoipa::{Modify, OpenApi as DeriveOpenApi}; -#[derive(OpenApi)] +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut OpenApi) { + let components = openapi.components.get_or_insert_with(Components::new); + components.add_security_scheme( + "bearerAuth", + SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).build()), + ); + } +} + +#[derive(DeriveOpenApi)] #[openapi( info( title = "Parseon API", description = "HTTP API for managing Parseon EVM chains, monitors, and decoded results" ), + modifiers(&SecurityAddon), tags( - (name = "health", description = "Service health, finalized indexing status, and metrics"), + (name = "health", description = "Service health, canonical indexing status, and metrics"), (name = "chains", description = "EVM chain registry management"), (name = "monitors", description = "EVM call and event monitor management"), (name = "filters", description = "Stateless monitor filter validation and preview"), diff --git a/parseon-server/src/api/routes.rs b/parseon-server/src/api/routes.rs index 59c7f45..71012f8 100644 --- a/parseon-server/src/api/routes.rs +++ b/parseon-server/src/api/routes.rs @@ -8,6 +8,7 @@ pub(crate) fn health_routes() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(handlers::healthz)) .routes(routes!(handlers::status)) + .routes(routes!(handlers::readyz)) .routes(routes!(handlers::metrics)) } diff --git a/parseon-server/src/config.rs b/parseon-server/src/config.rs index 1b97a60..8753325 100644 --- a/parseon-server/src/config.rs +++ b/parseon-server/src/config.rs @@ -8,6 +8,9 @@ use parseon_core::Url; #[derive(Debug, Clone, Parser)] #[command(name = "parseon", about = "Parseon — EVM indexer with runtime ABI decoding")] pub(crate) struct Config { + /// Bearer token protecting data and administrative API routes. + #[arg(long, env = "API_TOKEN")] + pub api_token: String, #[command(flatten)] pub storage: StorageConfig, #[command(flatten)] @@ -40,6 +43,12 @@ pub(crate) struct ServerConfig { /// Log filter directive (e.g. `info,parseon=debug`) #[arg(long, env = "RUST_LOG", default_value = "info")] pub rust_log: String, + /// Comma-separated browser origins; empty disables CORS. + #[arg(long, env = "CORS_ORIGINS", default_value = "")] + pub cors_origins: String, + /// Maximum JSON request body size. + #[arg(long, env = "MAX_BODY_BYTES", default_value = "1048576")] + pub max_body_bytes: usize, } #[derive(Debug, Clone, Args)] @@ -59,6 +68,12 @@ pub(crate) struct IndexingConfig { /// Maximum concurrent atomic storage commits across all chains #[arg(long, env = "STORAGE_WRITE_CONCURRENCY", default_value = "4")] pub storage_write_concurrency: NonZeroUsize, + /// Number of latest blocks retained before finality promotion. + #[arg(long, env = "CONFIRMATION_DEPTH", default_value = "64")] + pub confirmations: NonZeroU64, + /// Maximum canonical block history used for reorg recovery. + #[arg(long, env = "ROLLBACK_RETENTION", default_value = "256")] + pub rollback_retention: NonZeroU64, } #[cfg(feature = "webhook-sink")] @@ -80,6 +95,9 @@ pub(crate) struct RpcConfig { /// Maximum JSON-RPC calls grouped into one receipt batch #[arg(long, env = "RPC_BATCH_SIZE", default_value = "20")] pub batch_size: NonZeroUsize, + /// Allow private/loopback RPC destinations for local development only. + #[arg(long, env = "ALLOW_PRIVATE_RPC_NETWORKS", default_value_t = false)] + pub allow_private_networks: bool, } fn parse_poll_interval(value: &str) -> Result { @@ -96,12 +114,23 @@ impl Config { drop(dotenvy::dotenv()); Self::parse() } + + pub(crate) fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!(!self.api_token.is_empty(), "API_TOKEN must not be empty"); + anyhow::ensure!( + self.indexing.rollback_retention >= self.indexing.confirmations, + "ROLLBACK_RETENTION must be at least CONFIRMATION_DEPTH" + ); + anyhow::ensure!(self.server.max_body_bytes > 0, "MAX_BODY_BYTES must be positive"); + Ok(()) + } } #[cfg(test)] mod tests { use super::{Config, parse_poll_interval}; use clap::Parser; + use std::num::NonZeroU64; use std::time::Duration; #[cfg(feature = "webhook-sink")] @@ -127,7 +156,13 @@ mod tests { #[test] fn accepts_storage_url() { - let args = with_webhook(vec!["parseon", "--storage-url", "postgres://localhost/parseon"]); + let args = with_webhook(vec![ + "parseon", + "--api-token", + "test-token", + "--storage-url", + "postgres://localhost/parseon", + ]); assert_eq!( Config::try_parse_from(args).unwrap().storage.storage_url.as_str(), "postgres://localhost/parseon" @@ -138,6 +173,8 @@ mod tests { fn accepts_zero_block_cache_size() { let args = with_webhook(vec![ "parseon", + "--api-token", + "test-token", "--storage-url", "postgres://localhost/parseon", "--block-cache-size", @@ -146,10 +183,33 @@ mod tests { assert_eq!(Config::try_parse_from(args).unwrap().indexing.block_cache_size, 0); } + #[test] + fn validates_production_safety_settings() { + let config = Config::try_parse_from(with_webhook(vec![ + "parseon", + "--api-token", + "token", + "--storage-url", + "postgres://localhost/parseon", + ])) + .unwrap(); + assert!(config.validate().is_ok()); + + let mut invalid = config.clone(); + invalid.api_token.clear(); + assert!(invalid.validate().is_err()); + + let mut invalid = config; + invalid.indexing.rollback_retention = NonZeroU64::new(1).unwrap(); + assert!(invalid.validate().is_err()); + } + #[test] fn rejects_removed_database_configuration_names() { let args = with_webhook(vec![ "parseon", + "--api-token", + "test-token", "--storage-url", "postgres://localhost/parseon", "--database-url", @@ -159,6 +219,8 @@ mod tests { let args = with_webhook(vec![ "parseon", + "--api-token", + "test-token", "--storage-url", "postgres://localhost/parseon", "--db-write-concurrency", diff --git a/parseon-server/src/main.rs b/parseon-server/src/main.rs index 9105279..8d187b2 100644 --- a/parseon-server/src/main.rs +++ b/parseon-server/src/main.rs @@ -18,6 +18,7 @@ use tokio_util::sync::CancellationToken; #[tokio::main] async fn main() -> anyhow::Result<()> { let config = config::Config::load(); + config.validate()?; drop( tracing_subscriber::fmt() @@ -60,12 +61,15 @@ async fn main() -> anyhow::Result<()> { poll_interval: config.indexing.poll_interval, block_concurrency: config.indexing.block_concurrency, storage_write_concurrency: config.indexing.storage_write_concurrency, + confirmations: config.indexing.confirmations, + rollback_retention: config.indexing.rollback_retention, }; let telemetry = Arc::new(metrics::Metrics::default()); let source_factory = Arc::new(parseon_rpc::JsonRpcBlockSourceFactory::new( parseon_rpc::RpcConfig { request_concurrency: config.rpc.request_concurrency, batch_size: config.rpc.batch_size, + allow_private_networks: config.rpc.allow_private_networks, }, telemetry.clone(), )); @@ -107,7 +111,15 @@ async fn main() -> anyhow::Result<()> { } }); - let state = api::AppState::new(chains, monitors, runtime_status, telemetry); + let state = api::AppState::new( + chains, + monitors, + runtime_status, + telemetry, + config.api_token, + config.server.cors_origins, + config.server.max_body_bytes, + ); let app = api::router(state); let server_cancel = cancel.clone(); let mut server_handle = tokio::spawn(async move { diff --git a/parseon-server/src/metrics.rs b/parseon-server/src/metrics.rs index c9ca9d2..eb2df47 100644 --- a/parseon-server/src/metrics.rs +++ b/parseon-server/src/metrics.rs @@ -39,6 +39,12 @@ struct CacheLabels { result: &'static str, } +#[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] +struct WorkerStateLabels { + chain_id: String, + state: &'static str, +} + #[derive(Clone, Debug, Hash, PartialEq, Eq, EncodeLabelSet)] struct RpcLabels { chain_id: String, @@ -62,6 +68,8 @@ struct Inner { results_committed: Family, storage_commit_duration: Family, worker_lag: Family, + worker_state: Family, + worker_last_successful_poll: Family, in_flight: Family, cache_access: Family, } @@ -83,6 +91,8 @@ impl Default for Metrics { let results_committed = Family::default(); let storage_commit_duration = Family::new_with_constructor(histogram as fn() -> Histogram); let worker_lag = Family::default(); + let worker_state = Family::default(); + let worker_last_successful_poll = Family::default(); let in_flight = Family::default(); let cache_access = Family::default(); @@ -117,6 +127,16 @@ impl Default for Metrics { "Finalized blocks between the slowest active monitor and the finalized head.", worker_lag.clone(), ); + registry.register( + "parseon_worker_state", + "Current worker lifecycle state (one for the active state).", + worker_state.clone(), + ); + registry.register( + "parseon_worker_last_successful_poll_timestamp_seconds", + "Unix timestamp of the last successful worker poll.", + worker_last_successful_poll.clone(), + ); registry.register( "parseon_in_flight", "Work currently in flight by bounded pipeline stage.", @@ -137,6 +157,8 @@ impl Default for Metrics { results_committed, storage_commit_duration, worker_lag, + worker_state, + worker_last_successful_poll, in_flight, cache_access, }), @@ -221,6 +243,25 @@ impl Telemetry for Metrics { .set(i64::try_from(lag).unwrap_or(i64::MAX)); } + fn set_worker_state(&self, chain_id: u64, state: &'static str) { + for candidate in ["starting", "running", "degraded", "blocked", "disabled"] { + self.inner + .worker_state + .get_or_create(&WorkerStateLabels { + chain_id: chain_id.to_string(), + state: candidate, + }) + .set(i64::from(candidate == state)); + } + } + + fn set_worker_last_successful_poll(&self, chain_id: u64, timestamp: i64) { + self.inner + .worker_last_successful_poll + .get_or_create(&Self::chain_labels(chain_id)) + .set(timestamp); + } + fn adjust_in_flight(&self, chain_id: u64, stage: &'static str, delta: i64) { let gauge = self .inner @@ -251,6 +292,8 @@ mod tests { metrics.record_cache(8453, true); metrics.record_commit(8453, 2, 1, "success", Duration::from_millis(1)); metrics.set_worker_lag(8453, 7); + metrics.set_worker_state(8453, "running"); + metrics.set_worker_last_successful_poll(8453, 123); metrics.adjust_in_flight(8453, "storage", 1); let output = metrics.render().unwrap(); @@ -259,6 +302,9 @@ mod tests { assert!(output.contains("strategy=\"batch\"")); assert!(output.contains("parseon_storage_commit_duration_seconds")); assert!(output.contains("stage=\"storage\"")); + assert!(output.contains("parseon_worker_state")); + assert!(output.contains("state=\"running\"")); + assert!(output.contains("parseon_worker_last_successful_poll_timestamp_seconds")); assert!(!output.contains("rpc_url")); } } diff --git a/scripts/backup_postgres.sh b/scripts/backup_postgres.sh new file mode 100755 index 0000000..5cf839c --- /dev/null +++ b/scripts/backup_postgres.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +: "${PGSERVICE:?set PGSERVICE to a PostgreSQL service name}" +: "${PGSERVICEFILE:?set PGSERVICEFILE to a libpq service file}" +: "${PGPASSFILE:?set PGPASSFILE to a protected libpq password file}" +: "${AGE_RECIPIENT:?set AGE_RECIPIENT to the age public key used for backups}" +BACKUP_DIR=${BACKUP_DIR:-./backups} +command -v age >/dev/null || { echo 'age is required for encrypted backups' >&2; exit 127; } +command -v sha256sum >/dev/null || { echo 'sha256sum is required for backup checksums' >&2; exit 127; } +mkdir -p "$BACKUP_DIR" +output="$BACKUP_DIR/parseon-$(date -u +%Y%m%dT%H%M%SZ).dump.age" +partial="$output.tmp.$$" +trap 'rm -f "$partial"' EXIT +pg_dump --format=custom --no-owner --dbname="$PGSERVICE" \ + | age -r "$AGE_RECIPIENT" -o "$partial" +mv "$partial" "$output" +sha256sum "$output" > "$output.sha256" +trap - EXIT +printf '%s\n' "$output" diff --git a/scripts/gen_erpc.py b/scripts/gen_erpc.py index ec710a6..e4f7214 100644 --- a/scripts/gen_erpc.py +++ b/scripts/gen_erpc.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse +import ipaddress import json import re import socket @@ -30,14 +31,19 @@ import time import urllib.error import urllib.request +from urllib.parse import parse_qsl, urlsplit from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path -SRC_DEFAULT = Path("/tmp/opencode/rpcs.json") -DST_DEFAULT = Path("/home/melonges/Desktop/parseon/erpc.yaml") +SRC_DEFAULT = Path("rpcs.json") +DST_DEFAULT = Path("erpc.yaml") +CREDENTIAL_QUERY_KEYS = {"api_key", "apikey", "access_token", "token", "secret", "password"} ALIAS_RE = re.compile(r"[^a-zA-Z0-9_-]+") DEFAULT_HEADERS = {"content-type": "application/json", "user-agent": "parseon-gen-erpc/1.0"} -# eth_getBlockByNumber(["latest", false]) — same payload chainlist.org uses. +# The probe also checks eth_chainId so an endpoint cannot be assigned to the wrong network. +CHAIN_ID_BODY = json.dumps( + {"jsonrpc": "2.0", "method": "eth_chainId", "params": [], "id": 1} +).encode() RPC_BODY = json.dumps( {"jsonrpc": "2.0", "method": "eth_getBlockByNumber", "params": ["latest", False], "id": 1} ).encode() @@ -45,6 +51,37 @@ _SSL_CTX = ssl.create_default_context() +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +_OPENER = urllib.request.build_opener( + _NoRedirect(), urllib.request.HTTPSHandler(context=_SSL_CTX) +) + + +def endpoint_safety(url: str) -> str | None: + """Return a rejection reason for URLs that do not resolve publicly.""" + parsed = urlsplit(url) + if parsed.scheme not in {"http", "https"}: + return "scheme" + if not parsed.hostname: + return "host" + try: + port = parsed.port or (443 if parsed.scheme == "https" else 80) + addresses = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM) + except (OSError, ValueError): + return "dns" + for address in addresses: + ip = ipaddress.ip_address(address[4][0]) + if ip.version == 6 and ip.ipv4_mapped is not None: + ip = ip.ipv4_mapped + if not ip.is_global: + return "private-address" + return None + + def http_rpcs(entry: dict) -> list[str]: out: list[str] = [] seen: set[str] = set() @@ -54,9 +91,16 @@ def http_rpcs(entry: dict) -> list[str]: url = (r.get("url") or "").strip().replace("\u200b", "") if not (url.startswith("http://") or url.startswith("https://")): continue - # Skip placeholder URLs with API_KEY template variables (chainlist does too). + # Never copy credentials from chainlist or a local source into generated config. + # Operators must inject private upstream URLs through their deployment secret. + parsed = urlsplit(url) + query_keys = {key.lower() for key, _ in parse_qsl(parsed.query, keep_blank_values=True)} + if parsed.username or parsed.password or query_keys & CREDENTIAL_QUERY_KEYS: + continue if "API_KEY" in url or "${" in url: continue + if endpoint_safety(url) is not None: + continue if url in seen: continue seen.add(url) @@ -74,19 +118,14 @@ def yaml_quote(s: str) -> str: return "'" + s.replace("'", "''") + "'" -def probe(url: str, timeout: float) -> tuple[int | None, int | None, str]: - """POST eth_getBlockByNumber(["latest", false]) to `url`. - - Returns (height, latency_ms, reason). height/latency are None on failure. - reason is "ok" on success or a short failure tag. - """ - req = urllib.request.Request(url, data=RPC_BODY, headers=DEFAULT_HEADERS, method="POST") +def _post(url: str, body: bytes, timeout: float) -> tuple[bytes | None, int | None, str | None]: + req = urllib.request.Request(url, data=body, headers=DEFAULT_HEADERS, method="POST") t0 = time.monotonic() try: - with urllib.request.urlopen(req, timeout=timeout, context=_SSL_CTX) as resp: + with _OPENER.open(req, timeout=timeout) as resp: if resp.status != 200: return None, None, f"http {resp.status}" - raw = resp.read(65536) + return resp.read(65536), int((time.monotonic() - t0) * 1000), None except urllib.error.HTTPError as e: return None, None, f"http {e.code}" except (urllib.error.URLError, socket.timeout, TimeoutError, OSError, ssl.SSLError) as e: @@ -94,8 +133,26 @@ def probe(url: str, timeout: float) -> tuple[int | None, int | None, str]: except Exception as e: return None, None, f"other:{type(e).__name__}" - latency_ms = int((time.monotonic() - t0) * 1000) +def probe(url: str, chain_id: int, timeout: float) -> tuple[int | None, int | None, str]: + """Check endpoint chain identity and latest block height.""" + safety = endpoint_safety(url) + if safety is not None: + return None, None, f"unsafe:{safety}" + raw, _, failure = _post(url, CHAIN_ID_BODY, timeout) + if failure is not None or raw is None: + return None, None, failure or "empty-response" + try: + chain_doc = json.loads(raw) + actual_chain_id = int(chain_doc["result"], 16) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return None, None, "bad-chain-id" + if actual_chain_id != chain_id: + return None, None, f"wrong-chain:{actual_chain_id}" + + raw, latency_ms, failure = _post(url, RPC_BODY, timeout) + if failure is not None or raw is None: + return None, None, failure or "empty-response" try: doc = json.loads(raw) except json.JSONDecodeError: @@ -133,7 +190,7 @@ def probe_all( c["chainId"]: [] for c in chains } with ThreadPoolExecutor(max_workers=workers) as pool: - fut_to_task = {pool.submit(probe, url, timeout): (cid, url) for cid, url in tasks} + fut_to_task = {pool.submit(probe, url, cid, timeout): (cid, url) for cid, url in tasks} for fut in as_completed(fut_to_task): cid, url = fut_to_task[fut] try: @@ -280,6 +337,7 @@ def main() -> int: lines.append("# Top N mainnet EVM chains by TVL with at least one HTTP/HTTPS RPC.") lines.append("# Endpoints probed with eth_getBlockByNumber and ranked by chainlist.org's") lines.append("# height-then-latency algorithm; failures dropped.") + lines.append("# Credential-bearing URLs are intentionally excluded; inject private endpoints at deploy time.") lines.append(f"# Regenerate with: python3 scripts/gen_erpc.py --top {args.top} [--no-probe] [--filter-stale] [--src rpcs.json] [--dst erpc.yaml]") lines.append("") lines.append("logLevel: warn") diff --git a/scripts/restore_postgres.sh b/scripts/restore_postgres.sh new file mode 100755 index 0000000..006edbc --- /dev/null +++ b/scripts/restore_postgres.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 077 + +: "${PGSERVICE:?set PGSERVICE to the isolated PostgreSQL restore service}" +: "${PGSERVICEFILE:?set PGSERVICEFILE to a libpq service file}" +: "${PGPASSFILE:?set PGPASSFILE to a protected libpq password file}" +: "${AGE_IDENTITY:?set AGE_IDENTITY to the age private key used for backups}" +: "${RESTORE_CONFIRM:?set RESTORE_CONFIRM=YES to allow a destructive restore}" +[ "$RESTORE_CONFIRM" = YES ] || { echo 'RESTORE_CONFIRM must be YES' >&2; exit 2; } +[ "$#" -eq 1 ] || { echo "usage: $0 backup.dump.age" >&2; exit 2; } +[ -f "$1" ] || { echo "backup not found: $1" >&2; exit 2; } +[ -f "$1.sha256" ] || { echo "backup checksum not found: $1.sha256" >&2; exit 2; } +command -v age >/dev/null || { echo 'age is required for encrypted restores' >&2; exit 127; } +command -v sha256sum >/dev/null || { echo 'sha256sum is required for backup checksums' >&2; exit 127; } +sha256sum -c "$1.sha256" +age -d -i "$AGE_IDENTITY" "$1" \ + | pg_restore --clean --if-exists --exit-on-error --no-owner --dbname="$PGSERVICE" -