diff --git a/augurScan/Dockerfile b/augurScan/Dockerfile index c12b55c34..b601ce016 100644 --- a/augurScan/Dockerfile +++ b/augurScan/Dockerfile @@ -18,7 +18,7 @@ ENV NODE_ENV=production COPY --from=dependencies /workspace/node_modules ./node_modules COPY augurScan/package.json augurScan/bun.lock ./ COPY augurScan/config ./augurScan/config -COPY augurScan/migrations ./augurScan/migrations +COPY augurScan/schema.sql ./augurScan/schema.sql COPY --from=browser-build /workspace/augurScan/public ./augurScan/public COPY augurScan/src ./augurScan/src COPY shared/ts/ethereum.ts ./shared/ts/ethereum.ts diff --git a/augurScan/PLAN.md b/augurScan/PLAN.md index 181b0cca6..7d7443636 100644 --- a/augurScan/PLAN.md +++ b/augurScan/PLAN.md @@ -8,7 +8,7 @@ The scanner owns its application, configuration, and database inputs under `/aug ## Delivered architecture -The shipped stack is Bun and TypeScript, the repository's `micro-eth-signer`-based adapter for JSON-RPC and ABI primitives, PostgreSQL for persistence, a Bun-native HTTP/SSE server, and a dependency-free HTML/CSS/JavaScript browser UI. Dependency versions and container images are pinned. `compose.yaml` starts the app and its persistent database; the app runs idempotent migrations before it serves traffic. +The shipped stack is Bun and TypeScript, the repository's `micro-eth-signer`-based adapter for JSON-RPC and ABI primitives, PostgreSQL for persistence, a Bun-native HTTP/SSE server, and a dependency-free HTML/CSS/JavaScript browser UI. Dependency versions and container images are pinned. `compose.yaml` starts the app and its persistent database; the app initializes the complete schema in an empty database before it serves traffic and rejects incompatible databases instead of upgrading them. Configuration is checked in under `config/`: @@ -24,7 +24,7 @@ Each block transaction commits atomically with its receipt, top-level protocol a Logs and top-level calls involving protocol activity sources select receipts for indexing and dynamic discovery. Shared WETH, REP, Multicall3, and proxy-deployer contracts never select unrelated receipts, but their known logs are retained when either activity-source path selected the receipt. Registration events add pools, share tokens, price coordinators, truth auctions, escalation games, and child reputation tokens. A fixed-point receipt pass retains initializer logs that occur before their registration log. Unknown or malformed events keep their topics, data, and decode error. -Token display rules are keyed by contract kind, event/function, and argument. Semantic `atto*` fields, the OpenOracle ETH sentinel, and known REP/share/WETH kinds use fixed 18-decimal protocol units. Arbitrary token values use canonical metadata read at an indexed block and fall back to exact base units only when that metadata is unavailable. Failed metadata reads retry with bounded block backoff and follow canonical reorg state. +Token display rules are keyed by contract kind, event/function, and argument. Semantic `atto*` fields, the OpenOracle ETH sentinel, and known REP/share/WETH kinds use fixed 18-decimal protocol units; configured USDC uses a fixed 6-decimal unit. Arbitrary token values use canonical metadata read at an indexed block and fall back to exact base units only when that metadata is unavailable. Failed metadata reads retry with bounded block backoff and follow canonical reorg state. Before extending a chain, the indexer verifies parent hashes. A mismatch searches the configured 64-block window for a common ancestor, marks old branch evidence noncanonical, invalidates its derived state, and replays the replacement branch. If no retained ancestor matches, canonical state is rebuilt from the configured start boundary. Orphaned blocks, actions, receipts, and logs remain queryable as debugging evidence. @@ -39,19 +39,24 @@ The browser provides: - deep-linked evidence with contract provenance, complete receipt, related logs, decoded action/event schemas, exact raw values, copy controls, and explorer links; - searchable pool, question, vault, and universe catalogs with automatic loading, live commit refresh, error recovery, and responsive graph/detail layouts; - a single-network rich list ranked by ETH or SepoliaETH, WETH, or sent transactions, with bounded per-token REP balances, pool/vault participation, and explicit pending or partial balance state. +- an Operations destination with freshness, report, escalation, auction, tagged pool/vault risk, fork/migration, price-provenance, semantic-change, and direct entity-detail views; +- canonical domain projections and unified timelines for reports, games, auctions, AMM activity, forks, and migrations; +- bounded canonical tagged-block reads for current pool, vault, escalation, and auction values, with retained read failures and stale-on-reorg semantics; +- stable keyset pagination; full-window exact AMM volume and fee summaries; and explicitly bounded price-impact, TWAP-coverage, and candlestick observations; +- pool-level OpenOracle coordinator history plus REP/WETH, REP/native-ETH, REP/USDC, and liquidity histories. ## Delivery and validation The implementation is delivered in these completed slices: -1. Isolated configuration, ABI snapshot, Docker packaging, migrations, and health endpoints. +1. Isolated configuration, ABI snapshot, Docker packaging, fresh-database schema initialization, and health endpoints. 2. Resumable multi-network indexing, polling, advisory leases, RPC recovery, reorg retention, dynamic discovery, exact decoding, and SSE commit notices. 3. Log/action APIs and responsive activity/evidence UI. 4. Event-replayed system catalogs and historical charts. -5. Unit coverage for configuration, lifecycle recovery, metadata/decoding, and projections, plus a PostgreSQL integration scenario for migrations, leases, restart, discovery, receipt evidence, API canonicality, and reorg replacement. +5. Unit coverage for configuration, lifecycle recovery, metadata/decoding, and projections, plus a PostgreSQL integration scenario for schema initialization, leases, restart, discovery, receipt evidence, API canonicality, and reorg replacement. Acceptance requires an empty-volume Compose start to expose the UI and begin each configured network independently; exact head progress and failures must remain visible; restart must resume without duplicate canonical occurrences; a reorg must retain the orphan and serve its replacement; and decoded amounts must always expose both a correctly scaled display value and exact raw evidence. ## Deferred scope -Provider-specific internal-call tracing, automatic re-decoding after an ABI snapshot change, arbitrary historical state-at-block queries, very large deployment load testing, and automated backup/restore drills remain future work. A production operator must still choose archival-capable RPC providers, verified deployment start blocks, credentials, resource limits, backup schedules, and external access controls. +Provider-specific internal-call tracing, automatic re-decoding after an ABI snapshot change, arbitrary user-selected historical state-at-block queries, normalized cross-venue liquidity, manipulation-resistant TWAP claims, very large deployment load testing, and automated backup/restore drills remain future work. A production operator must still choose archival-capable RPC providers, verified deployment start blocks, credentials, resource limits, backup schedules, and external access controls. diff --git a/augurScan/README.md b/augurScan/README.md index c8411896e..b8a8b25a9 100644 --- a/augurScan/README.md +++ b/augurScan/README.md @@ -2,7 +2,11 @@ augurScan is a read-only activity explorer for the Zoltar/Augur protocol. It indexes multiple configured networks from Ethereum JSON-RPC into PostgreSQL while the web UI shows one globally selected network at a time. The indexer detects each configured contract's deployment boundary before range-scanning its logs, fetches transaction evidence only for matching log transactions, decodes them with a self-contained ABI catalog, retains raw evidence and orphaned reorg history, and streams committed updates to the UI. -The selector in the top-right header sets the `chainId` URL parameter for Activity, **System state**, **Contracts**, and **Rich list**. System state derives bounded registries for indexed security pools, questions, vaults, and Zoltar universes. It distinguishes immutable identity/configuration from event-driven accounting, plots pool/vault/REP-supply histories, renders dedicated coordinator and Uniswap REP/ETH price charts plus a conditional YES/NO AMM chart, and renders the returned universe parent/child graph. Contracts lists the configured and dynamically discovered system contracts, checks their runtime bytecode through the configured RPC provider, and links each selection to its deployment evidence and explorer. After the index has an effective start, deployment refreshes search historical bytecode back to that stored boundary; the provider must support those historical reads. A contract already present there is reported as code present at the search boundary without claiming an exact deployment block, while a failed historical read leaves its deployment status pending without stopping block indexing. The Rich list ranks every observed sender and decoded participant by ETH or SepoliaETH, WETH, or sent transactions and displays amounts with at most two decimal places. Each REP token remains separate with its contract identity, alongside pool associations and vault positions. Clicking a sent-transaction count opens the account's sent transactions with decoded details. Pending and partial labels distinguish incomplete balance sampling from a known zero balance. See [STATE_MODEL.md](STATE_MODEL.md) for the event, balance, and field model. +The selector in the top-right header sets the `chainId` URL parameter for Activity, **Operations**, **System state**, **Contracts**, and **Rich list**. Operations summarizes index freshness, OpenOracle reports, escalation games, truth auctions, current pool/vault risk, fork/migration evidence, price provenance, and recent semantic changes. Its catalogs live at `/operations/reports`, `/operations/escalations`, `/operations/auctions`, and `/operations/risk`; catalog rows open direct report, escalation, auction, fork, pool-risk, and vault-risk routes. System state derives bounded registries for indexed security pools, questions, vaults, and Zoltar universes. Pool details show coordinator/OpenOracle history, conditional YES/NO prices, REP/WETH and REP/native-ETH curves, REP/USDC curves, exact venue liquidity evidence, and a link to the pair's AMM analytics. Contracts links each selection to deployment evidence without showing scanner-internal registry provenance or discovery bookkeeping. + +Direct browser routes are `/operations/report/:openOracleAddress/:reportId`, `/operations/escalation/:gameAddress`, `/operations/auction/:auctionAddress`, `/operations/fork/:universeIdentity`, `/operations/risk/pool/:poolAddress`, `/operations/risk/vault/:poolAddress/:vaultAddress`, and `/operations/trading/:marketAddress`. Preserve the selected network with the `chainId` query parameter when constructing a link. + +Deployment detection requires an actual historical boundary. A lagging RPC that reports head `#0` but returns present-day bytecode for `eth_getCode(..., #0)` cannot prove a deployment at genesis, so augurScan leaves the deployment pending. Even after that node advances, code reported at genesis is treated as an untrustworthy historical response unless block `0` was explicitly configured as the deployment. An inexact non-genesis boundary is labeled “Deployed at or before”; exact binary-search results are labeled “Deployed at.” Address links stay inside augurScan. Known addresses use their protocol name; unknown addresses use the full address wherever the layout has room and compact forms only in dense rows. `/address?chainId=:chainId&address=:address` shows the address's balances, separate REP tokens, pools, vaults, interaction counts, and recent transactions, with Etherscan available as a secondary link. @@ -33,9 +37,9 @@ To enable only one network, set `NETWORKS=mainnet` or `NETWORKS=sepolia` on the Set `MAINNET_AMM_FACTORY_ADDRESS` and/or `SEPOLIA_AMM_FACTORY_ADDRESS` to the deployed Augur `TwoWayConstantProductFactory` for each enabled network. The factory becomes an activity source, `PairCreated` discovers each canonical pair, and pair `Sync` events supply reserve snapshots. No public AMM deployment address is assumed by this repository. Adding or changing a factory after indexing has begun triggers the same deployment-aware reconciliation as a manifest address update: history within the stored index range is replayed, while an earlier deployment requires a rebuild. -Uniswap V2, V3, and V4 indexing is configured independently with the `*_UNISWAP_V2_FACTORY_ADDRESS`, `*_UNISWAP_V3_FACTORY_ADDRESS`, and `*_UNISWAP_V4_POOL_MANAGER_ADDRESS` variables. Canonical mainnet V2/V3/V4 addresses and the canonical Sepolia V3 factory are defaults; Sepolia V2 and V4 remain opt-in because this repository's testnet deployment can use locally deployed addresses. Factory events discover only exact known-universe REP/WETH pairs. V4 queries are restricted by indexed pool ID to the standard hookless REP/native-ETH configurations used by the repository (fees `100`, `500`, `3000`, and `10000`, with tick spacing `1`, `10`, `60`, and `200` respectively); the shared PoolManager's unrelated global traffic is not indexed. Empty an environment value to disable its default venue. Adding a venue after the network checkpoint has passed its deployment replays history when the deployment is within the stored index range; an earlier deployment requires a rebuild before live polling can resume. +Uniswap V2, V3, and V4 indexing is configured independently with the `*_UNISWAP_V2_FACTORY_ADDRESS`, `*_UNISWAP_V3_FACTORY_ADDRESS`, and `*_UNISWAP_V4_POOL_MANAGER_ADDRESS` variables. Canonical mainnet V2/V3/V4 addresses and the canonical Sepolia V3 factory are defaults; Sepolia V2 and V4 remain opt-in. Factory events retain exact known-universe REP/WETH and REP/USDC pairs; the checked-in manifests identify WETH and Circle USDC so token decimals and quote orientation stay explicit. V4 retains hookless REP/native-ETH and REP/USDC pool IDs for the four configured fee/tick-spacing pairs; ERC-20 currencies use Uniswap's address-sorted order, while native ETH is the zero-address currency. V2 liquidity is the exact reserve product, while V3/V4 liquidity is the exact active-liquidity integer emitted by `Swap`; these raw values are not normalized across token systems. -All quantities and identifiers are stored losslessly. Protocol fields explicitly named `attoREP`, `attoETH`, or `attoShares`, the OpenOracle native-token sentinel, and known REP/share/WETH contract kinds use fixed 18-decimal protocol units. Native values use the selected network's configured symbol (`ETH` or `SepoliaETH`). Other token amounts use the referenced token's discovered on-chain decimals and symbol. Only an arbitrary token without metadata is labeled in exact base units instead of being guessed. Raw values remain available in details. +All quantities and identifiers are stored losslessly. Protocol fields explicitly named `attoREP`, `attoETH`, or `attoShares`, the OpenOracle native-token sentinel, and known REP/share/WETH contract kinds use fixed 18-decimal protocol units. A configured USDC contract uses the fixed 6-decimal `USDC` unit. Native values use the selected network's configured symbol (`ETH` or `SepoliaETH`). Other token amounts use the referenced token's discovered on-chain decimals and symbol. Only an arbitrary token without metadata is labeled in exact base units instead of being guessed. Raw values remain available in details. ## Local development @@ -48,13 +52,13 @@ bun run dev The browser source lives under `browser/`. `bun run build` bundles `browser/app.ts` and its helpers to the stable browser entry point `public/app.js`; that generated file is ignored and must not be edited. `bun run dev`, `bun run start`, and `bun run qa:serve` build it automatically, and development mode rebuilds it when browser TypeScript changes. -Local development expects PostgreSQL at `POSTGRES_URL`. The server applies SQL migrations under a PostgreSQL advisory lock before serving. Migration 005 introduces range-index dataset cursors and rejects an older database if it has an indexed network checkpoint or any block row. No legacy checkpoint or activity compatibility is retained. For the Compose setup, first run the shared-network command under **Start with Docker**. Then run `docker compose down --volumes` to delete the old database and `docker compose up --build` to rebuild it from each network's automatically discovered effective start. A database without a checkpoint or block row migrates in place. +Local development expects PostgreSQL at `POSTGRES_URL`. augurScan supports fresh databases only: on first start it applies the complete [`schema.sql`](schema.sql) in one explicit transaction under a PostgreSQL advisory lock, then records the schema version. Later starts accept that version without reapplying the schema. A non-empty database without the current augurScan schema marker, or a database with another schema version, is rejected with an instruction to wipe it; augurScan never upgrades or transforms an existing database. After a wipe, the indexer retrieves chain history again from RPC starting at the configured or automatically discovered effective boundary. This single transaction boundary also avoids the PostgreSQL “already/no transaction in progress” warnings. For the Compose setup, first run the shared-network command under **Start with Docker**. Use `docker compose down --volumes` to delete an incompatible database and `docker compose up --build` to initialize a fresh one. `POSTGRES_URL` must connect directly to PostgreSQL or through a session-mode pooler. The per-network writer lease is a session-level advisory lock and is not compatible with transaction-mode pooling. At acquisition, augurScan records the PostgreSQL backend PID and verifies that later lease checks remain on that backend. If a proxy moves the reserved connection, the indexer reports an actionable `DatabaseConsistencyError` instead of treating the new backend as the lease owner. The scanner imports its Ethereum primitives through `src/ethereum.ts`, which reuses the repository's `micro-eth-signer`-based shared adapter. The adapter performs strict JSON-RPC envelope validation and does not batch unrelated requests, so malformed provider responses cannot be mistaken for missing blocks. `bun run check:ethereum-imports` rejects direct Viem imports. The Docker image copies only the scanner inputs and this shared adapter source from the repository build context. -The default test suite runs without infrastructure. To exercise migration, checkpoint restart, dynamic discovery persistence, reorg/orphan retention, and current-chain API results, start a separate disposable PostgreSQL container and run the integration test against it: +The default test suite runs without infrastructure. To exercise fresh-schema initialization, checkpoint restart, dynamic discovery persistence, reorg/orphan retention, and current-chain API results, start a separate disposable PostgreSQL container and run the integration test against it: ```bash docker run --detach --rm --name augurscan-test-postgres \ @@ -68,7 +72,7 @@ POSTGRES_TEST_URL=postgres://augurscan:augurscan@localhost:55432/augurscan_test docker stop augurscan-test-postgres ``` -The integration test truncates its target database; never point `POSTGRES_TEST_URL` at a database containing data you need. +The integration test expects a dedicated test database and destroys and recreates its `public` schema; never point `POSTGRES_TEST_URL` at a database containing data you need. Regenerate the committed ABI snapshot after contract event/function changes: @@ -87,10 +91,10 @@ The script reads Solidity and deployment-address sources from the parent reposit - Protocol activity-source addresses are queried with inclusive `eth_getLogs` ranges beginning at their individual deployment boundaries. Matching hashes select the only transactions and receipts fetched; failed transactions and successful transactions without a matching protocol log are intentionally outside the index. Shared WETH, REP, Multicall3, and permissionless proxy-deployer addresses remain labeled and ABI/token-aware but never select receipts from unrelated public traffic; their known logs are retained when a selected receipt contains them. - Every queried activity-source address has an independent PostgreSQL cursor recording its effective scan boundary and last successfully retrieved block. Configured REP and WETH identities also receive coverage cursors because they determine which Uniswap events pass the historical market filters, even though augurScan does not query their logs directly. A cursor boundary is the deployment or discovery block when known, or the stored effective start when deployment detection requires conservative fallback. Deployment observations and all cursors covered by a successful segment advance atomically with that segment's final block commit. Dynamic contracts start at their discovery block; reorg recovery rewinds affected deployment observations and cursors and removes orphan-only datasets. - Range-size and result-limit failures split the current inclusive query in half without overlapping its successor. For example, rejected `0-100` and `0-50` requests can become a successful `0-25`; the next inclusive request begins at `26`, not `25`. Unrelated authentication, transport, and chain errors are preserved for normal provider failover. -- Factory and registry events discover pools, share tokens, price coordinators, truth auctions, escalation games, child REP tokens, Augur AMM pairs, and exact REP/WETH Uniswap V2/V3 pools. V4 activity is selected by standard hookless REP/native pool ID; discovering child REP expands that filter in the discovery block and remaining scan range. Receipts are decoded to a fixed point so constructor/initializer logs that precede the registration event are retained. -- Token name, symbol, and decimals are read at the indexed block and cached for known/discovered tokens and token-bearing OpenOracle logs or calls. Failed reads retry with bounded backoff and metadata observations follow active-chain reorg state. Known REP/share/WETH kinds and the OpenOracle ETH sentinel have fixed 18-decimal protocol units; only arbitrary tokens whose metadata is unavailable fall back to exact base units. +- Factory and registry events discover pools, share tokens, price coordinators, truth auctions, escalation games, child REP tokens, Augur AMM pairs, and exact REP/WETH or REP/USDC Uniswap V2/V3 pools. V4 activity is selected by standard hookless REP/native-ETH and address-sorted REP/USDC pool IDs; discovering child REP expands those filters in the discovery block and remaining scan range. Receipts are decoded to a fixed point so constructor/initializer logs that precede the registration event are retained. +- Token name, symbol, and decimals are read at the indexed block and cached for known/discovered tokens and token-bearing OpenOracle logs or calls. Failed reads retry with bounded backoff and metadata observations follow active-chain reorg state. Known REP/share/WETH kinds and the OpenOracle ETH sentinel have fixed 18-decimal protocol units, while configured USDC has a fixed 6-decimal unit; only arbitrary tokens whose metadata is unavailable fall back to exact base units. - OpenOracle's raw 235-byte `ReportSubmitted` and `ReportDisputed` payloads are length-checked and decoded with their specified packed field layout. -- Coordinator initialization `RepEthPriceSet` events and accepted `PriceReported` settlements form the coordinator REP/ETH price series. An initialization seed can be zero or inherited from a parent pool and has no settlement timestamp, so it does not establish timestamp-based coordinator price validity. Rejected reports do not create observations. Uniswap V2 `Sync` reserves and V3/V4 `Initialize` or `Swap` square-root prices form separate, pool-attributed spot series for the pool's exact universe REP token. V2/V3 are REP per WETH and V4 is REP per native ETH. They are event-time marginal prices, not a TWAP; no liquidity-depth or manipulation-resistance claim is made. Each Augur AMM `Sync` event records exact YES/NO reserves and derives complementary conditional prices in basis points; these are manipulable spot observations, not a TWAP or protocol oracle. +- Coordinator initialization `RepEthPriceSet` events and accepted `PriceReported` settlements form the coordinator REP/ETH price series. An initialization seed can be zero or inherited from a parent pool and has no settlement timestamp, so it does not establish timestamp-based coordinator price validity. Rejected reports do not create observations. Uniswap V2 `Sync` reserves and V3/V4 `Initialize` or `Swap` square-root prices form separate, pool-attributed REP/WETH, REP/USDC, or REP/native-ETH spot series for the pool's exact universe REP token. They are event-time marginal prices, not a TWAP; no liquidity-depth or manipulation-resistance claim is made. Each Augur AMM `Sync` event records exact YES/NO reserves and derives complementary conditional prices in basis points; these are manipulable spot observations, not a TWAP or protocol oracle. - Block, complete transaction receipt, transaction/action ABI schema, contract discovery, log, and checkpoint changes commit atomically. Expanded evidence retains argument type/order and every raw receipt log. - A connection-scoped PostgreSQL advisory lock elects one active indexer per chain. Additional app replicas serve the UI and wait in standby until that chain's lock is released. - A parent-hash mismatch searches the configured 64-block safety window for a common ancestor. Orphaned rows remain stored as replaced-chain debugging evidence, and active-chain indexing resumes from the ancestor. If no retained ancestor matches, that network safely rewinds to its stored effective start and rebuilds current state. @@ -105,6 +109,14 @@ Version 0.1 indexes transactions selected by protocol-emitter logs and every kno - Database readiness: `GET /health/ready` - Indexer freshness and recent chain-integrity audit: `GET /health/indexers` - Network status: `GET /api/v1/networks` +- Operations overview with an `asOf` envelope: `GET /api/v1/operations?chainId=:chainId` +- Report, escalation, auction, risk, and fork catalogs: `GET /api/v1/state/{reports|escalations|auctions|risk|forks}?chainId=:chainId` +- Report detail: `GET /api/v1/state/reports/:chainId/:openOracleAddress/:reportId` +- Escalation and auction detail: `GET /api/v1/state/{escalations|auctions}/:chainId/:contractAddress` +- Pool and vault risk detail: `GET /api/v1/state/risk/pools/:chainId/:poolAddress` and `GET /api/v1/state/risk/vaults/:chainId/:poolAddress/:vaultAddress` +- Fork detail: `GET /api/v1/state/forks/:chainId/:universeIdentity` +- AMM swaps, liquidity events, exact price impact, volume, fees, TWAP coverage, and hourly candles: `GET /api/v1/state/trading/:chainId/:marketAddress` +- Canonical semantic timeline: `GET /api/v1/state/timeline/:chainId/:entityType/:entityIdentity` - Paginated logs: `GET /api/v1/logs?chainId=:chainId` - Full log occurrence: `GET /api/v1/logs/:chainId/:blockHash/:txHash/:logIndex` - Top-level protocol actions: `GET /api/v1/actions` @@ -115,11 +127,15 @@ Version 0.1 indexes transactions selected by protocol-emitter logs and every kno - Snapshot-bound transactions sent by one address: `GET /api/v1/address-transactions?chainId=:chainId&address=:address`. Follow the opaque `nextCursor` to read later pages without newly indexed blocks shifting the result set. The cursor is bound to the chain, address, snapshot block and hash, and stable transaction total. If a reorg or historical insertion invalidates that snapshot, the endpoint returns `409 Transaction history changed; restart pagination`; restart from the first page without a cursor. - Known protocol identity for an address: `GET /api/v1/address-identity?chainId=:chainId&address=:address` - Pools, questions, vaults, and universes: `GET /api/v1/state/catalog?chainId=:chainId` -- Pool history, including its bounded AMM, coordinator REP/ETH, and Uniswap REP/ETH price series: `GET /api/v1/state/pools/:chainId/:poolAddress` +- Pool history, including its bounded AMM, coordinator REP/ETH, OpenOracle, and Uniswap REP/WETH, REP/USDC, and REP/native-ETH series: `GET /api/v1/state/pools/:chainId/:poolAddress` - Vault history: `GET /api/v1/state/vaults/:chainId/:poolAddress/:vaultAddress` - Question usage: `GET /api/v1/state/questions/:chainId/:questionId` - Universe history: `GET /api/v1/state/universes/:chainId/:universeId` +The Operations overview caps its event collections at 250 reports, 250 escalations, 250 auctions, 250 pools, 250 vaults, 100 fork/migration events, 30 recent semantic changes, and the latest coordinator price, so its displayed counts describe those returned collections. Dedicated report, escalation, and auction catalogs load their own keyset-paged endpoints and expose an accessible continuation instead of inheriting the overview cap. Detail pages use the same bounded continuation and refetch their currently visible depth after a committed-block refresh, so appended evidence is not replaced by the first page. Risk catalogs bound pools and vaults independently; their opaque `poolCursor` and `vaultCursor` values are address keysets tied to the indexed block and hash. These cursors do not paginate liquidation approvals. The risk overview and catalog include the newest 100 chain-wide approval transitions. A pool detail instead includes the newest 100 transitions whose installed approval names that security pool. A vault detail further requires the installed approval's receiver or target vault to match. Later reserve, release, consume, and revoke transitions inherit those relationships only from the matching Set event in the same registry. A consumed-transition summary labels consumed debt and unused reserved debt released by that transition separately. A receiver-vault nonce invalidation has no pool relationship, so it remains in the chain-wide list but not a pool-scoped detail. Report, escalation, auction, fork, trading, and timeline catalogs/details use opaque newest-first keyset cursors bound to the indexed block and hash; a changed head returns `409`, after which the client restarts from the first page. The cursor includes block, transaction hash, and log index so multiple transactions at one block cannot be skipped. Pool and vault history endpoints are separate bounded, non-cursor responses: their `limit` applies independently to each returned series and `truncated` indicates that at least one series has more records. + +Risk endpoints return tagged snapshot evidence including block, method, availability, protocol state, and scanner severity. Vault health with nonzero open interest requires pool and vault snapshots from the same block hash. A mismatch is unavailable and exposes both evidence boundaries until coherent sampling catches up. A coherent snapshot that depends on a protocol-invalid coordinator price is also unavailable, exposes price provenance, and remains unavailable until the protocol price becomes valid. Pool capacity is similarly marked unusable for risk decisions when its nonzero settlement collateral or minting capacity depends on an invalid price. Directly observed pool or vault bad debt remains `critical` even when the price is invalid because that state does not depend on a price calculation. + `GET /health/indexers` includes process-local `ownership` diagnostics for every indexer that has attempted ownership. `failuresTotal` and `reacquisitionsTotal` are counters since process start; `consecutiveFailures`, `lastFailureAt`, and `lastFailureStage` identify an active recovery loop; `active` and `backendPid` identify the session currently used by this replica. These fields are operational diagnostics, not durable protocol data, and reset when the app restarts. Ownership failures identify the network, lifecycle stage (`acquire`, `verify`, `seed`, `owned-run`, `record-failure`, or `release`), consecutive failure count, retry delay, backend PID when known, and a sanitized reason. Rapid failures use jittered exponential backoff capped at five minutes. A successful takeover or reconnection emits an `indexer ownership reacquired` log before indexing resumes. `standby` remains the expected state when another replica owns the chain's lease and is not reported as a failure. @@ -150,14 +166,18 @@ uniswapRepEthPrices: Array<{ venue: "v2" | "v3" | "v4"; market_id: string; event_name: "Initialize" | "Swap" | "Sync"; contract_address: string; token0_address: string; token1_address: string; fee_hundredths_bip: string; tick_spacing: number | null; hooks_address: string | null; - quote_symbol: "ETH" | "WETH"; quote_token_address: string; - rep_per_eth_1e18: string; timestamp: string; + quote_symbol: "ETH" | "WETH" | "USDC"; quote_token_address: string; + rep_per_eth_1e18: string; liquidity_value: string | null; timestamp: string; +}> +openOracleHistory: Array<{ + block_number: string; block_hash: string; tx_hash: string; log_index: number; + event_name: string; arguments: object; summary: string; coordinator_address: string; timestamp: string; }> ``` Except for `log_index` and non-null `tick_spacing`, which are JSON numbers, `chain_id`, `block_number`, onchain integer values, reserves, and basis points are lossless decimal strings. `timestamp` and non-null `settlement_timestamp` are ISO 8601 strings. `market` is omitted when no canonical pair has been indexed. The price arrays are empty when there are no observations. Initialization REP/ETH rows have null report and settlement fields; accepted settlements populate both. -`truncated` becomes true when snapshots, lifecycle events, AMM prices, coordinator REP/ETH prices, or Uniswap REP/ETH prices exceed `limit`; each returned series contains its newest `limit` observations ordered from oldest to newest. +`truncated` becomes true when snapshots, lifecycle events, AMM prices, coordinator REP/ETH prices, Uniswap price observations, or OpenOracle history exceed `limit`; each returned series contains its newest `limit` observations ordered from oldest to newest. The UI always sends its selected `chainId` to the logs, rich-list, and state-catalog endpoints. Direct API clients may omit it to query all configured networks. State catalog responses default to 500 and cap at 1,000 rows per entity class. History endpoints default `limit` to 1,000 and cap it at 2,000 records per returned series; `truncated` is true when any series has more records. These bounds keep database transactions, JSON normalization, and graph rendering predictable. diff --git a/augurScan/STATE_MODEL.md b/augurScan/STATE_MODEL.md index 14573eed0..8c5a86d1c 100644 --- a/augurScan/STATE_MODEL.md +++ b/augurScan/STATE_MODEL.md @@ -2,6 +2,53 @@ The state dashboard is an event-derived view of the canonical chain through the selected network's indexed block. It never overwrites raw log evidence. Every projection row retains its block-hash occurrence and canonical flag, so a reorganization preserves the orphaned observation while removing it from the current view. +## Operations evidence model + +The fresh-install schema stores replayable domain projections for OpenOracle reports, escalation games, truth auctions, AMM activity, forks, and protocol migrations. The same decoded event also produces a `protocol_timeline_entries` row with entity identity, semantic event kind, source contract, source event, related addresses, transaction/log position, and canonical block occurrence. Reorg rewind marks affected projection and timeline rows noncanonical; replay uses the source log position as its idempotency key. Raw logs remain the retained evidence layer within the current database. augurScan does not upgrade old database layouts. Wiping an incompatible database deletes that evidence; the indexer then retrieves canonical chain evidence from RPC, starting at the configured or automatically discovered effective boundary, and builds only the current projections. + +| Value | Source classification | +| --- | --- | +| Report amounts, reporter, tokens, flags, fees, clock inputs, and round number | Direct event fields decoded from the canonical 235-byte packed report | +| Report dispute and settlement boundaries | Deterministic calculation from event fields using the indexed block or indexed timestamp selected by the report flag | +| Escalation deposits and per-outcome totals | Direct event fields plus deterministic canonical aggregation | +| Auction schedule, bids, clearing result, settlements, and refunds | Direct event fields plus deterministic canonical aggregation | +| Current values absent from events | Current contract read at the latest fully indexed canonical block, stored in `entity_state_snapshots` with method, success/failure, block hash, and observation time | +| Related addresses carried by one timeline event | Direct event fields; timeline entries do not claim cross-record inferred relationships. Risk summaries may associate approval transitions that share both an approval ID and registry, and expose that association as inferred evidence. | +| Warning or urgency | Scanner presentation state, separate from protocol state | + +After indexing begins, every Operations response is anchored to the network's latest fully indexed canonical block. Its `asOf` object includes the indexed block/hash/timestamp, observed provider head, lag, phase, and last successful refresh. Before the first block is indexed, Operations endpoints return empty evidence with a synthetic zero block/timestamp boundary and `availability: "Awaiting indexed evidence"`; zero is only the loading anchor, not a claimed deployment or protocol value. A value absent from indexed events or a failed tagged read is unavailable evidence, never numeric zero. + +When the indexer is caught up, it samples at most 25 least-recently observed pools, vaults, escalation games, and truth auctions per polling cycle, with four concurrent entity-snapshot jobs using the shared five-operation RPC queue. Every call is tagged to the same indexed block. The canonical block hash is checked before and after the reads and again inside the database transaction. Repeated cycles at a static head continue through unsampled entities; once every entity has a snapshot at that block, the sampler stops until the indexed boundary advances. Failed reads are retained as bounded availability evidence. A reorg marks snapshots tied to displaced blocks stale and noncanonical. A manifest-history reset marks every snapshot for that chain stale and noncanonical before canonical logs and current reads are replayed. + +### OpenOracle reports + +A report is identified by `(chain_id, open_oracle_address, report_id)`. `ReportSubmitted` and every `ReportDisputed` event preserve a separate round. `ReportSettled` closes the lifecycle without overwriting the last round. Flag bit 0 selects timestamp time when set and block time when clear: + +```text +dispute boundary = reportTimestamp + disputeDelay +settlement boundary = reportTimestamp + settlementTime +``` + +The exact boundary is inclusive for the new state: at the dispute boundary the dispute window is open, and at the settlement boundary the report is settleable. Settlement evidence takes precedence over clock-derived state. + +### Auctions and escalations + +Auction state uses the canonical indexed timestamp and exact `AuctionStarted`, `AuctionFinalized`, `BidSubmitted`, and `BidSettled` evidence. A finalized auction remains “bid settlements outstanding” while fewer bid settlements than submitted bids are observed. Escalation stake totals sum exact canonical `DepositOnOutcome.attoRepAmount` values by INVALID, NO, and YES; no floating-point arithmetic enters the projection. + +Report, escalation, auction, fork, trading, and timeline catalog or detail pagination is newest first and stable on `(block_number, log_index, tx_hash)`. The block-global log index preserves EVM execution order; the transaction hash is only a final deterministic tie-breaker. Each opaque cursor is bound to the chain, domain, entity, indexed block, and indexed hash. Risk catalogs instead use independent pool-address and vault-address keysets at the same indexed boundary. A head change invalidates either cursor form instead of silently mixing evidence boundaries. Live refreshes refetch the currently visible depth for paged Operations views, and append requests are serialized with refresh work so a stale continuation cannot replace canonical evidence. + +### Risk and trading calculations + +Liquidation approval events are retained as a separate chain-scoped lifecycle keyed by the registry and approval ID (or receiver-vault nonce identity for nonce invalidations). Set, reserve, release, consume, revoke, and nonce-invalidation transitions remain canonical evidence and are linked into risk responses and semantic timelines. `consumedDebtAttoEth` is the debt moved by a consumed reservation. `releasedDebtAttoEth` is the unused part of that same reservation returned to the approval's available balance; it is not a separate release action. The resulting available, reserved, and consumed balances remain direct fields from that event. Approval events describe authorization state; they are not inferred liquidation executions. + +Pool capacity remains visible as an exact tagged value, but it is marked unusable for risk decisions when `protocolValid` is false and either settlement collateral or current minting capacity is nonzero. Vault health is unavailable when its open interest is nonzero and the coherent pool snapshot has the same invalid price. These invalid-price responses expose price provenance and remain unavailable until the protocol price becomes valid. Directly observed pool or vault bad debt takes precedence and remains a critical protocol state because it is not derived from price. Separately, vault risk is unavailable when the pool and vault snapshots have different block hashes; that response exposes both snapshot boundaries until coherent sampling catches up. Scanner severity does not reinterpret an expired protocol price as healthy or liquidatable. + +Pool capacity is the exact current minting capacity less settlement collateral, floored at zero; utilization retains the exact basis-point integer. Vault health reproduces both `SecurityPoolUtils.isVaultHealthyAtFactor` constraints: associated backing includes dispute-staked REP, while the migration-safety constraint uses only pool-held backing and the greater of the half-excess security multiplier or liquidation-bonus multiplier. Protocol state (`healthy`, `liquidatable`, or `bad-debt`) is stored separately from the scanner's named 12,000-bps warning band. + +For an AMM `Swap`, pre-swap reserves are reconstructed from emitted post-swap reserves and exact input/output amounts. Spot, execution price, and price impact remain numerator/denominator pairs; basis points are a display derivative. The 24-hour and seven-day volume and fee summaries aggregate exact event integers. TWAP integrates exact reserve ratios over the selected wall-clock window and reports covered seconds separately; the observation immediately before the window supplies its opening price. A partial window is labeled partial rather than extrapolated. Hourly candles retain exact rational OHLC values and observation counts. + +One trading response reads at most 10,001 newest price candidates and calculates TWAP and candles from at most 10,000. The candidates include one optional observation before the seven-day window so TWAP can establish its opening price. `observationsTruncated: true` means that oldest candidate was omitted; it does not by itself prove that an in-window observation was omitted. Consumers should inspect `observationRange` and the TWAP coverage state, while candles describe every retained in-window observation. Volume, fee, swap-count, and liquidity-event summaries use independent SQL aggregates over every canonical event in their stated window and remain complete even when price candidates are truncated. + ## Questions `ZoltarQuestionData.QuestionCreated` contains the complete question definition. The question ID is the hash of this definition and its outcome options, and none of these fields changes afterward. @@ -35,20 +82,20 @@ conditional_no_bps = 10,000 - conditional_yes_bps The first calculation floors integer division and the second is its exact complement, so the stored values always total 10,000 basis points. Conceptually, the two ratios are NO reserve / total reserve for conditional YES and YES reserve / total reserve for conditional NO. The database retains the exact reserves and basis-point observations. The chart uses a shared 0–100% axis because the two conditional values are complementary. These values are conditional on a valid resolution and remain manipulable spot prices; historical display does not turn them into a TWAP or manipulation-resistant oracle. A configured AMM factory must be indexed from at or before its deployment to provide complete pair and reserve history. -Configured Uniswap venues add a separate universe-scoped spot-price history. V2 and V3 markets must pair the pool's exact universe REP ERC-20 with the configured WETH contract. V4 markets must pair that REP with native ETH, use no hook, and use one of the repository's four standard fee/tick-spacing configurations. This identity check prevents a parent REP pool or a sibling child-universe REP pool from supplying the displayed series. +Configured Uniswap venues add a separate universe-scoped spot-price history. V2 and V3 markets must pair the pool's exact universe REP ERC-20 with a configured WETH or USDC contract. V4 markets must pair that REP with native ETH or configured USDC, use no hook, and use one of the repository's four standard fee/tick-spacing configurations. V4 ERC-20 currencies are ordered by address when deriving the pool ID; native ETH uses the zero-address currency. This identity check prevents a parent REP pool or a sibling child-universe REP pool from supplying the displayed series, and the configured USDC identity supplies its fixed 6-decimal quote scale. -For scale `S = 10^18`, `Q = 2^96`, and V3/V4 `x = sqrtPriceX96`, augurScan derives REP per quote asset as follows: +For quote-token base-unit scale `D = 10^quoteDecimals`, `Q = 2^96`, and V3/V4 `x = sqrtPriceX96`, augurScan derives a 1e18-scaled REP-per-quote value as follows. `D` is `10^18` for WETH or native ETH and `10^6` for USDC: ```text -V2, REP is token0: floor(reserve0 * S / reserve1) -V2, REP is token1: floor(reserve1 * S / reserve0) -V3/V4, REP is token0: floor(Q^2 * S / x^2) -V3/V4, REP is token1: floor(x^2 * S / Q^2) +V2, REP is token0: floor(reserve0 * D / reserve1) +V2, REP is token1: floor(reserve1 * D / reserve0) +V3/V4, REP is token0: floor(Q^2 * D / x^2) +V3/V4, REP is token1: floor(x^2 * D / Q^2) ``` -Only positive reserve or square-root-price inputs create returned chart points, and every division uses positive integer arithmetic that floors the result. Each pool and fee tier remains a distinct line on one shared value axis. +Only positive reserve or square-root-price inputs create returned chart points, and every division uses positive integer arithmetic that floors the result. Each pool and fee tier remains a distinct line. When every returned line has the same quote symbol, the lines share one numeric range. When the result mixes quote symbols, the renderer scales every line independently; labels and exact point values carry the WETH, native ETH, or USDC unit, so line height is not a cross-quote comparison. -The observations are event-time marginal prices used only for historical display. They are not inputs to the Open Oracle, the coordinator, or protocol settlement. They do not use V2 cumulative-price fields, calculate a TWAP, enforce minimum liquidity, or prove resistance to same-block manipulation. V2/V3 WETH is displayed separately from V4 native ETH rather than silently claiming the assets are identical. The indexer retains raw reserves or square-root prices, event and transaction identity, token order, pool ID/address, fee, tick spacing, and hook address so every derived chart point is auditable. +The observations are event-time marginal prices used only for historical display. They are not inputs to the Open Oracle, the coordinator, or protocol settlement. They do not use V2 cumulative-price fields, calculate a TWAP, enforce minimum liquidity, or prove resistance to same-block manipulation. REP/WETH, REP/native ETH, and REP/USDC are explicitly labeled and are not placed on one shared numeric axis. Quote-token decimals are applied before producing the 1e18-scaled REP-per-quote ratio. The indexer retains raw reserves or square-root prices, exact emitted liquidity, event and transaction identity, token order, pool ID/address, fee, tick spacing, and hook address. ## Vaults diff --git a/augurScan/browser/api-validation.ts b/augurScan/browser/api-validation.ts index 6c8529a42..6923f6450 100644 --- a/augurScan/browser/api-validation.ts +++ b/augurScan/browser/api-validation.ts @@ -72,7 +72,8 @@ export const isUniswapPriceValue = (value: unknown): boolean => isString(value['fee_hundredths_bip']) && isString(value['quote_symbol']) && isString(value['event_name']) && - isString(value['rep_per_eth_1e18']) + isString(value['rep_per_eth_1e18']) && + (value['liquidity_value'] === undefined || isNullableString(value['liquidity_value'])) export const isArgumentDefinition = (value: unknown): boolean => isRecord(value) && @@ -220,7 +221,9 @@ export const isRichListRecordValue = (value: unknown): boolean => Array.isArray(value['pool_associations']) && value['pool_associations'].every(isPoolAssociationValue) && Array.isArray(value['vault_positions']) && - value['vault_positions'].every(isVaultPositionValue) + value['vault_positions'].every(isVaultPositionValue) && + (value['escalation_claims'] === undefined || (Array.isArray(value['escalation_claims']) && value['escalation_claims'].every(isJsonRecord))) && + (value['auction_claims'] === undefined || (Array.isArray(value['auction_claims']) && value['auction_claims'].every(isJsonRecord))) const hasStringFields = (value: Record, fields: readonly string[]): boolean => fields.every((field) => isString(value[field])) const hasNullableStringFields = (value: Record, fields: readonly string[]): boolean => fields.every((field) => isNullableString(value[field])) diff --git a/augurScan/browser/app.ts b/augurScan/browser/app.ts index 28703330e..86bec8035 100644 --- a/augurScan/browser/app.ts +++ b/augurScan/browser/app.ts @@ -19,15 +19,17 @@ import { isVaultStateEntityValue, type JsonValue, } from './api-validation.ts' -import { chartValueBounds, uniswapPriceChartModel, uniswapPriceProvenance } from './chart-values.ts' +import { chartValueBounds, uniswapLiquidityChartModel, uniswapPriceChartModel, uniswapPriceProvenance } from './chart-values.ts' import { demoAmmPriceHistory, demoDenseUniswapRepEthPriceHistory, demoRepEthPriceHistory, demoUniswapRepEthPriceHistory } from './demo-fixtures.ts' import { requiredElementRole } from './dom-elements.ts' import { accountStateDuringStagedRefresh, activityRefreshRetention, + approvalTransitionFields, canonicalPageLimit, classifyLiveRecords, collectCanonicalPages, + compareCanonicalEventPosition, contractDeploymentBlockActionLabel, contractDeploymentStatus, contractDeploymentTimestampLabel, @@ -43,6 +45,8 @@ import { isCurrentLiveRequest, isNoncanonicalDetailFailure, mergeUniqueRecords, + operationsCatalogRecordKey, + operationsDetailRecordKey, paginatedSnapshotWasReplaced, paginationRequestAllowed, queuedPaginationPresentation, @@ -51,6 +55,7 @@ import { refreshPresentation, resolveActivityRefreshDepth, retainedPaginationAvailable, + runSerializedOperationsLoad, runWithForegroundReservation, shouldClearPendingDetailState, shouldContinueTransactionRestore, @@ -188,6 +193,8 @@ interface RichListRecord { claimableFeesAttoEth: SerializedAtomicInteger blockNumber: string }> + escalation_claims?: Array> + auction_claims?: Array> [key: string]: unknown } type PoolRecord = (typeof demoCatalog.pools)[number] & { current_state?: Record } @@ -315,6 +322,7 @@ interface EntityHistory { ammPrices: ReturnType repEthPrices: ReturnType uniswapRepEthPrices: ReturnType + openOracleHistory: ChartRow[] market?: { pair_address?: string | null; fee_bps?: string | number | null } | null pools: JsonValue[] forks: JsonValue[] @@ -435,10 +443,11 @@ const detailState = pageUrl.searchParams.get('detailState') const deploymentState = pageUrl.searchParams.get('deploymentState') const networkState = pageUrl.searchParams.get('networkState') const isSystem = location.pathname === '/system' +const isOperations = location.pathname === '/operations' || location.pathname.startsWith('/operations/') const isContracts = location.pathname === '/contracts' const isRichList = location.pathname === '/richlist' const isAddress = location.pathname === '/address' -const isActivity = !isSystem && !isContracts && !isRichList && !isAddress +const isActivity = !isSystem && !isOperations && !isContracts && !isRichList && !isAddress const initialChainId = pageUrl.searchParams.get('chainId') ?? '' const initialActivityFilters = { event: pageUrl.searchParams.get('event') ?? '', @@ -464,6 +473,22 @@ let demoCanonicalRouteRefreshErrorConsumed = false let demoTransactionRestoreErrorConsumed = false let demoTransactionAppendErrorConsumed = false let demoNetworkFallbackErrorConsumed = false +let operationsRequestVersion = 0 +const operationsLoadState: { promise?: Promise; context?: string } = {} +let operationsCatalogState: + | { + readonly chainId: string + readonly section: OperationsCatalogSection + readonly items: readonly Record[] + } + | undefined +let operationsDetailState: + | { + readonly chainId: string + readonly routeKey: string + readonly items: readonly Record[] + } + | undefined let demoRouteRefreshErrorConsumed = false let logsRequestVersion = 0 let activityPaginationIntentVersion = 0 @@ -751,7 +776,7 @@ const demoLogs = Array.from({ length: 18 }, (_, index) => { event_name: demoEvents[index % demoEvents.length], summary: index % 2 === 0 - ? 'amountAttoRep=4,250.75 REP · vault=Market maker (0x19B4…E2a0)' + ? 'amount=4,250.75 REP · vault=Market maker (0x19B4…E2a0)' : `reportId=1842 · price=0.004281 ${network.id === 'sepolia' ? 'SepoliaETH' : 'ETH'} · outcomeIndex=2`, decode_status: index === 7 ? 'unknown' : 'decoded', canonical: true, @@ -759,13 +784,13 @@ const demoLogs = Array.from({ length: 18 }, (_, index) => { topics: [demoHash], data: '0x00', arguments: { - ['amountAttoRep']: '4250750000000000000000', + ['amountAttoRep']: (4_250_750n * 10n ** 15n).toString(), vault: '0x19B4a7C60926D8FBe420C2a49f1DB56D7800E2a0', coordinator: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', recipients: ['0x7777777777777777777777777777777777777777', '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], }, display_arguments: { - ['amountAttoRep']: '4,250.75 REP', + ['amountAttoRep']: (4_250_750n * 10n ** 15n).toString(), vault: 'Market maker (0x19B4a7C60926D8FBe420C2a49f1DB56D7800E2a0)', coordinator: 'OpenOracle (0xc9b36e44643fc5d882654ffd9791ae7171b0e9db)', recipients: ['Security Pool (0x7777777777777777777777777777777777777777)', '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], @@ -1207,6 +1232,15 @@ const demoHistory = (path: string) => { ammPrices: hasAmm ? demoAmmPriceHistory() : [], repEthPrices: hasRepEthPrices ? displayedRepEthPrices : [], uniswapRepEthPrices: hasRepEthPrices ? (priceDemo === 'eight' ? demoDenseUniswapRepEthPriceHistory() : demoUniswapRepEthPriceHistory()) : [], + openOracleHistory: hasRepEthPrices + ? displayedRepEthPrices.slice(-3).map((price, index) => ({ + timestamp: price.timestamp, + block_number: price.block_number, + event_name: index === 0 ? 'ReportSubmitted' : index === 1 ? 'ReportDisputed' : 'PriceReported', + summary: index === 0 ? 'REP/ETH report submitted' : index === 1 ? 'Replacement round accepted' : 'Coordinator accepted the settled price', + coordinator_address: poolItem.coordinator_address, + })) + : [], } } if (type === 'vaults') { @@ -1455,6 +1489,7 @@ const decodeEntityHistory = (value: unknown): EntityHistory => { const ammPrices = value['ammPrices'] ?? [] const repEthPrices = value['repEthPrices'] ?? [] const uniswapRepEthPrices = value['uniswapRepEthPrices'] ?? [] + const openOracleHistory = value['openOracleHistory'] ?? [] const pools = value['pools'] ?? [] const forks = value['forks'] ?? [] if (!Array.isArray(snapshots) || !snapshots.every(isChartRow)) throw new Error('State history snapshots are malformed') @@ -1462,6 +1497,7 @@ const decodeEntityHistory = (value: unknown): EntityHistory => { if (!Array.isArray(ammPrices) || !ammPrices.every(isAmmPrice)) throw new Error('AMM price history is malformed') if (!Array.isArray(repEthPrices) || !repEthPrices.every(isRepEthPrice)) throw new Error('REP/ETH price history is malformed') if (!Array.isArray(uniswapRepEthPrices) || !uniswapRepEthPrices.every(isUniswapPrice)) throw new Error('Uniswap price history is malformed') + if (!Array.isArray(openOracleHistory) || !openOracleHistory.every(isChartRow)) throw new Error('OpenOracle history is malformed') if (!Array.isArray(pools) || !pools.every(isJsonValue)) throw new Error('Question pool history is malformed') if (!Array.isArray(forks) || !forks.every(isJsonValue)) throw new Error('Question fork history is malformed') const market = value['market'] @@ -1473,7 +1509,298 @@ const decodeEntityHistory = (value: unknown): EntityHistory => { (market['fee_bps'] !== undefined && market['fee_bps'] !== null && typeof market['fee_bps'] !== 'string' && typeof market['fee_bps'] !== 'number')) ) throw new Error('State market history is malformed') - return { snapshots, events, ammPrices, repEthPrices, uniswapRepEthPrices, pools, forks, ...(market === undefined ? {} : { market }) } + return { snapshots, events, ammPrices, repEthPrices, uniswapRepEthPrices, openOracleHistory, pools, forks, ...(market === undefined ? {} : { market }) } +} + +const demoOperations = (chainId: string) => { + const network = demoNetworks.find((item) => item.chain_id === chainId) ?? demoNetworks[0] + const asOf = { + blockNumber: network?.indexed_block ?? '0', + blockHash: network?.indexed_hash ?? demoHash, + blockTimestamp: String(Math.floor(new Date(network?.indexed_timestamp ?? Date.now()).getTime() / 1_000)), + observedHead: network?.observed_block ?? network?.indexed_block ?? '0', + lagBlocks: '0', + phase: network?.phase ?? 'live', + lastSuccessfulRefresh: network?.last_success_at ?? new Date().toISOString(), + } + const reports = [ + { + open_oracle_address: '0x529dcaC57677451CBfe766d88CcC133D082500df', + report_id: '1842', + observed_rounds: 3, + block_number: asOf.blockNumber, + report_data: { + token1: '0x0000000000000000000000000000000000000000', + token2: '0x221657776846890989a759ba2973e427dff5c9bb', + currentAmount1: '1000000000000000000', + currentAmount2: '233590000000000000000', + currentReporter: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + }, + lifecycle: { state: 'Dispute window open', clock: 'timestamp', nextTransition: String(Number(asOf.blockTimestamp) + 1_800) }, + }, + { + open_oracle_address: '0x529dcaC57677451CBfe766d88CcC133D082500df', + report_id: '1841', + observed_rounds: 1, + block_number: String(BigInt(asOf.blockNumber) - 12n), + report_data: { token1: '0x221657776846890989a759ba2973e427dff5c9bb', token2: '0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }, + lifecycle: { state: 'Settleable', clock: 'block' }, + }, + ] + const escalations = [ + { + game_address: '0x7777777777777777777777777777777777777777', + event_name: 'DepositOnOutcome', + block_number: asOf.blockNumber, + invalid_stake_atto_rep: '400000000000000000000', + no_stake_atto_rep: '900000000000000000000', + yes_stake_atto_rep: '1250000000000000000000', + }, + ] + const auctions = [ + { + auction_address: '0x8888888888888888888888888888888888888888', + status: 'Open', + bid_count: 18, + bidder_count: 11, + block_number: asOf.blockNumber, + start_data: { attoEthRaiseCap: String(20_000_000_000_000_000_000n), maxAttoRepBeingSold: String(5_000_000_000_000_000_000_000n) }, + }, + ] + const risk = { + pools: [ + { + pool_address: '0x9999999999999999999999999999999999999999', + block_number: asOf.blockNumber, + read_status: 'success', + protocol_state: '0', + scanner_severity: 'warning', + scanner_reason: 'Pool is above the scanner capacity warning band', + capacity: { + usedAttoEth: (42n * 10n ** 18n).toString(), + capacityAttoEth: (50n * 10n ** 18n).toString(), + availableAttoEth: (8n * 10n ** 18n).toString(), + utilizationBps: '8400', + }, + }, + ], + vaults: [ + { + pool_address: '0x9999999999999999999999999999999999999999', + vault_address: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + block_number: asOf.blockNumber, + read_status: 'success', + protocol_state: 'healthy', + scanner_severity: 'warning', + scanner_reason: 'Health factor is below the scanner warning threshold', + risk: { healthFactorBps: '11350', targetHealthFactorBps: '12000', liquidationBoundaryBps: '10000' }, + }, + ], + recentLiquidations: [], + approvalEvents: [ + { + event_name: 'LiquidationApprovalConsumed', + approval_identity: `0x${'a'.repeat(64)}`, + receiver_vault: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + block_number: asOf.blockNumber, + transaction_index: 8, + log_index: 3, + event_data: { + operationId: '42', + consumedDebtAttoEth: (2n * 10n ** 18n).toString(), + releasedDebtAttoEth: (3n * 10n ** 17n).toString(), + resultingAvailableDebtAttoEth: (8n * 10n ** 18n).toString(), + resultingReservedDebtAttoEth: 0n.toString(), + resultingConsumedDebtAttoEth: (2n * 10n ** 18n).toString(), + }, + }, + { + event_name: 'LiquidationApprovalReserved', + approval_identity: `0x${'a'.repeat(64)}`, + receiver_vault: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + block_number: asOf.blockNumber, + transaction_index: 2, + log_index: 1, + event_data: { operationId: '42', reservedDebtAttoEth: (2n * 10n ** 18n).toString() }, + }, + { + event_name: 'LiquidationApprovalNonceInvalidated', + approval_identity: 'nonce:0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + receiver_vault: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + block_number: asOf.blockNumber, + transaction_index: 9, + log_index: 1, + event_data: { previousNonce: '41', newNonce: '42' }, + }, + ], + } + return { + chainId, + asOf, + data: { + reports, + escalations, + auctions, + risk, + prices: [{ source_event: 'PriceReported', value: '233590000000000000000', block_number: asOf.blockNumber }], + forks: [{ universe_identity: '0', event_name: 'MigrationRepSplit', block_number: asOf.blockNumber }], + recentChanges: [ + { semantic_event_kind: 'ReportDisputed', entity_identity: '0x529dca…:1842', block_number: asOf.blockNumber }, + { semantic_event_kind: 'DepositOnOutcome', entity_identity: '0x777777…', block_number: asOf.blockNumber }, + ], + }, + } +} + +const demoOperationsDetail = (path: string): unknown => { + const request = new URL(path, location.origin) + const parts = request.pathname.split('/').filter(Boolean) + const domain = parts[3] + const chainId = (domain === 'risk' ? parts[5] : parts[4]) ?? '1' + const operations = demoOperations(chainId) + const identity = parts.slice(5).map(decodeURIComponent) + const evidence = (eventName: string, eventData: Record) => ({ + event_name: eventName, + event_data: eventData, + block_number: operations.asOf.blockNumber, + block_hash: operations.asOf.blockHash, + tx_hash: demoHash, + log_index: 7, + canonical: true, + }) + const evidencePage = (item: Record) => { + const continuationFixture = pageUrl.searchParams.get('detailMore') === '1' + if (continuationFixture && request.searchParams.has('cursor')) + return { + items: [{ ...item, block_number: String(BigInt(operations.asOf.blockNumber) - 1n), log_index: 2, tx_hash: `0x${'d'.repeat(64)}` }], + limit: 100, + hasMore: false, + } + return { items: [item], limit: 100, hasMore: continuationFixture, ...(continuationFixture ? { nextCursor: 'demo-detail-older' } : {}) } + } + if (domain === 'reports') { + const report = operations.data.reports.find( + (item) => item.open_oracle_address.toLowerCase() === identity[0]?.toLowerCase() && item.report_id === identity[1], + ) + const current = { + ...evidence('ReportDisputed', report?.report_data ?? {}), + report_data: report?.report_data ?? {}, + lifecycle: report?.lifecycle ?? { state: 'Awaiting indexed evidence', clock: 'timestamp' }, + } + return { + chainId, + asOf: operations.asOf, + data: { identity: { openOracleAddress: identity[0], reportId: identity[1] }, current, rounds: evidencePage(current) }, + } + } + if (domain === 'escalations') { + const event = evidence('DepositOnOutcome', { + depositor: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + outcome: '1', + attoRepAmount: '1250000000000000000000', + }) + return { + chainId, + asOf: operations.asOf, + data: { + identity: identity[0], + snapshot: { + entity_identity: identity[0], + block_number: operations.asOf.blockNumber, + read_status: 'success', + source_method: 'lifecycle(), balances(), totalCapital()', + read_result: { phase: 'Active', requiredNextDepositAttoRep: (500n * 10n ** 18n).toString() }, + }, + deposits: [event], + claims: [], + events: evidencePage(event), + }, + } + } + if (domain === 'auctions') { + const bid = evidence('BidSubmitted', { bidder: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', tick: '14', bidAmountAttoEth: (3n * 10n ** 18n).toString() }) + return { + chainId, + asOf: operations.asOf, + data: { + identity: identity[0], + snapshot: { + entity_identity: identity[0], + block_number: operations.asOf.blockNumber, + read_status: 'success', + source_method: 'auctionState(), computeClearing()', + read_result: { state: 'Open' }, + }, + demandCurve: [{ tick: '14', amountAttoEth: (3n * 10n ** 18n).toString(), cumulativeDemandAttoEth: (3n * 10n ** 18n).toString() }], + events: evidencePage(bid), + }, + } + } + if (domain === 'risk') { + const kind = parts[4] + const risk = operations.data.risk + const entity = + kind === 'pools' + ? risk.pools.find((item) => item.pool_address.toLowerCase() === parts[6]?.toLowerCase()) + : risk.vaults.find( + (item) => item.pool_address.toLowerCase() === parts[6]?.toLowerCase() && item.vault_address.toLowerCase() === parts[7]?.toLowerCase(), + ) + return { chainId: parts[5] ?? chainId, asOf: operations.asOf, data: entity ?? {} } + } + if (domain === 'trading') { + const swap = evidence('Swap', { + yesForNo: true, + amountIn: '1000000000000000000', + amountOut: '970000000000000000', + feeAmount: '3000000000000000', + resultingYesReserve: '51000000000000000000', + resultingNoReserve: '49030000000000000000', + }) + return { + chainId, + asOf: operations.asOf, + data: { + market: identity[0], + summary: { + swaps_24h: 12, + swaps_7d: 63, + input_volume_24h: '18000000000000000000', + input_volume_7d: '91000000000000000000', + fees_24h: '54000000000000000', + fees_7d: '273000000000000000', + }, + twap24h: { state: 'Available', numerator: '49', denominator: '51', coverageSeconds: '86400', windowSeconds: '86400' }, + twap7d: { state: 'Partial coverage', numerator: '97', denominator: '100', coverageSeconds: '518400', windowSeconds: '604800' }, + candles: [ + { + bucketStart: String(BigInt(operations.asOf.blockTimestamp) - 3600n), + open: { numerator: '1', denominator: '1' }, + high: { numerator: '1', denominator: '1' }, + low: { numerator: '49', denominator: '51' }, + close: { numerator: '49', denominator: '51' }, + observations: 12, + }, + ], + events: evidencePage(swap), + }, + } + } + const migration = evidence('MigrationRepSplit', { + universeId: identity[0], + childUniverseId: '1', + outcomeIndex: '1', + migrator: '0xc9b36e44643fc5d882654ffd9791ae7171b0e9db', + amountAttoRep: (4_500n * 10n ** 18n).toString(), + }) + return { + chainId, + asOf: operations.asOf, + data: { + identity: identity[0], + branches: [{ child_universe_id: '1', outcome_index: '1', migrated_atto_rep: '4500000000000000000000', migrator_count: 1, migration_count: 1 }], + events: evidencePage(migration), + }, + } } const api = async (path: string, { signal }: { signal?: AbortSignal } = {}): Promise => { @@ -1501,6 +1828,52 @@ const api = async (path: string, { signal }: { signal?: AbortSignal } = {}): Pro items[0] = { ...items[0], deployment_block: null, deployment_block_exact: null, deployment_timestamp: null, deployment_checked_block: '0' } return { items } } + if (path.startsWith('/api/v1/operations')) { + if (demoState === 'loading') return await new Promise(() => {}) + if (demoState === 'error') throw new Error('Operations could not be loaded') + return demoOperations(new URL(path, location.origin).searchParams.get('chainId') ?? '1') + } + if (/^\/api\/v1\/state\/(reports|escalations|auctions)(?:\?|$)/.test(path)) { + if (demoState === 'loading') return await new Promise(() => {}) + if (demoState === 'error') throw new Error('Operations catalog could not be loaded') + const request = new URL(path, location.origin) + const chainId = request.searchParams.get('chainId') ?? '1' + const operations = demoOperations(chainId) + const section = request.pathname.split('/').at(-1) + const fixtureItems = section === 'reports' ? operations.data.reports : section === 'escalations' ? operations.data.escalations : operations.data.auctions + const items = pageUrl.searchParams.get('catalogEmpty') === '1' ? [] : fixtureItems + const continuationFixture = pageUrl.searchParams.get('catalogMore') === '1' + const cursor = request.searchParams.get('cursor') + if (continuationFixture && cursor !== null) { + if (pageUrl.searchParams.get('catalogAppendDelay') === '1') await new Promise((resolve) => setTimeout(resolve, 2_500)) + if (pageUrl.searchParams.get('catalogAppendError') === '1') throw new Error('Older canonical records could not be loaded') + const older = items.at(-1) + return { + chainId, + asOf: operations.asOf, + data: { + items: older === undefined ? [] : [{ ...older, block_number: String(BigInt(operations.asOf.blockNumber) - 100n) }], + limit: 100, + hasMore: false, + }, + } + } + return { + chainId, + asOf: operations.asOf, + data: { items, limit: 100, hasMore: continuationFixture, ...(continuationFixture ? { nextCursor: 'demo-older' } : {}) }, + } + } + if (/^\/api\/v1\/state\/(reports|escalations|auctions|forks|trading|risk\/(?:pools|vaults))\//.test(path)) { + if (demoState === 'loading') return await new Promise(() => {}) + if (demoState === 'error') throw new Error('Operations detail could not be loaded') + const request = new URL(path, location.origin) + if (request.searchParams.has('cursor') && pageUrl.searchParams.get('detailAppendDelay') === '1') + await new Promise((resolve) => setTimeout(resolve, 2_500)) + if (request.searchParams.has('cursor') && pageUrl.searchParams.get('detailAppendError') === '1') + throw new Error('Older canonical evidence could not be loaded') + return demoOperationsDetail(path) + } if (path.startsWith('/api/v1/state/catalog')) { if (demoReorgObserved && pageUrl.searchParams.get('canonicalRouteRefreshError') === '1' && !demoCanonicalRouteRefreshErrorConsumed) { demoCanonicalRouteRefreshErrorConsumed = true @@ -2076,16 +2449,787 @@ const requiredChainId = () => { return chainId } +type OperationsResponse = { + readonly chainId: string | number + readonly asOf: Record + readonly data: Record +} + +const decodeOperationsResponse = (value: unknown): OperationsResponse => { + if (!isRecord(value) || (!isString(value['chainId']) && typeof value['chainId'] !== 'number') || !isRecord(value['asOf']) || !isRecord(value['data'])) + throw new Error('Operations response is malformed') + return { chainId: value['chainId'], asOf: value['asOf'], data: value['data'] } +} + +const operationRecords = (value: unknown): Record[] => (Array.isArray(value) ? value.filter(isRecord) : []) +type OperationsCatalogSection = 'auctions' | 'escalations' | 'reports' +const operationsCatalogSection = (): OperationsCatalogSection | undefined => { + const section = location.pathname.split('/')[2] + return section === 'reports' || section === 'escalations' || section === 'auctions' ? section : undefined +} + +const operationsCatalogEndpoint = (section: OperationsCatalogSection, cursor?: string, limit = 100): string => { + const query = new URLSearchParams({ chainId: requiredChainId(), limit: String(limit) }) + if (cursor !== undefined) query.set('cursor', cursor) + return `/api/v1/state/${section}?${query.toString()}` +} + +const catalogOperationsResponse = ( + response: OperationsResponse, + section: OperationsCatalogSection, + items: readonly Record[], +): OperationsResponse => ({ ...response, data: { [section]: items, _catalogPage: response.data } }) + +const approvalTransitionSummary = (item: Readonly>): string => { + const eventData = isRecord(item['event_data']) ? item['event_data'] : {} + const receiver = String(item['receiver_vault'] ?? eventData['receiverVault'] ?? '') + const operation = typeof eventData['operationId'] === 'string' ? `operation ${shortIdentifier(eventData['operationId'])}` : undefined + const fields = approvalTransitionFields(eventData).map( + (field) => `${field.label} ${operationNumber(field.value)}${field.unit === '' ? '' : ` ${field.unit}`}`, + ) + const details = [operation, ...fields, receiver === '' ? undefined : `receiver ${shortIdentifier(receiver, 10, 6)}`].filter( + (value): value is string => value !== undefined, + ) + return details.length === 0 ? 'Authorization lifecycle transition' : details.join(' · ') +} + +type OperationsRenderContext = { + readonly focusHref?: string + readonly focusLoadMore: boolean + readonly scrollY: number +} + +const captureOperationsRenderContext = (): OperationsRenderContext => { + const content = $('#operations-content') + const active = document.activeElement + return { + ...(active instanceof HTMLAnchorElement && content.contains(active) ? { focusHref: active.href } : {}), + focusLoadMore: + active instanceof HTMLElement && + (active.classList.contains('operations-catalog-more') || + active.classList.contains('operations-detail-more') || + active.classList.contains('operations-pagination-complete')), + scrollY: window.scrollY, + } +} + +const restoreOperationsRenderContext = (snapshot: OperationsRenderContext) => { + const content = $('#operations-content') + const continuation = content.querySelector('.operations-catalog-more, .operations-detail-more') + const completion = content.querySelector('.operations-pagination-complete') + const catalogRows = [...content.querySelectorAll('a.operations-row')] + const target = + snapshot.focusHref === undefined + ? snapshot.focusLoadMore + ? (continuation ?? completion ?? catalogRows.at(-1)) + : undefined + : catalogRows.find((candidate) => candidate.href === snapshot.focusHref) + target?.focus({ preventScroll: true }) + window.scrollTo({ top: snapshot.scrollY, behavior: 'auto' }) +} +const operationNumber = (value: unknown): string => + typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint' ? number(value) : number(undefined) +const operationCounted = (value: unknown, singular: string, plural?: string): string => + typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint' ? counted(value, singular, plural) : counted(undefined, singular, plural) + +const operationsHref = (pathname: string): string => { + const destination = new URL(pathname, location.origin) + const chainId = selectedChainId() + if (chainId !== '') destination.searchParams.set('chainId', chainId) + if (isDemo) destination.searchParams.set('demo', '1') + return `${destination.pathname}${destination.search}` +} + +const operationCard = (label: string, value: string, detail?: string) => { + const card = element('div', 'operations-card') + card.append(element('span', '', label), element('strong', '', value)) + if (detail !== undefined) card.append(element('small', '', detail)) + return card +} + +const operationRow = (title: string, status: string, identity: string, block: unknown, href?: string) => { + const row = href === undefined ? element('div', 'operations-row') : document.createElement('a') + row.className = 'operations-row' + if (row instanceof HTMLAnchorElement && href !== undefined) row.href = href + const copy = element('div') + copy.append(element('strong', '', title), element('span', '', status), element('code', '', shortIdentifier(identity, 12, 8))) + row.append(copy) + if (typeof block === 'string' || typeof block === 'number') { + const evidence = element('div', 'operations-evidence') + evidence.append(element('small', '', `Indexed #${number(block)}`)) + row.append(evidence) + } + return row +} + +const operationsPanel = (title: string, rows: HTMLElement[], empty: string) => { + const panel = element('section', 'operations-panel') + panel.append(element('h3', '', title)) + const list = element('div', 'operations-list') + list.append(...(rows.length === 0 ? [element('div', 'state-placeholder', empty)] : rows)) + panel.append(list) + return panel +} + +const renderOperations = (response: OperationsResponse, preservedContext?: OperationsRenderContext) => { + const content = $('#operations-content') + const renderContext = preservedContext ?? captureOperationsRenderContext() + const { asOf, data } = response + const selected = location.pathname.split('/')[2] ?? 'overview' + const reports = operationRecords(data['reports']) + const escalations = operationRecords(data['escalations']) + const auctions = operationRecords(data['auctions']) + const forks = operationRecords(data['forks']) + const changes = operationRecords(data['recentChanges']) + const prices = operationRecords(data['prices']) + const risk = isRecord(data['risk']) ? data['risk'] : {} + const pools = operationRecords(risk['pools']) + const vaults = operationRecords(risk['vaults']) + const approvals = operationRecords(risk['approvalEvents']).sort(compareCanonicalEventPosition) + const freshness = element('div', 'operations-freshness') + freshness.append( + operationCard( + 'Indexed head', + `#${number(typeof asOf['blockNumber'] === 'string' ? asOf['blockNumber'] : undefined)}`, + shortIdentifier(String(asOf['blockHash'] ?? 'Unavailable')), + ), + operationCard('Observed head', `#${number(typeof asOf['observedHead'] === 'string' ? asOf['observedHead'] : undefined)}`), + operationCard('Block lag', number(typeof asOf['lagBlocks'] === 'string' ? asOf['lagBlocks'] : undefined), String(asOf['phase'] ?? 'Unavailable')), + operationCard( + 'Indexed timestamp', + asOf['blockTimestamp'] === undefined ? 'Unavailable' : exactTimestamp(Number(asOf['blockTimestamp']) * 1_000).replace('.000Z', 'Z'), + ), + operationCard('Live updates', connection.classList.contains('live') ? 'Connected' : 'Reconnecting', 'Canonical commits only'), + ) + const metrics = element('div', 'operations-metrics') + metrics.append( + operationCard( + 'OpenOracle reports', + number(reports.length), + counted(reports.filter((item) => isRecord(item['lifecycle']) && item['lifecycle']['state'] === 'Settleable').length, 'settleable'), + ), + operationCard('Escalation games', number(escalations.length), 'Canonical event projections'), + operationCard('Truth auctions', number(auctions.length), counted(auctions.filter((item) => item['status'] === 'Open').length, 'open')), + operationCard('Pool / vault snapshots', `${number(pools.length)} / ${number(vaults.length)}`, 'Latest canonical accounting'), + ) + const reportRows = reports.map((item) => { + const lifecycle = isRecord(item['lifecycle']) ? item['lifecycle'] : {} + const reportData = isRecord(item['report_data']) ? item['report_data'] : {} + return operationRow( + `Report ${String(item['report_id'] ?? '—')}`, + `${String(lifecycle['state'] ?? 'Awaiting indexed evidence')} · ${operationCounted(item['observed_rounds'], 'round')} · ${String(reportData['token1'] ?? 'token 1')} / ${String(reportData['token2'] ?? 'token 2')}`, + `${String(item['open_oracle_address'] ?? '')}:${String(item['report_id'] ?? '')}`, + item['block_number'], + operationsHref( + `/operations/report/${encodeURIComponent(String(item['open_oracle_address'] ?? ''))}/${encodeURIComponent(String(item['report_id'] ?? ''))}`, + ), + ) + }) + const escalationRows = escalations.map((item) => + operationRow( + 'Escalation game', + `${String(item['event_name'] ?? 'Active')} · INVALID ${operationNumber(item['invalid_stake_atto_rep'])} · NO ${operationNumber(item['no_stake_atto_rep'])} · YES ${operationNumber(item['yes_stake_atto_rep'])} attoREP`, + String(item['game_address'] ?? ''), + item['block_number'], + operationsHref(`/operations/escalation/${encodeURIComponent(String(item['game_address'] ?? ''))}`), + ), + ) + const auctionRows = auctions.map((item) => + operationRow( + 'Truth auction', + `${String(item['status'] ?? 'Awaiting indexed evidence')} · ${operationCounted(item['bid_count'], 'bid')} · ${operationCounted(item['bidder_count'], 'bidder')}`, + String(item['auction_address'] ?? ''), + item['block_number'], + operationsHref(`/operations/auction/${encodeURIComponent(String(item['auction_address'] ?? ''))}`), + ), + ) + const riskRows = [ + ...pools.map((item) => { + const capacity = isRecord(item['capacity']) ? item['capacity'] : {} + return operationRow( + 'Pool accounting', + `${String(item['scanner_severity'] ?? 'unavailable')} · ${operationNumber(capacity['utilizationBps'])} bps utilized · ${String(item['scanner_reason'] ?? '')}`, + String(item['pool_address'] ?? ''), + item['block_number'], + operationsHref(`/operations/risk/pool/${encodeURIComponent(String(item['pool_address'] ?? ''))}`), + ) + }), + ...vaults.map((item) => { + const itemRisk = isRecord(item['risk']) ? item['risk'] : {} + return operationRow( + 'Vault position', + `${String(item['scanner_severity'] ?? 'unavailable')} · health ${operationNumber(itemRisk['healthFactorBps'])} bps · ${String(item['scanner_reason'] ?? '')}`, + String(item['vault_address'] ?? ''), + item['block_number'], + operationsHref( + `/operations/risk/vault/${encodeURIComponent(String(item['pool_address'] ?? ''))}/${encodeURIComponent(String(item['vault_address'] ?? ''))}`, + ), + ) + }), + ] + const approvalRows = approvals.map((item) => + operationRow( + String(item['event_name'] ?? 'Liquidation approval'), + approvalTransitionSummary(item), + String(item['approval_identity'] ?? item['receiver_vault'] ?? ''), + item['block_number'], + ), + ) + const forkRows = forks.map((item) => + operationRow( + 'Fork / migration', + String(item['event_name'] ?? 'Migration evidence'), + String(item['universe_identity'] ?? ''), + item['block_number'], + operationsHref(`/operations/fork/${encodeURIComponent(String(item['universe_identity'] ?? ''))}`), + ), + ) + const changeRows = changes.map((item) => + operationRow( + String(item['semantic_event_kind'] ?? 'Protocol transition'), + 'Canonical semantic evidence', + String(item['entity_identity'] ?? ''), + item['block_number'], + ), + ) + const priceRows = prices.map((item) => + operationRow( + 'Coordinator REP / ETH', + `${operationNumber(item['value'])} scaled 1e18 · ${String(item['source_event'] ?? 'Unavailable')}`, + String(item['source_contract'] ?? ''), + item['block_number'], + ), + ) + const attentionReportRows: HTMLElement[] = [] + for (const [index, item] of reports.entries()) { + const lifecycle = isRecord(item['lifecycle']) ? item['lifecycle'] : {} + const row = reportRows[index] + if ((lifecycle['state'] === 'Dispute window open' || lifecycle['state'] === 'Settleable') && row !== undefined) attentionReportRows.push(row) + } + const concludedEscalationEvents = new Set(['NonDecisionReached', 'GameContinuedFromFork', 'InheritedThresholdTie']) + const activeEscalationRows: HTMLElement[] = [] + for (const [index, item] of escalations.entries()) { + const row = escalationRows[index] + if (!concludedEscalationEvents.has(String(item['event_name'] ?? '')) && row !== undefined) activeEscalationRows.push(row) + } + const activeAuctionRows: HTMLElement[] = [] + for (const [index, item] of auctions.entries()) { + const row = auctionRows[index] + if (['Open', 'Awaiting finalization', 'Bid settlements outstanding'].includes(String(item['status'] ?? '')) && row !== undefined) + activeAuctionRows.push(row) + } + const panels = + selected === 'reports' + ? [operationsPanel('OpenOracle reports', reportRows, 'No canonical report evidence has been indexed.')] + : selected === 'escalations' + ? [operationsPanel('Escalation games', escalationRows, 'No canonical escalation evidence has been indexed.')] + : selected === 'auctions' + ? [operationsPanel('Truth auctions', auctionRows, 'No canonical auction evidence has been indexed.')] + : selected === 'risk' + ? [ + operationsPanel('Pool and vault risk evidence', riskRows, 'No canonical accounting snapshots have been indexed.'), + operationsPanel('Liquidation approval lifecycle', approvalRows, 'No liquidation approval evidence has been indexed.'), + ] + : [ + operationsPanel('Needs attention · reports', attentionReportRows.slice(0, 5), 'No reports need attention.'), + operationsPanel('Active escalations', activeEscalationRows, 'No escalation games are active.'), + operationsPanel('Active auctions', activeAuctionRows, 'No auctions are active.'), + operationsPanel('Pool and vault risk', riskRows, 'No risk snapshots are available.'), + operationsPanel('Fork and migration progress', forkRows, 'No fork or migration evidence has been indexed.'), + operationsPanel('Price provenance', priceRows, 'No accepted coordinator price is available.'), + operationsPanel('Recent semantic changes', changeRows, 'No semantic changes have been indexed.'), + ] + const grid = element('div', panels.length === 1 ? 'operations-grid operations-grid-single' : 'operations-grid') + grid.append(...panels) + const catalogPage = isRecord(data['_catalogPage']) ? data['_catalogPage'] : undefined + const catalogSection = operationsCatalogSection() + if (catalogPage !== undefined && catalogSection !== undefined) + operationsCatalogState = { chainId: requiredChainId(), section: catalogSection, items: operationRecords(data[catalogSection]) } + if (catalogPage?.['hasMore'] === true && typeof catalogPage['nextCursor'] === 'string' && selected !== 'overview') { + const loadMore = document.createElement('button') + loadMore.type = 'button' + loadMore.className = 'secondary operations-catalog-more' + loadMore.textContent = 'Show more indexed records' + loadMore.setAttribute('aria-label', `Show more ${selected} from older canonical blocks`) + const loadMoreStatus = element('p', 'activity-summary') + loadMoreStatus.setAttribute('role', 'status') + loadMoreStatus.setAttribute('aria-live', 'polite') + loadMore.addEventListener('click', async () => { + const paginationContext = captureOperationsRenderContext() + loadMore.disabled = true + loadMore.setAttribute('aria-busy', 'true') + loadMoreStatus.textContent = 'Loading older canonical records…' + const section = operationsCatalogSection() + if (section === undefined) return + const loaded = await loadOperations({ + live: true, + catalogTargetCount: operationRecords(data[section]).length + 100, + preservedContext: paginationContext, + }) + if (!loaded && loadMore.isConnected) { + loadMore.disabled = false + loadMore.removeAttribute('aria-busy') + loadMore.textContent = 'Retry older records' + loadMoreStatus.textContent = 'Older canonical records could not be loaded.' + loadMore.focus({ preventScroll: true }) + } + }) + grid.append(loadMore, loadMoreStatus) + } else if (catalogPage !== undefined && selected !== 'overview' && renderContext.focusLoadMore) { + const completeStatus = element('p', 'activity-summary', 'All indexed records are shown.') + completeStatus.setAttribute('role', 'status') + completeStatus.setAttribute('aria-live', 'polite') + grid.append(completeStatus) + } + if (selected === 'overview') content.replaceChildren(freshness, metrics, grid) + else { + const routeFreshness = element('p', 'operations-route-freshness') + routeFreshness.textContent = `As of indexed block #${number(typeof asOf['blockNumber'] === 'string' ? asOf['blockNumber'] : undefined)} · ${number( + typeof asOf['lagBlocks'] === 'string' ? asOf['lagBlocks'] : undefined, + )} blocks behind · ${connection.classList.contains('live') ? 'live updates connected' : 'live updates reconnecting'}` + content.replaceChildren(routeFreshness, grid) + } + content.setAttribute('aria-busy', 'false') + $('#operations-status').hidden = true + restoreOperationsRenderContext(renderContext) +} + +type OperationsDetailRoute = { + readonly kind: 'auction' | 'escalation' | 'fork' | 'pool' | 'report' | 'trading' | 'vault' + readonly identity: readonly string[] +} + +const operationsDetailRoute = (): OperationsDetailRoute | undefined => { + const parts = location.pathname.split('/').filter(Boolean) + if (parts[0] !== 'operations') return undefined + const kind = parts[1] + if (kind === 'report' && parts.length === 4) return { kind, identity: [decodeURIComponent(parts[2] ?? ''), decodeURIComponent(parts[3] ?? '')] } + if ((kind === 'auction' || kind === 'escalation' || kind === 'fork') && parts.length === 3) return { kind, identity: [decodeURIComponent(parts[2] ?? '')] } + if (kind === 'trading' && parts.length === 3) return { kind, identity: [decodeURIComponent(parts[2] ?? '')] } + if (kind === 'risk' && parts[2] === 'pool' && parts.length === 4) return { kind: 'pool', identity: [decodeURIComponent(parts[3] ?? '')] } + if (kind === 'risk' && parts[2] === 'vault' && parts.length === 5) + return { kind: 'vault', identity: [decodeURIComponent(parts[3] ?? ''), decodeURIComponent(parts[4] ?? '')] } + return undefined +} + +const operationsDetailEndpoint = (route: OperationsDetailRoute, cursor?: string, limit = 100): string => { + const chainId = encodeURIComponent(requiredChainId()) + const identity = route.identity.map(encodeURIComponent).join('/') + const resource = + route.kind === 'report' + ? 'reports' + : route.kind === 'escalation' + ? 'escalations' + : route.kind === 'auction' + ? 'auctions' + : route.kind === 'pool' + ? 'risk/pools' + : route.kind === 'vault' + ? 'risk/vaults' + : route.kind === 'trading' + ? 'trading' + : 'forks' + const query = new URLSearchParams({ limit: String(limit) }) + if (cursor !== undefined) query.set('cursor', cursor) + return `/api/v1/state/${resource}/${chainId}/${identity}?${query.toString()}` +} + +const operationsDetailRouteKey = (route: OperationsDetailRoute): string => `${route.kind}:${route.identity.join(':').toLowerCase()}` + +const rawEvidence = (value: unknown) => { + const disclosure = document.createElement('details') + disclosure.className = 'operations-raw-evidence' + disclosure.append(element('summary', '', 'Raw chain evidence')) + const raw = document.createElement('pre') + raw.textContent = JSON.stringify(value, null, 2) ?? 'Unavailable' + disclosure.append(raw) + return disclosure +} + +const detailEvidenceRows = (items: readonly Record[]) => + items.map((item) => { + const eventName = String(item['event_name'] ?? item['semantic_event_kind'] ?? 'Protocol evidence') + const block = item['block_number'] + const row = operationRow(eventName, `Canonical event · log ${String(item['log_index'] ?? '—')}`, String(item['tx_hash'] ?? ''), block) + row.append(rawEvidence(item)) + return row + }) + +const detailPageRecord = (data: Record, key: string): Record => (isRecord(data[key]) ? data[key] : {}) + +const renderOperationsDetail = (response: OperationsResponse, route: OperationsDetailRoute, preservedContext?: OperationsRenderContext) => { + const content = $('#operations-content') + const renderContext = preservedContext ?? captureOperationsRenderContext() + const data = response.data + const asOf = response.asOf + const header = element('section', 'operations-detail-header') + const back = document.createElement('a') + const catalogPath = + route.kind === 'report' + ? '/operations/reports' + : route.kind === 'escalation' + ? '/operations/escalations' + : route.kind === 'auction' + ? '/operations/auctions' + : route.kind === 'pool' || route.kind === 'vault' + ? '/operations/risk' + : '/operations' + back.href = operationsHref(catalogPath) + back.textContent = catalogPath === '/operations' ? '← Back to Operations' : '← Back to catalog' + const titleIdentity = route.kind === 'vault' ? route.identity[1] : route.identity[0] + const title = element( + 'h2', + '', + route.kind === 'report' + ? `OpenOracle report ${route.identity[1]}` + : `${route.kind[0]?.toUpperCase()}${route.kind.slice(1)} ${shortIdentifier(titleIdentity ?? '')}`, + ) + header.append( + back, + title, + element( + 'p', + 'operations-route-freshness', + `As of indexed block #${number(String(asOf['blockNumber'] ?? ''))} · ${number(String(asOf['lagBlocks'] ?? ''))} blocks behind`, + ), + ) + + const summary = element('div', 'operations-metrics') + const snapshot = isRecord(data['snapshot']) ? data['snapshot'] : undefined + const current = isRecord(data['current']) ? data['current'] : undefined + const lifecycle = current !== undefined && isRecord(current['lifecycle']) ? current['lifecycle'] : undefined + summary.append( + operationCard( + 'Protocol state', + String( + lifecycle?.['state'] ?? (snapshot?.['read_status'] === 'success' ? 'Current tagged read available' : (snapshot?.['read_status'] ?? 'Event-derived')), + ), + ), + operationCard( + 'Calculation kind', + snapshot === undefined ? 'derived-from-events' : 'tagged-contract-read', + String(snapshot?.['source_method'] ?? 'Canonical event projection'), + ), + operationCard('Entity identity', route.identity.join(' · ')), + ) + + const panels: HTMLElement[] = [] + const approvalEvents = operationRecords(data['approvalEvents']).sort(compareCanonicalEventPosition) + if (approvalEvents.length > 0) + panels.push( + operationsPanel( + 'Liquidation approval lifecycle', + approvalEvents.map((item) => + operationRow( + String(item['event_name'] ?? 'Liquidation approval'), + approvalTransitionSummary(item), + String(item['approval_identity'] ?? ''), + item['block_number'], + ), + ), + 'No approval transitions are related to this risk entity.', + ), + ) + if (snapshot !== undefined) + panels.push( + operationsPanel( + 'Current-state snapshot', + [ + operationRow('Tagged block read', String(snapshot['read_status']), String(snapshot['entity_identity'] ?? ''), snapshot['block_number']), + rawEvidence(snapshot), + ], + 'Snapshot unavailable', + ), + ) + if (current !== undefined) + panels.push( + operationsPanel( + 'Current report', + [ + operationRow( + String(lifecycle?.['state'] ?? current['event_name'] ?? 'Report'), + 'Latest canonical report evidence', + route.identity.join(':'), + current['block_number'], + ), + rawEvidence(current), + ], + 'Current report unavailable', + ), + ) + if (route.kind === 'pool' || route.kind === 'vault') + panels.push( + operationsPanel( + 'Current risk state', + [ + operationRow( + String(data['protocol_state'] ?? 'Unavailable'), + `${String(data['scanner_severity'] ?? 'unavailable')} · ${String(data['scanner_reason'] ?? 'Current-state evidence unavailable')}`, + route.identity.join(':'), + data['block_number'], + ), + rawEvidence(data), + ], + 'Risk state unavailable', + ), + ) + if (route.kind === 'trading') { + const tradingSummary = isRecord(data['summary']) ? data['summary'] : {} + const twap24h = isRecord(data['twap24h']) ? data['twap24h'] : {} + const twap7d = isRecord(data['twap7d']) ? data['twap7d'] : {} + panels.push( + operationsPanel( + 'Trading summary', + [ + operationRow( + '24-hour activity', + `${operationCounted(tradingSummary['swaps_24h'], 'swap')} · ${operationNumber(tradingSummary['input_volume_24h'])} input units · ${operationNumber(tradingSummary['fees_24h'])} fee units`, + route.identity[0] ?? '', + undefined, + ), + operationRow( + 'Seven-day activity', + `${operationCounted(tradingSummary['swaps_7d'], 'swap')} · ${operationNumber(tradingSummary['input_volume_7d'])} input units · ${operationNumber(tradingSummary['fees_7d'])} fee units`, + route.identity[0] ?? '', + undefined, + ), + operationRow( + '24-hour TWAP', + `${String(twap24h['state'] ?? 'Unavailable')} · ${String(twap24h['numerator'] ?? '—')} / ${String(twap24h['denominator'] ?? '—')}`, + 'NO per YES', + undefined, + ), + operationRow( + 'Seven-day TWAP', + `${String(twap7d['state'] ?? 'Unavailable')} · ${String(twap7d['numerator'] ?? '—')} / ${String(twap7d['denominator'] ?? '—')}`, + 'NO per YES', + undefined, + ), + rawEvidence({ summary: tradingSummary, twap24h, twap7d, candles: data['candles'] }), + ], + 'No trading observations are available.', + ), + ) + } + + const demand = operationRecords(data['demandCurve']) + if (demand.length > 0) { + const demandRows = demand.map((point) => + operationRow( + `Tick ${String(point['tick'])}`, + `${operationNumber(point['amountAttoEth'])} attoETH · cumulative ${operationNumber(point['cumulativeDemandAttoEth'])}`, + String(point['tick']), + undefined, + ), + ) + panels.push(operationsPanel('Demand curve data', demandRows, 'No bids have been indexed.')) + } + const branches = operationRecords(data['branches']) + if (branches.length > 0) + panels.push( + operationsPanel( + 'Child universe branches', + branches.map((branch) => + operationRow( + `Child ${String(branch['child_universe_id'])}`, + `Outcome ${String(branch['outcome_index'] ?? '—')} · ${operationNumber(branch['migrated_atto_rep'])} attoREP · ${operationCounted(branch['migrator_count'], 'migrator')}`, + String(branch['child_universe_id']), + undefined, + ), + ), + 'No child branches have been indexed.', + ), + ) + const evidencePage = detailPageRecord(data, route.kind === 'report' ? 'rounds' : 'events') + const evidenceItems = operationRecords(evidencePage['items']) + operationsDetailState = { chainId: requiredChainId(), routeKey: operationsDetailRouteKey(route), items: evidenceItems } + const evidencePanel = operationsPanel( + route.kind === 'report' ? 'Report rounds' : 'Lifecycle timeline', + detailEvidenceRows(evidenceItems), + 'No canonical evidence is available.', + ) + if (evidencePage['hasMore'] === true && typeof evidencePage['nextCursor'] === 'string') { + const loadMore = document.createElement('button') + loadMore.type = 'button' + loadMore.className = 'secondary compact operations-detail-more' + loadMore.textContent = 'Show older evidence' + loadMore.setAttribute('aria-label', 'Show older canonical evidence') + const loadMoreStatus = element('p', 'activity-summary') + loadMoreStatus.setAttribute('role', 'status') + loadMoreStatus.setAttribute('aria-live', 'polite') + loadMore.addEventListener('click', async () => { + const paginationContext = captureOperationsRenderContext() + loadMore.disabled = true + loadMore.setAttribute('aria-busy', 'true') + loadMoreStatus.textContent = 'Loading older canonical evidence…' + const loaded = await loadOperations({ + live: true, + detailTargetCount: evidenceItems.length + 100, + preservedContext: paginationContext, + }) + if (!loaded && loadMore.isConnected) { + loadMore.disabled = false + loadMore.removeAttribute('aria-busy') + loadMore.textContent = 'Retry older evidence' + loadMoreStatus.textContent = 'Older canonical evidence could not be loaded.' + loadMore.focus({ preventScroll: true }) + } + }) + evidencePanel.append(loadMore, loadMoreStatus) + } else if (renderContext.focusLoadMore) { + const completeStatus = element('p', 'activity-summary operations-pagination-complete', 'All indexed evidence is shown.') + completeStatus.setAttribute('role', 'status') + completeStatus.setAttribute('aria-live', 'polite') + completeStatus.tabIndex = -1 + evidencePanel.append(completeStatus) + } + panels.push(evidencePanel) + const grid = element('div', 'operations-grid operations-grid-single') + grid.append(...panels) + content.replaceChildren(header, summary, grid) + content.setAttribute('aria-busy', 'false') + $('#operations-status').hidden = true + restoreOperationsRenderContext(renderContext) +} + +const loadOperationsCatalog = async (section: OperationsCatalogSection, retainedCount: number): Promise => { + let first: OperationsResponse | undefined + let last: OperationsResponse | undefined + const snapshot = await collectCanonicalPages( + async (cursor?: string, limit = 100) => { + const response = decodeOperationsResponse(await api(operationsCatalogEndpoint(section, cursor, limit))) + first ??= response + last = response + return { + items: operationRecords(response.data['items']), + ...(response.data['hasMore'] === true && typeof response.data['nextCursor'] === 'string' ? { nextCursor: response.data['nextCursor'] } : {}), + } + }, + retainedCount, + (item) => operationsCatalogRecordKey(section, item), + ) + if (first === undefined || last === undefined) throw new Error('Operations catalog returned no page') + return catalogOperationsResponse( + { ...first, data: { ...last.data, hasMore: snapshot.nextCursor !== undefined, nextCursor: snapshot.nextCursor } }, + section, + snapshot.items, + ) +} + +const loadOperationsDetail = async (route: OperationsDetailRoute, retainedCount: number): Promise => { + const pageKey = route.kind === 'report' ? 'rounds' : 'events' + let first: OperationsResponse | undefined + let last: OperationsResponse | undefined + const snapshot = await collectCanonicalPages( + async (cursor?: string, limit = 100) => { + const response = decodeOperationsResponse(await api(operationsDetailEndpoint(route, cursor, limit))) + first ??= response + last = response + const page = detailPageRecord(response.data, pageKey) + return { + items: operationRecords(page['items']), + ...(page['hasMore'] === true && typeof page['nextCursor'] === 'string' ? { nextCursor: page['nextCursor'] } : {}), + } + }, + retainedCount, + operationsDetailRecordKey, + ) + if (first === undefined || last === undefined) throw new Error('Operations detail returned no page') + const lastPage = detailPageRecord(last.data, pageKey) + return { + ...first, + data: { + ...first.data, + [pageKey]: { + ...lastPage, + items: snapshot.items, + hasMore: snapshot.nextCursor !== undefined, + ...(snapshot.nextCursor === undefined ? {} : { nextCursor: snapshot.nextCursor }), + }, + }, + } +} + +const loadOperations = async ({ + live = false, + catalogTargetCount, + detailTargetCount, + preservedContext, +}: { + live?: boolean + catalogTargetCount?: number + detailTargetCount?: number + preservedContext?: OperationsRenderContext +} = {}): Promise => { + const requestedContext = `${requiredChainId()}:${location.pathname}` + return await runSerializedOperationsLoad( + operationsLoadState, + requestedContext, + live, + catalogTargetCount !== undefined || detailTargetCount !== undefined, + () => `${requiredChainId()}:${location.pathname}`, + () => operationsRequestVersion++, + async () => { + const requestVersion = ++operationsRequestVersion + const status = $('#operations-status') + const content = $('#operations-content') + const preserveRenderedContent = live && content.childElementCount > 0 + status.hidden = false + status.className = preserveRenderedContent ? 'sr-only' : 'system-status' + status.textContent = preserveRenderedContent ? 'Refreshing canonical protocol operations…' : 'Loading canonical protocol operations…' + content.setAttribute('aria-busy', 'true') + try { + const detailRoute = operationsDetailRoute() + const catalogSection = detailRoute === undefined ? operationsCatalogSection() : undefined + const retainedCatalogCount = + catalogSection !== undefined && operationsCatalogState?.chainId === requiredChainId() && operationsCatalogState.section === catalogSection + ? operationsCatalogState.items.length + : 0 + const retainedDetailCount = + detailRoute !== undefined && + operationsDetailState?.chainId === requiredChainId() && + operationsDetailState.routeKey === operationsDetailRouteKey(detailRoute) + ? operationsDetailState.items.length + : 0 + const response = + detailRoute !== undefined + ? await loadOperationsDetail(detailRoute, detailTargetCount ?? retainedDetailCount) + : catalogSection === undefined + ? decodeOperationsResponse(await api(`/api/v1/operations?chainId=${encodeURIComponent(requiredChainId())}`)) + : await loadOperationsCatalog(catalogSection, catalogTargetCount ?? retainedCatalogCount) + if (requestVersion !== operationsRequestVersion) return false + if (detailRoute === undefined) renderOperations(response, preservedContext) + else renderOperationsDetail(response, detailRoute, preservedContext) + return true + } catch (error) { + if (requestVersion !== operationsRequestVersion) return false + if (preserveRenderedContent) { + status.className = 'sr-only' + status.textContent = 'Could not refresh protocol operations. Existing indexed evidence remains visible.' + content.setAttribute('aria-busy', 'false') + return false + } + status.dataset.errorDetail = error instanceof Error ? error.message : 'Unknown operations request failure' + renderRetryStatus(status, 'Could not load protocol operations.', loadOperations) + content.replaceChildren() + content.setAttribute('aria-busy', 'false') + return false + } + }, + ) +} + const syncNetworkUrl = () => { const url = new URL(location.href) const chainId = selectedChainId() if (chainId) url.searchParams.set('chainId', chainId) else url.searchParams.delete('chainId') history.replaceState(null, '', url) - for (const link of document.querySelectorAll('.product-nav a')) { + for (const link of document.querySelectorAll('.product-nav a, .operations-nav a')) { const destination = new URL(link.href) if (chainId) destination.searchParams.set('chainId', chainId) else destination.searchParams.delete('chainId') + if (isDemo) destination.searchParams.set('demo', '1') link.href = destination.href } } @@ -2140,6 +3284,7 @@ const loadNetworks = async ({ synchronizeActivity = true, refreshAfterCurrent = await loadLogs() } if (isSystem && synchronizeActivity && previousNetwork !== selectedChainId()) await loadSystemState() + if (isOperations && synchronizeActivity && previousNetwork !== selectedChainId()) await loadOperations() if (isContracts && synchronizeActivity && previousNetwork !== selectedChainId()) await loadContracts() if (isRichList && synchronizeActivity && previousNetwork !== selectedChainId()) await loadRichList() if (isAddress && synchronizeActivity && previousNetwork !== selectedChainId()) await loadAddressProfile() @@ -3444,15 +4589,54 @@ const chartCard = ( card.append(heading) if (rows.length === 0) card.append(element('p', 'data-note', emptyMessage)) else { + const independentlyScaled = definitions.length > 1 && sharedRange === undefined + if (independentlyScaled) { + const currentValues = element('dl', 'chart-current-values') + for (const { key, label, decimals = 18, unit = '' } of definitions) { + const latest = rows.findLast((row) => row[key] !== undefined) + if (latest === undefined) continue + const item = element('div') + item.append(element('dt', '', label), element('dd', '', exactUnit(chartNumericValue(latest[key]), decimals, unit, decimals))) + currentValues.append(item) + } + card.append(currentValues) + } const viewport = element('div', 'chart-scroll') viewport.append(lineChart(rows, definitions, { sharedRange, axisUnit })) + const dataDisclosure = document.createElement('details') + dataDisclosure.className = 'chart-data-disclosure' + dataDisclosure.append(element('summary', '', 'View exact chart data')) + const tableViewport = element('div', 'chart-data-scroll') + const table = document.createElement('table') + const caption = element('caption', '', `${title} exact indexed observations`) + const head = document.createElement('thead') + const headerRow = document.createElement('tr') + headerRow.append(element('th', '', 'Indexed time')) + for (const definition of definitions) headerRow.append(element('th', '', definition.label)) + head.append(headerRow) + const body = document.createElement('tbody') + for (const row of rows) { + const tableRow = document.createElement('tr') + const time = element('th', '', new Date(row.timestamp).toLocaleString()) + time.setAttribute('scope', 'row') + tableRow.append(time) + for (const { key, decimals = 18, unit = '' } of definitions) { + const value = row[key] + tableRow.append(element('td', '', value === undefined ? 'Unavailable' : exactUnit(chartNumericValue(value), decimals, unit, decimals))) + } + body.append(tableRow) + } + table.append(caption, head, body) + tableViewport.append(table) + dataDisclosure.append(tableViewport) card.append( viewport, element( 'p', 'data-note', - `${note}${definitions.length > 1 && sharedRange === undefined ? ' Each line is independently scaled to its observed range so every trend remains visible; exact current values are shown above.' : ''}`, + `${note}${independentlyScaled ? ' Each line is independently scaled to its observed range so every trend remains visible; exact latest values are listed above.' : ''}`, ), + dataDisclosure, ) } return card @@ -3490,7 +4674,6 @@ const renderContractDetail = (contract: ContractRecord | undefined) => { head.append(identity, statusNode) const grid = element('div', 'contract-detail-grid') grid.append( - detailCard('Registry source', contract.provenance), detailCard( 'Deployment block', contract.deployment_block === null || contract.deployment_block === undefined @@ -3498,7 +4681,6 @@ const renderContractDetail = (contract: ContractRecord | undefined) => { : `${contract.deployment_block_exact === false ? 'Search boundary ' : ''}#${number(contract.deployment_block)}`, ), detailCard(contractDeploymentTimestampLabel(contract), contract.deployment_timestamp ? exactTimestamp(contract.deployment_timestamp) : 'Not observed'), - detailCard('First protocol discovery block', contract.discovery_block ? `#${number(contract.discovery_block)}` : 'Configured contract', true), ) const actions = element('div', 'detail-tools') const copyAddress = copyButton(contract.address, 'address') @@ -4045,6 +5227,30 @@ const renderAddressProfile = ( if (involvementGrid.childElementCount === 0) involvementGrid.append(element('p', 'data-note', 'No pool or vault involvement has been indexed.')) involvement.append(involvementGrid) setLiveRecord(involvement, 'involvement', { pools: item.pool_associations, vaults: item.vault_positions }) + const escalationClaims = operationsPanel( + 'Escalation interactions', + (item.escalation_claims ?? []).map((claim) => + operationRow( + String(claim['type'] ?? 'Escalation position'), + `${String(claim['provenance'] ?? 'historical interaction')} · current claimability is unavailable`, + String(claim['entity'] ?? ''), + claim['blockNumber'], + ), + ), + 'No escalation interactions are associated with this address.', + ) + const auctionClaims = operationsPanel( + 'Auction interactions', + (item.auction_claims ?? []).map((claim) => + operationRow( + String(claim['type'] ?? 'Auction position'), + `${String(claim['provenance'] ?? 'historical interaction')} · current entitlement is unavailable`, + String(claim['entity'] ?? ''), + claim['blockNumber'], + ), + ), + 'No truth-auction interactions are associated with this address.', + ) const activity = element('section', 'address-profile-panel') const activityHeader = element('div', 'address-section-heading') const activityCopy = element('div') @@ -4109,7 +5315,7 @@ const renderAddressProfile = ( interactionPanel.append(interactionList) setLiveRecord(interactionPanel, 'references', interactions) setLiveRecord(activity, 'transactions', transactions) - content.replaceChildren(header, metrics, balances, involvement, interactionPanel, activity) + content.replaceChildren(header, metrics, balances, involvement, escalationClaims, auctionClaims, interactionPanel, activity) applyLiveChanges(content, previousSections, { live }) content.setAttribute('aria-busy', 'false') } @@ -4232,7 +5438,9 @@ const renderPoolDetail = async (poolItem: PoolRecord, requestVersion: number, ca const ammPrices = history.ammPrices ?? [] const repEthPrices = history.repEthPrices ?? [] const uniswapRepEthPrices = history.uniswapRepEthPrices ?? [] + const openOracleHistory = history.openOracleHistory ?? [] const uniswapChart = uniswapPriceChartModel(uniswapRepEthPrices) + const uniswapLiquidity = uniswapLiquidityChartModel(uniswapRepEthPrices) const latestAmmPrice = ammPrices.at(-1) const latestRepEthPrice = repEthPrices.at(-1) const latestUniswapPrice = uniswapChart.latestObservation @@ -4245,6 +5453,20 @@ const renderPoolDetail = async (poolItem: PoolRecord, requestVersion: number, ca 'Latest indexed', ), ) + fragment.append( + operationsPanel( + 'OpenOracle coordinator state and history', + openOracleHistory.map((observation) => + operationRow( + String(observation['event_name'] ?? 'Coordinator transition'), + String(observation['summary'] ?? 'Canonical OpenOracle coordinator evidence'), + String(observation['coordinator_address'] ?? poolItem.coordinator_address), + observation['block_number'], + ), + ), + 'No OpenOracle coordinator state transitions have been indexed for this pool.', + ), + ) const metrics = element('div', 'metric-grid') metrics.append( metricCard( @@ -4277,15 +5499,22 @@ const renderPoolDetail = async (poolItem: PoolRecord, requestVersion: number, ca 'Authoritative PoolAccountingCheckpoint results. Collateral and fees use attoETH; capacity ownership uses attoREP.', ), chartCard( - 'Uniswap REP / ETH spot price history', + 'Uniswap REP price curves', uniswapChart.rows, uniswapChart.definitions, - 'Event-time marginal prices derived from V2 Sync reserves and V3/V4 Initialize or Swap sqrt prices. V2/V3 quote REP per WETH; V4 quotes REP per native ETH. These values can be manipulated within a block and are not a TWAP or protocol oracle.', + 'Event-time marginal prices derived from V2 Sync reserves and V3/V4 Initialize or Swap sqrt prices. Curves retain their explicit WETH, native ETH, or USDC quote orientation. These values can be manipulated within a block and are not a TWAP or protocol oracle.', { sharedRange: uniswapChart.sharedRange, - emptyMessage: 'No Uniswap REP / ETH pool observations have been indexed for this universe.', + emptyMessage: 'No Uniswap REP / ETH or REP / USDC pool observations have been indexed for this universe.', }, ), + chartCard( + 'Uniswap liquidity over time', + uniswapLiquidity.rows, + uniswapLiquidity.definitions, + 'V2 points preserve the exact reserve product. V3 and V4 points preserve the exact active-liquidity integer emitted by Swap. Each venue is raw protocol evidence and is not silently normalized across token decimal systems.', + { emptyMessage: 'No Uniswap liquidity observations have been indexed for this universe.' }, + ), ) fragment.append( chartCard( @@ -4370,6 +5599,13 @@ const renderPoolDetail = async (poolItem: PoolRecord, requestVersion: number, ca staticField('Child pools', number(poolItem.child_count)), ) staticCard.append(grid) + if (history.market?.pair_address) { + const analyticsLink = document.createElement('a') + analyticsLink.className = 'secondary compact state-analytics-link' + analyticsLink.href = operationsHref(`/operations/trading/${encodeURIComponent(history.market.pair_address)}`) + analyticsLink.textContent = 'Open AMM trading analytics' + staticCard.append(analyticsLink) + } fragment.append(staticCard) $('#state-detail').replaceChildren(fragment) } @@ -5034,6 +6270,7 @@ const retryCanonicalRefresh = async (button: HTMLButtonElement) => { } else { await loadNetworks({ refreshAfterCurrent: true }) if (isSystem) await loadSystemState() + else if (isOperations) await loadOperations() else if (isContracts) await loadContracts() else if (isRichList) await loadRichList() else if (isAddress) await loadAddressProfile() @@ -5169,6 +6406,13 @@ const resetSelectedNetworkContext = () => { } else if (isAddress) { currentAddressProfile = undefined $('#address-profile-content').replaceChildren(element('div', 'state-placeholder', 'Loading address activity…')) + } else if (isOperations) { + operationsRequestVersion++ + operationsLoadState.promise = undefined + operationsLoadState.context = undefined + operationsCatalogState = undefined + $('#operations-content').replaceChildren() + $('#operations-content').setAttribute('aria-busy', 'true') } } @@ -5180,6 +6424,8 @@ globalNetworkFilter.addEventListener('change', async () => { updateFreshness() if (isSystem) { await loadSystemState() + } else if (isOperations) { + await loadOperations() } else if (isContracts) { await loadContracts() } else if (isRichList) { @@ -5218,6 +6464,11 @@ const refreshAfterUpdates = async (_count: number, _forceContentRefresh: boolean if (contentRefreshed && canonicalRefreshRequired && activeReorgRecovery === undefined) completeCanonicalRefresh() return contentRefreshed } + if (isOperations) { + const [, contentRefreshed] = await Promise.all([networkRefresh, loadOperations({ live: true })]) + if (contentRefreshed && canonicalRefreshRequired && activeReorgRecovery === undefined) completeCanonicalRefresh() + return contentRefreshed + } if (isContracts) { const [, contentRefreshed] = await Promise.all([networkRefresh, loadContracts({ live: true })]) if (contentRefreshed && canonicalRefreshRequired && activeReorgRecovery === undefined) completeCanonicalRefresh() @@ -5507,11 +6758,25 @@ if (!isRichList && accountDeepLink !== null) { } $('#activity').hidden = !isActivity $('#system').hidden = !isSystem +$('#operations').hidden = !isOperations $('#contracts').hidden = !isContracts $('#richlist').hidden = !isRichList $('#address-profile').hidden = !isAddress -$('.skip-link').href = isSystem ? '#system' : isContracts ? '#contracts' : isRichList ? '#richlist' : isAddress ? '#address-profile' : '#activity' +$('.skip-link').href = isSystem + ? '#system' + : isOperations + ? '#operations' + : isContracts + ? '#contracts' + : isRichList + ? '#richlist' + : isAddress + ? '#address-profile' + : '#activity' for (const link of document.querySelectorAll('.product-nav a')) + if (new URL(link.href).pathname === location.pathname || (isOperations && new URL(link.href).pathname === '/operations')) + link.setAttribute('aria-current', 'page') +for (const link of document.querySelectorAll('.operations-nav a')) if (new URL(link.href).pathname === location.pathname) link.setAttribute('aria-current', 'page') const requestedTab = pageUrl.searchParams.get('tab') @@ -5521,6 +6786,7 @@ if (isSystem) selectedEntityKey = pageUrl.searchParams.get('entity') ?? undefine const initialDashboardLoad = (async () => { await loadNetworks({ synchronizeActivity: false }) if (isSystem) await loadSystemState() + else if (isOperations) await loadOperations() else if (isContracts) await loadContracts() else if (isRichList) await loadRichList() else if (isAddress) await loadAddressProfile() diff --git a/augurScan/browser/chart-values.ts b/augurScan/browser/chart-values.ts index 2f1dadba2..b21a39798 100644 --- a/augurScan/browser/chart-values.ts +++ b/augurScan/browser/chart-values.ts @@ -6,6 +6,7 @@ export interface UniswapPriceObservation { quote_symbol: string event_name: string rep_per_eth_1e18: string + liquidity_value?: string | null block_number: string contract_address: string } @@ -14,6 +15,10 @@ export interface UniswapChartRow extends UniswapPriceObservation { [key: `uniswap_price_${number}`]: string } +export interface UniswapLiquidityChartRow extends UniswapPriceObservation { + [key: `uniswap_liquidity_${number}`]: string +} + export interface ChartDefinition { key: Extract label: string @@ -41,6 +46,7 @@ export const uniswapPriceProvenance = (observation: UniswapPriceObservation): st `${observation.venue.toUpperCase()} · ${feeLabel(observation.fee_hundredths_bip)} · ${observation.quote_symbol} · ${shortMarketId(observation.market_id)}` export const uniswapPriceChartModel = (observations: readonly UniswapPriceObservation[]) => { + const quoteSymbols = new Set(observations.map(({ quote_symbol }) => quote_symbol)) const bounds = chartValueBounds( observations.map((observation) => Number(observation.rep_per_eth_1e18) / 1e18), undefined, @@ -78,7 +84,48 @@ export const uniswapPriceChartModel = (observations: readonly UniswapPriceObserv pointLabel: (row) => `${row.event_name} · ${row.market_id}`, }), ), - sharedRange: [bounds.minimum, bounds.maximum] as const, + sharedRange: quoteSymbols.size <= 1 ? ([bounds.minimum, bounds.maximum] as const) : undefined, latestObservation: rows.at(-1), } } + +export const uniswapLiquidityChartModel = (observations: readonly UniswapPriceObservation[]) => { + const available = observations.filter( + (observation): observation is UniswapPriceObservation & { liquidity_value: string } => + observation.liquidity_value !== undefined && observation.liquidity_value !== null, + ) + const identities = [ + ...new Map( + available.map((observation) => { + const identity = `${observation.venue}:${observation.market_id}` + return [identity, observation] + }), + ).entries(), + ].sort(([, left], [, right]) => left.venue.localeCompare(right.venue) || Number(left.fee_hundredths_bip) - Number(right.fee_hundredths_bip)) + const keys = new Map( + identities.map(([identity], index): [string, `uniswap_liquidity_${number}`] => [identity, `uniswap_liquidity_${index}`]), + ) + const rows: UniswapLiquidityChartRow[] = [...available] + .sort((left, right) => new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime()) + .map((observation) => { + const key = keys.get(`${observation.venue}:${observation.market_id}`) + if (key === undefined) throw new Error(`Missing liquidity series for ${observation.venue}:${observation.market_id}`) + return { ...observation, [key]: observation.liquidity_value } + }) + return { + rows, + definitions: identities.map(([identity, observation], index) => ({ + key: + keys.get(identity) ?? + (() => { + throw new Error(`Missing liquidity definition for ${identity}`) + })(), + label: `${uniswapPriceProvenance(observation)} liquidity`, + decimals: 0, + unit: observation.venue === 'v2' ? 'reserve product' : 'active liquidity', + className: `series-${index % 8}`, + pointLabel: (row: UniswapLiquidityChartRow) => + `${row.venue.toUpperCase()} ${row.event_name} · ${row.market_id} · ${row.venue === 'v2' ? 'reserve product' : 'active liquidity'}`, + })), + } +} diff --git a/augurScan/browser/demo-fixtures.ts b/augurScan/browser/demo-fixtures.ts index fb145dde5..7b6c1a5a6 100644 --- a/augurScan/browser/demo-fixtures.ts +++ b/augurScan/browser/demo-fixtures.ts @@ -21,6 +21,7 @@ export interface RepEthPriceHistoryRecord { interface DemoUniswapMarket { venue: 'v2' | 'v3' | 'v4' fee: string + quote?: 'ETH' | 'USDC' | 'WETH' } const demoRepEthValues = [ @@ -61,7 +62,7 @@ export const demoRepEthPriceHistory = (now = Date.now()): RepEthPriceHistoryReco }) const demoUniswapHistory = (markets: readonly DemoUniswapMarket[], now: number): UniswapPriceObservation[] => - markets.flatMap(({ venue, fee }, venueIndex) => + markets.flatMap(({ venue, fee, quote }, venueIndex) => demoRepEthValues.slice(1).map((value, index) => ({ timestamp: new Date(now - (4 - index) * 8 * 86_400_000 + venueIndex * 9_000_000).toISOString(), block_number: String(23_110_000 + index * 12_000 + venueIndex * 120), @@ -69,9 +70,13 @@ const demoUniswapHistory = (markets: readonly DemoUniswapMarket[], now: number): market_id: venue === 'v4' ? `0x${(venueIndex + 7).toString(16).repeat(64)}` : `0x${(venueIndex + 7).toString(16).repeat(40)}`, contract_address: `0x${(venueIndex + 4).toString(16).repeat(40)}`, fee_hundredths_bip: fee, - quote_symbol: venue === 'v4' ? 'ETH' : 'WETH', + quote_symbol: quote ?? (venue === 'v4' ? 'ETH' : 'WETH'), event_name: index === 0 && venue !== 'v2' ? 'Initialize' : venue === 'v2' ? 'Sync' : 'Swap', - rep_per_eth_1e18: (BigInt(value) + (BigInt(venueIndex) - 1n) * 300_000_000_000_000_000n).toString(), + rep_per_eth_1e18: + quote === 'USDC' + ? (4_200_000_000_000_000n + BigInt(index) * 90_000_000_000_000n).toString() + : (BigInt(value) + (BigInt(venueIndex) - 1n) * 300_000_000_000_000_000n).toString(), + liquidity_value: (10n ** 24n + BigInt(venueIndex * 7 + index) * 10n ** 22n).toString(), })), ) @@ -80,6 +85,7 @@ export const demoUniswapRepEthPriceHistory = (now = Date.now()): UniswapPriceObs [ { venue: 'v2', fee: '3000' }, { venue: 'v3', fee: '500' }, + { venue: 'v3', fee: '500', quote: 'USDC' }, { venue: 'v4', fee: '3000' }, ], now, diff --git a/augurScan/browser/live-update.ts b/augurScan/browser/live-update.ts index ce3443921..61d17a415 100644 --- a/augurScan/browser/live-update.ts +++ b/augurScan/browser/live-update.ts @@ -55,6 +55,89 @@ export const mergeUniqueRecords = (primary: readonly T[], retained: readonly }) } +export const operationsCatalogRecordKey = (section: 'auctions' | 'escalations' | 'reports', record: Readonly>): string => { + if (section === 'reports') return `${String(record['open_oracle_address'] ?? '')}:${String(record['report_id'] ?? '')}` + return String(record[section === 'auctions' ? 'auction_address' : 'game_address'] ?? '') +} + +export const operationsDetailRecordKey = (record: Readonly>): string => + `${String(record['block_hash'] ?? '')}:${String(record['tx_hash'] ?? '')}:${String(record['log_index'] ?? '')}:${String(record['event_name'] ?? record['semantic_event_kind'] ?? '')}` + +const approvalFieldDefinitions = [ + ['maxCumulativeDebtAttoEth', 'maximum cumulative debt', 'attoETH'], + ['maxDebtPerLiquidationAttoEth', 'maximum debt per liquidation', 'attoETH'], + ['reservedDebtAttoEth', 'reserved debt', 'attoETH'], + ['consumedDebtAttoEth', 'consumed debt', 'attoETH'], + ['releasedDebtAttoEth', 'released debt', 'attoETH'], + ['resultingAvailableDebtAttoEth', 'resulting available debt', 'attoETH'], + ['resultingReservedDebtAttoEth', 'resulting reserved debt', 'attoETH'], + ['resultingConsumedDebtAttoEth', 'resulting consumed debt', 'attoETH'], + ['previousNonce', 'previous nonce', ''], + ['newNonce', 'new nonce', ''], +] as const + +export const approvalTransitionFields = ( + data: Readonly>, +): Array<{ readonly label: string; readonly value: string; readonly unit: string }> => + approvalFieldDefinitions.flatMap(([key, label, unit]) => (typeof data[key] === 'string' ? [{ label, value: data[key], unit }] : [])) + +export const operationsLoadDisposition = ( + activeContext: string, + requestedContext: string, + live: boolean, + hasPaginationTarget: boolean, +): 'join' | 'queue' | 'supersede' => { + if (activeContext !== requestedContext) return 'supersede' + return live || hasPaginationTarget ? 'queue' : 'join' +} + +export type OperationsLoadState = { promise?: Promise; context?: string } + +export const runSerializedOperationsLoad = async ( + state: OperationsLoadState, + requestedContext: string, + live: boolean, + hasPaginationTarget: boolean, + currentContext: () => string, + supersede: () => void, + run: () => Promise, +): Promise => { + while (state.promise !== undefined) { + const active = state.promise + const disposition = operationsLoadDisposition(state.context ?? '', requestedContext, live, hasPaginationTarget) + if (disposition === 'supersede') supersede() + const activeResult = await active + if (disposition === 'join') return activeResult + if (currentContext() !== requestedContext) return false + } + const promise = run().finally(() => { + if (state.promise === promise) { + state.promise = undefined + state.context = undefined + } + }) + state.promise = promise + state.context = requestedContext + return await promise +} + +const canonicalEventPosition = (record: Readonly>, key: 'block_number' | 'transaction_index' | 'log_index'): bigint => { + const value = record[key] + return (typeof value === 'string' && /^\d+$/.test(value)) || (typeof value === 'number' && Number.isSafeInteger(value)) ? BigInt(value) : 0n +} + +export const compareCanonicalEventPosition = (left: Readonly>, right: Readonly>): number => { + const leftBlock = canonicalEventPosition(left, 'block_number') + const rightBlock = canonicalEventPosition(right, 'block_number') + if (leftBlock !== rightBlock) return leftBlock < rightBlock ? -1 : 1 + const leftTransaction = canonicalEventPosition(left, 'transaction_index') + const rightTransaction = canonicalEventPosition(right, 'transaction_index') + if (leftTransaction !== rightTransaction) return leftTransaction < rightTransaction ? -1 : 1 + const leftLog = canonicalEventPosition(left, 'log_index') + const rightLog = canonicalEventPosition(right, 'log_index') + return leftLog === rightLog ? 0 : leftLog < rightLog ? -1 : 1 +} + export const canonicalPageLimit = (targetCount: number, loadedCount: number, pageSize: number): number => targetCount > loadedCount ? Math.min(pageSize, targetCount - loadedCount) : pageSize @@ -327,7 +410,7 @@ export const indexerProgressEstimate = ( export const contractDeploymentStatus = (contract: ContractDeploymentRecord) => { if (contract.deployment_block !== null && contract.deployment_block !== undefined) return contract.deployment_block_exact === false - ? { label: `Code present at #${contract.deployment_block}`, tone: 'live' } + ? { label: `Deployed at or before #${contract.deployment_block}`, tone: 'live' } : { label: 'Deployed', tone: 'live' } if (contract.deployment_checked_block !== null && contract.deployment_checked_block !== undefined) return { label: `No code at #${contract.deployment_checked_block}`, tone: 'error' } @@ -335,7 +418,7 @@ export const contractDeploymentStatus = (contract: ContractDeploymentRecord) => } export const contractDeploymentTimestampLabel = (contract: ContractDeploymentRecord): string => - contract.deployment_block_exact === false ? 'Code present at' : 'Deployed at' + contract.deployment_block_exact === false ? 'Deployed at or before' : 'Deployed at' export const contractDeploymentBlockActionLabel = (contract: ContractDeploymentRecord): string => contract.deployment_block_exact === false ? 'Open search boundary block ↗' : 'Open deployment block ↗' diff --git a/augurScan/config/manifests/mainnet.json b/augurScan/config/manifests/mainnet.json index 3663e58e3..0f053542c 100644 --- a/augurScan/config/manifests/mainnet.json +++ b/augurScan/config/manifests/mainnet.json @@ -17,6 +17,7 @@ ["0x3A7b70613E86908a4494D3cBf627C62342c96990", "Security Pool Factory", "securityPoolFactory"], ["0xb077F2f7a41d52a0dBe03d1d3Ff7fa1ad7816505", "Escalation Game Proof Verifier", "escalationProofVerifier"], ["0x221657776846890989a759ba2973e427dff5c9bb", "Genesis REP", "reputationToken"], - ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "Wrapped Ether", "weth"] + ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "Wrapped Ether", "weth"], + ["0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "USD Coin", "usdc"] ] } diff --git a/augurScan/config/manifests/sepolia.json b/augurScan/config/manifests/sepolia.json index b8e092911..682478340 100644 --- a/augurScan/config/manifests/sepolia.json +++ b/augurScan/config/manifests/sepolia.json @@ -17,6 +17,7 @@ ["0x49De932c947343ADb87508280f99251A3b326283", "Security Pool Factory", "securityPoolFactory"], ["0xb077F2f7a41d52a0dBe03d1d3Ff7fa1ad7816505", "Escalation Game Proof Verifier", "escalationProofVerifier"], ["0x13CD80A6741A6349d2FeBDd65991108eCfD4f283", "Genesis REP", "reputationToken"], - ["0x65156FD21726b8efcB627fa38c506E3f3542F601", "Wrapped Ether", "weth"] + ["0x65156FD21726b8efcB627fa38c506E3f3542F601", "Wrapped Ether", "weth"], + ["0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "USD Coin", "usdc"] ] } diff --git a/augurScan/migrations/001_initial.sql b/augurScan/migrations/001_initial.sql deleted file mode 100644 index 4623a85f4..000000000 --- a/augurScan/migrations/001_initial.sql +++ /dev/null @@ -1,268 +0,0 @@ -CREATE TABLE IF NOT EXISTS networks ( - chain_id bigint PRIMARY KEY, - id text NOT NULL UNIQUE, - name text NOT NULL, - explorer_base_url text NOT NULL, - start_block bigint NOT NULL, - indexed_block bigint, - indexed_hash text, - indexed_timestamp timestamptz, - observed_block bigint, - finalized_block bigint, - phase text NOT NULL DEFAULT 'backfilling', - last_poll_at timestamptz, - last_error text, - updated_at timestamptz NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS blocks ( - chain_id bigint NOT NULL REFERENCES networks(chain_id), - number bigint NOT NULL, - hash text NOT NULL, - parent_hash text NOT NULL, - timestamp timestamptz NOT NULL, - canonical boolean NOT NULL DEFAULT true, - finalized boolean NOT NULL DEFAULT false, - ingested_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (chain_id, hash) -); -CREATE UNIQUE INDEX IF NOT EXISTS blocks_canonical_number ON blocks(chain_id, number) WHERE canonical; - -CREATE TABLE IF NOT EXISTS contracts ( - chain_id bigint NOT NULL REFERENCES networks(chain_id), - address text NOT NULL, - label text NOT NULL, - kind text NOT NULL, - provenance text NOT NULL, - discovery_block bigint, - discovery_tx_hash text, - canonical boolean NOT NULL DEFAULT true, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (chain_id, address) -); - -CREATE TABLE IF NOT EXISTS transactions ( - chain_id bigint NOT NULL REFERENCES networks(chain_id), - hash text NOT NULL, - block_hash text NOT NULL, - block_number bigint NOT NULL, - transaction_index integer NOT NULL, - from_address text NOT NULL, - to_address text, - value numeric(78, 0) NOT NULL, - input text NOT NULL, - status text, - gas_used numeric(78, 0), - receipt jsonb NOT NULL, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, hash) -); -CREATE INDEX IF NOT EXISTS transactions_block_order ON transactions(chain_id, block_number DESC, transaction_index DESC) WHERE canonical; -CREATE UNIQUE INDEX IF NOT EXISTS transactions_canonical_hash ON transactions(chain_id, hash) WHERE canonical; - -CREATE TABLE IF NOT EXISTS actions ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - contract_address text, - function_name text, - function_signature text, - arguments jsonb, - display_arguments jsonb, - argument_schema jsonb NOT NULL DEFAULT '[]'::jsonb, - decode_status text NOT NULL, - decode_error text, - summary text NOT NULL, - PRIMARY KEY (chain_id, block_hash, tx_hash), - FOREIGN KEY (chain_id, block_hash, tx_hash) REFERENCES transactions(chain_id, block_hash, hash) -); - -CREATE TABLE IF NOT EXISTS logs ( - chain_id bigint NOT NULL, - tx_hash text NOT NULL, - block_hash text NOT NULL, - block_number bigint NOT NULL, - transaction_index integer NOT NULL, - log_index integer NOT NULL, - emitter_address text NOT NULL, - topics jsonb NOT NULL, - data text NOT NULL, - event_name text, - event_signature text, - arguments jsonb, - display_arguments jsonb, - argument_schema jsonb NOT NULL DEFAULT '[]'::jsonb, - decode_status text NOT NULL, - decode_error text, - summary text NOT NULL, - canonical boolean NOT NULL DEFAULT true, - finalized boolean NOT NULL DEFAULT false, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index), - FOREIGN KEY (chain_id, block_hash, tx_hash) REFERENCES transactions(chain_id, block_hash, hash) -); -CREATE INDEX IF NOT EXISTS logs_feed ON logs(chain_id, block_number DESC, transaction_index DESC, log_index DESC) WHERE canonical; -CREATE INDEX IF NOT EXISTS logs_emitter ON logs(chain_id, emitter_address, block_number DESC) WHERE canonical; -CREATE INDEX IF NOT EXISTS logs_event ON logs(chain_id, event_name, block_number DESC) WHERE canonical; -CREATE UNIQUE INDEX IF NOT EXISTS logs_canonical_position ON logs(chain_id, tx_hash, log_index) WHERE canonical; - -CREATE TABLE IF NOT EXISTS contract_discoveries ( - chain_id bigint NOT NULL REFERENCES networks(chain_id), - address text NOT NULL, - block_hash text NOT NULL, - block_number bigint NOT NULL, - tx_hash text NOT NULL, - label text NOT NULL, - kind text NOT NULL, - provenance text NOT NULL, - canonical boolean NOT NULL DEFAULT true, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (chain_id, address, block_hash, tx_hash) -); - -CREATE TABLE IF NOT EXISTS token_metadata ( - chain_id bigint NOT NULL, - address text NOT NULL, - block_hash text NOT NULL, - name text, - symbol text, - decimals integer, - read_error text, - read_block bigint NOT NULL, - canonical boolean NOT NULL DEFAULT true, - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (chain_id, address, block_hash) -); -CREATE UNIQUE INDEX IF NOT EXISTS token_metadata_canonical_address ON token_metadata(chain_id, address) WHERE canonical; - -CREATE TABLE IF NOT EXISTS questions ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - question_id numeric(78, 0) NOT NULL, - created_timestamp timestamptz NOT NULL, - title text NOT NULL, - description text NOT NULL, - start_time timestamptz NOT NULL, - end_time timestamptz NOT NULL, - num_ticks numeric(78, 0) NOT NULL, - display_value_min numeric(78, 0) NOT NULL, - display_value_max numeric(78, 0) NOT NULL, - answer_unit text NOT NULL, - outcome_options jsonb NOT NULL, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, question_id), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE UNIQUE INDEX IF NOT EXISTS questions_canonical_id ON questions(chain_id, question_id) WHERE canonical; - -CREATE TABLE IF NOT EXISTS pools ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - pool_address text NOT NULL, - parent_address text NOT NULL, - universe_id numeric(78, 0) NOT NULL, - question_id numeric(78, 0) NOT NULL, - truth_auction_address text NOT NULL, - coordinator_address text NOT NULL, - share_token_address text NOT NULL, - security_multiplier_bps numeric(78, 0) NOT NULL, - initial_priority_fee_atto_eth_per_gas numeric(78, 0) NOT NULL, - initial_retention_rate numeric(78, 0) NOT NULL, - initial_settlement_collateral_atto_eth numeric(78, 0) NOT NULL, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, pool_address), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE UNIQUE INDEX IF NOT EXISTS pools_canonical_address ON pools(chain_id, pool_address) WHERE canonical; -CREATE INDEX IF NOT EXISTS pools_question ON pools(chain_id, question_id) WHERE canonical; -CREATE INDEX IF NOT EXISTS pools_universe ON pools(chain_id, universe_id) WHERE canonical; - -CREATE TABLE IF NOT EXISTS pool_snapshots ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - pool_address text NOT NULL, - reason integer NOT NULL, - vault_address text NOT NULL, - settlement_collateral_atto_eth numeric(78, 0) NOT NULL, - total_capacity_ownership_atto_rep numeric(78, 0) NOT NULL, - fee_eligible_capacity_ownership_atto_rep numeric(78, 0) NOT NULL, - total_claimable_vault_fees_atto_eth numeric(78, 0) NOT NULL, - unallocated_accrued_fees_atto_eth numeric(78, 0) NOT NULL, - fee_index numeric(78, 0) NOT NULL, - fee_index_remainder numeric(78, 0) NOT NULL, - total_fees_owed_remainder numeric(78, 0) NOT NULL, - uncheckpointed_fee_eligible_capacity_ownership_atto_rep numeric(78, 0) NOT NULL, - last_updated_fee_accumulator timestamptz NOT NULL, - current_retention_rate numeric(78, 0) NOT NULL, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, pool_address), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE INDEX IF NOT EXISTS pool_snapshots_history ON pool_snapshots(chain_id, pool_address, block_number DESC, log_index DESC) WHERE canonical; - -CREATE TABLE IF NOT EXISTS pool_state_events ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - pool_address text NOT NULL, - event_name text NOT NULL, - state jsonb NOT NULL, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, pool_address), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE INDEX IF NOT EXISTS pool_state_events_history ON pool_state_events(chain_id, pool_address, block_number DESC, log_index DESC) WHERE canonical; - -CREATE TABLE IF NOT EXISTS vault_snapshots ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - pool_address text NOT NULL, - vault_address text NOT NULL, - rep_backing_units numeric(78, 0) NOT NULL, - capacity_ownership_atto_rep numeric(78, 0) NOT NULL, - claimable_fees_atto_eth numeric(78, 0) NOT NULL, - fee_index numeric(78, 0) NOT NULL, - vault_fee_remainder numeric(78, 0) NOT NULL, - resulting_total_rep_backing_units numeric(78, 0) NOT NULL, - resulting_fee_eligible_capacity_ownership_atto_rep numeric(78, 0) NOT NULL, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, pool_address, vault_address), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE INDEX IF NOT EXISTS vault_snapshots_history ON vault_snapshots(chain_id, vault_address, pool_address, block_number DESC, log_index DESC) WHERE canonical; - -CREATE TABLE IF NOT EXISTS universe_events ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - universe_id numeric(78, 0) NOT NULL, - event_name text NOT NULL, - parent_universe_id numeric(78, 0), - forking_outcome_index numeric(78, 0), - reputation_token_address text, - fork_question_id numeric(78, 0), - fork_time timestamptz, - forker_address text, - fork_threshold_atto_rep numeric(78, 0), - migration_rep_balance_atto_rep numeric(78, 0), - theoretical_supply_atto_rep numeric(78, 0), - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, universe_id), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE INDEX IF NOT EXISTS universe_events_history ON universe_events(chain_id, universe_id, block_number DESC, log_index DESC) WHERE canonical; diff --git a/augurScan/migrations/002_reliability.sql b/augurScan/migrations/002_reliability.sql deleted file mode 100644 index e27390f47..000000000 --- a/augurScan/migrations/002_reliability.sql +++ /dev/null @@ -1,49 +0,0 @@ -ALTER TABLE networks - ADD COLUMN IF NOT EXISTS last_success_at timestamptz, - ADD COLUMN IF NOT EXISTS failure_started_at timestamptz, - ADD COLUMN IF NOT EXISTS consecutive_failures integer NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS next_retry_at timestamptz, - ADD COLUMN IF NOT EXISTS last_reorg_at timestamptz, - ADD COLUMN IF NOT EXISTS last_reorg_depth bigint; - -ALTER TABLE networks DROP CONSTRAINT IF EXISTS networks_checkpoint_pair; -ALTER TABLE networks ADD CONSTRAINT networks_checkpoint_pair CHECK ((indexed_block IS NULL) = (indexed_hash IS NULL)); -ALTER TABLE networks DROP CONSTRAINT IF EXISTS networks_start_block_non_negative; -ALTER TABLE networks ADD CONSTRAINT networks_start_block_non_negative CHECK (start_block >= 0); -ALTER TABLE networks DROP CONSTRAINT IF EXISTS networks_failure_count_non_negative; -ALTER TABLE networks ADD CONSTRAINT networks_failure_count_non_negative CHECK (consecutive_failures >= 0); -ALTER TABLE networks DROP CONSTRAINT IF EXISTS networks_reorg_depth_non_negative; -ALTER TABLE networks ADD CONSTRAINT networks_reorg_depth_non_negative CHECK (last_reorg_depth IS NULL OR last_reorg_depth >= 0); - -CREATE TABLE IF NOT EXISTS live_events ( - id bigserial PRIMARY KEY, - event text NOT NULL CHECK (event IN ('block', 'reorg', 'status')), - payload jsonb NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() -); -CREATE INDEX IF NOT EXISTS live_events_created_at ON live_events(created_at); - -CREATE TABLE IF NOT EXISTS live_event_state ( - singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), - pruned_through_id bigint NOT NULL DEFAULT 0 CHECK (pruned_through_id >= 0), - updated_at timestamptz NOT NULL DEFAULT now() -); -INSERT INTO live_event_state (singleton) VALUES (true) ON CONFLICT (singleton) DO NOTHING; - -CREATE UNIQUE INDEX IF NOT EXISTS blocks_chain_hash_number ON blocks(chain_id, hash, number); - -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'transactions_block_position_fk') THEN - ALTER TABLE transactions ADD CONSTRAINT transactions_block_position_fk - FOREIGN KEY (chain_id, block_hash, block_number) REFERENCES blocks(chain_id, hash, number); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'contract_discoveries_block_position_fk') THEN - ALTER TABLE contract_discoveries ADD CONSTRAINT contract_discoveries_block_position_fk - FOREIGN KEY (chain_id, block_hash, block_number) REFERENCES blocks(chain_id, hash, number); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'token_metadata_block_position_fk') THEN - ALTER TABLE token_metadata ADD CONSTRAINT token_metadata_block_position_fk - FOREIGN KEY (chain_id, block_hash, read_block) REFERENCES blocks(chain_id, hash, number); - END IF; -END $$; diff --git a/augurScan/migrations/003_capacity_and_rich_list.sql b/augurScan/migrations/003_capacity_and_rich_list.sql deleted file mode 100644 index ef044eab8..000000000 --- a/augurScan/migrations/003_capacity_and_rich_list.sql +++ /dev/null @@ -1,46 +0,0 @@ -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'pool_snapshots' AND column_name = 'total_coverage_commitment_atto_eth') THEN - IF EXISTS (SELECT 1 FROM networks WHERE indexed_block IS NOT NULL) OR EXISTS (SELECT 1 FROM blocks LIMIT 1) THEN - RAISE EXCEPTION 'augurScan cannot upgrade indexed pre-003 history: legacy ETH coverage projections and missing rich-list activity require rebuilding the database from the verified network start block'; - END IF; - ALTER TABLE pool_snapshots RENAME COLUMN total_coverage_commitment_atto_eth TO total_capacity_ownership_atto_rep; - ALTER TABLE pool_snapshots RENAME COLUMN fee_eligible_coverage_commitment_atto_eth TO fee_eligible_capacity_ownership_atto_rep; - ALTER TABLE pool_snapshots RENAME COLUMN uncheckpointed_fee_eligible_coverage_atto_eth TO uncheckpointed_fee_eligible_capacity_ownership_atto_rep; - END IF; - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'vault_snapshots' AND column_name = 'coverage_commitment_atto_eth') THEN - ALTER TABLE vault_snapshots RENAME COLUMN coverage_commitment_atto_eth TO capacity_ownership_atto_rep; - ALTER TABLE vault_snapshots RENAME COLUMN resulting_fee_eligible_coverage_atto_eth TO resulting_fee_eligible_capacity_ownership_atto_rep; - END IF; -END $$; - -CREATE TABLE IF NOT EXISTS address_activity ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - block_number bigint NOT NULL, - tx_hash text NOT NULL, - address text NOT NULL, - pool_address text NOT NULL, - role text NOT NULL CHECK (role IN ('sender', 'referenced')), - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, address, pool_address), - FOREIGN KEY (chain_id, block_hash, tx_hash) REFERENCES transactions(chain_id, block_hash, hash), - FOREIGN KEY (chain_id, block_hash, block_number) REFERENCES blocks(chain_id, hash, number) -); -CREATE INDEX IF NOT EXISTS address_activity_address ON address_activity(chain_id, address, block_number DESC) WHERE canonical; -CREATE INDEX IF NOT EXISTS address_activity_pool ON address_activity(chain_id, pool_address, address) WHERE canonical; - -CREATE TABLE IF NOT EXISTS address_balance_snapshots ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - block_number bigint NOT NULL, - address text NOT NULL, - asset_address text NOT NULL, - asset_kind text NOT NULL CHECK (asset_kind IN ('native', 'rep', 'weth')), - balance numeric(78, 0) NOT NULL CHECK (balance >= 0), - canonical boolean NOT NULL DEFAULT true, - observed_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (chain_id, block_hash, address, asset_address), - FOREIGN KEY (chain_id, block_hash, block_number) REFERENCES blocks(chain_id, hash, number) -); -CREATE INDEX IF NOT EXISTS address_balances_latest ON address_balance_snapshots(chain_id, address, asset_address, block_number DESC) WHERE canonical; diff --git a/augurScan/migrations/004_contract_deployments.sql b/augurScan/migrations/004_contract_deployments.sql deleted file mode 100644 index ac183ee87..000000000 --- a/augurScan/migrations/004_contract_deployments.sql +++ /dev/null @@ -1,15 +0,0 @@ -ALTER TABLE contracts - ADD COLUMN IF NOT EXISTS deployment_block bigint, - ADD COLUMN IF NOT EXISTS deployment_timestamp timestamptz, - ADD COLUMN IF NOT EXISTS deployment_block_exact boolean, - ADD COLUMN IF NOT EXISTS deployment_checked_block bigint; - -ALTER TABLE contracts DROP CONSTRAINT IF EXISTS contracts_deployment_block_non_negative; -ALTER TABLE contracts ADD CONSTRAINT contracts_deployment_block_non_negative CHECK (deployment_block IS NULL OR deployment_block >= 0); -ALTER TABLE contracts DROP CONSTRAINT IF EXISTS contracts_deployment_checked_block_non_negative; -ALTER TABLE contracts ADD CONSTRAINT contracts_deployment_checked_block_non_negative CHECK (deployment_checked_block IS NULL OR deployment_checked_block >= 0); -ALTER TABLE contracts DROP CONSTRAINT IF EXISTS contracts_deployment_fields_complete; -ALTER TABLE contracts ADD CONSTRAINT contracts_deployment_fields_complete CHECK ( - (deployment_block IS NULL AND deployment_timestamp IS NULL AND deployment_block_exact IS NULL) - OR (deployment_block IS NOT NULL AND deployment_timestamp IS NOT NULL AND deployment_block_exact IS NOT NULL) -); diff --git a/augurScan/migrations/005_log_scan_cursors.sql b/augurScan/migrations/005_log_scan_cursors.sql deleted file mode 100644 index 86905a0ca..000000000 --- a/augurScan/migrations/005_log_scan_cursors.sql +++ /dev/null @@ -1,24 +0,0 @@ -DO $$ -BEGIN - IF EXISTS (SELECT 1 FROM networks WHERE indexed_block IS NOT NULL) - OR EXISTS (SELECT 1 FROM blocks LIMIT 1) - THEN - RAISE EXCEPTION 'augurScan range indexing requires a fresh database; rebuild PostgreSQL from each network start block'; - END IF; -END $$; - -ALTER TABLE transactions ALTER COLUMN status SET NOT NULL; -ALTER TABLE transactions ADD CONSTRAINT transactions_successful_log_evidence CHECK (status = 'success'); - -CREATE TABLE log_scan_cursors ( - chain_id bigint NOT NULL REFERENCES networks(chain_id), - contract_address text NOT NULL, - start_block bigint NOT NULL CHECK (start_block >= 0), - last_retrieved_block bigint NOT NULL, - updated_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (chain_id, contract_address), - CHECK (last_retrieved_block >= start_block - 1) -); - -CREATE INDEX log_scan_cursors_progress - ON log_scan_cursors(chain_id, last_retrieved_block, contract_address); diff --git a/augurScan/migrations/006_price_history.sql b/augurScan/migrations/006_price_history.sql deleted file mode 100644 index 79c74e7c2..000000000 --- a/augurScan/migrations/006_price_history.sql +++ /dev/null @@ -1,79 +0,0 @@ -CREATE TABLE IF NOT EXISTS amm_markets ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - pair_address text NOT NULL, - pool_address text NOT NULL, - share_token_address text NOT NULL, - universe_id numeric(78, 0) NOT NULL, - fee_bps numeric(78, 0) NOT NULL, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, pair_address), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE UNIQUE INDEX IF NOT EXISTS amm_markets_canonical_pair ON amm_markets(chain_id, pair_address) WHERE canonical; -CREATE UNIQUE INDEX IF NOT EXISTS amm_markets_canonical_pool ON amm_markets(chain_id, pool_address) WHERE canonical; - -CREATE TABLE IF NOT EXISTS amm_price_snapshots ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - pair_address text NOT NULL, - yes_reserve_atto_shares numeric(78, 0) NOT NULL, - no_reserve_atto_shares numeric(78, 0) NOT NULL, - conditional_yes_bps numeric(78, 0) NOT NULL CHECK (conditional_yes_bps BETWEEN 0 AND 10000), - conditional_no_bps numeric(78, 0) NOT NULL CHECK (conditional_no_bps BETWEEN 0 AND 10000), - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, pair_address), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index), - CHECK (yes_reserve_atto_shares + no_reserve_atto_shares > 0), - CHECK (conditional_yes_bps + conditional_no_bps = 10000) -); -CREATE INDEX IF NOT EXISTS amm_price_snapshots_history ON amm_price_snapshots(chain_id, pair_address, block_number DESC, log_index DESC) WHERE canonical; - -CREATE TABLE IF NOT EXISTS rep_eth_price_snapshots ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - coordinator_address text NOT NULL, - event_name text NOT NULL CHECK (event_name IN ('RepEthPriceSet', 'PriceReported')), - report_id numeric(78, 0), - rep_per_eth_1e18 numeric(78, 0) NOT NULL CHECK (rep_per_eth_1e18 >= 0), - settlement_timestamp timestamptz, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, coordinator_address), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index) -); -CREATE INDEX IF NOT EXISTS rep_eth_price_snapshots_history ON rep_eth_price_snapshots(chain_id, coordinator_address, block_number DESC, log_index DESC) WHERE canonical; - --- Preserve price history already decoded by earlier augurScan versions. AMM logs need --- the new ABI and factory seed, so operators must rebuild to recover pre-006 AMM history. -INSERT INTO rep_eth_price_snapshots ( - chain_id, block_hash, tx_hash, log_index, block_number, coordinator_address, - event_name, report_id, rep_per_eth_1e18, settlement_timestamp, canonical -) -SELECT - chain_id, block_hash, tx_hash, log_index, block_number, emitter_address, - event_name, - CASE WHEN event_name = 'PriceReported' THEN (arguments ->> 'reportId')::numeric ELSE NULL END, - (arguments ->> 'price')::numeric, - CASE - WHEN event_name = 'PriceReported' THEN to_timestamp((arguments ->> 'lastSettlementTimestamp')::numeric) - ELSE NULL - END, - canonical -FROM logs -WHERE event_name IN ('RepEthPriceSet', 'PriceReported') - AND decode_status = 'decoded' - AND arguments ->> 'price' ~ '^[0-9]+$' - AND (event_name <> 'PriceReported' OR ( - arguments ->> 'reportId' ~ '^[0-9]+$' - AND arguments ->> 'lastSettlementTimestamp' ~ '^[0-9]+$' - )) -ON CONFLICT (chain_id, block_hash, tx_hash, log_index, coordinator_address) DO NOTHING; diff --git a/augurScan/migrations/007_uniswap_price_history.sql b/augurScan/migrations/007_uniswap_price_history.sql deleted file mode 100644 index 7361015d2..000000000 --- a/augurScan/migrations/007_uniswap_price_history.sql +++ /dev/null @@ -1,59 +0,0 @@ -CREATE TABLE IF NOT EXISTS uniswap_rep_eth_markets ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - venue text NOT NULL CHECK (venue IN ('v2', 'v3', 'v4')), - market_id text NOT NULL, - contract_address text NOT NULL, - token0_address text NOT NULL, - token1_address text NOT NULL, - fee_hundredths_bip numeric(78, 0) NOT NULL, - tick_spacing integer, - hooks_address text, - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, market_id), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index), - CHECK (token0_address <> token1_address), - CHECK ((venue = 'v2' AND tick_spacing IS NULL AND hooks_address IS NULL) OR venue <> 'v2'), - CHECK ((venue = 'v4' AND hooks_address IS NOT NULL) OR (venue <> 'v4' AND hooks_address IS NULL)), - CHECK ( - venue <> 'v4' - OR (fee_hundredths_bip = 100 AND tick_spacing = 1) - OR (fee_hundredths_bip = 500 AND tick_spacing = 10) - OR (fee_hundredths_bip = 3000 AND tick_spacing = 60) - OR (fee_hundredths_bip = 10000 AND tick_spacing = 200) - ) -); -CREATE UNIQUE INDEX IF NOT EXISTS uniswap_rep_eth_markets_canonical_id - ON uniswap_rep_eth_markets(chain_id, venue, market_id) WHERE canonical; -CREATE INDEX IF NOT EXISTS uniswap_rep_eth_markets_tokens - ON uniswap_rep_eth_markets(chain_id, token0_address, token1_address) WHERE canonical; - -CREATE TABLE IF NOT EXISTS uniswap_rep_eth_price_observations ( - chain_id bigint NOT NULL, - block_hash text NOT NULL, - tx_hash text NOT NULL, - log_index integer NOT NULL, - block_number bigint NOT NULL, - venue text NOT NULL CHECK (venue IN ('v2', 'v3', 'v4')), - market_id text NOT NULL, - event_name text NOT NULL CHECK (event_name IN ('Initialize', 'Swap', 'Sync')), - reserve0 numeric(78, 0), - reserve1 numeric(78, 0), - sqrt_price_x96 numeric(78, 0), - canonical boolean NOT NULL DEFAULT true, - PRIMARY KEY (chain_id, block_hash, tx_hash, log_index, market_id), - FOREIGN KEY (chain_id, block_hash, tx_hash, log_index) REFERENCES logs(chain_id, block_hash, tx_hash, log_index), - CHECK ( - (venue = 'v2' AND event_name = 'Sync' AND reserve0 IS NOT NULL AND reserve1 IS NOT NULL AND sqrt_price_x96 IS NULL) - OR - (venue IN ('v3', 'v4') AND event_name IN ('Initialize', 'Swap') AND reserve0 IS NULL AND reserve1 IS NULL AND sqrt_price_x96 IS NOT NULL) - ) -); -CREATE INDEX IF NOT EXISTS uniswap_rep_eth_price_observations_history - ON uniswap_rep_eth_price_observations(chain_id, venue, market_id, block_number DESC, log_index DESC) WHERE canonical; - --- Factory and pool ABIs become known in this release. Rebuild an existing database to --- recover Uniswap events that predate this migration; future observations index normally. diff --git a/augurScan/package.json b/augurScan/package.json index efb8bb0c7..a1bc1b225 100644 --- a/augurScan/package.json +++ b/augurScan/package.json @@ -7,11 +7,11 @@ "build": "bun scripts/build-browser.ts", "dev": "bun scripts/dev.ts", "start": "bun run build && bun src/server.ts", - "migrate": "bun src/migrate.ts", + "schema:init": "bun src/schema.ts", "metadata:snapshot": "bun scripts/snapshot-project-metadata.ts", "qa:serve": "bun run build && bun scripts/serve-demo.ts", "test": "bun test", - "test:unit": "bun test tests/api-validation.test.ts tests/api.test.ts tests/chart-values.test.ts tests/config.test.ts tests/demo-fixtures.test.ts tests/docker-packaging.test.ts tests/http.test.ts tests/indexer-lifecycle.test.ts tests/limits.test.ts tests/live-ui.test.ts tests/live.test.ts tests/metadata.test.ts tests/projections.test.ts tests/time.test.ts", + "test:unit": "bun test tests/api-validation.test.ts tests/api.test.ts tests/chart-values.test.ts tests/config.test.ts tests/demo-fixtures.test.ts tests/docker-packaging.test.ts tests/http.test.ts tests/indexer-lifecycle.test.ts tests/limits.test.ts tests/live-ui.test.ts tests/live.test.ts tests/metadata.test.ts tests/operations.test.ts tests/projections.test.ts tests/schema.test.ts tests/time.test.ts", "test:integration": "bun test tests/postgres.integration.test.ts", "typecheck": "cd .. && bun install --frozen-lockfile && cd augurScan && bun x tsc --noEmit", "check:ethereum-imports": "bun scripts/check-ethereum-imports.ts", diff --git a/augurScan/public/index.html b/augurScan/public/index.html index 8423fb458..54939093a 100644 --- a/augurScan/public/index.html +++ b/augurScan/public/index.html @@ -22,6 +22,7 @@

augurScan