diff --git a/.env.example b/.env.example index 791e5cc..91aed4a 100644 --- a/.env.example +++ b/.env.example @@ -17,19 +17,28 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/programmatic-orders # Schema for this app (required when using Postgres; avoids "previously used by a different Ponder app") DATABASE_SCHEMA=programmatic_orders -# Dev/local: reduce RPC usage during sync -# DISABLE_POLL_RESULT_CHECK=true — skip C1 ContractPoller multicalls for non-deterministic generators -# DISABLE_DETERMINISTIC_CANCEL_SWEEP=true — skip C5 singleOrders checks for deterministic generators -# DISABLE_SETTLEMENT_FACTORY_CHECK=true — skip getCode + FACTORY() calls in the GPv2Settlement:Trade -# handler entirely. Use to benchmark base sync throughput vs. the cost of those RPC calls. - -# C1 ContractPoller per-block generator cap (optional; default: 200) -# Hard ceiling on how many generators the block handler multicalls per block. -# Overflow defers to the next block, prioritized by oldest lastCheckBlock. +# Performance / escape-hatch flags (all optional; safe to omit in production) +# DISABLE_POLL_RESULT_CHECK=true # escape-hatch: skips OrderDiscoveryPoller multicalls for +# # non-deterministic generators; leaves those orders undetected +# # until re-enabled. Use only to benchmark or diagnose. +# DISABLE_DETERMINISTIC_CANCEL_SWEEP=true # escape-hatch: skips CancellationWatcher singleOrders() +# # reads; on-chain remove() on TWAP/StopLoss/CirclesBackingOrder +# # generators will not be detected while disabled. +# DISABLE_SETTLEMENT_FACTORY_CHECK=true # escape-hatch: skips getCode + FACTORY() RPC calls in the +# # GPv2Settlement handler. Benchmark-only; do not use in prod. + +# Per-block generator cap for OrderDiscoveryPoller + CancellationWatcher (optional; default: 200) +# Hard ceiling per block per chain. Excess generators defer to the next block (oldest-first). # Override per chain with the numeric chain-id suffix: # MAX_GENERATORS_PER_BLOCK_1=200 # mainnet # MAX_GENERATORS_PER_BLOCK_100=400 # gnosis (shorter block time → higher budget) +# eth_getLogs block range cap (optional; default: 1000) +# Increase if your RPC provider supports a larger range to speed up backfill. +# Override per chain with the numeric chain-id suffix: +# ETH_GET_LOGS_BLOCK_RANGE_1=2000 # mainnet +# ETH_GET_LOGS_BLOCK_RANGE_100=5000 # gnosis + # Logging (optional) # PINO_LOG_LEVEL=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86f7d39..dc2ac81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,6 @@ jobs: env: # Public RPC MAINNET_RPC_URL: https://eth.api.pocket.network + + - name: Test + run: pnpm test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 93f2f37..4e8bfc7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,6 +19,18 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Set up SSH key run: | mkdir -p ~/.ssh @@ -45,8 +57,4 @@ jobs: echo "${{ secrets.DEPLOY_ENV_FILE_CONTENT }}" > .env - name: Run deploy script - run: | - cd deployment - bash deploy-remotely.sh \ - cow-deploy:${{ secrets.DEPLOY_PATH }} \ - ../.env + run: npx tsx deployment/deploy-remotely.ts cow-deploy:${{ secrets.DEPLOY_PATH }} ../.env diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..740c0e1 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,50 @@ +name: Publish Docker image + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-push: + name: Build and push to ghcr.io + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=sha,format=long + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + PIPELINE_BUILD_TAG=${{ github.sha }} diff --git a/README.md b/README.md index 929f52c..8c917b0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,25 @@ pnpm dev The GraphQL API is at `http://localhost:42069` once the dev server starts. +> **First run takes time.** The indexer must backfill all on-chain events from the contract's deploy block before it goes live. This can take several hours depending on your RPC endpoint. The API is queryable the whole time — data just fills in progressively. + +## Is it working? + +Use these endpoints to check indexer health without reading logs: + +| Endpoint | What to expect | +|----------|----------------| +| `GET /healthz` | `200 {"status":"ok"}` — process is alive | +| `GET /ready` | `503` while backfilling, `200` once caught up | +| `GET /status` | Per-chain block progress (current vs. latest) | +| `GET /metrics` | Prometheus metrics (block lag, handler latency) | + +**Normal during backfill** — `/ready` returns `503` and `/status` shows `checkpoint` far behind `latest`. The indexer is working; it just hasn't caught up yet. Expect this for several hours on first run. + +**Stuck vs. slow** — if `/status` shows the same `checkpoint` block for more than 5 minutes _after_ backfill (i.e., once `/ready` returned `200`), the indexer may be stuck. Check `docker logs ` for errors. + +**Container crashed** — `/healthz` returns a connection error. Restart the container and check logs. + ## Commands | Command | What it does | diff --git a/deployment/deploy-remotely.sh b/deployment/deploy-remotely.sh deleted file mode 100755 index e06d0f7..0000000 --- a/deployment/deploy-remotely.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -set -exo pipefail - -REPO_ROOT_DIR=$(git rev-parse --show-toplevel) -APP_REVISION=$(git rev-parse --short HEAD) - -DEPLOY_TARGET="${1:-}" -ENV_FILE_PATH="${2:-.env}" - -if [[ -z "$DEPLOY_TARGET" ]]; then - echo "Usage: $0 [env_file_path]" - exit 1 -fi - -if [[ "$DEPLOY_TARGET" == "-" ]]; then - # Local deployment - TARGET_DEPLOY_DIR="$REPO_ROOT_DIR" - APP_DEPLOY_DIR="$TARGET_DEPLOY_DIR/deployment" - - bash "$APP_DEPLOY_DIR/manage.sh" ${MANAGE_CMD_OVERRIDE:-up} \ - --env-file "$ENV_FILE_PATH" \ - --revision "$APP_REVISION" -elif [[ "$DEPLOY_TARGET" =~ ^[^:]+:.+ ]]; then - # Remote deployment via SSH - SSH_HOST=$(echo "$DEPLOY_TARGET" | cut -d':' -f1) - REMOTE_PATH=$(echo "$DEPLOY_TARGET" | cut -d':' -f2-) - - # Sync repository to remote - # .env is excluded — copied separately via scp to preserve server secrets - rsync -avz --delete \ - --mkpath \ - --exclude='.git' \ - --exclude='node_modules' \ - --exclude='.env' \ - --exclude='.env.local' \ - --exclude='.vite' \ - --exclude='*.log' \ - --exclude='tmp/' \ - "$REPO_ROOT_DIR/" "$SSH_HOST:$REMOTE_PATH/" - - # Copy .env to deployment directory on remote (separate from rsync) - REMOTE_ENV_PATH="$REMOTE_PATH/deployment/.env" - scp "$ENV_FILE_PATH" "$SSH_HOST:$REMOTE_ENV_PATH" - - APP_DEPLOY_DIR="$REMOTE_PATH/deployment" - MANAGE_CMD="${MANAGE_CMD_OVERRIDE:-up}" - - # Run manage.sh on remote - ssh "$SSH_HOST" "cd $APP_DEPLOY_DIR && bash manage.sh $MANAGE_CMD --env-file .env --revision $APP_REVISION" -else - echo "Error: must be '-' or SSH_HOST:PATH" - exit 1 -fi diff --git a/deployment/deploy-remotely.ts b/deployment/deploy-remotely.ts new file mode 100644 index 0000000..37c9a53 --- /dev/null +++ b/deployment/deploy-remotely.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env tsx +/** + * deploy-remotely.ts — rsync + SSH deploy or local deploy. + * Replaces deploy-remotely.sh. + * + * NOTE: This script is specific to Bleu's internal deployment workflow. + * It assumes a particular server layout and SSH setup. Adapt as needed for + * your own hosting environment. + * + * Usage: + * npx tsx deployment/deploy-remotely.ts [env_file_path] + * + * deploy_target: + * - Local deployment (runs manage.ts in this repo) + * host:path Remote deployment via SSH (rsync + scp + ssh) + * + * Note: Remote deployment requires Node 18+ and pnpm installed on the remote host. + * Run `pnpm install` on the remote after the first deploy to install tsx. + */ + +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; + +function run( + cmd: string, + args: string[], + opts: { cwd?: string; ignoreError?: boolean } = {} +): void { + console.log(`+ ${cmd} ${args.join(" ")}`); + const result = spawnSync(cmd, args, { + stdio: "inherit", + cwd: opts.cwd, + }); + if (!opts.ignoreError && result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function runCapture(cmd: string, args: string[]): string { + const result = spawnSync(cmd, args, { encoding: "utf-8" }); + if (result.status !== 0) { + console.error(`Command failed: ${cmd} ${args.join(" ")}`); + console.error(result.stderr); + process.exit(result.status ?? 1); + } + return result.stdout.trim(); +} + +function usage(): never { + console.error( + `Usage: tsx deployment/deploy-remotely.ts [env_file_path] + + deploy_target: + - Local deployment + host:path Remote deployment via SSH + + env_file_path: path to .env file (default: .env) +` + ); + process.exit(1); +} + +const [deployTarget, envFilePath = ".env"] = process.argv.slice(2); + +if (!deployTarget) { + usage(); +} + +const appRevision = runCapture("git", ["rev-parse", "--short", "HEAD"]); +const repoRootDir = runCapture("git", ["rev-parse", "--show-toplevel"]); +const manageCmd = process.env["MANAGE_CMD_OVERRIDE"] ?? "up"; + +if (deployTarget === "-") { + // Local deployment + const absoluteEnvFile = resolve(envFilePath); + run( + "npx", + [ + "tsx", + "deployment/manage.ts", + manageCmd, + "--env-file", + absoluteEnvFile, + "--revision", + appRevision, + ], + { cwd: repoRootDir } + ); +} else if (/^[^:]+:.+/.test(deployTarget)) { + // Remote deployment via SSH + const colonIdx = deployTarget.indexOf(":"); + const sshHost = deployTarget.slice(0, colonIdx); + const remotePath = deployTarget.slice(colonIdx + 1); + + // rsync repo to remote host + run("rsync", [ + "-avz", + "--delete", + "--mkpath", + "--exclude=.git", + "--exclude=node_modules", + "--exclude=.env", + "--exclude=.env.local", + "--exclude=.vite", + "--exclude=*.log", + "--exclude=tmp/", + `${repoRootDir}/`, + `${sshHost}:${remotePath}/`, + ]); + + // Copy env file to remote deployment directory + const remoteEnvPath = `${remotePath}/deployment/.env`; + run("scp", [envFilePath, `${sshHost}:${remoteEnvPath}`]); + + // Run manage.ts on the remote host + // Note: The remote host must have Node 18+ and pnpm installed. + // After the first deploy, run `pnpm install` on the remote to install tsx. + const remoteDeployDir = `${remotePath}/deployment`; + run("ssh", [ + sshHost, + `cd ${remoteDeployDir} && npx tsx manage.ts ${manageCmd} --env-file .env --revision ${appRevision}`, + ]); +} else { + console.error( + "Error: must be '-' (local) or SSH_HOST:PATH (remote)" + ); + usage(); +} diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml deleted file mode 100644 index 3eea2fc..0000000 --- a/deployment/docker-compose.yml +++ /dev/null @@ -1,52 +0,0 @@ -services: - postgres: - image: postgres:16 - restart: unless-stopped - command: ["bash", "/start-db.sh"] - environment: - POSTGRES_DB: ${POSTGRES_DB:?error} - POSTGRES_USER: ${POSTGRES_USER:?error} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?error} - POSTGRES_MEMORY_LIMIT: ${POSTGRES_MEMORY_LIMIT:-1G} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=en_US.UTF-8" - shm_size: 256m - ports: - - "${POSTGRES_PORT:-5433}:5432" - volumes: - - ./static/start-db.sh:/start-db.sh:ro - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 - - ponder: - image: ${PROJECT_PREFIX:?error}-ponder:${APP_REVISION:?error} - restart: unless-stopped - build: - context: .. - dockerfile: Dockerfile - args: - PIPELINE_BUILD_TAG: ${APP_REVISION} - environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - DATABASE_SCHEMA: ${DATABASE_SCHEMA:?error} - MAINNET_RPC_URL: ${MAINNET_RPC_URL:?error} - GNOSIS_RPC_URL: ${GNOSIS_RPC_URL:?error} - DISABLE_POLL_RESULT_CHECK: ${DISABLE_POLL_RESULT_CHECK:-false} - DISABLE_SETTLEMENT_FACTORY_CHECK: ${DISABLE_SETTLEMENT_FACTORY_CHECK:-false} - ports: - - "${PONDER_EXPOSED_PORT:-40000}:3000" - depends_on: - postgres: - condition: service_healthy - logging: - driver: json-file - options: - max-size: "50m" - max-file: "5" - -volumes: - postgres-data: - name: ${PROJECT_PREFIX:?error}-postgres-data diff --git a/deployment/manage.sh b/deployment/manage.sh deleted file mode 100755 index 99d24c6..0000000 --- a/deployment/manage.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat < [options] - -Commands: - up Deploy the stack - down Tear down the stack - -Options: - -e, --env-file Path to .env file (required) - -r, --revision Application revision (required for 'up') - -h, --help Show this help message -EOF - exit 1 -} - -COMMAND="${1:-}" -shift || true - -ENV_FILE_PATH="" -APP_REVISION="" - -while [[ $# -gt 0 ]]; do - case "$1" in - -e|--env-file) ENV_FILE_PATH="$2"; shift 2 ;; - -r|--revision) APP_REVISION="$2"; shift 2 ;; - -h|--help) usage ;; - *) echo "Unknown option: $1"; usage ;; - esac -done - -if [[ -z "$COMMAND" ]]; then echo "Error: command required (up|down)"; usage; fi -if [[ -z "$ENV_FILE_PATH" ]]; then echo "Error: --env-file required"; usage; fi - -APP_DEPLOY_DIR="$(dirname "$(realpath "$0")")" -cd "$APP_DEPLOY_DIR" - -set -a -source "$ENV_FILE_PATH" -set +a - -if [[ -z "${PROJECT_PREFIX:-}" ]]; then - echo "Error: PROJECT_PREFIX must be set in the env file" - exit 1 -fi - -export PROJECT_PREFIX -export APP_REVISION="${APP_REVISION:-latest}" -export DATABASE_SCHEMA="programmatic_orders" - -cmd_up() { - if [[ -z "${APP_REVISION:-}" || "$APP_REVISION" == "latest" ]]; then - echo "Error: --revision is required for 'up'" - exit 1 - fi - - echo ">>> Building ponder image..." - docker compose \ - -p "${PROJECT_PREFIX}" -f docker-compose.yml \ - build --no-cache - - echo ">>> Deploying (DATABASE_SCHEMA=${DATABASE_SCHEMA})..." - docker compose \ - -p "${PROJECT_PREFIX}" -f docker-compose.yml \ - up -d --remove-orphans - - echo ">>> Cleaning up old ponder images..." - IMAGE_NAME="${PROJECT_PREFIX}-ponder" - OLD_IMAGES=$(docker images --format "{{.Repository}}:{{.Tag}}" "$IMAGE_NAME" | grep -v ":${APP_REVISION}$" || true) - if [[ -n "$OLD_IMAGES" ]]; then - echo "$OLD_IMAGES" | xargs -r docker rmi 2>/dev/null || true - fi - docker image prune -f 2>/dev/null || true - docker container prune -f 2>/dev/null || true - - echo ">>> Deploy complete." -} - -cmd_down() { - echo ">>> Stopping stack..." - docker compose \ - -p "${PROJECT_PREFIX}" -f docker-compose.yml \ - down -v --remove-orphans || true -} - -case "$COMMAND" in - up) cmd_up ;; - down) cmd_down ;; - *) echo "Unknown command: $COMMAND"; usage ;; -esac diff --git a/deployment/manage.ts b/deployment/manage.ts new file mode 100644 index 0000000..78e34df --- /dev/null +++ b/deployment/manage.ts @@ -0,0 +1,218 @@ +#!/usr/bin/env tsx +/** + * manage.ts — orchestrates `docker compose` up/down for the deploy stack. + * Replaces manage.sh. + * + * NOTE: This script is specific to Bleu's internal deployment workflow. + * Adapt the paths and docker compose arguments as needed for your own setup. + * + * Usage: + * npx tsx deployment/manage.ts --env-file [--revision ] + */ + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function usage(): never { + console.error(`Usage: tsx deployment/manage.ts [options] + +Commands: + up Deploy the stack + down Tear down the stack + +Options: + -e, --env-file Path to .env file (required) + -r, --revision Application revision (required for 'up') + -h, --help Show this help message +`); + process.exit(1); +} + +function parseArgs(args: string[]): { + command: string; + envFile: string; + revision: string; +} { + const [command, ...rest] = args; + + if (!command || command === "--help" || command === "-h") { + usage(); + } + + let envFile = ""; + let revision = "latest"; + + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg === "-e" || arg === "--env-file") { + envFile = rest[++i] ?? ""; + } else if (arg === "-r" || arg === "--revision") { + revision = rest[++i] ?? "latest"; + } else if (arg === "-h" || arg === "--help") { + usage(); + } else { + console.error(`Unknown option: ${arg}`); + usage(); + } + } + + if (!envFile) { + console.error("Error: --env-file required"); + usage(); + } + + return { command, envFile, revision }; +} + +function loadEnvFile(envFilePath: string): void { + const absolutePath = resolve(envFilePath); + const content = readFileSync(absolutePath, "utf-8"); + + for (const line of content.split("\n")) { + const trimmed = line.trim(); + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith("#")) continue; + + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + + const key = trimmed.slice(0, eqIdx).trim(); + let value = trimmed.slice(eqIdx + 1).trim(); + + // Strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + if (key) { + process.env[key] = value; + } + } +} + +function run( + cmd: string, + args: string[], + opts: { cwd?: string; ignoreError?: boolean } = {} +): void { + console.log(`+ ${cmd} ${args.join(" ")}`); + const result = spawnSync(cmd, args, { + stdio: "inherit", + cwd: opts.cwd ?? __dirname, + }); + if (!opts.ignoreError && result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function cmdUp(projectPrefix: string, revision: string): void { + if (!revision || revision === "latest") { + console.error("Error: --revision is required for 'up'"); + process.exit(1); + } + + run("docker", [ + "compose", + "-p", + projectPrefix, + "-f", + "../docker-compose.yml", + "--profile", + "deploy", + "build", + "--no-cache", + ]); + + run("docker", [ + "compose", + "-p", + projectPrefix, + "-f", + "../docker-compose.yml", + "--profile", + "deploy", + "up", + "-d", + "--remove-orphans", + ]); + + // Prune old images for this project + const imageName = `${projectPrefix}-ponder`; + const listResult = spawnSync( + "docker", + ["images", "--format", "{{.Repository}}:{{.Tag}}", imageName], + { encoding: "utf-8", cwd: __dirname } + ); + + if (listResult.status === 0 && listResult.stdout) { + const oldImages = listResult.stdout + .trim() + .split("\n") + .filter((img) => img && !img.endsWith(`:${revision}`)); + + for (const img of oldImages) { + run("docker", ["rmi", img], { ignoreError: true }); + } + } + + run("docker", ["image", "prune", "-f"], { ignoreError: true }); + run("docker", ["container", "prune", "-f"], { ignoreError: true }); + + console.log(">>> Deploy complete."); +} + +function cmdDown(projectPrefix: string): void { + run( + "docker", + [ + "compose", + "-p", + projectPrefix, + "-f", + "../docker-compose.yml", + "--profile", + "deploy", + "down", + "-v", + "--remove-orphans", + ], + { ignoreError: true } + ); +} + +// ---- main ---- + +const { command, envFile, revision } = parseArgs(process.argv.slice(2)); + +loadEnvFile(envFile); + +// Hardcoded per project convention +process.env["DATABASE_SCHEMA"] = "programmatic_orders"; + +const projectPrefix = process.env["PROJECT_PREFIX"]; +if (!projectPrefix) { + console.error("Error: PROJECT_PREFIX must be set in the env file"); + process.exit(1); +} + +const appRevision = revision !== "latest" ? revision : process.env["APP_REVISION"] ?? "latest"; +process.env["APP_REVISION"] = appRevision; + +switch (command) { + case "up": + cmdUp(projectPrefix, appRevision); + break; + case "down": + cmdDown(projectPrefix); + break; + default: + console.error(`Unknown command: ${command}`); + usage(); +} diff --git a/deployment/static/start-db.sh b/deployment/static/start-db.sh deleted file mode 100755 index 3207dfc..0000000 --- a/deployment/static/start-db.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -POSTGRES_MAX_CONNECTIONS="${POSTGRES_MAX_CONNECTIONS:-100}" - -if [ -n "${POSTGRES_MEMORY_LIMIT:-}" ]; then - LIMIT_BYTES=$(numfmt --from=iec "${POSTGRES_MEMORY_LIMIT}" 2>/dev/null) - if [ -z "$LIMIT_BYTES" ] || [ "$LIMIT_BYTES" = "0" ]; then - echo "Error: Invalid POSTGRES_MEMORY_LIMIT value: $POSTGRES_MEMORY_LIMIT" >&2 - exit 1 - fi - TOTAL_RAM_MB=$((LIMIT_BYTES / 1024 / 1024)) -else - TOTAL_RAM_KB=$(grep MemTotal /proc/meminfo | awk '{print $2}') - TOTAL_RAM_MB=$((TOTAL_RAM_KB / 1024)) -fi - -SHARED_BUFFERS_MB=$((TOTAL_RAM_MB * 20 / 100)) -MAINTENANCE_WORK_MEM_MB=$((TOTAL_RAM_MB * 5 / 100)) -EFFECTIVE_CACHE_SIZE_MB=$((TOTAL_RAM_MB / 2)) -WORK_MEM_MB=$(( (TOTAL_RAM_MB * 25 / 100) / POSTGRES_MAX_CONNECTIONS )) - -if [ "$WORK_MEM_MB" -lt 1 ]; then WORK_MEM_MB=1; fi -if [ "$SHARED_BUFFERS_MB" -lt 32 ]; then SHARED_BUFFERS_MB=32; fi -if [ "$MAINTENANCE_WORK_MEM_MB" -lt 16 ]; then MAINTENANCE_WORK_MEM_MB=16; fi - -set -x -exec docker-entrypoint.sh \ - -c "max_connections=${POSTGRES_MAX_CONNECTIONS}" \ - -c "shared_buffers=${SHARED_BUFFERS_MB}MB" \ - -c "work_mem=${WORK_MEM_MB}MB" \ - -c "maintenance_work_mem=${MAINTENANCE_WORK_MEM_MB}MB" \ - -c "effective_cache_size=${EFFECTIVE_CACHE_SIZE_MB}MB" \ - -c "max_wal_size=1GB" \ - -c "min_wal_size=256MB" \ - -c "checkpoint_completion_target=0.9" \ - -c "wal_buffers=8MB" diff --git a/docker-compose.yml b/docker-compose.yml index 428d227..66bd37a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,69 @@ services: + # Single postgres for both local dev and production. + # Dev: uses hardcoded defaults (no .env needed). Run with: docker compose up -d + # Prod: set POSTGRES_USER/PASSWORD/DB/PORT/PROJECT_PREFIX in .env and run with + # docker compose --profile deploy up -d postgres: image: postgres:16 - container_name: programmatic-orders-db - ports: - - "5432:5432" + container_name: ${PROJECT_PREFIX:-programmatic-orders}-db + restart: unless-stopped + # Memory flags tuned for POSTGRES_MEMORY_LIMIT=1G. Adjust proportionally for other limits: + # shared_buffers=20% RAM, work_mem=25%RAM/max_connections, effective_cache_size=50% RAM + command: >- + postgres + -c max_connections=100 + -c shared_buffers=204MB + -c work_mem=2MB + -c maintenance_work_mem=51MB + -c effective_cache_size=512MB + -c max_wal_size=1GB + -c min_wal_size=256MB + -c checkpoint_completion_target=0.9 + -c wal_buffers=8MB environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: programmatic-orders + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} # ggignore + POSTGRES_DB: ${POSTGRES_DB:-programmatic-orders} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=en_US.UTF-8" + shm_size: 256m + ports: + - "${POSTGRES_PORT:-5432}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-programmatic-orders}"] + interval: 10s timeout: 5s retries: 5 + ponder: + image: ${PROJECT_PREFIX:?error}-ponder:${APP_REVISION:?error} + restart: unless-stopped + build: + context: . + dockerfile: Dockerfile + args: + PIPELINE_BUILD_TAG: ${APP_REVISION} + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-programmatic-orders} # ggignore + DATABASE_SCHEMA: ${DATABASE_SCHEMA:?error} + MAINNET_RPC_URL: ${MAINNET_RPC_URL:?error} + GNOSIS_RPC_URL: ${GNOSIS_RPC_URL:?error} + # Add a new chain's RPC URL env var here when enabling it in src/chains/index.ts + DISABLE_POLL_RESULT_CHECK: ${DISABLE_POLL_RESULT_CHECK:-false} + DISABLE_SETTLEMENT_FACTORY_CHECK: ${DISABLE_SETTLEMENT_FACTORY_CHECK:-false} + ports: + - "${PONDER_EXPOSED_PORT:-40000}:3000" + depends_on: + postgres: + condition: service_healthy + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + profiles: [deploy] + volumes: - postgres_data: + postgres-data: + name: ${PROJECT_PREFIX:-local}-postgres-data diff --git a/docs/api-reference.md b/docs/api-reference.md index 36c680f..e605547 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2,7 +2,7 @@ The indexer exposes three ways to query indexed data: a Ponder-generated GraphQL endpoint, a read-only SQL passthrough, and two custom REST endpoints for queries that require cross-table logic. -The default local URL is `http://localhost:42069`. +The default local URL is `http://localhost:42069` when using `pnpm dev`. The production server (`pnpm start`, Docker) listens on port **3000** (mapped to the host via `PONDER_EXPOSED_PORT`, default 40000). ## Endpoints @@ -13,7 +13,11 @@ The default local URL is `http://localhost:42069`. | `/api/*` | GET | Custom REST endpoints. Full reference in Swagger UI at `/docs`. | | `/docs` | GET | Swagger UI for the REST endpoints. | | `/openapi.json` | GET | OpenAPI 3.0 spec for the REST endpoints. | -| `/healthz` | GET | Returns `{ "status": "ok" }` when the server is up. Does not reflect indexer sync progress. | +| `/health` | GET | Ponder built-in. Returns `200` (empty body) when the process is running. | +| `/ready` | GET | Ponder built-in. Returns `200` when initial sync is complete; `503` while still syncing. Suitable for K8s readiness probes. | +| `/healthz` | GET | Application-level. Returns `{ "status": "ok" }` when the server is up. Does not reflect indexer sync progress. | +| `/status` | GET | Sync progress per chain. Returns current indexed block, latest chain block, and a completion percentage. Useful for monitoring backfill progress. | +| `/metrics` | GET | Prometheus metrics. Exposes Ponder internals (block lag, handler latency, RPC call counts). | ## GraphQL @@ -31,13 +35,44 @@ For schema details (columns, indexes, relations), see [architecture.md](./archit ## REST endpoints -Two custom endpoints mounted at `/api`, documented in Swagger UI at `/docs`: +Custom endpoints mounted at `/api`, documented in Swagger UI at `/docs`: - `GET /api/orders/by-owner/{owner}` — discrete orders for a wallet, with automatic proxy resolution. - `GET /api/generator/{eventId}/execution-summary` — part-count breakdown by status for a generator. +- `GET /api/sync-progress` — per-chain historical sync progress (total blocks, processed blocks, percentage, realtime mode flag). Open `/docs` for request/response shapes and to try them out. +### `GET /api/sync-progress` + +Returns the indexer's historical backfill progress per chain, parsed from Ponder's built-in Prometheus metrics. Useful for monitoring first-run sync without reading raw metrics. + +Example response: + +```json +{ + "mainnet": { + "totalBlocks": 7000000, + "processedBlocks": 3000000, + "historicalBlocksFetchedPct": 42.9, + "isRealtime": false, + "isComplete": false + }, + "gnosis": { + "totalBlocks": 17000000, + "processedBlocks": 17000000, + "historicalBlocksFetchedPct": 100.0, + "isRealtime": true, + "isComplete": true + } +} +``` + +- `historicalBlocksFetchedPct` is rounded to one decimal place (0–100). +- `isRealtime` flips to `true` once the chain enters live-sync mode. +- `isComplete` flips to `true` once all historical blocks are processed. +- Returns `{}` if the `/metrics` endpoint is unreachable. + ## Order type decoding The `decodedParams` JSON field on `conditionalOrderGenerator` has a different shape per order type (TWAP, Stop Loss, Perpetual Swap, Good After Time, Trade Above Threshold). Full breakdown — handler addresses, Solidity structs, and field-by-field decoding — lives in [supported-order-types.md](./supported-order-types.md). @@ -92,7 +127,7 @@ There is one principled exception to "everything as string": `discreteOrder.vali | `discreteOrder.validTo` | number | yes | Unix seconds when this discrete order expires. `uint32` per CoW protocol. | | `discreteOrder.creationDate` | string | no | Unix seconds when the discrete order was first observed. Source varies — see the GraphQL field doc. | | `candidateDiscreteOrder.validTo` | number | yes | Same as `discreteOrder.validTo`. | -| `candidateDiscreteOrder.creationDate` | string | no | Block timestamp at C1 discovery. | +| `candidateDiscreteOrder.creationDate` | string | no | Block timestamp at **OrderDiscoveryPoller** discovery. | | `candidateDiscreteOrder.possibleValidAfterTimestamp` | string | yes | TWAP only: `t0 + partIndex*t`. Earliest Unix-seconds time the part can be valid. | ### Timestamp-like values inside `decodedParams` @@ -107,9 +142,13 @@ The `conditionalOrderGenerator.decodedParams` JSON encodes Solidity struct field ## Indexed chains +The active chain list is `ACTIVE_CHAINS` in `src/chains/index.ts`. Currently active: + | Chain | Chain ID | |-------|----------| -| Ethereum mainnet | 1 | -| Gnosis Chain | 100 | +| Ethereum Mainnet | 1 | +| Gnosis | 100 | Filter queries with `where: { chainId: 1 }` (GraphQL) or `?chainId=1` (REST). + +> Adding a chain: create `src/chains/.ts` following the existing chain files as a template, add it to `ACTIVE_CHAINS` in `src/chains/index.ts`, and add its RPC URL env var. See COW-986 for the full checklist. diff --git a/docs/architecture.md b/docs/architecture.md index d11b3ed..3cd93bf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,20 +6,17 @@ This document covers how the indexer works, from on-chain events to the GraphQL The system is a Ponder 0.16.x indexer that watches the ComposableCoW contract on Ethereum mainnet and Gnosis Chain. When a user creates a programmatic order (TWAP, Stop Loss, etc.), the contract emits a `ConditionalOrderCreated` event. The indexer picks that up, decodes the order parameters, resolves the actual owner (which may be behind a proxy), and writes the result to Postgres. A Hono HTTP server exposes the data through GraphQL and a SQL passthrough endpoint. -Ponder registers nine top-level handlers: four contract event handlers (`ComposableCow` backfill, `ComposableCowLive`, `CoWShedFactory`, `GPv2Settlement`) plus five live-only block handlers in `blockHandler.ts` (C1–C5). The contract handlers react to on-chain events; C1–C5 poll contract state and the orderbook API during live sync. `settlement.ts` inspects `Settlement` receipts to detect Aave adapters from Trade logs. +Ponder registers handlers for three independent on-chain event streams: `ComposableCow` (conditional order creation), `CoWShedFactory` (proxy wallet deployment), and `GPv2Settlement` (Aave adapter detection via `Settlement` events — `Trade` logs in the receipt identify the adapter address). During live sync, additional block handlers in `blockHandler.ts` poll contract state and the CoW orderbook API. See `blockHandler.ts` for the current handler list and responsibilities. `settlement.ts` detects Aave flash loan adapters via a queue-based approach: the `GPv2Settlement:Settlement` event handler enqueues tx hashes, and `SettlementResolver:block` drains the queue and does all RPC work so errors never crash the event handler. ## Contracts and Chains -Configuration lives in `src/data.ts`. The ComposableCoW contract is deployed at the same CREATE2 address on every chain (`0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74`), so `data.ts` only needs to specify the start block per chain. +Configuration lives in `src/chains/` (one file per chain). The ComposableCoW contract is deployed at the same CREATE2 address on every chain (`0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74`), so each chain config only needs to specify the start block per chain. -Currently indexed: +Currently active chains, their start blocks, and contract addresses are defined in `src/chains/`. To add a chain, create a chain file there and register it in `src/chains/index.ts`. -- **Mainnet** (chain ID 1) -- ComposableCoW from block 17883049, CoWShedFactory from block 22939254, GPv2Settlement from block 23812751 -- **Gnosis** (chain ID 100) -- ComposableCoW from block 29389123, CoWShedFactory from block 41469991 +Stub configs exist for all 12 chains in cow-sdk's `ALL_SUPPORTED_CHAIN_IDS`; contract addresses for the remaining chains need verification before enabling (see COW-986). -Arbitrum is stubbed out but not yet active. See [Adding a New Chain](#adding-a-new-chain) below for the step-by-step checklist to wire in a new chain. - -`ponder.config.ts` imports everything from `data.ts` and wires it into Ponder's `createConfig`. It never contains raw addresses or block numbers directly. The config also sets up five live-only block handlers — C1 (`ContractPoller`), C2 (`CandidateConfirmer`), C3 (`StatusUpdater`), C4 (`HistoricalBootstrap`), C5 (`DeterministicCancellationSweeper`) — all running every block during live sync. +`ponder.config.ts` derives all config from `ACTIVE_CHAINS` in `src/chains/index.ts` and wires it into Ponder's `createConfig`. It never contains raw addresses or block numbers directly. It also registers the live-only block handlers from `blockHandler.ts` — all run during live sync only (`startBlock: "latest"`). Three contracts are indexed: @@ -29,54 +26,47 @@ Three contracts are indexed: ## Data Flow +Three independent on-chain event streams feed into the same schema tables and are then served by the API layer: + ``` -ComposableCoW contract (mainnet + gnosis) - | - | ConditionalOrderCreated events - v -composableCow.ts handler - | - compute params hash (keccak256 of ABI-encoded handler/salt/staticInput) - | - look up owner in ownerMapping table (CoWShed proxy resolution) - | - identify order type from handler address - | - decode staticInput into structured params - | - write transaction + conditionalOrderGenerator rows - v -CoWShedFactory contract - | - | COWShedBuilt events - v -cowshed.ts handler - | - write ownerMapping: shed address -> user EOA - v -GPv2Settlement contract (FlashLoanRouter settlements only) - | - | Settlement events - v -settlement.ts handler - | - fetch transaction receipt, scan Trade logs - | - for each trade owner: check if it's an Aave adapter (FACTORY() call) - | - if yes: resolve EOA via owner(), write ownerMapping - v -blockHandler.ts (five live-only block handlers) - | C1 (ContractPoller) — multicall getTradeableOrderWithSignature for non-deterministic - | generators; detects cancellation via SingleOrderNotAuthed error - | C2 (CandidateConfirmer) — confirms candidate orders via orderbook API → discreteOrder; - | cascades parent Cancelled status to orphan candidates - | C3 (StatusUpdater) — polls API for status updates on open discrete orders; - | cascades parent Cancelled status to orphan open rows - | C4 (HistoricalBootstrap) — one-time backfill of non-deterministic historical orders - | C5 (DeterministicCancellationSweeper) — periodic singleOrders() mapping read for - | deterministic generators (allCandidatesKnown=true); flips - | to Cancelled when remove() has been called on-chain - v -schema tables (Postgres) - | - v -Hono API server - | GET /graphql -- GraphQL endpoint (auto-generated from schema) - | GET /sql/* -- Ponder SQL passthrough - | GET /api/* -- custom REST endpoints (Swagger UI at /docs) - | GET /healthz -- health check +ComposableCoW contract CoWShedFactory contract GPv2Settlement contract +(mainnet + gnosis) (mainnet + gnosis) (FlashLoanRouter filter only) + | | | +ConditionalOrderCreated COWShedBuilt events Settlement events + | | | + v v v +composableCow.ts handler cowshed.ts handler settlement.ts handler + - hash params tuple - write ownerMapping: - fetch receipt, scan Trade logs + - resolve owner proxy shed → user EOA - check FACTORY() on each trader + - decode staticInput (used by composableCow.ts - if Aave adapter: call owner(), + - write transaction + at next order creation) write ownerMapping + conditionalOrderGenerator + | | | + +---------------+---------------+-------------------------------+ + | + v + schema tables (Postgres) + | + | (live sync only) + v + blockHandler.ts — live block handlers + OrderDiscoveryPoller — multicall getTradeableOrderWithSignature for + non-deterministic generators; detects cancellation + via SingleOrderNotAuthed error + CandidateConfirmer — confirms candidates via orderbook API → discreteOrder; + cascades parent Cancelled to orphan candidates + OrderStatusTracker — polls API for status updates on open discrete orders; + cascades parent Cancelled to orphan open rows + OwnerBackfill — one-time backfill of non-deterministic historical orders + CancellationWatcher — periodic singleOrders() read for deterministic generators; + flips to Cancelled when remove() has been called on-chain + | + v + Hono API server + GET /graphql -- GraphQL endpoint (auto-generated from schema) + GET /sql/* -- Ponder SQL passthrough + GET /api/* -- custom REST endpoints (Swagger UI at /docs) + GET /healthz -- health check ``` ## Schema @@ -116,7 +106,9 @@ PK: `(chainId, orderUid)`. See [api-reference.md](./api-reference.md) for full f ### candidate_discrete_order -Staging rows for discrete orders discovered by C1 (`getTradeableOrderWithSignature`) before the orderbook API lists them. When C2 confirms a UID against the API, the row is promoted to `discrete_order` and removed from candidates. +**Why candidate orders?** The CoW watch-tower submits orders to the orderbook API on behalf of generators. There is a gap between when the indexer discovers a valid order UID (via `getTradeableOrderWithSignature` or precompute) and when it actually appears in the orderbook API (after the watch-tower posts it). Storing candidates immediately lets the indexer track all UIDs it knows about, confirm them against the API in a later block, and avoid polling the API for UIDs that haven't been posted yet. Without this staging table, the indexer would either poll the API every block for every possible UID (expensive), or miss orders entirely. + +Staging rows for discrete orders discovered by `OrderDiscoveryPoller` (`getTradeableOrderWithSignature`) or precomputed at creation time before the orderbook API lists them. When `CandidateConfirmer` confirms a UID against the API, the row is promoted to `discrete_order` and removed from candidates. Stale candidates (past `validTo`) are pruned on each `CandidateConfirmer` block. Key columns: `orderUid`, `chainId`, `conditionalOrderGeneratorId`, amounts, `validTo`, `creationDate`, `possibleValidAfterTimestamp` (TWAP scheduling). PK: `(chainId, orderUid)`. @@ -152,40 +144,55 @@ This is the primary event handler. When a `ConditionalOrderCreated` fires: When a CoWShed proxy wallet is deployed, this handler stores the mapping from the proxy address (`shed`) to the deploying user address in `ownerMapping`. This mapping is then available for the composableCow handler to resolve owners. -### settlement.ts -- GPv2Settlement Settlement +### settlement.ts -- Flash Loan Adapter Detection + +This file detects Aave V3 flash loan adapter contracts using a queue-based two-stage approach. The GPv2Settlement contract is filtered (in `ponder.config.ts`) to only index settlements from the FlashLoanRouter solver, so the event volume is very low. + +**Stage 1 — `GPv2Settlement:Settlement` event handler (enqueue only):** -This handler detects Aave V3 flash loan adapter contracts. The GPv2Settlement contract is filtered (in `ponder.config.ts`) to only index settlements from the FlashLoanRouter solver, which keeps the event volume low. +When a Settlement event fires, the handler writes the transaction hash into the `settlementQueue` table and returns immediately. No RPC calls are made here — errors in RPC never crash the event handler, keeping the indexer stable. -For each Settlement event: +**Stage 2 — `SettlementResolver:block` block handler (drain and resolve):** -1. Fetch the full transaction receipt and iterate over all logs. -2. Filter for Trade logs emitted by the settlement contract (matching the Trade event topic). -3. For each trade, extract the `owner` from the indexed topic. -4. Skip if already mapped, skip if the address is an EOA (no bytecode). -5. Call `FACTORY()` on the address using raw `eth_call` (not `readContract`, which would log warnings on reverts). If the returned address matches the known AaveV3AdapterFactory address, this is a flash loan adapter. -6. Call `owner()` on the adapter to get the EOA, then write the `ownerMapping` entry. +Every block, this handler drains up to `MAX_SETTLEMENTS_PER_BLOCK` rows from `settlementQueue` for the current chain. For each queued transaction: -The handler uses raw `eth_call` for the FACTORY() check specifically to avoid Ponder's built-in WARN logging on contract call reverts. Most trade addresses are not Aave adapters, so FACTORY() reverts are the common case, and the warnings would flood the logs. +1. Fetch the full transaction receipt (with timeout). On error, log a warning and delete the queue row (skip it). +2. Iterate over all logs in the receipt. Keep only Trade logs emitted by the GPv2Settlement contract. +3. For each trade, extract the `owner` address from the indexed topic. +4. Skip if already in `ownerMapping` (adapter seen in a prior settlement). +5. Call `getCode` on the address (with timeout). Skip if EOA (no bytecode). +6. Call `FACTORY()` via raw `eth_call` (not `readContract`, which logs a WARN on every revert). If the returned address doesn't match the known AaveV3AdapterFactory address, skip. +7. Call `owner()` on the adapter to retrieve the underlying EOA. +8. Write a row to `ownerMapping` (`address` → `owner`, `addressType = FlashLoanHelper`). +9. Update any `conditionalOrderGenerator` rows owned by the adapter address to set `ownerAddressType = FlashLoanHelper`. +10. Delete the queue row. -Stats are accumulated and logged every 30 seconds to track throughput without per-event log spam. +The raw `eth_call` for `FACTORY()` avoids Ponder's built-in WARN logs on reverts — most addresses are not Aave adapters, so reverts are the common case and would flood the logs if `readContract` were used. -### blockHandler.ts -- C1 / C2 / C3 / C4 / C5 +Stats (total settlements, trade logs found, EOA skips, adapter mappings, avg FACTORY() latency) are accumulated and logged every 30 seconds. -Five live-only block handlers, all in a single file. They only run during live sync (startBlock: "latest") to avoid hammering the orderbook API during historical backfill. C1 and C5 share a per-chain batch cap (`MAX_GENERATORS_PER_BLOCK_`, default 200) and pull from a priority queue ordered by oldest `lastCheckBlock` first. Generators past the cap defer to the next block. +### blockHandler.ts -- live block handlers -**C1 — ContractPoller** (every block, mainnet + gnosis): Multicalls `getTradeableOrderWithSignature` on ComposableCoW for each `Active` generator where `allCandidatesKnown=false`. A success result creates a `candidateDiscreteOrder` entry. A `SingleOrderNotAuthed` error marks the generator as `Cancelled` with `lastPollResult='cancelled:SingleOrderNotAuthed'`. Other errors (tryNextBlock, tryAtEpoch, etc.) advance the generator's `nextCheckBlock` accordingly. Single-shot non-deterministic types (GoodAfterTime, TradeAboveThreshold) set `allCandidatesKnown=true` after first success. Can be disabled with `DISABLE_POLL_RESULT_CHECK=true`. +All block handlers run only during live sync (`startBlock: "latest"`) to avoid hammering the orderbook API during historical backfill. `OrderDiscoveryPoller` and `CancellationWatcher` share a per-chain batch cap (`MAX_GENERATORS_PER_BLOCK_`, default 200) and pull from a priority queue ordered by oldest `lastCheckBlock` first. Generators past the cap defer to the next block. -**C2 — CandidateConfirmer** (every block, mainnet + gnosis): First drains any `candidateDiscreteOrder` rows whose parent generator is `Cancelled` — promoting them into `discreteOrder` with `status='cancelled'` and deleting the candidate rows. Then checks remaining `candidateDiscreteOrder` rows against the orderbook API: when a candidate appears in the API, it's promoted to `discreteOrder` and deleted from candidates. Candidates past their `validTo` are also pruned. +**OrderDiscoveryPoller** (every block, mainnet + gnosis): Multicalls `getTradeableOrderWithSignature` on ComposableCoW for each `Active` generator where `allCandidatesKnown=false`. A success result creates a `candidateDiscreteOrder` entry. A `SingleOrderNotAuthed` error marks the generator as `Cancelled` with `lastPollResult='cancelled:SingleOrderNotAuthed'`. Other errors (tryNextBlock, tryAtEpoch, etc.) advance the generator's `nextCheckBlock` accordingly. Single-shot non-deterministic types (GoodAfterTime, TradeAboveThreshold) set `allCandidatesKnown=true` after first success. Can be disabled with `DISABLE_POLL_RESULT_CHECK=true`. -**C3 — StatusUpdater** (every block, mainnet + gnosis): Polls the orderbook API for all `open` discrete orders and updates their status from the API response. Then sweeps any remaining `open` rows whose parent generator is `Cancelled` to `status='cancelled'` (API-terminal statuses from the loop above still win for children that were traded before on-chain cancellation). Finally expires any orders past their `validTo` timestamp. +**CandidateConfirmer** (every block, mainnet + gnosis): First drains any `candidateDiscreteOrder` rows whose parent generator is `Cancelled` — promoting them into `discreteOrder` with `status='cancelled'` and deleting the candidate rows. Then checks remaining `candidateDiscreteOrder` rows against the orderbook API: when a candidate appears in the API, it's promoted to `discreteOrder` and deleted from candidates. Candidates past their `validTo` are also pruned. -**C4 — HistoricalBootstrap** (fires once at latest block, mainnet + gnosis): One-time fetch of historical orders for non-deterministic generators (PerpetualSwap, GoodAfterTime, TradeAboveThreshold, Unknown) that were active during backfill but have no discrete orders yet. Queries the CoW Protocol `/orders?owner=` endpoint per owner. +**TWAP aged-out fallback**: When a candidate's `orderUid` is no longer served by `/orders/by_uids` (typically after the order expires from the orderbook cache), `CandidateConfirmer` falls back to fetching the owner's full order list from `/account/{owner}/orders`. This resolves TWAP parts that the orderbook stopped tracking before C2 processed them. On timeout or API failure, the candidate defaults to `expired`. -**C5 — DeterministicCancellationSweeper** (every block, mainnet + gnosis): Closes C1's blind spot. C1 skips `allCandidatesKnown=true` generators, so removals via `ComposableCoW.remove()` on deterministic types (TWAP, StopLoss, CirclesBackingOrder) would otherwise go undetected — `remove()` emits no event. C5 multicalls `singleOrders(owner, hash)` on a per-generator cadence of `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100). A `false` return means the owner called `remove()` on-chain: the generator is flipped to `Cancelled` with `lastPollResult='cancelled:removeMapping'`, after which C2 and C3's parent-cancelled cascades reconcile the children on the next block. `true` reschedules the next check. Can be disabled with `DISABLE_DETERMINISTIC_CANCEL_SWEEP=true`. +**OrderStatusTracker** (every block, mainnet + gnosis): Polls the orderbook API for all `open` discrete orders and updates their status from the API response. Then sweeps any remaining `open` rows whose parent generator is `Cancelled` to `status='cancelled'` (API-terminal statuses from the loop above still win for children that were traded before on-chain cancellation). Finally expires any orders past their `validTo` timestamp. + +**OwnerBackfill** (fires once at latest block, mainnet + gnosis): One-time fetch of historical orders for non-deterministic generators (PerpetualSwap, GoodAfterTime, TradeAboveThreshold, Unknown) that were active during backfill but have no discrete orders yet. Queries the CoW Protocol `/orders?owner=` endpoint per owner. + +**CancellationWatcher** (every block, mainnet + gnosis): Closes `OrderDiscoveryPoller`'s blind spot. `OrderDiscoveryPoller` skips `allCandidatesKnown=true` generators, so removals via `ComposableCoW.remove()` on deterministic types (TWAP, StopLoss, CirclesBackingOrder) would otherwise go undetected — `remove()` emits no event. `CancellationWatcher` multicalls `singleOrders(owner, hash)` on a per-generator cadence of `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100). A `false` return means the owner called `remove()` on-chain: the generator is flipped to `Cancelled` with `lastPollResult='cancelled:removeMapping'`, after which `CandidateConfirmer` and `OrderStatusTracker`'s parent-cancelled cascades reconcile the children on the next block. `true` reschedules the next check. Can be disabled with `DISABLE_DETERMINISTIC_CANCEL_SWEEP=true`. ## Order Types and Decoders -Eight order types are supported, each with a dedicated decoder in `src/decoders/`. Three are deterministic (UIDs precomputed at creation, `allCandidatesKnown=true`, not polled by C1): TWAP, StopLoss, CirclesBackingOrder. Five are non-deterministic (UIDs depend on runtime state, polled every block by C1): PerpetualSwap, GoodAfterTime, TradeAboveThreshold, SwapOrderHandler, ERC4626CowSwapFeeBurner. +All order types supported by the indexer have a dedicated decoder in `src/decoders/` (see that directory for the current list). Two categories exist based on how UIDs are discovered: + +- **Deterministic** (`allCandidatesKnown=true`): UIDs are precomputed at order creation time from the params alone, so all candidate UIDs are known immediately. Currently: TWAP, StopLoss, CirclesBackingOrder. Not polled by `OrderDiscoveryPoller`. +- **Non-deterministic** (`allCandidatesKnown=false`): UIDs depend on runtime state and cannot be precomputed. Currently: PerpetualSwap, GoodAfterTime, TradeAboveThreshold, SwapOrderHandler, ERC4626CowSwapFeeBurner. Polled every block by `OrderDiscoveryPoller`. Core handler addresses are identical across all chains; some newer handlers (SwapOrderHandler, ERC4626CowSwapFeeBurner) are per-chain overlays. Both are tracked in `src/utils/order-types.ts`. @@ -219,16 +226,13 @@ See [api-reference.md](./api-reference.md) for the full endpoint list. ## Adding a New Chain -1. Add the deployment entry to the relevant export in `src/data.ts` (start block, address if it differs). -2. Wire it into the contract config objects (`ComposableCowContract`, `CoWShedFactoryContract`, etc.) in the same file. -3. Add the chain to `ponder.config.ts` under `chains` with its RPC URL env var. -4. Add the chain's handler addresses to `HANDLER_MAP` in `src/utils/order-types.ts` (they're currently the same across all chains). -5. Add the RPC URL to `.env.local`. -6. Run `pnpm codegen` to regenerate types. - -The block handlers (C1–C5) already run on both mainnet and gnosis. Adding a new chain requires adding entries to each block handler's `chain` config in `ponder.config.ts`. +1. Create `src/chains/.ts` implementing the `ChainConfig` interface (use `src/chains/base.ts` as a template). Fill in confirmed contract addresses; leave `null` for any that aren't deployed yet. +2. Import and add the new chain to `ALL_DEFINED_CHAINS` in `src/chains/index.ts`. +3. When all required addresses are confirmed, add it to `ACTIVE_CHAINS` in the same file. +4. Add its RPC URL env var to `.env.local` (or `.env` in production) and to the `ponder` service environment in `docker-compose.yml`. +5. Run `pnpm codegen` to regenerate types. ## Known Limitations -- Cancellation detection has a small lag. For non-deterministic generators, C1 catches `SingleOrderNotAuthed` on the next poll (every block). For deterministic generators, C5 reads `singleOrders(owner, hash)` every `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100) — so on-chain removal is reflected with worst-case latency of ~100 blocks (~20 min mainnet, ~8 min Gnosis). There is no on-chain event for `remove()`, so shorter detection latency would require a higher-cadence sweep. Once the generator is marked `Cancelled`, C2 and C3 cascade the state to children on the next block; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children that were already traded on the orderbook. +- Cancellation detection has a small lag. For non-deterministic generators, `OrderDiscoveryPoller` catches `SingleOrderNotAuthed` on the next poll (every block). For deterministic generators, `CancellationWatcher` reads `singleOrders(owner, hash)` every `DETERMINISTIC_CANCEL_SWEEP_INTERVAL` blocks (default 100) — so on-chain removal is reflected with worst-case latency of ~100 blocks (~20 min mainnet, ~8 min Gnosis). There is no on-chain event for `remove()`, so shorter detection latency would require a higher-cadence sweep. Once the generator is marked `Cancelled`, `CandidateConfirmer` and `OrderStatusTracker` cascade the state to children on the next block. The `CandidateConfirmer` cascade does a preflight `/by_uids` query so candidates already on the orderbook get their actual status rather than defaulting to `cancelled`; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children already promoted to `discrete_order`. - Aave adapter owner resolution is reactive — `owner_mapping` is written when the adapter appears in settlement, which may be after the conditional order is created. The generator row keeps `resolvedOwner` equal to the adapter address when no mapping existed at insert time; that column is not backfilled when the mapping is inserted later. `ownerAddressType` on the generator IS backfilled when the mapping is inserted — after which GraphQL and REST filters on `ownerAddressType = "flash_loan_helper"` reflect the correct value. `resolvedOwner` is still not backfilled (set once at insert, unchanged thereafter). diff --git a/docs/deployment.md b/docs/deployment.md index 29a4171..27159f2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -15,12 +15,14 @@ All config goes in a `.env` file (production) or `.env.local` (local dev). Start The indexer is RPC-heavy during initial sync. Rate-limited endpoints will work but sync takes considerably longer. Use an endpoint with generous throughput for production. +> **Adding a new chain:** when a chain is added to `ACTIVE_CHAINS` in `src/chains/index.ts`, its RPC URL env var (defined as `rpcEnvVar` in the chain config file) must be added here and to the `ponder` service environment in `docker-compose.yml` under the `deploy` profile. + ### Database | Variable | Required | Description | |----------|----------|-------------| | `DATABASE_URL` | Yes | PostgreSQL connection string | -| `DATABASE_SCHEMA` | Yes | PostgreSQL schema name. `manage.sh` defaults to `programmatic_orders`. | +| `DATABASE_SCHEMA` | Yes | PostgreSQL schema name. `manage.ts` defaults to `programmatic_orders`. | Example: `DATABASE_URL=postgresql://cow_programmatic:secretpass@localhost:5433/cow_programmatic` @@ -28,15 +30,15 @@ Example: `DATABASE_URL=postgresql://cow_programmatic:secretpass@localhost:5433/c | Variable | Required | Description | |----------|----------|-------------| -| `DISABLE_POLL_RESULT_CHECK` | No | Disables the C1 ContractPoller block handler. Skips RPC multicalls for non-deterministic generators. Saves RPC calls during initial sync at the cost of not detecting poll results until re-enabled. | -| `DISABLE_DETERMINISTIC_CANCEL_SWEEP` | No | Disables the C5 DeterministicCancellationSweeper. Skips periodic `singleOrders()` reads on deterministic generators. While disabled, on-chain `ComposableCoW.remove()` calls on TWAP/StopLoss/CirclesBackingOrder generators will not be detected and those generators stay `Active`. | -| `MAX_GENERATORS_PER_BLOCK_` | No | Per-block cap on how many generators C1 and C5 will touch on the given chain (e.g. `MAX_GENERATORS_PER_BLOCK_1=200`, `MAX_GENERATORS_PER_BLOCK_100=400`). Default is 200. Excess generators defer to the next block, prioritized by oldest `lastCheckBlock` first. | +| `DISABLE_POLL_RESULT_CHECK` | No | Disables the `OrderDiscoveryPoller` block handler. Skips RPC multicalls for non-deterministic generators. Saves RPC calls during initial sync at the cost of not detecting poll results until re-enabled. | +| `DISABLE_DETERMINISTIC_CANCEL_SWEEP` | No | Disables the `CancellationWatcher`. Skips periodic `singleOrders()` reads on deterministic generators. While disabled, on-chain `ComposableCoW.remove()` calls on TWAP/StopLoss/CirclesBackingOrder generators will not be detected and those generators stay `Active`. | +| `MAX_GENERATORS_PER_BLOCK_` | No | Per-block cap on how many generators `OrderDiscoveryPoller` and `CancellationWatcher` will touch on the given chain (e.g. `MAX_GENERATORS_PER_BLOCK_1=200`, `MAX_GENERATORS_PER_BLOCK_100=400`). Default is 200. Excess generators defer to the next block, prioritized by oldest `lastCheckBlock` first. | | `DISABLE_SETTLEMENT_FACTORY_CHECK` | No | Skips `getCode` + `FACTORY()` RPC calls in the GPv2Settlement handler. Useful for benchmarking base sync throughput. | | `PINO_LOG_LEVEL` | No | Log verbosity: `debug`, `info`, `warn`, `error`. Defaults to Ponder's built-in default. | ### Production Docker Variables -Used by `deployment/docker-compose.yml` and `deployment/manage.sh`: +Used by `docker-compose.yml` (deploy profile) and `deployment/manage.ts`: | Variable | Required | Description | |----------|----------|-------------| @@ -45,10 +47,10 @@ Used by `deployment/docker-compose.yml` and `deployment/manage.sh`: | `POSTGRES_PASSWORD` | Yes | PostgreSQL password | | `POSTGRES_DB` | Yes | PostgreSQL database name | | `POSTGRES_PORT` | No | Host port mapped to PostgreSQL. Default: `5433`. | -| `POSTGRES_MEMORY_LIMIT` | No | Memory allocated to PostgreSQL. Default: `1G`. The `start-db.sh` entrypoint auto-tunes `shared_buffers`, `work_mem`, etc. based on this. | +| `POSTGRES_MEMORY_LIMIT` | No | Unused. Memory flags are now hardcoded inline in `docker-compose.yml` (tuned for 1G). Adjust the `command:` block proportionally if you allocate more RAM. | | `PONDER_EXPOSED_PORT` | No | Host port mapped to the Ponder API. Default: `40000`. Inside the container, Ponder listens on `3000`. | -If you're using the `deploy-remotely.sh` workflow, these variables also need to be set as GitHub Actions secrets (or equivalent) in your CI environment. +If you're using the `deploy-remotely.ts` workflow, these variables also need to be set as GitHub Actions secrets (or equivalent) in your CI environment. ## Database Setup @@ -71,7 +73,7 @@ Ponder manages schema migrations automatically. When it starts, it creates or up ### Production -Production uses a separate stack in `deployment/` that runs PostgreSQL and the indexer together. See the Docker section below. +Production uses the `deploy` profile in the root `docker-compose.yml`, which runs PostgreSQL and the indexer together. See the Docker section below. ## Docker @@ -79,66 +81,40 @@ Production uses a separate stack in `deployment/` that runs PostgreSQL and the i ### Production Stack ``` +docker-compose.yml # root compose file — dev postgres (default) + deploy profile deployment/ - docker-compose.yml # PostgreSQL + Ponder services - manage.sh # Build image, bring up/down the stack - deploy-remotely.sh # Rsync + SSH deploy to a remote host - static/start-db.sh # PostgreSQL entrypoint with memory auto-tuning + manage.ts # Build image, bring up/down the stack + deploy-remotely.ts # Rsync + SSH deploy to a remote host ``` -The `Dockerfile` in the project root builds the Ponder image: two-stage Node 22 Alpine, installs dependencies with `--frozen-lockfile`, exposes port 3000, runs `pnpm start`. The health check hits `/ready` with a 24-hour start period (initial sync takes hours). - -### PostgreSQL Auto-Tuning - -`start-db.sh` tunes memory settings from `POSTGRES_MEMORY_LIMIT`. With the default 1G: +The deploy services (`postgres-deploy` and `ponder`) live in the root `docker-compose.yml` under the `deploy` profile. Start them with: -- `shared_buffers`: ~204MB -- `work_mem`: 2MB per connection -- `effective_cache_size`: 512MB -- `maintenance_work_mem`: 51MB +```bash +docker compose --profile deploy up -d +``` +The `Dockerfile` in the project root builds the Ponder image: two-stage Node 22 Alpine, installs dependencies with `--frozen-lockfile`, exposes port 3000, runs `pnpm start`. The health check hits `/ready` with a 24-hour start period (initial sync takes hours). ## Deploying ### How it works in practice -`deploy-remotely.sh` handles the full flow: +`deploy-remotely.ts` handles the full flow: ```bash # Local deploy (builds and starts on this machine) -./deployment/deploy-remotely.sh - /path/to/.env +npx tsx deployment/deploy-remotely.ts - /path/to/.env # Remote deploy via SSH -./deployment/deploy-remotely.sh user@host:/opt/cow-indexer /path/to/.env +npx tsx deployment/deploy-remotely.ts user@host:/opt/cow-indexer /path/to/.env ``` What it does: 1. Rsyncs the repo to the target (excluding `.git`, `node_modules`, `.env`, logs) 2. Copies the `.env` file to `deployment/.env` on the remote -3. Runs `manage.sh up`, which builds a Docker image tagged with the current git SHA and brings up the stack +3. Runs `manage.ts up`, which builds a Docker image tagged with the current git SHA and brings up the stack On the target machine, you need Docker and DNS configured to point at the container's exposed port (`PONDER_EXPOSED_PORT`, default 40000). -To tear down: `./deployment/manage.sh down --env-file deployment/.env` - -### Production architecture - -For a production setup, run at least two containers: one dedicated to indexing and one (or more) serving the API. This way if a user overloads the API with queries, the indexer keeps working. And if the indexer crashes or restarts, the API stays up with the last-synced data. - -The current `deployment/docker-compose.yml` runs a single container doing both. Splitting indexer and API is a straightforward change: run two instances of the same image, one with indexing enabled and one configured as API-only (Ponder supports this via its `--api-only` flag or by disabling indexing). - -### API Endpoints - -Once running, the indexer exposes: - -- `GET /graphql` and `POST /graphql` -- GraphQL API -- `/sql/*` -- Ponder SQL client (direct Drizzle-based queries) -- `GET /healthz` -- returns `{"status":"ok"}` -- `GET /ready` -- readiness check (used by the Docker health check) - - -## What's Not Implemented +To tear down: `npx tsx deployment/manage.ts down --env-file deployment/.env` -- No monitoring or alerting. Watch container logs and the `/healthz` endpoint. Standard observability tooling (Prometheus, Grafana) can be wired up but nothing is preconfigured. -- No automated backups. Use standard PostgreSQL tools (`pg_dump`, WAL archiving). -- Single-instance deployment by default. See the production architecture section above for multi-container guidance. diff --git a/package.json b/package.json index 257684c..da50219 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,19 @@ "type": "module", "scripts": { "dev": "ponder dev", - "start": "ponder start -p 3000 --schema ${DATABASE_SCHEMA:-public}", + "start": "ponder start -p 3000 --schema ${DATABASE_SCHEMA:-public} --log-format json", "db": "ponder db", "codegen": "ponder codegen", "lint": "eslint . --ext .ts", "typecheck": "tsc", - "test": "vitest run" + "test": "vitest run", + "deploy:up": "tsx deployment/manage.ts up", + "deploy:down": "tsx deployment/manage.ts down", + "deploy:remote": "tsx deployment/deploy-remotely.ts" }, "dependencies": { "@cowprotocol/cow-sdk": "^7.3.8", + "drizzle-orm": "0.41.0", "@hono/swagger-ui": "^0.5.3", "@hono/zod-openapi": "^0.19.10", "hono": "^4.5.0", @@ -26,6 +30,7 @@ "@types/node": "^20.9.0", "eslint": "^8.53.0", "eslint-config-ponder": "^0.16.2", + "tsx": "^4.0.0", "typescript": "^5.2.2", "vite": "^6.0.0", "vite-tsconfig-paths": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d208fca..b4eb45f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@hono/zod-openapi': specifier: ^0.19.10 version: 0.19.10(hono@4.12.3)(zod@3.25.76) + drizzle-orm: + specifier: 0.41.0 + version: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0)(kysely@0.26.3)(pg@8.19.0) hono: specifier: ^4.5.0 version: 4.12.3 @@ -42,15 +45,18 @@ importers: eslint-config-ponder: specifier: ^0.16.2 version: 0.16.3(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1) + tsx: + specifier: ^4.0.0 + version: 4.22.3 typescript: specifier: ^5.2.2 version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@20.19.35)(yaml@2.8.3) + version: 6.4.1(@types/node@20.19.35)(tsx@4.22.3)(yaml@2.8.3) vite-tsconfig-paths: specifier: ^5.0.0 - version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.35)(yaml@2.8.3)) + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.35)(tsx@4.22.3)(yaml@2.8.3)) vitest: specifier: ^2.0.0 version: 2.1.9(@types/node@20.19.35) @@ -149,6 +155,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -161,6 +173,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -173,6 +191,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -185,6 +209,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -197,6 +227,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -209,6 +245,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -221,6 +263,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -233,6 +281,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -245,6 +299,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -257,6 +317,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -269,6 +335,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -281,6 +353,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -293,6 +371,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -305,6 +389,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -317,6 +407,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -329,6 +425,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -341,12 +443,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -359,12 +473,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -377,12 +503,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -395,6 +533,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -407,6 +551,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -419,6 +569,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -431,6 +587,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@escape.tech/graphql-armor-max-aliases@2.6.2': resolution: {integrity: sha512-SDk7pAzY6gutsdZ3NlyY55RrytrCPxJJxSN/DBfIGKphTrfBvKQWTnioQ9OlLP9kPjCE6XM5UWwGt7uqbpKSYA==} engines: {node: '>=18.0.0'} @@ -1336,6 +1498,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2385,6 +2552,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2446,6 +2618,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-license@3.0.4: @@ -2792,147 +2965,225 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.21.5': optional: true '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.21.5': optional: true '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@escape.tech/graphql-armor-max-aliases@2.6.2': dependencies: graphql: 16.13.0 @@ -3806,6 +4057,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@4.0.0: {} eslint-config-ponder@0.16.3(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1): @@ -4954,6 +5234,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -5070,13 +5356,13 @@ snapshots: - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.35)(yaml@2.8.3)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.35)(tsx@4.22.3)(yaml@2.8.3)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.1(@types/node@20.19.35)(yaml@2.8.3) + vite: 6.4.1(@types/node@20.19.35)(tsx@4.22.3)(yaml@2.8.3) transitivePeerDependencies: - supports-color - typescript @@ -5090,7 +5376,7 @@ snapshots: '@types/node': 20.19.35 fsevents: 2.3.3 - vite@6.4.1(@types/node@20.19.35)(yaml@2.8.3): + vite@6.4.1(@types/node@20.19.35)(tsx@4.22.3)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -5101,6 +5387,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.35 fsevents: 2.3.3 + tsx: 4.22.3 yaml: 2.8.3 vitest@2.1.9(@types/node@20.19.35): diff --git a/ponder.config.ts b/ponder.config.ts index c3e66a6..a1d18b5 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -1,88 +1,133 @@ import { createConfig } from "ponder"; -import { - ComposableCowContract, - COMPOSABLE_COW_DEPLOYMENTS, - CoWShedFactoryContract, - FLASH_LOAN_ROUTER_ADDRESSES, - GPv2SettlementContract, -} from "./src/data"; +import { ACTIVE_CHAINS } from "./src/chains"; +import { pollerInterval } from "./src/chains/types"; import { ComposableCowAbi } from "./abis/ComposableCowAbi"; +import { CoWShedFactoryAbi } from "./abis/CoWShedFactoryAbi"; +import { GPv2SettlementAbi } from "./abis/GPv2SettlementAbi"; -export default createConfig({ - chains: { - mainnet: { - id: 1, - rpc: process.env.MAINNET_RPC_URL!, - }, - gnosis: { - id: 100, - rpc: process.env.GNOSIS_RPC_URL!, +// Build chain entries: { mainnet: { id: 1, rpc: "..." }, gnosis: { id: 100, rpc: "..." }, ... } +const chains = Object.fromEntries( + ACTIVE_CHAINS.map((c) => [ + c.name, + { + id: c.chainId, + rpc: process.env[c.rpcEnvVar]!, + // Many RPC providers cap eth_getLogs at 1000–2000 blocks; set conservatively to avoid + // InvalidInputRpcError retry storms during backfill. Override via ETH_GET_LOGS_BLOCK_RANGE_. + ethGetLogsBlockRange: Number(process.env[`ETH_GET_LOGS_BLOCK_RANGE_${c.chainId}`] ?? 1000), }, - }, + ]), +); + +const cowShedChains = ACTIVE_CHAINS.filter((c) => c.cowShedFactory !== null); +const settlementChains = ACTIVE_CHAINS.filter( + (c) => c.gpv2Settlement !== null && c.flashLoanRouter !== null, +); + +export default createConfig({ + chains, contracts: { - ComposableCow: ComposableCowContract, + ComposableCow: { + abi: ComposableCowAbi, + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [ + c.name, + { address: c.composableCow.address, startBlock: c.composableCow.startBlock }, + ]), + ), + }, ComposableCowLive: { abi: ComposableCowAbi, - chain: { - mainnet: { ...COMPOSABLE_COW_DEPLOYMENTS.mainnet, startBlock: "latest" }, - gnosis: { ...COMPOSABLE_COW_DEPLOYMENTS.gnosis, startBlock: "latest" }, - }, + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [ + c.name, + { address: c.composableCowLive.address, startBlock: "latest" as const }, + ]), + ), + }, + CoWShedFactory: { + abi: CoWShedFactoryAbi, + chain: Object.fromEntries( + cowShedChains.map((c) => [ + c.name, + { address: c.cowShedFactory!.address, startBlock: c.cowShedFactory!.startBlock }, + ]), + ), }, - CoWShedFactory: CoWShedFactoryContract, GPv2Settlement: { - ...GPv2SettlementContract, - filter: { - event: "Settlement", - args: { solver: FLASH_LOAN_ROUTER_ADDRESSES.mainnet }, - }, + abi: GPv2SettlementAbi, + chain: Object.fromEntries( + settlementChains.map((c) => [ + c.name, + { + address: c.gpv2Settlement!.address, + startBlock: c.gpv2Settlement!.startBlock, + filter: { + event: "Settlement" as const, + args: { solver: c.flashLoanRouter! }, + }, + }, + ]), + ), }, }, blocks: { - // C1: Contract Poller — RPC multicall for non-deterministic generators + // OrderDiscoveryPoller — RPC multicall for non-deterministic generators. // Gnosis interval=4 (~20s) vs mainnet interval=1 (~12s). // The CoW watch-tower processes orders sequentially — with 1,461+ gnosis // generators, a full cycle takes many blocks. Polling every 5s gnosis block // wastes RPC calls since state rarely changes between blocks. - ContractPoller: { - chain: { - mainnet: { startBlock: "latest" }, - gnosis: { startBlock: "latest", interval: 4 }, - }, + OrderDiscoveryPoller: { + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [ + c.name, + { + startBlock: "latest" as const, + ...(pollerInterval(c.blockTime) > 1 ? { interval: pollerInterval(c.blockTime) } : {}), + }, + ]), + ), interval: 1, }, - // C2: Candidate Confirmer — checks API for unconfirmed candidates + // CandidateConfirmer — checks API for unconfirmed candidates. CandidateConfirmer: { - chain: { - mainnet: { startBlock: "latest" }, - gnosis: { startBlock: "latest" }, - }, + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.name, { startBlock: "latest" as const }]), + ), + interval: 1, + }, + // OrderStatusTracker — polls API for open discrete order status. + OrderStatusTracker: { + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.name, { startBlock: "latest" as const }]), + ), interval: 1, }, - // C3: Status Updater — polls API for open discrete order status - StatusUpdater: { - chain: { - mainnet: { startBlock: "latest" }, - gnosis: { startBlock: "latest" }, - }, + // OwnerBackfill — one-time owner fetch for non-deterministic backfill orders. + OwnerBackfill: { + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [ + c.name, + { startBlock: "latest" as const, endBlock: "latest" as const }, + ]), + ), interval: 1, }, - // C4: Historical Bootstrap — one-time owner fetch for non-deterministic backfill orders - HistoricalBootstrap: { - chain: { - mainnet: { startBlock: "latest", endBlock: "latest" }, - gnosis: { startBlock: "latest", endBlock: "latest" }, - }, + // CancellationWatcher — singleOrders() mapping read for deterministic + // generators (allCandidatesKnown=true). Cadence per generator is + // DETERMINISTIC_CANCEL_SWEEP_INTERVAL blocks; the handler itself is cheap when nothing is due. + CancellationWatcher: { + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.name, { startBlock: "latest" as const }]), + ), interval: 1, }, - // C5: Deterministic Cancellation Sweeper — singleOrders() mapping read for - // generators C1 skips (allCandidatesKnown=true). Cadence per generator is - // DETERMINISTIC_CANCEL_SWEEP_INTERVAL blocks; the handler itself is cheap - // when nothing is due. - DeterministicCancellationSweeper: { - chain: { - mainnet: { startBlock: "latest" }, - gnosis: { startBlock: "latest" }, - }, + // SettlementResolver — async Aave adapter discovery from queued Settlement events. + // Only runs on chains that have a flash loan router (currently mainnet only). + SettlementResolver: { + chain: Object.fromEntries( + settlementChains.map((c) => [c.name, { startBlock: "latest" as const }]), + ), interval: 1, }, }, diff --git a/schema/tables.ts b/schema/tables.ts index bebbaa0..ae58d1d 100644 --- a/schema/tables.ts +++ b/schema/tables.ts @@ -149,6 +149,19 @@ export const bootstrapRetryQueue = onchainTable( }) ); +export const settlementQueue = onchainTable( + "settlement_queue", + (t) => ({ + txHash: t.hex().notNull(), + chainId: t.integer().notNull(), + blockNumber: t.bigint().notNull(), + blockTimestamp: t.bigint().notNull(), + }), + (table) => ({ + pk: primaryKey({ columns: [table.chainId, table.txHash] }), + }) +); + export const ownerMapping = onchainTable( "owner_mapping", (t) => ({ diff --git a/src/api/endpoints/execution-summary.ts b/src/api/endpoints/execution-summary.ts index ae8f557..908b3a7 100644 --- a/src/api/endpoints/execution-summary.ts +++ b/src/api/endpoints/execution-summary.ts @@ -1,6 +1,7 @@ import type { RouteHandler } from "@hono/zod-openapi"; import { db } from "ponder:api"; -import { sql } from "ponder"; +import { discreteOrder } from "ponder:schema"; +import { and, count, eq } from "ponder"; import type { executionSummaryRoute } from "../routes"; export const executionSummaryHandler: RouteHandler< @@ -9,17 +10,20 @@ export const executionSummaryHandler: RouteHandler< const { eventId } = c.req.valid("param"); const { chainId } = c.req.valid("query"); - const rows = await db.execute<{ status: string; count: string }>( - sql`SELECT status, COUNT(*)::text AS count - FROM discrete_order - WHERE conditional_order_generator_id = ${eventId} - AND chain_id = ${chainId} - GROUP BY status`, - ); + const rows = await db + .select({ status: discreteOrder.status, count: count() }) + .from(discreteOrder) + .where( + and( + eq(discreteOrder.conditionalOrderGeneratorId, eventId), + eq(discreteOrder.chainId, chainId), + ), + ) + .groupBy(discreteOrder.status); const counts: Record = {}; - for (const row of rows.rows) { - counts[row.status] = Number(row.count); + for (const row of rows) { + counts[row.status] = row.count; } const filledParts = counts["fulfilled"] ?? 0; diff --git a/src/api/endpoints/orders-by-owner.ts b/src/api/endpoints/orders-by-owner.ts index 42c754e..f8b7d04 100644 --- a/src/api/endpoints/orders-by-owner.ts +++ b/src/api/endpoints/orders-by-owner.ts @@ -54,6 +54,7 @@ export const ordersByOwnerHandler: RouteHandler< owner: schema.conditionalOrderGenerator.owner, resolvedOwner: schema.conditionalOrderGenerator.resolvedOwner, status: schema.conditionalOrderGenerator.status, + hash: schema.conditionalOrderGenerator.hash, ownerAddressType: schema.conditionalOrderGenerator.ownerAddressType, }) .from(schema.conditionalOrderGenerator) diff --git a/src/api/endpoints/sync-progress.ts b/src/api/endpoints/sync-progress.ts new file mode 100644 index 0000000..4caf985 --- /dev/null +++ b/src/api/endpoints/sync-progress.ts @@ -0,0 +1,82 @@ +import type { RouteHandler } from "@hono/zod-openapi"; +import type { syncProgressRoute } from "../routes"; + +// Prometheus text-format parser for a single gauge metric. +// Matches lines like: metric_name{label="value"} 123 +const GAUGE_RE = /^(\w+)\{([^}]*)\}\s+([\d.]+)/; + +function parsePrometheusGauge( + lines: string[], + metricName: string, +): Map { + const result = new Map(); + for (const line of lines) { + if (!line.startsWith(metricName + "{")) continue; + const m = GAUGE_RE.exec(line); + if (!m) continue; + const labels = m[2] as string; + const value = Number(m[3]); + // Extract chain label value + const chainMatch = /chain="([^"]+)"/.exec(labels); + if (chainMatch) result.set(chainMatch[1] as string, value); + } + return result; +} + +export const syncProgressHandler: RouteHandler = + async (c) => { + // Resolve /metrics relative to the current request so this works on any port. + const origin = new URL(c.req.url).origin; + const metricsText = await fetch(`${origin}/metrics`) + .then((r) => r.text()) + .catch(() => ""); + + const lines = metricsText.split("\n"); + + const total = parsePrometheusGauge(lines, "ponder_historical_total_blocks"); + const completed = parsePrometheusGauge( + lines, + "ponder_historical_completed_blocks", + ); + const cached = parsePrometheusGauge( + lines, + "ponder_historical_cached_blocks", + ); + const isRealtime = parsePrometheusGauge(lines, "ponder_sync_is_realtime"); + const isComplete = parsePrometheusGauge(lines, "ponder_sync_is_complete"); + + const chains = new Set([ + ...total.keys(), + ...completed.keys(), + ...cached.keys(), + ]); + + const result: Record< + string, + { + totalBlocks: number; + processedBlocks: number; + historicalBlocksFetchedPct: number; + isRealtime: boolean; + isComplete: boolean; + } + > = {}; + + for (const chain of chains) { + const t = total.get(chain) ?? 0; + const c_ = completed.get(chain) ?? 0; + const ca = cached.get(chain) ?? 0; + const processed = c_ + ca; + const pct = t > 0 ? Math.round((processed / t) * 1000) / 10 : 100; + + result[chain] = { + totalBlocks: t, + processedBlocks: processed, + historicalBlocksFetchedPct: pct, + isRealtime: (isRealtime.get(chain) ?? 0) === 1, + isComplete: (isComplete.get(chain) ?? 0) === 1, + }; + } + + return c.json(result, 200); + }; diff --git a/src/api/router.ts b/src/api/router.ts index dd74eac..8c83ded 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -1,7 +1,12 @@ import { OpenAPIHono } from "@hono/zod-openapi"; -import { ordersByOwnerRoute, executionSummaryRoute } from "./routes"; +import { + ordersByOwnerRoute, + executionSummaryRoute, + syncProgressRoute, +} from "./routes"; import { ordersByOwnerHandler } from "./endpoints/orders-by-owner"; import { executionSummaryHandler } from "./endpoints/execution-summary"; +import { syncProgressHandler } from "./endpoints/sync-progress"; export const apiRouter = new OpenAPIHono(); @@ -18,3 +23,4 @@ apiRouter.onError((err, c) => { apiRouter.openapi(ordersByOwnerRoute, ordersByOwnerHandler); apiRouter.openapi(executionSummaryRoute, executionSummaryHandler); +apiRouter.openapi(syncProgressRoute, syncProgressHandler); diff --git a/src/api/routes.ts b/src/api/routes.ts index 7955d1d..8b65a80 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -8,6 +8,7 @@ import { ExecutionSummaryQuery, ExecutionSummaryResponse, } from "./schemas/execution-summary"; +import { SyncProgressResponse } from "./schemas/sync-progress"; export const ordersByOwnerRoute = createRoute({ method: "get", @@ -28,6 +29,23 @@ export const ordersByOwnerRoute = createRoute({ }, }); +export const syncProgressRoute = createRoute({ + method: "get", + path: "/sync-progress", + tags: ["Indexer"], + summary: "Per-chain historical sync progress", + description: + "Returns the indexer's historical backfill progress per chain: total blocks to process, blocks already processed, percentage complete, and whether the chain is in realtime mode. Reads from Ponder's built-in Prometheus metrics. During initial sync, historicalBlocksFetchedPct will rise from 0 to 100 and isComplete will flip to true once the chain is fully caught up.", + responses: { + 200: { + description: "Per-chain sync progress.", + content: { + "application/json": { schema: SyncProgressResponse }, + }, + }, + }, +}); + export const executionSummaryRoute = createRoute({ method: "get", path: "/generator/{eventId}/execution-summary", diff --git a/src/api/schemas/common.ts b/src/api/schemas/common.ts index 029a5c5..6f65cdc 100644 --- a/src/api/schemas/common.ts +++ b/src/api/schemas/common.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { CHAIN_NAMES } from "../../data"; export const AddressParam = z.object({ owner: z @@ -21,8 +22,12 @@ export const DiscreteOrderStatusQuery = z.enum([ "cancelled", ]); +const _indexedChainsDesc = Object.entries(CHAIN_NAMES) + .map(([id, name]) => `${id} (${name})`) + .join(", "); + export const ChainIdQuery = z.coerce .number() .int() .positive() - .describe("EVM chain ID. Indexed chains: 1 (mainnet), 100 (Gnosis)."); + .describe(`EVM chain ID. Indexed chains: ${_indexedChainsDesc}.`); diff --git a/src/api/schemas/orders-by-owner.ts b/src/api/schemas/orders-by-owner.ts index 884c36c..d55fd08 100644 --- a/src/api/schemas/orders-by-owner.ts +++ b/src/api/schemas/orders-by-owner.ts @@ -19,6 +19,11 @@ export const GeneratorSummary = z.object({ owner: z.string(), resolvedOwner: z.string().nullable(), status: z.string(), + hash: z + .string() + .describe( + "On-chain canonical identifier: keccak256(abi.encode(ConditionalOrderParams { handler, salt, staticInput })) — the value returned by ComposableCow.hash(params) and used as the key in singleOrders(owner, hash) and remove(owner, hash).", + ), ownerAddressType: z .enum(["cowshed_proxy", "flash_loan_helper"]) .nullable() diff --git a/src/api/schemas/sync-progress.ts b/src/api/schemas/sync-progress.ts new file mode 100644 index 0000000..fded028 --- /dev/null +++ b/src/api/schemas/sync-progress.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +export const ChainProgressSchema = z.object({ + totalBlocks: z + .number() + .int() + .describe("Total number of historical blocks to process."), + processedBlocks: z + .number() + .int() + .describe("Blocks already processed (completed + served from cache)."), + historicalBlocksFetchedPct: z + .number() + .describe("Completion percentage (0–100). Rounded to one decimal place."), + isRealtime: z + .boolean() + .describe("True when the chain has caught up and is in live-sync mode."), + isComplete: z + .boolean() + .describe("True when all historical blocks have been fully processed."), +}); + +export const SyncProgressResponse = z + .record(z.string(), ChainProgressSchema) + .describe( + "Per-chain sync progress. Keys are chain names (e.g. 'mainnet', 'gnosis').", + ); diff --git a/src/application/decoders/erc1271Signature.ts b/src/application/decoders/erc1271Signature.ts index ed4eb86..7f54948 100644 --- a/src/application/decoders/erc1271Signature.ts +++ b/src/application/decoders/erc1271Signature.ts @@ -28,7 +28,7 @@ import { decodeAbiParameters, type Hex } from "viem"; // GPv2Order.Data: 12 fixed-size fields × 32 bytes = 384 bytes total (used for byte-offset math) const GPV2_ORDER_BYTES = 384; -const PAYLOAD_STRUCT_ABI = [ +export const PAYLOAD_STRUCT_ABI = [ { type: "tuple" as const, name: "payload", diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..dc5c822 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -15,8 +15,8 @@ */ import { ponder } from "ponder:registry"; -import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema"; -import { and, asc, eq, inArray, lte, or, sql } from "ponder"; +import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder, discreteOrderStatusEnum } from "ponder:schema"; +import { and, asc, eq, inArray, isNull, lte, or, sql } from "ponder"; import type { Hex } from "viem"; import { COMPOSABLE_COW_ADDRESS_BY_CHAIN_ID, @@ -27,6 +27,7 @@ import { BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, DEFAULT_MAX_GENERATORS_PER_BLOCK, DETERMINISTIC_CANCEL_SWEEP_INTERVAL, + ORDERBOOK_HTTP_TIMEOUT_MS, RECHECK_INTERVAL, TRY_NEXT_BLOCK_WARMUP_THRESHOLD, TRY_NEXT_BLOCK_COOLDOWN_THRESHOLD, @@ -34,16 +35,19 @@ import { TRY_NEXT_BLOCK_BACKOFF_MID, TRY_NEXT_BLOCK_BACKOFF_COLD, } from "../../constants"; -import { fetchComposableOrders, fetchOrderStatusByUids, upsertDiscreteOrders } from "../helpers/orderbookClient"; +import { fetchComposableOrders, fetchOrderStatusByUids, fetchOwnerOrderStatuses, upsertDiscreteOrders } from "../helpers/orderbookClient"; import { TimeoutError, withTimeout } from "../helpers/withTimeout"; import { GET_TRADEABLE_ORDER_WITH_ERRORS_ABI, parsePollError, } from "../helpers/pollResultErrors"; import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; +import { log } from "../helpers/logger"; +import { type OrderType } from "../../utils/order-types"; +type DiscreteStatus = (typeof discreteOrderStatusEnum.enumValues)[number]; -const NON_DETERMINISTIC_TYPES = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"] as const; -const SINGLE_SHOT_NON_DETERMINISTIC = ["GoodAfterTime", "TradeAboveThreshold"] as const; +const NON_DETERMINISTIC_TYPES: readonly OrderType[] = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"]; +const SINGLE_SHOT_NON_DETERMINISTIC: readonly OrderType[] = ["GoodAfterTime", "TradeAboveThreshold"]; const BLOCK_NEVER = 2n ** 63n - 1n; // sentinel for epoch-scheduled generators (PollTryAtEpoch) const VALID_DISCRETE_STATUSES = new Set(["fulfilled", "unfilled", "expired", "cancelled"]); @@ -68,7 +72,7 @@ const SINGLE_ORDERS_ABI = [ // allCandidatesKnown=false. Normally only non-deterministic types, but also // serves as fallback for deterministic types whose precompute failed. -ponder.on("ContractPoller:block", async ({ event, context }) => { +ponder.on("OrderDiscoveryPoller:block", async ({ event, context }) => { if (process.env.DISABLE_POLL_RESULT_CHECK) return; const chainId = context.chain.id as SupportedChainId; @@ -112,16 +116,14 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { handler: Hex; salt: Hex; staticInput: Hex; - orderType: string; + orderType: OrderType; decodedParams: Record | null; consecutiveTryNextBlock: number; }[]; if (dueOrders.length === 0) return; - console.log( - `[COW:C1] ENTER block=${currentBlock} chain=${chainId} due=${dueOrders.length}`, - ); + log("info", "OrderDiscoveryPoller:ENTER", { block: String(currentBlock), chainId, due: dueOrders.length }); const c1MulticallPromise = context.client.multicall({ contracts: dueOrders.map((order) => ({ @@ -147,9 +149,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { ); } catch (err) { if (err instanceof TimeoutError) { - console.warn( - `[COW:C1] multicall timeout block=${currentBlock} chain=${chainId} due=${dueOrders.length}`, - ); + log("warn", "OrderDiscoveryPoller:multicall_timeout", { block: String(currentBlock), chainId, due: dueOrders.length }); return; } throw err; @@ -197,7 +197,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { .onConflictDoNothing(), ); - const isSingleShot = (SINGLE_SHOT_NON_DETERMINISTIC as readonly string[]).includes(order.orderType); + const isSingleShot = SINGLE_SHOT_NON_DETERMINISTIC.includes(order.orderType); successPromises.push( updateGeneratorPollState(context, chainId, order.generatorId, currentBlock, { nextCheckBlock: currentBlock + RECHECK_INTERVAL, @@ -263,9 +263,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { eq(conditionalOrderGenerator.eventId, order.generatorId), ), ); - console.log( - `[COW:C1] NEVER generatorId=${order.generatorId} reason=${pollResult.reason} block=${currentBlock} chain=${chainId}`, - ); + log("info", "OrderDiscoveryPoller:NEVER", { block: String(currentBlock), chainId, generatorId: order.generatorId, reason: pollResult.reason }); neverCount++; break; @@ -284,9 +282,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { eq(conditionalOrderGenerator.eventId, order.generatorId), ), ); - console.log( - `[COW:C1] CANCELLED generatorId=${order.generatorId} block=${currentBlock} chain=${chainId}`, - ); + log("info", "OrderDiscoveryPoller:CANCELLED", { block: String(currentBlock), chainId, generatorId: order.generatorId }); break; } } @@ -295,9 +291,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { await Promise.all(successPromises); const capped = dueOrders.length === maxGeneratorsPerBlock; - console.log( - `[COW:C1] DONE block=${currentBlock} chain=${chainId} due=${dueOrders.length} success=${successCount} never=${neverCount} backedOff=${backedOffCount}${capped ? " CAPPED" : ""}`, - ); + log("info", "OrderDiscoveryPoller:DONE", { block: String(currentBlock), chainId, due: dueOrders.length, success: successCount, never: neverCount, backedOff: backedOffCount, capped }); }); // ─── C2: Candidate Confirmer ───────────────────────────────────────────────── @@ -354,23 +348,46 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { }[]; if (orphanCandidates.length > 0) { + // COW-990: preflight /by_uids before writing cancelled. A candidate could have + // been posted by the watch-tower and filled/expired between generator creation + // and the cancellation cascade (~0.17% observed rate). Use the API status when + // available; fall back to 'cancelled' for UIDs not yet on the orderbook. + // Bounded by ORDERBOOK_HTTP_TIMEOUT_MS * 2; on timeout the empty map fallback + // keeps correctness degraded-gracefully (all orphans written as 'cancelled'). + let preflightStatuses: Awaited>; + try { + preflightStatuses = await withTimeout( + fetchOrderStatusByUids(context, chainId, orphanCandidates.map((c) => c.orderUid)), + ORDERBOOK_HTTP_TIMEOUT_MS * 2, + "c2:cascade:preflight", + ); + } catch { + preflightStatuses = new Map(); + } + + // onConflictDoNothing: if C3 already promoted this UID with a terminal status + // (e.g. 'fulfilled'), the existing row wins and this insert is a no-op. + // preflightKnown counts API hits, not rows actually written. await context.db.sql .insert(discreteOrder) .values( - orphanCandidates.map((c) => ({ - orderUid: c.orderUid, - chainId, - conditionalOrderGeneratorId: c.generatorId, - status: "cancelled" as const, - sellAmount: c.sellAmount, - buyAmount: c.buyAmount, - feeAmount: c.feeAmount, - validTo: c.validTo, - creationDate: c.creationDate, - executedSellAmount: null, - executedBuyAmount: null, - promotedAt: event.block.timestamp, - })), + orphanCandidates.map((c) => { + const apiEntry = preflightStatuses.get(c.orderUid); + return { + orderUid: c.orderUid, + chainId, + conditionalOrderGeneratorId: c.generatorId, + status: (apiEntry?.status ?? "cancelled") as DiscreteStatus, + sellAmount: c.sellAmount, + buyAmount: c.buyAmount, + feeAmount: c.feeAmount, + validTo: c.validTo, + creationDate: c.creationDate, + executedSellAmount: apiEntry?.executedSellAmount ?? null, + executedBuyAmount: apiEntry?.executedBuyAmount ?? null, + promotedAt: event.block.timestamp, + }; + }), ) .onConflictDoNothing(); @@ -386,9 +403,8 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ), ); - console.log( - `[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length}`, - ); + const preflightKnown = preflightStatuses.size; + log("info", "CandidateConfirmer:parent_cancelled", { block: String(event.block.number), chainId, parentCancelled: orphanCandidates.length, preflightKnown }); } } @@ -409,7 +425,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { and( eq(candidateDiscreteOrder.chainId, chainId), or( - sql`${candidateDiscreteOrder.possibleValidAfterTimestamp} IS NULL`, + isNull(candidateDiscreteOrder.possibleValidAfterTimestamp), lte(candidateDiscreteOrder.possibleValidAfterTimestamp, event.block.timestamp), ), ), @@ -428,7 +444,6 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { const uids = unconfirmed.map((c) => c.orderUid); const statuses = await fetchOrderStatusByUids(context, chainId, uids); - type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; const rowsToUpsert: (typeof discreteOrder.$inferInsert)[] = []; const confirmedUids: string[] = []; @@ -514,6 +529,47 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { if (stale.length > 0) { const staleStatuses = await fetchOrderStatusByUids(context, chainId, stale.map((c) => c.orderUid)); + + // TWAP parts can age out of /by_uids before C2 sees them, causing fulfilled + // parts to be recorded as "expired". For any missed UIDs, fall back to + // /account/{owner}/orders — one fetch per unique owner. + const missed = stale.filter((c) => !staleStatuses.has(c.orderUid)); + if (missed.length > 0) { + const generatorIds = [...new Set(missed.map((c) => c.generatorId))]; + const ownerRows = (await context.db.sql + .select({ eventId: conditionalOrderGenerator.eventId, owner: conditionalOrderGenerator.owner }) + .from(conditionalOrderGenerator) + .where(inArray(conditionalOrderGenerator.eventId, generatorIds))) as { + eventId: string; + owner: string; + }[]; + const ownerByGeneratorId = new Map(ownerRows.map((g) => [g.eventId, g.owner as Hex])); + + const missedByOwner = new Map>(); + for (const c of missed) { + const owner = ownerByGeneratorId.get(c.generatorId); + if (!owner) continue; + const ownerKey = owner.toLowerCase() as Hex; + if (!missedByOwner.has(ownerKey)) missedByOwner.set(ownerKey, new Set()); + missedByOwner.get(ownerKey)!.add(c.orderUid); + } + + for (const [owner, ownerMissedUids] of missedByOwner) { + try { + const ownerStatuses = await withTimeout( + fetchOwnerOrderStatuses(chainId, owner), + BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, + "c2:stale:accountFallback", + ); + for (const [uid, info] of ownerStatuses) { + if (ownerMissedUids.has(uid)) staleStatuses.set(uid, info); + } + } catch (err) { + console.warn(`[COW:C2] block=${event.block.number} chain=${chainId} accountFallback failed owner=${owner}`, err); + } + } + } + const staleRows: (typeof discreteOrder.$inferInsert)[] = stale.map((c) => { const entry = staleStatuses.get(c.orderUid); return { @@ -548,16 +604,14 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { } if (confirmed > 0 || stale.length > 0) { - console.log( - `[COW:C2] block=${event.block.number} chain=${chainId} candidates=${unconfirmed.length} confirmed=${confirmed} expired=${stale.length}`, - ); + log("info", "CandidateConfirmer:DONE", { block: String(event.block.number), chainId, candidates: unconfirmed.length, confirmed, expired: stale.length }); } }); // ─── C3: Status Updater ────────────────────────────────────────────────────── // Polls the API for status updates on open discrete orders. Expires past validTo. -ponder.on("StatusUpdater:block", async ({ event, context }) => { +ponder.on("OrderStatusTracker:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentTimestamp = event.block.timestamp; @@ -602,9 +656,7 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { } if (updated > 0) { - console.log( - `[COW:C3] block=${event.block.number} chain=${chainId} open=${openOrders.length} updated=${updated}`, - ); + log("info", "OrderStatusTracker:DONE", { block: String(event.block.number), chainId, open: openOrders.length, updated }); } } @@ -658,7 +710,7 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { // One-time discovery of historical discrete orders for non-deterministic // generators created during backfill. Fires once at startBlock=endBlock="latest". -ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { +ponder.on("OwnerBackfill:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentBlock = event.block.number; @@ -668,9 +720,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { .from(bootstrapRetryQueue) .where(eq(bootstrapRetryQueue.chainId, chainId)); - console.log( - `[COW:C4] block=${currentBlock} chain=${chainId} pending_retry=${queued.length}`, - ); + log("info", "OwnerBackfill:START", { block: String(currentBlock), chainId, pendingRetry: queued.length }); let totalDiscovered = 0; const retriedOwners = new Set(); @@ -690,9 +740,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { .where(and(eq(bootstrapRetryQueue.chainId, chainId), eq(bootstrapRetryQueue.owner, owner as Hex))); } catch (err) { if (err instanceof TimeoutError) { - console.warn( - `[COW:C4] owner retry timeout owner=${owner} chain=${chainId} retry_count=${retryCount + 1} after=${BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS}ms`, - ); + log("warn", "OwnerBackfill:owner_retry_timeout", { block: String(currentBlock), chainId, owner, retryCount: retryCount + 1, timeoutMs: BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS }); await context.db.sql .update(bootstrapRetryQueue) .set({ retryCount: retryCount + 1, lastRetryAt: currentBlock }) @@ -723,26 +771,24 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { eq(conditionalOrderGenerator.chainId, chainId), eq(conditionalOrderGenerator.status, "Active"), inArray(conditionalOrderGenerator.orderType, [...NON_DETERMINISTIC_TYPES]), - sql`${discreteOrder.orderUid} IS NULL`, + isNull(discreteOrder.orderUid), ), ) as { generatorId: string; owner: Hex; - orderType: string; + orderType: OrderType; }[]; // Exclude owners already retried above — they were just attempted this run const freshOwners = new Set(generators.map((g) => g.owner).filter((o) => !retriedOwners.has(o))); if (freshOwners.size === 0 && retriedOwners.size === 0) { - console.log(`[COW:C4] block=${currentBlock} chain=${chainId} no generators need bootstrap`); + log("info", "OwnerBackfill:no_bootstrap_needed", { block: String(currentBlock), chainId }); return; } if (freshOwners.size > 0) { - console.log( - `[COW:C4] block=${currentBlock} chain=${chainId} generators=${generators.length} fresh_owners=${freshOwners.size}`, - ); + log("info", "OwnerBackfill:bootstrap_start", { block: String(currentBlock), chainId, generators: generators.length, freshOwners: freshOwners.size }); } for (const owner of freshOwners) { @@ -756,9 +802,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { totalDiscovered += count; } catch (err) { if (err instanceof TimeoutError) { - console.warn( - `[COW:C4] owner timeout owner=${owner} chain=${chainId} after=${BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS}ms`, - ); + log("warn", "OwnerBackfill:owner_timeout", { block: String(currentBlock), chainId, owner, timeoutMs: BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS }); await context.db.sql .insert(bootstrapRetryQueue) .values({ chainId, owner, firstTimeoutAt: currentBlock, retryCount: 1, lastRetryAt: currentBlock }) @@ -769,9 +813,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { } } - console.log( - `[COW:C4] DONE block=${currentBlock} chain=${chainId} discovered=${totalDiscovered}`, - ); + log("info", "OwnerBackfill:DONE", { block: String(currentBlock), chainId, discovered: totalDiscovered }); }); // ─── C5: Deterministic Cancellation Sweeper ────────────────────────────────── @@ -783,7 +825,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { // Cancelled, which lets the C2/C3 parent-cancelled cascade (COW-918) reconcile // the child discrete / candidate rows on the next block. -ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) => { +ponder.on("CancellationWatcher:block", async ({ event, context }) => { if (process.env.DISABLE_DETERMINISTIC_CANCEL_SWEEP) return; const chainId = context.chain.id as SupportedChainId; @@ -810,7 +852,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = eq(conditionalOrderGenerator.status, "Active"), eq(conditionalOrderGenerator.allCandidatesKnown, true), or( - sql`${conditionalOrderGenerator.nextCheckBlock} IS NULL`, + isNull(conditionalOrderGenerator.nextCheckBlock), lte(conditionalOrderGenerator.nextCheckBlock, currentBlock), ), ), @@ -820,14 +862,12 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = generatorId: string; owner: Hex; hash: Hex; - orderType: string; + orderType: OrderType; }[]; if (dueGenerators.length === 0) return; - console.log( - `[COW:C5] ENTER block=${currentBlock} chain=${chainId} due=${dueGenerators.length}`, - ); + log("info", "CancellationWatcher:ENTER", { block: String(currentBlock), chainId, due: dueGenerators.length }); const c5MulticallPromise = context.client.multicall({ contracts: dueGenerators.map((g) => ({ @@ -848,9 +888,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = ); } catch (err) { if (err instanceof TimeoutError) { - console.warn( - `[COW:C5] multicall timeout block=${currentBlock} chain=${chainId} due=${dueGenerators.length}`, - ); + log("warn", "CancellationWatcher:multicall_timeout", { block: String(currentBlock), chainId, due: dueGenerators.length }); return; } throw err; @@ -887,9 +925,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = eq(conditionalOrderGenerator.eventId, gen.generatorId), ), ); - console.log( - `[COW:C5] CANCELLED generatorId=${gen.generatorId} orderType=${gen.orderType} block=${currentBlock} chain=${chainId}`, - ); + log("info", "CancellationWatcher:CANCELLED", { block: String(currentBlock), chainId, generatorId: gen.generatorId, orderType: gen.orderType }); cancelledCount++; } else { await context.db.sql @@ -909,9 +945,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = } } - console.log( - `[COW:C5] DONE block=${currentBlock} chain=${chainId} due=${dueGenerators.length} cancelled=${cancelledCount} stillActive=${stillActiveCount} errors=${errorCount}`, - ); + log("info", "CancellationWatcher:DONE", { block: String(currentBlock), chainId, due: dueGenerators.length, cancelled: cancelledCount, stillActive: stillActiveCount, errors: errorCount }); }); // ─── Shared helpers ────────────────────────────────────────────────────────── diff --git a/src/application/handlers/composableCow.ts b/src/application/handlers/composableCow.ts index 44f7858..3168285 100644 --- a/src/application/handlers/composableCow.ts +++ b/src/application/handlers/composableCow.ts @@ -8,8 +8,8 @@ * * For deterministic types (TWAP, StopLoss, CirclesBackingOrder), precomputeAndDiscover * computes all UIDs, fetches their status from the API, upserts discrete orders, and marks - * allCandidatesKnown=true. Non-deterministic types are left for the C1-C4 block handlers to - * discover at live sync. + * allCandidatesKnown=true. Non-deterministic types are left for the OrderDiscoveryPoller + * block handler to discover at live sync. * * CirclesBackingOrder (Gnosis only) additionally reads two constructor immutables * (SELL_TOKEN, SELL_AMOUNT) from the handler contract at creation time and merges them @@ -24,12 +24,14 @@ * * This affects only EIP-1271 composable orders where the user cancels through * the API rather than calling ComposableCoW.remove() on-chain. In practice - * this is rare — the standard cancellation path for composable orders is - * on-chain, which emits ConditionalOrderCancelled (handled elsewhere) or - * triggers PollNever in the block handler. + * this is rare — the standard on-chain cancellation path is detected via + * SingleOrderNotAuthed (OrderDiscoveryPoller) and the CancellationWatcher, + * both of which work correctly. * - * If this gap proves significant in production, a lightweight periodic check - * can be added for owners with open orders. Track via issue tracker if needed. + * A newer ComposableCoW contract version (nullislabs/composable-cow#1) emits a + * ConditionalOrderRemoved event from remove(), which would allow the indexer to + * detect on-chain cancellations directly without polling. Supporting this contract + * version is tracked as a future improvement. * */ @@ -45,6 +47,7 @@ import { getOrderTypeFromHandler } from "../../utils/order-types"; import { decodeStaticInput } from "../../decoders/index"; import { precomputeAndDiscover } from "../helpers/uidPrecompute"; import { CirclesBackingOrderAbi } from "../../../abis/CirclesBackingOrderAbi"; +import { log } from "../helpers/logger"; // ─── CirclesBackingOrder immutables cache ─────────────────────────────────── // @@ -128,14 +131,9 @@ async function insertGenerator( const orderType = getOrderTypeFromHandler(handler, chainId); if (orderType === "Unknown") { - console.warn( - `[ComposableCow] Unknown handler ${handler} on chain ${chainId}, ` + - `saving as Unknown — event=${event.id}`, - ); + log("warn", "composableCow:unknownHandler", { handler, chainId, event: event.id }); } else { - console.log( - `[ComposableCow] ConditionalOrderCreated event=${event.id} chain=${chainId} orderType=${orderType} block=${event.block.number}`, - ); + log("info", "composableCow:created", { event: event.id, chainId, orderType, block: String(event.block.number) }); } // Decode staticInput; for CirclesBackingOrder, also merge in handler immutables. @@ -169,13 +167,9 @@ async function insertGenerator( }; } - console.log( - `[ComposableCow] Decoded event=${event.id} orderType=${orderType} decodedParams=${decodedParams ? "ok" : "null"}`, - ); + log("info", "composableCow:decoded", { event: event.id, orderType, decodedParams: decodedParams ? "ok" : "null" }); } catch (err) { - console.warn( - `[ComposableCow] Decode failed event=${event.id} orderType=${orderType} err=${err}`, - ); + log("warn", "composableCow:decodeFailed", { event: event.id, orderType, err: String(err) }); decodedParams = null; decodeError = "invalid_static_input"; } @@ -253,7 +247,7 @@ ponder.on( // ─── Live handler (ComposableCowLive — startBlock: "latest") ──────────────── // Same as backfill: pre-compute covers deterministic types. -// Non-deterministic types are discovered by C1-C4 block handlers at live sync. +// Non-deterministic types are discovered by the OrderDiscoveryPoller block handler at live sync. ponder.on( "ComposableCowLive:ConditionalOrderCreated", diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 7304b07..3aad857 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -1,12 +1,21 @@ import { ponder } from "ponder:registry"; -import { AddressType, conditionalOrderGenerator, ownerMapping, transaction } from "ponder:schema"; +import { + AddressType, + conditionalOrderGenerator, + ownerMapping, + settlementQueue, + transaction, +} from "ponder:schema"; import { and, eq } from "ponder"; -import { decodeAbiParameters, keccak256, toBytes } from "viem"; +import { keccak256, toBytes } from "viem"; +import { log } from "../helpers/logger"; import { AaveV3AdapterHelperAbi } from "../../../abis/AaveV3AdapterHelperAbi"; import { AAVE_V3_ADAPTER_FACTORY_ADDRESSES, GPV2_SETTLEMENT_DEPLOYMENTS, } from "../../data"; +import { BLOCK_HANDLER_RPC_TIMEOUT_MS, SETTLEMENT_INNER_RPC_TIMEOUT_MS } from "../../constants"; +import { TimeoutError as _TimeoutError, withTimeout } from "../helpers/withTimeout"; // Trade(address,address,address,uint256,uint256,uint256,bytes) — topic0 hash const TRADE_TOPIC = keccak256( @@ -14,10 +23,9 @@ const TRADE_TOPIC = keccak256( ); // ── Stats / timing ──────────────────────────────────────────────────────────── -// Logged every LOG_INTERVAL_MS to measure per-step cost without flooding logs. const stats = { - total: 0, // Settlement events processed - tradeLogsFound: 0, // Trade logs found in receipts + total: 0, + tradeLogsFound: 0, skippedAlreadyMapped: 0, skippedEOA: 0, skippedNotAdapter: 0, @@ -31,15 +39,15 @@ function logStatsIfIntervalPassed() { if (Date.now() - statsLastLogAt < LOG_INTERVAL_MS) return; const contractAddresses = stats.tradeLogsFound - stats.skippedAlreadyMapped - stats.skippedEOA; - console.log( - `[SETTLEMENT:STATS] settlements=${stats.total}` + - ` tradeLogs=${stats.tradeLogsFound}` + - ` alreadyMapped=${stats.skippedAlreadyMapped}` + - ` eoa=${stats.skippedEOA}` + - ` notAdapter=${stats.skippedNotAdapter}` + - ` mapped=${stats.mapped}` + - ` | avgFactory=${contractAddresses > 0 ? (stats.msFactory / contractAddresses).toFixed(1) : 0}ms`, - ); + log("info", "settlement:stats", { + settlements: stats.total, + tradeLogs: stats.tradeLogsFound, + alreadyMapped: stats.skippedAlreadyMapped, + eoa: stats.skippedEOA, + notAdapter: stats.skippedNotAdapter, + mapped: stats.mapped, + avgFactoryMs: contractAddresses > 0 ? Number((stats.msFactory / contractAddresses).toFixed(1)) : 0, + }); statsLastLogAt = Date.now(); } @@ -48,19 +56,35 @@ function logStatsIfIntervalPassed() { // which floods the log since non-adapter contracts do not implement FACTORY(). const FACTORY_SELECTOR = "0x2dd31000" as const; +// Max settlements resolved per SettlementResolver block tick. +const MAX_SETTLEMENTS_PER_BLOCK = 20; + +// ── Event handler — enqueue only ───────────────────────────────────────────── +// All RPC work is deferred to SettlementResolver:block so errors in RPC calls +// never propagate to the event handler and crash the indexer. ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { - // Kill switch: set DISABLE_SETTLEMENT_FACTORY_CHECK=true to skip all RPC - // calls in this handler. Use to benchmark base throughput vs. factory cost. + if (process.env.DISABLE_SETTLEMENT_FACTORY_CHECK === "true") return; + + await context.db + .insert(settlementQueue) + .values({ + txHash: event.transaction.hash, + chainId: context.chain.id, + blockNumber: event.block.number, + blockTimestamp: event.block.timestamp, + }) + .onConflictDoNothing(); +}); + +// ── Block handler — drain queue and resolve adapters ───────────────────────── +ponder.on("SettlementResolver:block", async ({ event: _event, context }) => { if (process.env.DISABLE_SETTLEMENT_FACTORY_CHECK === "true") return; const chainId = context.chain.id; const chainName = context.chain.name; - // Resolve chain-specific addresses — skip safely if chain is not configured const settlementDeployment = - GPV2_SETTLEMENT_DEPLOYMENTS[ - chainName as keyof typeof GPV2_SETTLEMENT_DEPLOYMENTS - ]; + GPV2_SETTLEMENT_DEPLOYMENTS[chainName as keyof typeof GPV2_SETTLEMENT_DEPLOYMENTS]; if (!settlementDeployment) return; const settlementAddress = settlementDeployment.address.toLowerCase(); @@ -70,157 +94,159 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { ]?.toLowerCase(); if (!adapterFactoryAddress) return; - stats.total++; + const pending = await context.db.sql + .select() + .from(settlementQueue) + .where(eq(settlementQueue.chainId, chainId)) + .limit(MAX_SETTLEMENTS_PER_BLOCK); - // Fetch the full receipt to access all logs in the transaction. - // Volume is negligible (FlashLoanRouter settlements only), so the extra RPC - // call per settlement is acceptable and much cheaper than the old per-trade approach. - const receipt = await context.client.getTransactionReceipt({ - hash: event.transaction.hash, - }); + if (pending.length === 0) return; - for (const log of receipt.logs) { - // Only Trade logs emitted by GPv2Settlement in this same transaction - if (log.address.toLowerCase() !== settlementAddress) continue; - if (log.topics[0] !== TRADE_TOPIC) continue; - - stats.tradeLogsFound++; - - // Decode owner from topics[1] — ABI-encoded 32-byte padded address - const owner = `0x${log.topics[1]!.slice(26)}` as `0x${string}`; - const ownerAddress = owner.toLowerCase() as `0x${string}`; - - // Skip if already mapped (adapter seen in a prior settlement) - const existing = await context.db.sql - .select() - .from(ownerMapping) - .where( - and( - eq(ownerMapping.chainId, chainId), - eq(ownerMapping.address, ownerAddress), - ), - ) - .limit(1); - - if (existing.length > 0) { - stats.skippedAlreadyMapped++; - logStatsIfIntervalPassed(); - continue; - } + for (const item of pending) { + stats.total++; - // Skip if EOA (no bytecode) - const code = await context.client.getCode({ address: owner }); - if (!code || code === "0x") { - stats.skippedEOA++; - logStatsIfIntervalPassed(); + let receipt: Awaited>; + try { + receipt = await withTimeout( + context.client.getTransactionReceipt({ hash: item.txHash }), + BLOCK_HANDLER_RPC_TIMEOUT_MS, + "settlement:getTransactionReceipt", + ); + } catch (err) { + log("warn", "SettlementResolver:receipt_failed", { chainId, txHash: item.txHash, err: err instanceof Error ? err.message : String(err) }); + await context.db.sql + .delete(settlementQueue) + .where(and(eq(settlementQueue.chainId, chainId), eq(settlementQueue.txHash, item.txHash))); continue; } - // Check for Aave adapter via raw eth_call. - // readContract() is intentionally avoided here: Ponder logs a WARN for every - // revert, and FACTORY() reverts on any non-adapter contract. - const t1 = Date.now(); - let factoryData: `0x${string}` | undefined; - try { - const result = await context.client.call({ - to: owner, - data: FACTORY_SELECTOR, - }); - factoryData = result.data; - } catch { + for (const txLog of receipt.logs) { + if (txLog.address.toLowerCase() !== settlementAddress) continue; + if (txLog.topics[0] !== TRADE_TOPIC) continue; + + stats.tradeLogsFound++; + + const owner = `0x${txLog.topics[1]!.slice(26)}` as `0x${string}`; + const ownerAddress = owner.toLowerCase() as `0x${string}`; + + const existing = await context.db.sql + .select() + .from(ownerMapping) + .where(and(eq(ownerMapping.chainId, chainId), eq(ownerMapping.address, ownerAddress))) + .limit(1); + + if (existing.length > 0) { + stats.skippedAlreadyMapped++; + logStatsIfIntervalPassed(); + continue; + } + + let code: `0x${string}` | undefined; + try { + code = await withTimeout( + context.client.getCode({ address: owner }), + SETTLEMENT_INNER_RPC_TIMEOUT_MS, + "settlement:getCode", + ); + } catch (err) { + log("warn", "SettlementResolver:getCode_failed", { chainId, owner, err: err instanceof Error ? err.message : String(err) }); + continue; + } + if (!code || code === "0x") { + stats.skippedEOA++; + logStatsIfIntervalPassed(); + continue; + } + + const t1 = Date.now(); + let factoryData: `0x${string}` | undefined; + try { + const result = await withTimeout( + context.client.call({ to: owner, data: FACTORY_SELECTOR }), + SETTLEMENT_INNER_RPC_TIMEOUT_MS, + "settlement:call:FACTORY", + ); + factoryData = result.data; + } catch { + stats.msFactory += Date.now() - t1; + stats.skippedNotAdapter++; + logStatsIfIntervalPassed(); + continue; + } stats.msFactory += Date.now() - t1; - stats.skippedNotAdapter++; - logStatsIfIntervalPassed(); - continue; - } - stats.msFactory += Date.now() - t1; - // ABI-encoded address = 32 bytes = 66 hex chars (including 0x prefix) - if (!factoryData || factoryData.length < 66) { - stats.skippedNotAdapter++; + if (!factoryData || factoryData.length < 66) { + stats.skippedNotAdapter++; + logStatsIfIntervalPassed(); + continue; + } + + const factoryAddress = `0x${factoryData.slice(26)}` as `0x${string}`; + if (factoryAddress.toLowerCase() !== adapterFactoryAddress) { + stats.skippedNotAdapter++; + logStatsIfIntervalPassed(); + continue; + } + + let eoaOwner: `0x${string}`; + try { + eoaOwner = await withTimeout( + context.client.readContract({ + address: owner, + abi: AaveV3AdapterHelperAbi, + functionName: "owner", + }), + BLOCK_HANDLER_RPC_TIMEOUT_MS, + "settlement:readContract:owner", + ); + } catch (err) { + log("warn", "SettlementResolver:readOwner_failed", { chainId, owner, err: err instanceof Error ? err.message : String(err) }); + continue; + } + + await context.db + .insert(transaction) + .values({ + hash: item.txHash, + chainId, + blockNumber: item.blockNumber, + blockTimestamp: item.blockTimestamp, + }) + .onConflictDoNothing(); + + await context.db + .insert(ownerMapping) + .values({ + chainId, + address: ownerAddress, + owner: eoaOwner.toLowerCase() as `0x${string}`, + addressType: AddressType.FlashLoanHelper, + txHash: item.txHash, + blockNumber: item.blockNumber, + resolutionDepth: 1, + }) + .onConflictDoNothing(); + + await context.db.sql + .update(conditionalOrderGenerator) + .set({ ownerAddressType: AddressType.FlashLoanHelper }) + .where( + and( + eq(conditionalOrderGenerator.chainId, chainId), + eq(conditionalOrderGenerator.owner, ownerAddress), + ), + ); + + stats.mapped++; logStatsIfIntervalPassed(); - continue; - } - - // Decode padded address: 0x + 24 zero-padding hex chars + 40 address hex chars - const factoryAddress = `0x${factoryData.slice(26)}` as `0x${string}`; - if (factoryAddress.toLowerCase() !== adapterFactoryAddress) { - stats.skippedNotAdapter++; - logStatsIfIntervalPassed(); - continue; + log("info", "SettlementResolver:aave_adapter_mapped", { chainId, adapter: ownerAddress, eoa: eoaOwner.toLowerCase(), block: String(item.blockNumber) }); } - // Resolve EOA via owner() — this call should always succeed at this point - const eoaOwner = await context.client.readContract({ - address: owner, - abi: AaveV3AdapterHelperAbi, - functionName: "owner", - }); - - await context.db - .insert(transaction) - .values({ - hash: event.transaction.hash, - chainId, - blockNumber: event.block.number, - blockTimestamp: event.block.timestamp, - }) - .onConflictDoNothing(); - - await context.db - .insert(ownerMapping) - .values({ - chainId, - address: ownerAddress, - owner: eoaOwner.toLowerCase() as `0x${string}`, - addressType: AddressType.FlashLoanHelper, - txHash: event.transaction.hash, - blockNumber: event.block.number, - resolutionDepth: 1, - }) - .onConflictDoNothing(); - await context.db.sql - .update(conditionalOrderGenerator) - .set({ ownerAddressType: AddressType.FlashLoanHelper }) - .where( - and( - eq(conditionalOrderGenerator.chainId, chainId), - eq(conditionalOrderGenerator.owner, ownerAddress), - ), - ); + .delete(settlementQueue) + .where(and(eq(settlementQueue.chainId, chainId), eq(settlementQueue.txHash, item.txHash))); - // Decode non-indexed Trade log fields: sellToken, buyToken, amounts, orderUid - const [sellToken, buyToken, sellAmount, buyAmount, , orderUid] = - decodeAbiParameters( - [ - { type: "address" }, - { type: "address" }, - { type: "uint256" }, - { type: "uint256" }, - { type: "uint256" }, - { type: "bytes" }, - ], - log.data, - ); - - stats.mapped++; logStatsIfIntervalPassed(); - - console.log( - `[COW:SETTLEMENT:TRADE] AAVE_ADAPTER_MAPPED` + - ` adapter=${ownerAddress}` + - ` eoa=${eoaOwner.toLowerCase()}` + - ` orderUid=${orderUid}` + - ` sellToken=${sellToken.toLowerCase()}` + - ` buyToken=${buyToken.toLowerCase()}` + - ` sellAmount=${sellAmount}` + - ` buyAmount=${buyAmount}` + - ` block=${event.block.number}` + - ` chain=${chainId}`, - ); } - - logStatsIfIntervalPassed(); }); diff --git a/src/application/handlers/setup.ts b/src/application/handlers/setup.ts index bc4b219..e69f25c 100644 --- a/src/application/handlers/setup.ts +++ b/src/application/handlers/setup.ts @@ -1,14 +1,15 @@ import { ponder } from "ponder:registry"; import { sql } from "ponder"; +import { log } from "../helpers/logger"; /** - * Creates the cow_cache schema and orderbook_cache table on startup. + * Creates the cow_cache schema and persistent cache tables on startup. * * The cow_cache schema is separate from Ponder's per-deployment schema, so it * survives `ponder start` redeployments (which create a new namespace each time). * Ponder's `user` pool does not restrict search_path, so fully qualified names - * (cow_cache.orderbook_cache) work from event handlers. The `readonly` pool used - * by the API layer also works with fully qualified names. + * work from event handlers. The `readonly` pool used by the API layer also works + * with fully qualified names. * * Cache semantics (enforced by consumers, not here): * - Terminal states (fulfilled/expired/cancelled): cached indefinitely (cannot change) @@ -18,15 +19,6 @@ ponder.on("ComposableCow:setup", async ({ context }) => { // Create a separate schema that Ponder's per-deployment schema management won't touch. await context.db.sql.execute(sql`CREATE SCHEMA IF NOT EXISTS cow_cache`); - // Legacy per-owner cache (kept for backward compat, no longer actively used) - await context.db.sql.execute(sql` - CREATE TABLE IF NOT EXISTS cow_cache.orderbook_cache ( - cache_key TEXT PRIMARY KEY, - response_json TEXT NOT NULL, - fetched_at BIGINT NOT NULL - ) - `); - // Per-UID cache for terminal order statuses + executed amounts await context.db.sql.execute(sql` CREATE TABLE IF NOT EXISTS cow_cache.order_uid_cache ( @@ -46,7 +38,5 @@ ponder.on("ComposableCow:setup", async ({ context }) => { ) as { count: number }[]; const count = result[0]?.count ?? 0; - console.log( - `[COW:SETUP] cow_cache.order_uid_cache ready — ${count} entr${count === 1 ? "y" : "ies"} from previous run`, - ); + log("info", "setup:cacheReady", { count, entries: `${count} entr${count === 1 ? "y" : "ies"} from previous run` }); }); diff --git a/src/application/helpers/logger.ts b/src/application/helpers/logger.ts new file mode 100644 index 0000000..272c2a5 --- /dev/null +++ b/src/application/helpers/logger.ts @@ -0,0 +1,16 @@ +// Structured JSON logger for handler code — always emits one JSON line per call regardless of Ponder's --log-format setting. + +type LogLevel = "info" | "warn" | "error"; + +export function log( + level: LogLevel, + msg: string, + fields: Record = {}, +): void { + const line = JSON.stringify({ time: Date.now(), level, msg, ...fields }); + if (level === "warn" || level === "error") { + console.error(line); + } else { + console.log(line); + } +} diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 5ef7b2d..0b99df5 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -18,12 +18,15 @@ import { conditionalOrderGenerator, discreteOrder, } from "ponder:schema"; -import { and, eq, sql } from "ponder"; +import { and, eq, inArray } from "ponder"; +import { pgSchema, integer, text } from "drizzle-orm/pg-core"; import { encodeAbiParameters, keccak256, type Hex } from "viem"; +import { type OrderType } from "../../utils/order-types"; import { COMPOSABLE_COW_HANDLER_ADDRESSES, ORDERBOOK_API_URLS } from "../../data"; import { ORDERBOOK_HTTP_TIMEOUT_MS, SIGNING_SCHEME_EIP1271 } from "../../constants"; import { decodeEip1271Signature } from "../decoders/erc1271Signature"; import { fetchWithTimeout, TimeoutError, withTimeout } from "./withTimeout"; +import { log } from "./logger"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -52,7 +55,7 @@ export type ComposableOrder = Pick< uid: string; generatorId: string; generatorHash: string; - orderType: string; + orderType: OrderType; creationDate: number; }; @@ -85,16 +88,16 @@ export async function fetchComposableOrders( ): Promise { const apiBaseUrl = ORDERBOOK_API_URLS[chainId]; if (!apiBaseUrl) { - console.warn(`[COW:OB] No API URL for chainId=${chainId}`); + log("warn", "ob:noApiUrl", { chainId }); return []; } - console.log(`[COW:OB] FETCH owner=${owner} chain=${chainId}`); + log("info", "ob:fetch", { owner, chainId }); const allApiOrders = await fetchAccountOrders(apiBaseUrl, owner); const composable = await filterAndProcess(context, chainId, allApiOrders); if (composable.length === 0) { - console.log(`[COW:OB] owner=${owner} chain=${chainId} apiTotal=${allApiOrders.length} composable=0`); + log("info", "ob:fetchResult", { owner, chainId, apiTotal: allApiOrders.length, composable: 0 }); return []; } @@ -139,9 +142,7 @@ export async function fetchComposableOrders( } } - console.log( - `[COW:OB] owner=${owner} chain=${chainId} apiTotal=${allApiOrders.length} composable=${composable.length} cached=${composable.length - toRefresh.length} refreshed=${toRefresh.length}`, - ); + log("info", "ob:fetchResult", { owner, chainId, apiTotal: allApiOrders.length, composable: composable.length, cached: composable.length - toRefresh.length, refreshed: toRefresh.length }); return results; } @@ -252,9 +253,7 @@ export async function fetchOrderStatusByUids( ); } catch (err) { if (err instanceof TimeoutError) { - console.warn( - `[COW:OB] statusByUids timeout chain=${chainId} toFetch=${toFetch.length} after=${ORDERBOOK_HTTP_TIMEOUT_MS * 2}ms`, - ); + log("warn", "ob:statusByUidsTimeout", { chainId, toFetch: toFetch.length, after: ORDERBOOK_HTTP_TIMEOUT_MS * 2 }); return result; // cache-only map — caller treats missing UIDs as "not on API yet" } throw err; @@ -274,7 +273,7 @@ export async function fetchOrderStatusByUids( status: order.status as ComposableOrder["status"], generatorId: "", generatorHash: "", - orderType: "", + orderType: "Unknown", sellAmount: order.sellAmount, buyAmount: order.buyAmount, feeAmount: order.feeAmount, @@ -294,15 +293,42 @@ export async function fetchOrderStatusByUids( return result; } +/** + * Fallback status lookup via GET /account/{owner}/orders. + * Used when /orders/by_uids returns nothing for UIDs that may have aged out + * of the API's retention window (e.g. TWAP parts near or past validTo). + * Returns a Map of uid -> OrderStatusInfo for all orders found for this owner. + */ +export async function fetchOwnerOrderStatuses( + chainId: number, + owner: Hex, + maxPages = 3, +): Promise> { + const result = new Map(); + const apiBaseUrl = ORDERBOOK_API_URLS[chainId]; + if (!apiBaseUrl) return result; + const orders = await fetchAccountOrders(apiBaseUrl, owner, maxPages); + for (const order of orders) { + result.set(order.uid, { + status: order.status, + executedSellAmount: order.executedSellAmount, + executedBuyAmount: order.executedBuyAmount, + }); + } + return result; +} + // ─── API calls ─────────────────────────────────────────────────────────────── -/** Fetch all orders for an owner with pagination. */ +/** Fetch orders for an owner with pagination. maxPages limits how many pages are fetched (0 = unlimited). */ async function fetchAccountOrders( apiBaseUrl: string, owner: Hex, + maxPages = 0, ): Promise { const allOrders: OrderbookOrder[] = []; let offset = 0; + let pagesFetched = 0; // eslint-disable-next-line no-constant-condition while (true) { @@ -315,21 +341,21 @@ async function fetchAccountOrders( "ob:account", ); if (!response.ok) { - console.warn(`[COW:OB] API ${response.status} owner=${owner}`); + log("warn", "ob:accountError", { status: response.status, owner }); break; } const page = (await response.json()) as OrderbookOrder[]; allOrders.push(...page); + pagesFetched++; if (page.length < PAGE_LIMIT) break; // last page + if (maxPages > 0 && pagesFetched >= maxPages) break; // page cap reached offset += page.length; } catch (err) { if (err instanceof TimeoutError) { - console.warn( - `[COW:OB] Account fetch timeout owner=${owner} offset=${offset} after=${ORDERBOOK_HTTP_TIMEOUT_MS}ms`, - ); + log("warn", "ob:accountTimeout", { owner, offset, after: ORDERBOOK_HTTP_TIMEOUT_MS }); break; } - console.warn(`[COW:OB] Fetch failed owner=${owner} err=${err}`); + log("warn", "ob:accountFetchFailed", { owner, err: String(err) }); break; } } @@ -361,19 +387,17 @@ async function fetchOrdersByUids( "ob:byUids", ); if (!response.ok) { - console.warn(`[COW:OB] Batch fetch ${response.status} uids=${chunk.length} offset=${i}`); + log("warn", "ob:batchFetchError", { status: response.status, uids: chunk.length, offset: i }); continue; } - const batch = (await response.json()) as OrderbookOrder[]; - results.push(...batch); + const raw = (await response.json()) as { order: OrderbookOrder }[]; + results.push(...raw.map((item) => item.order)); } catch (err) { if (err instanceof TimeoutError) { - console.warn( - `[COW:OB] Batch fetch timeout uids=${chunk.length} offset=${i} after=${ORDERBOOK_HTTP_TIMEOUT_MS}ms`, - ); + log("warn", "ob:batchFetchTimeout", { uids: chunk.length, offset: i, after: ORDERBOOK_HTTP_TIMEOUT_MS }); continue; } - console.warn(`[COW:OB] Batch fetch failed err=${err} offset=${i}`); + log("warn", "ob:batchFetchFailed", { err: String(err), offset: i }); } } @@ -432,7 +456,7 @@ async function filterAndProcess( ) .limit(1)) as { eventId: string; - orderType: string; + orderType: OrderType; }[]; if (generators.length === 0) continue; @@ -459,7 +483,16 @@ async function filterAndProcess( } // ─── Per-UID cache helpers ────────────────────────────────────────────────── -// cow_cache.order_uid_cache is created by setup.ts. Fully qualified names required. +// cow_cache.order_uid_cache is created by setup.ts. Table defined here for typed queries. +const cowCacheSchema = pgSchema("cow_cache"); +const orderUidCache = cowCacheSchema.table("order_uid_cache", { + chainId: integer("chain_id").notNull(), + orderUid: text("order_uid").notNull(), + status: text("status").notNull(), + fetchedAt: integer("fetched_at").notNull(), + executedSellAmount: text("executed_sell_amount"), + executedBuyAmount: text("executed_buy_amount"), +}); /** Cached order data returned by getCachedUidStatuses. */ interface CachedOrderData { @@ -483,19 +516,25 @@ async function getCachedUidStatuses( const batchSize = 500; for (let i = 0; i < uids.length; i += batchSize) { const batch = uids.slice(i, i + batchSize); - const placeholders = batch.map((uid) => `'${uid.replace(/'/g, "''")}'`).join(","); - const rows = (await context.db.sql.execute( - sql.raw( - `SELECT order_uid, status, executed_sell_amount, executed_buy_amount - FROM cow_cache.order_uid_cache - WHERE chain_id = ${chainId} AND order_uid IN (${placeholders})`, - ), - )) as { order_uid: string; status: string; executed_sell_amount: string | null; executed_buy_amount: string | null }[]; + const rows = await context.db.sql + .select({ + orderUid: orderUidCache.orderUid, + status: orderUidCache.status, + executedSellAmount: orderUidCache.executedSellAmount, + executedBuyAmount: orderUidCache.executedBuyAmount, + }) + .from(orderUidCache) + .where( + and( + eq(orderUidCache.chainId, chainId), + inArray(orderUidCache.orderUid, batch), + ), + ); for (const row of rows) { - result.set(row.order_uid, { + result.set(row.orderUid, { status: row.status, - executedSellAmount: row.executed_sell_amount, - executedBuyAmount: row.executed_buy_amount, + executedSellAmount: row.executedSellAmount, + executedBuyAmount: row.executedBuyAmount, }); } } @@ -516,17 +555,25 @@ async function cacheUidStatuses( const now = Math.floor(Date.now() / 1000); for (const order of orders) { try { - await context.db.sql.execute( - sql`INSERT INTO cow_cache.order_uid_cache - (chain_id, order_uid, status, fetched_at, executed_sell_amount, executed_buy_amount) - VALUES (${chainId}, ${order.uid}, ${order.status}, ${now}, - ${order.executedSellAmount}, ${order.executedBuyAmount}) - ON CONFLICT (chain_id, order_uid) DO UPDATE SET - status = EXCLUDED.status, - fetched_at = EXCLUDED.fetched_at, - executed_sell_amount = EXCLUDED.executed_sell_amount, - executed_buy_amount = EXCLUDED.executed_buy_amount`, - ); + await context.db.sql + .insert(orderUidCache) + .values({ + chainId, + orderUid: order.uid, + status: order.status, + fetchedAt: now, + executedSellAmount: order.executedSellAmount, + executedBuyAmount: order.executedBuyAmount, + }) + .onConflictDoUpdate({ + target: [orderUidCache.chainId, orderUidCache.orderUid], + set: { + status: order.status, + fetchedAt: now, + executedSellAmount: order.executedSellAmount, + executedBuyAmount: order.executedBuyAmount, + }, + }); } catch { // Best-effort cache write } diff --git a/src/application/helpers/uidPrecompute.ts b/src/application/helpers/uidPrecompute.ts index 80ca37e..16a3644 100644 --- a/src/application/helpers/uidPrecompute.ts +++ b/src/application/helpers/uidPrecompute.ts @@ -20,7 +20,8 @@ import { and, eq } from "ponder"; import { candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema"; import { computeOrderUid, type GPv2OrderData } from "./orderUid"; import { fetchOrderStatusByUids } from "./orderbookClient"; -import { isDeterministicOrderType } from "../../utils/order-types"; +import { type OrderType, DETERMINISTIC_ORDER_TYPE } from "../../utils/order-types"; +import { log } from "./logger"; // GPv2Order.sol constant hashes const KIND_SELL = "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775" as Hex; @@ -55,13 +56,13 @@ export interface PrecomputedOrder { export function precomputeOrderUids( chainId: number, owner: Hex, - orderType: string, + orderType: OrderType, decodedParams: Record | null, blockTimestamp: bigint, ): PrecomputedOrder[] | null { if (!decodedParams) { - if (isDeterministicOrderType(orderType)) { - console.warn(`[COW:PRECOMPUTE] SKIP type=${orderType} owner=${owner} chain=${chainId} reason=decodedParams_null`); + if (DETERMINISTIC_ORDER_TYPE[orderType]) { + log("warn", "precompute:skip", { orderType, owner, chainId, reason: "decodedParams_null" }); } return null; } @@ -93,7 +94,7 @@ export async function precomputeAndDiscover( chainId: number, generatorEventId: string, owner: Hex, - orderType: string, + orderType: OrderType, decodedParams: Record | null, blockTimestamp: bigint, ): Promise { @@ -167,9 +168,7 @@ export async function precomputeAndDiscover( eq(conditionalOrderGenerator.eventId, generatorEventId), ), ); - console.log( - `[ComposableCow] All ${precomputed.length} pre-computed orders terminal on API — generator=${generatorEventId} marked Completed`, - ); + log("info", "precompute:allTerminal", { count: precomputed.length, generatorEventId }); return true; } @@ -221,7 +220,7 @@ function precomputeTwapUids( const appData = params["appData"] as Hex | undefined; if (!sellToken || !buyToken || !partSellAmount || !minPartLimit || !n || !t || !appData) { - console.warn(`[COW:PRECOMPUTE] SKIP type=TWAP owner=${owner} chain=${chainId} reason=missing_params missing=${[!sellToken && "sellToken", !buyToken && "buyToken", !partSellAmount && "partSellAmount", !minPartLimit && "minPartLimit", !n && "n", !t && "t", !appData && "appData"].filter(Boolean).join(",")}`); + log("warn", "precompute:skip", { orderType: "TWAP", owner, chainId, reason: "missing_params", missing: [!sellToken && "sellToken", !buyToken && "buyToken", !partSellAmount && "partSellAmount", !minPartLimit && "minPartLimit", !n && "n", !t && "t", !appData && "appData"].filter(Boolean).join(",") }); return null; } @@ -232,11 +231,11 @@ function precomputeTwapUids( const t0 = BigInt(t0Raw ?? "0") === 0n ? blockTimestamp : BigInt(t0Raw!); if (nParts <= 0 || tSeconds <= 0n) { - console.warn(`[COW:PRECOMPUTE] SKIP type=TWAP owner=${owner} chain=${chainId} reason=invalid_math nParts=${nParts} tSeconds=${tSeconds}`); + log("warn", "precompute:skip", { orderType: "TWAP", owner, chainId, reason: "invalid_math", nParts, tSeconds: String(tSeconds) }); return null; } if (nParts > 100000) { - console.warn(`[COW:PRECOMPUTE] SKIP type=TWAP owner=${owner} chain=${chainId} reason=too_many_parts nParts=${nParts}`); + log("warn", "precompute:skip", { orderType: "TWAP", owner, chainId, reason: "too_many_parts", nParts }); return null; } @@ -318,7 +317,7 @@ function precomputeStopLossUid( const validTo = params["validTo"]; if (!sellToken || !buyToken || !sellAmount || !buyAmount || !appData || !validTo) { - console.warn(`[COW:PRECOMPUTE] SKIP type=StopLoss owner=${owner} chain=${chainId} reason=missing_params missing=${[!sellToken && "sellToken", !buyToken && "buyToken", !sellAmount && "sellAmount", !buyAmount && "buyAmount", !appData && "appData", !validTo && "validTo"].filter(Boolean).join(",")}`); + log("warn", "precompute:skip", { orderType: "StopLoss", owner, chainId, reason: "missing_params", missing: [!sellToken && "sellToken", !buyToken && "buyToken", !sellAmount && "sellAmount", !buyAmount && "buyAmount", !appData && "appData", !validTo && "validTo"].filter(Boolean).join(",") }); return null; } diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts new file mode 100644 index 0000000..f4b0d0d --- /dev/null +++ b/src/chains/arbitrum.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 1; // ~0.25s avg; use 1s as a conservative estimate for polling math + +export const arbitrum: ChainConfig = { + name: "arbitrum", + chainId: SupportedChainId.ARBITRUM_ONE, + rpcEnvVar: "ARBITRUM_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 204751436, // verified: tx 0xede8f4305385f5df63d5221d1377380724c11781000b30a29cf636241abaa59f (cowprotocol/composable-cow networks.json + arbiscan) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 358667546, // verified: tx 0x97b8fa7baf78bca1836e6a7cdce3bd0b983fa96352fc168ebf5f24ba63f23a91 + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 400913741, // AaveV3AdapterFactory deployment block on Arbitrum + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // verified: ROUTER() on Arbitrum AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", // CREATE2 — same across chains + orderbookApiPath: "arbitrum_one", +}; diff --git a/src/chains/avalanche.ts b/src/chains/avalanche.ts new file mode 100644 index 0000000..fbdb127 --- /dev/null +++ b/src/chains/avalanche.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 2; // ~2s per block on Avalanche C-Chain + +export const avalanche: ChainConfig = { + name: "avalanche", + chainId: SupportedChainId.AVALANCHE, + rpcEnvVar: "AVALANCHE_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 60434336, // verified: tx 0xaa800a7183e8313e11a0024a8fe189770c33aaf8fc1451a3a5c373898e25fefa (snowscan.xyz, 2025-04-17) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 65617025, // verified: tx 0xcf5f0c9a40d26d09e497a6ce871df31ca13d8e72b1724d8ba015368cf36068f1 + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 72063515, // AaveV3AdapterFactory deployment block on Avalanche + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // verified: ROUTER() on Avalanche AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", // CREATE2 — same across chains + orderbookApiPath: "avalanche", // TODO: verify CoW Protocol orderbook URL for Avalanche +}; diff --git a/src/chains/base.ts b/src/chains/base.ts new file mode 100644 index 0000000..ee24034 --- /dev/null +++ b/src/chains/base.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 2; // ~2s per block on Base + +export const base: ChainConfig = { + name: "base", + chainId: SupportedChainId.BASE, + rpcEnvVar: "BASE_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 21794150, // verified: tx 0xdfa9fded3b1743ce2556a245b17690b073cdd9d59739b60d5e4091e445d732b7 (basescan, 2024-10-31) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 32986811, // verified: tx 0x29c63a32edbf7b29ae73e6a8e5293b65c65906943ad41092205a473e4bbf56d0 + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 38260337, // AaveV3AdapterFactory deployment block on Base + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // verified: ROUTER() on Base AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", // CREATE2 — same across chains + orderbookApiPath: "base", +}; diff --git a/src/chains/bnb.ts b/src/chains/bnb.ts new file mode 100644 index 0000000..ffdedcb --- /dev/null +++ b/src/chains/bnb.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 3; // ~3s per block on BNB Chain + +export const bnb: ChainConfig = { + name: "bnb", + chainId: SupportedChainId.BNB, + rpcEnvVar: "BNB_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 48433175, // verified: tx 0x6595bc3c236157c5a164eb37267486b3c2f6eee02d2e6d9068550e939b18ed71 (cowprotocol/composable-cow networks.json + bscscan.com, 2025-04-17) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 61362362, // verified: tx 0x76d25671fd1c31044a6cf481df15649fc3503cf5a492de92be8601fee02e259f + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 68412820, // AaveV3AdapterFactory deployment block on BNB + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // verified: ROUTER() on BNB AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", // CREATE2 — same across chains + orderbookApiPath: "bnb", // TODO: verify CoW Protocol orderbook URL for BNB +}; diff --git a/src/chains/gnosis.ts b/src/chains/gnosis.ts new file mode 100644 index 0000000..cfbdc18 --- /dev/null +++ b/src/chains/gnosis.ts @@ -0,0 +1,32 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 5; + +export const gnosis: ChainConfig = { + name: "gnosis", + chainId: SupportedChainId.GNOSIS_CHAIN, + rpcEnvVar: "GNOSIS_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + startBlock: 29389123, + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: [ + "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", // current (CoWShedForComposableCoW) + "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // legacy (COWShed); 2 historical events + ] as const, + startBlock: 41469991, // earliest COWShedBuilt from either factory on Gnosis + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 43177077, // AaveV3AdapterFactory deployment block on Gnosis + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // confirmed via ROUTER() on Gnosis AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", // verified on Gnosisscan + orderbookApiPath: "xdai", +}; diff --git a/src/chains/index.ts b/src/chains/index.ts new file mode 100644 index 0000000..fcc50b1 --- /dev/null +++ b/src/chains/index.ts @@ -0,0 +1,59 @@ +export type { ChainConfig, SupportedChainId } from "./types"; + +import { mainnet } from "./mainnet"; +import { gnosis } from "./gnosis"; +import { arbitrum } from "./arbitrum"; +import { base } from "./base"; +import { sepolia } from "./sepolia"; +import { bnb } from "./bnb"; +import { polygon } from "./polygon"; +import { lens } from "./lens"; +import { plasma } from "./plasma"; +import { avalanche } from "./avalanche"; +import { ink } from "./ink"; +import { linea } from "./linea"; + +/** + * ALL_DEFINED_CHAINS — one entry per chain in cow-sdk's ALL_SUPPORTED_CHAIN_IDS. + * + * When cow-sdk adds a new chain to ALL_SUPPORTED_CHAIN_IDS, add a corresponding + * src/chains/.ts here. Populate contract addresses from the block explorer + * (ComposableCow is CREATE2 at 0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74; + * CoWShedFactory and AaveV3AdapterFactory addresses vary per chain and must be verified). + */ +export const ALL_DEFINED_CHAINS = [ + mainnet, + gnosis, + arbitrum, + base, + sepolia, + bnb, + polygon, + lens, + plasma, + avalanche, + ink, + linea, +]; + +/** + * ACTIVE_CHAINS — the chains this indexer instance actually processes. + * + * To enable a chain: add it here and supply its RPC URL env var in docker-compose.yml + * and the deployment .env file. To disable: remove it from this array. + * ponder.config.ts derives all RPC/contract config from this array. + */ +export const ACTIVE_CHAINS = [ + mainnet, + gnosis, + // arbitrum, // fully verified — enable when ARBITRUM_RPC_URL is provisioned + // base, // fully verified — enable when BASE_RPC_URL is provisioned + // bnb, // fully verified — enable when BNB_RPC_URL is provisioned + // polygon, // fully verified — enable when POLYGON_RPC_URL is provisioned + // avalanche,// fully verified — enable when AVALANCHE_RPC_URL is provisioned + // linea, // fully verified — enable when LINEA_RPC_URL is provisioned + // plasma, // fully verified — enable when PLASMA_RPC_URL is provisioned + // lens, // enable when LENS_RPC_URL is provisioned; aaveV3AdapterFactory=null (no flash loan detection); orderbook not live yet + // sepolia, // enable when SEPOLIA_RPC_URL is provisioned; aaveV3AdapterFactory=null (no flash loan detection) + // ink, // enable when INK_RPC_URL is provisioned; cowShedFactory=null (no CoWShed indexing); aaveV3AdapterFactory=null (no flash loan detection) +]; diff --git a/src/chains/ink.ts b/src/chains/ink.ts new file mode 100644 index 0000000..4e76142 --- /dev/null +++ b/src/chains/ink.ts @@ -0,0 +1,23 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 2; // ~2s per block on Ink Chain (OP-based L2) + +export const ink: ChainConfig = { + name: "ink", + chainId: SupportedChainId.INK, + rpcEnvVar: "INK_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 34878187, // verified: tx 0xf21049cccc6ea17370e6d3650e689cf3c5be0a097a035953501218a14b8f030f (explorer.inkonchain.com Blockscout API + rpc-gel.inkonchain.com) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Ink + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Ink + flashLoanRouter: null, // TODO: confirm via ROUTER() on Ink AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on explorer.inkonchain.com + orderbookApiPath: "ink", // TODO: verify CoW Protocol orderbook URL for Ink +}; diff --git a/src/chains/lens.ts b/src/chains/lens.ts new file mode 100644 index 0000000..8501f3b --- /dev/null +++ b/src/chains/lens.ts @@ -0,0 +1,26 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 1; // ~1s per block on Lens Chain (zkSync-based L2) + +export const lens: ChainConfig = { + name: "lens", + chainId: SupportedChainId.LENS, + rpcEnvVar: "LENS_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 3516559, // verified: tx 0x39105403b3b7ee84959807135fbebb1bba1de86f85916295d99ff69617c15ae0 (cowprotocol/composable-cow networks.json + rpc.lens.xyz, 2025-09) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 3519249, // verified: tx 0x53df62bc122ecb5bfa9770776bb54b3a81e5f7238e4b02c52ac7000eb36c86bd + }, + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Lens + flashLoanRouter: null, // TODO: confirm via ROUTER() on Lens AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on explorer.lens.xyz + orderbookApiPath: "lens", // NOTE: api.cow.fi/lens returns 404 — CoW Protocol has not launched orderbook support for Lens yet +}; diff --git a/src/chains/linea.ts b/src/chains/linea.ts new file mode 100644 index 0000000..a2ade34 --- /dev/null +++ b/src/chains/linea.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 3; // ~3s per block on Linea + +export const linea: ChainConfig = { + name: "linea", + chainId: SupportedChainId.LINEA, + rpcEnvVar: "LINEA_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 25028474, // verified: tx 0x61f2e7ecec07f7b5c93d491f460cca41eba991fbb022f6866ee17510c9e61151 (cowprotocol/composable-cow networks.json + lineascan.build) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 25033271, // verified: tx 0xad527499a510773fed02f46787d8ed9190d52fe40997c661353805e2bc056a65 + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 26288706, // AaveV3AdapterFactory deployment block on Linea + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // verified: ROUTER() on Linea AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", // CREATE2 — same across chains + orderbookApiPath: "linea", // TODO: verify CoW Protocol orderbook URL for Linea +}; diff --git a/src/chains/mainnet.ts b/src/chains/mainnet.ts new file mode 100644 index 0000000..58a5b25 --- /dev/null +++ b/src/chains/mainnet.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 12; + +export const mainnet: ChainConfig = { + name: "mainnet", + chainId: SupportedChainId.MAINNET, + rpcEnvVar: "MAINNET_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + startBlock: 17883049, + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", + startBlock: 22939254, + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 23812751, // AaveV3AdapterFactory deployment block (Nov 16, 2025) + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", + aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", + orderbookApiPath: "mainnet", +}; diff --git a/src/chains/plasma.ts b/src/chains/plasma.ts new file mode 100644 index 0000000..3967640 --- /dev/null +++ b/src/chains/plasma.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 1; // ~1s per block on Plasma (L2) + +export const plasma: ChainConfig = { + name: "plasma", + chainId: SupportedChainId.PLASMA, + rpcEnvVar: "PLASMA_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 4810535, // verified: tx 0xa4db8e5f949f39af60460fc05979b363b01570970e94eb8397dc39cfbdcaed86 (cowprotocol/composable-cow networks.json + rpc.plasma.to) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 4803028, // verified: tx 0x33d7ed32d433467d75373baf0bcbc99fec65df8a8fd6f67673efa8378f67ebcc + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 7830693, // AaveV3AdapterFactory deployment block on Plasma + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // verified: ROUTER() on Plasma AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", // CREATE2 — same across chains + orderbookApiPath: "plasma", // TODO: verify CoW Protocol orderbook URL for Plasma +}; diff --git a/src/chains/polygon.ts b/src/chains/polygon.ts new file mode 100644 index 0000000..6ccec47 --- /dev/null +++ b/src/chains/polygon.ts @@ -0,0 +1,29 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 2; // ~2s per block on Polygon + +export const polygon: ChainConfig = { + name: "polygon", + chainId: SupportedChainId.POLYGON, + rpcEnvVar: "POLYGON_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 70406888, // verified: tx 0xef1fdc60092220b9137d2b23189499d995119c281cad648710ac3636bbebf17a (polygonscan.com, 2025-04-17) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 74072686, // verified: tx 0x9d877eaa06776c30a409fc31db365e8441f982598586345d53ffaee4f9d2da6d + }, + gpv2Settlement: { + address: "0x9008D19f58AAbD9eD0D60971565AA8510560ab41", + startBlock: 79103055, // AaveV3AdapterFactory deployment block on Polygon + }, + flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // verified: ROUTER() on Polygon AaveV3AdapterFactory + aaveV3AdapterFactory: "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", // CREATE2 — same across chains + orderbookApiPath: "polygon", // TODO: verify CoW Protocol orderbook URL for Polygon +}; diff --git a/src/chains/sepolia.ts b/src/chains/sepolia.ts new file mode 100644 index 0000000..d5b338e --- /dev/null +++ b/src/chains/sepolia.ts @@ -0,0 +1,26 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { type ChainConfig } from "./types"; + +const blockTime = 12; // ~12s per block on Sepolia (same as mainnet) + +export const sepolia: ChainConfig = { + name: "sepolia", + chainId: SupportedChainId.SEPOLIA, + rpcEnvVar: "SEPOLIA_RPC_URL", + blockTime, + composableCow: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains + startBlock: 5072748, // verified: tx 0xed9625240dec4803ea76358bcac3d4c8678b81a6ffddd50c0326c12626d3f38e (cowprotocol/composable-cow networks.json + sepolia.etherscan.io, 2024-01-12) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: { + address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains + startBlock: 8784028, // verified: tx 0x4d42972f24fa0846523513e7733b1d2238d0a709c3e4a2cd415cc64885bd1762 + }, + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Sepolia + flashLoanRouter: null, // TODO: confirm via ROUTER() on Sepolia AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on Sepolia + orderbookApiPath: "sepolia", +}; diff --git a/src/chains/types.ts b/src/chains/types.ts new file mode 100644 index 0000000..5d0b480 --- /dev/null +++ b/src/chains/types.ts @@ -0,0 +1,70 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; + +export type { SupportedChainId }; + +/** + * Derive the ContractPoller block interval from block time so that polls + * happen approximately every 20 seconds regardless of chain speed. + * e.g. mainnet (12s) → 1 block, gnosis (5s) → 4 blocks, base (2s) → 10 blocks. + */ +export function pollerInterval(blockTimeSeconds: number): number { + return Math.max(1, Math.floor(20 / blockTimeSeconds)); +} + +/** + * ChainConfig — everything needed to configure one chain in ponder.config.ts + * and derive runtime constants (block time, RPC URL, API URL, etc.). + * + * Add a new chain by: + * 1. Creating src/chains/.ts implementing this interface. + * 2. Importing and appending it to ACTIVE_CHAINS in src/chains/index.ts. + */ +export interface ChainConfig { + /** Ponder chain key (e.g. "mainnet", "gnosis"). Must match ponder chain names. */ + name: string; + /** EIP-155 chain ID — must be a value from the cow-sdk SupportedChainId enum. */ + chainId: SupportedChainId; + /** Environment variable name holding the RPC URL for this chain. */ + rpcEnvVar: string; + /** Approximate block time in seconds — used to estimate block numbers from epoch timestamps. */ + blockTime: number; + + /** ComposableCoW CREATE2 deployment on this chain. */ + composableCow: { address: `0x${string}`; startBlock: number }; + /** ComposableCowLive — same address, always starts at "latest" for live event monitoring. */ + composableCowLive: { address: `0x${string}` }; + + /** + * CoWShedFactory deployment(s) on this chain. + * Gnosis has two factory addresses (current + legacy), so address may be an array. + * Null when the factory address hasn't been confirmed for this chain yet. + */ + cowShedFactory: { + address: `0x${string}` | readonly `0x${string}`[]; + startBlock: number; + } | null; + + /** + * GPv2Settlement deployment — null if not indexed on this chain. + * Currently only indexed where AaveV3AdapterFactory is deployed. + */ + gpv2Settlement: { address: `0x${string}`; startBlock: number } | null; + + /** + * FlashLoanRouter address — used to filter GPv2Settlement:Settlement events. + * Null if GPv2Settlement is not indexed on this chain. + */ + flashLoanRouter: `0x${string}` | null; + + /** + * AaveV3AdapterFactory address — used for view calls (not a Ponder-indexed contract). + * Null if not deployed/confirmed on this chain. + */ + aaveV3AdapterFactory: `0x${string}` | null; + + /** + * CoW Protocol Orderbook API path for this chain (the part after https://api.cow.fi/). + * e.g. "mainnet", "xdai", "arbitrum_one". Combined with the base URL at call sites. + */ + orderbookApiPath: string; +} diff --git a/src/constants.ts b/src/constants.ts index b427256..51c17d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,7 +19,7 @@ export const RECHECK_INTERVAL = BigInt(ORDERBOOK_POLL_INTERVAL); export const SIGNING_SCHEME_EIP1271 = "eip1271"; /** - * COW-908: Hard per-block ceiling on how many generators the C1 ContractPoller + * Hard per-block ceiling on how many generators the OrderDiscoveryPoller * will multicall in a single block. Generators exceeding the cap defer to the * next block (prioritized by oldest lastCheckBlock first). * @@ -47,13 +47,13 @@ export const TRY_NEXT_BLOCK_BACKOFF_MID = 10n; export const TRY_NEXT_BLOCK_BACKOFF_COLD = 50n; /** - * C5 (DeterministicCancellationSweeper) re-check cadence, in blocks. + * CancellationWatcher re-check cadence, in blocks. * * For deterministic generators (`allCandidatesKnown = true`), `remove()` detection * is via a `ComposableCoW.singleOrders(owner, hash)` storage read. `remove()` is * rare; a ~100 block cadence gives a worst-case detection lag of ~20 min on - * mainnet and ~8 min on Gnosis while keeping the RPC cost well below C1's - * every-block poll. + * mainnet and ~8 min on Gnosis while keeping the RPC cost well below + * OrderDiscoveryPoller's every-block poll. */ export const DETERMINISTIC_CANCEL_SWEEP_INTERVAL = 100n; @@ -67,14 +67,18 @@ export const ORDERBOOK_HTTP_TIMEOUT_MS = 10_000; /** * Hard wall-clock cap for a block handler's aggregate `context.client.multicall` - * call (C1, C5). viem has no per-call signal; the timer races the promise and + * call (OrderDiscoveryPoller, CancellationWatcher). viem has no per-call signal; the timer races the promise and * the handler returns cleanly on breach. */ export const BLOCK_HANDLER_RPC_TIMEOUT_MS = 15_000; +// Tighter cap for cheap inner-loop calls (getCode, eth_call) in the settlement handler. +// The outer receipt fetch and readContract(owner()) keep the full 15 s. +export const SETTLEMENT_INNER_RPC_TIMEOUT_MS = 5_000; + /** - * Hard wall-clock cap for the whole per-owner bootstrap fetch in C4 + * Hard wall-clock cap for the whole per-owner bootstrap fetch in OwnerBackfill * (account pagination + by_uids refresh). Owners that exceed this are skipped; - * the normal C1 / C2 path picks them up on subsequent blocks. + * the normal OrderDiscoveryPoller / CandidateConfirmer path picks them up on subsequent blocks. */ export const BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS = 30_000; diff --git a/src/data.ts b/src/data.ts index 4d7bf76..4063929 100644 --- a/src/data.ts +++ b/src/data.ts @@ -1,123 +1,12 @@ -import { ComposableCowAbi } from "../abis/ComposableCowAbi"; -import { CoWShedFactoryAbi } from "../abis/CoWShedFactoryAbi"; -import { GPv2SettlementAbi } from "../abis/GPv2SettlementAbi"; import { ALL_HANDLER_ADDRESSES } from "./utils/order-types"; +import { ACTIVE_CHAINS, ALL_DEFINED_CHAINS } from "./chains"; +import { SupportedChainId } from "@cowprotocol/cow-sdk"; -/** - * Supported chain IDs — update this type when adding a new chain. - * All per-chain Record<> maps below use this type to enforce completeness. - */ -export type SupportedChainId = 1 | 100; +export { SupportedChainId }; // CREATE2-deployed contracts share the same address across chains -const COMPOSABLE_COW_ADDRESS = - "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74" as const; export const GPV2_SETTLEMENT_ADDRESS = "0x9008D19f58AAbD9eD0D60971565AA8510560ab41" as const; -const AAVE_V3_ADAPTER_FACTORY_ADDRESS = - "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192" as const; -const FLASH_LOAN_ROUTER_ADDRESS = - "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69" as const; - -/** - * ComposableCoW contract configuration per chain. - * Mainnet only for M1. Add gnosis/arbitrum in a future task. - */ -export const COMPOSABLE_COW_DEPLOYMENTS = { - mainnet: { - address: COMPOSABLE_COW_ADDRESS, - startBlock: 17883049, - // No endBlock — index continuously - }, - gnosis: { - address: COMPOSABLE_COW_ADDRESS, - startBlock: 29389123, - }, - // arbitrum: { address: COMPOSABLE_COW_ADDRESS, startBlock: ... }, // TODO: add Arbitrum support -} as const; - -export const ComposableCowContract = { - abi: ComposableCowAbi, - chain: { - mainnet: COMPOSABLE_COW_DEPLOYMENTS.mainnet, - gnosis: COMPOSABLE_COW_DEPLOYMENTS.gnosis, - }, -} as const; - -/** - * CoWShedFactory — emits COWShedBuilt (user, shed). - * - * Gnosis has two factory deployments with the same ABI: - * - 0x4f4350bf... (current) — deploys CoWShedForComposableCoW proxies - * - 0x312f92fe... (legacy) — deploys standard COWShed proxies (2 historical events) - * Both are indexed via a single Ponder contract entry using an address array. - */ -export const COW_SHED_FACTORY_DEPLOYMENTS = { - mainnet: { - address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86" as const, - startBlock: 22939254, - }, - gnosis: { - address: [ - "0x4f4350bf2c74aacd508d598a1ba94ef84378793d", // current (CoWShedForComposableCoW) - "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // legacy (COWShed); 2 historical events - ] as const, - startBlock: 41469991, // earliest COWShedBuilt from either factory on Gnosis - }, -} as const; - -export const CoWShedFactoryContract = { - abi: CoWShedFactoryAbi, - chain: { - mainnet: COW_SHED_FACTORY_DEPLOYMENTS.mainnet, - gnosis: COW_SHED_FACTORY_DEPLOYMENTS.gnosis, - }, -} as const; - -/** - * GPv2Settlement — mainnet only. - * - * Start block = AaveV3AdapterFactory deployment block, NOT ComposableCoW genesis. - */ -export const GPV2_SETTLEMENT_DEPLOYMENTS = { - mainnet: { - address: GPV2_SETTLEMENT_ADDRESS, - startBlock: 23812751, // AaveV3AdapterFactory deployment block (Nov 16, 2025) - }, - gnosis: { - address: GPV2_SETTLEMENT_ADDRESS, - startBlock: 43177077, // AaveV3AdapterFactory deployment block on Gnosis - }, -} as const; - -export const GPv2SettlementContract = { - abi: GPv2SettlementAbi, - chain: { - mainnet: GPV2_SETTLEMENT_DEPLOYMENTS.mainnet, - }, -} as const; - -/** - * AaveV3AdapterFactory — deploys per-user flash loan adapter proxies. - * Detection: call FACTORY() on a contract; if it returns this address, it is an Aave adapter. - * Not a Ponder-indexed contract — used for view calls only. - */ -export const AAVE_V3_ADAPTER_FACTORY_ADDRESSES = { - mainnet: AAVE_V3_ADAPTER_FACTORY_ADDRESS, - gnosis: AAVE_V3_ADAPTER_FACTORY_ADDRESS, // verified on Gnosisscan - // arbitrum: "0x...", // TODO: verify -} as const; - -/** - * FlashLoanRouter — the CoW Protocol solver that submits all Aave flash loan settlements. - * Confirmed via ROUTER() on AaveV3AdapterFactory (immutable variable, cannot change). - * Used to filter GPv2Settlement:Settlement events to only those involving flash loans. - */ -export const FLASH_LOAN_ROUTER_ADDRESSES = { - mainnet: FLASH_LOAN_ROUTER_ADDRESS, - gnosis: FLASH_LOAN_ROUTER_ADDRESS, // confirmed via ROUTER() on Gnosis AaveV3AdapterFactory - // arbitrum: "0x...", // TODO: confirm via ROUTER() on arbitrum AaveV3AdapterFactory -} as const; /** * Orderbook polling interval in blocks. @@ -126,23 +15,30 @@ export const FLASH_LOAN_ROUTER_ADDRESSES = { */ export const ORDERBOOK_POLL_INTERVAL = 20; +/** + * Human-readable chain names keyed by chain ID. + * Derived from ACTIVE_CHAINS — used for API schema descriptions and logging. + */ +export const CHAIN_NAMES: Record = Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.chainId, c.name]), +) as Record; + /** * Approximate block time in seconds per chain ID. - * Used by the block handler to estimate block numbers from epoch timestamps (PollTryAtEpoch). + * Derived from ACTIVE_CHAINS — update chain files to change block times. */ -export const BLOCK_TIME_SECONDS: Record = { - 1: 12, // mainnet - 100: 5, // gnosis -}; +export const BLOCK_TIME_SECONDS: Record = Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.chainId, c.blockTime]), +) as Record; /** * ComposableCoW address keyed by numeric chain ID. - * Derived from COMPOSABLE_COW_DEPLOYMENTS — update that map to add new chains. + * Derived from ACTIVE_CHAINS — update chain files to change addresses. */ -export const COMPOSABLE_COW_ADDRESS_BY_CHAIN_ID: Record = { - 1: COMPOSABLE_COW_DEPLOYMENTS.mainnet.address, - 100: COMPOSABLE_COW_DEPLOYMENTS.gnosis.address, -}; +export const COMPOSABLE_COW_ADDRESS_BY_CHAIN_ID: Record = + Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.chainId, c.composableCow.address]), + ) as Record; /** * Known ComposableCoW order handler addresses — derived from the ALL_HANDLER_ADDRESSES @@ -159,13 +55,33 @@ export const COMPOSABLE_COW_HANDLER_ADDRESSES = new Set(ALL_HANDLER_ADDRESSES); /** * CoW Protocol Orderbook API base URLs per chain ID. - * Used by the orderbook fetch utility and trade event handler. - * No authentication required. Append /api/v1/ for all calls. + * Derived from ALL_DEFINED_CHAINS so every configured chain is covered, + * including inactive ones used for API-only lookups. + */ +export const ORDERBOOK_API_URLS: Record = Object.fromEntries( + ALL_DEFINED_CHAINS.map((c) => [c.chainId, `https://api.cow.fi/${c.orderbookApiPath}`]), +); + +/** + * AaveV3AdapterFactory addresses keyed by chain name. + * Derived from ACTIVE_CHAINS — only chains with a non-null aaveV3AdapterFactory are included. + * Used by settlement.ts to resolve per-chain factory addresses at runtime. + */ +export const AAVE_V3_ADAPTER_FACTORY_ADDRESSES: Record = + Object.fromEntries( + ACTIVE_CHAINS + .filter((c) => c.aaveV3AdapterFactory !== null) + .map((c) => [c.name, c.aaveV3AdapterFactory!]), + ); + +/** + * GPv2Settlement deployment info keyed by chain name. + * Derived from ACTIVE_CHAINS — only chains with a non-null gpv2Settlement are included. + * Used by settlement.ts to resolve the settlement contract address per chain. */ -export const ORDERBOOK_API_URLS: Record = { - 1: "https://api.cow.fi/mainnet", - 100: "https://api.cow.fi/xdai", - 42161: "https://api.cow.fi/arbitrum_one", - 8453: "https://api.cow.fi/base", - 11155111: "https://api.cow.fi/sepolia", -}; +export const GPV2_SETTLEMENT_DEPLOYMENTS: Record = + Object.fromEntries( + ACTIVE_CHAINS + .filter((c) => c.gpv2Settlement !== null) + .map((c) => [c.name, c.gpv2Settlement!]), + ); diff --git a/src/utils/order-types.ts b/src/utils/order-types.ts index 707eabf..93cd1b3 100644 --- a/src/utils/order-types.ts +++ b/src/utils/order-types.ts @@ -77,8 +77,14 @@ export function getOrderTypeFromHandler( // Single source of truth for which order types have UIDs computable from staticInput // alone (no on-chain calls). Keep in sync with the switch in `precomputeOrderUids`. -export const DETERMINISTIC_ORDER_TYPES = new Set(["TWAP", "StopLoss"]); - -export function isDeterministicOrderType(orderType: string): boolean { - return DETERMINISTIC_ORDER_TYPES.has(orderType as OrderType); -} +export const DETERMINISTIC_ORDER_TYPE: Record = { + TWAP: true, + StopLoss: true, + CirclesBackingOrder: true, + PerpetualSwap: false, + GoodAfterTime: false, + TradeAboveThreshold: false, + SwapOrderHandler: false, + ERC4626CowSwapFeeBurner: false, + Unknown: false, +}; diff --git a/tests/__mocks__/ponder-api.ts b/tests/__mocks__/ponder-api.ts new file mode 100644 index 0000000..ab6ca87 --- /dev/null +++ b/tests/__mocks__/ponder-api.ts @@ -0,0 +1,14 @@ +import { vi } from "vitest"; + +// Chainable select stub — each .select() call gets its own independent chain so +// tests can use mockReturnValueOnce to return different rows per query. +export function makeSelectChain(rows: unknown[] = []) { + const where = vi.fn().mockResolvedValue(rows); + const from = vi.fn().mockReturnValue({ where }); + return { from }; +} + +export const db = { + execute: vi.fn().mockResolvedValue({ rows: [] }), + select: vi.fn().mockReturnValue(makeSelectChain()), +}; diff --git a/tests/__mocks__/ponder-schema.ts b/tests/__mocks__/ponder-schema.ts new file mode 100644 index 0000000..5ff8d3e --- /dev/null +++ b/tests/__mocks__/ponder-schema.ts @@ -0,0 +1,4 @@ +// Stub for ponder:schema virtual module — only used to satisfy imports in vitest. +// Values are placeholders; tests that exercise ponder schema columns must mock them. +export const conditionalOrderGenerator = {} as never; +export const discreteOrder = {} as never; diff --git a/tests/__mocks__/ponder.ts b/tests/__mocks__/ponder.ts new file mode 100644 index 0000000..ca5f54d --- /dev/null +++ b/tests/__mocks__/ponder.ts @@ -0,0 +1,8 @@ +// Stub for the `ponder` module — satisfies imports from orderbookClient.ts in vitest. +// The exported sql tag and helpers are no-ops; tests mock context.db.sql.execute directly. +export const and = (..._args: unknown[]) => ({}); +export const eq = (..._args: unknown[]) => ({}); +export const sql = Object.assign( + (_strings: TemplateStringsArray, ..._values: unknown[]) => ({}), + { raw: (_str: string) => ({}) }, +); diff --git a/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts new file mode 100644 index 0000000..e496771 --- /dev/null +++ b/tests/api/execution-summary.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { z } from "zod"; + +// Mock virtual modules before any ponder-importing source files are loaded. +vi.mock("ponder:api", () => ({ db: { select: vi.fn() } })); +vi.mock("ponder", () => ({ + and: (..._args: unknown[]) => ({}), + eq: (..._args: unknown[]) => ({}), + count: () => ({}), +})); +vi.mock("ponder:schema", () => ({ + discreteOrder: { status: "status", conditionalOrderGeneratorId: "conditionalOrderGeneratorId", chainId: "chainId" }, +})); + +import { db } from "ponder:api"; +import { executionSummaryRoute } from "../../src/api/routes"; +import { executionSummaryHandler } from "../../src/api/endpoints/execution-summary"; +import { DiscreteOrderStatusQuery } from "../../src/api/schemas/common"; + +const Status = DiscreteOrderStatusQuery.enum; +type StatusRow = { status: z.infer; count: number }; + +function buildApp() { + const app = new OpenAPIHono(); + app.openapi(executionSummaryRoute, executionSummaryHandler); + return app; +} + +const EVENT_ID = "177991282000000000000001000000000046395020000000000000001750000000000000054"; + +function makeUrl(eventId = EVENT_ID, chainId = 1) { + return `http://localhost/generator/${eventId}/execution-summary?chainId=${chainId}`; +} + +function makeSelectChain(rows: unknown[] = []) { + const groupBy = vi.fn().mockResolvedValue(rows); + const where = vi.fn().mockReturnValue({ groupBy }); + const from = vi.fn().mockReturnValue({ where }); + return { from }; +} + +beforeEach(() => { + vi.mocked(db.select).mockReset(); +}); + +describe("GET /api/generator/:eventId/execution-summary", () => { + it("returns all-zero counts when no discrete orders exist", async () => { + vi.mocked(db.select).mockReturnValueOnce(makeSelectChain([]) as never); + + const res = await buildApp().request(makeUrl()); + expect(res.status).toBe(200); + + const body = await res.json() as Record; + expect(body["totalParts"]).toBe(0); + expect(body["filledParts"]).toBe(0); + expect(body["openParts"]).toBe(0); + expect(body["unfilledParts"]).toBe(0); + expect(body["expiredParts"]).toBe(0); + expect(body["cancelledParts"]).toBe(0); + }); + + it("maps fulfilled, expired, open, unfilled, cancelled to the right fields", async () => { + const rows: StatusRow[] = [ + { status: Status.fulfilled, count: 3 }, + { status: Status.expired, count: 7 }, + { status: Status.open, count: 2 }, + ]; + vi.mocked(db.select).mockReturnValueOnce(makeSelectChain(rows) as never); + + const body = await (await buildApp().request(makeUrl())).json() as Record; + + expect(body["filledParts"]).toBe(3); + expect(body["expiredParts"]).toBe(7); + expect(body["openParts"]).toBe(2); + expect(body["unfilledParts"]).toBe(0); + expect(body["cancelledParts"]).toBe(0); + expect(body["totalParts"]).toBe(12); + }); + + it("totalParts is the sum of all status counts", async () => { + const rows: StatusRow[] = [ + { status: Status.fulfilled, count: 10 }, + { status: Status.cancelled, count: 5 }, + { status: Status.unfilled, count: 3 }, + ]; + vi.mocked(db.select).mockReturnValueOnce(makeSelectChain(rows) as never); + + const body = await (await buildApp().request(makeUrl())).json() as Record; + expect(body["totalParts"]).toBe(18); + }); + + it("echoes back the generatorId and chainId", async () => { + vi.mocked(db.select).mockReturnValueOnce(makeSelectChain([]) as never); + + const body = await (await buildApp().request(makeUrl(EVENT_ID, 100))).json() as Record; + expect(body["generatorId"]).toBe(EVENT_ID); + expect(body["chainId"]).toBe(100); + }); + + it("returns 400 when chainId query param is missing", async () => { + const res = await buildApp().request( + `http://localhost/generator/${EVENT_ID}/execution-summary`, + ); + expect(res.status).toBe(400); + }); + + it("returns 500 when the DB throws", async () => { + const groupBy = vi.fn().mockRejectedValueOnce(new Error("db error")); + const where = vi.fn().mockReturnValue({ groupBy }); + const from = vi.fn().mockReturnValue({ where }); + vi.mocked(db.select).mockReturnValueOnce({ from } as never); + + const res = await buildApp().request(makeUrl()); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts new file mode 100644 index 0000000..3113295 --- /dev/null +++ b/tests/api/orders-by-owner.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + GeneratorSummary, + OrdersByOwnerResponse, +} from "../../src/api/schemas/orders-by-owner"; + +// Mock virtual modules before any ponder-importing source files are loaded. +// ponder:api is resolved to tests/__mocks__/ponder-api.ts via vitest alias — no inline override needed. +vi.mock("ponder:schema", () => { + const ownerMapping = { owner: "owner", chainId: "chainId", address: "address" }; + const conditionalOrderGenerator = { + eventId: "eventId", chainId: "chainId", orderType: "orderType", + owner: "owner", resolvedOwner: "resolvedOwner", status: "status", + ownerAddressType: "ownerAddressType", + }; + const discreteOrder = { + conditionalOrderGeneratorId: "conditionalOrderGeneratorId", + orderUid: "orderUid", chainId: "chainId", status: "status", + sellAmount: "sellAmount", buyAmount: "buyAmount", feeAmount: "feeAmount", + validTo: "validTo", creationDate: "creationDate", + executedSellAmount: "executedSellAmount", executedBuyAmount: "executedBuyAmount", + }; + return { + default: { ownerMapping, conditionalOrderGenerator, discreteOrder }, + ownerMapping, + conditionalOrderGenerator, + discreteOrder, + }; +}); +vi.mock("ponder", () => ({ + and: (..._args: unknown[]) => ({}), + eq: (..._args: unknown[]) => ({}), + inArray: (..._args: unknown[]) => ({}), + or: (..._args: unknown[]) => ({}), +})); + +import { db } from "ponder:api"; +import { makeSelectChain } from "../__mocks__/ponder-api"; +import { ordersByOwnerHandler } from "../../src/api/endpoints/orders-by-owner"; + +const OWNER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const EVENT_ID = "abc123"; +const CHAIN_ID = 1; + +/** Minimal Hono context stub that satisfies orders-by-owner handler requirements. */ +function makeContext({ + owner = OWNER, + chainId = CHAIN_ID, + status, + ownerAddressType, +}: { + owner?: string; + chainId?: number; + status?: string; + ownerAddressType?: string; +} = {}) { + const responses: Array<{ body: unknown; status: number }> = []; + return { + req: { + valid: (type: "param" | "query") => { + if (type === "param") return { owner }; + return { chainId, status, ownerAddressType }; + }, + }, + json: (body: unknown, httpStatus = 200) => { + // Simulate JSON serialization to catch BigInt issues early. + const serialised = JSON.parse(JSON.stringify(body, (_k, v) => + typeof v === "bigint" ? v.toString() : v + )); + responses.push({ body: serialised, status: httpStatus }); + return { body: serialised, status: httpStatus }; + }, + _responses: responses, + }; +} + +const GENERATOR = { + eventId: EVENT_ID, + chainId: CHAIN_ID, + orderType: "TWAP", + owner: OWNER, + resolvedOwner: OWNER, + status: "Active", + ownerAddressType: null, + hash: "0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1", +}; + +const ORDER = { + orderUid: "0x" + "bb".repeat(56), + chainId: CHAIN_ID, + status: "fulfilled", + sellAmount: "1000000000000000000", + buyAmount: "2000000000000000000", + feeAmount: "1000000000000000", + validTo: 9_999_999_999, + creationDate: BigInt("1700000000"), + executedSellAmount: "1000000000000000000", + executedBuyAmount: "2000000000000000000", + generatorId: EVENT_ID, +}; + +beforeEach(() => { + vi.mocked(db.select).mockReset(); +}); + +describe("ordersByOwnerHandler", () => { + it("returns empty orders array when no generators are found", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) // ownerMapping → no proxies + .mockReturnValueOnce(makeSelectChain([]) as never); // generators → none + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: unknown[] }; + expect(result.orders).toEqual([]); + }); + + it("returns empty orders when generators exist but have no discrete orders", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: unknown[] }; + expect(result.orders).toEqual([]); + }); + + it("returns enriched orders with embedded generator data including hash", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: Array> }; + + expect(result.orders).toHaveLength(1); + const order = result.orders[0]!; + expect(order["orderUid"]).toBe(ORDER.orderUid); + expect(order["status"]).toBe("fulfilled"); + const gen = order["generator"] as Record; + expect(gen["eventId"]).toBe(EVENT_ID); + expect(gen["orderType"]).toBe("TWAP"); + expect(gen["hash"]).toBe(GENERATOR.hash); + }); + + it("serialises creationDate as a decimal string (BigInt scalar)", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: Array> }; + expect(result.orders[0]!["creationDate"]).toBe("1700000000"); + }); + + it("includes proxy addresses from ownerMapping in the generator lookup", async () => { + const PROXY = "0xcccccccccccccccccccccccccccccccccccccccc"; + vi.mocked(db.select) + .mockReturnValueOnce(makeSelectChain([{ address: PROXY }]) as never) + .mockReturnValueOnce(makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(makeSelectChain([ORDER]) as never); + + const ctx = makeContext(); + await ordersByOwnerHandler(ctx as never, vi.fn() as never); + const result = ctx._responses[0]!.body as { orders: unknown[] }; + expect(result.orders).toHaveLength(1); + }); +}); + +// A minimal valid GeneratorSummary payload that satisfies all required fields. +const validGenerator = { + eventId: "0xabc123", + chainId: 1, + orderType: "TWAP", + owner: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + resolvedOwner: null, + status: "open", + hash: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ownerAddressType: null, +} as const; + +describe("GeneratorSummary schema", () => { + // Regression guard for COW-993: hash was previously missing from the schema, + // causing it to be silently dropped from API responses. safeParse accepts + // unknown so TS gives no protection here at runtime. + it("fails parse when hash is missing", () => { + const { hash: _omitted, ...withoutHash } = validGenerator; + const result = GeneratorSummary.safeParse(withoutHash); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join(".")); + expect(paths).toContain("hash"); + } + }); + + // Regression guard: ownerAddressType is nullable — null is the common case + // for generators that don't go through a proxy. + it("ownerAddressType accepts null", () => { + const result = GeneratorSummary.safeParse({ ...validGenerator, ownerAddressType: null }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ownerAddressType).toBeNull(); + } + }); +}); + +describe("OrdersByOwnerResponse schema", () => { + it("wraps an array of GeneratorSummary correctly via the orders field", () => { + const orderItem = { + orderUid: "0xorder001", + chainId: 1, + status: "open", + sellAmount: "1000000000000000000", + buyAmount: "2000000000000000000", + feeAmount: "0", + validTo: null, + creationDate: "1700000000", + executedSellAmount: null, + executedBuyAmount: null, + generatorId: "0xabc123", + generator: validGenerator, + }; + + const result = OrdersByOwnerResponse.safeParse({ orders: [orderItem] }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.orders).toHaveLength(1); + expect(result.data.orders[0]!.generator?.hash).toBe(validGenerator.hash); + } + }); + + it("parses an empty orders array", () => { + const result = OrdersByOwnerResponse.safeParse({ orders: [] }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.orders).toHaveLength(0); + } + }); +}); diff --git a/tests/api/sync-progress.test.ts b/tests/api/sync-progress.test.ts new file mode 100644 index 0000000..b39e401 --- /dev/null +++ b/tests/api/sync-progress.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { Hono } from "hono"; +import { syncProgressHandler } from "../../src/api/endpoints/sync-progress"; + +type ChainProgress = { + totalBlocks: number; + processedBlocks: number; + historicalBlocksFetchedPct: number; + isRealtime: boolean; + isComplete: boolean; +}; + +function buildApp() { + const app = new Hono(); + app.get("/api/sync-progress", (c) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + syncProgressHandler(c as any, async () => {}), + ); + return app; +} + +const SAMPLE_METRICS = ` +# HELP ponder_historical_total_blocks Number of blocks required for the historical sync +# TYPE ponder_historical_total_blocks gauge +ponder_historical_total_blocks{chain="mainnet"} 7000000 +ponder_historical_total_blocks{chain="gnosis"} 17000000 +# HELP ponder_historical_completed_blocks Number of blocks processed +# TYPE ponder_historical_completed_blocks gauge +ponder_historical_completed_blocks{chain="mainnet"} 500000 +ponder_historical_completed_blocks{chain="gnosis"} 1000000 +# HELP ponder_historical_cached_blocks Number of blocks from cache +# TYPE ponder_historical_cached_blocks gauge +ponder_historical_cached_blocks{chain="mainnet"} 2500000 +ponder_historical_cached_blocks{chain="gnosis"} 1400000 +# HELP ponder_sync_is_realtime Boolean indicating realtime mode +# TYPE ponder_sync_is_realtime gauge +ponder_sync_is_realtime{chain="mainnet"} 0 +ponder_sync_is_realtime{chain="gnosis"} 1 +# HELP ponder_sync_is_complete Boolean indicating sync complete +# TYPE ponder_sync_is_complete gauge +ponder_sync_is_complete{chain="mainnet"} 0 +ponder_sync_is_complete{chain="gnosis"} 1 +`.trim(); + +function mockFetch(metricsBody: string) { + vi.stubGlobal( + "fetch", + vi.fn((_url: string) => + Promise.resolve({ + text: () => Promise.resolve(metricsBody), + } as Response), + ), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("GET /api/sync-progress", () => { + it("returns 200", async () => { + mockFetch(SAMPLE_METRICS); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + expect(res.status).toBe(200); + }); + + it("returns one entry per chain found in metrics", async () => { + mockFetch(SAMPLE_METRICS); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + const body = (await res.json()) as Record; + expect(Object.keys(body)).toEqual(expect.arrayContaining(["mainnet", "gnosis"])); + }); + + it("computes processedBlocks as completed + cached", async () => { + mockFetch(SAMPLE_METRICS); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + const body = (await res.json()) as Record; + // mainnet: 500_000 completed + 2_500_000 cached = 3_000_000 + expect(body["mainnet"]!.processedBlocks).toBe(3_000_000); + // gnosis: 1_000_000 + 1_400_000 = 2_400_000 + expect(body["gnosis"]!.processedBlocks).toBe(2_400_000); + }); + + it("computes historicalBlocksFetchedPct correctly (rounded to 1 decimal)", async () => { + mockFetch(SAMPLE_METRICS); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + const body = (await res.json()) as Record; + // mainnet: 3_000_000 / 7_000_000 = 42.857... → 42.9 + expect(body["mainnet"]!.historicalBlocksFetchedPct).toBe(42.9); + // gnosis: 2_400_000 / 17_000_000 = 14.117... → 14.1 + expect(body["gnosis"]!.historicalBlocksFetchedPct).toBe(14.1); + }); + + it("sets isRealtime and isComplete from metrics flags", async () => { + mockFetch(SAMPLE_METRICS); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + const body = (await res.json()) as Record; + expect(body["mainnet"]!.isRealtime).toBe(false); + expect(body["mainnet"]!.isComplete).toBe(false); + expect(body["gnosis"]!.isRealtime).toBe(true); + expect(body["gnosis"]!.isComplete).toBe(true); + }); + + it("returns empty object and 200 when metrics endpoint is unreachable", async () => { + vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new Error("unreachable")))); + const app = buildApp(); + const res = await app.request("http://localhost/api/sync-progress"); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body).toEqual({}); + }); +}); diff --git a/tests/application/decoders/erc1271Signature.test.ts b/tests/application/decoders/erc1271Signature.test.ts new file mode 100644 index 0000000..27053d1 --- /dev/null +++ b/tests/application/decoders/erc1271Signature.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { encodeAbiParameters, getAddress, type Hex } from "viem"; +import { decodeEip1271Signature, PAYLOAD_STRUCT_ABI } from "../../../src/application/decoders/erc1271Signature"; + +const HANDLER = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd" as Hex; +const SALT = ("0x" + "ab".repeat(32)) as Hex; +const STATIC_INPUT = "0xdeadbeef" as Hex; +const PROOF: Hex[] = [("0x" + "11".repeat(32)) as Hex]; +const OFFCHAIN_INPUT = "0x1234" as Hex; + +/** Build a Format B signature (ERC1271Forwarder / CoWShed path). */ +function buildFormatB({ + handler = HANDLER, + salt = SALT, + staticInput = STATIC_INPUT, + proof = [] as Hex[], + offchainInput = "0x" as Hex, +} = {}): Hex { + const payloadEncoded = encodeAbiParameters(PAYLOAD_STRUCT_ABI, [ + { proof, params: { handler, salt, staticInput }, offchainInput }, + ]); + // Format B: 384 zero bytes (GPv2Order.Data placeholder) + PayloadStruct ABI encoding + return ("0x" + "00".repeat(384) + payloadEncoded.slice(2)) as Hex; +} + +/** Build a Format A signature (ISafeSignatureVerifier / Safe path). */ +function buildFormatA({ + handler = HANDLER, + salt = SALT, + staticInput = STATIC_INPUT, + proof = [] as Hex[], + offchainInput = "0x" as Hex, +} = {}): Hex { + const payloadEncoded = encodeAbiParameters(PAYLOAD_STRUCT_ABI, [ + { proof, params: { handler, salt, staticInput }, offchainInput }, + ]); + const payloadBytes = payloadEncoded.slice(2); // strip "0x" + const payloadLen = payloadBytes.length / 2; + + // Format A byte layout (all offsets in bytes): + // 0–3 selector 4 bytes + // 4–35 domainSeparator 32 bytes + // 36–67 typeHash 32 bytes + // 68–99 ABI offset to encodeData (=0x80=128 from byte 68) 32 bytes + // 100–131 ABI offset to payload (=0x220=544 from byte 68) 32 bytes + // 132–163 encodeData length (= 384) 32 bytes + // 164–547 abi.encode(GPv2Order.Data) 384 bytes + // 548–579 payload length 32 bytes + // 580–N abi.encode(PayloadStruct) + const padHex = (n: number, bytes: number) => n.toString(16).padStart(bytes * 2, "0"); + const hex = [ + "5fd7e97d", // selector (no 0x prefix here) + "00".repeat(32), // domainSeparator + "00".repeat(32), // typeHash + padHex(0x80, 32), // offset to encodeData + padHex(0x220, 32), // offset to payload + padHex(384, 32), // encodeData length = 384 + "00".repeat(384), // GPv2Order.Data placeholder + padHex(payloadLen, 32), // payload length + payloadBytes, // abi.encode(PayloadStruct) + ].join(""); + return ("0x" + hex) as Hex; +} + +// ─── Format B (ERC1271Forwarder / CoWShed) ──────────────────────────────────── + +describe("decodeEip1271Signature — Format B (ERC1271Forwarder)", () => { + it("round-trips handler, salt, staticInput", () => { + const sig = buildFormatB(); + const result = decodeEip1271Signature(sig); + expect(result).not.toBeNull(); + expect(result!.handler).toBe(HANDLER.toLowerCase()); + expect(result!.salt).toBe(SALT); + expect(result!.staticInput).toBe(STATIC_INPUT); + }); + + it("normalises handler address to lowercase", () => { + // viem requires checksummed addresses for encoding; use getAddress() to checksum first, + // then verify the decoder lowercases the output regardless of the encoded casing. + const checksummed = getAddress(HANDLER); + const sig = buildFormatB({ handler: checksummed }); + const result = decodeEip1271Signature(sig); + expect(result!.handler).toBe(HANDLER.toLowerCase()); + }); + + it("round-trips a non-empty proof array", () => { + const sig = buildFormatB({ proof: PROOF }); + const result = decodeEip1271Signature(sig); + expect(result!.proof).toEqual(PROOF); + }); + + it("round-trips offchainInput", () => { + const sig = buildFormatB({ offchainInput: OFFCHAIN_INPUT }); + const result = decodeEip1271Signature(sig); + expect(result!.offchainInput).toBe(OFFCHAIN_INPUT); + }); + + it("round-trips a multi-byte staticInput", () => { + const longInput = ("0x" + "cc".repeat(64)) as Hex; + const sig = buildFormatB({ staticInput: longInput }); + const result = decodeEip1271Signature(sig); + expect(result!.staticInput).toBe(longInput); + }); +}); + +// ─── Format A (ISafeSignatureVerifier / Safe wallet) ───────────────────────── + +describe("decodeEip1271Signature — Format A (ISafeSignatureVerifier)", () => { + it("detects the 0x5fd7e97d selector and round-trips handler, salt, staticInput", () => { + const sig = buildFormatA(); + const result = decodeEip1271Signature(sig); + expect(result).not.toBeNull(); + expect(result!.handler).toBe(HANDLER.toLowerCase()); + expect(result!.salt).toBe(SALT); + expect(result!.staticInput).toBe(STATIC_INPUT); + }); + + it("round-trips a non-empty proof via Format A", () => { + const sig = buildFormatA({ proof: PROOF }); + const result = decodeEip1271Signature(sig); + expect(result!.proof).toEqual(PROOF); + }); +}); + +// ─── Error / edge cases ─────────────────────────────────────────────────────── + +describe("decodeEip1271Signature — invalid inputs", () => { + it("returns null for empty hex string", () => { + expect(decodeEip1271Signature("0x")).toBeNull(); + }); + + it("returns null for a signature that is too short to contain a payload", () => { + // Only 10 bytes — nothing to decode + expect(decodeEip1271Signature(("0x" + "aa".repeat(10)) as Hex)).toBeNull(); + }); + + it("returns null for random garbage bytes", () => { + const garbage = ("0x" + "ff".repeat(200)) as Hex; + expect(decodeEip1271Signature(garbage)).toBeNull(); + }); +}); diff --git a/tests/helpers/orderbookClient.test.ts b/tests/helpers/orderbookClient.test.ts new file mode 100644 index 0000000..56cc98a --- /dev/null +++ b/tests/helpers/orderbookClient.test.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import type { Hex } from "viem"; + +// Mock Ponder virtual modules that are not available outside the Ponder runtime. +// vi.mock calls are hoisted by vitest so they resolve before any imports below. +vi.mock("ponder:schema", () => ({ + conditionalOrderGenerator: { $inferInsert: {}, eventId: "eventId", orderType: "orderType", chainId: "chainId", hash: "hash" }, + discreteOrder: { $inferInsert: {}, chainId: "chainId", orderUid: "orderUid" }, +})); + +vi.mock("ponder", () => ({ + and: vi.fn(), + eq: vi.fn(), + sql: Object.assign(vi.fn(), { raw: vi.fn() }), +})); + +import * as data from "../../src/data"; +import { fetchOrderStatusByUids, fetchOwnerOrderStatuses } from "../../src/application/helpers/orderbookClient"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +type RequestHandler = (req: IncomingMessage, res: ServerResponse) => void; + +async function startServer(handler: RequestHandler): Promise<{ url: string; close: () => Promise }> { + const server: Server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** Temporarily override `ORDERBOOK_API_URLS[chainId]` for the duration of a test callback. */ +async function withFakeApi( + chainId: number, + serverUrl: string, + fn: () => Promise, +): Promise { + const original = data.ORDERBOOK_API_URLS[chainId]; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data.ORDERBOOK_API_URLS as any)[chainId] = serverUrl; + await fn(); + } finally { + if (original === undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (data.ORDERBOOK_API_URLS as any)[chainId]; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data.ORDERBOOK_API_URLS as any)[chainId] = original; + } + } +} + +/** Minimal Ponder context stub for fetchOrderStatusByUids tests. */ +function makeContext() { + return { db: { sql: { execute: async () => [] } } }; +} + +/** Build a single `{ order: {...} }` item matching the real CoW Orderbook API shape (by_uids endpoint). */ +function makeWrappedOrder(uid: string, status: "open" | "fulfilled" | "expired" | "cancelled") { + return { + order: { + uid, + status, + sellAmount: "1000000000000000000", + buyAmount: "2000000000000000000", + feeAmount: "1000000000000000", + validTo: 9_999_999_999, + creationDate: "2024-01-01T00:00:00Z", + signingScheme: "eip1271", + signature: "0x", + executedSellAmount: status === "fulfilled" ? "1000000000000000000" : "0", + executedBuyAmount: status === "fulfilled" ? "2000000000000000000" : "0", + }, + }; +} + +interface OrderStub { + uid: string; + status: string; + executedSellAmount: string; + executedBuyAmount: string; + sellAmount?: string; + buyAmount?: string; + feeAmount?: string; + validTo?: number; + creationDate?: string; + signingScheme?: string; + signature?: string; +} + +function makeOrderStub(overrides: Partial & Pick): OrderStub { + return { + sellAmount: "1000000000000000000", + buyAmount: "2000000000", + feeAmount: "0", + validTo: 9999999999, + creationDate: "2024-01-01T00:00:00.000Z", + signingScheme: "eip1271", + signature: "0x", + executedSellAmount: "0", + executedBuyAmount: "0", + ...overrides, + }; +} + +// Realistic CoW order UIDs (orderHash + owner + validTo = 56 bytes each). +const UID_A = `0x${"aa".repeat(56)}` as const; +const UID_B = `0x${"bb".repeat(56)}` as const; + +// Isolated chain ID that doesn't exist in production — safe to mutate and delete. +const TEST_CHAIN_ID = 99_999; + +// ─── fetchOrderStatusByUids tests ───────────────────────────────────────────── + +describe("fetchOrderStatusByUids", () => { + beforeAll(() => { + // Placeholder so the early-exit guard (!apiBaseUrl) passes for TEST_CHAIN_ID. + // Individual tests replace this with the actual server URL before each call. + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = "http://placeholder"; + }); + + afterAll(() => { + delete (data.ORDERBOOK_API_URLS as Record)[TEST_CHAIN_ID]; + }); + + it("returns empty map immediately when the uids array is empty", async () => { + const result = await fetchOrderStatusByUids(makeContext(), TEST_CHAIN_ID, []); + expect(result.size).toBe(0); + }); + + it("correctly unwraps the { order } wrapper and maps uid → status (regression: COW-979)", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([makeWrappedOrder(UID_A, "fulfilled")])); + }); + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + try { + const result = await fetchOrderStatusByUids(makeContext(), TEST_CHAIN_ID, [UID_A]); + expect(result.has(UID_A)).toBe(true); + expect(result.get(UID_A)?.status).toBe("fulfilled"); + } finally { + await close(); + } + }); + + it("populates executed amounts from the unwrapped response", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([makeWrappedOrder(UID_A, "fulfilled")])); + }); + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + try { + const result = await fetchOrderStatusByUids(makeContext(), TEST_CHAIN_ID, [UID_A]); + const info = result.get(UID_A); + expect(info?.executedSellAmount).toBe("1000000000000000000"); + expect(info?.executedBuyAmount).toBe("2000000000000000000"); + } finally { + await close(); + } + }); + + it("returns statuses for multiple orders in a single batch", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([ + makeWrappedOrder(UID_A, "fulfilled"), + makeWrappedOrder(UID_B, "open"), + ])); + }); + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + try { + const result = await fetchOrderStatusByUids(makeContext(), TEST_CHAIN_ID, [UID_A, UID_B]); + expect(result.get(UID_A)?.status).toBe("fulfilled"); + expect(result.get(UID_B)?.status).toBe("open"); + } finally { + await close(); + } + }); + + it("returns empty map on HTTP error response without throwing", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(500); + res.end("Internal Server Error"); + }); + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + try { + const result = await fetchOrderStatusByUids(makeContext(), TEST_CHAIN_ID, [UID_A]); + expect(result.size).toBe(0); + } finally { + await close(); + } + }); + + it("returns empty map when the response body is an empty array", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("[]"); + }); + data.ORDERBOOK_API_URLS[TEST_CHAIN_ID] = url; + try { + const result = await fetchOrderStatusByUids(makeContext(), TEST_CHAIN_ID, [UID_A]); + expect(result.size).toBe(0); + } finally { + await close(); + } + }); +}); + +// ─── fetchOwnerOrderStatuses tests ──────────────────────────────────────────── + +const FAKE_OWNER = "0xaabbccddEEff0011223344556677889900aabbcc" as Hex; +const FAKE_CHAIN_ID = 1; +const UNKNOWN_CHAIN_ID = 99999; + +describe("fetchOwnerOrderStatuses", () => { + it("returns an empty map for an unknown chainId (no API URL configured)", async () => { + const result = await fetchOwnerOrderStatuses(UNKNOWN_CHAIN_ID, FAKE_OWNER); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + + it("happy path — server returns orders, Map is built with uid/status/executedAmounts", async () => { + const orders = [ + makeOrderStub({ uid: "0xuid1", status: "fulfilled", executedSellAmount: "500", executedBuyAmount: "1000" }), + makeOrderStub({ uid: "0xuid2", status: "open", executedSellAmount: "0", executedBuyAmount: "0" }), + makeOrderStub({ uid: "0xuid3", status: "expired", executedSellAmount: "250", executedBuyAmount: "500" }), + ]; + + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(orders)); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(3); + + expect(result.get("0xuid1")).toEqual({ + status: "fulfilled", + executedSellAmount: "500", + executedBuyAmount: "1000", + }); + expect(result.get("0xuid2")).toEqual({ + status: "open", + executedSellAmount: "0", + executedBuyAmount: "0", + }); + expect(result.get("0xuid3")).toEqual({ + status: "expired", + executedSellAmount: "250", + executedBuyAmount: "500", + }); + }); + } finally { + await close(); + } + }); + + it("handles null executedSellAmount and executedBuyAmount from the server", async () => { + const orders = [ + { + uid: "0xuid-null", + status: "cancelled", + executedSellAmount: null, + executedBuyAmount: null, + sellAmount: "1000", + buyAmount: "2000", + feeAmount: "0", + validTo: 9999999999, + creationDate: "2024-01-01T00:00:00.000Z", + signingScheme: "eip1271", + signature: "0x", + }, + ]; + + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(orders)); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + + expect(result.size).toBe(1); + expect(result.get("0xuid-null")).toEqual({ + status: "cancelled", + executedSellAmount: null, + executedBuyAmount: null, + }); + }); + } finally { + await close(); + } + }); + + it("handles an empty orders array from the server", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([])); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + } finally { + await close(); + } + }); + + it("paginates — fetches subsequent pages when first page is full (PAGE_LIMIT=1000)", async () => { + const PAGE_LIMIT = 1000; + const page1: OrderStub[] = Array.from({ length: PAGE_LIMIT }, (_, i) => + makeOrderStub({ uid: `0xpage1-${i}`, status: "open" }), + ); + const page2: OrderStub[] = [ + makeOrderStub({ uid: "0xpage2-0", status: "fulfilled", executedSellAmount: "999", executedBuyAmount: "888" }), + ]; + + const receivedOffsets: number[] = []; + + const { url, close } = await startServer((req, res) => { + const parsedUrl = new URL(req.url ?? "/", `http://127.0.0.1`); + const offset = parseInt(parsedUrl.searchParams.get("offset") ?? "0", 10); + receivedOffsets.push(offset); + + const page = offset === 0 ? page1 : page2; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify(page)); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + + expect(receivedOffsets).toContain(0); + expect(receivedOffsets).toContain(PAGE_LIMIT); + + expect(result.size).toBe(PAGE_LIMIT + 1); + + expect(result.get("0xpage2-0")).toEqual({ + status: "fulfilled", + executedSellAmount: "999", + executedBuyAmount: "888", + }); + }); + } finally { + await close(); + } + }); + + it("handles a non-200 response gracefully — returns empty map without throwing", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ message: "Internal Server Error" })); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + const result = await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + } finally { + await close(); + } + }); + + it("uses the correct /api/v1/account/{owner}/orders endpoint with limit and offset params", async () => { + const receivedPaths: string[] = []; + + const { url, close } = await startServer((req, res) => { + receivedPaths.push(req.url ?? ""); + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([])); + }); + + try { + await withFakeApi(FAKE_CHAIN_ID, url, async () => { + await fetchOwnerOrderStatuses(FAKE_CHAIN_ID, FAKE_OWNER); + }); + + expect(receivedPaths.length).toBeGreaterThanOrEqual(1); + const firstPath = receivedPaths[0]!; + expect(firstPath).toContain(`/api/v1/account/${FAKE_OWNER}/orders`); + expect(firstPath).toContain("limit=1000"); + expect(firstPath).toContain("offset=0"); + } finally { + await close(); + } + }); +}); diff --git a/tests/utils/order-types.test.ts b/tests/utils/order-types.test.ts new file mode 100644 index 0000000..46e39d0 --- /dev/null +++ b/tests/utils/order-types.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { + DETERMINISTIC_ORDER_TYPE, + type OrderType, +} from "../../src/utils/order-types"; + +describe("DETERMINISTIC_ORDER_TYPE", () => { + it("covers every OrderType (exhaustive record)", () => { + // If a new OrderType is added to the union without updating the record, + // TypeScript will catch it at compile time. This test documents the intent. + const types = Object.keys(DETERMINISTIC_ORDER_TYPE) as OrderType[]; + expect(types.length).toBeGreaterThan(0); + }); + + it("marks TWAP, StopLoss, CirclesBackingOrder as deterministic", () => { + expect(DETERMINISTIC_ORDER_TYPE["TWAP"]).toBe(true); + expect(DETERMINISTIC_ORDER_TYPE["StopLoss"]).toBe(true); + // Regression guard for COW-1003: CirclesBackingOrder must be deterministic + expect(DETERMINISTIC_ORDER_TYPE["CirclesBackingOrder"]).toBe(true); + }); + + it("marks non-deterministic types as false", () => { + expect(DETERMINISTIC_ORDER_TYPE["PerpetualSwap"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["GoodAfterTime"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["TradeAboveThreshold"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["SwapOrderHandler"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["ERC4626CowSwapFeeBurner"]).toBe(false); + expect(DETERMINISTIC_ORDER_TYPE["Unknown"]).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5fa9726..21d675b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,19 @@ import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ test: { include: ["src/**/*.test.ts", "tests/**/*.test.ts"], }, + resolve: { + alias: [ + // ponder:schema must come before ponder to avoid prefix-match shadowing. + { find: "ponder:schema", replacement: resolve(__dirname, "tests/__mocks__/ponder-schema.ts") }, + { find: /^ponder$/, replacement: resolve(__dirname, "tests/__mocks__/ponder.ts") }, + { find: "ponder:api", replacement: resolve(__dirname, "tests/__mocks__/ponder-api.ts") }, + ], + }, });