diff --git a/.env.example b/.env.example index 791e5cc..91aed4a 100644 --- a/.env.example +++ b/.env.example @@ -17,19 +17,28 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/programmatic-orders # Schema for this app (required when using Postgres; avoids "previously used by a different Ponder app") DATABASE_SCHEMA=programmatic_orders -# Dev/local: reduce RPC usage during sync -# DISABLE_POLL_RESULT_CHECK=true — skip C1 ContractPoller multicalls for non-deterministic generators -# DISABLE_DETERMINISTIC_CANCEL_SWEEP=true — skip C5 singleOrders checks for deterministic generators -# DISABLE_SETTLEMENT_FACTORY_CHECK=true — skip getCode + FACTORY() calls in the GPv2Settlement:Trade -# handler entirely. Use to benchmark base sync throughput vs. the cost of those RPC calls. - -# C1 ContractPoller per-block generator cap (optional; default: 200) -# Hard ceiling on how many generators the block handler multicalls per block. -# Overflow defers to the next block, prioritized by oldest lastCheckBlock. +# Performance / escape-hatch flags (all optional; safe to omit in production) +# DISABLE_POLL_RESULT_CHECK=true # escape-hatch: skips OrderDiscoveryPoller multicalls for +# # non-deterministic generators; leaves those orders undetected +# # until re-enabled. Use only to benchmark or diagnose. +# DISABLE_DETERMINISTIC_CANCEL_SWEEP=true # escape-hatch: skips CancellationWatcher singleOrders() +# # reads; on-chain remove() on TWAP/StopLoss/CirclesBackingOrder +# # generators will not be detected while disabled. +# DISABLE_SETTLEMENT_FACTORY_CHECK=true # escape-hatch: skips getCode + FACTORY() RPC calls in the +# # GPv2Settlement handler. Benchmark-only; do not use in prod. + +# Per-block generator cap for OrderDiscoveryPoller + CancellationWatcher (optional; default: 200) +# Hard ceiling per block per chain. Excess generators defer to the next block (oldest-first). # Override per chain with the numeric chain-id suffix: # MAX_GENERATORS_PER_BLOCK_1=200 # mainnet # MAX_GENERATORS_PER_BLOCK_100=400 # gnosis (shorter block time → higher budget) +# eth_getLogs block range cap (optional; default: 1000) +# Increase if your RPC provider supports a larger range to speed up backfill. +# Override per chain with the numeric chain-id suffix: +# ETH_GET_LOGS_BLOCK_RANGE_1=2000 # mainnet +# ETH_GET_LOGS_BLOCK_RANGE_100=5000 # gnosis + # Logging (optional) # PINO_LOG_LEVEL=info diff --git a/docs/api-reference.md b/docs/api-reference.md index 00fa0b0..e605547 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2,7 +2,7 @@ The indexer exposes three ways to query indexed data: a Ponder-generated GraphQL endpoint, a read-only SQL passthrough, and two custom REST endpoints for queries that require cross-table logic. -The default local URL is `http://localhost:42069`. +The default local URL is `http://localhost:42069` when using `pnpm dev`. The production server (`pnpm start`, Docker) listens on port **3000** (mapped to the host via `PONDER_EXPOSED_PORT`, default 40000). ## Endpoints @@ -13,8 +13,9 @@ 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 | 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. | +| `/health` | GET | Ponder built-in. Returns `200` (empty body) when the process is running. | +| `/ready` | GET | Ponder built-in. Returns `200` when initial sync is complete; `503` while still syncing. Suitable for K8s readiness probes. | +| `/healthz` | GET | Application-level. Returns `{ "status": "ok" }` when the server is up. Does not reflect indexer sync progress. | | `/status` | GET | Sync progress per chain. Returns current indexed block, latest chain block, and a completion percentage. Useful for monitoring backfill progress. | | `/metrics` | GET | Prometheus metrics. Exposes Ponder internals (block lag, handler latency, RPC call counts). | @@ -126,7 +127,7 @@ There is one principled exception to "everything as string": `discreteOrder.vali | `discreteOrder.validTo` | number | yes | Unix seconds when this discrete order expires. `uint32` per CoW protocol. | | `discreteOrder.creationDate` | string | no | Unix seconds when the discrete order was first observed. Source varies — see the GraphQL field doc. | | `candidateDiscreteOrder.validTo` | number | yes | Same as `discreteOrder.validTo`. | -| `candidateDiscreteOrder.creationDate` | string | no | Block timestamp at C1 discovery. | +| `candidateDiscreteOrder.creationDate` | string | no | Block timestamp at **OrderDiscoveryPoller** discovery. | | `candidateDiscreteOrder.possibleValidAfterTimestamp` | string | yes | TWAP only: `t0 + partIndex*t`. Earliest Unix-seconds time the part can be valid. | ### Timestamp-like values inside `decodedParams` diff --git a/docs/architecture.md b/docs/architecture.md index 31e4e1e..68c175d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,20 +6,17 @@ This document covers how the indexer works, from on-chain events to the GraphQL The system is a Ponder 0.16.x indexer that watches the ComposableCoW contract on Ethereum mainnet and Gnosis Chain. When a user creates a programmatic order (TWAP, Stop Loss, etc.), the contract emits a `ConditionalOrderCreated` event. The indexer picks that up, decodes the order parameters, resolves the actual owner (which may be behind a proxy), and writes the result to Postgres. A Hono HTTP server exposes the data through GraphQL and a SQL passthrough endpoint. -Ponder registers nine top-level handlers: four contract event handlers (`ComposableCow` backfill, `ComposableCowLive`, `CoWShedFactory`, `GPv2Settlement`) plus five live-only block handlers in `blockHandler.ts` (C1–C5). The contract handlers react to on-chain events; C1–C5 poll contract state and the orderbook API during live sync. `settlement.ts` inspects `Settlement` receipts to detect Aave adapters from Trade logs. +Ponder registers handlers for three independent on-chain event streams: `ComposableCow` (conditional order creation), `CoWShedFactory` (proxy wallet deployment), and `GPv2Settlement` (Aave adapter detection via `Settlement` events — `Trade` logs in the receipt identify the adapter address). During live sync, additional block handlers in `blockHandler.ts` poll contract state and the CoW orderbook API. See `blockHandler.ts` for the current handler list and responsibilities. ## Contracts and Chains 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). -`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. It also registers the live-only block handlers from `blockHandler.ts` — all run during live sync only (`startBlock: "latest"`). Three contracts are indexed: @@ -29,54 +26,47 @@ Three contracts are indexed: ## Data Flow +Three independent on-chain event streams feed into the same schema tables and are then served by the API layer: + ``` -ComposableCoW contract (see `src/chains/index.ts`) - | - | ConditionalOrderCreated events - v -composableCow.ts handler - | - compute params hash (keccak256 of ABI-encoded handler/salt/staticInput) - | - look up owner in ownerMapping table (CoWShed proxy resolution) - | - identify order type from handler address - | - decode staticInput into structured params - | - write transaction + conditionalOrderGenerator rows - v -CoWShedFactory contract - | - | COWShedBuilt events - v -cowshed.ts handler - | - write ownerMapping: shed address -> user EOA - v -GPv2Settlement contract (FlashLoanRouter settlements only) - | - | Settlement events - v -settlement.ts handler - | - fetch transaction receipt, scan Trade logs - | - for each trade owner: check if it's an Aave adapter (FACTORY() call) - | - if yes: resolve EOA via owner(), write ownerMapping - v -blockHandler.ts (five live-only block handlers) - | C1 (ContractPoller) — multicall getTradeableOrderWithSignature for non-deterministic - | generators; detects cancellation via SingleOrderNotAuthed error - | C2 (CandidateConfirmer) — confirms candidate orders via orderbook API → discreteOrder; - | cascades parent Cancelled status to orphan candidates - | C3 (StatusUpdater) — polls API for status updates on open discrete orders; - | cascades parent Cancelled status to orphan open rows - | C4 (HistoricalBootstrap) — one-time backfill of non-deterministic historical orders - | C5 (DeterministicCancellationSweeper) — periodic singleOrders() mapping read for - | deterministic generators (allCandidatesKnown=true); flips - | to Cancelled when remove() has been called on-chain - v -schema tables (Postgres) - | - v -Hono API server - | GET /graphql -- GraphQL endpoint (auto-generated from schema) - | GET /sql/* -- Ponder SQL passthrough - | GET /api/* -- custom REST endpoints (Swagger UI at /docs) - | GET /healthz -- health check +ComposableCoW contract CoWShedFactory contract GPv2Settlement contract +(mainnet + gnosis) (mainnet + gnosis) (FlashLoanRouter filter only) + | | | +ConditionalOrderCreated COWShedBuilt events Settlement events + | | | + v v v +composableCow.ts handler cowshed.ts handler settlement.ts handler + - hash params tuple - write ownerMapping: - fetch receipt, scan Trade logs + - resolve owner proxy shed → user EOA - check FACTORY() on each trader + - decode staticInput (used by composableCow.ts - if Aave adapter: call owner(), + - write transaction + at next order creation) write ownerMapping + conditionalOrderGenerator + | | | + +---------------+---------------+-------------------------------+ + | + v + schema tables (Postgres) + | + | (live sync only) + v + blockHandler.ts — live block handlers + OrderDiscoveryPoller — multicall getTradeableOrderWithSignature for + non-deterministic generators; detects cancellation + via SingleOrderNotAuthed error + CandidateConfirmer — confirms candidates via orderbook API → discreteOrder; + cascades parent Cancelled to orphan candidates + OrderStatusTracker — polls API for status updates on open discrete orders; + cascades parent Cancelled to orphan open rows + OwnerBackfill — one-time backfill of non-deterministic historical orders + CancellationWatcher — periodic singleOrders() read for deterministic generators; + flips to Cancelled when remove() has been called on-chain + | + v + Hono API server + GET /graphql -- GraphQL endpoint (auto-generated from schema) + GET /sql/* -- Ponder SQL passthrough + GET /api/* -- custom REST endpoints (Swagger UI at /docs) + GET /healthz -- health check ``` ## Schema @@ -116,7 +106,9 @@ PK: `(chainId, orderUid)`. See [api-reference.md](./api-reference.md) for full f ### candidate_discrete_order -Staging rows for discrete orders discovered by C1 (`getTradeableOrderWithSignature`) before the orderbook API lists them. When C2 confirms a UID against the API, the row is promoted to `discrete_order` and removed from candidates. +**Why candidate orders?** The CoW watch-tower submits orders to the orderbook API on behalf of generators. There is a gap between when the indexer discovers a valid order UID (via `getTradeableOrderWithSignature` or precompute) and when it actually appears in the orderbook API (after the watch-tower posts it). Storing candidates immediately lets the indexer track all UIDs it knows about, confirm them against the API in a later block, and avoid polling the API for UIDs that haven't been posted yet. Without this staging table, the indexer would either poll the API every block for every possible UID (expensive), or miss orders entirely. + +Staging rows for discrete orders discovered by `OrderDiscoveryPoller` (`getTradeableOrderWithSignature`) or precomputed at creation time before the orderbook API lists them. When `CandidateConfirmer` confirms a UID against the API, the row is promoted to `discrete_order` and removed from candidates. Stale candidates (past `validTo`) are pruned on each `CandidateConfirmer` block. Key columns: `orderUid`, `chainId`, `conditionalOrderGeneratorId`, amounts, `validTo`, `creationDate`, `possibleValidAfterTimestamp` (TWAP scheduling). PK: `(chainId, orderUid)`. @@ -169,23 +161,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): 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): 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): 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): 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): 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`. @@ -227,5 +222,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. 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`. +- 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 22be3cc..e85667b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,10 +30,11 @@ 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. | +| `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 @@ -52,6 +53,41 @@ Used by `docker-compose.yml` (deploy profile) and `deployment/manage.ts`: 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. +## 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 }, +} +``` + ## Database Setup ### Local Development @@ -181,6 +217,31 @@ 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` -- 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 - No monitoring or alerting. Watch container logs and the `/healthz` endpoint. Standard observability tooling (Prometheus, Grafana) can be wired up but nothing is preconfigured. diff --git a/ponder.config.ts b/ponder.config.ts index 2f07c92..8ddd0f5 100644 --- a/ponder.config.ts +++ b/ponder.config.ts @@ -7,7 +7,16 @@ 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]! }]), + ACTIVE_CHAINS.map((c) => [ + c.name, + { + id: c.chainId, + rpc: process.env[c.rpcEnvVar]!, + // Many RPC providers cap eth_getLogs at 1000–2000 blocks; set conservatively to avoid + // InvalidInputRpcError retry storms during backfill. Override via ETH_GET_LOGS_BLOCK_RANGE_. + ethGetLogsBlockRange: Number(process.env[`ETH_GET_LOGS_BLOCK_RANGE_${c.chainId}`] ?? 1000), + }, + ]), ); const cowShedChains = ACTIVE_CHAINS.filter((c) => c.cowShedFactory !== null);