From 34577e4412cc8edb275f5f8020e82cc25c7317c9 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 28 May 2026 10:20:22 -0300 Subject: [PATCH 01/89] fix: unwrap order wrapper from POST /orders/by_uids response (COW-979) The by_uids endpoint returns [{order: {...}}] but the code was treating it as a flat OrderbookOrder[]. This caused order.uid to be undefined, so fetchOrderStatusByUids returned an empty map for all candidates, preventing C2 from ever promoting candidateDiscreteOrders to discreteOrders. --- src/application/helpers/orderbookClient.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 5ef7b2d..225b21a 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -364,8 +364,8 @@ async function fetchOrdersByUids( console.warn(`[COW:OB] Batch fetch ${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( From f8078467152ca7e9dee1b6ef2a1aff169b032951 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 28 May 2026 10:50:43 -0300 Subject: [PATCH 02/89] feat: replace deployment shell scripts with TypeScript/Node alternatives (COW-977) Replace manage.sh, deploy-remotely.sh, and static/start-db.sh with tsx scripts and inline compose config. Adds deploy:up/down/remote pnpm scripts. Co-Authored-By: Claude Sonnet 4.6 --- deployment/deploy-remotely.sh | 53 ------ deployment/deploy-remotely.ts | 124 ++++++++++++++ deployment/docker-compose.yml | 20 ++- deployment/manage.sh | 93 ----------- deployment/manage.ts | 209 ++++++++++++++++++++++++ deployment/static/start-db.sh | 36 ----- package.json | 6 +- pnpm-lock.yaml | 294 +++++++++++++++++++++++++++++++++- 8 files changed, 644 insertions(+), 191 deletions(-) delete mode 100755 deployment/deploy-remotely.sh create mode 100644 deployment/deploy-remotely.ts delete mode 100755 deployment/manage.sh create mode 100644 deployment/manage.ts delete mode 100755 deployment/static/start-db.sh 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..1d315b8 --- /dev/null +++ b/deployment/deploy-remotely.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env tsx +/** + * deploy-remotely.ts — rsync + SSH deploy or local deploy. + * Replaces deploy-remotely.sh. + * + * 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 index 3eea2fc..4ae9389 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -2,18 +2,32 @@ services: postgres: image: postgres:16 restart: unless-stopped - command: ["bash", "/start-db.sh"] + # Memory tuning values are computed for the default POSTGRES_MEMORY_LIMIT=1G (1024 MB): + # shared_buffers = 20% of 1024 MB = 204 MB + # maintenance_work_mem = 5% of 1024 MB = 51 MB + # effective_cache_size = 50% of 1024 MB = 512 MB + # work_mem = 25% of 1024 MB / 100 connections = 2 MB + # To adjust for a different memory limit, change the -c flags proportionally. + 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_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}"] 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..7db65f4 --- /dev/null +++ b/deployment/manage.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env tsx +/** + * manage.ts — orchestrates `docker compose` up/down for the deploy stack. + * Replaces manage.sh. + * + * 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", + "build", + "--no-cache", + ]); + + run("docker", [ + "compose", + "-p", + projectPrefix, + "-f", + "docker-compose.yml", + "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", + "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/package.json b/package.json index 257684c..7bad8da 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "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", @@ -26,6 +29,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..d9206a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,15 +42,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 +152,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 +170,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 +188,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 +206,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 +224,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 +242,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 +260,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 +278,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 +296,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 +314,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 +332,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 +350,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 +368,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 +386,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 +404,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 +422,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 +440,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 +470,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 +500,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 +530,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 +548,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 +566,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 +584,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 +1495,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 +2549,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 +2615,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 +2962,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 +4054,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 +5231,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 +5353,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 +5373,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 +5384,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): From e906993c9807b8310b5ab04d8f3644e797d73bab Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 28 May 2026 10:53:14 -0300 Subject: [PATCH 03/89] feat: modularize ponder config with per-chain files under src/chains/ (COW-978) Extract per-chain addresses into src/chains/{mainnet,gnosis,arbitrum}.ts with a central ACTIVE_CHAINS index. ponder.config.ts now derives all config with no hardcoded addresses. Toggling a chain requires one line in src/chains/index.ts. Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 124 +++++++++++++++++++------------ src/chains/arbitrum.ts | 26 +++++++ src/chains/gnosis.ts | 30 ++++++++ src/chains/index.ts | 23 ++++++ src/chains/mainnet.ts | 27 +++++++ src/chains/types.ts | 60 +++++++++++++++ src/data.ts | 162 ++++++++++------------------------------- 7 files changed, 281 insertions(+), 171 deletions(-) create mode 100644 src/chains/arbitrum.ts create mode 100644 src/chains/gnosis.ts create mode 100644 src/chains/index.ts create mode 100644 src/chains/mainnet.ts create mode 100644 src/chains/types.ts diff --git a/ponder.config.ts b/ponder.config.ts index c3e66a6..bb78769 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -1,40 +1,64 @@ 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 { ComposableCowAbi } from "./abis/ComposableCowAbi"; +import { CoWShedFactoryAbi } from "./abis/CoWShedFactoryAbi"; +import { GPv2SettlementAbi } from "./abis/GPv2SettlementAbi"; + +// 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]! }]), +); + +// Chains that have GPv2Settlement indexed (non-null gpv2Settlement + flashLoanRouter) +const settlementChains = ACTIVE_CHAINS.filter( + (c) => c.gpv2Settlement !== null && c.flashLoanRouter !== null, +); export default createConfig({ - chains: { - mainnet: { - id: 1, - rpc: process.env.MAINNET_RPC_URL!, - }, - gnosis: { - id: 100, - rpc: process.env.GNOSIS_RPC_URL!, - }, - }, + 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( + ACTIVE_CHAINS.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: { @@ -44,34 +68,39 @@ export default createConfig({ // 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 }, - }, + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [ + c.name, + { + startBlock: "latest" as const, + ...(c.contractPollerInterval > 1 ? { interval: c.contractPollerInterval } : {}), + }, + ]), + ), interval: 1, }, // C2: Candidate Confirmer — 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, }, // C3: Status Updater — polls API for open discrete order status StatusUpdater: { - chain: { - mainnet: { startBlock: "latest" }, - gnosis: { startBlock: "latest" }, - }, + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.name, { startBlock: "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" }, - }, + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [ + c.name, + { startBlock: "latest" as const, endBlock: "latest" as const }, + ]), + ), interval: 1, }, // C5: Deterministic Cancellation Sweeper — singleOrders() mapping read for @@ -79,10 +108,9 @@ export default createConfig({ // DETERMINISTIC_CANCEL_SWEEP_INTERVAL blocks; the handler itself is cheap // when nothing is due. DeterministicCancellationSweeper: { - chain: { - mainnet: { startBlock: "latest" }, - gnosis: { startBlock: "latest" }, - }, + chain: Object.fromEntries( + ACTIVE_CHAINS.map((c) => [c.name, { startBlock: "latest" as const }]), + ), interval: 1, }, }, diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts new file mode 100644 index 0000000..3ee15ef --- /dev/null +++ b/src/chains/arbitrum.ts @@ -0,0 +1,26 @@ +// Placeholder — addresses not yet confirmed. Enable when Arbitrum indexing is ready. +// +// import type { ChainConfig } from "./types"; +// +// export const arbitrum: ChainConfig = { +// name: "arbitrum", +// chainId: 42161, +// rpcEnvVar: "ARBITRUM_RPC_URL", +// blockTime: 1, // ~0.25s avg, treat as ~1s for polling purposes +// composableCow: { +// address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // TODO: confirm CREATE2 address on Arbitrum +// startBlock: 0, // TODO: set deployment block +// }, +// composableCowLive: { +// address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // TODO: confirm +// }, +// cowShedFactory: { +// address: "0x...", // TODO: confirm CoWShedFactory address on Arbitrum +// startBlock: 0, // TODO: set deployment block +// }, +// gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Arbitrum +// flashLoanRouter: null, // TODO: confirm via ROUTER() on Arbitrum AaveV3AdapterFactory +// aaveV3AdapterFactory: null, // TODO: verify on Arbiscan +// contractPollerInterval: 1, +// orderbookApiUrl: "https://api.cow.fi/arbitrum_one", +// }; diff --git a/src/chains/gnosis.ts b/src/chains/gnosis.ts new file mode 100644 index 0000000..9486be2 --- /dev/null +++ b/src/chains/gnosis.ts @@ -0,0 +1,30 @@ +import type { ChainConfig } from "./types"; + +export const gnosis: ChainConfig = { + name: "gnosis", + chainId: 100, + rpcEnvVar: "GNOSIS_RPC_URL", + blockTime: 5, + 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 + contractPollerInterval: 4, // ~20s — avoids wasteful RPC calls given 1,461+ generators + orderbookApiUrl: "https://api.cow.fi/xdai", +}; diff --git a/src/chains/index.ts b/src/chains/index.ts new file mode 100644 index 0000000..8807f53 --- /dev/null +++ b/src/chains/index.ts @@ -0,0 +1,23 @@ +export type { ChainConfig } from "./types"; + +import { mainnet } from "./mainnet"; +import { gnosis } from "./gnosis"; +// import { arbitrum } from "./arbitrum"; // uncomment to enable Arbitrum + +/** + * ACTIVE_CHAINS — the canonical list of chains this indexer processes. + * + * To add a chain: + * 1. Create src/chains/.ts implementing ChainConfig. + * 2. Import and append it here. + * 3. Update SupportedChainId in src/data.ts. + * 4. Ensure the RPC URL env var is set (see .env.local.example). + * + * To disable a chain temporarily, comment out its entry below — no other + * files need to change. ponder.config.ts derives everything from this array. + */ +export const ACTIVE_CHAINS = [ + mainnet, + gnosis, + // arbitrum, +]; diff --git a/src/chains/mainnet.ts b/src/chains/mainnet.ts new file mode 100644 index 0000000..9f457cd --- /dev/null +++ b/src/chains/mainnet.ts @@ -0,0 +1,27 @@ +import type { ChainConfig } from "./types"; + +export const mainnet: ChainConfig = { + name: "mainnet", + chainId: 1, + rpcEnvVar: "MAINNET_RPC_URL", + blockTime: 12, + 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", + contractPollerInterval: 1, + orderbookApiUrl: "https://api.cow.fi/mainnet", +}; diff --git a/src/chains/types.ts b/src/chains/types.ts new file mode 100644 index 0000000..c967bb1 --- /dev/null +++ b/src/chains/types.ts @@ -0,0 +1,60 @@ +/** + * 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. + * 3. Updating SupportedChainId in src/data.ts. + */ +export interface ChainConfig { + /** Ponder chain key (e.g. "mainnet", "gnosis"). Must match ponder chain names. */ + name: string; + /** Numeric EIP-155 chain ID (e.g. 1, 100). */ + chainId: number; + /** 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. + */ + cowShedFactory: { + address: `0x${string}` | readonly `0x${string}`[]; + startBlock: number; + }; + + /** + * 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 on this chain. + */ + aaveV3AdapterFactory: `0x${string}` | null; + + /** + * ContractPoller block interval override for this chain. + * Defaults to 1; Gnosis uses 4 (~20s) to avoid wasteful RPC calls. + */ + contractPollerInterval: number; + + /** CoW Protocol Orderbook API base URL for this chain. */ + orderbookApiUrl: string; +} diff --git a/src/data.ts b/src/data.ts index 4d7bf76..9865f34 100644 --- a/src/data.ts +++ b/src/data.ts @@ -1,7 +1,5 @@ -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 } from "./chains"; /** * Supported chain IDs — update this type when adding a new chain. @@ -10,114 +8,8 @@ import { ALL_HANDLER_ADDRESSES } from "./utils/order-types"; export type SupportedChainId = 1 | 100; // 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. @@ -128,21 +20,20 @@ export const ORDERBOOK_POLL_INTERVAL = 20; /** * 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 +50,38 @@ 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. + * Active chains are derived from ACTIVE_CHAINS; additional well-known chains are + * included for completeness (e.g. Arbitrum, Base, Sepolia) even if not currently indexed. */ export const ORDERBOOK_API_URLS: Record = { - 1: "https://api.cow.fi/mainnet", - 100: "https://api.cow.fi/xdai", + // Derive from ACTIVE_CHAINS first + ...Object.fromEntries(ACTIVE_CHAINS.map((c) => [c.chainId, c.orderbookApiUrl])), + // Additional well-known chains (not currently indexed, but useful for future expansion) 42161: "https://api.cow.fi/arbitrum_one", 8453: "https://api.cow.fi/base", 11155111: "https://api.cow.fi/sepolia", }; + +/** + * 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 GPV2_SETTLEMENT_DEPLOYMENTS: Record = + Object.fromEntries( + ACTIVE_CHAINS + .filter((c) => c.gpv2Settlement !== null) + .map((c) => [c.name, c.gpv2Settlement!]), + ); From 033598084508ec443b5897c4ed962f98b1b97d91 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 28 May 2026 11:47:16 -0300 Subject: [PATCH 04/89] test: add integration tests for fetchOrderStatusByUids (COW-979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify the { order } unwrap fix with a real HTTP server: 6 tests covering correct uid→status mapping, executed amounts, multiple orders, HTTP errors, and empty responses. Adds ponder/ponder:schema vitest stubs to resolve virtual module imports without a running Ponder process. Co-Authored-By: Claude Sonnet 4.6 --- tests/__mocks__/ponder-schema.ts | 4 + tests/__mocks__/ponder.ts | 8 ++ tests/helpers/orderbookClient.test.ts | 150 ++++++++++++++++++++++++++ vitest.config.ts | 11 ++ 4 files changed, 173 insertions(+) create mode 100644 tests/__mocks__/ponder-schema.ts create mode 100644 tests/__mocks__/ponder.ts create mode 100644 tests/helpers/orderbookClient.test.ts 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/helpers/orderbookClient.test.ts b/tests/helpers/orderbookClient.test.ts new file mode 100644 index 0000000..0905eaa --- /dev/null +++ b/tests/helpers/orderbookClient.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { fetchOrderStatusByUids } from "../../src/application/helpers/orderbookClient"; +import { ORDERBOOK_API_URLS } from "../../src/data"; + +// Isolated chain ID that doesn't exist in production — safe to mutate and delete. +const TEST_CHAIN_ID = 99_999; + +async function startServer( + handler: (req: IncomingMessage, res: ServerResponse) => void, +): 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())), + }; +} + +/** + * Minimal Ponder context stub. + * context.db.sql.execute returns [] (empty cache) so every UID goes to the live API path. + * Cache writes are no-ops. + */ +function makeContext() { + return { db: { sql: { execute: async () => [] } } }; +} + +/** Build a single `{ order: {...} }` item matching the real CoW Orderbook API shape. */ +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", + }, + }; +} + +// 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; + +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. + ORDERBOOK_API_URLS[TEST_CHAIN_ID] = "http://placeholder"; + }); + + afterAll(() => { + delete (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 () => { + // Bug: the API returns [{ order: { uid, status, ... } }] but the code was reading + // the array items as flat OrderbookOrder objects, so order.uid was always undefined. + // This caused fetchOrderStatusByUids to return an empty map for every candidate, + // silently skipping C2/C3 promotions for fulfilled/expired orders. + const { url, close } = await startServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify([makeWrappedOrder(UID_A, "fulfilled")])); + }); + 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")])); + }); + 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"), + ])); + }); + 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"); + }); + 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("[]"); + }); + 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(); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5fa9726..9cda082 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,18 @@ 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") }, + ], + }, }); From 97231b9b57c13a88afcdd86842d2ca7e6e25a87b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 28 May 2026 14:00:23 -0300 Subject: [PATCH 05/89] refactor: address PR review feedback on chain modularization (COW-978) - Use SupportedChainId from @cowprotocol/cow-sdk for chainId typing in ChainConfig - Derive contractPollerInterval from blockTime via pollerInterval() helper (~20s target) - Add arbitrum, base, and sepolia chain config files (cowShedFactory/gpv2Settlement marked null until addresses are confirmed) - Export ALL_DEFINED_CHAINS for ORDERBOOK_API_URLS; ACTIVE_CHAINS stays mainnet+gnosis - Make cowShedFactory nullable in ChainConfig; filter in ponder.config.ts Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 6 ++--- src/chains/arbitrum.ts | 50 ++++++++++++++++++++---------------------- src/chains/base.ts | 24 ++++++++++++++++++++ src/chains/gnosis.ts | 11 ++++++---- src/chains/index.ts | 30 +++++++++++++++---------- src/chains/mainnet.ts | 11 ++++++---- src/chains/sepolia.ts | 24 ++++++++++++++++++++ src/chains/types.ts | 27 +++++++++++++++++------ src/data.ts | 24 +++++++------------- 9 files changed, 136 insertions(+), 71 deletions(-) create mode 100644 src/chains/base.ts create mode 100644 src/chains/sepolia.ts diff --git a/ponder.config.ts b/ponder.config.ts index bb78769..e82501d 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -9,7 +9,7 @@ const chains = Object.fromEntries( ACTIVE_CHAINS.map((c) => [c.name, { id: c.chainId, rpc: process.env[c.rpcEnvVar]! }]), ); -// Chains that have GPv2Settlement indexed (non-null gpv2Settlement + flashLoanRouter) +const cowShedChains = ACTIVE_CHAINS.filter((c) => c.cowShedFactory !== null); const settlementChains = ACTIVE_CHAINS.filter( (c) => c.gpv2Settlement !== null && c.flashLoanRouter !== null, ); @@ -38,9 +38,9 @@ export default createConfig({ CoWShedFactory: { abi: CoWShedFactoryAbi, chain: Object.fromEntries( - ACTIVE_CHAINS.map((c) => [ + cowShedChains.map((c) => [ c.name, - { address: c.cowShedFactory.address, startBlock: c.cowShedFactory.startBlock }, + { address: c.cowShedFactory!.address, startBlock: c.cowShedFactory!.startBlock }, ]), ), }, diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts index 3ee15ef..e551151 100644 --- a/src/chains/arbitrum.ts +++ b/src/chains/arbitrum.ts @@ -1,26 +1,24 @@ -// Placeholder — addresses not yet confirmed. Enable when Arbitrum indexing is ready. -// -// import type { ChainConfig } from "./types"; -// -// export const arbitrum: ChainConfig = { -// name: "arbitrum", -// chainId: 42161, -// rpcEnvVar: "ARBITRUM_RPC_URL", -// blockTime: 1, // ~0.25s avg, treat as ~1s for polling purposes -// composableCow: { -// address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // TODO: confirm CREATE2 address on Arbitrum -// startBlock: 0, // TODO: set deployment block -// }, -// composableCowLive: { -// address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // TODO: confirm -// }, -// cowShedFactory: { -// address: "0x...", // TODO: confirm CoWShedFactory address on Arbitrum -// startBlock: 0, // TODO: set deployment block -// }, -// gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Arbitrum -// flashLoanRouter: null, // TODO: confirm via ROUTER() on Arbitrum AaveV3AdapterFactory -// aaveV3AdapterFactory: null, // TODO: verify on Arbiscan -// contractPollerInterval: 1, -// orderbookApiUrl: "https://api.cow.fi/arbitrum_one", -// }; +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 162066830, // TODO: verify exact ComposableCow deployment block on Arbitrum + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Arbitrum + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Arbitrum + flashLoanRouter: null, // TODO: confirm via ROUTER() on Arbitrum AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on Arbiscan + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/arbitrum_one", +}; diff --git a/src/chains/base.ts b/src/chains/base.ts new file mode 100644 index 0000000..9256ddb --- /dev/null +++ b/src/chains/base.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 15_000_000, // TODO: verify exact ComposableCow deployment block on Base + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Base + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Base + flashLoanRouter: null, // TODO: confirm via ROUTER() on Base AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on Basescan + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/base", +}; diff --git a/src/chains/gnosis.ts b/src/chains/gnosis.ts index 9486be2..732141a 100644 --- a/src/chains/gnosis.ts +++ b/src/chains/gnosis.ts @@ -1,10 +1,13 @@ -import type { ChainConfig } from "./types"; +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, type ChainConfig } from "./types"; + +const blockTime = 5; export const gnosis: ChainConfig = { name: "gnosis", - chainId: 100, + chainId: SupportedChainId.GNOSIS_CHAIN, rpcEnvVar: "GNOSIS_RPC_URL", - blockTime: 5, + blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", startBlock: 29389123, @@ -25,6 +28,6 @@ export const gnosis: ChainConfig = { }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // confirmed via ROUTER() on Gnosis AaveV3AdapterFactory aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", // verified on Gnosisscan - contractPollerInterval: 4, // ~20s — avoids wasteful RPC calls given 1,461+ generators + contractPollerInterval: pollerInterval(blockTime), orderbookApiUrl: "https://api.cow.fi/xdai", }; diff --git a/src/chains/index.ts b/src/chains/index.ts index 8807f53..eec5f30 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -1,23 +1,31 @@ -export type { ChainConfig } from "./types"; +export type { ChainConfig, SupportedChainId } from "./types"; import { mainnet } from "./mainnet"; import { gnosis } from "./gnosis"; -// import { arbitrum } from "./arbitrum"; // uncomment to enable Arbitrum +import { arbitrum } from "./arbitrum"; +import { base } from "./base"; +import { sepolia } from "./sepolia"; /** - * ACTIVE_CHAINS — the canonical list of chains this indexer processes. + * ALL_DEFINED_CHAINS — every chain that has a confirmed config file. + * Used to derive ORDERBOOK_API_URLS and other chain-wide lookups. * - * To add a chain: - * 1. Create src/chains/.ts implementing ChainConfig. - * 2. Import and append it here. - * 3. Update SupportedChainId in src/data.ts. - * 4. Ensure the RPC URL env var is set (see .env.local.example). + * Adding a new chain: create src/chains/.ts, import it here, + * and add it to this array. Then add it to ACTIVE_CHAINS when ready to index. + */ +export const ALL_DEFINED_CHAINS = [mainnet, gnosis, arbitrum, base, sepolia]; + +/** + * ACTIVE_CHAINS — the chains this indexer instance actually processes. * - * To disable a chain temporarily, comment out its entry below — no other - * files need to change. ponder.config.ts derives everything from this array. + * To enable a chain: uncomment its entry (ensure cowShedFactory is confirmed first). + * To disable a chain: comment out its entry — no other files need to change. + * ponder.config.ts derives all RPC/contract config from this array. */ export const ACTIVE_CHAINS = [ mainnet, gnosis, - // arbitrum, + // arbitrum, // TODO: confirm cowShedFactory address before enabling + // base, // TODO: confirm cowShedFactory address before enabling + // sepolia, // TODO: confirm cowShedFactory address before enabling ]; diff --git a/src/chains/mainnet.ts b/src/chains/mainnet.ts index 9f457cd..7378564 100644 --- a/src/chains/mainnet.ts +++ b/src/chains/mainnet.ts @@ -1,10 +1,13 @@ -import type { ChainConfig } from "./types"; +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, type ChainConfig } from "./types"; + +const blockTime = 12; export const mainnet: ChainConfig = { name: "mainnet", - chainId: 1, + chainId: SupportedChainId.MAINNET, rpcEnvVar: "MAINNET_RPC_URL", - blockTime: 12, + blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", startBlock: 17883049, @@ -22,6 +25,6 @@ export const mainnet: ChainConfig = { }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", - contractPollerInterval: 1, + contractPollerInterval: pollerInterval(blockTime), orderbookApiUrl: "https://api.cow.fi/mainnet", }; diff --git a/src/chains/sepolia.ts b/src/chains/sepolia.ts new file mode 100644 index 0000000..ff4784a --- /dev/null +++ b/src/chains/sepolia.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 4_000_000, // TODO: verify exact ComposableCow deployment block on Sepolia + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Sepolia + 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 + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/sepolia", +}; diff --git a/src/chains/types.ts b/src/chains/types.ts index c967bb1..cf2f329 100644 --- a/src/chains/types.ts +++ b/src/chains/types.ts @@ -1,3 +1,16 @@ +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.). @@ -5,13 +18,12 @@ * 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. - * 3. Updating SupportedChainId in src/data.ts. */ export interface ChainConfig { /** Ponder chain key (e.g. "mainnet", "gnosis"). Must match ponder chain names. */ name: string; - /** Numeric EIP-155 chain ID (e.g. 1, 100). */ - chainId: number; + /** 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. */ @@ -25,11 +37,12 @@ export interface ChainConfig { /** * 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. @@ -45,13 +58,13 @@ export interface ChainConfig { /** * AaveV3AdapterFactory address — used for view calls (not a Ponder-indexed contract). - * Null if not deployed on this chain. + * Null if not deployed/confirmed on this chain. */ aaveV3AdapterFactory: `0x${string}` | null; /** - * ContractPoller block interval override for this chain. - * Defaults to 1; Gnosis uses 4 (~20s) to avoid wasteful RPC calls. + * ContractPoller block interval for this chain. + * Derive with pollerInterval(blockTime) — targets ~20s between polls. */ contractPollerInterval: number; diff --git a/src/data.ts b/src/data.ts index 9865f34..5aee1c0 100644 --- a/src/data.ts +++ b/src/data.ts @@ -1,11 +1,8 @@ import { ALL_HANDLER_ADDRESSES } from "./utils/order-types"; -import { ACTIVE_CHAINS } from "./chains"; +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 export const GPV2_SETTLEMENT_ADDRESS = @@ -50,17 +47,12 @@ export const COMPOSABLE_COW_HANDLER_ADDRESSES = new Set(ALL_HANDLER_ADDRESSES); /** * CoW Protocol Orderbook API base URLs per chain ID. - * Active chains are derived from ACTIVE_CHAINS; additional well-known chains are - * included for completeness (e.g. Arbitrum, Base, Sepolia) even if not currently indexed. + * 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 = { - // Derive from ACTIVE_CHAINS first - ...Object.fromEntries(ACTIVE_CHAINS.map((c) => [c.chainId, c.orderbookApiUrl])), - // Additional well-known chains (not currently indexed, but useful for future expansion) - 42161: "https://api.cow.fi/arbitrum_one", - 8453: "https://api.cow.fi/base", - 11155111: "https://api.cow.fi/sepolia", -}; +export const ORDERBOOK_API_URLS: Record = Object.fromEntries( + ALL_DEFINED_CHAINS.map((c) => [c.chainId, c.orderbookApiUrl]), +); /** * AaveV3AdapterFactory addresses keyed by chain name. From a0dde977aae6c60d5ddeb64621e9126458b174fd Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 28 May 2026 17:44:34 -0300 Subject: [PATCH 06/89] docs: add bleu-context note to deployment scripts (COW-977) Clarify that manage.ts and deploy-remotely.ts are tailored to Bleu's internal deployment workflow, as requested in PR review. Co-Authored-By: Claude Sonnet 4.6 --- deployment/deploy-remotely.ts | 4 ++++ deployment/manage.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/deployment/deploy-remotely.ts b/deployment/deploy-remotely.ts index 1d315b8..37c9a53 100644 --- a/deployment/deploy-remotely.ts +++ b/deployment/deploy-remotely.ts @@ -3,6 +3,10 @@ * 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] * diff --git a/deployment/manage.ts b/deployment/manage.ts index 7db65f4..f1097a3 100644 --- a/deployment/manage.ts +++ b/deployment/manage.ts @@ -3,6 +3,9 @@ * 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 ] */ From dc92b48134928867781a0d640f6accf7cddd8eb3 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 28 May 2026 19:31:36 -0300 Subject: [PATCH 07/89] feat: add GET /api/sync-progress endpoint (COW-985) Returns per-chain historical sync progress as clean JSON: totalBlocks, processedBlocks, progressPct (0-100), isRealtime, isComplete. Reads from Ponder's Prometheus /metrics endpoint using the request origin so it works on any port. Registered in OpenAPI/Swagger. 6 integration tests covering: status code, chain entries, processedBlocks calculation, progressPct rounding, realtime/complete flags, and graceful degradation when /metrics is unreachable. Co-Authored-By: Claude Sonnet 4.6 --- src/api/endpoints/sync-progress.ts | 82 ++++++++++++++++++++++ src/api/router.ts | 8 ++- src/api/routes.ts | 18 +++++ src/api/schemas/sync-progress.ts | 27 +++++++ tests/api/sync-progress.test.ts | 109 +++++++++++++++++++++++++++++ 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 src/api/endpoints/sync-progress.ts create mode 100644 src/api/schemas/sync-progress.ts create mode 100644 tests/api/sync-progress.test.ts diff --git a/src/api/endpoints/sync-progress.ts b/src/api/endpoints/sync-progress.ts new file mode 100644 index 0000000..c72bb35 --- /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; + progressPct: 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, + progressPct: 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..cc7b777 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, progressPct 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/sync-progress.ts b/src/api/schemas/sync-progress.ts new file mode 100644 index 0000000..25f525a --- /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)."), + progressPct: 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/tests/api/sync-progress.test.ts b/tests/api/sync-progress.test.ts new file mode 100644 index 0000000..11aec16 --- /dev/null +++ b/tests/api/sync-progress.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Hono } from "hono"; +import { syncProgressHandler } from "../../src/api/endpoints/sync-progress"; + +function buildApp() { + const app = new Hono(); + app.get("/api/sync-progress", async (c) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return syncProgressHandler(c as any); + }); + 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(); + 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(); + // 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 progressPct 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(); + // mainnet: 3_000_000 / 7_000_000 = 42.857... → 42.9 + expect(body.mainnet.progressPct).toBe(42.9); + // gnosis: 2_400_000 / 17_000_000 = 14.117... → 14.1 + expect(body.gnosis.progressPct).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(); + 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(); + expect(body).toEqual({}); + }); +}); From 5de8d272f0d496f2c2088a5cea1df5c6ad96b36e Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 10:17:22 -0300 Subject: [PATCH 08/89] fix: resolve TypeScript strict-mode errors in sync-progress tests (COW-985) Add non-null assertions for Record index access under noUncheckedIndexedAccess. Co-Authored-By: Claude Sonnet 4.6 --- tests/api/sync-progress.test.ts | 42 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/tests/api/sync-progress.test.ts b/tests/api/sync-progress.test.ts index 11aec16..87857ad 100644 --- a/tests/api/sync-progress.test.ts +++ b/tests/api/sync-progress.test.ts @@ -1,13 +1,21 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +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; + progressPct: number; + isRealtime: boolean; + isComplete: boolean; +}; + function buildApp() { const app = new Hono(); - app.get("/api/sync-progress", async (c) => { + app.get("/api/sync-progress", (c) => // eslint-disable-next-line @typescript-eslint/no-explicit-any - return syncProgressHandler(c as any); - }); + syncProgressHandler(c as any, async () => {}), + ); return app; } @@ -61,7 +69,7 @@ describe("GET /api/sync-progress", () => { mockFetch(SAMPLE_METRICS); const app = buildApp(); const res = await app.request("http://localhost/api/sync-progress"); - const body = await res.json(); + const body = (await res.json()) as Record; expect(Object.keys(body)).toEqual(expect.arrayContaining(["mainnet", "gnosis"])); }); @@ -69,33 +77,33 @@ describe("GET /api/sync-progress", () => { mockFetch(SAMPLE_METRICS); const app = buildApp(); const res = await app.request("http://localhost/api/sync-progress"); - const body = await res.json(); + 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); + 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); + expect(body["gnosis"]!.processedBlocks).toBe(2_400_000); }); it("computes progressPct 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(); + const body = (await res.json()) as Record; // mainnet: 3_000_000 / 7_000_000 = 42.857... → 42.9 - expect(body.mainnet.progressPct).toBe(42.9); + expect(body["mainnet"]!.progressPct).toBe(42.9); // gnosis: 2_400_000 / 17_000_000 = 14.117... → 14.1 - expect(body.gnosis.progressPct).toBe(14.1); + expect(body["gnosis"]!.progressPct).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(); - expect(body.mainnet.isRealtime).toBe(false); - expect(body.mainnet.isComplete).toBe(false); - expect(body.gnosis.isRealtime).toBe(true); - expect(body.gnosis.isComplete).toBe(true); + 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 () => { @@ -103,7 +111,7 @@ describe("GET /api/sync-progress", () => { const app = buildApp(); const res = await app.request("http://localhost/api/sync-progress"); expect(res.status).toBe(200); - const body = await res.json(); + const body = (await res.json()) as Record; expect(body).toEqual({}); }); }); From dd442fc28c49b6b8345a305464d54d52ad28fd09 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz <50002794+lgahdl@users.noreply.github.com> Date: Fri, 29 May 2026 16:17:11 -0300 Subject: [PATCH 09/89] feat: consolidate deployment compose into root under --profile deploy (COW-976) (#68) Moves deployment/docker-compose.yml to the root docker-compose.yml, unifying dev and prod postgres into a single service. Production containers run under the "deploy" profile. Updates manage.ts and deployment docs accordingly. Default postgres credentials are env-var-substituted dev fallbacks, not real secrets. Co-authored-by: Claude Sonnet 4.6 --- deployment/docker-compose.yml | 66 --------------------------------- deployment/manage.ts | 12 ++++-- docker-compose.yml | 69 ++++++++++++++++++++++++++++++----- docs/deployment.md | 40 ++++++++++++-------- 4 files changed, 93 insertions(+), 94 deletions(-) delete mode 100644 deployment/docker-compose.yml diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml deleted file mode 100644 index 4ae9389..0000000 --- a/deployment/docker-compose.yml +++ /dev/null @@ -1,66 +0,0 @@ -services: - postgres: - image: postgres:16 - restart: unless-stopped - # Memory tuning values are computed for the default POSTGRES_MEMORY_LIMIT=1G (1024 MB): - # shared_buffers = 20% of 1024 MB = 204 MB - # maintenance_work_mem = 5% of 1024 MB = 51 MB - # effective_cache_size = 50% of 1024 MB = 512 MB - # work_mem = 25% of 1024 MB / 100 connections = 2 MB - # To adjust for a different memory limit, change the -c flags proportionally. - 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_DB: ${POSTGRES_DB:?error} - POSTGRES_USER: ${POSTGRES_USER:?error} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?error} - POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=en_US.UTF-8" - shm_size: 256m - ports: - - "${POSTGRES_PORT:-5433}:5432" - volumes: - - 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.ts b/deployment/manage.ts index f1097a3..78e34df 100644 --- a/deployment/manage.ts +++ b/deployment/manage.ts @@ -123,7 +123,9 @@ function cmdUp(projectPrefix: string, revision: string): void { "-p", projectPrefix, "-f", - "docker-compose.yml", + "../docker-compose.yml", + "--profile", + "deploy", "build", "--no-cache", ]); @@ -133,7 +135,9 @@ function cmdUp(projectPrefix: string, revision: string): void { "-p", projectPrefix, "-f", - "docker-compose.yml", + "../docker-compose.yml", + "--profile", + "deploy", "up", "-d", "--remove-orphans", @@ -172,7 +176,9 @@ function cmdDown(projectPrefix: string): void { "-p", projectPrefix, "-f", - "docker-compose.yml", + "../docker-compose.yml", + "--profile", + "deploy", "down", "-v", "--remove-orphans", 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/deployment.md b/docs/deployment.md index 29a4171..9edf137 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -15,6 +15,8 @@ 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 | @@ -36,7 +38,7 @@ Example: `DATABASE_URL=postgresql://cow_programmatic:secretpass@localhost:5433/c ### 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,7 +47,7 @@ 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. @@ -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,24 +81,32 @@ 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.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 +``` + +The deploy services (`postgres-deploy` and `ponder`) live in the root `docker-compose.yml` under the `deploy` profile. Start them with: + +```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). -### PostgreSQL Auto-Tuning +### PostgreSQL Memory Flags -`start-db.sh` tunes memory settings from `POSTGRES_MEMORY_LIMIT`. With the default 1G: +Memory settings are hardcoded in the `command:` block of `docker-compose.yml`, tuned for 1G RAM: -- `shared_buffers`: ~204MB -- `work_mem`: 2MB per connection -- `effective_cache_size`: 512MB +- `shared_buffers`: 204MB (~20% RAM) +- `work_mem`: 2MB per connection (~25% RAM / max_connections) +- `effective_cache_size`: 512MB (~50% RAM) - `maintenance_work_mem`: 51MB +Adjust these proportionally if you change the host's available memory. + ## Deploying @@ -115,17 +125,17 @@ The `Dockerfile` in the project root builds the Ponder image: two-stage Node 22 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` +To tear down: `npx tsx deployment/manage.ts 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). +The current deploy profile in `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 From e5b15d5e4177fbd1e70a088e20a81bf35ef79921 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz <50002794+lgahdl@users.noreply.github.com> Date: Fri, 29 May 2026 16:17:18 -0300 Subject: [PATCH 10/89] fix: update deploy.yml to use deploy-remotely.ts (COW-980) (#69) deploy-remotely.sh was deleted by COW-977. Add Node/pnpm setup steps and switch the run command to npx tsx deployment/deploy-remotely.ts. Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/deploy.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) 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 From c8dd4b4028d57c64b5481b391e942244c6006655 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz <50002794+lgahdl@users.noreply.github.com> Date: Fri, 29 May 2026 16:17:23 -0300 Subject: [PATCH 11/89] feat: add Docker image publish workflow to ghcr.io (COW-981) (#70) Builds and pushes the image to ghcr.io on every push to main. Tags: full git SHA (for Kubernetes to pin), branch name, and latest. Uses GitHub Actions layer cache to speed up repeat builds. Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/docker.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/docker.yml 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 }} From 42dac5f8649b81217e3607613d9269ae6ad4fe2e Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz <50002794+lgahdl@users.noreply.github.com> Date: Fri, 29 May 2026 16:17:27 -0300 Subject: [PATCH 12/89] docs: add operational guide to README for non-Ponder operators (COW-984) (#73) Add "Is it working?" section explaining what to check during and after backfill, how to distinguish stuck from slow, and what each health endpoint signals. Addresses client feedback that the README should be fool-proof for operators with no Ponder background. Co-authored-by: Claude Sonnet 4.6 --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 | From 7b917974c545bf6c227c6bb1a77522459f82fdc5 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz <50002794+lgahdl@users.noreply.github.com> Date: Fri, 29 May 2026 16:17:53 -0300 Subject: [PATCH 13/89] fix: derive active chain descriptions from CHAIN_NAMES (COW-982) (#71) * fix: derive active chain descriptions from CHAIN_NAMES in data.ts (COW-982) Add CHAIN_NAMES map to src/data.ts as single source of truth for chain labels. ChainIdQuery description now derives from it dynamically. Update docs/api-reference.md and docs/architecture.md to remove hardcoded chain references and point to src/data.ts instead. Co-Authored-By: Claude Sonnet 4.6 * test: verify CHAIN_NAMES drives ChainIdQuery description (COW-982) 4 tests: CHAIN_NAMES has expected entries, all names are non-empty, ChainIdQuery.description contains every id+name pair from CHAIN_NAMES, and the description is not the old hardcoded string. Co-Authored-By: Claude Sonnet 4.6 * refactor: derive CHAIN_NAMES from cow-sdk, drop chain-names test (COW-982) Addresses reviewer feedback: import chain labels from getChainInfo() instead of hardcoding, and remove the low-value chain-names unit test. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- docs/api-reference.md | 4 ++++ docs/architecture.md | 14 +++++++------- src/api/schemas/common.ts | 7 ++++++- src/data.ts | 9 +++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 36c680f..6800c27 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -107,9 +107,13 @@ The `conditionalOrderGenerator.decodedParams` JSON encodes Solidity struct field ## Indexed chains +The active chain list is the `SupportedChainId` type and `CHAIN_NAMES` map in `src/data.ts`. Currently: + | Chain | Chain ID | |-------|----------| | Ethereum mainnet | 1 | | Gnosis Chain | 100 | Filter queries with `where: { chainId: 1 }` (GraphQL) or `?chainId=1` (REST). + +> Adding a chain: extend `SupportedChainId`, `CHAIN_NAMES`, and the per-chain config maps in `src/data.ts`. The API schema descriptions derive from `CHAIN_NAMES` automatically. diff --git a/docs/architecture.md b/docs/architecture.md index d11b3ed..2533e17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,7 +30,7 @@ Three contracts are indexed: ## Data Flow ``` -ComposableCoW contract (mainnet + gnosis) +ComposableCoW contract (all chains in SupportedChainId — see src/data.ts) | | ConditionalOrderCreated events v @@ -173,15 +173,15 @@ Stats are accumulated and logged every 30 seconds to track throughput without pe 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. -**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`. +**C1 — ContractPoller** (every block): 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`. -**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. +**C2 — CandidateConfirmer** (every block): 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. -**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. +**C3 — StatusUpdater** (every block): 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. -**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. +**C4 — HistoricalBootstrap** (fires once at latest block): 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. -**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`. +**C5 — DeterministicCancellationSweeper** (every block): 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`. ## Order Types and Decoders @@ -226,7 +226,7 @@ See [api-reference.md](./api-reference.md) for the full endpoint list. 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`. +The block handlers (C1–C5) run on all chains in `ACTIVE_CHAINS` (see `src/data.ts`). Adding a new chain: extend `SupportedChainId`, add the per-chain config maps in `src/data.ts`, and add the chain to `ponder.config.ts`. ## Known Limitations 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/data.ts b/src/data.ts index 4d7bf76..88fcac5 100644 --- a/src/data.ts +++ b/src/data.ts @@ -1,3 +1,4 @@ +import { getChainInfo } from "@cowprotocol/cow-sdk"; import { ComposableCowAbi } from "../abis/ComposableCowAbi"; import { CoWShedFactoryAbi } from "../abis/CoWShedFactoryAbi"; import { GPv2SettlementAbi } from "../abis/GPv2SettlementAbi"; @@ -126,6 +127,14 @@ export const FLASH_LOAN_ROUTER_ADDRESSES = { */ export const ORDERBOOK_POLL_INTERVAL = 20; +// Derived from cow-sdk so chain labels stay in sync with the rest of CoW Protocol tooling. +export const CHAIN_NAMES: Record = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 1: getChainInfo(1)!.eip155Label, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 100: getChainInfo(100)!.eip155Label, +}; + /** * Approximate block time in seconds per chain ID. * Used by the block handler to estimate block numbers from epoch timestamps (PollTryAtEpoch). From ae1a750603390a764dae163b3c2900833ea5b08e Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz <50002794+lgahdl@users.noreply.github.com> Date: Fri, 29 May 2026 16:17:58 -0300 Subject: [PATCH 14/89] docs: document /ready /status /metrics and Kubernetes probes (COW-983) (#72) * docs: document /ready /status /metrics endpoints and k8s probes (COW-983) Add /ready, /status, and /metrics to the endpoints table with full descriptions. Document Kubernetes liveness/readiness probe config. Explain /status response shape and what "stuck" looks like vs normal backfill. Addresses client feedback about underdocumented health endpoints. Co-Authored-By: Claude Sonnet 4.6 * test: add unit tests for GET /healthz endpoint (COW-983) 3 tests: verifies 200 status, {"status":"ok"} body, and JSON content-type. Matches the behaviour documented in docs/api-reference.md. Co-Authored-By: Claude Sonnet 4.6 * refactor: remove redundant healthz test and probe doc sections (COW-983) Addresses reviewer feedback: endpoint descriptions in the table are sufficient; the dedicated sub-sections and the healthz unit test are overhead. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- docs/api-reference.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 6800c27..9d20194 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -13,7 +13,10 @@ 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. | +| `/healthz` | GET | Liveness probe. Always returns `200 { "status": "ok" }` if the process is up. | +| `/ready` | GET | Readiness probe. Returns `200` once historical sync is complete; `503` with `{ "message": "Historical indexing is not complete." }` while still backfilling. | +| `/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 From 62d40c6dab6422e8e6065d8156633961c3b0ae5c Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 16:30:00 -0300 Subject: [PATCH 15/89] feat: add chain stubs for all 12 cow-sdk SupportedChainId entries (COW-978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every chain in cow-sdk's ALL_SUPPORTED_CHAIN_IDS now has a config file in src/chains/. New stubs (BNB, Polygon, Lens, Plasma, Avalanche, Ink, Linea) follow the same pattern as existing stubs — composableCow address is the known CREATE2 deployment; all other addresses are null with TODOs pointing to the relevant block explorer. None are active until addresses are confirmed (tracked in COW-986). Co-Authored-By: Claude Sonnet 4.6 --- src/chains/avalanche.ts | 24 ++++++++++++++++++++ src/chains/bnb.ts | 24 ++++++++++++++++++++ src/chains/index.ts | 50 +++++++++++++++++++++++++++++++++++------ src/chains/ink.ts | 24 ++++++++++++++++++++ src/chains/lens.ts | 24 ++++++++++++++++++++ src/chains/linea.ts | 24 ++++++++++++++++++++ src/chains/plasma.ts | 24 ++++++++++++++++++++ src/chains/polygon.ts | 24 ++++++++++++++++++++ 8 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 src/chains/avalanche.ts create mode 100644 src/chains/bnb.ts create mode 100644 src/chains/ink.ts create mode 100644 src/chains/lens.ts create mode 100644 src/chains/linea.ts create mode 100644 src/chains/plasma.ts create mode 100644 src/chains/polygon.ts diff --git a/src/chains/avalanche.ts b/src/chains/avalanche.ts new file mode 100644 index 0000000..a160b95 --- /dev/null +++ b/src/chains/avalanche.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 0, // TODO: verify ComposableCow deployment block on Avalanche (check snowscan.xyz) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Avalanche + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Avalanche + flashLoanRouter: null, // TODO: confirm via ROUTER() on Avalanche AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on snowscan.xyz + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/avalanche", // TODO: verify CoW Protocol orderbook URL for Avalanche +}; diff --git a/src/chains/bnb.ts b/src/chains/bnb.ts new file mode 100644 index 0000000..1529c27 --- /dev/null +++ b/src/chains/bnb.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 0, // TODO: verify ComposableCow deployment block on BNB (check bscscan.com) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on BNB + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on BNB + flashLoanRouter: null, // TODO: confirm via ROUTER() on BNB AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on bscscan.com + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/bnb", // TODO: verify CoW Protocol orderbook URL for BNB +}; diff --git a/src/chains/index.ts b/src/chains/index.ts index eec5f30..1ba3f29 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -5,21 +5,50 @@ 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 — every chain that has a confirmed config file. - * Used to derive ORDERBOOK_API_URLS and other chain-wide lookups. + * ALL_DEFINED_CHAINS — one entry per chain in cow-sdk's ALL_SUPPORTED_CHAIN_IDS. * - * Adding a new chain: create src/chains/.ts, import it here, - * and add it to this array. Then add it to ACTIVE_CHAINS when ready to index. + * 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). + * + * Chains with unconfirmed addresses should have null fields and be kept out of + * ACTIVE_CHAINS until all required addresses are verified. */ -export const ALL_DEFINED_CHAINS = [mainnet, gnosis, arbitrum, base, sepolia]; +export const ALL_DEFINED_CHAINS = [ + // --- Fully configured --- + mainnet, + gnosis, + // --- Partially configured (addresses confirmed, not yet active) --- + arbitrum, + base, + sepolia, + // --- Stubs: added to mirror cow-sdk's ALL_SUPPORTED_CHAIN_IDS; contract + // addresses must be verified before enabling in ACTIVE_CHAINS (COW-986) --- + bnb, + polygon, + lens, + plasma, + avalanche, + ink, + linea, +]; /** * ACTIVE_CHAINS — the chains this indexer instance actually processes. * - * To enable a chain: uncomment its entry (ensure cowShedFactory is confirmed first). - * To disable a chain: comment out its entry — no other files need to change. + * To enable a chain: move it here from the stub list above (ensure all contract + * addresses in its ChainConfig are confirmed — no null fields that are required + * at runtime). To disable a chain: remove it from this array. * ponder.config.ts derives all RPC/contract config from this array. */ export const ACTIVE_CHAINS = [ @@ -28,4 +57,11 @@ export const ACTIVE_CHAINS = [ // arbitrum, // TODO: confirm cowShedFactory address before enabling // base, // TODO: confirm cowShedFactory address before enabling // sepolia, // TODO: confirm cowShedFactory address before enabling + // bnb, // TODO: verify all contract addresses (COW-986) + // polygon, // TODO: verify all contract addresses (COW-986) + // lens, // TODO: verify all contract addresses (COW-986) + // plasma, // TODO: verify all contract addresses (COW-986) + // avalanche,// TODO: verify all contract addresses (COW-986) + // ink, // TODO: verify all contract addresses (COW-986) + // linea, // TODO: verify all contract addresses (COW-986) ]; diff --git a/src/chains/ink.ts b/src/chains/ink.ts new file mode 100644 index 0000000..5a7130b --- /dev/null +++ b/src/chains/ink.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 0, // TODO: verify ComposableCow deployment block on Ink (check explorer.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 + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/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..8ccc2e4 --- /dev/null +++ b/src/chains/lens.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 0, // TODO: verify ComposableCow deployment block on Lens (check explorer.lens.xyz) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Lens + 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 + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/lens", // TODO: verify CoW Protocol orderbook URL for Lens +}; diff --git a/src/chains/linea.ts b/src/chains/linea.ts new file mode 100644 index 0000000..987e68d --- /dev/null +++ b/src/chains/linea.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 0, // TODO: verify ComposableCow deployment block on Linea (check lineascan.build) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Linea + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Linea + flashLoanRouter: null, // TODO: confirm via ROUTER() on Linea AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on lineascan.build + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/linea", // TODO: verify CoW Protocol orderbook URL for Linea +}; diff --git a/src/chains/plasma.ts b/src/chains/plasma.ts new file mode 100644 index 0000000..7345c7a --- /dev/null +++ b/src/chains/plasma.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 0, // TODO: verify ComposableCow deployment block on Plasma (check plasmascan.to) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Plasma + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Plasma + flashLoanRouter: null, // TODO: confirm via ROUTER() on Plasma AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on plasmascan.to + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/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..e221ac1 --- /dev/null +++ b/src/chains/polygon.ts @@ -0,0 +1,24 @@ +import { SupportedChainId } from "@cowprotocol/cow-sdk"; +import { pollerInterval, 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: 0, // TODO: verify ComposableCow deployment block on Polygon (check polygonscan.com) + }, + composableCowLive: { + address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", + }, + cowShedFactory: null, // TODO: confirm CoWShedFactory address on Polygon + gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Polygon + flashLoanRouter: null, // TODO: confirm via ROUTER() on Polygon AaveV3AdapterFactory + aaveV3AdapterFactory: null, // TODO: verify on polygonscan.com + contractPollerInterval: pollerInterval(blockTime), + orderbookApiUrl: "https://api.cow.fi/polygon", // TODO: verify CoW Protocol orderbook URL for Polygon +}; From 8227547a2acf9e53bc297b791661f459a2a30991 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 16:34:04 -0300 Subject: [PATCH 16/89] docs: document GET /api/sync-progress endpoint (COW-985) Add entry in REST endpoints section and a dedicated subsection with example response and field descriptions. Co-Authored-By: Claude Sonnet 4.6 --- docs/api-reference.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 36c680f..036e4f8 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -31,13 +31,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, + "progressPct": 42.9, + "isRealtime": false, + "isComplete": false + }, + "gnosis": { + "totalBlocks": 17000000, + "processedBlocks": 17000000, + "progressPct": 100.0, + "isRealtime": true, + "isComplete": true + } +} +``` + +- `progressPct` 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). From 02b97e199fece8d680030a8d36518fd6a95b333b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 16:34:46 -0300 Subject: [PATCH 17/89] docs: fix stale references and remove duplicated content (COW-987) Fix deploy-remotely.sh/.ts, manage.sh/.ts, remove deleted static/start-db.sh, remove duplicate API Endpoints section, update chain config references from src/data.ts to src/chains/index.ts, and update Adding a New Chain steps. Co-Authored-By: Claude Sonnet 4.6 --- docs/api-reference.md | 8 ++++---- docs/architecture.md | 27 ++++++++++++--------------- docs/deployment.md | 25 +++++++------------------ 3 files changed, 23 insertions(+), 37 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 9d20194..9d1416c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -110,13 +110,13 @@ The `conditionalOrderGenerator.decodedParams` JSON encodes Solidity struct field ## Indexed chains -The active chain list is the `SupportedChainId` type and `CHAIN_NAMES` map in `src/data.ts`. Currently: +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: extend `SupportedChainId`, `CHAIN_NAMES`, and the per-chain config maps in `src/data.ts`. The API schema descriptions derive from `CHAIN_NAMES` automatically. +> 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 2533e17..ff5abef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,16 +10,16 @@ Ponder registers nine top-level handlers: four contract event handlers (`Composa ## 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: -- **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 +- **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 -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. +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). -`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. 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. Three contracts are indexed: @@ -30,7 +30,7 @@ Three contracts are indexed: ## Data Flow ``` -ComposableCoW contract (all chains in SupportedChainId — see src/data.ts) +ComposableCoW contract (see `src/chains/index.ts`) | | ConditionalOrderCreated events v @@ -219,14 +219,11 @@ 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) run on all chains in `ACTIVE_CHAINS` (see `src/data.ts`). Adding a new chain: extend `SupportedChainId`, add the per-chain config maps in `src/data.ts`, and add the chain to `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 diff --git a/docs/deployment.md b/docs/deployment.md index 9edf137..4e75934 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -22,7 +22,7 @@ The indexer is RPC-heavy during initial sync. Rate-limited endpoints will work b | 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` @@ -50,7 +50,7 @@ Used by `docker-compose.yml` (deploy profile) and `deployment/manage.ts`: | `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 @@ -83,9 +83,8 @@ Production uses the `deploy` profile in the root `docker-compose.yml`, which run ``` docker-compose.yml # root compose file — dev postgres (default) + deploy profile deployment/ - 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 deploy services (`postgres-deploy` and `ponder`) live in the root `docker-compose.yml` under the `deploy` profile. Start them with: @@ -112,14 +111,14 @@ Adjust these proportionally if you change the host's available memory. ### 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: @@ -137,16 +136,6 @@ For a production setup, run at least two containers: one dedicated to indexing a The current deploy profile in `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 - No monitoring or alerting. Watch container logs and the `/healthz` endpoint. Standard observability tooling (Prometheus, Grafana) can be wired up but nothing is preconfigured. From b40791a9df52e03847748d7237ce131780478d87 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 16:46:06 -0300 Subject: [PATCH 18/89] feat: research and update contract deployment info for all 12 chains (COW-986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add verified ComposableCoW deployment blocks where confirmed on-chain via cowprotocol/composable-cow networks.json cross-referenced with block explorers and public RPC nodes. Also verify orderbook API URLs against cow-sdk ORDER_BOOK_PROD_CONFIG source. Verified startBlocks: - Arbitrum (42161): 204751436 (networks.json + arbiscan) - Base (8453): 21794150 (basescan, 2024-10-31) - Sepolia (11155111): 5072748 (networks.json + sepolia.etherscan.io, 2024-01-12) - BNB (56): 48433175 (networks.json + bscscan.com, 2025-04-17) - Polygon (137): 70406888 (polygonscan.com, 2025-04-17) - Lens (232): 3516559 (networks.json + rpc.lens.xyz, 2025-09) - Plasma (9745): 4810535 (networks.json + rpc.plasma.to) - Avalanche (43114): 60434336 (snowscan.xyz, 2025-04-17) - Ink (57073): 34878187 (Blockscout API + rpc-gel.inkonchain.com) - Linea (59144): 25028474 (networks.json + lineascan.build) Verified orderbook API URLs (all return HTTP 200 from api.cow.fi): - bnb, polygon, plasma, avalanche, ink, linea — confirmed active - lens — NOT yet in cow-sdk ORDER_BOOK_PROD_CONFIG; api.cow.fi/lens returns 404 Also restore CHAIN_NAMES export in src/data.ts (removed during merge conflict resolution) — derived from ACTIVE_CHAINS.name instead of the old getChainInfo(). Co-Authored-By: Claude Sonnet 4.6 --- src/chains/arbitrum.ts | 2 +- src/chains/avalanche.ts | 4 ++-- src/chains/base.ts | 2 +- src/chains/bnb.ts | 4 ++-- src/chains/ink.ts | 4 ++-- src/chains/lens.ts | 4 ++-- src/chains/linea.ts | 4 ++-- src/chains/plasma.ts | 4 ++-- src/chains/polygon.ts | 4 ++-- src/chains/sepolia.ts | 2 +- src/data.ts | 8 ++++++++ 11 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts index e551151..328187c 100644 --- a/src/chains/arbitrum.ts +++ b/src/chains/arbitrum.ts @@ -10,7 +10,7 @@ export const arbitrum: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 162066830, // TODO: verify exact ComposableCow deployment block on Arbitrum + startBlock: 204751436, // verified: tx 0xede8f4305385f5df63d5221d1377380724c11781000b30a29cf636241abaa59f (cowprotocol/composable-cow networks.json + arbiscan) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/avalanche.ts b/src/chains/avalanche.ts index a160b95..e170898 100644 --- a/src/chains/avalanche.ts +++ b/src/chains/avalanche.ts @@ -10,7 +10,7 @@ export const avalanche: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Avalanche (check snowscan.xyz) + startBlock: 60434336, // verified: tx 0xaa800a7183e8313e11a0024a8fe189770c33aaf8fc1451a3a5c373898e25fefa (snowscan.xyz, 2025-04-17) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,5 +20,5 @@ export const avalanche: ChainConfig = { flashLoanRouter: null, // TODO: confirm via ROUTER() on Avalanche AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on snowscan.xyz contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/avalanche", // TODO: verify CoW Protocol orderbook URL for Avalanche + orderbookApiUrl: "https://api.cow.fi/avalanche", // verified: api.cow.fi/avalanche returns 200 (ORDER_BOOK_PROD_CONFIG in cow-sdk) }; diff --git a/src/chains/base.ts b/src/chains/base.ts index 9256ddb..cecc3e0 100644 --- a/src/chains/base.ts +++ b/src/chains/base.ts @@ -10,7 +10,7 @@ export const base: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 15_000_000, // TODO: verify exact ComposableCow deployment block on Base + startBlock: 21794150, // verified: tx 0xdfa9fded3b1743ce2556a245b17690b073cdd9d59739b60d5e4091e445d732b7 (basescan, 2024-10-31) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/bnb.ts b/src/chains/bnb.ts index 1529c27..94942cc 100644 --- a/src/chains/bnb.ts +++ b/src/chains/bnb.ts @@ -10,7 +10,7 @@ export const bnb: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on BNB (check bscscan.com) + startBlock: 48433175, // verified: tx 0x6595bc3c236157c5a164eb37267486b3c2f6eee02d2e6d9068550e939b18ed71 (cowprotocol/composable-cow networks.json + bscscan.com, 2025-04-17) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,5 +20,5 @@ export const bnb: ChainConfig = { flashLoanRouter: null, // TODO: confirm via ROUTER() on BNB AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on bscscan.com contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/bnb", // TODO: verify CoW Protocol orderbook URL for BNB + orderbookApiUrl: "https://api.cow.fi/bnb", // verified: api.cow.fi/bnb returns 200 (ORDER_BOOK_PROD_CONFIG in cow-sdk) }; diff --git a/src/chains/ink.ts b/src/chains/ink.ts index 5a7130b..4b9394c 100644 --- a/src/chains/ink.ts +++ b/src/chains/ink.ts @@ -10,7 +10,7 @@ export const ink: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Ink (check explorer.inkonchain.com) + startBlock: 34878187, // verified: tx 0xf21049cccc6ea17370e6d3650e689cf3c5be0a097a035953501218a14b8f030f (explorer.inkonchain.com Blockscout API + rpc-gel.inkonchain.com) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,5 +20,5 @@ export const ink: ChainConfig = { flashLoanRouter: null, // TODO: confirm via ROUTER() on Ink AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on explorer.inkonchain.com contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/ink", // TODO: verify CoW Protocol orderbook URL for Ink + orderbookApiUrl: "https://api.cow.fi/ink", // verified: api.cow.fi/ink returns 200 (ORDER_BOOK_PROD_CONFIG in cow-sdk) }; diff --git a/src/chains/lens.ts b/src/chains/lens.ts index 8ccc2e4..f9d4191 100644 --- a/src/chains/lens.ts +++ b/src/chains/lens.ts @@ -10,7 +10,7 @@ export const lens: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Lens (check explorer.lens.xyz) + startBlock: 3516559, // verified: tx 0x39105403b3b7ee84959807135fbebb1bba1de86f85916295d99ff69617c15ae0 (cowprotocol/composable-cow networks.json + rpc.lens.xyz, 2025-09) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,5 +20,5 @@ export const lens: ChainConfig = { flashLoanRouter: null, // TODO: confirm via ROUTER() on Lens AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on explorer.lens.xyz contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/lens", // TODO: verify CoW Protocol orderbook URL for Lens + orderbookApiUrl: "https://api.cow.fi/lens", // TODO: Lens is NOT in cow-sdk ORDER_BOOK_PROD_CONFIG yet; api.cow.fi/lens returns 404 — verify when CoW Protocol adds Lens orderbook support }; diff --git a/src/chains/linea.ts b/src/chains/linea.ts index 987e68d..88ed983 100644 --- a/src/chains/linea.ts +++ b/src/chains/linea.ts @@ -10,7 +10,7 @@ export const linea: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Linea (check lineascan.build) + startBlock: 25028474, // verified: tx 0x61f2e7ecec07f7b5c93d491f460cca41eba991fbb022f6866ee17510c9e61151 (cowprotocol/composable-cow networks.json + lineascan.build) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,5 +20,5 @@ export const linea: ChainConfig = { flashLoanRouter: null, // TODO: confirm via ROUTER() on Linea AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on lineascan.build contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/linea", // TODO: verify CoW Protocol orderbook URL for Linea + orderbookApiUrl: "https://api.cow.fi/linea", // verified: api.cow.fi/linea returns 200 (ORDER_BOOK_PROD_CONFIG in cow-sdk) }; diff --git a/src/chains/plasma.ts b/src/chains/plasma.ts index 7345c7a..d2c7498 100644 --- a/src/chains/plasma.ts +++ b/src/chains/plasma.ts @@ -10,7 +10,7 @@ export const plasma: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Plasma (check plasmascan.to) + startBlock: 4810535, // verified: tx 0xa4db8e5f949f39af60460fc05979b363b01570970e94eb8397dc39cfbdcaed86 (cowprotocol/composable-cow networks.json + rpc.plasma.to) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,5 +20,5 @@ export const plasma: ChainConfig = { flashLoanRouter: null, // TODO: confirm via ROUTER() on Plasma AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on plasmascan.to contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/plasma", // TODO: verify CoW Protocol orderbook URL for Plasma + orderbookApiUrl: "https://api.cow.fi/plasma", // verified: api.cow.fi/plasma returns 200 (ORDER_BOOK_PROD_CONFIG in cow-sdk) }; diff --git a/src/chains/polygon.ts b/src/chains/polygon.ts index e221ac1..59828b4 100644 --- a/src/chains/polygon.ts +++ b/src/chains/polygon.ts @@ -10,7 +10,7 @@ export const polygon: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Polygon (check polygonscan.com) + startBlock: 70406888, // verified: tx 0xef1fdc60092220b9137d2b23189499d995119c281cad648710ac3636bbebf17a (polygonscan.com, 2025-04-17) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", @@ -20,5 +20,5 @@ export const polygon: ChainConfig = { flashLoanRouter: null, // TODO: confirm via ROUTER() on Polygon AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on polygonscan.com contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/polygon", // TODO: verify CoW Protocol orderbook URL for Polygon + orderbookApiUrl: "https://api.cow.fi/polygon", // verified: api.cow.fi/polygon returns 200 (ORDER_BOOK_PROD_CONFIG in cow-sdk) }; diff --git a/src/chains/sepolia.ts b/src/chains/sepolia.ts index ff4784a..3e4efd2 100644 --- a/src/chains/sepolia.ts +++ b/src/chains/sepolia.ts @@ -10,7 +10,7 @@ export const sepolia: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 4_000_000, // TODO: verify exact ComposableCow deployment block on Sepolia + startBlock: 5072748, // verified: tx 0xed9625240dec4803ea76358bcac3d4c8678b81a6ffddd50c0326c12626d3f38e (cowprotocol/composable-cow networks.json + sepolia.etherscan.io, 2024-01-12) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/data.ts b/src/data.ts index 5aee1c0..fc686fe 100644 --- a/src/data.ts +++ b/src/data.ts @@ -15,6 +15,14 @@ export const GPV2_SETTLEMENT_ADDRESS = */ 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. * Derived from ACTIVE_CHAINS — update chain files to change block times. From 72ac1a09a92d9753645dbde4fef87dd988120edb Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 17:45:14 -0300 Subject: [PATCH 19/89] refactor: remove contractPollerInterval from ChainConfig, rename orderbookApiUrl to orderbookApiPath (COW-978) - Remove contractPollerInterval field from ChainConfig; compute inline in ponder.config.ts via pollerInterval() - Rename orderbookApiUrl -> orderbookApiPath storing only the path suffix (e.g. "mainnet", "xdai") - Update src/data.ts to construct full URL from path: https://api.cow.fi/${c.orderbookApiPath} - Update all 12 chain files accordingly Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 3 ++- src/chains/arbitrum.ts | 5 ++--- src/chains/avalanche.ts | 5 ++--- src/chains/base.ts | 5 ++--- src/chains/bnb.ts | 5 ++--- src/chains/gnosis.ts | 5 ++--- src/chains/ink.ts | 5 ++--- src/chains/lens.ts | 5 ++--- src/chains/linea.ts | 5 ++--- src/chains/mainnet.ts | 5 ++--- src/chains/plasma.ts | 5 ++--- src/chains/polygon.ts | 5 ++--- src/chains/sepolia.ts | 5 ++--- src/chains/types.ts | 9 +++------ src/data.ts | 2 +- 15 files changed, 30 insertions(+), 44 deletions(-) diff --git a/ponder.config.ts b/ponder.config.ts index e82501d..2f07c92 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -1,5 +1,6 @@ import { createConfig } from "ponder"; 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"; @@ -73,7 +74,7 @@ export default createConfig({ c.name, { startBlock: "latest" as const, - ...(c.contractPollerInterval > 1 ? { interval: c.contractPollerInterval } : {}), + ...(pollerInterval(c.blockTime) > 1 ? { interval: pollerInterval(c.blockTime) } : {}), }, ]), ), diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts index e551151..ce449ac 100644 --- a/src/chains/arbitrum.ts +++ b/src/chains/arbitrum.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 1; // ~0.25s avg; use 1s as a conservative estimate for polling math @@ -19,6 +19,5 @@ export const arbitrum: ChainConfig = { gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Arbitrum flashLoanRouter: null, // TODO: confirm via ROUTER() on Arbitrum AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on Arbiscan - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/arbitrum_one", + orderbookApiPath: "arbitrum_one", }; diff --git a/src/chains/avalanche.ts b/src/chains/avalanche.ts index a160b95..9d8895d 100644 --- a/src/chains/avalanche.ts +++ b/src/chains/avalanche.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 2; // ~2s per block on Avalanche C-Chain @@ -19,6 +19,5 @@ export const avalanche: ChainConfig = { gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Avalanche flashLoanRouter: null, // TODO: confirm via ROUTER() on Avalanche AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on snowscan.xyz - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/avalanche", // TODO: verify CoW Protocol orderbook URL for Avalanche + orderbookApiPath: "avalanche", // TODO: verify CoW Protocol orderbook URL for Avalanche }; diff --git a/src/chains/base.ts b/src/chains/base.ts index 9256ddb..b352356 100644 --- a/src/chains/base.ts +++ b/src/chains/base.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 2; // ~2s per block on Base @@ -19,6 +19,5 @@ export const base: ChainConfig = { gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Base flashLoanRouter: null, // TODO: confirm via ROUTER() on Base AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on Basescan - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/base", + orderbookApiPath: "base", }; diff --git a/src/chains/bnb.ts b/src/chains/bnb.ts index 1529c27..9bdbbb0 100644 --- a/src/chains/bnb.ts +++ b/src/chains/bnb.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 3; // ~3s per block on BNB Chain @@ -19,6 +19,5 @@ export const bnb: ChainConfig = { gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on BNB flashLoanRouter: null, // TODO: confirm via ROUTER() on BNB AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on bscscan.com - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/bnb", // TODO: verify CoW Protocol orderbook URL for BNB + orderbookApiPath: "bnb", // TODO: verify CoW Protocol orderbook URL for BNB }; diff --git a/src/chains/gnosis.ts b/src/chains/gnosis.ts index 732141a..cfbdc18 100644 --- a/src/chains/gnosis.ts +++ b/src/chains/gnosis.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 5; @@ -28,6 +28,5 @@ export const gnosis: ChainConfig = { }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", // confirmed via ROUTER() on Gnosis AaveV3AdapterFactory aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", // verified on Gnosisscan - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/xdai", + orderbookApiPath: "xdai", }; diff --git a/src/chains/ink.ts b/src/chains/ink.ts index 5a7130b..bf387fd 100644 --- a/src/chains/ink.ts +++ b/src/chains/ink.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 2; // ~2s per block on Ink Chain (OP-based L2) @@ -19,6 +19,5 @@ export const ink: ChainConfig = { 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 - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/ink", // TODO: verify CoW Protocol orderbook URL for Ink + orderbookApiPath: "ink", // TODO: verify CoW Protocol orderbook URL for Ink }; diff --git a/src/chains/lens.ts b/src/chains/lens.ts index 8ccc2e4..60c02d6 100644 --- a/src/chains/lens.ts +++ b/src/chains/lens.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 1; // ~1s per block on Lens Chain (zkSync-based L2) @@ -19,6 +19,5 @@ export const lens: ChainConfig = { 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 - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/lens", // TODO: verify CoW Protocol orderbook URL for Lens + orderbookApiPath: "lens", // TODO: verify CoW Protocol orderbook URL for Lens }; diff --git a/src/chains/linea.ts b/src/chains/linea.ts index 987e68d..b74ab78 100644 --- a/src/chains/linea.ts +++ b/src/chains/linea.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 3; // ~3s per block on Linea @@ -19,6 +19,5 @@ export const linea: ChainConfig = { gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Linea flashLoanRouter: null, // TODO: confirm via ROUTER() on Linea AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on lineascan.build - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/linea", // TODO: verify CoW Protocol orderbook URL for Linea + orderbookApiPath: "linea", // TODO: verify CoW Protocol orderbook URL for Linea }; diff --git a/src/chains/mainnet.ts b/src/chains/mainnet.ts index 7378564..58a5b25 100644 --- a/src/chains/mainnet.ts +++ b/src/chains/mainnet.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 12; @@ -25,6 +25,5 @@ export const mainnet: ChainConfig = { }, flashLoanRouter: "0x9da8B48441583a2b93e2eF8213aAD0EC0b392C69", aaveV3AdapterFactory: "0xdeCc46a4b09162f5369c5c80383aaa9159bcf192", - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/mainnet", + orderbookApiPath: "mainnet", }; diff --git a/src/chains/plasma.ts b/src/chains/plasma.ts index 7345c7a..fa0f273 100644 --- a/src/chains/plasma.ts +++ b/src/chains/plasma.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 1; // ~1s per block on Plasma (L2) @@ -19,6 +19,5 @@ export const plasma: ChainConfig = { gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Plasma flashLoanRouter: null, // TODO: confirm via ROUTER() on Plasma AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on plasmascan.to - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/plasma", // TODO: verify CoW Protocol orderbook URL for Plasma + orderbookApiPath: "plasma", // TODO: verify CoW Protocol orderbook URL for Plasma }; diff --git a/src/chains/polygon.ts b/src/chains/polygon.ts index e221ac1..c2dc9a3 100644 --- a/src/chains/polygon.ts +++ b/src/chains/polygon.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 2; // ~2s per block on Polygon @@ -19,6 +19,5 @@ export const polygon: ChainConfig = { gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Polygon flashLoanRouter: null, // TODO: confirm via ROUTER() on Polygon AaveV3AdapterFactory aaveV3AdapterFactory: null, // TODO: verify on polygonscan.com - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/polygon", // TODO: verify CoW Protocol orderbook URL for Polygon + orderbookApiPath: "polygon", // TODO: verify CoW Protocol orderbook URL for Polygon }; diff --git a/src/chains/sepolia.ts b/src/chains/sepolia.ts index ff4784a..32f6bcd 100644 --- a/src/chains/sepolia.ts +++ b/src/chains/sepolia.ts @@ -1,5 +1,5 @@ import { SupportedChainId } from "@cowprotocol/cow-sdk"; -import { pollerInterval, type ChainConfig } from "./types"; +import { type ChainConfig } from "./types"; const blockTime = 12; // ~12s per block on Sepolia (same as mainnet) @@ -19,6 +19,5 @@ export const sepolia: ChainConfig = { 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 - contractPollerInterval: pollerInterval(blockTime), - orderbookApiUrl: "https://api.cow.fi/sepolia", + orderbookApiPath: "sepolia", }; diff --git a/src/chains/types.ts b/src/chains/types.ts index cf2f329..5d0b480 100644 --- a/src/chains/types.ts +++ b/src/chains/types.ts @@ -63,11 +63,8 @@ export interface ChainConfig { aaveV3AdapterFactory: `0x${string}` | null; /** - * ContractPoller block interval for this chain. - * Derive with pollerInterval(blockTime) — targets ~20s between polls. + * 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. */ - contractPollerInterval: number; - - /** CoW Protocol Orderbook API base URL for this chain. */ - orderbookApiUrl: string; + orderbookApiPath: string; } diff --git a/src/data.ts b/src/data.ts index 5aee1c0..dcbe7d4 100644 --- a/src/data.ts +++ b/src/data.ts @@ -51,7 +51,7 @@ export const COMPOSABLE_COW_HANDLER_ADDRESSES = new Set(ALL_HANDLER_ADDRESSES); * including inactive ones used for API-only lookups. */ export const ORDERBOOK_API_URLS: Record = Object.fromEntries( - ALL_DEFINED_CHAINS.map((c) => [c.chainId, c.orderbookApiUrl]), + ALL_DEFINED_CHAINS.map((c) => [c.chainId, `https://api.cow.fi/${c.orderbookApiPath}`]), ); /** From 67820718e7f05296686aa5995e59aa5ae65a0541 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 17:48:21 -0300 Subject: [PATCH 20/89] chore: fill in verified ComposableCow startBlock for all chains (COW-978) Replace placeholder startBlocks (0 / rough estimates) with values verified from cowprotocol/composable-cow networks.json and block explorers. Co-Authored-By: Claude Sonnet 4.6 --- src/chains/arbitrum.ts | 2 +- src/chains/avalanche.ts | 2 +- src/chains/base.ts | 2 +- src/chains/bnb.ts | 2 +- src/chains/ink.ts | 2 +- src/chains/lens.ts | 2 +- src/chains/linea.ts | 2 +- src/chains/plasma.ts | 2 +- src/chains/polygon.ts | 2 +- src/chains/sepolia.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts index ce449ac..b741721 100644 --- a/src/chains/arbitrum.ts +++ b/src/chains/arbitrum.ts @@ -10,7 +10,7 @@ export const arbitrum: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 162066830, // TODO: verify exact ComposableCow deployment block on Arbitrum + startBlock: 204751436, // verified: tx 0xede8f4305385f5df63d5221d1377380724c11781000b30a29cf636241abaa59f (cowprotocol/composable-cow networks.json + arbiscan) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/avalanche.ts b/src/chains/avalanche.ts index 9d8895d..260e5ad 100644 --- a/src/chains/avalanche.ts +++ b/src/chains/avalanche.ts @@ -10,7 +10,7 @@ export const avalanche: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Avalanche (check snowscan.xyz) + startBlock: 60434336, // verified: tx 0xaa800a7183e8313e11a0024a8fe189770c33aaf8fc1451a3a5c373898e25fefa (snowscan.xyz, 2025-04-17) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/base.ts b/src/chains/base.ts index b352356..5859e27 100644 --- a/src/chains/base.ts +++ b/src/chains/base.ts @@ -10,7 +10,7 @@ export const base: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 15_000_000, // TODO: verify exact ComposableCow deployment block on Base + startBlock: 21794150, // verified: tx 0xdfa9fded3b1743ce2556a245b17690b073cdd9d59739b60d5e4091e445d732b7 (basescan, 2024-10-31) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/bnb.ts b/src/chains/bnb.ts index 9bdbbb0..caa4a3d 100644 --- a/src/chains/bnb.ts +++ b/src/chains/bnb.ts @@ -10,7 +10,7 @@ export const bnb: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on BNB (check bscscan.com) + startBlock: 48433175, // verified: tx 0x6595bc3c236157c5a164eb37267486b3c2f6eee02d2e6d9068550e939b18ed71 (cowprotocol/composable-cow networks.json + bscscan.com, 2025-04-17) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/ink.ts b/src/chains/ink.ts index bf387fd..4e76142 100644 --- a/src/chains/ink.ts +++ b/src/chains/ink.ts @@ -10,7 +10,7 @@ export const ink: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Ink (check explorer.inkonchain.com) + startBlock: 34878187, // verified: tx 0xf21049cccc6ea17370e6d3650e689cf3c5be0a097a035953501218a14b8f030f (explorer.inkonchain.com Blockscout API + rpc-gel.inkonchain.com) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/lens.ts b/src/chains/lens.ts index 60c02d6..a546bbd 100644 --- a/src/chains/lens.ts +++ b/src/chains/lens.ts @@ -10,7 +10,7 @@ export const lens: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Lens (check explorer.lens.xyz) + startBlock: 3516559, // verified: tx 0x39105403b3b7ee84959807135fbebb1bba1de86f85916295d99ff69617c15ae0 (cowprotocol/composable-cow networks.json + rpc.lens.xyz, 2025-09) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/linea.ts b/src/chains/linea.ts index b74ab78..a5aaed4 100644 --- a/src/chains/linea.ts +++ b/src/chains/linea.ts @@ -10,7 +10,7 @@ export const linea: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Linea (check lineascan.build) + startBlock: 25028474, // verified: tx 0x61f2e7ecec07f7b5c93d491f460cca41eba991fbb022f6866ee17510c9e61151 (cowprotocol/composable-cow networks.json + lineascan.build) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/plasma.ts b/src/chains/plasma.ts index fa0f273..895b182 100644 --- a/src/chains/plasma.ts +++ b/src/chains/plasma.ts @@ -10,7 +10,7 @@ export const plasma: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Plasma (check plasmascan.to) + startBlock: 4810535, // verified: tx 0xa4db8e5f949f39af60460fc05979b363b01570970e94eb8397dc39cfbdcaed86 (cowprotocol/composable-cow networks.json + rpc.plasma.to) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/polygon.ts b/src/chains/polygon.ts index c2dc9a3..c9ea808 100644 --- a/src/chains/polygon.ts +++ b/src/chains/polygon.ts @@ -10,7 +10,7 @@ export const polygon: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 0, // TODO: verify ComposableCow deployment block on Polygon (check polygonscan.com) + startBlock: 70406888, // verified: tx 0xef1fdc60092220b9137d2b23189499d995119c281cad648710ac3636bbebf17a (polygonscan.com, 2025-04-17) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", diff --git a/src/chains/sepolia.ts b/src/chains/sepolia.ts index 32f6bcd..e698eae 100644 --- a/src/chains/sepolia.ts +++ b/src/chains/sepolia.ts @@ -10,7 +10,7 @@ export const sepolia: ChainConfig = { blockTime, composableCow: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", // CREATE2 — same across chains - startBlock: 4_000_000, // TODO: verify exact ComposableCow deployment block on Sepolia + startBlock: 5072748, // verified: tx 0xed9625240dec4803ea76358bcac3d4c8678b81a6ffddd50c0326c12626d3f38e (cowprotocol/composable-cow networks.json + sepolia.etherscan.io, 2024-01-12) }, composableCowLive: { address: "0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74", From 46a8f85587c17983f0ef30e7fa675baaf0e7eb79 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 18:42:43 -0300 Subject: [PATCH 21/89] chore: fill in verified aaveV3AdapterFactory, flashLoanRouter and gpv2Settlement for 5 chains (COW-978) All addresses confirmed on-chain via ROUTER() call. Deployment blocks verified by binary search on each chain's RPC. Chains: arbitrum, base, avalanche, linea, polygon Co-Authored-By: Claude Sonnet 4.6 --- src/chains/arbitrum.ts | 9 ++++++--- src/chains/avalanche.ts | 9 ++++++--- src/chains/base.ts | 9 ++++++--- src/chains/linea.ts | 9 ++++++--- src/chains/polygon.ts | 9 ++++++--- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/chains/arbitrum.ts b/src/chains/arbitrum.ts index 84a7ab0..f4b0d0d 100644 --- a/src/chains/arbitrum.ts +++ b/src/chains/arbitrum.ts @@ -19,8 +19,11 @@ export const arbitrum: ChainConfig = { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains startBlock: 358667546, // verified: tx 0x97b8fa7baf78bca1836e6a7cdce3bd0b983fa96352fc168ebf5f24ba63f23a91 }, - gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Arbitrum - flashLoanRouter: null, // TODO: confirm via ROUTER() on Arbitrum AaveV3AdapterFactory - aaveV3AdapterFactory: null, // TODO: verify on Arbiscan + 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 index 8611502..fbdb127 100644 --- a/src/chains/avalanche.ts +++ b/src/chains/avalanche.ts @@ -19,8 +19,11 @@ export const avalanche: ChainConfig = { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains startBlock: 65617025, // verified: tx 0xcf5f0c9a40d26d09e497a6ce871df31ca13d8e72b1724d8ba015368cf36068f1 }, - gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Avalanche - flashLoanRouter: null, // TODO: confirm via ROUTER() on Avalanche AaveV3AdapterFactory - aaveV3AdapterFactory: null, // TODO: verify on snowscan.xyz + 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 index 2f0825e..ee24034 100644 --- a/src/chains/base.ts +++ b/src/chains/base.ts @@ -19,8 +19,11 @@ export const base: ChainConfig = { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains startBlock: 32986811, // verified: tx 0x29c63a32edbf7b29ae73e6a8e5293b65c65906943ad41092205a473e4bbf56d0 }, - gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Base - flashLoanRouter: null, // TODO: confirm via ROUTER() on Base AaveV3AdapterFactory - aaveV3AdapterFactory: null, // TODO: verify on Basescan + 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/linea.ts b/src/chains/linea.ts index 0cff651..a2ade34 100644 --- a/src/chains/linea.ts +++ b/src/chains/linea.ts @@ -19,8 +19,11 @@ export const linea: ChainConfig = { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains startBlock: 25033271, // verified: tx 0xad527499a510773fed02f46787d8ed9190d52fe40997c661353805e2bc056a65 }, - gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Linea - flashLoanRouter: null, // TODO: confirm via ROUTER() on Linea AaveV3AdapterFactory - aaveV3AdapterFactory: null, // TODO: verify on lineascan.build + 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/polygon.ts b/src/chains/polygon.ts index efc4b8e..6ccec47 100644 --- a/src/chains/polygon.ts +++ b/src/chains/polygon.ts @@ -19,8 +19,11 @@ export const polygon: ChainConfig = { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains startBlock: 74072686, // verified: tx 0x9d877eaa06776c30a409fc31db365e8441f982598586345d53ffaee4f9d2da6d }, - gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Polygon - flashLoanRouter: null, // TODO: confirm via ROUTER() on Polygon AaveV3AdapterFactory - aaveV3AdapterFactory: null, // TODO: verify on polygonscan.com + 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 }; From 0ad999f1f97cd67d916776a62713df2b795aad04 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 18:49:17 -0300 Subject: [PATCH 22/89] chore: fill in verified aaveV3AdapterFactory, flashLoanRouter and gpv2Settlement for BNB and Plasma (COW-978) Co-Authored-By: Claude Sonnet 4.6 --- src/chains/bnb.ts | 9 ++++++--- src/chains/plasma.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/chains/bnb.ts b/src/chains/bnb.ts index 6fc78ef..ffdedcb 100644 --- a/src/chains/bnb.ts +++ b/src/chains/bnb.ts @@ -19,8 +19,11 @@ export const bnb: ChainConfig = { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains startBlock: 61362362, // verified: tx 0x76d25671fd1c31044a6cf481df15649fc3503cf5a492de92be8601fee02e259f }, - gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on BNB - flashLoanRouter: null, // TODO: confirm via ROUTER() on BNB AaveV3AdapterFactory - aaveV3AdapterFactory: null, // TODO: verify on bscscan.com + 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/plasma.ts b/src/chains/plasma.ts index 2afdd35..3967640 100644 --- a/src/chains/plasma.ts +++ b/src/chains/plasma.ts @@ -19,8 +19,11 @@ export const plasma: ChainConfig = { address: "0x312f92fe5f1710408b20d52a374fa29e099cfa86", // CREATE2 — same across chains startBlock: 4803028, // verified: tx 0x33d7ed32d433467d75373baf0bcbc99fec65df8a8fd6f67673efa8378f67ebcc }, - gpv2Settlement: null, // TODO: enable once AaveV3AdapterFactory is confirmed on Plasma - flashLoanRouter: null, // TODO: confirm via ROUTER() on Plasma AaveV3AdapterFactory - aaveV3AdapterFactory: null, // TODO: verify on plasmascan.to + 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 }; From 7b08af585e3fc6a030120350f28fa72542aeb352 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 18:58:37 -0300 Subject: [PATCH 23/89] =?UTF-8?q?chore:=20update=20ACTIVE=5FCHAINS=20comme?= =?UTF-8?q?nts=20=E2=80=94=20all=20addresses=20verified,=20blocked=20on=20?= =?UTF-8?q?RPC=20provisioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/chains/index.ts | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/src/chains/index.ts b/src/chains/index.ts index 1ba3f29..0ba6278 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -20,20 +20,13 @@ import { linea } from "./linea"; * 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). - * - * Chains with unconfirmed addresses should have null fields and be kept out of - * ACTIVE_CHAINS until all required addresses are verified. */ export const ALL_DEFINED_CHAINS = [ - // --- Fully configured --- mainnet, gnosis, - // --- Partially configured (addresses confirmed, not yet active) --- arbitrum, base, sepolia, - // --- Stubs: added to mirror cow-sdk's ALL_SUPPORTED_CHAIN_IDS; contract - // addresses must be verified before enabling in ACTIVE_CHAINS (COW-986) --- bnb, polygon, lens, @@ -46,22 +39,21 @@ export const ALL_DEFINED_CHAINS = [ /** * ACTIVE_CHAINS — the chains this indexer instance actually processes. * - * To enable a chain: move it here from the stub list above (ensure all contract - * addresses in its ChainConfig are confirmed — no null fields that are required - * at runtime). To disable a chain: remove it from this array. + * 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, // TODO: confirm cowShedFactory address before enabling - // base, // TODO: confirm cowShedFactory address before enabling - // sepolia, // TODO: confirm cowShedFactory address before enabling - // bnb, // TODO: verify all contract addresses (COW-986) - // polygon, // TODO: verify all contract addresses (COW-986) - // lens, // TODO: verify all contract addresses (COW-986) - // plasma, // TODO: verify all contract addresses (COW-986) - // avalanche,// TODO: verify all contract addresses (COW-986) - // ink, // TODO: verify all contract addresses (COW-986) - // linea, // TODO: verify all contract addresses (COW-986) + // arbitrum, // addresses verified — enable when ARBITRUM_RPC_URL is provisioned + // base, // addresses verified — enable when BASE_RPC_URL is provisioned + // bnb, // addresses verified — enable when BNB_RPC_URL is provisioned + // polygon, // addresses verified — enable when POLYGON_RPC_URL is provisioned + // avalanche,// addresses verified — enable when AVALANCHE_RPC_URL is provisioned + // linea, // addresses verified — enable when LINEA_RPC_URL is provisioned + // plasma, // addresses verified — enable when PLASMA_RPC_URL is provisioned + // lens, // addresses verified — enable when LENS_RPC_URL is provisioned (orderbook not live yet) + // sepolia, // addresses verified — enable when SEPOLIA_RPC_URL is provisioned + // ink, // cowShedFactory not deployed on Ink — enable when confirmed ]; From b24ab1a169e30a6bca2e70851a51b85c83cbbfc2 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Fri, 29 May 2026 19:00:19 -0300 Subject: [PATCH 24/89] =?UTF-8?q?chore:=20clarify=20ACTIVE=5FCHAINS=20comm?= =?UTF-8?q?ents=20=E2=80=94=20null=20fields=20are=20not=20runtime=20blocke?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/chains/index.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/chains/index.ts b/src/chains/index.ts index 0ba6278..fcc50b1 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -46,14 +46,14 @@ export const ALL_DEFINED_CHAINS = [ export const ACTIVE_CHAINS = [ mainnet, gnosis, - // arbitrum, // addresses verified — enable when ARBITRUM_RPC_URL is provisioned - // base, // addresses verified — enable when BASE_RPC_URL is provisioned - // bnb, // addresses verified — enable when BNB_RPC_URL is provisioned - // polygon, // addresses verified — enable when POLYGON_RPC_URL is provisioned - // avalanche,// addresses verified — enable when AVALANCHE_RPC_URL is provisioned - // linea, // addresses verified — enable when LINEA_RPC_URL is provisioned - // plasma, // addresses verified — enable when PLASMA_RPC_URL is provisioned - // lens, // addresses verified — enable when LENS_RPC_URL is provisioned (orderbook not live yet) - // sepolia, // addresses verified — enable when SEPOLIA_RPC_URL is provisioned - // ink, // cowShedFactory not deployed on Ink — enable when confirmed + // 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) ]; From 2737eab9dbb97290fb318fca25384be0173d398c Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 13:27:00 -0300 Subject: [PATCH 25/89] test: add pnpm test to CI and expand test coverage (COW-995) - Add pnpm test step to CI workflow after codegen - Add ponder:api mock and expand vitest.config.ts aliases - New tests: execution-summary endpoint (5), orders-by-owner endpoint (5+1 todo), erc1271Signature decoder (10) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 3 + tests/__mocks__/ponder-api.ts | 16 ++ tests/api/execution-summary.test.ts | 96 ++++++++++ tests/api/orders-by-owner.test.ts | 179 ++++++++++++++++++ .../decoders/erc1271Signature.test.ts | 162 ++++++++++++++++ vitest.config.ts | 12 ++ 6 files changed, 468 insertions(+) create mode 100644 tests/__mocks__/ponder-api.ts create mode 100644 tests/api/execution-summary.test.ts create mode 100644 tests/api/orders-by-owner.test.ts create mode 100644 tests/application/decoders/erc1271Signature.test.ts 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/tests/__mocks__/ponder-api.ts b/tests/__mocks__/ponder-api.ts new file mode 100644 index 0000000..33c414d --- /dev/null +++ b/tests/__mocks__/ponder-api.ts @@ -0,0 +1,16 @@ +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. +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()), + /** Helper used by tests to program per-call return values. */ + __makeSelectChain: makeSelectChain, +}; diff --git a/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts new file mode 100644 index 0000000..edcd80c --- /dev/null +++ b/tests/api/execution-summary.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { OpenAPIHono } from "@hono/zod-openapi"; + +// Mock virtual modules before any ponder-importing source files are loaded. +vi.mock("ponder:api", () => ({ db: { execute: vi.fn() } })); +vi.mock("ponder", () => ({ + sql: Object.assign( + (_s: TemplateStringsArray, ..._v: unknown[]) => ({}), + { raw: (_s: string) => ({}) }, + ), +})); + +import { db } from "ponder:api"; +import { executionSummaryRoute } from "../../src/api/routes"; +import { executionSummaryHandler } from "../../src/api/endpoints/execution-summary"; + +type StatusRow = { status: string; count: string }; + +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}`; +} + +beforeEach(() => { + vi.mocked(db.execute).mockReset(); +}); + +describe("GET /api/generator/:eventId/execution-summary", () => { + it("returns all-zero counts when no discrete orders exist", async () => { + vi.mocked(db.execute).mockResolvedValue({ rows: [] } 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: "fulfilled", count: "3" }, + { status: "expired", count: "7" }, + { status: "open", count: "2" }, + ]; + vi.mocked(db.execute).mockResolvedValue({ 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: "fulfilled", count: "10" }, + { status: "cancelled", count: "5" }, + { status: "unfilled", count: "3" }, + ]; + vi.mocked(db.execute).mockResolvedValue({ 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.execute).mockResolvedValue({ rows: [] } 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 app = buildApp(); + const res = await app.request( + `http://localhost/generator/${EVENT_ID}/execution-summary`, + ); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts new file mode 100644 index 0000000..84e8dbf --- /dev/null +++ b/tests/api/orders-by-owner.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Mock virtual modules before any ponder-importing source files are loaded. +vi.mock("ponder:api", () => ({ db: { execute: vi.fn(), select: vi.fn() } })); +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 { 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, + }; +} + +/** Returns a db.select chain that resolves to `rows`. */ +function makeChain(rows: unknown[]) { + const where = vi.fn().mockResolvedValue(rows); + const from = vi.fn().mockReturnValue({ where }); + return { from }; +} + +const GENERATOR = { + eventId: EVENT_ID, + chainId: CHAIN_ID, + orderType: "TWAP", + owner: OWNER, + resolvedOwner: OWNER, + status: "Active", + ownerAddressType: null, +}; + +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(makeChain([]) as never) // ownerMapping → no proxies + .mockReturnValueOnce(makeChain([]) 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(makeChain([]) as never) + .mockReturnValueOnce(makeChain([GENERATOR]) as never) + .mockReturnValueOnce(makeChain([]) 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", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeChain([]) as never) + .mockReturnValueOnce(makeChain([GENERATOR]) as never) + .mockReturnValueOnce(makeChain([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"); + }); + + it("serialises creationDate as a decimal string (BigInt scalar)", async () => { + vi.mocked(db.select) + .mockReturnValueOnce(makeChain([]) as never) + .mockReturnValueOnce(makeChain([GENERATOR]) as never) + .mockReturnValueOnce(makeChain([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(makeChain([{ address: PROXY }]) as never) + .mockReturnValueOnce(makeChain([GENERATOR]) as never) + .mockReturnValueOnce(makeChain([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); + }); + + // Regression guard for COW-993 (F15): hash must be present in generator object. + // Enable this test after the F15 fix lands (add `hash` to GeneratorSummary schema + // and select it in ordersByOwnerHandler). + it.todo("includes hash in generator object (COW-993)"); +}); diff --git a/tests/application/decoders/erc1271Signature.test.ts b/tests/application/decoders/erc1271Signature.test.ts new file mode 100644 index 0000000..35e71bf --- /dev/null +++ b/tests/application/decoders/erc1271Signature.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { encodeAbiParameters, getAddress, type Hex } from "viem"; +import { decodeEip1271Signature } 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; + +// The PayloadStruct ABI as defined in the decoder — must match exactly. +const PAYLOAD_STRUCT_ABI = [ + { + type: "tuple" as const, + name: "payload", + components: [ + { name: "proof", type: "bytes32[]" as const }, + { + type: "tuple" as const, + name: "params", + components: [ + { name: "handler", type: "address" as const }, + { name: "salt", type: "bytes32" as const }, + { name: "staticInput", type: "bytes" as const }, + ], + }, + { name: "offchainInput", type: "bytes" as const }, + ], + }, +] as const; + +/** 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/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") }, + ], + }, }); From 07478fca0de3fa273e10e7a26572497f07bcbe78 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 15:33:57 -0300 Subject: [PATCH 26/89] fix: minor cleanups from grant review (COW-996, COW-999, COW-1003) - COW-1003 [F2]: add CirclesBackingOrder to DETERMINISTIC_ORDER_TYPES; it was already precomputed in uidPrecompute.ts but missing from the set, causing spurious non-deterministic log warnings - COW-996 [F3]: replace nonexistent ConditionalOrderCancelled event reference with accurate description of actual cancellation detection (SingleOrderNotAuthed + C5 singleOrders() sweep) - COW-999 [F10]: remove decode-only-for-logging dead code in settlement.ts; decodeAbiParameters block and console.log were decoding Trade log fields solely for a log line, with no downstream use Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/composableCow.ts | 6 ++-- src/application/handlers/settlement.ts | 21 +----------- src/utils/order-types.ts | 2 +- tests/utils/order-types.test.ts | 39 +++++++++++++++++++++++ 4 files changed, 44 insertions(+), 24 deletions(-) create mode 100644 tests/utils/order-types.test.ts diff --git a/src/application/handlers/composableCow.ts b/src/application/handlers/composableCow.ts index 44f7858..ced8075 100644 --- a/src/application/handlers/composableCow.ts +++ b/src/application/handlers/composableCow.ts @@ -24,9 +24,9 @@ * * 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 (C1 block handler) and the C5 singleOrders() sweep, + * 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. diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 7304b07..60ffda5 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -1,7 +1,7 @@ import { ponder } from "ponder:registry"; import { AddressType, conditionalOrderGenerator, ownerMapping, transaction } from "ponder:schema"; import { and, eq } from "ponder"; -import { decodeAbiParameters, keccak256, toBytes } from "viem"; +import { keccak256, toBytes } from "viem"; import { AaveV3AdapterHelperAbi } from "../../../abis/AaveV3AdapterHelperAbi"; import { AAVE_V3_ADAPTER_FACTORY_ADDRESSES, @@ -191,20 +191,6 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { ), ); - // 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(); @@ -212,11 +198,6 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { `[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}`, ); diff --git a/src/utils/order-types.ts b/src/utils/order-types.ts index 707eabf..a9540df 100644 --- a/src/utils/order-types.ts +++ b/src/utils/order-types.ts @@ -77,7 +77,7 @@ 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 const DETERMINISTIC_ORDER_TYPES = new Set(["TWAP", "StopLoss", "CirclesBackingOrder"]); export function isDeterministicOrderType(orderType: string): boolean { return DETERMINISTIC_ORDER_TYPES.has(orderType as OrderType); diff --git a/tests/utils/order-types.test.ts b/tests/utils/order-types.test.ts new file mode 100644 index 0000000..cd0c5ad --- /dev/null +++ b/tests/utils/order-types.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { + DETERMINISTIC_ORDER_TYPES, + isDeterministicOrderType, +} from "../../src/utils/order-types"; + +describe("DETERMINISTIC_ORDER_TYPES", () => { + it("includes TWAP", () => { + expect(DETERMINISTIC_ORDER_TYPES.has("TWAP")).toBe(true); + }); + + it("includes StopLoss", () => { + expect(DETERMINISTIC_ORDER_TYPES.has("StopLoss")).toBe(true); + }); + + // Regression guard for COW-1003 (F2): CirclesBackingOrder is deterministic + // (precomputed in uidPrecompute.ts) but was missing from this set, causing + // spurious non-deterministic warnings in logs. + it("includes CirclesBackingOrder (COW-1003)", () => { + expect(DETERMINISTIC_ORDER_TYPES.has("CirclesBackingOrder")).toBe(true); + }); + + it("does not include non-deterministic types", () => { + expect(DETERMINISTIC_ORDER_TYPES.has("PerpetualSwap")).toBe(false); + expect(DETERMINISTIC_ORDER_TYPES.has("GoodAfterTime")).toBe(false); + expect(DETERMINISTIC_ORDER_TYPES.has("TradeAboveThreshold")).toBe(false); + }); + + it("isDeterministicOrderType returns true for all members", () => { + for (const type of DETERMINISTIC_ORDER_TYPES) { + expect(isDeterministicOrderType(type)).toBe(true); + } + }); + + it("isDeterministicOrderType returns false for unknown types", () => { + expect(isDeterministicOrderType("Unknown")).toBe(false); + expect(isDeterministicOrderType("")).toBe(false); + }); +}); From e83977c1e69ee45102ee253de0f54df548b4a3b8 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 15:39:28 -0300 Subject: [PATCH 27/89] fix: schema hygiene and SQL parameterization (COW-997, COW-998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COW-997 [F4]: remove dead cow_cache.orderbook_cache table creation from setup.ts; table had 0 rows and 0 reads/writes — live cache is order_uid_cache - COW-998 [F6]: replace string interpolation in getCachedUidStatuses with sql.join() parameterized query, consistent with cache write pattern in same file Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/setup.ts | 15 +++------------ src/application/helpers/orderbookClient.ts | 10 ++++------ 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/application/handlers/setup.ts b/src/application/handlers/setup.ts index bc4b219..95a9ea6 100644 --- a/src/application/handlers/setup.ts +++ b/src/application/handlers/setup.ts @@ -2,13 +2,13 @@ import { ponder } from "ponder:registry"; import { sql } from "ponder"; /** - * 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 +18,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 ( diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 5ef7b2d..ef3c57a 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -483,13 +483,11 @@ 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 uidList = sql.join(batch.map((uid) => sql`${uid}`), sql`, `); 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})`, - ), + sql`SELECT order_uid, status, executed_sell_amount, executed_buy_amount + FROM cow_cache.order_uid_cache + WHERE chain_id = ${chainId} AND order_uid IN (${uidList})`, )) as { order_uid: string; status: string; executed_sell_amount: string | null; executed_buy_amount: string | null }[]; for (const row of rows) { result.set(row.order_uid, { From 294fc9da4476f1304ef7b19360c96fb424fe7c63 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 15:40:33 -0300 Subject: [PATCH 28/89] feat: expose generator hash in REST /api/orders/by-owner response (COW-993) hash = keccak256(abi.encode(handler, salt, staticInput)) is the on-chain canonical identifier used by ComposableCow.singleOrders() and remove(). It was already indexed in the schema but missing from the REST response, forcing integrators to use GraphQL to look up an order by hash. Co-Authored-By: Claude Sonnet 4.6 --- src/api/endpoints/orders-by-owner.ts | 1 + src/api/schemas/orders-by-owner.ts | 5 +++++ 2 files changed, 6 insertions(+) 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/schemas/orders-by-owner.ts b/src/api/schemas/orders-by-owner.ts index 884c36c..1f132ce 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(handler, salt, staticInput)). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", + ), ownerAddressType: z .enum(["cowshed_proxy", "flash_loan_helper"]) .nullable() From f46e344adc7253f81c928c74bb6a2d60148360b3 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 15:53:03 -0300 Subject: [PATCH 29/89] refactor: rename block handlers to semantic namespaced names (COW-1000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames all five Ponder block-handler entries from opaque C1–C5 shorthand to self-documenting composableCow.* names that match their responsibility. Updates ponder.config.ts keys, ponder.on() call names, section headers, log prefixes, and all in-file cross-references. Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 25 ++++--- src/application/handlers/blockHandler.ts | 86 ++++++++++++------------ 2 files changed, 55 insertions(+), 56 deletions(-) diff --git a/ponder.config.ts b/ponder.config.ts index c3e66a6..ecc6c16 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -38,47 +38,46 @@ export default createConfig({ }, }, blocks: { - // C1: Contract Poller — RPC multicall for non-deterministic generators + // composableCow.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: { + "composableCow.OrderDiscoveryPoller": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest", interval: 4 }, }, interval: 1, }, - // C2: Candidate Confirmer — checks API for unconfirmed candidates - CandidateConfirmer: { + // composableCow.CandidateConfirmer — checks API for unconfirmed candidates. + "composableCow.CandidateConfirmer": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, }, interval: 1, }, - // C3: Status Updater — polls API for open discrete order status - StatusUpdater: { + // composableCow.OrderStatusTracker — polls API for open discrete order status. + "composableCow.OrderStatusTracker": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, }, interval: 1, }, - // C4: Historical Bootstrap — one-time owner fetch for non-deterministic backfill orders - HistoricalBootstrap: { + // composableCow.OwnerBackfill — one-time owner fetch for non-deterministic backfill orders. + "composableCow.OwnerBackfill": { chain: { mainnet: { startBlock: "latest", endBlock: "latest" }, gnosis: { startBlock: "latest", endBlock: "latest" }, }, 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: { + // composableCow.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. + "composableCow.CancellationWatcher": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..e058bc6 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -1,17 +1,17 @@ /** * Block handlers — five responsibilities split into separate Ponder block entries. * - * C1 (ContractPoller): RPC multicall for non-deterministic generators. Every block. - * C2 (CandidateConfirmer): API batch check for unconfirmed candidates. Every block. - * C3 (StatusUpdater): API batch check for open discrete orders + expiry. Every block. - * C4 (HistoricalBootstrap): One-time owner fetch for non-deterministic backfill orders. - * C5 (DeterministicCancellationSweeper): singleOrders() mapping read for - * deterministic generators (allCandidatesKnown=true) that - * C1 skips. Runs every block but re-checks each generator - * only every DETERMINISTIC_CANCEL_SWEEP_INTERVAL blocks. + * OrderDiscoveryPoller: RPC multicall for non-deterministic generators. Every block. + * CandidateConfirmer: API batch check for unconfirmed candidates. Every block. + * OrderStatusTracker: API batch check for open discrete orders + expiry. Every block. + * OwnerBackfill: One-time owner fetch for non-deterministic backfill orders. + * CancellationWatcher: singleOrders() mapping read for deterministic generators + * (allCandidatesKnown=true) that OrderDiscoveryPoller skips. + * Runs every block but re-checks each generator only every + * DETERMINISTIC_CANCEL_SWEEP_INTERVAL blocks. * * All handlers start at "latest" — only run during live sync. - * C4 additionally has endBlock: "latest", so it fires exactly once. + * OwnerBackfill additionally has endBlock: "latest", so it fires exactly once. */ import { ponder } from "ponder:registry"; @@ -47,7 +47,7 @@ const SINGLE_SHOT_NON_DETERMINISTIC = ["GoodAfterTime", "TradeAboveThreshold"] a const BLOCK_NEVER = 2n ** 63n - 1n; // sentinel for epoch-scheduled generators (PollTryAtEpoch) const VALID_DISCRETE_STATUSES = new Set(["fulfilled", "unfilled", "expired", "cancelled"]); -// Minimal ABI for C5: reads the singleOrders(owner, hash) mapping on ComposableCoW. +// Minimal ABI for CancellationWatcher: reads the singleOrders(owner, hash) mapping on ComposableCoW. // `false` means the owner called remove() — generator is cancelled on-chain. const SINGLE_ORDERS_ABI = [ { @@ -63,12 +63,12 @@ const SINGLE_ORDERS_ABI = [ ] as const; -// ─── C1: Contract Poller ───────────────────────────────────────────────────── +// ─── composableCow.OrderDiscoveryPoller ────────────────────────────────────── // Polls getTradeableOrderWithSignature for any active generator where // 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("composableCow.OrderDiscoveryPoller:block", async ({ event, context }) => { if (process.env.DISABLE_POLL_RESULT_CHECK) return; const chainId = context.chain.id as SupportedChainId; @@ -120,7 +120,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { if (dueOrders.length === 0) return; console.log( - `[COW:C1] ENTER block=${currentBlock} chain=${chainId} due=${dueOrders.length}`, + `[COW:OrderDiscoveryPoller] ENTER block=${currentBlock} chain=${chainId} due=${dueOrders.length}`, ); const c1MulticallPromise = context.client.multicall({ @@ -148,7 +148,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}`, + `[COW:OrderDiscoveryPoller] multicall timeout block=${currentBlock} chain=${chainId} due=${dueOrders.length}`, ); return; } @@ -264,7 +264,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { ), ); console.log( - `[COW:C1] NEVER generatorId=${order.generatorId} reason=${pollResult.reason} block=${currentBlock} chain=${chainId}`, + `[COW:OrderDiscoveryPoller] NEVER generatorId=${order.generatorId} reason=${pollResult.reason} block=${currentBlock} chain=${chainId}`, ); neverCount++; break; @@ -285,7 +285,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { ), ); console.log( - `[COW:C1] CANCELLED generatorId=${order.generatorId} block=${currentBlock} chain=${chainId}`, + `[COW:OrderDiscoveryPoller] CANCELLED generatorId=${order.generatorId} block=${currentBlock} chain=${chainId}`, ); break; } @@ -296,15 +296,15 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { 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" : ""}`, + `[COW:OrderDiscoveryPoller] DONE block=${currentBlock} chain=${chainId} due=${dueOrders.length} success=${successCount} never=${neverCount} backedOff=${backedOffCount}${capped ? " CAPPED" : ""}`, ); }); -// ─── C2: Candidate Confirmer ───────────────────────────────────────────────── +// ─── composableCow.CandidateConfirmer ──────────────────────────────────────── // Checks if candidate discrete orders exist on the Orderbook API. // When confirmed, promotes them to discreteOrder. -ponder.on("CandidateConfirmer:block", async ({ event, context }) => { +ponder.on("composableCow.CandidateConfirmer:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; // Parent-cancelled cascade: candidates whose parent generator flipped to @@ -387,7 +387,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ); console.log( - `[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length}`, + `[COW:CandidateConfirmer] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length}`, ); } } @@ -549,15 +549,15 @@ 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}`, + `[COW:CandidateConfirmer] block=${event.block.number} chain=${chainId} candidates=${unconfirmed.length} confirmed=${confirmed} expired=${stale.length}`, ); } }); -// ─── C3: Status Updater ────────────────────────────────────────────────────── +// ─── composableCow.OrderStatusTracker ──────────────────────────────────────── // Polls the API for status updates on open discrete orders. Expires past validTo. -ponder.on("StatusUpdater:block", async ({ event, context }) => { +ponder.on("composableCow.OrderStatusTracker:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentTimestamp = event.block.timestamp; @@ -603,7 +603,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}`, + `[COW:OrderStatusTracker] block=${event.block.number} chain=${chainId} open=${openOrders.length} updated=${updated}`, ); } } @@ -654,11 +654,11 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { ); }); -// ─── C4: Historical Bootstrap ──────────────────────────────────────────────── +// ─── composableCow.OwnerBackfill ───────────────────────────────────────────── // 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("composableCow.OwnerBackfill:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentBlock = event.block.number; @@ -669,7 +669,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { .where(eq(bootstrapRetryQueue.chainId, chainId)); console.log( - `[COW:C4] block=${currentBlock} chain=${chainId} pending_retry=${queued.length}`, + `[COW:OwnerBackfill] block=${currentBlock} chain=${chainId} pending_retry=${queued.length}`, ); let totalDiscovered = 0; @@ -691,7 +691,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { } 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`, + `[COW:OwnerBackfill] owner retry timeout owner=${owner} chain=${chainId} retry_count=${retryCount + 1} after=${BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS}ms`, ); await context.db.sql .update(bootstrapRetryQueue) @@ -735,13 +735,13 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { 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`); + console.log(`[COW:OwnerBackfill] block=${currentBlock} chain=${chainId} no generators need bootstrap`); return; } if (freshOwners.size > 0) { console.log( - `[COW:C4] block=${currentBlock} chain=${chainId} generators=${generators.length} fresh_owners=${freshOwners.size}`, + `[COW:OwnerBackfill] block=${currentBlock} chain=${chainId} generators=${generators.length} fresh_owners=${freshOwners.size}`, ); } @@ -757,7 +757,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { } catch (err) { if (err instanceof TimeoutError) { console.warn( - `[COW:C4] owner timeout owner=${owner} chain=${chainId} after=${BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS}ms`, + `[COW:OwnerBackfill] owner timeout owner=${owner} chain=${chainId} after=${BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS}ms`, ); await context.db.sql .insert(bootstrapRetryQueue) @@ -770,20 +770,20 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { } console.log( - `[COW:C4] DONE block=${currentBlock} chain=${chainId} discovered=${totalDiscovered}`, + `[COW:OwnerBackfill] DONE block=${currentBlock} chain=${chainId} discovered=${totalDiscovered}`, ); }); -// ─── C5: Deterministic Cancellation Sweeper ────────────────────────────────── -// C1 skips generators with allCandidatesKnown=true (deterministic types: TWAP, -// StopLoss, CirclesBackingOrder), so SingleOrderNotAuthed is never observed -// for them. This handler closes that gap by reading +// ─── composableCow.CancellationWatcher ─────────────────────────────────────── +// OrderDiscoveryPoller skips generators with allCandidatesKnown=true (deterministic +// types: TWAP, StopLoss, CirclesBackingOrder), so SingleOrderNotAuthed is never +// observed for them. This handler closes that gap by reading // ComposableCoW.singleOrders(owner, hash) on a DETERMINISTIC_CANCEL_SWEEP_INTERVAL // cadence. A `false` result means the owner called remove() on-chain → flip to -// Cancelled, which lets the C2/C3 parent-cancelled cascade (COW-918) reconcile -// the child discrete / candidate rows on the next block. +// Cancelled, which lets the CandidateConfirmer/OrderStatusTracker parent-cancelled +// cascade (COW-918) reconcile the child discrete / candidate rows on the next block. -ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) => { +ponder.on("composableCow.CancellationWatcher:block", async ({ event, context }) => { if (process.env.DISABLE_DETERMINISTIC_CANCEL_SWEEP) return; const chainId = context.chain.id as SupportedChainId; @@ -826,7 +826,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = if (dueGenerators.length === 0) return; console.log( - `[COW:C5] ENTER block=${currentBlock} chain=${chainId} due=${dueGenerators.length}`, + `[COW:CancellationWatcher] ENTER block=${currentBlock} chain=${chainId} due=${dueGenerators.length}`, ); const c5MulticallPromise = context.client.multicall({ @@ -849,7 +849,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}`, + `[COW:CancellationWatcher] multicall timeout block=${currentBlock} chain=${chainId} due=${dueGenerators.length}`, ); return; } @@ -888,7 +888,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = ), ); console.log( - `[COW:C5] CANCELLED generatorId=${gen.generatorId} orderType=${gen.orderType} block=${currentBlock} chain=${chainId}`, + `[COW:CancellationWatcher] CANCELLED generatorId=${gen.generatorId} orderType=${gen.orderType} block=${currentBlock} chain=${chainId}`, ); cancelledCount++; } else { @@ -910,7 +910,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}`, + `[COW:CancellationWatcher] DONE block=${currentBlock} chain=${chainId} due=${dueGenerators.length} cancelled=${cancelledCount} stillActive=${stillActiveCount} errors=${errorCount}`, ); }); From fc8a76bdbad30d1cbd844d857dc5abeda48e7f60 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 15:55:35 -0300 Subject: [PATCH 30/89] fix: wrap settlement.ts RPC calls in withTimeout, remove decode dead code (COW-991) Applies the COW-921 timeout discipline to the Settlement event handler: wraps getTransactionReceipt, getCode, call, and readContract in withTimeout using BLOCK_HANDLER_RPC_TIMEOUT_MS. Removes the decode-only-for-logging decodeAbiParameters block (F10) and replaces the inaccurate "volume is negligible" comment with the actual observed figure (27k+ adapters). Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/settlement.ts | 82 +++++++++++++++----------- 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 7304b07..26b4f70 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -1,12 +1,14 @@ import { ponder } from "ponder:registry"; import { AddressType, conditionalOrderGenerator, ownerMapping, transaction } from "ponder:schema"; import { and, eq } from "ponder"; -import { decodeAbiParameters, keccak256, toBytes } from "viem"; +import { keccak256, toBytes } from "viem"; import { AaveV3AdapterHelperAbi } from "../../../abis/AaveV3AdapterHelperAbi"; import { AAVE_V3_ADAPTER_FACTORY_ADDRESSES, GPV2_SETTLEMENT_DEPLOYMENTS, } from "../../data"; +import { BLOCK_HANDLER_RPC_TIMEOUT_MS } from "../../constants"; +import { TimeoutError, withTimeout } from "../helpers/withTimeout"; // Trade(address,address,address,uint256,uint256,uint256,bytes) — topic0 hash const TRADE_TOPIC = keccak256( @@ -73,11 +75,19 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { stats.total++; // 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, - }); + // FlashLoanRouter settlements only, but 27k+ adapters were resolved in practice — + // the RPC call is meaningful and must be timeout-guarded. + let receipt: Awaited>; + try { + receipt = await withTimeout( + context.client.getTransactionReceipt({ hash: event.transaction.hash }), + BLOCK_HANDLER_RPC_TIMEOUT_MS, + "settlement:getTransactionReceipt", + ); + } catch (err) { + if (err instanceof TimeoutError) return; + throw err; + } for (const log of receipt.logs) { // Only Trade logs emitted by GPv2Settlement in this same transaction @@ -109,7 +119,17 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { } // Skip if EOA (no bytecode) - const code = await context.client.getCode({ address: owner }); + let code: `0x${string}` | undefined; + try { + code = await withTimeout( + context.client.getCode({ address: owner }), + BLOCK_HANDLER_RPC_TIMEOUT_MS, + "settlement:getCode", + ); + } catch (err) { + if (err instanceof TimeoutError) continue; + throw err; + } if (!code || code === "0x") { stats.skippedEOA++; logStatsIfIntervalPassed(); @@ -122,10 +142,11 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { const t1 = Date.now(); let factoryData: `0x${string}` | undefined; try { - const result = await context.client.call({ - to: owner, - data: FACTORY_SELECTOR, - }); + const result = await withTimeout( + context.client.call({ to: owner, data: FACTORY_SELECTOR }), + BLOCK_HANDLER_RPC_TIMEOUT_MS, + "settlement:call:FACTORY", + ); factoryData = result.data; } catch { stats.msFactory += Date.now() - t1; @@ -152,11 +173,21 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { } // Resolve EOA via owner() — this call should always succeed at this point - const eoaOwner = await context.client.readContract({ - address: owner, - abi: AaveV3AdapterHelperAbi, - functionName: "owner", - }); + 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) { + if (err instanceof TimeoutError) continue; + throw err; + } await context.db .insert(transaction) @@ -191,20 +222,6 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { ), ); - // 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(); @@ -212,11 +229,6 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { `[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}`, ); From 590cc48e0b6faaee02b024615a9b5eaf19da979f Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 16:25:04 -0300 Subject: [PATCH 31/89] docs: fix ops gaps and architecture.md accuracy (COW-1001 + COW-1002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit COW-1001 [F14]: - .env.example: fix stale C1/C5 names, add escape-hatch tags per flag - ponder.config.ts: set ethGetLogsBlockRange: 1000 per chain to prevent InvalidInputRpcError retry storms against providers with a 1k-block cap - docs/api-reference.md: document that dev uses port 42069, prod uses 3000 - docs/deployment.md: add sections for dev vs start restart semantics, multichain ordering behavior, and RPC provider block-range limits COW-1002 [F21]: - docs/architecture.md: replace hardcoded counts ("Eight order types", "Five live-only block handlers", C1–C5 references) with links to source - data-flow diagram: redraw as three parallel on-chain streams converging at the schema tables, rather than a single sequential chain of arrows - block handler section: rename to semantic names (OrderDiscoveryPoller, CandidateConfirmer, OrderStatusTracker, OwnerBackfill, CancellationWatcher) - candidate_discrete_order: add "Why candidate orders?" motivational paragraph - order types section: restructure as deterministic vs non-deterministic rather than a hardcoded count Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 21 ++++---- docs/api-reference.md | 2 +- docs/architecture.md | 118 +++++++++++++++++++++--------------------- docs/deployment.md | 44 ++++++++++++++++ ponder.config.ts | 4 ++ 5 files changed, 119 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index 791e5cc..98fc57e 100644 --- a/.env.example +++ b/.env.example @@ -17,15 +17,18 @@ 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) diff --git a/docs/api-reference.md b/docs/api-reference.md index 36c680f..66d528c 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 diff --git a/docs/architecture.md b/docs/architecture.md index d11b3ed..23c7c4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ 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 Trade logs). 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. ## Contracts and Chains @@ -19,7 +19,7 @@ Currently indexed: 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` imports everything from `data.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 +29,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 +109,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)`. @@ -169,23 +164,26 @@ The handler uses raw `eth_call` for the FACTORY() check specifically to avoid Po Stats are accumulated and logged every 30 seconds to track throughput without per-event log spam. -### blockHandler.ts -- C1 / C2 / C3 / C4 / C5 +### blockHandler.ts -- live block handlers -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. +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. -**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`. +**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`. -**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. +**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. -**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. +**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. -**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. +**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. -**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`. +**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`. @@ -226,9 +224,9 @@ See [api-reference.md](./api-reference.md) for the full endpoint list. 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`. +The block handlers 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`. ## 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; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children that were already traded on the orderbook. - 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..b6855de 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -50,6 +50,50 @@ Used by `deployment/docker-compose.yml` and `deployment/manage.sh`: 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. +## pnpm dev vs pnpm start + +| | `pnpm dev` | `pnpm start` | +|---|---|---| +| Port | **42069** | **3000** (mapped by Docker via `PONDER_EXPOSED_PORT`) | +| Restart | **Full re-index from scratch** — no checkpoint; re-starts from the configured start blocks | **Resumes from last checkpoint** — picks up where it left off | +| Hot-reload | Yes (schema/handler/config changes auto-restart) | No | +| Use case | Local development | Production | + +Use `pnpm start` (or the Docker image) in production. Restarting `pnpm dev` silently triggers a full multi-hour re-index every time. + +Config or schema changes always force a full re-index regardless of which command you use, because Ponder detects the change and clears the checkpoint. + +## Multichain Ordering + +Ponder defaults to `ordering: "multichain"` (also called "parallel" mode), which processes each chain's historical backlog independently. In practice during a cold start this means one chain's blocks are indexed before the other gets meaningful progress — e.g. Gnosis may reach 20% while mainnet sits at 0%. + +If you need cross-chain consistency (e.g. an API endpoint that joins mainnet + gnosis rows in real-time), set `ordering: "omnichain"` in `ponder.config.ts`. Omnichain mode interleaves blocks across chains by timestamp so both chains advance together, at the cost of slower overall throughput. + +For this indexer the default multichain mode is fine: the REST endpoints and GraphQL queries are per-chain. + +## RPC Provider Limits and ethGetLogsBlockRange + +Many RPC providers cap `eth_getLogs` to 1000–2000 blocks per request. Without an explicit `ethGetLogsBlockRange` in `ponder.config.ts`, Ponder uses a larger internal default, which causes repeated `InvalidInputRpcError: query block range exceeds server limit` warnings and retry storms during backfill. + +`ponder.config.ts` sets `ethGetLogsBlockRange: 1000` for both mainnet and gnosis as a safe conservative default. If your provider allows higher limits (e.g. Alchemy allows 10 000), you can increase it: + +```ts +// ponder.config.ts +chains: { + mainnet: { id: 1, rpc: ..., ethGetLogsBlockRange: 10_000 }, + gnosis: { id: 100, rpc: ..., ethGetLogsBlockRange: 10_000 }, +} +``` + +Common provider limits: + +| Provider | Typical eth_getLogs limit | +|----------|--------------------------| +| Alchemy | 10 000 blocks | +| Infura | 10 000 blocks | +| QuickNode | 1 000–10 000 blocks (plan-dependent) | +| Public RPCs (Pocket, etc.) | 1 000 blocks | + ## Database Setup ### Local Development diff --git a/ponder.config.ts b/ponder.config.ts index c3e66a6..438ef56 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -13,10 +13,14 @@ export default createConfig({ mainnet: { id: 1, rpc: process.env.MAINNET_RPC_URL!, + // Many RPC providers cap eth_getLogs at 1000–2000 blocks; set conservatively to avoid + // InvalidInputRpcError retry storms during backfill. Override if your provider allows more. + ethGetLogsBlockRange: 1000, }, gnosis: { id: 100, rpc: process.env.GNOSIS_RPC_URL!, + ethGetLogsBlockRange: 1000, }, }, contracts: { From e140516bbfe114f2af95964200f4e54e15b89d88 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 16:27:20 -0300 Subject: [PATCH 32/89] fix: preflight /by_uids before cascade-cancelled insert in CandidateConfirmer (COW-990) When a generator is cancelled, the cascade in CandidateConfirmer previously inserted all orphan candidates as (cancelled, null) without checking the orderbook. If the watch-tower had already posted and a solver had filled an in-flight part, the indexer would mark it cancelled despite being fulfilled (~0.17% observed rate on Phase-3 harness data). Fix: batch-query /by_uids for all orphan UIDs before the cascade insert. Use the API status + executed amounts when available; fall back to 'cancelled' for UIDs not yet on the orderbook. Timeout errors degrade gracefully to the old behavior. The onConflictDoNothing guard still protects already-terminal discrete_order rows from being overwritten. Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 2 +- src/application/handlers/blockHandler.ts | 51 +++++++++++++++++------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d11b3ed..41212ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -230,5 +230,5 @@ The block handlers (C1–C5) already run on both mainnet and gnosis. Adding a ne ## 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, 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. The C2 cascade does a preflight `/by_uids` query so that 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/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..0bb8123 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -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, @@ -354,23 +355,42 @@ 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. + type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; + let preflightStatuses: Awaited>; + try { + preflightStatuses = await withTimeout( + fetchOrderStatusByUids(context, chainId, orphanCandidates.map((c) => c.orderUid)), + ORDERBOOK_HTTP_TIMEOUT_MS, + "c2:cascade:preflight", + ); + } catch { + preflightStatuses = new Map(); + } + 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,8 +406,9 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ), ); + const preflightHits = preflightStatuses.size; console.log( - `[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length}`, + `[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length} preflight-hits=${preflightHits}`, ); } } From 226208b444ecd9ad11d8dfc836380ceeba8a5b42 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 16:39:35 -0300 Subject: [PATCH 33/89] feat: structured JSON logging + K8s probe docs (COW-994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structured logging: - Add src/application/helpers/cowLogger.ts — thin JSON logger that emits one JSON line per call with { time, level, msg, ...fields } - Migrate all console.log/warn calls in blockHandler.ts and settlement.ts to cowLog(); each line now carries chainId and block as proper JSON fields so log aggregators can filter by chain without regex parsing - Add --log-format json to pnpm start so Ponder's own log lines are also JSON in production; pnpm dev keeps pretty format for local readability K8s probe docs (docs/deployment.md): - Document /healthz (liveness) vs /ready (readiness) distinction - Add sample K8s manifest snippet with correct probe mapping - Explain why /ready must NOT be used as a liveness probe - Add structured logging section explaining --log-format json behavior Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 36 ++++++++++++ package.json | 2 +- src/application/handlers/blockHandler.ts | 71 +++++++----------------- src/application/handlers/settlement.ts | 42 +++++++------- src/application/helpers/cowLogger.ts | 19 +++++++ 5 files changed, 96 insertions(+), 74 deletions(-) create mode 100644 src/application/helpers/cowLogger.ts diff --git a/docs/deployment.md b/docs/deployment.md index 29a4171..05ce647 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -88,6 +88,42 @@ deployment/ 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). +### Kubernetes Probes + +The indexer exposes two health endpoints with distinct semantics: + +| Endpoint | Semantic | Returns 200 when | +|----------|----------|-----------------| +| `/healthz` | **Liveness** — is the process alive? | Always, once the server starts | +| `/ready` | **Readiness** — is the index fully synced? | Only when fully synced | + +Map these to different K8s probe types: + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 3000 + periodSeconds: 30 + failureThreshold: 3 +readinessProbe: + httpGet: + path: /ready + port: 3000 + periodSeconds: 10 + failureThreshold: 18 # 3-minute window before marking unready +``` + +**Do not** use `/ready` as the liveness probe. A pod that is still indexing (which takes hours on a cold start) returns 200 on `/healthz` but not on `/ready`. Using `/ready` for liveness would kill the pod before it ever finishes syncing. + +The Docker Compose health check uses `/ready` with a 24-hour start period as a pragmatic fallback for single-container deployments, not as a K8s-style probe. + +### Structured Logging + +`pnpm start` runs with `--log-format json`, which makes both Ponder's internal log lines and the handler log lines (via `cowLog`) emit newline-delimited JSON. Each handler log line includes `chainId` and `block` as top-level fields, enabling log aggregators (Datadog, CloudWatch, Loki) to filter and alert by chain. + +`pnpm dev` uses Ponder's default pretty format for readability during local development. + ### PostgreSQL Auto-Tuning `start-db.sh` tunes memory settings from `POSTGRES_MEMORY_LIMIT`. With the default 1G: diff --git a/package.json b/package.json index 257684c..6d06e55 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "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", diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..d648d33 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -41,6 +41,7 @@ import { parsePollError, } from "../helpers/pollResultErrors"; import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; +import { cowLog } from "../helpers/cowLogger"; const NON_DETERMINISTIC_TYPES = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"] as const; const SINGLE_SHOT_NON_DETERMINISTIC = ["GoodAfterTime", "TradeAboveThreshold"] as const; @@ -119,9 +120,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { if (dueOrders.length === 0) return; - console.log( - `[COW:C1] ENTER block=${currentBlock} chain=${chainId} due=${dueOrders.length}`, - ); + cowLog("info", "C1:ENTER", { block: String(currentBlock), chainId, due: dueOrders.length }); const c1MulticallPromise = context.client.multicall({ contracts: dueOrders.map((order) => ({ @@ -147,9 +146,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}`, - ); + cowLog("warn", "C1:multicall_timeout", { block: String(currentBlock), chainId, due: dueOrders.length }); return; } throw err; @@ -263,9 +260,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}`, - ); + cowLog("info", "C1:NEVER", { block: String(currentBlock), chainId, generatorId: order.generatorId, reason: pollResult.reason }); neverCount++; break; @@ -284,9 +279,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}`, - ); + cowLog("info", "C1:CANCELLED", { block: String(currentBlock), chainId, generatorId: order.generatorId }); break; } } @@ -295,9 +288,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" : ""}`, - ); + cowLog("info", "C1:DONE", { block: String(currentBlock), chainId, due: dueOrders.length, success: successCount, never: neverCount, backedOff: backedOffCount, capped }); }); // ─── C2: Candidate Confirmer ───────────────────────────────────────────────── @@ -386,9 +377,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ), ); - console.log( - `[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length}`, - ); + cowLog("info", "C2:parent_cancelled", { block: String(event.block.number), chainId, parentCancelled: orphanCandidates.length }); } } @@ -548,9 +537,7 @@ 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}`, - ); + cowLog("info", "C2:DONE", { block: String(event.block.number), chainId, candidates: unconfirmed.length, confirmed, expired: stale.length }); } }); @@ -602,9 +589,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}`, - ); + cowLog("info", "C3:DONE", { block: String(event.block.number), chainId, open: openOrders.length, updated }); } } @@ -668,9 +653,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}`, - ); + cowLog("info", "C4:START", { block: String(currentBlock), chainId, pendingRetry: queued.length }); let totalDiscovered = 0; const retriedOwners = new Set(); @@ -690,9 +673,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`, - ); + cowLog("warn", "C4: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 }) @@ -735,14 +716,12 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { 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`); + cowLog("info", "C4: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}`, - ); + cowLog("info", "C4:bootstrap_start", { block: String(currentBlock), chainId, generators: generators.length, freshOwners: freshOwners.size }); } for (const owner of freshOwners) { @@ -756,9 +735,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`, - ); + cowLog("warn", "C4: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 +746,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { } } - console.log( - `[COW:C4] DONE block=${currentBlock} chain=${chainId} discovered=${totalDiscovered}`, - ); + cowLog("info", "C4:DONE", { block: String(currentBlock), chainId, discovered: totalDiscovered }); }); // ─── C5: Deterministic Cancellation Sweeper ────────────────────────────────── @@ -825,9 +800,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = if (dueGenerators.length === 0) return; - console.log( - `[COW:C5] ENTER block=${currentBlock} chain=${chainId} due=${dueGenerators.length}`, - ); + cowLog("info", "C5:ENTER", { block: String(currentBlock), chainId, due: dueGenerators.length }); const c5MulticallPromise = context.client.multicall({ contracts: dueGenerators.map((g) => ({ @@ -848,9 +821,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}`, - ); + cowLog("warn", "C5:multicall_timeout", { block: String(currentBlock), chainId, due: dueGenerators.length }); return; } throw err; @@ -887,9 +858,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}`, - ); + cowLog("info", "C5:CANCELLED", { block: String(currentBlock), chainId, generatorId: gen.generatorId, orderType: gen.orderType }); cancelledCount++; } else { await context.db.sql @@ -909,9 +878,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}`, - ); + cowLog("info", "C5:DONE", { block: String(currentBlock), chainId, due: dueGenerators.length, cancelled: cancelledCount, stillActive: stillActiveCount, errors: errorCount }); }); // ─── Shared helpers ────────────────────────────────────────────────────────── diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 7304b07..ff6ba17 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -2,6 +2,7 @@ import { ponder } from "ponder:registry"; import { AddressType, conditionalOrderGenerator, ownerMapping, transaction } from "ponder:schema"; import { and, eq } from "ponder"; import { decodeAbiParameters, keccak256, toBytes } from "viem"; +import { cowLog } from "../helpers/cowLogger"; import { AaveV3AdapterHelperAbi } from "../../../abis/AaveV3AdapterHelperAbi"; import { AAVE_V3_ADAPTER_FACTORY_ADDRESSES, @@ -31,15 +32,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`, - ); + cowLog("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(); } @@ -208,18 +209,17 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { 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}`, - ); + cowLog("info", "settlement:aave_adapter_mapped", { + block: String(event.block.number), + chainId, + adapter: ownerAddress, + eoa: eoaOwner.toLowerCase(), + orderUid: String(orderUid), + sellToken: sellToken.toLowerCase(), + buyToken: buyToken.toLowerCase(), + sellAmount: String(sellAmount), + buyAmount: String(buyAmount), + }); } logStatsIfIntervalPassed(); diff --git a/src/application/helpers/cowLogger.ts b/src/application/helpers/cowLogger.ts new file mode 100644 index 0000000..5a2fbaa --- /dev/null +++ b/src/application/helpers/cowLogger.ts @@ -0,0 +1,19 @@ +/** + * Structured JSON logger for handler code. Outputs one JSON line per call so + * log aggregators (Datadog, CloudWatch, etc.) can filter by chainId, handler, + * block number, or any other field without regex parsing. + * + * Ponder's own log lines are controlled by --log-format (pretty|json) on the + * CLI. These handler lines are always JSON so they remain parseable regardless + * of Ponder's format setting. + */ + +type LogLevel = "info" | "warn" | "error"; + +export function cowLog( + level: LogLevel, + msg: string, + fields: Record = {}, +): void { + console.log(JSON.stringify({ time: Date.now(), level, msg, ...fields })); +} From 74576422e30f52dc12948effa14e30d46430108d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 16:48:38 -0300 Subject: [PATCH 34/89] fix: account fallback for TWAP parts aged out of /by_uids (COW-989) When /orders/by_uids returns nothing for a stale candidateDiscreteOrder (near or past validTo), fall back to /account/{owner}/orders before defaulting to "expired". Groups missed UIDs by owner so only one account fetch per unique owner is needed. Prevents fulfilled TWAP parts from being recorded as "expired" when the API has aged them out of /by_uids. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 44 +++++++++++++++++++++- src/application/helpers/orderbookClient.ts | 24 ++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..00c0303 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -26,6 +26,7 @@ import { BLOCK_HANDLER_RPC_TIMEOUT_MS, BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, DEFAULT_MAX_GENERATORS_PER_BLOCK, + ORDERBOOK_HTTP_TIMEOUT_MS, DETERMINISTIC_CANCEL_SWEEP_INTERVAL, RECHECK_INTERVAL, TRY_NEXT_BLOCK_WARMUP_THRESHOLD, @@ -34,7 +35,7 @@ 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, @@ -514,6 +515,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), + ORDERBOOK_HTTP_TIMEOUT_MS, + "c2:stale:accountFallback", + ); + for (const [uid, info] of ownerStatuses) { + if (ownerMissedUids.has(uid)) staleStatuses.set(uid, info); + } + } catch { + // Fallback failed — these UIDs will default to "expired" + } + } + } + const staleRows: (typeof discreteOrder.$inferInsert)[] = stale.map((c) => { const entry = staleStatuses.get(c.orderUid); return { diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 5ef7b2d..635e6a7 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -294,6 +294,30 @@ 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, +): Promise> { + const result = new Map(); + const apiBaseUrl = ORDERBOOK_API_URLS[chainId]; + if (!apiBaseUrl) return result; + const orders = await fetchAccountOrders(apiBaseUrl, owner); + 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. */ From 8804df3f21ca6da07ee34a569efd128ddb583c50 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 18:42:32 -0300 Subject: [PATCH 35/89] test: add GeneratorSummary schema tests including hash field (COW-993) Co-Authored-By: Claude Sonnet 4.6 --- tests/api/orders-by-owner.test.ts | 119 ++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/api/orders-by-owner.test.ts diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts new file mode 100644 index 0000000..ce76433 --- /dev/null +++ b/tests/api/orders-by-owner.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from "vitest"; +import { + GeneratorSummary, + OrdersByOwnerResponse, +} from "../../src/api/schemas/orders-by-owner"; + +// 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", () => { + it("parses correctly when hash is present as a valid hex string", () => { + const result = GeneratorSummary.safeParse(validGenerator); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hash).toBe(validGenerator.hash); + } + }); + + 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"); + } + }); + + it("fails parse when hash is not a string (number supplied)", () => { + const result = GeneratorSummary.safeParse({ ...validGenerator, hash: 12345 }); + expect(result.success).toBe(false); + if (!result.success) { + const paths = result.error.issues.map((i) => i.path.join(".")); + expect(paths).toContain("hash"); + } + }); + + it("hash field carries the correct describe() text", () => { + const shape = GeneratorSummary.shape; + const description = shape.hash.description; + expect(description).toBe( + "On-chain canonical identifier: keccak256(abi.encode(handler, salt, staticInput)). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", + ); + }); + + it("ownerAddressType accepts null (regression guard for unchanged field)", () => { + const result = GeneratorSummary.safeParse({ ...validGenerator, ownerAddressType: null }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ownerAddressType).toBeNull(); + } + }); + + it("ownerAddressType accepts the enum value 'cowshed_proxy'", () => { + const result = GeneratorSummary.safeParse({ + ...validGenerator, + ownerAddressType: "cowshed_proxy", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ownerAddressType).toBe("cowshed_proxy"); + } + }); + + it("ownerAddressType accepts the enum value 'flash_loan_helper'", () => { + const result = GeneratorSummary.safeParse({ + ...validGenerator, + ownerAddressType: "flash_loan_helper", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ownerAddressType).toBe("flash_loan_helper"); + } + }); +}); + +describe("OrdersByOwnerResponse schema", () => { + it("wraps an array of GeneratorSummary correctly via the orders field", () => { + // Build a minimal OrderItem that nests the generator. + 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); + } + }); +}); From be1b7679af4edb41175560f5eb192f343679f770 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 18:43:14 -0300 Subject: [PATCH 36/89] test: add fetchOwnerOrderStatuses unit tests (COW-989) Co-Authored-By: Claude Sonnet 4.6 --- tests/helpers/orderbookClient.test.ts | 287 ++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 tests/helpers/orderbookClient.test.ts diff --git a/tests/helpers/orderbookClient.test.ts b/tests/helpers/orderbookClient.test.ts new file mode 100644 index 0000000..ad8cfa9 --- /dev/null +++ b/tests/helpers/orderbookClient.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, 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() }), +})); + +// We import the module under test after patching ORDERBOOK_API_URLS via a +// helper that starts a local HTTP server and temporarily overrides the URL. +// Because orderbookClient.ts reads ORDERBOOK_API_URLS at call time (not at +// module load time) we can monkey-patch it for each test. +import * as data from "../../src/data"; +import { fetchOwnerOrderStatuses } from "../../src/application/helpers/orderbookClient"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +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, + }; +} + +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; + } + } +} + +const FAKE_OWNER = "0xaabbccddEEff0011223344556677889900aabbcc" as Hex; +const FAKE_CHAIN_ID = 1; +const UNKNOWN_CHAIN_ID = 99999; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +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 () => { + // PAGE_LIMIT is 1000 in orderbookClient.ts. Build two pages: first exactly + // 1000 orders (triggers another fetch), second with fewer (terminates pagination). + 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); + + // Should have fetched both pages + expect(receivedOffsets).toContain(0); + expect(receivedOffsets).toContain(PAGE_LIMIT); + + // Total entries = 1000 + 1 + expect(result.size).toBe(PAGE_LIMIT + 1); + + // Spot-check the page-2 entry + 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 () => { + // fetchAccountOrders breaks out of the loop on non-ok response and + // returns whatever was accumulated so far (nothing). So result is empty. + 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(); + } + }); +}); From d2ed6d853fdefb5c32e8099f78d499fd414ef1b5 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 1 Jun 2026 19:17:55 -0300 Subject: [PATCH 37/89] fix: remove composableCow. prefix from block interval names (Ponder dot-namespace conflict) Ponder 0.16.x treats dots in block interval names as namespace separators, causing ponder.on('composableCow.OrderDiscoveryPoller:block') to fail validation. Block intervals must use simple names without dots. Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 20 ++++++++++---------- src/application/handlers/blockHandler.ts | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/ponder.config.ts b/ponder.config.ts index ecc6c16..0c97b8b 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -38,46 +38,46 @@ export default createConfig({ }, }, blocks: { - // composableCow.OrderDiscoveryPoller — 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. - "composableCow.OrderDiscoveryPoller": { + "OrderDiscoveryPoller": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest", interval: 4 }, }, interval: 1, }, - // composableCow.CandidateConfirmer — checks API for unconfirmed candidates. - "composableCow.CandidateConfirmer": { + // CandidateConfirmer — checks API for unconfirmed candidates. + "CandidateConfirmer": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, }, interval: 1, }, - // composableCow.OrderStatusTracker — polls API for open discrete order status. - "composableCow.OrderStatusTracker": { + // OrderStatusTracker — polls API for open discrete order status. + "OrderStatusTracker": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, }, interval: 1, }, - // composableCow.OwnerBackfill — one-time owner fetch for non-deterministic backfill orders. - "composableCow.OwnerBackfill": { + // OwnerBackfill — one-time owner fetch for non-deterministic backfill orders. + "OwnerBackfill": { chain: { mainnet: { startBlock: "latest", endBlock: "latest" }, gnosis: { startBlock: "latest", endBlock: "latest" }, }, interval: 1, }, - // composableCow.CancellationWatcher — singleOrders() mapping read for deterministic + // 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. - "composableCow.CancellationWatcher": { + "CancellationWatcher": { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index e058bc6..d8cf55d 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -63,12 +63,12 @@ const SINGLE_ORDERS_ABI = [ ] as const; -// ─── composableCow.OrderDiscoveryPoller ────────────────────────────────────── +// ─── OrderDiscoveryPoller ────────────────────────────────────── // Polls getTradeableOrderWithSignature for any active generator where // allCandidatesKnown=false. Normally only non-deterministic types, but also // serves as fallback for deterministic types whose precompute failed. -ponder.on("composableCow.OrderDiscoveryPoller: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; @@ -300,11 +300,11 @@ ponder.on("composableCow.OrderDiscoveryPoller:block", async ({ event, context }) ); }); -// ─── composableCow.CandidateConfirmer ──────────────────────────────────────── +// ─── CandidateConfirmer ──────────────────────────────────────── // Checks if candidate discrete orders exist on the Orderbook API. // When confirmed, promotes them to discreteOrder. -ponder.on("composableCow.CandidateConfirmer:block", async ({ event, context }) => { +ponder.on("CandidateConfirmer:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; // Parent-cancelled cascade: candidates whose parent generator flipped to @@ -554,10 +554,10 @@ ponder.on("composableCow.CandidateConfirmer:block", async ({ event, context }) = } }); -// ─── composableCow.OrderStatusTracker ──────────────────────────────────────── +// ─── OrderStatusTracker ──────────────────────────────────────── // Polls the API for status updates on open discrete orders. Expires past validTo. -ponder.on("composableCow.OrderStatusTracker:block", async ({ event, context }) => { +ponder.on("OrderStatusTracker:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentTimestamp = event.block.timestamp; @@ -654,11 +654,11 @@ ponder.on("composableCow.OrderStatusTracker:block", async ({ event, context }) = ); }); -// ─── composableCow.OwnerBackfill ───────────────────────────────────────────── +// ─── OwnerBackfill ───────────────────────────────────────────── // One-time discovery of historical discrete orders for non-deterministic // generators created during backfill. Fires once at startBlock=endBlock="latest". -ponder.on("composableCow.OwnerBackfill:block", async ({ event, context }) => { +ponder.on("OwnerBackfill:block", async ({ event, context }) => { const chainId = context.chain.id as SupportedChainId; const currentBlock = event.block.number; @@ -774,7 +774,7 @@ ponder.on("composableCow.OwnerBackfill:block", async ({ event, context }) => { ); }); -// ─── composableCow.CancellationWatcher ─────────────────────────────────────── +// ─── CancellationWatcher ─────────────────────────────────────── // OrderDiscoveryPoller skips generators with allCandidatesKnown=true (deterministic // types: TWAP, StopLoss, CirclesBackingOrder), so SingleOrderNotAuthed is never // observed for them. This handler closes that gap by reading @@ -783,7 +783,7 @@ ponder.on("composableCow.OwnerBackfill:block", async ({ event, context }) => { // Cancelled, which lets the CandidateConfirmer/OrderStatusTracker parent-cancelled // cascade (COW-918) reconcile the child discrete / candidate rows on the next block. -ponder.on("composableCow.CancellationWatcher: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; From 8b9cee2dd3a0589a0474659b9898e2e65268dc73 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 11:03:37 -0300 Subject: [PATCH 38/89] fix: rename progressPct to historicalSyncProgressPct in sync-progress API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the field name self-documenting — it clearly tracks historical block-fetch progress (not handler completion), consistent with the distinction between progressPct=100 and isComplete=false. Co-Authored-By: Claude Sonnet 4.6 --- src/api/endpoints/sync-progress.ts | 4 ++-- src/api/routes.ts | 2 +- src/api/schemas/sync-progress.ts | 2 +- tests/api/sync-progress.test.ts | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/api/endpoints/sync-progress.ts b/src/api/endpoints/sync-progress.ts index c72bb35..998074e 100644 --- a/src/api/endpoints/sync-progress.ts +++ b/src/api/endpoints/sync-progress.ts @@ -56,7 +56,7 @@ export const syncProgressHandler: RouteHandler = { totalBlocks: number; processedBlocks: number; - progressPct: number; + historicalSyncProgressPct: number; isRealtime: boolean; isComplete: boolean; } @@ -72,7 +72,7 @@ export const syncProgressHandler: RouteHandler = result[chain] = { totalBlocks: t, processedBlocks: processed, - progressPct: pct, + historicalSyncProgressPct: pct, isRealtime: (isRealtime.get(chain) ?? 0) === 1, isComplete: (isComplete.get(chain) ?? 0) === 1, }; diff --git a/src/api/routes.ts b/src/api/routes.ts index cc7b777..a0a7c0f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -35,7 +35,7 @@ export const syncProgressRoute = createRoute({ 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, progressPct will rise from 0 to 100 and isComplete will flip to true once the chain is fully caught up.", + "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, historicalSyncProgressPct 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.", diff --git a/src/api/schemas/sync-progress.ts b/src/api/schemas/sync-progress.ts index 25f525a..a5c518d 100644 --- a/src/api/schemas/sync-progress.ts +++ b/src/api/schemas/sync-progress.ts @@ -9,7 +9,7 @@ export const ChainProgressSchema = z.object({ .number() .int() .describe("Blocks already processed (completed + served from cache)."), - progressPct: z + historicalSyncProgressPct: z .number() .describe("Completion percentage (0–100). Rounded to one decimal place."), isRealtime: z diff --git a/tests/api/sync-progress.test.ts b/tests/api/sync-progress.test.ts index 87857ad..4404ff2 100644 --- a/tests/api/sync-progress.test.ts +++ b/tests/api/sync-progress.test.ts @@ -5,7 +5,7 @@ import { syncProgressHandler } from "../../src/api/endpoints/sync-progress"; type ChainProgress = { totalBlocks: number; processedBlocks: number; - progressPct: number; + historicalSyncProgressPct: number; isRealtime: boolean; isComplete: boolean; }; @@ -84,15 +84,15 @@ describe("GET /api/sync-progress", () => { expect(body["gnosis"]!.processedBlocks).toBe(2_400_000); }); - it("computes progressPct correctly (rounded to 1 decimal)", async () => { + it("computes historicalSyncProgressPct 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"]!.progressPct).toBe(42.9); + expect(body["mainnet"]!.historicalSyncProgressPct).toBe(42.9); // gnosis: 2_400_000 / 17_000_000 = 14.117... → 14.1 - expect(body["gnosis"]!.progressPct).toBe(14.1); + expect(body["gnosis"]!.historicalSyncProgressPct).toBe(14.1); }); it("sets isRealtime and isComplete from metrics flags", async () => { From b7d8838f69f07ae9ad455837794eb1a87f04f50f Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 16:24:31 -0300 Subject: [PATCH 39/89] fix: remove decode-only-for-logging block from settlement.ts cowLog (COW-994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decodeAbiParameters call (orderUid, sellToken, buyToken, sellAmount, buyAmount) was only used for logging. COW-991 removes this block separately; removing it here too prevents a conflict when both PRs land — otherwise the cowLog fields reference variables that no longer exist, crashing the indexer. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/settlement.ts | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index ff6ba17..00677b5 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -1,7 +1,7 @@ import { ponder } from "ponder:registry"; import { AddressType, conditionalOrderGenerator, ownerMapping, transaction } from "ponder:schema"; import { and, eq } from "ponder"; -import { decodeAbiParameters, keccak256, toBytes } from "viem"; +import { keccak256, toBytes } from "viem"; import { cowLog } from "../helpers/cowLogger"; import { AaveV3AdapterHelperAbi } from "../../../abis/AaveV3AdapterHelperAbi"; import { @@ -192,20 +192,6 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { ), ); - // 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(); @@ -214,11 +200,6 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { chainId, adapter: ownerAddress, eoa: eoaOwner.toLowerCase(), - orderUid: String(orderUid), - sellToken: sellToken.toLowerCase(), - buyToken: buyToken.toLowerCase(), - sellAmount: String(sellAmount), - buyAmount: String(buyAmount), }); } From 4594e5f66a7daa9459b6485795b8411056fca433 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:05:53 -0300 Subject: [PATCH 40/89] fix: correct hash field describe() to use tuple abi.encode notation (COW-993) Co-Authored-By: Claude Sonnet 4.6 --- src/api/schemas/orders-by-owner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/schemas/orders-by-owner.ts b/src/api/schemas/orders-by-owner.ts index 1f132ce..e9e5c85 100644 --- a/src/api/schemas/orders-by-owner.ts +++ b/src/api/schemas/orders-by-owner.ts @@ -22,7 +22,7 @@ export const GeneratorSummary = z.object({ hash: z .string() .describe( - "On-chain canonical identifier: keccak256(abi.encode(handler, salt, staticInput)). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", + "On-chain canonical identifier: keccak256(abi.encode((handler, salt, staticInput))). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", ), ownerAddressType: z .enum(["cowshed_proxy", "flash_loan_helper"]) From 8992efc68557ad8fa7ef3155f3fb2119ac18bae5 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:05:57 -0300 Subject: [PATCH 41/89] fix: use tighter 5s timeout for inner-loop RPC calls in settlement handler (COW-991) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/settlement.ts | 6 +++--- src/constants.ts | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 26b4f70..5786c0b 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -7,7 +7,7 @@ import { AAVE_V3_ADAPTER_FACTORY_ADDRESSES, GPV2_SETTLEMENT_DEPLOYMENTS, } from "../../data"; -import { BLOCK_HANDLER_RPC_TIMEOUT_MS } from "../../constants"; +import { BLOCK_HANDLER_RPC_TIMEOUT_MS, SETTLEMENT_INNER_RPC_TIMEOUT_MS } from "../../constants"; import { TimeoutError, withTimeout } from "../helpers/withTimeout"; // Trade(address,address,address,uint256,uint256,uint256,bytes) — topic0 hash @@ -123,7 +123,7 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { try { code = await withTimeout( context.client.getCode({ address: owner }), - BLOCK_HANDLER_RPC_TIMEOUT_MS, + SETTLEMENT_INNER_RPC_TIMEOUT_MS, "settlement:getCode", ); } catch (err) { @@ -144,7 +144,7 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { try { const result = await withTimeout( context.client.call({ to: owner, data: FACTORY_SELECTOR }), - BLOCK_HANDLER_RPC_TIMEOUT_MS, + SETTLEMENT_INNER_RPC_TIMEOUT_MS, "settlement:call:FACTORY", ); factoryData = result.data; diff --git a/src/constants.ts b/src/constants.ts index b427256..3f79fa3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -72,6 +72,10 @@ export const ORDERBOOK_HTTP_TIMEOUT_MS = 10_000; */ 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 * (account pagination + by_uids refresh). Owners that exceed this are skipped; From b732822cdf066dffb8154e932e9376e4426246a2 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:05:58 -0300 Subject: [PATCH 42/89] fix: update docs/api-reference.md to use historicalSyncProgressPct field name Co-Authored-By: Claude Sonnet 4.6 --- docs/api-reference.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 89fa309..0552804 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -53,21 +53,21 @@ Example response: "mainnet": { "totalBlocks": 7000000, "processedBlocks": 3000000, - "progressPct": 42.9, + "historicalSyncProgressPct": 42.9, "isRealtime": false, "isComplete": false }, "gnosis": { "totalBlocks": 17000000, "processedBlocks": 17000000, - "progressPct": 100.0, + "historicalSyncProgressPct": 100.0, "isRealtime": true, "isComplete": true } } ``` -- `progressPct` is rounded to one decimal place (0–100). +- `historicalSyncProgressPct` 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. From 810949edf423cdd59c8f77cf6c7f6a122d86d9af Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:06:18 -0300 Subject: [PATCH 43/89] fix: update stale C1/C5 name references in constants.ts and docs (COW-1000) Co-Authored-By: Claude Sonnet 4.6 --- docs/api-reference.md | 2 +- docs/deployment.md | 6 +++--- src/constants.ts | 14 +++++++------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 36c680f..e7bc8c7 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -92,7 +92,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` diff --git a/docs/deployment.md b/docs/deployment.md index 29a4171..2971052 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -28,9 +28,9 @@ 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. | diff --git a/src/constants.ts b/src/constants.ts index b427256..719b8cb 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 + * COW-908: 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,14 @@ 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; /** - * 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; From 3cb8f76aea6d5fd405fe3e50033d4e00abf9a6a0 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:06:19 -0300 Subject: [PATCH 44/89] fix: update stale C1/C5 name references in docs (COW-1001+COW-1002) Co-Authored-By: Claude Sonnet 4.6 --- docs/api-reference.md | 2 +- docs/deployment.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 66d528c..e5b4f00 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -92,7 +92,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` diff --git a/docs/deployment.md b/docs/deployment.md index b6855de..c779452 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -28,9 +28,9 @@ 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. | From e2c474b58707b689ca2fa987c43b2f86b3756d20 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:07:35 -0300 Subject: [PATCH 45/89] fix: fix preflight timeout, migrate console.log to cowLog, deduplicate DiscreteStatus type (COW-990) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 18 +++++++++++------- src/application/helpers/cowLogger.ts | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 src/application/helpers/cowLogger.ts diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 0bb8123..0642000 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -42,6 +42,9 @@ import { parsePollError, } from "../helpers/pollResultErrors"; import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; +import { cowLog } from "../helpers/cowLogger"; + +type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; const NON_DETERMINISTIC_TYPES = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"] as const; const SINGLE_SHOT_NON_DETERMINISTIC = ["GoodAfterTime", "TradeAboveThreshold"] as const; @@ -359,12 +362,11 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { // 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. - type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; let preflightStatuses: Awaited>; try { preflightStatuses = await withTimeout( fetchOrderStatusByUids(context, chainId, orphanCandidates.map((c) => c.orderUid)), - ORDERBOOK_HTTP_TIMEOUT_MS, + ORDERBOOK_HTTP_TIMEOUT_MS * 2, "c2:cascade:preflight", ); } catch { @@ -406,10 +408,13 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ), ); - const preflightHits = preflightStatuses.size; - console.log( - `[COW:C2] block=${event.block.number} chain=${chainId} parent-cancelled=${orphanCandidates.length} preflight-hits=${preflightHits}`, - ); + const preflightKnown = preflightStatuses.size; + cowLog("info", "c2:parent-cancelled", { + block: String(event.block.number), + chainId, + parentCancelled: orphanCandidates.length, + preflightKnown, + }); } } @@ -449,7 +454,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[] = []; diff --git a/src/application/helpers/cowLogger.ts b/src/application/helpers/cowLogger.ts new file mode 100644 index 0000000..3969837 --- /dev/null +++ b/src/application/helpers/cowLogger.ts @@ -0,0 +1,24 @@ +/** + * Structured JSON logger for handler code. Outputs one JSON line per call so + * log aggregators (Datadog, CloudWatch, etc.) can filter by chainId, handler, + * block number, or any other field without regex parsing. + * + * Ponder's own log lines are controlled by --log-format (pretty|json) on the + * CLI. These handler lines are always JSON so they remain parseable regardless + * of Ponder's format setting. + */ + +type LogLevel = "info" | "warn" | "error"; + +export function cowLog( + 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); + } +} From 414ae6f164b60da6e98d5f345a15145128e74538 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:07:36 -0300 Subject: [PATCH 46/89] fix: route warn/error to stderr in cowLog, add initialDelaySeconds to K8s probes (COW-994) Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 2 ++ src/application/helpers/cowLogger.ts | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/deployment.md b/docs/deployment.md index 05ce647..8036ef1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -104,12 +104,14 @@ livenessProbe: httpGet: path: /healthz port: 3000 + initialDelaySeconds: 30 periodSeconds: 30 failureThreshold: 3 readinessProbe: httpGet: path: /ready port: 3000 + initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 18 # 3-minute window before marking unready ``` diff --git a/src/application/helpers/cowLogger.ts b/src/application/helpers/cowLogger.ts index 5a2fbaa..3969837 100644 --- a/src/application/helpers/cowLogger.ts +++ b/src/application/helpers/cowLogger.ts @@ -15,5 +15,10 @@ export function cowLog( msg: string, fields: Record = {}, ): void { - console.log(JSON.stringify({ time: Date.now(), level, msg, ...fields })); + const line = JSON.stringify({ time: Date.now(), level, msg, ...fields }); + if (level === "warn" || level === "error") { + console.error(line); + } else { + console.log(line); + } } From e2ba179c83b478e772aea96ae460b8f5caeafedf Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:07:52 -0300 Subject: [PATCH 47/89] fix: add maxPages guard to fetchOwnerOrderStatuses, document aged-out fallback (COW-989) Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 2 ++ src/application/helpers/orderbookClient.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index d11b3ed..b6f929d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -177,6 +177,8 @@ Five live-only block handlers, all in a single file. They only run during live s **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. +**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`. + **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. **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. diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 635e6a7..03ac06a 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -303,11 +303,12 @@ export async function fetchOrderStatusByUids( 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); + const orders = await fetchAccountOrders(apiBaseUrl, owner, maxPages); for (const order of orders) { result.set(order.uid, { status: order.status, @@ -320,13 +321,15 @@ export async function fetchOwnerOrderStatuses( // ─── 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) { @@ -344,7 +347,9 @@ async function fetchAccountOrders( } 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) { From d3b5036453557ac41a0ac048b6cfbd1f90d50130 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:08:23 -0300 Subject: [PATCH 48/89] test: fix generator mock hash field, remove duplicate makeChain, add DB-throw coverage (COW-995) Co-Authored-By: Claude Sonnet 4.6 --- tests/api/execution-summary.test.ts | 7 ++++++ tests/api/orders-by-owner.test.ts | 38 ++++++++++++----------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts index edcd80c..4e725d5 100644 --- a/tests/api/execution-summary.test.ts +++ b/tests/api/execution-summary.test.ts @@ -93,4 +93,11 @@ describe("GET /api/generator/:eventId/execution-summary", () => { ); expect(res.status).toBe(400); }); + + it("returns 500 when the DB throws", async () => { + vi.mocked(db.execute).mockRejectedValueOnce(new Error("db error")); + + 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 index 84e8dbf..af5fc6c 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; // Mock virtual modules before any ponder-importing source files are loaded. -vi.mock("ponder:api", () => ({ db: { execute: vi.fn(), select: vi.fn() } })); +// 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 = { @@ -69,13 +69,6 @@ function makeContext({ }; } -/** Returns a db.select chain that resolves to `rows`. */ -function makeChain(rows: unknown[]) { - const where = vi.fn().mockResolvedValue(rows); - const from = vi.fn().mockReturnValue({ where }); - return { from }; -} - const GENERATOR = { eventId: EVENT_ID, chainId: CHAIN_ID, @@ -84,6 +77,7 @@ const GENERATOR = { resolvedOwner: OWNER, status: "Active", ownerAddressType: null, + hash: "0xabc123def456abc123def456abc123def456abc123def456abc123def456abc1", }; const ORDER = { @@ -107,8 +101,8 @@ beforeEach(() => { describe("ordersByOwnerHandler", () => { it("returns empty orders array when no generators are found", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) // ownerMapping → no proxies - .mockReturnValueOnce(makeChain([]) as never); // generators → none + .mockReturnValueOnce(db.__makeSelectChain([]) as never) // ownerMapping → no proxies + .mockReturnValueOnce(db.__makeSelectChain([]) as never); // generators → none const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -118,9 +112,9 @@ describe("ordersByOwnerHandler", () => { it("returns empty orders when generators exist but have no discrete orders", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -130,9 +124,9 @@ describe("ordersByOwnerHandler", () => { it("returns enriched orders with embedded generator data", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([ORDER]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -149,9 +143,9 @@ describe("ordersByOwnerHandler", () => { it("serialises creationDate as a decimal string (BigInt scalar)", async () => { vi.mocked(db.select) - .mockReturnValueOnce(makeChain([]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([ORDER]) as never); + .mockReturnValueOnce(db.__makeSelectChain([]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); @@ -162,9 +156,9 @@ describe("ordersByOwnerHandler", () => { it("includes proxy addresses from ownerMapping in the generator lookup", async () => { const PROXY = "0xcccccccccccccccccccccccccccccccccccccccc"; vi.mocked(db.select) - .mockReturnValueOnce(makeChain([{ address: PROXY }]) as never) - .mockReturnValueOnce(makeChain([GENERATOR]) as never) - .mockReturnValueOnce(makeChain([ORDER]) as never); + .mockReturnValueOnce(db.__makeSelectChain([{ address: PROXY }]) as never) + .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) + .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); const ctx = makeContext(); await ordersByOwnerHandler(ctx as never, vi.fn() as never); From 864586845425d6cebcf10120d9b49aa9a099efed Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 2 Jun 2026 17:08:28 -0300 Subject: [PATCH 49/89] fix: update stale C1/C5 names in comments, verify CirclesBackingOrder precompute (COW-996/999/1003) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/composableCow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/handlers/composableCow.ts b/src/application/handlers/composableCow.ts index ced8075..4aa348f 100644 --- a/src/application/handlers/composableCow.ts +++ b/src/application/handlers/composableCow.ts @@ -25,7 +25,7 @@ * 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 on-chain cancellation path is detected via - * SingleOrderNotAuthed (C1 block handler) and the C5 singleOrders() sweep, + * SingleOrderNotAuthed (OrderDiscoveryPoller) and the CancellationWatcher, * both of which work correctly. * * If this gap proves significant in production, a lightweight periodic check From bb80e89eaf331ed91bad06a382b44c3fde708f4b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Wed, 3 Jun 2026 10:21:22 -0300 Subject: [PATCH 50/89] fix: add non-null assertion on orders[0] in test to fix TS2532 Co-Authored-By: Claude Sonnet 4.6 --- tests/api/orders-by-owner.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts index ce76433..c96bb3b 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -105,7 +105,7 @@ describe("OrdersByOwnerResponse schema", () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.orders).toHaveLength(1); - expect(result.data.orders[0].generator?.hash).toBe(validGenerator.hash); + expect(result.data.orders[0]!.generator?.hash).toBe(validGenerator.hash); } }); From a418d7b8443083902a7f7d7e1eb41008e9d6ecb1 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:12 -0300 Subject: [PATCH 51/89] =?UTF-8?q?fix:=20improve=20hash=20field=20descripti?= =?UTF-8?q?on=20accuracy=20=E2=80=94=20reference=20ComposableCow.hash(para?= =?UTF-8?q?ms)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/api/schemas/orders-by-owner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/schemas/orders-by-owner.ts b/src/api/schemas/orders-by-owner.ts index e9e5c85..d55fd08 100644 --- a/src/api/schemas/orders-by-owner.ts +++ b/src/api/schemas/orders-by-owner.ts @@ -22,7 +22,7 @@ export const GeneratorSummary = z.object({ hash: z .string() .describe( - "On-chain canonical identifier: keccak256(abi.encode((handler, salt, staticInput))). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", + "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"]) From ff2338b9641c359c57dac369677ecc9289d79bb3 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:19 -0300 Subject: [PATCH 52/89] fix: replace stale C1-C4 block handler references with semantic names in comments Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/composableCow.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/application/handlers/composableCow.ts b/src/application/handlers/composableCow.ts index 4aa348f..b619a17 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 @@ -253,7 +253,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", From f41b45d05563187c6e3067570d1099d4b00b2c1b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:27 -0300 Subject: [PATCH 53/89] fix: wrap handler names in inline code in deployment.md flags table Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 2971052..9a76d5d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -28,9 +28,9 @@ Example: `DATABASE_URL=postgresql://cow_programmatic:secretpass@localhost:5433/c | Variable | Required | Description | |----------|----------|-------------| -| `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_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. | From de4af8ef77247fee70457648aa1754de000df177 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:34 -0300 Subject: [PATCH 54/89] docs: add sync-progress endpoint to API list and "checking if caught up" section Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index c779452..1ade315 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -177,8 +177,24 @@ 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) +- `GET /healthz` -- liveness probe; returns `{"status":"ok"}` as soon as the server starts +- `GET /ready` -- readiness probe; returns 200 only after the historical backfill is complete +- `GET /api/sync-progress` -- per-chain sync status with `historicalSyncProgressPct` (0–100) + +### Checking If the Indexer Is Caught Up + +`GET /ready` returns HTTP 200 when fully synced and 503 while still indexing. For a more granular view, `GET /api/sync-progress` returns the historical backfill percentage per chain: + +```json +{ + "chains": [ + { "chainId": 1, "chainName": "mainnet", "historicalSyncProgressPct": 100.0, "isSynced": true }, + { "chainId": 100, "chainName": "gnosis", "historicalSyncProgressPct": 100.0, "isSynced": true } + ] +} +``` + +`isSynced: true` means the backfill is complete and the indexer is processing new blocks in realtime. While `isSynced` is false the GraphQL/SQL data is partial — queries will succeed but results are incomplete. ## What's Not Implemented From 844e8157ff26104d17b64d4d125a4b29ce497666 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:41 -0300 Subject: [PATCH 55/89] fix: add comments explaining preflight timeout fallback and onConflictDoNothing semantics Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 0642000..eac2ce5 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -362,6 +362,8 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { // 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( @@ -373,6 +375,9 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { 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( From 6e140fd0a3f1a31f2b328b1e00f2d917f1e1982b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:47 -0300 Subject: [PATCH 56/89] fix: reduce cowLogger JSDoc to single-line comment per project convention Co-Authored-By: Claude Sonnet 4.6 --- src/application/helpers/cowLogger.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/application/helpers/cowLogger.ts b/src/application/helpers/cowLogger.ts index 3969837..91a2c0f 100644 --- a/src/application/helpers/cowLogger.ts +++ b/src/application/helpers/cowLogger.ts @@ -1,12 +1,4 @@ -/** - * Structured JSON logger for handler code. Outputs one JSON line per call so - * log aggregators (Datadog, CloudWatch, etc.) can filter by chainId, handler, - * block number, or any other field without regex parsing. - * - * Ponder's own log lines are controlled by --log-format (pretty|json) on the - * CLI. These handler lines are always JSON so they remain parseable regardless - * of Ponder's format setting. - */ +// 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"; From 86e5251c64f21e5322c6aa35abcfbd050515e9af Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:47:55 -0300 Subject: [PATCH 57/89] fix: use BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS for paginated account fallback timeout Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 00c0303..c85f342 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -544,7 +544,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { try { const ownerStatuses = await withTimeout( fetchOwnerOrderStatuses(chainId, owner), - ORDERBOOK_HTTP_TIMEOUT_MS, + BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS, "c2:stale:accountFallback", ); for (const [uid, info] of ownerStatuses) { From 37ddc2c296f155168c155b1876f6a57b72b69bba Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 10:48:02 -0300 Subject: [PATCH 58/89] fix: replace sql.join with Drizzle inArray in getCachedUidStatuses Co-Authored-By: Claude Sonnet 4.6 --- src/application/helpers/orderbookClient.ts | 40 ++++++++++++++++------ 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index ef3c57a..9fc9efc 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -18,7 +18,8 @@ import { conditionalOrderGenerator, discreteOrder, } from "ponder:schema"; -import { and, eq, sql } from "ponder"; +import { and, eq, inArray, sql } from "ponder"; +import { pgSchema, integer, text } from "drizzle-orm/pg-core"; import { encodeAbiParameters, keccak256, type Hex } from "viem"; import { COMPOSABLE_COW_HANDLER_ADDRESSES, ORDERBOOK_API_URLS } from "../../data"; import { ORDERBOOK_HTTP_TIMEOUT_MS, SIGNING_SCHEME_EIP1271 } from "../../constants"; @@ -459,7 +460,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 orderUidCacheTable = 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,17 +493,25 @@ async function getCachedUidStatuses( const batchSize = 500; for (let i = 0; i < uids.length; i += batchSize) { const batch = uids.slice(i, i + batchSize); - const uidList = sql.join(batch.map((uid) => sql`${uid}`), sql`, `); - const rows = (await context.db.sql.execute( - sql`SELECT order_uid, status, executed_sell_amount, executed_buy_amount - FROM cow_cache.order_uid_cache - WHERE chain_id = ${chainId} AND order_uid IN (${uidList})`, - )) as { order_uid: string; status: string; executed_sell_amount: string | null; executed_buy_amount: string | null }[]; + const rows = await context.db.sql + .select({ + orderUid: orderUidCacheTable.orderUid, + status: orderUidCacheTable.status, + executedSellAmount: orderUidCacheTable.executedSellAmount, + executedBuyAmount: orderUidCacheTable.executedBuyAmount, + }) + .from(orderUidCacheTable) + .where( + and( + eq(orderUidCacheTable.chainId, chainId), + inArray(orderUidCacheTable.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, }); } } From 76a8ff48265805adb3745f5e9e43500f1237ddde Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 11:25:02 -0300 Subject: [PATCH 59/89] =?UTF-8?q?fix:=20revert=20to=20sql.join=20for=20get?= =?UTF-8?q?CachedUidStatuses=20=E2=80=94=20drizzle-orm/pg-core=20is=20not?= =?UTF-8?q?=20a=20direct=20dep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/application/helpers/orderbookClient.ts | 40 ++++++---------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 9fc9efc..ef3c57a 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -18,8 +18,7 @@ import { conditionalOrderGenerator, discreteOrder, } from "ponder:schema"; -import { and, eq, inArray, sql } from "ponder"; -import { pgSchema, integer, text } from "drizzle-orm/pg-core"; +import { and, eq, sql } from "ponder"; import { encodeAbiParameters, keccak256, type Hex } from "viem"; import { COMPOSABLE_COW_HANDLER_ADDRESSES, ORDERBOOK_API_URLS } from "../../data"; import { ORDERBOOK_HTTP_TIMEOUT_MS, SIGNING_SCHEME_EIP1271 } from "../../constants"; @@ -460,16 +459,7 @@ async function filterAndProcess( } // ─── Per-UID cache helpers ────────────────────────────────────────────────── -// cow_cache.order_uid_cache is created by setup.ts. Table defined here for typed queries. -const cowCacheSchema = pgSchema("cow_cache"); -const orderUidCacheTable = 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"), -}); +// cow_cache.order_uid_cache is created by setup.ts. Fully qualified names required. /** Cached order data returned by getCachedUidStatuses. */ interface CachedOrderData { @@ -493,25 +483,17 @@ async function getCachedUidStatuses( const batchSize = 500; for (let i = 0; i < uids.length; i += batchSize) { const batch = uids.slice(i, i + batchSize); - const rows = await context.db.sql - .select({ - orderUid: orderUidCacheTable.orderUid, - status: orderUidCacheTable.status, - executedSellAmount: orderUidCacheTable.executedSellAmount, - executedBuyAmount: orderUidCacheTable.executedBuyAmount, - }) - .from(orderUidCacheTable) - .where( - and( - eq(orderUidCacheTable.chainId, chainId), - inArray(orderUidCacheTable.orderUid, batch), - ), - ); + const uidList = sql.join(batch.map((uid) => sql`${uid}`), sql`, `); + const rows = (await context.db.sql.execute( + sql`SELECT order_uid, status, executed_sell_amount, executed_buy_amount + FROM cow_cache.order_uid_cache + WHERE chain_id = ${chainId} AND order_uid IN (${uidList})`, + )) as { order_uid: string; status: string; executed_sell_amount: string | null; executed_buy_amount: string | null }[]; for (const row of rows) { - result.set(row.orderUid, { + result.set(row.order_uid, { status: row.status, - executedSellAmount: row.executedSellAmount, - executedBuyAmount: row.executedBuyAmount, + executedSellAmount: row.executed_sell_amount, + executedBuyAmount: row.executed_buy_amount, }); } } From 62c9dbad0aafbef69a8d2271aeb0035c8384152f Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 14:13:39 -0300 Subject: [PATCH 60/89] fix: use Drizzle query builder with inArray for getCachedUidStatuses; add drizzle-orm as direct dep Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + pnpm-lock.yaml | 4 +++ src/application/helpers/orderbookClient.ts | 40 ++++++++++++++++------ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 257684c..035ed15 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d208fca..5cfd2b6 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 @@ -2446,6 +2449,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: diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index ef3c57a..9fc9efc 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -18,7 +18,8 @@ import { conditionalOrderGenerator, discreteOrder, } from "ponder:schema"; -import { and, eq, sql } from "ponder"; +import { and, eq, inArray, sql } from "ponder"; +import { pgSchema, integer, text } from "drizzle-orm/pg-core"; import { encodeAbiParameters, keccak256, type Hex } from "viem"; import { COMPOSABLE_COW_HANDLER_ADDRESSES, ORDERBOOK_API_URLS } from "../../data"; import { ORDERBOOK_HTTP_TIMEOUT_MS, SIGNING_SCHEME_EIP1271 } from "../../constants"; @@ -459,7 +460,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 orderUidCacheTable = 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,17 +493,25 @@ async function getCachedUidStatuses( const batchSize = 500; for (let i = 0; i < uids.length; i += batchSize) { const batch = uids.slice(i, i + batchSize); - const uidList = sql.join(batch.map((uid) => sql`${uid}`), sql`, `); - const rows = (await context.db.sql.execute( - sql`SELECT order_uid, status, executed_sell_amount, executed_buy_amount - FROM cow_cache.order_uid_cache - WHERE chain_id = ${chainId} AND order_uid IN (${uidList})`, - )) as { order_uid: string; status: string; executed_sell_amount: string | null; executed_buy_amount: string | null }[]; + const rows = await context.db.sql + .select({ + orderUid: orderUidCacheTable.orderUid, + status: orderUidCacheTable.status, + executedSellAmount: orderUidCacheTable.executedSellAmount, + executedBuyAmount: orderUidCacheTable.executedBuyAmount, + }) + .from(orderUidCacheTable) + .where( + and( + eq(orderUidCacheTable.chainId, chainId), + inArray(orderUidCacheTable.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, }); } } From 06c8d306a3b244d42e0bb09300569878a57b7a3e Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 14:20:11 -0300 Subject: [PATCH 61/89] refactor: convert cacheUidStatuses INSERT to Drizzle query builder Replaces the remaining raw sql\` template in orderbookClient.ts with .insert().onConflictDoUpdate() using the already-defined orderUidCacheTable. Also drops the now-unused `sql` named import from ponder. Co-Authored-By: Claude Sonnet 4.6 --- src/application/helpers/orderbookClient.ts | 32 ++++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 9fc9efc..17e1fdc 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -18,7 +18,7 @@ import { conditionalOrderGenerator, discreteOrder, } from "ponder:schema"; -import { and, eq, inArray, 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 { COMPOSABLE_COW_HANDLER_ADDRESSES, ORDERBOOK_API_URLS } from "../../data"; @@ -532,17 +532,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(orderUidCacheTable) + .values({ + chainId, + orderUid: order.uid, + status: order.status, + fetchedAt: now, + executedSellAmount: order.executedSellAmount, + executedBuyAmount: order.executedBuyAmount, + }) + .onConflictDoUpdate({ + target: [orderUidCacheTable.chainId, orderUidCacheTable.orderUid], + set: { + status: order.status, + fetchedAt: now, + executedSellAmount: order.executedSellAmount, + executedBuyAmount: order.executedBuyAmount, + }, + }); } catch { // Best-effort cache write } From e4708b23c5b73fd1e5b8ddb61be019392cd5d79c Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 14:23:29 -0300 Subject: [PATCH 62/89] refactor: replace raw sql IS NULL and GROUP BY with Drizzle operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blockHandler.ts: 3× sql\`${col} IS NULL\` → isNull(col); adds isNull import - execution-summary.ts: sql\`SELECT … GROUP BY\` → .select().from().where().groupBy() using discreteOrder table and count() operator; drops Number() cast Co-Authored-By: Claude Sonnet 4.6 --- src/api/endpoints/execution-summary.ts | 24 ++++++++++++++---------- src/application/handlers/blockHandler.ts | 8 ++++---- 2 files changed, 18 insertions(+), 14 deletions(-) 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/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..6e660e9 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -16,7 +16,7 @@ import { ponder } from "ponder:registry"; import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema"; -import { and, asc, eq, inArray, lte, or, sql } from "ponder"; +import { and, asc, eq, inArray, isNull, lte, or, sql } from "ponder"; import type { Hex } from "viem"; import { COMPOSABLE_COW_ADDRESS_BY_CHAIN_ID, @@ -409,7 +409,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), ), ), @@ -723,7 +723,7 @@ 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; @@ -810,7 +810,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), ), ), From 64382af59ceb8b0f15f034da41147fa59cdc7e4d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 17:12:19 -0300 Subject: [PATCH 63/89] refactor(tests): address review comments on PR #79 - Export PAYLOAD_STRUCT_ABI from erc1271Signature.ts so the test can import it instead of duplicating the constant inline - Use z.infer for StatusRow.status in execution-summary.test.ts instead of the loose `string` type Co-Authored-By: Claude Sonnet 4.6 --- src/application/decoders/erc1271Signature.ts | 2 +- tests/api/execution-summary.test.ts | 4 +++- .../decoders/erc1271Signature.test.ts | 23 +------------------ 3 files changed, 5 insertions(+), 24 deletions(-) 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/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts index 4e725d5..669ba60 100644 --- a/tests/api/execution-summary.test.ts +++ b/tests/api/execution-summary.test.ts @@ -1,5 +1,6 @@ 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: { execute: vi.fn() } })); @@ -13,8 +14,9 @@ vi.mock("ponder", () => ({ 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"; -type StatusRow = { status: string; count: string }; +type StatusRow = { status: z.infer; count: string }; function buildApp() { const app = new OpenAPIHono(); diff --git a/tests/application/decoders/erc1271Signature.test.ts b/tests/application/decoders/erc1271Signature.test.ts index 35e71bf..27053d1 100644 --- a/tests/application/decoders/erc1271Signature.test.ts +++ b/tests/application/decoders/erc1271Signature.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { encodeAbiParameters, getAddress, type Hex } from "viem"; -import { decodeEip1271Signature } from "../../../src/application/decoders/erc1271Signature"; +import { decodeEip1271Signature, PAYLOAD_STRUCT_ABI } from "../../../src/application/decoders/erc1271Signature"; const HANDLER = "0xaabbccddaabbccddaabbccddaabbccddaabbccdd" as Hex; const SALT = ("0x" + "ab".repeat(32)) as Hex; @@ -8,27 +8,6 @@ const STATIC_INPUT = "0xdeadbeef" as Hex; const PROOF: Hex[] = [("0x" + "11".repeat(32)) as Hex]; const OFFCHAIN_INPUT = "0x1234" as Hex; -// The PayloadStruct ABI as defined in the decoder — must match exactly. -const PAYLOAD_STRUCT_ABI = [ - { - type: "tuple" as const, - name: "payload", - components: [ - { name: "proof", type: "bytes32[]" as const }, - { - type: "tuple" as const, - name: "params", - components: [ - { name: "handler", type: "address" as const }, - { name: "salt", type: "bytes32" as const }, - { name: "staticInput", type: "bytes" as const }, - ], - }, - { name: "offchainInput", type: "bytes" as const }, - ], - }, -] as const; - /** Build a Format B signature (ERC1271Forwarder / CoWShed path). */ function buildFormatB({ handler = HANDLER, From c4fd3b170cc563871a6b1c6add508e1857b6412b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 17:17:51 -0300 Subject: [PATCH 64/89] refactor(tests): use DiscreteOrderStatusQuery.enum constants in test data Replace raw string literals ("fulfilled", "expired", etc.) in StatusRow test fixtures with Status.* references from the Zod enum so renames are caught at compile time. Co-Authored-By: Claude Sonnet 4.6 --- tests/api/execution-summary.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts index 669ba60..1712208 100644 --- a/tests/api/execution-summary.test.ts +++ b/tests/api/execution-summary.test.ts @@ -16,6 +16,7 @@ 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: string }; function buildApp() { @@ -52,9 +53,9 @@ describe("GET /api/generator/:eventId/execution-summary", () => { it("maps fulfilled, expired, open, unfilled, cancelled to the right fields", async () => { const rows: StatusRow[] = [ - { status: "fulfilled", count: "3" }, - { status: "expired", count: "7" }, - { status: "open", count: "2" }, + { status: Status.fulfilled, count: "3" }, + { status: Status.expired, count: "7" }, + { status: Status.open, count: "2" }, ]; vi.mocked(db.execute).mockResolvedValue({ rows } as never); @@ -70,9 +71,9 @@ describe("GET /api/generator/:eventId/execution-summary", () => { it("totalParts is the sum of all status counts", async () => { const rows: StatusRow[] = [ - { status: "fulfilled", count: "10" }, - { status: "cancelled", count: "5" }, - { status: "unfilled", count: "3" }, + { status: Status.fulfilled, count: "10" }, + { status: Status.cancelled, count: "5" }, + { status: Status.unfilled, count: "3" }, ]; vi.mocked(db.execute).mockResolvedValue({ rows } as never); From 6f73a139b469e68e0749697f90543c9f7996470c Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 17:36:10 -0300 Subject: [PATCH 65/89] fix: export makeSelectChain from mock to fix __makeSelectChain typecheck error TypeScript checked db.__makeSelectChain against the real ReadonlyDrizzle type which doesn't have that property. Fix: export makeSelectChain as a standalone named export from ponder-api.ts mock and import it directly in the test. Co-Authored-By: Claude Sonnet 4.6 --- tests/__mocks__/ponder-api.ts | 4 +--- tests/api/orders-by-owner.test.ts | 29 +++++++++++++++-------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/__mocks__/ponder-api.ts b/tests/__mocks__/ponder-api.ts index 33c414d..ab6ca87 100644 --- a/tests/__mocks__/ponder-api.ts +++ b/tests/__mocks__/ponder-api.ts @@ -2,7 +2,7 @@ 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. -function makeSelectChain(rows: unknown[] = []) { +export function makeSelectChain(rows: unknown[] = []) { const where = vi.fn().mockResolvedValue(rows); const from = vi.fn().mockReturnValue({ where }); return { from }; @@ -11,6 +11,4 @@ function makeSelectChain(rows: unknown[] = []) { export const db = { execute: vi.fn().mockResolvedValue({ rows: [] }), select: vi.fn().mockReturnValue(makeSelectChain()), - /** Helper used by tests to program per-call return values. */ - __makeSelectChain: makeSelectChain, }; diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts index af5fc6c..df6fd0a 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -31,6 +31,7 @@ vi.mock("ponder", () => ({ })); import { db } from "ponder:api"; +import { makeSelectChain } from "../__mocks__/ponder-api"; import { ordersByOwnerHandler } from "../../src/api/endpoints/orders-by-owner"; const OWNER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -101,8 +102,8 @@ beforeEach(() => { describe("ordersByOwnerHandler", () => { it("returns empty orders array when no generators are found", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) // ownerMapping → no proxies - .mockReturnValueOnce(db.__makeSelectChain([]) as never); // generators → none + .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); @@ -112,9 +113,9 @@ describe("ordersByOwnerHandler", () => { it("returns empty orders when generators exist but have no discrete orders", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([]) as never); + .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); @@ -124,9 +125,9 @@ describe("ordersByOwnerHandler", () => { it("returns enriched orders with embedded generator data", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); + .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); @@ -143,9 +144,9 @@ describe("ordersByOwnerHandler", () => { it("serialises creationDate as a decimal string (BigInt scalar)", async () => { vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); + .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); @@ -156,9 +157,9 @@ describe("ordersByOwnerHandler", () => { it("includes proxy addresses from ownerMapping in the generator lookup", async () => { const PROXY = "0xcccccccccccccccccccccccccccccccccccccccc"; vi.mocked(db.select) - .mockReturnValueOnce(db.__makeSelectChain([{ address: PROXY }]) as never) - .mockReturnValueOnce(db.__makeSelectChain([GENERATOR]) as never) - .mockReturnValueOnce(db.__makeSelectChain([ORDER]) as never); + .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); From bc002a172c47e760b4c0de692e868e9393a14e72 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 17:38:55 -0300 Subject: [PATCH 66/89] =?UTF-8?q?refactor:=20rename=20orderUidCacheTable?= =?UTF-8?q?=20=E2=86=92=20orderUidCache=20(drop=20redundant=20Table=20suff?= =?UTF-8?q?ix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/application/helpers/orderbookClient.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 17e1fdc..ac10fea 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -462,7 +462,7 @@ async function filterAndProcess( // ─── Per-UID cache helpers ────────────────────────────────────────────────── // cow_cache.order_uid_cache is created by setup.ts. Table defined here for typed queries. const cowCacheSchema = pgSchema("cow_cache"); -const orderUidCacheTable = cowCacheSchema.table("order_uid_cache", { +const orderUidCache = cowCacheSchema.table("order_uid_cache", { chainId: integer("chain_id").notNull(), orderUid: text("order_uid").notNull(), status: text("status").notNull(), @@ -495,16 +495,16 @@ async function getCachedUidStatuses( const batch = uids.slice(i, i + batchSize); const rows = await context.db.sql .select({ - orderUid: orderUidCacheTable.orderUid, - status: orderUidCacheTable.status, - executedSellAmount: orderUidCacheTable.executedSellAmount, - executedBuyAmount: orderUidCacheTable.executedBuyAmount, + orderUid: orderUidCache.orderUid, + status: orderUidCache.status, + executedSellAmount: orderUidCache.executedSellAmount, + executedBuyAmount: orderUidCache.executedBuyAmount, }) - .from(orderUidCacheTable) + .from(orderUidCache) .where( and( - eq(orderUidCacheTable.chainId, chainId), - inArray(orderUidCacheTable.orderUid, batch), + eq(orderUidCache.chainId, chainId), + inArray(orderUidCache.orderUid, batch), ), ); for (const row of rows) { @@ -533,7 +533,7 @@ async function cacheUidStatuses( for (const order of orders) { try { await context.db.sql - .insert(orderUidCacheTable) + .insert(orderUidCache) .values({ chainId, orderUid: order.uid, @@ -543,7 +543,7 @@ async function cacheUidStatuses( executedBuyAmount: order.executedBuyAmount, }) .onConflictDoUpdate({ - target: [orderUidCacheTable.chainId, orderUidCacheTable.orderUid], + target: [orderUidCache.chainId, orderUidCache.orderUid], set: { status: order.status, fetchedAt: now, From e10387f42d4f62b0c63d39074c45d52936938e85 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 18:02:15 -0300 Subject: [PATCH 67/89] test: update execution-summary test for db.select + count() migration Handler was converted from db.execute(sql\`GROUP BY\`) to db.select().from().where().groupBy() with count() from ponder. Update mocks accordingly: ponder:api now exposes db.select, ponder exports and/eq/count, and StatusRow.count is number not string. Co-Authored-By: Claude Sonnet 4.6 --- tests/api/execution-summary.test.ts | 51 ++++++++++++++++++----------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/tests/api/execution-summary.test.ts b/tests/api/execution-summary.test.ts index 1712208..e496771 100644 --- a/tests/api/execution-summary.test.ts +++ b/tests/api/execution-summary.test.ts @@ -3,12 +3,14 @@ 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: { execute: vi.fn() } })); +vi.mock("ponder:api", () => ({ db: { select: vi.fn() } })); vi.mock("ponder", () => ({ - sql: Object.assign( - (_s: TemplateStringsArray, ..._v: unknown[]) => ({}), - { raw: (_s: string) => ({}) }, - ), + and: (..._args: unknown[]) => ({}), + eq: (..._args: unknown[]) => ({}), + count: () => ({}), +})); +vi.mock("ponder:schema", () => ({ + discreteOrder: { status: "status", conditionalOrderGeneratorId: "conditionalOrderGeneratorId", chainId: "chainId" }, })); import { db } from "ponder:api"; @@ -17,7 +19,7 @@ import { executionSummaryHandler } from "../../src/api/endpoints/execution-summa import { DiscreteOrderStatusQuery } from "../../src/api/schemas/common"; const Status = DiscreteOrderStatusQuery.enum; -type StatusRow = { status: z.infer; count: string }; +type StatusRow = { status: z.infer; count: number }; function buildApp() { const app = new OpenAPIHono(); @@ -31,13 +33,20 @@ 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.execute).mockReset(); + 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.execute).mockResolvedValue({ rows: [] } as never); + vi.mocked(db.select).mockReturnValueOnce(makeSelectChain([]) as never); const res = await buildApp().request(makeUrl()); expect(res.status).toBe(200); @@ -53,11 +62,11 @@ describe("GET /api/generator/:eventId/execution-summary", () => { 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" }, + { status: Status.fulfilled, count: 3 }, + { status: Status.expired, count: 7 }, + { status: Status.open, count: 2 }, ]; - vi.mocked(db.execute).mockResolvedValue({ rows } as never); + vi.mocked(db.select).mockReturnValueOnce(makeSelectChain(rows) as never); const body = await (await buildApp().request(makeUrl())).json() as Record; @@ -71,18 +80,18 @@ describe("GET /api/generator/:eventId/execution-summary", () => { 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" }, + { status: Status.fulfilled, count: 10 }, + { status: Status.cancelled, count: 5 }, + { status: Status.unfilled, count: 3 }, ]; - vi.mocked(db.execute).mockResolvedValue({ rows } as never); + 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.execute).mockResolvedValue({ rows: [] } as never); + 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); @@ -90,15 +99,17 @@ describe("GET /api/generator/:eventId/execution-summary", () => { }); it("returns 400 when chainId query param is missing", async () => { - const app = buildApp(); - const res = await app.request( + 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 () => { - vi.mocked(db.execute).mockRejectedValueOnce(new Error("db error")); + 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); From 48347385c7686da018267d7de0e4e2399a9a8370 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 18:19:46 -0300 Subject: [PATCH 68/89] docs: note ConditionalOrderRemoved in newer ComposableCoW contract (COW-1005) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/composableCow.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/application/handlers/composableCow.ts b/src/application/handlers/composableCow.ts index b619a17..61b72a0 100644 --- a/src/application/handlers/composableCow.ts +++ b/src/application/handlers/composableCow.ts @@ -28,8 +28,10 @@ * 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. * */ From 04181adcfb692a6cb7b7e847dd99812f10cd8930 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 18:25:37 -0300 Subject: [PATCH 69/89] refactor: replace isDeterministicOrderType with Record and tighten orderType typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - order-types.ts: replace DETERMINISTIC_ORDER_TYPES Set + isDeterministicOrderType() with DETERMINISTIC_ORDER_TYPE: Record — exhaustive record ensures TypeScript catches missing entries when new OrderTypes are added - uidPrecompute.ts: orderType: string → OrderType in both function signatures; isDeterministicOrderType() → DETERMINISTIC_ORDER_TYPE[] lookup - blockHandler.ts: NON_DETERMINISTIC_TYPES and SINGLE_SHOT_NON_DETERMINISTIC typed as readonly OrderType[]; three inline query result casts string → OrderType; removes as readonly string[] workaround on .includes() - orderbookClient.ts: orderType: string → OrderType in ComposableOrder and query result cast; empty string placeholder → "Unknown" - tests/utils/order-types.test.ts: rewrite to test DETERMINISTIC_ORDER_TYPE record Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 13 +++--- src/application/helpers/orderbookClient.ts | 7 ++-- src/application/helpers/uidPrecompute.ts | 8 ++-- src/utils/order-types.ts | 16 ++++--- tests/utils/order-types.test.ts | 49 +++++++++------------- 5 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 817453c..054b9cc 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -41,9 +41,10 @@ import { parsePollError, } from "../helpers/pollResultErrors"; import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; +import { type OrderType } from "../../utils/order-types"; -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"]); @@ -112,7 +113,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { handler: Hex; salt: Hex; staticInput: Hex; - orderType: string; + orderType: OrderType; decodedParams: Record | null; consecutiveTryNextBlock: number; }[]; @@ -197,7 +198,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, @@ -728,7 +729,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { ) as { generatorId: string; owner: Hex; - orderType: string; + orderType: OrderType; }[]; // Exclude owners already retried above — they were just attempted this run @@ -820,7 +821,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = generatorId: string; owner: Hex; hash: Hex; - orderType: string; + orderType: OrderType; }[]; if (dueGenerators.length === 0) return; diff --git a/src/application/helpers/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 5ef7b2d..0dd0771 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -20,6 +20,7 @@ import { } from "ponder:schema"; import { and, eq, sql } from "ponder"; 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"; @@ -52,7 +53,7 @@ export type ComposableOrder = Pick< uid: string; generatorId: string; generatorHash: string; - orderType: string; + orderType: OrderType; creationDate: number; }; @@ -274,7 +275,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, @@ -432,7 +433,7 @@ async function filterAndProcess( ) .limit(1)) as { eventId: string; - orderType: string; + orderType: OrderType; }[]; if (generators.length === 0) continue; diff --git a/src/application/helpers/uidPrecompute.ts b/src/application/helpers/uidPrecompute.ts index 80ca37e..bd25086 100644 --- a/src/application/helpers/uidPrecompute.ts +++ b/src/application/helpers/uidPrecompute.ts @@ -20,7 +20,7 @@ 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"; // GPv2Order.sol constant hashes const KIND_SELL = "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775" as Hex; @@ -55,12 +55,12 @@ 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)) { + if (DETERMINISTIC_ORDER_TYPE[orderType]) { console.warn(`[COW:PRECOMPUTE] SKIP type=${orderType} owner=${owner} chain=${chainId} reason=decodedParams_null`); } return null; @@ -93,7 +93,7 @@ export async function precomputeAndDiscover( chainId: number, generatorEventId: string, owner: Hex, - orderType: string, + orderType: OrderType, decodedParams: Record | null, blockTimestamp: bigint, ): Promise { diff --git a/src/utils/order-types.ts b/src/utils/order-types.ts index a9540df..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", "CirclesBackingOrder"]); - -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/utils/order-types.test.ts b/tests/utils/order-types.test.ts index cd0c5ad..46e39d0 100644 --- a/tests/utils/order-types.test.ts +++ b/tests/utils/order-types.test.ts @@ -1,39 +1,30 @@ import { describe, it, expect } from "vitest"; import { - DETERMINISTIC_ORDER_TYPES, - isDeterministicOrderType, + DETERMINISTIC_ORDER_TYPE, + type OrderType, } from "../../src/utils/order-types"; -describe("DETERMINISTIC_ORDER_TYPES", () => { - it("includes TWAP", () => { - expect(DETERMINISTIC_ORDER_TYPES.has("TWAP")).toBe(true); +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("includes StopLoss", () => { - expect(DETERMINISTIC_ORDER_TYPES.has("StopLoss")).toBe(true); + 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); }); - // Regression guard for COW-1003 (F2): CirclesBackingOrder is deterministic - // (precomputed in uidPrecompute.ts) but was missing from this set, causing - // spurious non-deterministic warnings in logs. - it("includes CirclesBackingOrder (COW-1003)", () => { - expect(DETERMINISTIC_ORDER_TYPES.has("CirclesBackingOrder")).toBe(true); - }); - - it("does not include non-deterministic types", () => { - expect(DETERMINISTIC_ORDER_TYPES.has("PerpetualSwap")).toBe(false); - expect(DETERMINISTIC_ORDER_TYPES.has("GoodAfterTime")).toBe(false); - expect(DETERMINISTIC_ORDER_TYPES.has("TradeAboveThreshold")).toBe(false); - }); - - it("isDeterministicOrderType returns true for all members", () => { - for (const type of DETERMINISTIC_ORDER_TYPES) { - expect(isDeterministicOrderType(type)).toBe(true); - } - }); - - it("isDeterministicOrderType returns false for unknown types", () => { - expect(isDeterministicOrderType("Unknown")).toBe(false); - expect(isDeterministicOrderType("")).toBe(false); + 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); }); }); From 928101662d99751f38d8da3ec9fd21c4d1414d55 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 18:49:54 -0300 Subject: [PATCH 70/89] test: trim orders-by-owner schema tests to regression-only guards Remove tests that duplicate TypeScript's static coverage (type check, describe text, enum values). Keep only the two runtime regression guards that safeParse-unknown cannot catch at compile time. Co-Authored-By: Claude Sonnet 4.6 --- tests/api/orders-by-owner.test.ts | 47 ++++--------------------------- 1 file changed, 6 insertions(+), 41 deletions(-) diff --git a/tests/api/orders-by-owner.test.ts b/tests/api/orders-by-owner.test.ts index c96bb3b..c24e916 100644 --- a/tests/api/orders-by-owner.test.ts +++ b/tests/api/orders-by-owner.test.ts @@ -25,6 +25,9 @@ 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); @@ -35,57 +38,19 @@ describe("GeneratorSummary schema", () => { } }); - it("fails parse when hash is not a string (number supplied)", () => { - const result = GeneratorSummary.safeParse({ ...validGenerator, hash: 12345 }); - expect(result.success).toBe(false); - if (!result.success) { - const paths = result.error.issues.map((i) => i.path.join(".")); - expect(paths).toContain("hash"); - } - }); - - it("hash field carries the correct describe() text", () => { - const shape = GeneratorSummary.shape; - const description = shape.hash.description; - expect(description).toBe( - "On-chain canonical identifier: keccak256(abi.encode(handler, salt, staticInput)). Used by ComposableCow.singleOrders(owner, hash) and remove(owner, hash).", - ); - }); - - it("ownerAddressType accepts null (regression guard for unchanged field)", () => { + // 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(); } }); - - it("ownerAddressType accepts the enum value 'cowshed_proxy'", () => { - const result = GeneratorSummary.safeParse({ - ...validGenerator, - ownerAddressType: "cowshed_proxy", - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.ownerAddressType).toBe("cowshed_proxy"); - } - }); - - it("ownerAddressType accepts the enum value 'flash_loan_helper'", () => { - const result = GeneratorSummary.safeParse({ - ...validGenerator, - ownerAddressType: "flash_loan_helper", - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.ownerAddressType).toBe("flash_loan_helper"); - } - }); }); describe("OrdersByOwnerResponse schema", () => { it("wraps an array of GeneratorSummary correctly via the orders field", () => { - // Build a minimal OrderItem that nests the generator. const orderItem = { orderUid: "0xorder001", chainId: 1, From 47e135c426e8b6bcad0be68cb8c38d4446a5f65d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 19:09:02 -0300 Subject: [PATCH 71/89] style: remove unnecessary quotes from block handler keys in ponder.config.ts All five keys are valid identifiers and don't need quoting. Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ponder.config.ts b/ponder.config.ts index 0c97b8b..b936a36 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -43,7 +43,7 @@ export default createConfig({ // 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. - "OrderDiscoveryPoller": { + OrderDiscoveryPoller: { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest", interval: 4 }, @@ -51,7 +51,7 @@ export default createConfig({ interval: 1, }, // CandidateConfirmer — checks API for unconfirmed candidates. - "CandidateConfirmer": { + CandidateConfirmer: { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, @@ -59,7 +59,7 @@ export default createConfig({ interval: 1, }, // OrderStatusTracker — polls API for open discrete order status. - "OrderStatusTracker": { + OrderStatusTracker: { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, @@ -67,7 +67,7 @@ export default createConfig({ interval: 1, }, // OwnerBackfill — one-time owner fetch for non-deterministic backfill orders. - "OwnerBackfill": { + OwnerBackfill: { chain: { mainnet: { startBlock: "latest", endBlock: "latest" }, gnosis: { startBlock: "latest", endBlock: "latest" }, @@ -77,7 +77,7 @@ export default createConfig({ // 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": { + CancellationWatcher: { chain: { mainnet: { startBlock: "latest" }, gnosis: { startBlock: "latest" }, From 1a5b7cf4a2833587b18720b5b6b3788dfa81ff81 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 19:36:58 -0300 Subject: [PATCH 72/89] refactor: move settlement RPC work to SettlementResolver block handler (COW-991) The GPv2Settlement event handler now only enqueues the tx hash into settlementQueue. A new SettlementResolver:block handler drains the queue and performs all RPC calls (getTransactionReceipt, getCode, call, readContract). RPC errors warn + skip rather than re-throw, so the indexer can never crash from a transient node failure in settlement processing. Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 8 + schema/tables.ts | 13 + src/application/handlers/settlement.ts | 334 ++++++++++++++----------- 3 files changed, 202 insertions(+), 153 deletions(-) diff --git a/ponder.config.ts b/ponder.config.ts index c3e66a6..5902e67 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -85,5 +85,13 @@ export default createConfig({ }, interval: 1, }, + // SettlementResolver — async Aave adapter discovery from queued Settlement events. + // Mainnet only: flash loan router is not deployed on Gnosis. + SettlementResolver: { + chain: { + mainnet: { startBlock: "latest" }, + }, + 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/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 5786c0b..a918796 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -1,5 +1,11 @@ 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 { keccak256, toBytes } from "viem"; import { AaveV3AdapterHelperAbi } from "../../../abis/AaveV3AdapterHelperAbi"; @@ -8,7 +14,7 @@ import { GPV2_SETTLEMENT_DEPLOYMENTS, } from "../../data"; import { BLOCK_HANDLER_RPC_TIMEOUT_MS, SETTLEMENT_INNER_RPC_TIMEOUT_MS } from "../../constants"; -import { TimeoutError, withTimeout } from "../helpers/withTimeout"; +import { TimeoutError as _TimeoutError, withTimeout } from "../helpers/withTimeout"; // Trade(address,address,address,uint256,uint256,uint256,bytes) — topic0 hash const TRADE_TOPIC = keccak256( @@ -16,10 +22,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, @@ -50,19 +55,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(); @@ -72,167 +93,174 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { ]?.toLowerCase(); if (!adapterFactoryAddress) return; - stats.total++; - - // Fetch the full receipt to access all logs in the transaction. - // FlashLoanRouter settlements only, but 27k+ adapters were resolved in practice — - // the RPC call is meaningful and must be timeout-guarded. - let receipt: Awaited>; - try { - receipt = await withTimeout( - context.client.getTransactionReceipt({ hash: event.transaction.hash }), - BLOCK_HANDLER_RPC_TIMEOUT_MS, - "settlement:getTransactionReceipt", - ); - } catch (err) { - if (err instanceof TimeoutError) return; - throw err; - } + const pending = await context.db.sql + .select() + .from(settlementQueue) + .where(eq(settlementQueue.chainId, chainId)) + .limit(MAX_SETTLEMENTS_PER_BLOCK); - 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; - } + if (pending.length === 0) return; + + for (const item of pending) { + stats.total++; - // Skip if EOA (no bytecode) - let code: `0x${string}` | undefined; + let receipt: Awaited>; try { - code = await withTimeout( - context.client.getCode({ address: owner }), - SETTLEMENT_INNER_RPC_TIMEOUT_MS, - "settlement:getCode", + receipt = await withTimeout( + context.client.getTransactionReceipt({ hash: item.txHash }), + BLOCK_HANDLER_RPC_TIMEOUT_MS, + "settlement:getTransactionReceipt", ); } catch (err) { - if (err instanceof TimeoutError) continue; - throw err; - } - if (!code || code === "0x") { - stats.skippedEOA++; - logStatsIfIntervalPassed(); + console.warn( + `[COW:SETTLEMENT:RESOLVER] receipt failed 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 withTimeout( - context.client.call({ to: owner, data: FACTORY_SELECTOR }), - SETTLEMENT_INNER_RPC_TIMEOUT_MS, - "settlement:call:FACTORY", - ); - factoryData = result.data; - } catch { + for (const log of receipt.logs) { + if (log.address.toLowerCase() !== settlementAddress) continue; + if (log.topics[0] !== TRADE_TOPIC) continue; + + stats.tradeLogsFound++; + + const owner = `0x${log.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) { + console.warn( + `[COW:SETTLEMENT:RESOLVER] getCode failed owner=${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++; - logStatsIfIntervalPassed(); - continue; - } + 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) { + console.warn( + `[COW:SETTLEMENT:RESOLVER] readContract:owner failed owner=${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(); - // Decode padded address: 0x + 24 zero-padding hex chars + 40 address hex chars - const factoryAddress = `0x${factoryData.slice(26)}` as `0x${string}`; + 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(); - if (factoryAddress.toLowerCase() !== adapterFactoryAddress) { - stats.skippedNotAdapter++; + await context.db.sql + .update(conditionalOrderGenerator) + .set({ ownerAddressType: AddressType.FlashLoanHelper }) + .where( + and( + eq(conditionalOrderGenerator.chainId, chainId), + eq(conditionalOrderGenerator.owner, ownerAddress), + ), + ); + + stats.mapped++; logStatsIfIntervalPassed(); - continue; - } - // Resolve EOA via owner() — this call should always succeed at this point - 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", + console.log( + `[COW:SETTLEMENT:TRADE] AAVE_ADAPTER_MAPPED` + + ` adapter=${ownerAddress}` + + ` eoa=${eoaOwner.toLowerCase()}` + + ` block=${item.blockNumber}` + + ` chain=${chainId}`, ); - } catch (err) { - if (err instanceof TimeoutError) continue; - throw err; } - 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))); - stats.mapped++; logStatsIfIntervalPassed(); - - console.log( - `[COW:SETTLEMENT:TRADE] AAVE_ADAPTER_MAPPED` + - ` adapter=${ownerAddress}` + - ` eoa=${eoaOwner.toLowerCase()}` + - ` block=${event.block.number}` + - ` chain=${chainId}`, - ); } - - logStatsIfIntervalPassed(); }); From 0cd52b3585d49f4b966052cac4f26fff95be5290 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 19:38:27 -0300 Subject: [PATCH 73/89] chore: merge develop into PR #84, adapt SettlementResolver to settlementChains SettlementResolver now uses settlementChains.map() (chains with a flash loan router) instead of hardcoded mainnet, consistent with develop's dynamic chain config pattern. Co-Authored-By: Claude Sonnet 4.6 --- ponder.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ponder.config.ts b/ponder.config.ts index 1b92341..4847deb 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -115,11 +115,11 @@ export default createConfig({ interval: 1, }, // SettlementResolver — async Aave adapter discovery from queued Settlement events. - // Mainnet only: flash loan router is not deployed on Gnosis. + // Only runs on chains that have a flash loan router (currently mainnet only). SettlementResolver: { - chain: { - mainnet: { startBlock: "latest" }, - }, + chain: Object.fromEntries( + settlementChains.map((c) => [c.name, { startBlock: "latest" as const }]), + ), interval: 1, }, }, From 35b8cb9847f113612d553c1a815b63656d2e9438 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 19:53:02 -0300 Subject: [PATCH 74/89] fix: address PR #85 review comments (COW-1001 + COW-1002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/api-reference.md: document Ponder built-in /health and /ready endpoints - docs/architecture.md: fix GPv2Settlement description — Settlement event triggers the handler; Trade logs in the receipt identify the adapter address - docs/deployment.md: mark QuickNode block range as unverified; add ETH_GET_LOGS_BLOCK_RANGE_ entry - ponder.config.ts: make ethGetLogsBlockRange configurable via ETH_GET_LOGS_BLOCK_RANGE_ - .env.example: document ETH_GET_LOGS_BLOCK_RANGE_ Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 6 ++++++ docs/api-reference.md | 4 +++- docs/architecture.md | 2 +- docs/deployment.md | 3 ++- ponder.config.ts | 6 +++--- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 98fc57e..91aed4a 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,12 @@ DATABASE_SCHEMA=programmatic_orders # 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/docs/api-reference.md b/docs/api-reference.md index e5b4f00..7c98bca 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -13,7 +13,9 @@ The default local URL is `http://localhost:42069` when using `pnpm dev`. The pro | `/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. | ## GraphQL diff --git a/docs/architecture.md b/docs/architecture.md index 23c7c4d..b15cccd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ 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 handlers for three independent on-chain event streams: `ComposableCow` (conditional order creation), `CoWShedFactory` (proxy wallet deployment), and `GPv2Settlement` (Aave adapter detection via Trade logs). 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. +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. ## Contracts and Chains diff --git a/docs/deployment.md b/docs/deployment.md index 1ade315..2d28713 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -32,6 +32,7 @@ Example: `DATABASE_URL=postgresql://cow_programmatic:secretpass@localhost:5433/c | `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. | +| `ETH_GET_LOGS_BLOCK_RANGE_` | No | Overrides the `ethGetLogsBlockRange` Ponder config per chain (e.g. `ETH_GET_LOGS_BLOCK_RANGE_1=2000`, `ETH_GET_LOGS_BLOCK_RANGE_100=5000`). Default is 1000. Increase if your RPC provider supports a larger range to speed up backfill. | | `PINO_LOG_LEVEL` | No | Log verbosity: `debug`, `info`, `warn`, `error`. Defaults to Ponder's built-in default. | ### Production Docker Variables @@ -91,7 +92,7 @@ Common provider limits: |----------|--------------------------| | Alchemy | 10 000 blocks | | Infura | 10 000 blocks | -| QuickNode | 1 000–10 000 blocks (plan-dependent) | +| QuickNode | 1 000–10 000 blocks (plan-dependent, unverified — check your plan's docs) | | Public RPCs (Pocket, etc.) | 1 000 blocks | ## Database Setup diff --git a/ponder.config.ts b/ponder.config.ts index 438ef56..62d9184 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -14,13 +14,13 @@ export default createConfig({ id: 1, rpc: process.env.MAINNET_RPC_URL!, // Many RPC providers cap eth_getLogs at 1000–2000 blocks; set conservatively to avoid - // InvalidInputRpcError retry storms during backfill. Override if your provider allows more. - ethGetLogsBlockRange: 1000, + // InvalidInputRpcError retry storms during backfill. Override via ETH_GET_LOGS_BLOCK_RANGE_1. + ethGetLogsBlockRange: Number(process.env.ETH_GET_LOGS_BLOCK_RANGE_1 ?? 1000), }, gnosis: { id: 100, rpc: process.env.GNOSIS_RPC_URL!, - ethGetLogsBlockRange: 1000, + ethGetLogsBlockRange: Number(process.env.ETH_GET_LOGS_BLOCK_RANGE_100 ?? 1000), }, }, contracts: { From a8313ed60060fe2d0a5b169cc307529ea7cbc15e Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 19:58:11 -0300 Subject: [PATCH 75/89] docs: correct QuickNode eth_getLogs block range limits 10k blocks on paid plans, 5 blocks on free trial (verified against QuickNode docs). Free trial is effectively unusable for backfill. Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/deployment.md b/docs/deployment.md index 2d28713..86681f0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -92,7 +92,7 @@ Common provider limits: |----------|--------------------------| | Alchemy | 10 000 blocks | | Infura | 10 000 blocks | -| QuickNode | 1 000–10 000 blocks (plan-dependent, unverified — check your plan's docs) | +| QuickNode | 10 000 blocks (paid plans); 5 blocks (free trial — unusable for backfill) | | Public RPCs (Pocket, etc.) | 1 000 blocks | ## Database Setup From dee1b2e77744a2d41f10b0cd8a9f1172d4449f31 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 20:03:50 -0300 Subject: [PATCH 76/89] docs: remove provider limits table from deployment.md Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 86681f0..d4dd99c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -86,15 +86,6 @@ chains: { } ``` -Common provider limits: - -| Provider | Typical eth_getLogs limit | -|----------|--------------------------| -| Alchemy | 10 000 blocks | -| Infura | 10 000 blocks | -| QuickNode | 10 000 blocks (paid plans); 5 blocks (free trial — unusable for backfill) | -| Public RPCs (Pocket, etc.) | 1 000 blocks | - ## Database Setup ### Local Development From 7419a073b98e246e3531879bf56509433bf6e9de Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 20:21:16 -0300 Subject: [PATCH 77/89] fix: derive DiscreteStatus from schema enum, remove cowLogger scope creep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace manual DiscreteStatus union with (typeof discreteOrderStatusEnum.enumValues)[number] so the type stays in sync with the schema automatically - Delete cowLogger.ts — structured logging belongs in COW-994 (PR #87), not in this fix PR; replace the one cowLog call with a plain console.log Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 15 +++++---------- src/application/helpers/cowLogger.ts | 24 ------------------------ 2 files changed, 5 insertions(+), 34 deletions(-) delete mode 100644 src/application/helpers/cowLogger.ts diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index eac2ce5..4ea3863 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -15,7 +15,7 @@ */ import { ponder } from "ponder:registry"; -import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from "ponder:schema"; +import { bootstrapRetryQueue, candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder, discreteOrderStatusEnum } from "ponder:schema"; import { and, asc, eq, inArray, lte, or, sql } from "ponder"; import type { Hex } from "viem"; import { @@ -42,9 +42,7 @@ import { parsePollError, } from "../helpers/pollResultErrors"; import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; -import { cowLog } from "../helpers/cowLogger"; - -type DiscreteStatus = "open" | "fulfilled" | "unfilled" | "expired" | "cancelled"; +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; @@ -414,12 +412,9 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ); const preflightKnown = preflightStatuses.size; - cowLog("info", "c2:parent-cancelled", { - block: String(event.block.number), - chainId, - parentCancelled: orphanCandidates.length, - preflightKnown, - }); + console.log( + `[COW:C2] c2:parent-cancelled block=${event.block.number} chainId=${chainId} parentCancelled=${orphanCandidates.length} preflightKnown=${preflightKnown}`, + ); } } diff --git a/src/application/helpers/cowLogger.ts b/src/application/helpers/cowLogger.ts deleted file mode 100644 index 3969837..0000000 --- a/src/application/helpers/cowLogger.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Structured JSON logger for handler code. Outputs one JSON line per call so - * log aggregators (Datadog, CloudWatch, etc.) can filter by chainId, handler, - * block number, or any other field without regex parsing. - * - * Ponder's own log lines are controlled by --log-format (pretty|json) on the - * CLI. These handler lines are always JSON so they remain parseable regardless - * of Ponder's format setting. - */ - -type LogLevel = "info" | "warn" | "error"; - -export function cowLog( - 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); - } -} From d873a8618ead1af11febffcc1e280e2804d49f58 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 21:00:00 -0300 Subject: [PATCH 78/89] fix: rename cowLog->log/logger.ts, use /health for liveness, clarify readiness semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename cowLogger.ts to logger.ts and cowLog() to log() — the module name already provides context; the cow prefix was misleading - Rename `for (const log of receipt.logs)` loop variable to txLog to avoid shadowing the newly imported log function - K8s liveness probe: use /health (Ponder built-in) instead of /healthz - readinessProbe: fix misleading failureThreshold comment; add paragraph clarifying that NotReady does not kill the pod — cold-start sync takes hours Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 14 ++++--- src/application/handlers/blockHandler.ts | 38 +++++++++---------- src/application/handlers/settlement.ts | 14 +++---- .../helpers/{cowLogger.ts => logger.ts} | 2 +- 4 files changed, 35 insertions(+), 33 deletions(-) rename src/application/helpers/{cowLogger.ts => logger.ts} (94%) diff --git a/docs/deployment.md b/docs/deployment.md index 8036ef1..e14f560 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -94,7 +94,7 @@ The indexer exposes two health endpoints with distinct semantics: | Endpoint | Semantic | Returns 200 when | |----------|----------|-----------------| -| `/healthz` | **Liveness** — is the process alive? | Always, once the server starts | +| `/health` | **Liveness** — is the process alive? | Always, once the server starts | | `/ready` | **Readiness** — is the index fully synced? | Only when fully synced | Map these to different K8s probe types: @@ -102,7 +102,7 @@ Map these to different K8s probe types: ```yaml livenessProbe: httpGet: - path: /healthz + path: /health port: 3000 initialDelaySeconds: 30 periodSeconds: 30 @@ -112,17 +112,19 @@ readinessProbe: path: /ready port: 3000 initialDelaySeconds: 30 - periodSeconds: 10 - failureThreshold: 18 # 3-minute window before marking unready + periodSeconds: 30 + failureThreshold: 3 # marks pod unready (not killed) — cold-start sync takes hours ``` -**Do not** use `/ready` as the liveness probe. A pod that is still indexing (which takes hours on a cold start) returns 200 on `/healthz` but not on `/ready`. Using `/ready` for liveness would kill the pod before it ever finishes syncing. +**Do not** use `/ready` as the liveness probe. A pod that is still indexing (which takes hours on a cold start) returns 200 on `/health` but not on `/ready`. Using `/ready` for liveness would kill the pod before it ever finishes syncing. + +A pod in `NotReady` state is not killed — it is simply removed from load-balancer rotation. On a cold start (no existing database), the pod will be `NotReady` for the duration of the historical backfill (hours). That is expected: the old pod (if any) keeps serving traffic during this window, and once the new pod catches up, K8s starts routing to it. The Docker Compose health check uses `/ready` with a 24-hour start period as a pragmatic fallback for single-container deployments, not as a K8s-style probe. ### Structured Logging -`pnpm start` runs with `--log-format json`, which makes both Ponder's internal log lines and the handler log lines (via `cowLog`) emit newline-delimited JSON. Each handler log line includes `chainId` and `block` as top-level fields, enabling log aggregators (Datadog, CloudWatch, Loki) to filter and alert by chain. +`pnpm start` runs with `--log-format json`, which makes both Ponder's internal log lines and the handler log lines (via `log()` in `src/application/helpers/logger.ts`) emit newline-delimited JSON. Each handler log line includes `chainId` and `block` as top-level fields, enabling log aggregators (Datadog, CloudWatch, Loki) to filter and alert by chain. `pnpm dev` uses Ponder's default pretty format for readability during local development. diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index d648d33..653eaa6 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -41,7 +41,7 @@ import { parsePollError, } from "../helpers/pollResultErrors"; import { computeOrderUid, type GPv2OrderData } from "../helpers/orderUid"; -import { cowLog } from "../helpers/cowLogger"; +import { log } from "../helpers/logger"; const NON_DETERMINISTIC_TYPES = ["PerpetualSwap", "GoodAfterTime", "TradeAboveThreshold", "Unknown"] as const; const SINGLE_SHOT_NON_DETERMINISTIC = ["GoodAfterTime", "TradeAboveThreshold"] as const; @@ -120,7 +120,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { if (dueOrders.length === 0) return; - cowLog("info", "C1:ENTER", { block: String(currentBlock), chainId, due: dueOrders.length }); + log("info", "C1:ENTER", { block: String(currentBlock), chainId, due: dueOrders.length }); const c1MulticallPromise = context.client.multicall({ contracts: dueOrders.map((order) => ({ @@ -146,7 +146,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { ); } catch (err) { if (err instanceof TimeoutError) { - cowLog("warn", "C1:multicall_timeout", { block: String(currentBlock), chainId, due: dueOrders.length }); + log("warn", "C1:multicall_timeout", { block: String(currentBlock), chainId, due: dueOrders.length }); return; } throw err; @@ -260,7 +260,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { eq(conditionalOrderGenerator.eventId, order.generatorId), ), ); - cowLog("info", "C1:NEVER", { block: String(currentBlock), chainId, generatorId: order.generatorId, reason: pollResult.reason }); + log("info", "C1:NEVER", { block: String(currentBlock), chainId, generatorId: order.generatorId, reason: pollResult.reason }); neverCount++; break; @@ -279,7 +279,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { eq(conditionalOrderGenerator.eventId, order.generatorId), ), ); - cowLog("info", "C1:CANCELLED", { block: String(currentBlock), chainId, generatorId: order.generatorId }); + log("info", "C1:CANCELLED", { block: String(currentBlock), chainId, generatorId: order.generatorId }); break; } } @@ -288,7 +288,7 @@ ponder.on("ContractPoller:block", async ({ event, context }) => { await Promise.all(successPromises); const capped = dueOrders.length === maxGeneratorsPerBlock; - cowLog("info", "C1:DONE", { block: String(currentBlock), chainId, due: dueOrders.length, success: successCount, never: neverCount, backedOff: backedOffCount, capped }); + log("info", "C1:DONE", { block: String(currentBlock), chainId, due: dueOrders.length, success: successCount, never: neverCount, backedOff: backedOffCount, capped }); }); // ─── C2: Candidate Confirmer ───────────────────────────────────────────────── @@ -377,7 +377,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { ), ); - cowLog("info", "C2:parent_cancelled", { block: String(event.block.number), chainId, parentCancelled: orphanCandidates.length }); + log("info", "C2:parent_cancelled", { block: String(event.block.number), chainId, parentCancelled: orphanCandidates.length }); } } @@ -537,7 +537,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { } if (confirmed > 0 || stale.length > 0) { - cowLog("info", "C2:DONE", { block: String(event.block.number), chainId, candidates: unconfirmed.length, confirmed, expired: stale.length }); + log("info", "C2:DONE", { block: String(event.block.number), chainId, candidates: unconfirmed.length, confirmed, expired: stale.length }); } }); @@ -589,7 +589,7 @@ ponder.on("StatusUpdater:block", async ({ event, context }) => { } if (updated > 0) { - cowLog("info", "C3:DONE", { block: String(event.block.number), chainId, open: openOrders.length, updated }); + log("info", "C3:DONE", { block: String(event.block.number), chainId, open: openOrders.length, updated }); } } @@ -653,7 +653,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { .from(bootstrapRetryQueue) .where(eq(bootstrapRetryQueue.chainId, chainId)); - cowLog("info", "C4:START", { block: String(currentBlock), chainId, pendingRetry: queued.length }); + log("info", "C4:START", { block: String(currentBlock), chainId, pendingRetry: queued.length }); let totalDiscovered = 0; const retriedOwners = new Set(); @@ -673,7 +673,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) { - cowLog("warn", "C4:owner_retry_timeout", { block: String(currentBlock), chainId, owner, retryCount: retryCount + 1, timeoutMs: BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS }); + log("warn", "C4: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 }) @@ -716,12 +716,12 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { const freshOwners = new Set(generators.map((g) => g.owner).filter((o) => !retriedOwners.has(o))); if (freshOwners.size === 0 && retriedOwners.size === 0) { - cowLog("info", "C4:no_bootstrap_needed", { block: String(currentBlock), chainId }); + log("info", "C4:no_bootstrap_needed", { block: String(currentBlock), chainId }); return; } if (freshOwners.size > 0) { - cowLog("info", "C4:bootstrap_start", { block: String(currentBlock), chainId, generators: generators.length, freshOwners: freshOwners.size }); + log("info", "C4:bootstrap_start", { block: String(currentBlock), chainId, generators: generators.length, freshOwners: freshOwners.size }); } for (const owner of freshOwners) { @@ -735,7 +735,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { totalDiscovered += count; } catch (err) { if (err instanceof TimeoutError) { - cowLog("warn", "C4:owner_timeout", { block: String(currentBlock), chainId, owner, timeoutMs: BOOTSTRAP_OWNER_FETCH_TIMEOUT_MS }); + log("warn", "C4: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 }) @@ -746,7 +746,7 @@ ponder.on("HistoricalBootstrap:block", async ({ event, context }) => { } } - cowLog("info", "C4:DONE", { block: String(currentBlock), chainId, discovered: totalDiscovered }); + log("info", "C4:DONE", { block: String(currentBlock), chainId, discovered: totalDiscovered }); }); // ─── C5: Deterministic Cancellation Sweeper ────────────────────────────────── @@ -800,7 +800,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = if (dueGenerators.length === 0) return; - cowLog("info", "C5:ENTER", { block: String(currentBlock), chainId, due: dueGenerators.length }); + log("info", "C5:ENTER", { block: String(currentBlock), chainId, due: dueGenerators.length }); const c5MulticallPromise = context.client.multicall({ contracts: dueGenerators.map((g) => ({ @@ -821,7 +821,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = ); } catch (err) { if (err instanceof TimeoutError) { - cowLog("warn", "C5:multicall_timeout", { block: String(currentBlock), chainId, due: dueGenerators.length }); + log("warn", "C5:multicall_timeout", { block: String(currentBlock), chainId, due: dueGenerators.length }); return; } throw err; @@ -858,7 +858,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = eq(conditionalOrderGenerator.eventId, gen.generatorId), ), ); - cowLog("info", "C5:CANCELLED", { block: String(currentBlock), chainId, generatorId: gen.generatorId, orderType: gen.orderType }); + log("info", "C5:CANCELLED", { block: String(currentBlock), chainId, generatorId: gen.generatorId, orderType: gen.orderType }); cancelledCount++; } else { await context.db.sql @@ -878,7 +878,7 @@ ponder.on("DeterministicCancellationSweeper:block", async ({ event, context }) = } } - cowLog("info", "C5:DONE", { block: String(currentBlock), chainId, due: dueGenerators.length, cancelled: cancelledCount, stillActive: stillActiveCount, errors: errorCount }); + log("info", "C5:DONE", { block: String(currentBlock), chainId, due: dueGenerators.length, cancelled: cancelledCount, stillActive: stillActiveCount, errors: errorCount }); }); // ─── Shared helpers ────────────────────────────────────────────────────────── diff --git a/src/application/handlers/settlement.ts b/src/application/handlers/settlement.ts index 00677b5..fe4825b 100644 --- a/src/application/handlers/settlement.ts +++ b/src/application/handlers/settlement.ts @@ -2,7 +2,7 @@ import { ponder } from "ponder:registry"; import { AddressType, conditionalOrderGenerator, ownerMapping, transaction } from "ponder:schema"; import { and, eq } from "ponder"; import { keccak256, toBytes } from "viem"; -import { cowLog } from "../helpers/cowLogger"; +import { log } from "../helpers/logger"; import { AaveV3AdapterHelperAbi } from "../../../abis/AaveV3AdapterHelperAbi"; import { AAVE_V3_ADAPTER_FACTORY_ADDRESSES, @@ -32,7 +32,7 @@ function logStatsIfIntervalPassed() { if (Date.now() - statsLastLogAt < LOG_INTERVAL_MS) return; const contractAddresses = stats.tradeLogsFound - stats.skippedAlreadyMapped - stats.skippedEOA; - cowLog("info", "settlement:stats", { + log("info", "settlement:stats", { settlements: stats.total, tradeLogs: stats.tradeLogsFound, alreadyMapped: stats.skippedAlreadyMapped, @@ -80,15 +80,15 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { hash: event.transaction.hash, }); - for (const log of receipt.logs) { + for (const txLog 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; + if (txLog.address.toLowerCase() !== settlementAddress) continue; + if (txLog.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 owner = `0x${txLog.topics[1]!.slice(26)}` as `0x${string}`; const ownerAddress = owner.toLowerCase() as `0x${string}`; // Skip if already mapped (adapter seen in a prior settlement) @@ -195,7 +195,7 @@ ponder.on("GPv2Settlement:Settlement", async ({ event, context }) => { stats.mapped++; logStatsIfIntervalPassed(); - cowLog("info", "settlement:aave_adapter_mapped", { + log("info", "settlement:aave_adapter_mapped", { block: String(event.block.number), chainId, adapter: ownerAddress, diff --git a/src/application/helpers/cowLogger.ts b/src/application/helpers/logger.ts similarity index 94% rename from src/application/helpers/cowLogger.ts rename to src/application/helpers/logger.ts index 91a2c0f..272c2a5 100644 --- a/src/application/helpers/cowLogger.ts +++ b/src/application/helpers/logger.ts @@ -2,7 +2,7 @@ type LogLevel = "info" | "warn" | "error"; -export function cowLog( +export function log( level: LogLevel, msg: string, fields: Record = {}, From 9e9cac2f644fc2af57f9e804354bc7d4fafd4a96 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Thu, 4 Jun 2026 21:09:07 -0300 Subject: [PATCH 79/89] fix: rename historicalSyncProgressPct -> historicalBlocksFetchedPct The new name is precise: it measures eth_getLogs block-fetch progress (ponder_historical_completed_blocks + cached / total), not handler execution. Co-Authored-By: Claude Sonnet 4.6 --- docs/api-reference.md | 6 +++--- src/api/endpoints/sync-progress.ts | 4 ++-- src/api/routes.ts | 2 +- src/api/schemas/sync-progress.ts | 2 +- tests/api/sync-progress.test.ts | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 0552804..00fa0b0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -53,21 +53,21 @@ Example response: "mainnet": { "totalBlocks": 7000000, "processedBlocks": 3000000, - "historicalSyncProgressPct": 42.9, + "historicalBlocksFetchedPct": 42.9, "isRealtime": false, "isComplete": false }, "gnosis": { "totalBlocks": 17000000, "processedBlocks": 17000000, - "historicalSyncProgressPct": 100.0, + "historicalBlocksFetchedPct": 100.0, "isRealtime": true, "isComplete": true } } ``` -- `historicalSyncProgressPct` is rounded to one decimal place (0–100). +- `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. diff --git a/src/api/endpoints/sync-progress.ts b/src/api/endpoints/sync-progress.ts index 998074e..4caf985 100644 --- a/src/api/endpoints/sync-progress.ts +++ b/src/api/endpoints/sync-progress.ts @@ -56,7 +56,7 @@ export const syncProgressHandler: RouteHandler = { totalBlocks: number; processedBlocks: number; - historicalSyncProgressPct: number; + historicalBlocksFetchedPct: number; isRealtime: boolean; isComplete: boolean; } @@ -72,7 +72,7 @@ export const syncProgressHandler: RouteHandler = result[chain] = { totalBlocks: t, processedBlocks: processed, - historicalSyncProgressPct: pct, + historicalBlocksFetchedPct: pct, isRealtime: (isRealtime.get(chain) ?? 0) === 1, isComplete: (isComplete.get(chain) ?? 0) === 1, }; diff --git a/src/api/routes.ts b/src/api/routes.ts index a0a7c0f..8b65a80 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -35,7 +35,7 @@ export const syncProgressRoute = createRoute({ 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, historicalSyncProgressPct will rise from 0 to 100 and isComplete will flip to true once the chain is fully caught up.", + "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.", diff --git a/src/api/schemas/sync-progress.ts b/src/api/schemas/sync-progress.ts index a5c518d..fded028 100644 --- a/src/api/schemas/sync-progress.ts +++ b/src/api/schemas/sync-progress.ts @@ -9,7 +9,7 @@ export const ChainProgressSchema = z.object({ .number() .int() .describe("Blocks already processed (completed + served from cache)."), - historicalSyncProgressPct: z + historicalBlocksFetchedPct: z .number() .describe("Completion percentage (0–100). Rounded to one decimal place."), isRealtime: z diff --git a/tests/api/sync-progress.test.ts b/tests/api/sync-progress.test.ts index 4404ff2..b39e401 100644 --- a/tests/api/sync-progress.test.ts +++ b/tests/api/sync-progress.test.ts @@ -5,7 +5,7 @@ import { syncProgressHandler } from "../../src/api/endpoints/sync-progress"; type ChainProgress = { totalBlocks: number; processedBlocks: number; - historicalSyncProgressPct: number; + historicalBlocksFetchedPct: number; isRealtime: boolean; isComplete: boolean; }; @@ -84,15 +84,15 @@ describe("GET /api/sync-progress", () => { expect(body["gnosis"]!.processedBlocks).toBe(2_400_000); }); - it("computes historicalSyncProgressPct correctly (rounded to 1 decimal)", async () => { + 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"]!.historicalSyncProgressPct).toBe(42.9); + expect(body["mainnet"]!.historicalBlocksFetchedPct).toBe(42.9); // gnosis: 2_400_000 / 17_000_000 = 14.117... → 14.1 - expect(body["gnosis"]!.historicalSyncProgressPct).toBe(14.1); + expect(body["gnosis"]!.historicalBlocksFetchedPct).toBe(14.1); }); it("sets isRealtime and isComplete from metrics flags", async () => { From 843c83c675b16c208d15924966e27139bdc23c16 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 09:56:51 -0300 Subject: [PATCH 80/89] feat: migrate all src/application/ log calls to structured logger (COW-994) Extends the log() migration from blockHandler.ts and settlement.ts to all remaining call sites in composableCow.ts, setup.ts, orderbookClient.ts, and uidPrecompute.ts. No console.log/warn/error remain in src/application/ outside logger.ts itself. Documents the scope and usage convention in deployment.md. Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 13 ++++++++- src/application/handlers/composableCow.ts | 18 ++++--------- src/application/handlers/setup.ts | 5 ++-- src/application/helpers/orderbookClient.ts | 31 +++++++++------------- src/application/helpers/uidPrecompute.ts | 15 +++++------ 5 files changed, 38 insertions(+), 44 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 437cfe0..3252575 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -131,10 +131,21 @@ The Docker Compose health check uses `/ready` with a 24-hour start period as a p ### Structured Logging -`pnpm start` runs with `--log-format json`, which makes both Ponder's internal log lines and the handler log lines (via `log()` in `src/application/helpers/logger.ts`) emit newline-delimited JSON. Each handler log line includes `chainId` and `block` as top-level fields, enabling log aggregators (Datadog, CloudWatch, Loki) to filter and alert by chain. +`pnpm start` runs with `--log-format json`, which makes both Ponder's internal log lines and the handler log lines emit newline-delimited JSON. Each handler log line includes structured fields (e.g. `chainId`, `block`) enabling log aggregators (Datadog, CloudWatch, Loki) to filter and alert by chain. `pnpm dev` uses Ponder's default pretty format for readability during local development. +**Convention:** all code under `src/application/` uses `log()` from `src/application/helpers/logger.ts` instead of `console.log/warn/error` directly. The `src/api/` layer (Hono routes) is exempt — Hono handles its own logging. Example: + +```ts +import { log } from "../helpers/logger"; + +log("info", "c2:confirmed", { chainId, orderUid, block: String(event.block.number) }); +log("warn", "c2:timeout", { chainId, block: String(event.block.number) }); +``` + +`warn` and `error` level messages go to `stderr`; `info` goes to `stdout`. The `level` field in the JSON payload is what log aggregators use to route and alert. + ### PostgreSQL Memory Flags Memory settings are hardcoded in the `command:` block of `docker-compose.yml`, tuned for 1G RAM: diff --git a/src/application/handlers/composableCow.ts b/src/application/handlers/composableCow.ts index 44f7858..c2b7057 100644 --- a/src/application/handlers/composableCow.ts +++ b/src/application/handlers/composableCow.ts @@ -45,6 +45,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 +129,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 +165,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"; } diff --git a/src/application/handlers/setup.ts b/src/application/handlers/setup.ts index 95a9ea6..e69f25c 100644 --- a/src/application/handlers/setup.ts +++ b/src/application/handlers/setup.ts @@ -1,5 +1,6 @@ import { ponder } from "ponder:registry"; import { sql } from "ponder"; +import { log } from "../helpers/logger"; /** * Creates the cow_cache schema and persistent cache tables on startup. @@ -37,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/orderbookClient.ts b/src/application/helpers/orderbookClient.ts index 16a6cf6..8d9c74d 100644 --- a/src/application/helpers/orderbookClient.ts +++ b/src/application/helpers/orderbookClient.ts @@ -25,6 +25,7 @@ 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 ─────────────────────────────────────────────────────────────────── @@ -86,16 +87,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 []; } @@ -140,9 +141,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; } @@ -253,9 +252,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; @@ -316,7 +313,7 @@ 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[]; @@ -325,12 +322,10 @@ async function fetchAccountOrders( 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; } } @@ -362,19 +357,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 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 }); } } diff --git a/src/application/helpers/uidPrecompute.ts b/src/application/helpers/uidPrecompute.ts index 80ca37e..cb84407 100644 --- a/src/application/helpers/uidPrecompute.ts +++ b/src/application/helpers/uidPrecompute.ts @@ -21,6 +21,7 @@ import { candidateDiscreteOrder, conditionalOrderGenerator, discreteOrder } from import { computeOrderUid, type GPv2OrderData } from "./orderUid"; import { fetchOrderStatusByUids } from "./orderbookClient"; import { isDeterministicOrderType } from "../../utils/order-types"; +import { log } from "./logger"; // GPv2Order.sol constant hashes const KIND_SELL = "0xf3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775" as Hex; @@ -61,7 +62,7 @@ export function precomputeOrderUids( ): PrecomputedOrder[] | null { if (!decodedParams) { if (isDeterministicOrderType(orderType)) { - console.warn(`[COW:PRECOMPUTE] SKIP type=${orderType} owner=${owner} chain=${chainId} reason=decodedParams_null`); + log("warn", "precompute:skip", { orderType, owner, chainId, reason: "decodedParams_null" }); } return null; } @@ -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; } From e772e3f462eb051ff48ebbe8027f52f312da58be Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 10:09:27 -0300 Subject: [PATCH 81/89] docs: rename block handlers in architecture.md, document flash loan path and order_uid_cache (COW-1000) - Update all C1-C5 / ContractPoller / StatusUpdater / HistoricalBootstrap / DeterministicCancellationSweeper references to the new semantic names (OrderDiscoveryPoller, OrderStatusTracker, OwnerBackfill, CancellationWatcher) - Clarify in overview and data-flow diagram that block handlers are generic (apply to all generators regardless of type); Aave flash loan detection is event-driven via settlement.ts, not a block handler - Add cow_cache.order_uid_cache section explaining why it's retained across Ponder deployments and what is/isn't cached Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 56 +++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ff5abef..bfc270d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ 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 nine top-level handlers: four contract event handlers (`ComposableCow` backfill, `ComposableCowLive`, `CoWShedFactory`, `GPv2Settlement`) plus five live-only block handlers in `blockHandler.ts`. The contract handlers react to on-chain events; the block handlers poll contract state and the orderbook API during live sync. `settlement.ts` inspects `Settlement` receipts to detect Aave flash loan adapters — this is event-driven, not a block handler. Block handlers are generic: they apply to all ComposableCoW generators regardless of order type. ## Contracts and Chains @@ -19,7 +19,7 @@ Currently active: 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). -`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. 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. The config also sets up five live-only block handlers — `OrderDiscoveryPoller`, `CandidateConfirmer`, `OrderStatusTracker`, `OwnerBackfill`, `CancellationWatcher` — all running during live sync. Three contracts are indexed: @@ -57,17 +57,17 @@ settlement.ts handler | - 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 +blockHandler.ts (five live-only block handlers — generic, apply to all generators) + | OrderDiscoveryPoller — multicall getTradeableOrderWithSignature for non-deterministic + | generators; detects cancellation via SingleOrderNotAuthed error + | CandidateConfirmer — confirms candidate orders via orderbook API → discreteOrder; + | cascades parent Cancelled status to orphan candidates + | OrderStatusTracker — polls API for status updates on open discrete orders; + | cascades parent Cancelled status to orphan open rows + | OwnerBackfill — one-time backfill of non-deterministic historical orders + | CancellationWatcher — periodic singleOrders() mapping read for + | deterministic generators (allCandidatesKnown=true); flips + | to Cancelled when remove() has been called on-chain v schema tables (Postgres) | @@ -109,14 +109,14 @@ PK: `(chainId, eventId)`. Indexed on `owner`, `handler`, `hash`, `chainId+owner` ### discrete_order -Links individual order UIDs (from the CoW Protocol orderbook) back to their parent generator. One generator can produce many discrete orders over its lifetime — a TWAP with 10 parts creates 10 discrete orders. Populated by C2 (CandidateConfirmer) after confirmation against the orderbook API; status kept current by C3 (StatusUpdater). +Links individual order UIDs (from the CoW Protocol orderbook) back to their parent generator. One generator can produce many discrete orders over its lifetime — a TWAP with 10 parts creates 10 discrete orders. Populated by `CandidateConfirmer` after confirmation against the orderbook API; status kept current by `OrderStatusTracker`. Key columns: `orderUid`, `chainId`, `conditionalOrderGeneratorId` (references `eventId`), `status` (open/fulfilled/unfilled/expired/cancelled), `sellAmount`, `buyAmount`, `executedSellAmount`, `executedBuyAmount`. PK: `(chainId, orderUid)`. See [api-reference.md](./api-reference.md) for full field docs. ### 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. +Staging rows for discrete orders discovered by `OrderDiscoveryPoller` (`getTradeableOrderWithSignature`) 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. Key columns: `orderUid`, `chainId`, `conditionalOrderGeneratorId`, amounts, `validTo`, `creationDate`, `possibleValidAfterTimestamp` (TWAP scheduling). PK: `(chainId, orderUid)`. @@ -132,6 +132,14 @@ PK: `(chainId, address)`. The `resolutionDepth` column records how many hops were needed to reach the EOA. For CoWShed proxies it's 0 (the `COWShedBuilt` event directly provides the user). For Aave adapters it's 1 (call `owner()` on the adapter to get the EOA). +### cow_cache.order_uid_cache + +A persistent cache table created outside Ponder's per-deployment schema (in a dedicated `cow_cache` PostgreSQL schema) by `setup.ts` on startup. It stores the last-known terminal status (`fulfilled`, `expired`, `cancelled`) and executed amounts for each discrete order UID. + +**Why it's retained across restarts:** Ponder creates a new schema namespace on each `ponder start` deployment. Without a separate cache schema, `CandidateConfirmer` and `OrderStatusTracker` would re-query the orderbook API for every order on startup — including thousands of already-final orders. The `cow_cache` schema survives deployments, so handlers skip API calls for orders whose terminal status is already known. + +**What is and isn't cached:** Only terminal statuses are stored. Open orders are never cached — they are always re-fetched on the next block. If an entry is present for a UID, the handler uses the cached status and skips the API call. + ## Handlers in Detail ### composableCow.ts -- ConditionalOrderCreated @@ -169,23 +177,23 @@ The handler uses raw `eth_call` for the FACTORY() check specifically to avoid Po Stats are accumulated and logged every 30 seconds to track throughput without per-event log spam. -### blockHandler.ts -- C1 / C2 / C3 / C4 / C5 +### blockHandler.ts — five live-only block handlers -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. +All five are generic — they apply to all ComposableCoW generators regardless of order type. Aave flash loan adapter detection is separate and event-driven (see `settlement.ts`). All handlers run during live sync only (`startBlock: "latest"`); they never fire during historical backfill to avoid hammering the orderbook API. `OrderDiscoveryPoller` and `CancellationWatcher` share a per-chain batch cap (`MAX_GENERATORS_PER_BLOCK_`, default 200), pulling from a priority queue ordered by oldest `lastCheckBlock` first. -**C1 — ContractPoller** (every block): 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`. +**OrderDiscoveryPoller** (every block): 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`. -**C2 — CandidateConfirmer** (every block): 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. +**CandidateConfirmer** (every block): 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. -**C3 — StatusUpdater** (every block): 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. +**OrderStatusTracker** (every block): 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. -**C4 — HistoricalBootstrap** (fires once at latest block): 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. +**OwnerBackfill** (fires once at latest block): 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. -**C5 — DeterministicCancellationSweeper** (every block): 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`. +**CancellationWatcher** (every block): 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. +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 `OrderDiscoveryPoller`): TWAP, StopLoss, CirclesBackingOrder. Five are non-deterministic (UIDs depend on runtime state, polled every block by `OrderDiscoveryPoller`): PerpetualSwap, GoodAfterTime, TradeAboveThreshold, SwapOrderHandler, ERC4626CowSwapFeeBurner. 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`. @@ -227,5 +235,5 @@ See [api-reference.md](./api-reference.md) for the full endpoint list. ## 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; API-terminal statuses (`fulfilled` / `unfilled` / `expired`) still win for children that were already traded on the orderbook. - 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). From 960c8633d5b3369a2cfde0ee4313b740654e59d8 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 10:20:10 -0300 Subject: [PATCH 82/89] docs: remove specific K8s probe timing values from deployment.md (COW-994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep only path and port — the operator decides periodSeconds, failureThreshold, and initialDelaySeconds based on their cluster SLOs. Add a note explaining this. Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 3252575..22be3cc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -104,23 +104,17 @@ The indexer exposes two health endpoints with distinct semantics: | `/health` | **Liveness** — is the process alive? | Always, once the server starts | | `/ready` | **Readiness** — is the index fully synced? | Only when fully synced | -Map these to different K8s probe types: +Map these to different K8s probe types. The specific timing values (`periodSeconds`, `failureThreshold`, `initialDelaySeconds`) depend on your cluster's SLOs; what matters is which path and port to use: ```yaml livenessProbe: httpGet: path: /health port: 3000 - initialDelaySeconds: 30 - periodSeconds: 30 - failureThreshold: 3 readinessProbe: httpGet: path: /ready port: 3000 - initialDelaySeconds: 30 - periodSeconds: 30 - failureThreshold: 3 # marks pod unready (not killed) — cold-start sync takes hours ``` **Do not** use `/ready` as the liveness probe. A pod that is still indexing (which takes hours on a cold start) returns 200 on `/health` but not on `/ready`. Using `/ready` for liveness would kill the pod before it ever finishes syncing. From ff70ed84e67126deb83bdd7a4be583b32fe540da Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 10:26:53 -0300 Subject: [PATCH 83/89] docs: add flash loan adapter lifecycle to architecture.md (COW-991) Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index ff5abef..7de37a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ 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 across several contracts and blocks: four contract event handlers (`ComposableCow` backfill, `ComposableCowLive`, `CoWShedFactory`, `GPv2Settlement`) plus five live-only block handlers in `blockHandler.ts` (C1–C5) and one more block handler (`SettlementResolver:block`) in `settlement.ts`. The contract handlers react to on-chain events; C1–C5 poll contract state and the orderbook API during live sync. `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 @@ -152,22 +152,32 @@ 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 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. +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. -For each Settlement event: +**Stage 1 — `GPv2Settlement:Settlement` event handler (enqueue only):** -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. +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. -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. +**Stage 2 — `SettlementResolver:block` block handler (drain and resolve):** -Stats are accumulated and logged every 30 seconds to track throughput without per-event log spam. +Every block, this handler drains up to `MAX_SETTLEMENTS_PER_BLOCK` rows from `settlementQueue` for the current chain. For each queued transaction: + +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. + +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. + +Stats (total settlements, trade logs found, EOA skips, adapter mappings, avg FACTORY() latency) are accumulated and logged every 30 seconds. ### blockHandler.ts -- C1 / C2 / C3 / C4 / C5 From bb2df4123d746a4f897754b3a34a35f606d6f7fa Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 10:28:00 -0300 Subject: [PATCH 84/89] docs: remove chain-specific block numbers from architecture.md (COW-1001) Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 31f484f..68c175d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,10 +12,7 @@ Ponder registers handlers for three independent on-chain event streams: `Composa 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 active: - -- **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 +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`. 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). From bce4ea15ed6f2641ab953ede1887fb0942a86677 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 10:59:16 -0300 Subject: [PATCH 85/89] docs: replace "generic" with "order-type-agnostic" for block handlers (COW-1000) Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 30af595..0a46e4d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,7 +6,7 @@ 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`. The contract handlers react to on-chain events; the block handlers poll contract state and the orderbook API during live sync. `settlement.ts` inspects `Settlement` receipts to detect Aave flash loan adapters — this is event-driven, not a block handler. Block handlers are generic: they apply to all ComposableCoW generators regardless of order type. +Ponder registers nine top-level handlers: four contract event handlers (`ComposableCow` backfill, `ComposableCowLive`, `CoWShedFactory`, `GPv2Settlement`) plus five live-only block handlers in `blockHandler.ts`. The contract handlers react to on-chain events; the block handlers poll contract state and the orderbook API during live sync. `settlement.ts` inspects `Settlement` receipts to detect Aave flash loan adapters — this is event-driven, not a block handler. Block handlers are order-type-agnostic: they apply to all ComposableCoW generators regardless of order type. ## Contracts and Chains @@ -57,7 +57,7 @@ settlement.ts handler | - 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 — generic, apply to all generators) +blockHandler.ts (five live-only block handlers — order-type-agnostic, apply to all generators) | OrderDiscoveryPoller — multicall getTradeableOrderWithSignature for non-deterministic | generators; detects cancellation via SingleOrderNotAuthed error | CandidateConfirmer — confirms candidate orders via orderbook API → discreteOrder; @@ -179,7 +179,7 @@ Stats are accumulated and logged every 30 seconds to track throughput without pe ### blockHandler.ts — five live-only block handlers -All five are generic — they apply to all ComposableCoW generators regardless of order type. Aave flash loan adapter detection is separate and event-driven (see `settlement.ts`). All handlers run during live sync only (`startBlock: "latest"`); they never fire during historical backfill to avoid hammering the orderbook API. `OrderDiscoveryPoller` and `CancellationWatcher` share a per-chain batch cap (`MAX_GENERATORS_PER_BLOCK_`, default 200), pulling from a priority queue ordered by oldest `lastCheckBlock` first. +All five are order-type-agnostic — they apply to all ComposableCoW generators regardless of order type. Aave flash loan adapter detection is separate and event-driven (see `settlement.ts`). All handlers run during live sync only (`startBlock: "latest"`); they never fire during historical backfill to avoid hammering the orderbook API. `OrderDiscoveryPoller` and `CancellationWatcher` share a per-chain batch cap (`MAX_GENERATORS_PER_BLOCK_`, default 200), pulling from a priority queue ordered by oldest `lastCheckBlock` first. **OrderDiscoveryPoller** (every block): 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`. From 2157e4a1ba94148182e5247459946b337a29d16b Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 14:35:48 -0300 Subject: [PATCH 86/89] docs: minor whitespace fix in architecture.md Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/architecture.md b/docs/architecture.md index 0a46e4d..1e999a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,6 +95,7 @@ PK: `(chainId, hash)`. The main table. One row per `ConditionalOrderCreated` event. Stores the raw order params, the decoded params (as JSON), and the resolved owner. Key columns: + - `eventId` -- Ponder's event ID, used as the entity identifier - `owner` -- the address from the event (could be a proxy) - `resolvedOwner` -- EOA from `ownerMapping` when `owner` already has a row at insert time; otherwise the same as `owner`. Not rewritten when a new `owner_mapping` row is added later. From ac0f05663d5be5c2fa6551ed1a1e395c4f1ca4ca Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Mon, 8 Jun 2026 16:12:10 -0300 Subject: [PATCH 87/89] fix: align ponder.on registrations with renamed block handlers in ponder.config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ponder.config.ts renamed the five block handlers to semantic names but blockHandler.ts still had the old ponder.on("ContractPoller:block", ...) registrations — causing Ponder to fail at startup or silently skip the mismatched handlers. Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index 0cca35e..f59f90d 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -72,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; @@ -570,7 +570,7 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { // ─── 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; @@ -669,7 +669,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; @@ -784,7 +784,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; From 2f2ccb764e5f5517ec417c5164767a4ce05fed2f Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 9 Jun 2026 14:32:18 -0300 Subject: [PATCH 88/89] docs: remove PostgreSQL memory flags, production architecture, and What's Not Implemented sections; drop COW-908 task ref from constant comment (COW-1000) Co-Authored-By: Claude Sonnet 4.6 --- docs/deployment.md | 23 ----------------------- src/constants.ts | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index a505901..27159f2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -95,18 +95,6 @@ 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). -### PostgreSQL Memory Flags - -Memory settings are hardcoded in the `command:` block of `docker-compose.yml`, tuned for 1G RAM: - -- `shared_buffers`: 204MB (~20% RAM) -- `work_mem`: 2MB per connection (~25% RAM / max_connections) -- `effective_cache_size`: 512MB (~50% RAM) -- `maintenance_work_mem`: 51MB - -Adjust these proportionally if you change the host's available memory. - - ## Deploying ### How it works in practice @@ -130,14 +118,3 @@ On the target machine, you need Docker and DNS configured to point at the contai To tear down: `npx tsx deployment/manage.ts 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 deploy profile in `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). - -## What's Not Implemented - -- 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/src/constants.ts b/src/constants.ts index 719b8cb..946f879 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 OrderDiscoveryPoller + * 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). * From f5256268717435ec507978544ff5b5d16dce6f63 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Abou Hatem de Liz Date: Tue, 9 Jun 2026 14:33:22 -0300 Subject: [PATCH 89/89] fix: log warning when account fallback fails in CandidateConfirmer stale sweep (COW-989) Co-Authored-By: Claude Sonnet 4.6 --- src/application/handlers/blockHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/application/handlers/blockHandler.ts b/src/application/handlers/blockHandler.ts index c85f342..6a929ca 100644 --- a/src/application/handlers/blockHandler.ts +++ b/src/application/handlers/blockHandler.ts @@ -550,8 +550,8 @@ ponder.on("CandidateConfirmer:block", async ({ event, context }) => { for (const [uid, info] of ownerStatuses) { if (ownerMissedUids.has(uid)) staleStatuses.set(uid, info); } - } catch { - // Fallback failed — these UIDs will default to "expired" + } catch (err) { + console.warn(`[COW:C2] block=${event.block.number} chain=${chainId} accountFallback failed owner=${owner}`, err); } } }