diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e25ddea --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# The Graph gateway credential. Prefer sending this as an Authorization bearer +# token instead of embedding it in a gateway URL. +GRAPH_API_KEY= + +# Ordered Base mainnet RPC endpoints passed to Nuthatch as repeatable --rpc +# arguments by nuthatch.service. Production loads them from the root-owned +# /etc/default/nuthatch; nuthatch.toml does not interpolate environment +# variables. Never commit keyed URLs or tokens. Use three independent providers +# and probe each endpoint's eth_getLogs range cap before deploying it. All three +# values are required by the documented nuthatch.service unit. +BASE_RPC_URL_PRIMARY= +BASE_RPC_URL_SECONDARY= +BASE_RPC_URL_TERTIARY= + +# Private URL of the Nuthatch HTTP API, without a trailing slash. When +# DeepTrace and Nuthatch are co-located, use loopback so normal operation does +# not depend on Tailscale or public DNS. Never expose this URL to MCP users. +# Production example: http://127.0.0.1:8288 +NUTHATCH_BASE_URL= + +# Reserved for authenticated admin access if --no-admin is removed. +# Production currently disables the admin UI with --no-admin. +NUTHATCH_ADMIN_TOKEN= + +# Gateway fixed-window rate limit: max requests per key (default: 30, maximum: 300). +DEEPTRACE_RATE_LIMIT_MAX_REQUESTS=30 + +# Gateway fixed-window duration in milliseconds (default: 60000, maximum: 3600000). +DEEPTRACE_RATE_LIMIT_WINDOW_MS=60000 + +# Per-source adapter timeout in milliseconds (default: 5000, maximum: 8000). +DEEPTRACE_SOURCE_TIMEOUT_MS=5000 + +# Where issued per-client bearer tokens are recorded, as SHA-256 digests only. +# Must live outside the deployed tree: the deploy rsyncs it with --delete, so an +# in-repo path is wiped on every release. Production: /var/lib/deeptrace/tokens.json +DEEPTRACE_TOKEN_STORE= + +# Deprecated shared bearer token, kept only so pre-migration clients keep +# working. One leak exposes every client and cannot be revoked selectively. +# Leave it empty: clients mint their own token at /auth or through OAuth. When +# set it must be at least 32 characters, or startup fails. +DEEPTRACE_HTTP_TOKEN= + +# Inactive MCP sessions expire after this many milliseconds (default: 1800000). +DEEPTRACE_HTTP_SESSION_IDLE_TIMEOUT_MS=1800000 + +# How often inactive MCP sessions are reaped (default: 60000). +DEEPTRACE_HTTP_SESSION_SWEEP_INTERVAL_MS=60000 diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..da941f7 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,41 @@ +name: code quality + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: setup node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: install dependencies + run: npm ci + + - name: check formatting + run: npm run format:check + + - name: lint code + run: npm run lint + + - name: check types + run: npm run typecheck + + - name: run unit tests + run: npm test + + - name: build project + run: npm run build diff --git a/.github/workflows/nuthatch-check.yml b/.github/workflows/nuthatch-check.yml new file mode 100644 index 0000000..7bd38c2 --- /dev/null +++ b/.github/workflows/nuthatch-check.yml @@ -0,0 +1,53 @@ +name: nuthatch check + +on: + push: + paths: + - "nest/**" + - ".github/workflows/nuthatch-check.yml" + pull_request: + paths: + - "nest/**" + - ".github/workflows/nuthatch-check.yml" + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: validate nest structure + run: | + test -f nest/nuthatch.toml || { echo "nest/nuthatch.toml missing"; exit 1; } + test -f nest/schema.json || { echo "nest/schema.json missing"; exit 1; } + test -d nest/abis || { echo "nest/abis/ missing"; exit 1; } + test -d nest/views || { echo "nest/views/ missing"; exit 1; } + test -d nest/checks || { echo "nest/checks/ missing"; exit 1; } + test -d nest/checks/expected || { echo "nest/checks/expected/ missing"; exit 1; } + + - name: validate view SQL exists + run: | + for view in pool_swap_freshness pool_swap_search wallet_swap_activity; do + test -f "nest/views/${view}.sql" || { echo "missing nest/views/${view}.sql"; exit 1; } + done + + - name: validate check SQL files exist + run: | + for check in view_shape latest_swap_identity recent_count_24h provenance_not_null decimal_columns hot_sealed_no_duplicates swap_search_parity wallet_activity_parity; do + test -f "nest/checks/${check}.sql" || { echo "missing nest/checks/${check}.sql"; exit 1; } + test -f "nest/checks/expected/${check}.json" || { echo "missing nest/checks/expected/${check}.json"; exit 1; } + done + + - name: validate no secrets in nest + run: | + if grep -rn "GRAPH_API_KEY\|Bearer \|api_key=\|NUTHATCH_ADMIN_TOKEN" nest/; then + echo "Secret found in nest/" + exit 1 + fi + echo "No secrets found in nest/" diff --git a/.gitignore b/.gitignore index c246668..64cd025 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ .env.* !.env.example +# Local planning docs — working notes, not part of the deliverable +/DATA_PIPE_PLAN.md +/docs/M*_PLAN.md + # TypeScript / Node node_modules/ dist/ @@ -24,3 +28,5 @@ coverage/ .yarn/cache/ .yarn/unplugged/ .pnp.* +/DEEPTRACE_HANDOFF.md +/DEV_PLAN.md diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b71d25a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +package-lock.json +*.md +LICENSE +src/http/public/index.html diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..90abee2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/PLAN.md b/PLAN.md index 2cebefd..13c2efa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,12 +1,13 @@ -# DeepTrace — Product Direction and MVP-0 Plan +# DeepTrace — Product Direction and Three-Tool MVP Plan DeepTrace is a read-only Graph Research MCP for builders and AI agents. It combines comparable protocol data from Messari Standardized Subgraphs with focused custom indexes from Nuthatch and returns compact, verifiable results. This file is the shared product and implementation reference: -* **Global Product Goals** describe the intended DeepTrace product. -* **MVP-0** is the only current implementation commitment. -* **Next Milestones** become active only after MVP-0 meets its definition of done. +- **Global Product Goals** describe the intended DeepTrace product. +- The **three-tool MVP** is the current implementation commitment: + `compare_pools`, `compare_lending_markets`, and `find_large_swaps`. +- Wallet research, protocol DEX metrics, broader lending, and expansion remain post-MVP. ## Global Product Goals @@ -15,67 +16,100 @@ DeepTrace supports four research workflows: 1. **Wallet Research** — supported DeFi positions, swaps, protocol usage, assets and counterparties. 2. **DEX Metrics** — TVL, volume, fees, revenue and usage across three or four standardized DEX deployments. 3. **Pool Comparison** — the same token pair compared across selected protocols and chains. -4. **Large Swap Search** — recent swaps above a user-provided USD threshold. +4. **Large Swap Search** — recent swaps above a user-provided token-denominated + human-unit threshold, without required USD pricing. The first users are builders integrating on-chain research into applications and AI agents that need a small, typed tool surface. -## Current Build Target — MVP-0 +## Current Build Target — Three-Tool MVP -The first implementation is one complete vertical slice: +The MVP public surface is implemented: ```text compare_pools +compare_lending_markets +find_large_swaps ``` -MVP-0 compares one token pair across three DEX deployments on one chain. - -It must: +`get_dex_metrics` and `research_wallet` are post-MVP. The server registers only +implemented tools. -* query the same compatible Standardized DEX pattern across all three deployments; -* query one Nuthatch source for fresh data from a selected pool; -* normalize the results into one `PoolComparisonRecord`; -* return TVL, volume and fees for a fixed time window; -* rank the pools by one requested metric; -* return coverage, freshness and provenance; -* preserve successful results when one source is unavailable; -* add grounded `ai_reasoning` only after the structured result is verified; -* expose the flow through MCP and the single `SKILL.md`. +### Tools 1–2 — Pool Comparison and Lending Markets (Messari standardized) -### Scope to Lock Before Coding +The Graph-backed vertical slice: -| Item | MVP-0 value | -| :--- | :--- | -| Chain | `TBD` — exactly one | -| Token pair | `TBD` — exactly one canonical pair | -| Standardized DEX deployments | `TBD` — exactly three verified live deployments | -| Nuthatch contracts/views | `TBD` — one pool or a small verified set | -| Time windows | `24h` and `7d` | -| Initial metrics | TVL, volume and fees | -| Ranking metric | `TBD` from the initial metrics | -| USD price source | `TBD` — one documented source | -| Public tool implemented | `compare_pools` | - -Implementation starts only after every `TBD` in this table is resolved. +```text +compare_pools +compare_lending_markets +``` -### MVP-0 Definition of Done +MVP compares one WETH/USDC pair across two Uniswap V3 fee tiers on Base, and +one USDC lending market across three Base lending protocols. Both tools read +Messari standardized subgraphs, so one schema family covers a DEX AMM and three +lending protocols. -MVP-0 is complete when: +It must: -* one live request returns comparable records for three pools; -* at least one returned fact depends on Nuthatch; -* repeated requests over the same source blocks are deterministic; -* one unavailable source produces a partial result rather than total failure; -* every metric identifies its source and time window; -* AI reasoning references only returned facts and provenance IDs; -* the complete flow can be demonstrated in under two minutes. +- query the same compatible Standardized DEX pattern across both pool bindings; +- query one shared Standardized Lending pattern across all three lending protocols; +- query one Nuthatch source for fresh data from a selected pool; +- normalize the results into one `PoolComparisonRecord`; +- return TVL, volume and fees for a fixed time window; +- rank the pools by one requested metric; +- return coverage, freshness and provenance; +- preserve successful results when one source is unavailable; +- return verified structured data only (no internal model call); +- expose the flow through MCP and the single `SKILL.md` for client-side presentation. -## Next Milestones +### Scope to Lock Before Coding -1. **Large Swap Search** — reuse the Nuthatch swap path and add explicit USD-threshold filtering. -2. **DEX Metrics** — expose protocol-level aggregates using the existing standardized adapters. -3. **Wallet Research** — add supported positions, swaps, assets, protocol usage and counterparties. -4. **Lending and DeFi Positions** — add selected standardized lending deployments and `DeFiPosition`. -5. **Expansion** — additional chains, deployments, contract discovery and vault/ERC-4626 positions. +| Item | MVP value | +| :------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Chain | Base (`8453`) | +| Token pair | WETH `0x4200000000000000000000000000000000000006` / native USDC `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` | +| Standardized DEX deployment | Messari Uniswap V3 Base `QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh` (schema `4.0.1`), bound twice: WETH/USDC 0.3% `0x6c561b44…` and 0.05% `0xd0b53d92…` — see `docs/source-scope.md` | +| Standardized lending deployments | Messari Aave v3 `Qmb5j4tE5d…`, Seamless `QmPSmTkJPS…`, Moonwell `QmeE6TgfRm…`, all on the USDC market | +| Nuthatch contracts/views | Uniswap V3 WETH/USDC 0.3% `0x6c561b44…` (freshness and large-swap search) | +| Time windows | `24h` and `7d` | +| Initial metrics | TVL, volume and fees for pools; TVL, deposits, borrows and variable rates for lending markets | +| Ranking metric | `volume_usd` by default for pools, `tvl_usd` for lending markets; the other metrics are selectable | +| Top-N | Default and maximum `3` | +| USD price source | Source-reported USD values only; no repricing in the MVP | +| Public tools implemented | `compare_pools`, `compare_lending_markets`, `find_large_swaps` | + +Base has exactly one healthy Messari `dex-amm` deployment, so DEX breadth comes from +comparing two fee tiers of that one standardized source rather than two protocols. +Cross-protocol reuse of the standardized schema is demonstrated by +`compare_lending_markets`, where one query template serves three unrelated lending +protocols. See `docs/source-scope.md`. +Core policy values are executable constants in `src/policy/m0.ts`. + +### MVP Definition of Done + +The three-tool MVP is complete when: + +- one live request returns comparable records for both locked pools, and one returns comparable records for all three lending markets; +- one live `find_large_swaps` request returns a stable page for the locked pool with an exact WETH or USDC human-unit threshold; +- at least one returned fact depends on Nuthatch; +- repeated requests over the same source blocks are deterministic; +- one unavailable Graph or freshness source produces a partial result rather than total failure; +- every metric identifies its source and time window; +- the client skill presents only returned facts and provenance IDs (no invented metrics); +- the complete flow can be demonstrated in under two minutes. + +### Tool 3 — Large Swap Search (LSS) — IMPLEMENTED + +`find_large_swaps` reuses the indexed Uniswap V3 pool events through a +dedicated Nuthatch swap adapter. It applies an exact WETH- or USDC-denominated +human-unit threshold and returns deterministic fixed-snapshot cursor pages. It +does not query The Graph or invent USD notionals in v1. + +### Post-MVP Milestones + +1. **Wallet Research** — supported positions, swaps, assets, protocol usage and counterparties. +2. **Protocol DEX Metrics** — protocol-level aggregates. +3. **Lending and DeFi Positions** — broader lending within wallet research. +4. **Expansion** — additional chains, deployments, discovery and vaults. ## Project Context @@ -83,69 +117,75 @@ DeepTrace is designed for The Graph developer-tooling, AI-use-case and composabl The submission should demonstrate: -* live blockchain data rather than fixtures; -* one reusable query pattern across exactly three DEX deployments in the same standardized category; -* Nuthatch as a load-bearing source of a fresh pool fact; -* a reusable MCP interface and one agent skill; -* transparent composition of multiple sources; -* a short demo proving cross-protocol reuse and a Nuthatch-backed result. +- live blockchain data rather than fixtures; +- one reusable query pattern across three lending deployments in the same standardized category, plus a second pattern reused across two pool bindings; +- Nuthatch as a load-bearing source of a fresh pool fact; +- a reusable MCP interface and one agent skill; +- transparent composition of multiple sources; +- a short demo proving cross-protocol reuse and a Nuthatch-backed result. Wallet-specific research remains part of the global product direction, but it is not required to prove the first vertical slice. DeepTrace is a semantic research layer rather than another raw GraphQL gateway. Existing tools already provide generic Subgraph access and broad lending queries. DeepTrace adds: -* four stable research operations instead of many low-level source tools; -* one canonical response contract across Graph and Nuthatch data; -* wallet- and pool-centric Nuthatch views; -* deterministic cross-source normalization and calculations; -* shared coverage, freshness and provenance semantics; -* permanent grounded AI reasoning over verified results. +- three implemented research operations instead of many low-level source tools; +- one canonical response contract across Graph and Nuthatch data; +- wallet- and pool-centric Nuthatch views; +- deterministic cross-source normalization and calculations; +- shared coverage, freshness and provenance semantics; +- client-side presentation via `SKILL.md` over verified structured results (no internal LLM). ## Public Interface -The planned DeepTrace interface contains one `SKILL.md` and four high-level MCP tools: +The MVP interface contains one `SKILL.md` and three high-level MCP tools: ```text -research_wallet -get_dex_metrics compare_pools +compare_lending_markets find_large_swaps ``` -MVP-0 registers only `compare_pools`. The remaining tools are added in the order defined under **Next Milestones**. +The server registers only implemented tools. `research_wallet` and +`get_dex_metrics` remain post-MVP. -The user's AI chooses the appropriate available tool. DeepTrace retrieves and verifies the data, applies its internal reasoning layer, and returns both structured facts and a grounded explanation. +The user's AI chooses the appropriate available tool. DeepTrace retrieves and verifies the data and returns structured facts only. The user's AI + `SKILL.md` turn that payload into prose. ### Tool Contracts -| Tool | Stage | Core request | Core result | -| :--- | :--- | :--- | :--- | -| `compare_pools` | MVP-0 | Configured token pair and chain, time window, ranking metric and Top-N | Canonical pool records and deterministic cross-DEX ranking | -| `find_large_swaps` | Next | Protocols or pools, chains, time window, USD threshold, limit and cursor | Recent swaps whose normalized USD notional passes the threshold | -| `get_dex_metrics` | Next | Protocols, chains, time window and requested metrics | Comparable protocol TVL, volume, fees, revenue and usage | -| `research_wallet` | Later | Address, chains, protocols, requested sections, time window, limit and cursor | Supported positions, swaps, assets, protocol usage, counterparties and observable inflows | +| Tool | Stage | Core request | Core result | +| :------------------------ | :---------- | :---------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------- | +| `compare_pools` | IMPLEMENTED | Locked pair and chain, time window, ranking metric and Top-N | Canonical pool records plus Nuthatch freshness and deterministic cross-pool ranking | +| `compare_lending_markets` | IMPLEMENTED | Locked market asset and chain, ranking metric and Top-N | Canonical lending market records and deterministic cross-protocol ranking | +| `find_large_swaps` | IMPLEMENTED | Locked pool and chain, threshold token, human-unit minimum amount, limit and cursor | Stable pages of normalized Nuthatch swaps passing the non-USD threshold | +| `get_dex_metrics` | Post-MVP | Protocols, chains, time window and requested metrics | Comparable protocol TVL, volume, fees, revenue and usage | +| `research_wallet` | Post-MVP | Public address, Base, requested supported sections, window, limit and cursor | Supported Graph positions plus Nuthatch activity, counterparties, protocol usage and observable flows | Every tool receives an explicit scope and returns the scope that was actually searched. -For MVP-0, the chain, pair and three deployments are configuration-backed allowlisted values. Inputs outside that locked scope return a clear unsupported-scope error rather than starting open-ended source discovery. +For the implemented tools, the chain, pair, market asset, Messari deployments, +locked Uniswap pool and Nuthatch capabilities are configuration-backed +allowlisted values. Inputs outside that scope return a clear unsupported-scope +error. ### One Skill Contract The single `SKILL.md` teaches the user's AI: -* which currently available tool matches a request; -* how to specify address, chain, protocol, pair and time-window scope; -* when to request Top-N versus paginated history; -* how to continue using `next_cursor`; -* how to interpret metric methodology; -* how to report coverage, freshness and provenance; -* how to present `ai_reasoning` without replacing structured facts. +- which currently available tool matches a request; +- how to specify address, chain, protocol, pair and time-window scope; +- when to request Top-N versus paginated history; +- how to continue using `next_cursor`; +- how to interpret metric methodology; +- how to report coverage, freshness and provenance; +- how to present structured results without inventing metrics or replacing facts. -During MVP-0 the skill documents `compare_pools` only. Future tool instructions are added when those tools are implemented. +The skill documents only tools present in `tools/list`; it covers +`compare_pools`, `compare_lending_markets`, and `find_large_swaps`. ## Global Reference Architecture -The diagram shows the intended full system. MVP-0 implements only the `Pool Comparison` path and the shared layers below it. +The diagram shows the intended full system. Pool Comparison, Lending Markets, +and Large Swap Search are implemented; Wallet Research remains later. ```text User / User's AI @@ -157,10 +197,11 @@ typed tools · validation · rate limits · read-only policy ▼ Request Validator + Deterministic Router │ - ├── Wallet Research later - ├── DEX Metrics next - ├── Pool Comparison MVP-0 - └── Large Swap Search next + ├── Wallet Research implemented + ├── DEX Metrics later + ├── Pool Comparison implemented + ├── Lending Markets implemented + └── Large Swap Search implemented │ ▼ Source + Coverage Registry @@ -186,12 +227,12 @@ Coverage + Freshness + Provenance └── Partial result with explicit warnings │ ▼ -DeepTrace AI Reasoning -grounded explanation · highlights · caveats · source references +Final MCP Response +structured facts only (status · data · coverage · freshness · provenance · warnings) │ ▼ -Final MCP Response -structured facts + ai_reasoning +User's AI + SKILL.md +presentation · highlights · caveats · source citations (outside DeepTrace) ``` Messari and Nuthatch are parallel data backends. Nuthatch does not run remote Subgraphs, and Standardized Subgraphs do not replace the custom Nuthatch indexes. @@ -200,9 +241,9 @@ Messari and Nuthatch are parallel data backends. Nuthatch does not run remote Su 1. **Parallel backends:** Nuthatch does not execute or wrap remote Messari Subgraphs. DeepTrace queries both backends and merges their normalized outputs. 2. **Category-level standardization:** one query pattern is reusable across compatible DEX deployments. DEX, lending and vault categories still use separate adapters. -3. **Small public surface:** only implemented high-level tools are registered through MCP—one in MVP-0 and four at the global target. Source queries, registries, normalizers and calculators are internal code. +3. **Small public surface:** only implemented high-level tools are registered through MCP—three in the current MVP and a small set at the global target. Source queries, registries, normalizers and calculators are internal code. 4. **Deterministic data path:** validation, routing, querying, unit conversion, deduplication, formulas, ranking and pagination run in code. -5. **Reasoning after verification:** the internal model receives the final quality-checked payload. It does not create source records or change calculated fields. +5. **No internal generative step:** DeepTrace never calls a model provider. Presentation belongs to the user's AI and `SKILL.md`. 6. **Read-only operation:** DeepTrace reads and explains data; it does not sign or submit blockchain transactions. 7. **Bounded research:** every result identifies the exact sources and scope that were searched. @@ -212,10 +253,10 @@ Messari and Nuthatch are parallel data backends. Nuthatch does not run remote Su Used for broad, comparable metrics across supported deployments: -* DEX protocol and pool snapshots; -* volume, TVL, fees and revenue; -* standardized lending markets and supported positions in the Wallet Research milestone; -* historical windows for ranking and comparison. +- DEX protocol and pool snapshots; +- volume, TVL, fees and revenue; +- standardized lending markets and supported positions in the Wallet Research milestone; +- historical windows for ranking and comparison. DeepTrace queries supported deployments directly through version-aware GraphQL adapters. @@ -225,8 +266,8 @@ Standardized Schemas provide common entities and metrics inside one protocol cat Existing MCP projects are implementation inputs and references, not additional public DeepTrace tools: -* **Graph Lending MCP:** reference or reusable MIT-licensed implementation for lending registries, schema-version handling, positions, fan-out and graceful failures. -* **Subgraph MCP:** development-time discovery, schema inspection and raw-query verification. It is not required in the normal request path. +- **Graph Lending MCP:** reference or reusable MIT-licensed implementation for lending registries, schema-version handling, positions, fan-out and graceful failures. +- **Subgraph MCP:** development-time discovery, schema inspection and raw-query verification. It is not required in the normal request path. Compatible open-source components should be reused with attribution. The builder still connects only to DeepTrace. @@ -234,10 +275,10 @@ Compatible open-source components should be reused with attribution. The builder Nuthatch provides focused wallet- and pool-centric indexing on one selected chain and a small verified set of contracts: -* selected pool `Swap` events for MVP-0 freshness and later large-swap filtering; -* selected ERC-20 `Transfer` events for later wallet flows and token activity; -* concentrated-liquidity position-manager events for later Wallet Research: `Transfer`, `IncreaseLiquidity`, `DecreaseLiquidity` and `Collect`; -* indexed block metadata for every returned Nuthatch view. +- selected pool `Swap` events for MVP-0 freshness and later large-swap filtering; +- selected ERC-20 `Transfer` events for later wallet flows and token activity; +- concentrated-liquidity position-manager events for later Wallet Research: `Transfer`, `IncreaseLiquidity`, `DecreaseLiquidity` and `Collect`; +- indexed block metadata for every returned Nuthatch view. DeepTrace uses the query surface supported by the selected Nuthatch version, such as its HTTP API, built-in MCP or available SQL views. The integration path is verified before implementation. @@ -247,11 +288,11 @@ Every configured backend has a versioned registry record: ```json { - "source_id": "dex-uniswap-v3-mainnet", + "source_id": "dex-uniswap-v3-base", "source_type": "standardized_subgraph", "category": "dex", "protocol": "uniswap-v3", - "chain_id": 1, + "chain_id": 8453, "deployment_or_view_id": "Qm...", "schema_version": "4.0.1", "methodology_version": "1.0.0", @@ -283,7 +324,7 @@ rank source_ids[] ``` -The three DEX adapters must produce this same record before ranking. Source-specific fields may be retained in provenance, but they must not change the public comparison shape. +The two DEX adapters must produce this same record before ranking. Source-specific fields may be retained in provenance, but they must not change the public comparison shape. ### `DeFiPosition` — Wallet Research milestone @@ -305,9 +346,9 @@ source_ids[] Planned position types: -* lending supply, collateral and borrow; -* AMM LP where the configured source exposes ownership; -* concentrated-liquidity LP from the selected position manager. +- lending supply, collateral and borrow; +- AMM LP where the configured source exposes ownership; +- concentrated-liquidity LP from the selected position manager. Each asset leg contains token address, symbol, decimals, role, raw amount, normalized amount, USD price and USD value when available. @@ -356,12 +397,13 @@ chain_id + protocol + position_id ## Research Terminology -* **Coverage:** chains, protocols, deployments, Nuthatch views, entities and time ranges actually searched, including unavailable or unsupported portions. -* **Freshness:** last indexed block, source timestamp, query timestamp and known source lag. -* **Provenance:** evidence path for a fact or metric: chain, protocol, source type, deployment or nest/view, schema/methodology version and block/time range. -* **Protocol usage:** observable interactions with supported protocol contracts and entities, aggregated by activity type, count and available volume. -* **Observable inflows:** incoming transfers, swap outputs, lending borrows, LP withdrawals and reward claims classified by on-chain event type. -* **Large swap:** a normalized swap whose USD notional passes the threshold supplied in the request. +- **Coverage:** chains, protocols, deployments, Nuthatch views, entities and time ranges actually searched, including unavailable or unsupported portions. +- **Freshness:** last indexed block, source timestamp, query timestamp and known source lag. +- **Provenance:** evidence path for a fact or metric: chain, protocol, source type, deployment or nest/view, schema/methodology version and block/time range. +- **Protocol usage:** observable interactions with supported protocol contracts and entities, aggregated by activity type, count and available volume. +- **Observable inflows:** incoming transfers, swap outputs, lending borrows, LP withdrawals and reward claims classified by on-chain event type. +- **Large swap:** a normalized swap whose selected WETH or USDC absolute pool + delta meets the positive human-unit threshold supplied in the request. ## Data Pipeline @@ -408,32 +450,47 @@ This is a pool-level historical fee yield, not an individual LP return or a pred ### USD Values -Every normalized USD value records: - -* price; -* price timestamp; -* price source; -* token amount used; -* status when no supported price is available. - -Missing prices remain explicit rather than being replaced with model-generated estimates. +MVP-0 consumes the USD values reported by each selected source without external +repricing. Every normalized value keeps its source ID, time window, unit, methodology +version and availability status. `"0"` is measured zero; `null` is unavailable. +Missing values remain explicit rather than being replaced with model-generated +estimates. Any later feature that calculates USD values from token amounts must first +lock a separate price source and timestamp methodology. ### Rankings and Thresholds -* Rankings use the requested metric and deterministic tie-breaking. -* Top-N is applied after filtering and normalization. -* Large-swap selection applies the request threshold to normalized USD notional. +- Rankings use the requested metric in descending order with unavailable values last. +- Ties use normalized protocol ascending, pool address ascending and source ID + ascending, in that order. +- Top-N is applied after filtering and normalization. +- MVP-0 defaults to Top-3 and rejects values above three. +- Large-swap selection applies the request threshold after normalization using + exact selected-token base-unit arithmetic; no USD conversion occurs. ## Reliability and Operations -* Source queries run in parallel with bounded timeouts. -* A failed source does not erase successful source results. -* Responses use `complete`, `partial` or `failed` status. -* Partial responses list missing coverage and explicit warnings. -* API keys and endpoint credentials never appear in output. -* Tool inputs have size and range limits. -* Histories use cursor pagination over a stable source block range. -* Read-only policy and rate limits are enforced at the gateway. +- Source queries run in parallel with a default 5-second and maximum 8-second timeout. +- The complete request has a 15-second deadline and a 64 KiB response limit. +- Known source lag is `queried_at - indexed_block_timestamp`. The core quality layer + treats lag above 300 seconds as stale coverage without rewriting the adapter-owned + source status. Validated `ok` data is preserved, while the overall response becomes + `partial` and includes a freshness warning. +- A failed source does not erase successful source results. +- `complete` requires all three Graph pool results and the required Nuthatch fact, + with none stale under the core quality threshold. +- `partial` requires at least one valid Graph pool result while expected coverage is + missing, stale or unavailable. +- `failed` means no valid Graph pool record can be compared. A Nuthatch-only result + does not make pool comparison successful. +- Partial responses list missing coverage and explicit warnings. +- Warnings are ordered by configured source order and then warning text. +- API keys and endpoint credentials never appear in output. +- Tool inputs have size and range limits. +- Histories use cursor pagination over a stable source block range. +- Read-only policy and rate limits are enforced at the gateway. +- The default rate limit is 30 requests per 60-second fixed window. Deployment + overrides cannot exceed 300 requests or a one-hour window, and reset occurs at the + fixed-window boundary. ## Result Contract @@ -452,13 +509,6 @@ Every tool returns the same top-level envelope: "returned": 0, "has_more": false, "next_cursor": null - }, - "ai_reasoning": { - "status": "complete", - "summary": "", - "highlights": [], - "caveats": [], - "source_ids": [] } } ``` @@ -473,7 +523,7 @@ The exact numeric values below are placeholders; the shape is the contract that { "status": "complete", "data": { - "chain_id": 1, + "chain_id": 8453, "pair": ["WETH", "USDC"], "window": "24h", "ranked_by": "volume_usd", @@ -502,48 +552,27 @@ The exact numeric values below are placeholders; the shape is the contract that "freshness": {}, "provenance": [], "warnings": [], - "pagination": null, - "ai_reasoning": { - "status": "complete", - "summary": "", - "highlights": [], - "caveats": [], - "source_ids": [] - } + "pagination": null } ``` `compare_pools` returns a bounded ranked set and therefore does not require pagination in MVP-0. -## AI Reasoning Layer - -AI reasoning is a permanent DeepTrace capability. It runs after normalization, deterministic metrics and the coverage gate. - -The reasoning layer receives: - -* the validated user request; -* the final structured result; -* versioned metric formulas; -* coverage and freshness; -* provenance identifiers. +## Presentation Boundary (No Internal AI) -It produces: +Decided 2026-07-25: DeepTrace does **not** call a model provider. After +coverage/freshness/provenance settlement, the MCP returns the verified structured +envelope only. There is no `ai_reasoning` field. -* a concise factual summary; -* important relationships and highlights; -* explanations of derived metrics; -* explicit caveats for partial coverage or stale sources; -* references to provenance records used in the explanation. +Presentation is the job of the **user's AI** guided by `SKILL.md`. That skill must: -The reasoning implementation lives in: +- present only values present in `data`; +- cite `source_ids` / `provenance` when claiming facts; +- always surface `warnings`, coverage holes, and freshness status; +- never invent rankings, USD values, or Nuthatch facts. -```text -src/reasoning/ -``` - -It uses one bounded model call and writes only to `ai_reasoning`. Structured `data`, metrics, coverage, freshness and provenance remain the source of truth. - -The reasoning output follows a typed schema. Every referenced source ID must exist in `provenance`. If the model provider is temporarily unavailable, DeepTrace still returns the verified structured result with `ai_reasoning.status` set to `unavailable`. +Do not implement `src/reasoning/` or provider keys unless a later milestone +explicitly reopens internal AI. ## Suggested Project Structure @@ -559,7 +588,6 @@ src/ normalization/ identities, decimals, prices and deduplication metrics/ deterministic calculations and rankings quality/ coverage, freshness, provenance and warnings - reasoning/ permanent grounded AI reasoning skill/ SKILL.md tests/ @@ -568,101 +596,96 @@ tests/ parity/ ``` -Internal modules may contain many functions, but only implemented high-level handlers are registered as public MCP tools. MVP-0 registers `compare_pools` only. +Internal modules may contain many functions, but only implemented high-level +handlers are registered as public MCP tools. The registered surface is +`compare_pools`, `compare_lending_markets`, and `find_large_swaps`. ## MVP Build Order ### 1. Freeze the Live Scope -* select the implementation language and MCP SDK; -* resolve every `TBD` in **Scope to Lock Before Coding**; -* verify that exactly three live DEX deployments expose comparable pool metrics; -* select the Nuthatch pool and the fresh fact it uniquely contributes; -* define the USD price source and timestamp policy; -* finalize `PoolComparisonRecord`, the `compare_pools` request/response schema, limits and timeouts; -* select the model provider, model, reasoning schema and latency budget; -* select the deployment transport. +- select the implementation language and MCP SDK; +- resolve every `TBD` in **Scope to Lock Before Coding**; +- verify that the two locked live DEX deployments expose comparable pool metrics; +- select the Nuthatch pool and the fresh fact it uniquely contributes; +- define the USD price source and timestamp policy; +- finalize `PoolComparisonRecord`, the `compare_pools` request/response schema, limits and timeouts; +- select the deployment transport. ### 2. Build the Nuthatch Path -* index `Swap` events for the selected pool or small verified pool set; -* expose the minimal view required for the declared freshness fact; -* attach indexed block metadata; -* verify sample events against chain receipts. +- index `Swap` events for the selected pool or small verified pool set; +- expose the minimal view required for the declared freshness fact; +- attach indexed block metadata; +- verify sample events against chain receipts. ### 3. Build the Standardized Graph Path -* implement the source registry; -* implement one version-aware pool query pattern across exactly three DEX deployments; -* implement timeouts and independent source failures. +- implement the source registry; +- implement one version-aware pool query pattern across the two locked DEX deployments; +- implement timeouts and independent source failures. ### 4. Build the DeepTrace Data Layer -* implement `PoolComparisonRecord`; -* decimal and price normalization; -* pool identity and deduplication; -* TVL, volume and fee methodologies; -* deterministic ranking and Top-N; -* coverage, freshness and provenance. +- implement `PoolComparisonRecord`; +- decimal and price normalization; +- pool identity and deduplication; +- TVL, volume and fee methodologies; +- deterministic ranking and Top-N; +- coverage, freshness and provenance. ### 5. Expose the MCP -* implement `compare_pools`; -* write one `SKILL.md` for the available tool; -* add rate limits and read-only policy; -* add setup and client configuration. - -### 6. Integrate AI Reasoning - -* implement the reasoning module against its typed output schema; -* add one bounded model call and graceful provider fallback; -* validate every reasoning source reference against provenance; -* test factual consistency, latency and response size. +- implement `compare_pools`; +- write one `SKILL.md` for the available tool; +- add rate limits and read-only policy; +- add setup and client configuration. -### 7. Ship +### 6. Ship -* live integration and parity tests; -* documented source coverage; -* open-source attribution and license; -* public repository; -* demo under two minutes. +- write one `SKILL.md` that teaches the client AI to present only returned facts; +- live integration and parity tests; +- documented source coverage; +- open-source attribution and license; +- public repository; +- demo under two minutes. ## Test Strategy ### Unit -* pair, chain, protocol, window and limit validation; -* decimal and USD normalization; -* pool identity and event deduplication; -* TVL, volume and fee methodology versions; -* deterministic ranking and Top-N; -* coverage and status calculation; -* provenance reference validation. +- pair, chain, protocol, window and limit validation; +- decimal and USD normalization; +- pool identity and event deduplication; +- TVL, volume and fee methodology versions; +- deterministic ranking and Top-N; +- coverage and status calculation; +- provenance reference validation. ### Live Integration -* Nuthatch view queries; -* exactly three Standardized DEX deployments; -* a request with one intentionally unavailable source; -* block and timestamp freshness metadata. +- Nuthatch view queries; +- the two locked DEX deployments; +- a request with one intentionally unavailable source; +- block and timestamp freshness metadata. ### Parity For selected events visible in both Graph and Nuthatch, compare transaction hash, log index, token addresses, raw amounts, pool identity and source block. -### AI Reasoning +### Client Presentation (skill) -* summary facts exist in the structured payload; -* cited source IDs exist in provenance; -* derived metrics are explained with the registered methodology; -* partial coverage appears in caveats; -* provider failure preserves the structured result; -* latency and output size remain within configured limits. +- skill instructs the client AI to cite only returned `source_ids` and surface warnings/freshness; +- no DeepTrace provider/model tests (no internal AI). ## Demo Flow 1. Submit one `compare_pools` request for the locked pair and chain. -2. Show three normalized pool records ranked by the locked metric. +2. Show the two normalized pool records ranked by the locked metric. 3. Show the fresh fact contributed by Nuthatch. -4. Repeat with one unavailable source and show the partial result. -5. Show coverage, freshness, provenance and grounded `ai_reasoning`. +4. Submit one `compare_lending_markets` request for native USDC and show the + three-protocol ranking. +5. Repeat with one unavailable source and show the partial result. +6. Run `find_large_swaps`, follow one returned cursor, and show exact amounts, + stable ordering, freshness and provenance. +7. Let the client chat pane narrate only from the structured data. diff --git a/README.md b/README.md new file mode 100644 index 0000000..173bc09 --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +![DeepTrace](logo.png) + +**Verifiable Base DeFi data for builders and AI agents.** + +--- + +DeepTrace is a read-only [MCP](https://modelcontextprotocol.io) server. You connect it to +Claude, Codex, Cursor, or any other MCP client, and your assistant gains four research +tools for Base (chain `8453`). + +The point is not "more data." The point is data an agent cannot fudge. Every answer says +where each number came from, how fresh it is, and what it could not see. When a source is +down, you get a partial result that says so — never a confident guess. + +## What it answers + +| Question | Tool | +| --- | --- | +| "Which WETH/USDC fee tier on Base has more volume this week?" | `compare_pools` | +| "Where is USDC lending paying best right now — Aave, Seamless, or Moonwell?" | `compare_lending_markets` | +| "Show me every swap over 50 WETH in that pool." | `find_large_swaps` | +| "What has this wallet been doing, and does it have an Aave position?" | `research_wallet` | + +**`compare_pools`** ranks the two locked Uniswap V3 WETH/USDC fee tiers (0.30% and 0.05%) +by TVL, volume, or fees from Messari standardized subgraphs, and attaches an independent +freshness fact from our own Nuthatch index so you can tell whether the subgraph is lagging. + +**`compare_lending_markets`** compares the native-USDC market across Aave v3, Seamless, and +Moonwell through one shared query template, so the three protocols are actually comparable +rather than three different definitions of "APY." + +**`find_large_swaps`** pages through swaps in the locked pool above a threshold you set in +real WETH or USDC — not dollars. There is no price join, so there is nothing to get wrong. +Pagination uses an opaque cursor over a frozen snapshot, so page 2 still belongs to the +same moment as page 1. + +**`research_wallet`** reports what our sources can actually observe about a public address: +its swaps in the indexed pool, who was on the other side of them, which assets and +protocols showed up, and its open Aave v3 positions. Ask for the sections you want over a +24h or 7d window. + +## Connect + +The hosted server is: + +```text +https://mcp.ikodo.dev +``` + +You do not need a Graph API key, Nuthatch access, Tailscale, or a local checkout. Nuthatch is +reachable only from inside the DeepTrace server: it is a private, unauthenticated service on the +server's loopback interface with no public route, so there is nothing for a client to connect to. +`mcp.ikodo.dev` serves the MCP endpoint and the connection page only. + +Whatever client you use, these are the only four settings: + +| Setting | Value | +| --- | --- | +| Name | `deeptrace` | +| Transport | Streamable HTTP | +| URL | `https://mcp.ikodo.dev` | +| Header | `Authorization: Bearer ` | + +There are two ways to authenticate. Clients that speak OAuth can skip tokens entirely: the +server implements OAuth 2.1 with PKCE and dynamic client registration, so the client +registers itself, you approve in the browser, and it stores its own credential. Everything +else uses a bearer token — open `https://mcp.ikodo.dev` in a browser and the page walks you +through getting one. Both flows, and their current client-compatibility limits, are covered +in [Authentication](docs/auth.md). + +### Two examples + +Every client wants the same URL and the same bearer token; only the file format differs. Two +are shown here — see [Connect an AI client](docs/connect.md) for the rest. + +**Claude** — `claude_desktop_config.json` for the desktop app, or `.mcp.json` in your +repository for Claude Code. Paste your token in place of ``: + +```json +{ + "mcpServers": { + "deeptrace": { + "command": "npx", + "args": ["mcp-remote", "--header", "Authorization:${AUTH_HEADER}", "https://mcp.ikodo.dev"], + "env": { + "AUTH_HEADER": "Bearer " + } + } + } +} +``` + +The missing space after `Authorization:` is deliberate. `mcp-remote` splits each `--header` +argument on its first colon, and a value containing a space can be mangled by the spawning +shell, so the scheme and the token travel together inside `AUTH_HEADER`. This file now holds +a live credential — keep it out of git. + +**Codex** — `~/.codex/config.toml`, or `.codex/config.toml` in a trusted project. The CLI, the +IDE extension, and the ChatGPT desktop experience all share this file: + +```toml +[mcp_servers.deeptrace] +url = "https://mcp.ikodo.dev" +bearer_token_env_var = "DEEPTRACE_TOKEN" +``` + +Codex takes the *name* of an environment variable, not the token itself, so nothing secret +lands in the file. Set it in the shell you launch Codex from: + +```sh +read -rsp "DeepTrace token: " DEEPTRACE_TOKEN +export DEEPTRACE_TOKEN +``` + +### Verify it connected + +Run your client's MCP listing — `claude mcp list`, `codex mcp list` — or check the connectors +panel in a desktop app. Then ask something concrete, such as comparing Base WETH/USDC pools +over the last 24 hours by volume and reporting which sources answered. + +A `401` means the credential is missing, invalid, or retired. A `404` usually means a typo in +the hostname. A client reporting that it needs authentication has reached the server +successfully — that is the browser flow waiting for you. + +### Optional skill + +The MCP connection is all you need — the server ships its own usage instructions, typed +inputs, and a declared output schema. For a richer workflow, the optional +[pool research skill](skills/deeptrace-pool-research/SKILL.md) teaches your client to +preserve exact decimals, keep Graph and Nuthatch facts separate, follow cursors correctly, +and surface partial coverage: + +```sh +npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research +``` + +The CLI detects your installed agents and asks which to target. Review the source before +approving; it does not configure MCP or handle your token. Start a new session afterward. + +## Why you can trust the output + +- **No model in the loop.** DeepTrace never calls an AI provider. It returns a verified + structured envelope and nothing else — there is no generated prose, no reasoning field, + and nothing for a model to hallucinate into the data. Interpretation is your client's job. +- **Read-only.** Every tool is annotated read-only and idempotent. Nothing signs, sends, or + mutates anything. +- **Sourced.** Responses carry per-source provenance, coverage, and freshness. Subgraph + deployments are pinned by hash and re-asserted on every call. +- **Honest about gaps.** One source failing produces a `partial` result with a warning, not + a silent hole. +- **No invented numbers.** Financial values pass through as exact decimal strings. Windows + are completed UTC days, so `24h` is the last full UTC day, not a rolling window. If those + days are missing, the value is `null` rather than a shorter window quietly substituted. + +## What it does not do + +DeepTrace is deliberately narrow, and it will tell you so rather than improvise: + +- Base only. No other chains. +- Pools: the WETH/USDC Uniswap V3 0.30% and 0.05% tiers, not every pool. +- Lending: the native-USDC market on three protocols, not every market. +- Swaps: the one indexed pool, thresholded in WETH or USDC. No USD conversion. +- Wallets: observed activity and Aave v3 positions — **not** complete balances, full + transaction history, portfolio value, P&L, or ownership. + +Pinned deployments and the exact field behind each metric are in +[source scope](docs/source-scope.md) and [wallet source scope](docs/wallet-source-scope.md). + +## Run it yourself + +Requires Node.js 22 or newer. + +```sh +npm ci +npm run format:check +npm run lint +npm run typecheck +npm test +npm run build +``` + +Then start the stdio transport: + +```sh +npm run build +npm start +``` + +Diagnostics go to stderr so they cannot corrupt the protocol stream on stdout. Point a +client at the built entry: + +```json +{ + "mcpServers": { + "deeptrace": { + "command": "node", + "args": ["/absolute/path/to/deeptrace/dist/index.js"] + } + } +} +``` + +## Documentation + +| Document | What is in it | +| --- | --- | +| [Authentication](docs/auth.md) | How a client gets a credential, and the two ways in | +| [Connect an AI client](docs/connect.md) | Per-client setup and troubleshooting | +| [Source scope](docs/source-scope.md) | Pinned deployments and field mappings | +| [Wallet source scope](docs/wallet-source-scope.md) | What wallet research can and cannot see | +| [Contract](docs/CONTRACT.md) | The response contract | +| [MCP testing](docs/mcp-testing.md) | Handshake, smoke checks, operator procedure | +| [Deployment](docs/deployment.md) | Nuthatch deployment notes | +| [PLAN.md](PLAN.md) | Product direction and architecture | diff --git a/deploy/systemd/nuthatch-watchdog.service b/deploy/systemd/nuthatch-watchdog.service new file mode 100644 index 0000000..3481d1c --- /dev/null +++ b/deploy/systemd/nuthatch-watchdog.service @@ -0,0 +1,21 @@ +[Unit] +Description=DeepTrace Nuthatch backfill progress watchdog +After=nuthatch.service + +[Service] +Type=oneshot +User=nuthatch +Group=nuthatch +WorkingDirectory=/opt/deeptrace +StateDirectory=deeptrace-nuthatch-watchdog +Environment=NUTHATCH_WATCHDOG_STATE_FILE=/var/lib/deeptrace-nuthatch-watchdog/state.json +Environment=NUTHATCH_WATCHDOG_STALL_AFTER_SECONDS=600 +Environment=NUTHATCH_WATCHDOG_TIMEOUT_MS=5000 +ExecStart=/usr/bin/node scripts/m4/nuthatch-watchdog.mjs +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +CapabilityBoundingSet= +RestrictAddressFamilies=AF_INET AF_INET6 +UMask=0077 diff --git a/deploy/systemd/nuthatch-watchdog.timer b/deploy/systemd/nuthatch-watchdog.timer new file mode 100644 index 0000000..b3b9b08 --- /dev/null +++ b/deploy/systemd/nuthatch-watchdog.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Poll Nuthatch backfill progress every two minutes + +[Timer] +OnBootSec=3min +OnUnitActiveSec=2min +RandomizedDelaySec=15s +Persistent=true +Unit=nuthatch-watchdog.service + +[Install] +WantedBy=timers.target diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md new file mode 100644 index 0000000..fbe5a1e --- /dev/null +++ b/docs/CONTRACT.md @@ -0,0 +1,133 @@ +# DeepTrace Source Adapter Contract + +This contract is the boundary between live source adapters and the rest of +DeepTrace. Graph and Nuthatch adapters return the same `SourceResult` shape +defined in `src/schemas/source-adapter.ts`. + +## Result shape + +Every result identifies its registry source, source type, protocol, Base chain, +status, freshness, provenance, warnings, and measured latency. The `source_id` +must match a Source Registry record. + +The supported statuses are: + +- `ok`: the source returned usable data. +- `timeout`: the source exceeded its bounded deadline. +- `error`: the source failed for another operational reason. +- `unsupported`: the source schema, deployment, or requested capability does + not match its registered contract. +- `stale`: the source is reachable but not sufficiently current or ready. + +An `ok` result always has non-null `data` and `freshness`. Every non-`ok` +result has `data: null`. A failed result may retain non-null freshness when the +adapter has reliable last-known indexing metadata, such as a stale Nuthatch +view; otherwise freshness is null. + +Adapters must catch operational and source-shape failures at their boundary and +return a non-`ok` result. No source exception crosses into normalization, +metrics, MCP, or client presentation code. + +The LSS adapter carries the same status, freshness, provenance, and warning +semantics in its tool-specific `LargeSwapSourceResult`. Its successful payload +is a bounded set of canonical `SwapEvent` candidates plus the searchable head; +the public quality envelope is settled only after stable pagination. + +Wallet Research uses parallel tool-specific Graph-position and Nuthatch-activity +results with the same quality semantics. Each source settles independently +before section-aware composition; neither source exception may erase usable +facts from the other. + +## Numeric and identity rules + +All financial values are base-10 decimal strings or null. They must never pass +through a JavaScript `number`, because doing so can silently lose precision. +Counts, block numbers, token decimals, fee-tier basis points, log indexes, +timestamps, and latency remain numbers. + +Pool and token addresses are lowercase. Unix timestamps are integer seconds. +Graph block hashes are omitted when the source does not expose one. Nuthatch +results always include the indexed block hash and the last swap block, +transaction hash, and log index. + +Null financial values mean the source did not provide a supported value. They +must not be replaced with estimates. + +## USD pricing + +DeepTrace may read only The Graph and Nuthatch. There is no external price API, +so **USD values are whatever the subgraph reports, consumed as-is**. Downstream +code must not re-price, cross-check against another venue, or derive a USD value +that the source did not supply. + +The price timestamp is the reporting snapshot's day or block, and the price +provenance is the subgraph deployment recorded in `provenance`. + +Nuthatch carries no USD at all — it indexes raw `Swap` events with raw `int256` +amounts. Any Nuthatch-derived value is unpriced by construction. +Large Swap Search thresholds the exact absolute selected-token pool delta after +normalization; it does not derive or accept a USD threshold. + +## Freshness and provenance + +Freshness describes the source state observed by the query: + +- `indexed_block` and `indexed_block_timestamp` identify the indexed head. +- `indexed_block_hash` is included when available. +- `queried_at` records when DeepTrace made the query. +- `has_indexing_errors` is included when the backend exposes that state. + +Provenance is required for both successful and failed results. It identifies +the deployment or Nuthatch registry/view, available schema and methodology +versions, and the query template that ran. Versions are null when the source +does not publish them. + +Warnings contain safe, user-presentable diagnostics. They must never contain +API keys, credential-bearing URLs, authorization headers, or admin tokens. + +## Registry relationship + +Registry records use the shape in `src/registry/types.ts`. MVP records are +Base DEX or lending sources with `chain_id: 8453`. A record declares its source type, +protocol, deployment or view identity, supported entities, versions, and +whether it is active. Locators are source-specific: Graph records carry the +stable gateway subgraph ID while Nuthatch records identify the configured view. +The registry, rather than adapter code, selects and locates configured sources. +This locator union is the M2 clarification to the otherwise frozen M1 contract: +the subgraph ID routes a request, while `deployment_or_view_id` remains the +independently asserted provenance identity. + +Pool freshness and large-swap search use separate Nuthatch registry records and +view IDs even when they resolve to the same private runtime. LSS SQL comes only +from hardcoded keyset query templates; validated block numbers, log indexes, +transaction hashes, and internal limits are the only interpolated values. +Public requests can never supply SQL, a table/view name, or a backend URL. + +Wallet Research likewise uses a dedicated `wallet_swap_activity` record and +view. Its only interpolated string is a lowercase address that has passed the +public request schema; snapshot coordinates and limits are validated integers. +The Graph wallet adapter is separately bound to the registered Aave v3 +deployment and account-filtered position query. + +## Fixtures and change control + +`tests/fixtures/sources/index.ts` exports five typed results: + +- three healthy Graph pool results; +- a timeout variant of the third Graph result; +- one healthy Nuthatch freshness result. + +It also exports a four-source complete scenario and a four-source partial +scenario. These are deliberately shape-only placeholders. M2 replaces their +source values with captured live evidence without changing the contract. + +Every fixture uses TypeScript's `satisfies` operator, so incompatible fields +fail strict fixture typechecking. Run: + +```sh +tsc -p tsconfig.fixtures.json +``` + +This contract is frozen for the two workstreams. Any change to its fields, +status semantics, nullability, units, or provenance rules requires agreement +from both people before implementation. diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..0f39dbf --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,177 @@ +# Authentication + +DeepTrace is a remote MCP server, so every request carries a bearer credential. This page +explains how a client gets one. + +There is no account to create, no dashboard, and no maintainer to email. Access is open by +design: anyone who reaches the server can obtain a credential. What the credential protects +is not the data — the data is public on-chain information — but the metered Graph API quota +behind it. The request rate limit in front of the tools exists as cost control, not as +access control. Taking a token is unmetered. + +Think of the token as the plug rather than the lock. It identifies one client so that one +client can be revoked without disturbing anyone else. + +## What you can do + +- Connect any MCP-compatible client with one URL and one header +- Let a client register itself and complete the whole flow in a browser, with no token + handling at all +- Mint a token yourself for CI, scripts, or clients that cannot open a browser +- Hold a separate credential per client, so losing one never affects the others + +## The connection + +Every harness accepts the same `key: value` pairs. Whatever client you use, these four +values are the entire configuration: + +| Key | Value | +| --- | --- | +| Name | `deeptrace` | +| Transport | Streamable HTTP | +| URL | `https://mcp.ikodo.dev` | +| Header | `Authorization: Bearer ` | + +Only the shape of the file differs between clients. The values never do. Per-client file +locations and formats are in [Connect an AI client](connect.md). + +## Two ways in + +### The browser flow + +Clients that speak OAuth need no token from you. Point the client at the URL with no header +and it will report that authentication is required, then open a browser. You approve once, +and the client stores its own credential. + +DeepTrace implements OAuth 2.1 authorization code with PKCE, served from the resource +server itself: + +| Endpoint | Purpose | +| --- | --- | +| `/.well-known/oauth-protected-resource` | Tells a client where the authorization server is | +| `/.well-known/oauth-authorization-server` | Advertises the endpoints and supported grants | +| `/oauth/register` | Dynamic client registration, RFC 7591 | +| `/oauth/authorize` | The consent page you approve | +| `/oauth/token` | Redeems the one-time code for a token | + +The issued scope is `deeptrace:read`. Clients are public — no client secret is issued or +accepted — so PKCE with `S256` is mandatory and is the only thing proving that the client +redeeming a code is the one that requested it. A code is bound to its `client_id`, +`redirect_uri`, and PKCE challenge, all three rechecked at redemption, and it is valid once. +Replaying it returns `400`. + +Redirect targets must be loopback. An off-host callback would let a stranger's server +receive the code, so `https:` and non-loopback `http:` are both refused. + +### Minting a token directly + +Open in a browser and press the button. The token is covered by +asterisks until you point at it, and one click selects the whole value to copy. + +The endpoint is built for that page and always answers in HTML — `POST` mints and returns +`201` with the token in the page body; there is no JSON representation. A script therefore +has to scrape it: + +```sh +TOKEN=$(curl -sS -X POST https://mcp.ikodo.dev/auth | grep -o 'dt_[A-Za-z0-9_-]\{20,\}' | head -1) +``` + +Minting is not capped, so a script can take a fresh token whenever it needs one. + +Tokens look like `dt_` followed by 32 random bytes in base64url. The server keeps only a +SHA-256 digest of each one, so the store holds no recoverable secret and lookup is by digest +rather than comparison. A token is shown once and cannot be recovered — mint a new one +instead. + +## Handle the token safely + +Read it without leaving a copy in shell history, then launch the client from that shell: + +```sh +read -rsp "DeepTrace token: " DEEPTRACE_TOKEN +export DEEPTRACE_TOKEN +``` + +Reference the variable from the config file rather than pasting the value, which keeps the +file safe to commit. Never put a token in a URL, a chat message, or a support log. A +legitimate DeepTrace page will never ask you to type a token into it. + +## Verify + +```sh +claude mcp list +opencode mcp list +codex mcp list +``` + +A connected server that reports needing authentication has been reached successfully — that +is the browser flow waiting for you, not a failure. + +## When something is wrong + +| Symptom | Meaning | +| --- | --- | +| `401 Unauthorized` | Reached the server, but the credential is missing, invalid, or retired. The response carries `resource_metadata`, which is how a client finds the browser flow. | +| `404 Not Found` | Check the hostname is exactly `mcp.ikodo.dev`. | +| `invalid_redirect_uri` | The client asked for a callback the server will not accept. See the compatibility limits below. | +| `400` on token exchange | The code was already redeemed, or the `redirect_uri` or PKCE verifier does not match the request. | +| Client offers OAuth but never starts it | The client probed a discovery path this server does not answer. See below. | +| Connected, but results are `partial` | Not an auth problem. Read `warnings` and `coverage`; a data source is unavailable or stale. | + +## Client compatibility limits + +Claude Code has been verified end to end against production: registration, consent, +redirect, token exchange, and an MCP `initialize` returning `200` with a session id. + +OpenCode and Codex are **not** verified end to end, and three known gaps in the current +implementation make failures likely rather than hypothetical: + +- **Loopback is IPv4 only.** `isLoopbackRedirect` accepts `http:` with hostname `127.0.0.1` + or `localhost`. An IPv6 callback on `[::1]`, or an RFC 8252 private-use scheme such as + `cursor://`, is refused with `invalid_redirect_uri`. +- **Only two discovery paths are answered.** A client probing + `/.well-known/openid-configuration`, or a variant with the MCP path appended, gets a miss + and reasonably concludes the server offers no OAuth. +- **Registrations do not survive a restart.** The client registry is in-memory, and an + unrecognised `client_id` is rejected at `/oauth/authorize`. Because a deploy restarts the + service, a client that cached its `client_id` is stranded until it registers again. + +Widening the accepted callbacks, answering the alias discovery paths, and tolerating an +unknown `client_id` when the callback is otherwise acceptable would close all three. The +last is safe because the code remains bound to `client_id`, `redirect_uri`, and the PKCE +challenge, all rechecked at exchange. + +## For operators + +The credential configuration lives in `/etc/deeptrace/http.env` (`root:root`, `0600`): + +| Variable | Purpose | +| --- | --- | +| `DEEPTRACE_HTTP_TOKEN` | The shared deployment credential, kept only for migration | +| `DEEPTRACE_TOKEN_STORE` | Path to the issued-token store | + +The issued-token store must live outside `/opt/deeptrace`, which the deploy rsyncs with +`--delete`; an in-repo default would be erased on every deploy. Production uses +`/var/lib/deeptrace/tokens.json`, mode `600`, owner `deploy`. Because the unit runs with +`ProtectSystem=strict`, a drop-in supplying `StateDirectory=deeptrace` and +`StateDirectoryMode=0700` is what creates that directory and punches it through the +hardening. Without it, minting fails with `EROFS`. See +[MCP testing](mcp-testing.md) under Issued token store. + +Origin validation runs before routing, and is deliberately not enforced everywhere. +Discovery, registration, and token redemption are exempt, because clients set their own +`Origin` — a CLI, an editor shell, a loopback callback — and those values cannot be +enumerated. It is enforced on MCP paths and on `POST /oauth/authorize`. + +### Retiring the shared token + +`DEEPTRACE_HTTP_TOKEN` still authenticates every client at once. One leak exposes all of +them and cannot be revoked selectively, so retiring it is the highest-value remaining task. +Per-client issued tokens already make it unnecessary. + +Retirement is an operational step, not a code change: remove the variable and restart. The +listener stays authenticated because issued tokens are unaffected. While the variable is +still set, the server logs a warning at startup naming it. + +A value that is present but shorter than the minimum is still rejected as a +misconfiguration — a weak shared secret is worse than none. diff --git a/docs/connect.md b/docs/connect.md new file mode 100644 index 0000000..292e4da --- /dev/null +++ b/docs/connect.md @@ -0,0 +1,252 @@ +# Connect an AI client + +DeepTrace is available as a remote MCP server at: + +```text +https://mcp.ikodo.dev +``` + +It uses the MCP Streamable HTTP transport and a bearer access token, the same +shape as other hosted research MCPs: you hold your own key and send it as an +`Authorization` header. + +## Get a key + +Tokens are self-serve: open in a browser and press the +button, or add the server with no header at all and let an OAuth-capable client +discover the flow and store its own credential. + +[Authentication](auth.md) covers both paths in full, along with token handling, +the OAuth endpoints, and current client-compatibility limits. + +## What a user needs + +Configure one remote MCP server with these values: + +| Setting | Value | +| --- | --- | +| Name | `deeptrace` | +| Transport | Streamable HTTP | +| URL | `https://mcp.ikodo.dev` | +| Header | `Authorization: Bearer ` | + +Store the token using the client's secret or environment-variable support. +Never put it in the URL, a chat prompt, a committed configuration file, or a +support log. + +That is the entire public connection. The user's device does not connect to +Nuthatch directly and does not need Tailscale, repository access, a Graph API +key, an RPC URL, or a local server. + +The older `https://mcp.ikodo.dev/mcp` URL remains a compatibility alias, but new +connections should use the root URL. + +## Claude Code + +Claude Code supports environment-variable expansion in HTTP headers. Add this +project-level `.mcp.json`: + +```json +{ + "mcpServers": { + "deeptrace": { + "type": "http", + "url": "https://mcp.ikodo.dev", + "headers": { + "Authorization": "Bearer ${DEEPTRACE_TOKEN}" + }, + "alwaysLoad": true + } + } +} +``` + +`alwaysLoad` keeps DeepTrace's single read-only tool immediately available. The +file contains only the variable reference, not the secret. + +## OpenCode + +Add this to the user-level `~/.config/opencode/opencode.json` or to a project's +`opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "deeptrace": { + "type": "remote", + "url": "https://mcp.ikodo.dev", + "enabled": true, + "oauth": false, + "headers": { + "Authorization": "Bearer {env:DEEPTRACE_TOKEN}" + } + } + } +} +``` + +Setting `oauth` to `false` tells OpenCode that this server uses the configured +bearer token instead of starting OAuth discovery. + +## Codex + +Add this to `~/.codex/config.toml`, or to `.codex/config.toml` in a trusted +project: + +```toml +[mcp_servers.deeptrace] +url = "https://mcp.ikodo.dev" +bearer_token_env_var = "DEEPTRACE_TOKEN" +``` + +The Codex CLI, IDE extension, and ChatGPT desktop Codex experience share this +MCP configuration. + +## Set the token and verify + +In Bash or Zsh, read the token without writing it to shell history: + +```sh +read -rsp "DeepTrace token: " DEEPTRACE_TOKEN +export DEEPTRACE_TOKEN +``` + +Launch the AI client from that shell. Then verify the connection: + +```sh +claude mcp list +opencode mcp list +codex mcp list +``` + +Claude Code and Codex use DeepTrace's MCP server instructions to understand +when and how to use the tool. OpenCode receives the same safety guidance in the +tool description and all three clients receive a declared output schema plus +structured results. + +## Any client, through mcp-remote + +Clients that only launch a local command — or that cannot attach a custom +header — reach DeepTrace through the `mcp-remote` bridge. This is the widely +used shape for hosted MCP servers and works anywhere `mcpServers` is understood: + +```json +{ + "mcpServers": { + "deeptrace": { + "command": "npx", + "args": ["mcp-remote", "--header", "Authorization:${AUTH_HEADER}", "https://mcp.ikodo.dev"], + "env": { + "AUTH_HEADER": "Bearer " + } + } + } +} +``` + +Note the missing space after `Authorization:`. `mcp-remote` splits each +`--header` argument on the first colon, and a value containing a space can be +mangled by the shell that spawns it, so the scheme and the token both live in +`AUTH_HEADER` instead. Keep the real token in your environment or secret store +and leave the `${AUTH_HEADER}` reference in the file. + +Prefer a native configuration from the sections above when your client has one: +it is one less process and one less dependency. Use `mcp-remote` only when the +client cannot send a header itself. + +## Other clients + +Use the four settings in the table when a client supports remote Streamable +HTTP MCP servers and custom headers. Client configuration formats differ, so +prefer the client's own secret storage over copying a token into plain JSON. + +Some hosted chat connectors accept only OAuth or unauthenticated MCP servers +and cannot attach a fixed bearer header. DeepTrace advertises OAuth 2.1 +authorization code with PKCE, so those clients can connect with no header at +all — add the URL alone and approve the consent page. In any client, do not +paste a token into chat or add it to the URL. + +For general remote-server connection guidance, see the +[official MCP guide](https://modelcontextprotocol.io/docs/develop/connect-remote-servers). + +## Try it + +After the client reports that `deeptrace` is connected, ask: + +> Compare Base WETH/USDC pools over the last 24 hours by volume. Tell me which +> sources answered, whether Nuthatch is fresh, and show any warnings. + +The available `compare_pools` tool currently supports: + +- Base chain ID `8453` +- WETH `0x4200000000000000000000000000000000000006` +- native USDC `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` +- `24h` or `7d` +- ranking by TVL, volume, or fees +- one to three ranked pools + +Graph subgraphs provide pool TVL, volume, and fee metrics. Nuthatch separately +reports whether recent Uniswap V3 swap indexing is fresh for its registered +pool. Nuthatch does not provide the USD metrics and does not prove subgraph +parity. + +A `partial` result can still contain useful evidence. Read its coverage, +freshness, warnings, and provenance before relying on the ranking. + +For large swaps, ask: + +> Find swaps of at least 5 WETH in the supported Base pool. Show exact token +> amounts, transaction and block identities, source freshness, and whether +> another page is available. + +`find_large_swaps` supports only the registered Uniswap V3 pool +`0x6c561b446416e1a00e8e93e221854d6ea4171372`, with an exact positive WETH or +native-USDC human-unit threshold and page size from 1 to 100. It does not +calculate USD notional. Continue with the returned opaque cursor without +changing the request scope. + +## Optional agent skill + +The MCP connection is the only required setup. Claude Code, OpenCode, and Codex +receive the tool schema, structured results, and essential safety instructions +from the server. + +For a richer workflow, install the optional +[`deeptrace-pool-research`](../skills/deeptrace-pool-research/SKILL.md) skill +from the project where you use your AI: + +```sh +npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research +``` + +The open-source `skills` CLI detects supported agents and asks which ones to +target. Keep the confirmation prompt so you can review the source before +installing it. The default project-scoped install is portable across Claude +Code, OpenCode, and Codex and is easier to audit with the rest of the project. +Start a new AI session after installation. + +On Windows, run the same command with `npx.cmd`. If Node.js is unavailable, +download or clone the complete skill folder—not only `SKILL.md`—and place it at +one of these project paths: + +- Claude Code: `.claude/skills/deeptrace-pool-research/` +- OpenCode or Codex: `.agents/skills/deeptrace-pool-research/` + +The skill preserves exact decimal strings, separates Graph metrics from +Nuthatch facts, follows stable large-swap cursors, surfaces partial or failed +coverage, and avoids invented fallbacks. It does not configure the MCP +connection, receive the bearer token, or replace the required URL-and-token +setup. + +## Troubleshoot + +| Symptom | Meaning | +| --- | --- | +| `401 Unauthorized` | The server is reachable, but the bearer token is missing, invalid, or revoked. Take a fresh one at `/auth`, or remove the header and let the client run OAuth. | +| `404 Not Found` | Check that the hostname is exactly `mcp.ikodo.dev`; do not use `mcp.icodo.dev`. | +| Timeout or DNS failure | Check the exact hostname and the local network. Tailscale is not required. | +| Connected, but result is `partial` | Read `warnings` and `coverage`; one source may be unavailable or stale. | +| Client offers OAuth only | Supported. Add the URL with no header and approve the consent page. | +| `Needs authentication` from `claude mcp list` | Expected before the first OAuth approval. Run `/mcp` in Claude Code to finish it. | +| `invalid_redirect_uri` during OAuth | The client asked to be called back at an address the server will not send a code to. Report the client and its callback shape. | diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..8c09823 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,85 @@ +# Nuthatch Deployment + +The Nuthatch nest is versioned with DeepTrace in the +[`ikodo0/deeptrace`](https://github.com/ikodo0/deeptrace) repository under +`nest/`. + +## Target + +- Proxmox node: `pve` +- Container: CT 104 (`wallet-intel`) +- Installed Nuthatch version: `0.6.1` +- Deployment directory: `/var/lib/nuthatch` +- Service user and group: `nuthatch:nuthatch` +- Service unit: `nuthatch.service` +- Local listener: `127.0.0.1:8288` +- Tailnet endpoint: `https://` +- Admin UI: disabled with `--no-admin` + +The systemd unit uses: + +```text +WorkingDirectory=/var/lib/nuthatch +EnvironmentFile=/etc/default/nuthatch +ExecStart=/usr/local/bin/nuthatch dev --dir ${NUTHATCH_DIR} --listen ${NUTHATCH_LISTEN} --rpc ${BASE_RPC_URL_PRIMARY} --rpc ${BASE_RPC_URL_SECONDARY} --rpc ${BASE_RPC_URL_TERTIARY} $NUTHATCH_EXTRA_ARGS +``` + +Nuthatch 0.6.1 reads `rpc_urls` in `nest/nuthatch.toml` literally, so the +committed nest retains credential-free HTTPS fallbacks. Define +`BASE_RPC_URL_PRIMARY`, `BASE_RPC_URL_SECONDARY`, and +`BASE_RPC_URL_TERTIARY` in the root-owned `/etc/default/nuthatch`, using +independent providers. The unit passes them as ordered, repeatable `--rpc` +arguments, which Nuthatch tries before the committed fallbacks. Keyed URLs must +never be committed, printed by a probe, or copied into evidence. + +## Deploy + +Autodeploy ships only the contents of `nest/`; the Nuthatch binary is managed +separately on CT 104. The deployment must: + +1. Stage the new nest without replacing the active directory. +2. Run `nuthatch check` against the staged nest. +3. Stop if validation fails, leaving the active nest and service unchanged. +4. Sync the validated nest to `/var/lib/nuthatch` with ownership + `nuthatch:nuthatch`. +5. Restart `nuthatch.service`. +6. Require `/ready` to return HTTP 200 before declaring success. + +Nuthatch remains bound to loopback. Tailscale Serve publishes port 8288 only +inside the tailnet; do not enable Funnel or a LAN listener. + +## Backfill watchdog + +`npm run watch:nuthatch` performs one loopback-only `/ready` sample and stores +the last observed high-water block. It alerts when the indexed block regresses, +`ready` is false, or a lagging indexer has made no progress for 600 seconds. +The reported `stalled` field is diagnostic only and never controls the alarm. +Output is one credential-free JSON line. Exit code `0` is healthy/observing, +`2` is a confirmed alert, and `1` is a watchdog or endpoint error. + +Install the report-only timer on CT 104 after `/opt/deeptrace` contains this +revision: + +```sh +install -m 0644 /opt/deeptrace/deploy/systemd/nuthatch-watchdog.service \ + /etc/systemd/system/nuthatch-watchdog.service +install -m 0644 /opt/deeptrace/deploy/systemd/nuthatch-watchdog.timer \ + /etc/systemd/system/nuthatch-watchdog.timer +systemctl daemon-reload +systemctl enable --now nuthatch-watchdog.timer +``` + +Inspect alerts with: + +```sh +systemctl status nuthatch-watchdog.service +journalctl -u nuthatch-watchdog.service -n 20 +``` + +The repository has no alert destination or privileged recovery unit, so the +watchdog deliberately runs as `nuthatch` and cannot restart services. On a +confirmed `no_progress` alert, an operator must run +`systemctl restart nuthatch.service`, then verify that `last_block` advances +across at least two timer intervals. Wire the failed unit into the host's +existing alert target before enabling unattended recovery. Do not grant the +watchdog user `systemctl` privileges or expose port 8288 publicly. diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md new file mode 100644 index 0000000..ea5e40e --- /dev/null +++ b/docs/mcp-testing.md @@ -0,0 +1,604 @@ +# MCP server testing guide + +This document explains how to run the DeepTrace MCP server and how to confirm it +answers a real tool call. It covers the local stdio and HTTP transports, the +public MCP gateway, its private Nuthatch source, and the operations needed on +container CT 104. It is written for someone who has never run this repo before. + +The single most important boundary is that MCP clients call only +`https://mcp.ikodo.dev`. Nuthatch is an internal source called by the DeepTrace +server; it is never exposed to an end user's machine. + +## Two surfaces + +There are two HTTP services, but only the DeepTrace MCP gateway is public: + +| Surface | URL | Backs onto | Who calls it | +| --------------------- | ----------------------- | --------------------------- | ------------------------- | +| DeepTrace MCP gateway | `https://mcp.ikodo.dev` | `127.0.0.1:8787` | MCP clients | +| Nuthatch source API | `http://127.0.0.1:8288` | co-located Nuthatch service | the DeepTrace server only | + +The root URL is canonical. `/mcp` remains a legacy compatibility alias for +already configured clients. Routes such as `/health`, `/ready`, `/nest`, +`/schema`, and `/sql` belong to Nuthatch and are intentionally unavailable on +the public hostname. + +## For an external collaborator + +This is the whole path for someone who only needs to call the tool. You do not +need Tailscale, a repo checkout, a local build, Graph credentials, or direct +Nuthatch access. + +### What you need from the maintainer + +1. The gateway URL: `https://mcp.ikodo.dev` +2. A bearer token sent through a secure channel + +Keep the token in the client's secret store or an environment variable. Never +put it in a URL, prompt, shell history, repository, or log. + +### Setup + +Register the public server with a client that accepts HTTP MCP headers. For +example, Claude Code can keep only an environment-variable reference in +`.mcp.json`: + +```json +{ + "mcpServers": { + "deeptrace": { + "type": "http", + "url": "https://mcp.ikodo.dev", + "headers": { + "Authorization": "Bearer ${DEEPTRACE_TOKEN}" + }, + "alwaysLoad": true + } + } +} +``` + +Read the secret without placing it in shell history, launch the client from the +same shell, and confirm it connected: + +``` +read -rsp "DeepTrace bearer token: " DEEPTRACE_TOKEN && echo +export DEEPTRACE_TOKEN +claude mcp list # expect: deeptrace: ... (HTTP) - ✔ Connected +``` + +### First call + +Ask the agent to compare Base WETH/USDC pools, or call the `compare_pools` +tool with `chain_id` `8453`, `token0` +`0x4200000000000000000000000000000000000006` and `token1` +`0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`. A healthy production result has +status `complete`, successful Graph coverage, and +`nuthatch_available: true`. A `partial` result is still a valid response when a +source is temporarily stale or unavailable; inspect its warnings and +provenance rather than inventing missing values. + +For the lending tool, ask where to lend USDC on Base, or call +`compare_lending_markets` with `chain_id` `8453` and `market_token` +`0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`. A result with status `complete` +or `partial` is a successful tool call. A partial response identifies the stale +or unavailable source in coverage and warnings. + +For swap history, call `find_large_swaps` with chain `8453`, pool +`0x6c561b446416e1a00e8e93e221854d6ea4171372`, WETH or native USDC as +`threshold_token`, and a positive human-unit `min_amount`. A healthy result is +`complete`; continue only by copying its opaque `next_cursor` into the same +request scope. This tool does not calculate USD notional. + +For source-bounded wallet facts, call `research_wallet` with chain `8453` and a +lowercase public address. A complete result requires a supported Aave position +and activity in the one indexed Nuthatch pool. Partial results preserve usable +sections. Observed assets are not complete wallet balances. + +### If it does not work — report back which one + +| You see | What it means | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| DNS, TLS, or timeout error | The public network path is unavailable. Confirm the hostname is exactly `mcp.ikodo.dev`; no Tailscale hostname is needed. | +| HTTP 401 | You reached the server; the token is wrong or stale. Ask for a reissue. | +| HTTP 404 | Use `https://mcp.ikodo.dev`; `/mcp` is supported only as a legacy alias. Do not append Nuthatch routes. | +| Connected but a source is partial | Read the returned source warnings and provenance; client connectivity succeeded. | + +Nothing here needs repo access, a Graph API key, or a local build. + +## Prerequisites + +Node >= 22. CT 104 runs v22.23.1. + +Environment variables. The app never reads `.env`, so export them in the +shell or systemd unit that starts the server. + +| Variable | Purpose | +| ------------------------------------------ | -------------------------------------------------------------------------------- | +| `DEEPTRACE_HTTP_TOKEN` | Optional shared token, kept only for migration. Minimum 32 characters when set; a shorter value fails startup. Omit it to retire it. | +| `DEEPTRACE_TOKEN_STORE` | Where issued per-client token digests live. Production: `/var/lib/deeptrace/tokens.json`. | +| `DEEPTRACE_HTTP_PORT` | Default `8787`. | +| `DEEPTRACE_HTTP_HOST` | Default `127.0.0.1`. | +| `DEEPTRACE_HTTP_SESSION_IDLE_TIMEOUT_MS` | Idle session lifetime. Default `1800000` (30 minutes). | +| `DEEPTRACE_HTTP_SESSION_SWEEP_INTERVAL_MS` | Idle-session cleanup cadence. Default `60000` (1 minute). | +| `GRAPH_API_KEY` | Required, or every Graph source returns `unavailable`. | +| `NUTHATCH_BASE_URL` | Production: `http://127.0.0.1:8288`, no trailing slash. | + +Authenticated session requests refresh activity, and in-flight tool calls are +not reaped. A standalone SSE stream does not keep an otherwise-idle session +alive forever. A session that remains inactive for the full idle timeout is +closed on the next cleanup sweep, releasing its slot in the 64-session process +limit. + +## Build + +``` +npm ci +npm run build +``` + +`npm run build` is `tsc && node scripts/copy-build-assets.mjs`. The second step +copies `src/**/*.json` into `dist/`. It is not optional: `src/registry/index.ts` +resolves `records.json` relative to the compiled module, so without it every +`compare_pools` call fails with +`Invalid registry configuration: records.json: could not be read`. + +## Run it locally + +### stdio + +This is the default for local Claude Code. + +``` +node dist/index.js +``` + +Verified handshake, three JSON-RPC lines on stdin: + +``` +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"c","version":"1"}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +``` + +Verified reply to `initialize`: + +``` +{"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"deeptrace","version":"0.1.0"}},"jsonrpc":"2.0","id":1} +``` + +### HTTP + +``` +DEEPTRACE_HTTP_TOKEN= DEEPTRACE_HTTP_PORT=8799 node dist/http.js +``` + +Logs: + +``` +[deeptrace] MCP HTTP transport listening on 127.0.0.1:8799 +``` + +## Test the HTTP transport + +### Fast path: the smoke script + +One command runs the whole handshake and prints a result per step. Use this +first; drop to the manual steps below only when something fails and you need to +see the raw exchange. + +``` +read -rsp "DeepTrace bearer token: " DEEPTRACE_HTTP_TOKEN && echo +export DEEPTRACE_HTTP_TOKEN +DEEPTRACE_MCP_URL=https://mcp.ikodo.dev npm run smoke:mcp +``` + +Example transcript shape (statuses and coverage ratios vary by live source +health; the smoke script must exercise `tools/list` plus one `tools/call` for +each of the four released tools): + +``` +auth gate (no token) PASS status=401 (expected 401) +unknown path PASS status=404 (expected 404) +initialize PASS status=200 session=established +notifications/initialized PASS status=202 (expected 202) +tools/list PASS tools=[compare_pools,compare_lending_markets,find_large_swaps,research_wallet] +tools/call compare_pools PASS status=complete 2/2 +tools/call compare_lending_markets PASS status=complete 3/3 +tools/call find_large_swaps PASS status=complete 1/1 +tools/call research_wallet PASS status=partial 1/2 +9 passed, 0 failed +``` + +Both variables are required; missing ones are reported by name only. The exit +code is 0 only when every check passes, so it works unchanged in CI or a +post-deploy hook. Export `DEEPTRACE_HTTP_TOKEN` from a secure prompt or secret +manager rather than writing a real value into this command. Point +`DEEPTRACE_MCP_URL` at `http://127.0.0.1:8787` to check a local instance +instead. The legacy `https://mcp.ikodo.dev/mcp` and +`http://127.0.0.1:8787/mcp` aliases remain available to existing +configurations. After a successful initialize, the script always makes a +best-effort authenticated `DELETE` to close the Streamable HTTP session, +including when a later check fails. Tokens and session IDs are never printed. + +For `compare_pools` and `compare_lending_markets`, both `complete` and +`partial` prove that the call completed. The LSS smoke check requires +`find_large_swaps` to be `complete` with 1/1 source coverage. A production +`compare_pools` call normally returns `complete`; its `partial` status means at +least one upstream source was stale or unavailable and must be explained from +the returned warnings. `find_large_swaps` has no partial status: an unavailable +sole source is `failed`. The wallet smoke accepts `complete` or `partial` with +at least one usable source; live two-source completion remains a separate +deployment gate. + +### Manual path + +The HTTP handshake is four steps followed by session cleanup. Skipping step 2 +or the negotiated protocol-version header is the usual mistake. + +Streamable HTTP requires both content types in `Accept`: + +``` +Accept: application/json, text/event-stream +``` + +Responses come back as SSE frames prefixed with `data: `. + +### Step 1 — initialize + +The session id comes back in the `mcp-session-id` header. + +``` +SID=$(curl -sS -D - -o /dev/null -X POST http://127.0.0.1:8799 \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \ + | tr -d '\r' | awk -F': ' '/^mcp-session-id/{print $2}') +``` + +### Step 2 — initialized notification + +Returns HTTP 202. Requests sent before this are rejected. + +``` +curl -sS -X POST http://127.0.0.1:8799 \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' +``` + +### Step 3 — tools/list + +Verified to return four read-only tools: `compare_pools`, +`compare_lending_markets`, `find_large_swaps`, and `research_wallet`. + +``` +curl -sS -X POST http://127.0.0.1:8799 \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' +``` + +### Step 4 — tools/call compare_pools + +Arguments are locked by the schema: `chain_id` must be `8453`, `token0` must be +the WETH address, `token1` the native USDC address. `window` is `"24h"` or +`"7d"`. `ranked_by` is `"tvl_usd"`, `"volume_usd"` or `"fees_usd"`. `top_n` +1..3. + +``` +curl -sS --max-time 90 -X POST http://127.0.0.1:8799 \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"compare_pools","arguments":{"chain_id":8453,"token0":"0x4200000000000000000000000000000000000006","token1":"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913","window":"24h","ranked_by":"tvl_usd"}}}' +``` + +Point-in-time sample from the Messari standardized fee-tier scope (values +change; shape does not): + +``` +status: partial +coverage: {"requested_deployments":2,"successful_deployments":2,"nuthatch_available":false} +pool 0x6c561b44... (0.3% tier) tvl 114858626.99 +pool 0xd0b53d92... (0.05% tier) tvl 10638092.50 +``` + +The specific values, freshness block, and status may change. If the result is +`partial`, use its warnings and per-source provenance to identify the degraded +source. + +### Step 5 — tools/call compare_lending_markets + +The lending arguments are locked the same way: `chain_id` must be `8453` and +`market_token` the native USDC address. `ranked_by` is `"tvl_usd"`, +`"total_deposit_balance_usd"`, `"total_borrow_balance_usd"`, +`"lender_variable_rate_percent"` or `"borrower_variable_rate_percent"`. `top_n` +1..3. + +``` +curl -sS --max-time 90 -X POST http://127.0.0.1:8799 \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"compare_lending_markets","arguments":{"chain_id":8453,"market_token":"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913","ranked_by":"tvl_usd"}}}' +``` + +Point-in-time sample (values change; shape does not): + +``` +status: complete +coverage: {"requested_sources":3,"successful_sources":3} +aave-v3 tvl 172445408.88 lend 3.517726% borrow 4.420738% +moonwell tvl 15066735.98 lend 4.116566% borrow 5.238681% +seamless-protocol tvl 205400.29 lend 0.110624% borrow 1.045268% is_active false +``` + +This tool reaches no Nuthatch view, so `complete` is the normal outcome when all +three subgraphs answer. Seamless reporting its USDC market inactive is a fact +the response carries through in `is_active` and a warning, not a failure. + +### Step 6 — tools/call find_large_swaps + +Call the released large-swap tool in the same session: + +``` +curl -sS --max-time 90 -X POST http://127.0.0.1:8799 \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"find_large_swaps","arguments":{"chain_id":8453,"pool_address":"0x6c561b446416e1a00e8e93e221854d6ea4171372","threshold_token":"0x4200000000000000000000000000000000000006","min_amount":"1","limit":1}}}' +``` + +`complete` may contain zero matches. `failed` means the sole Nuthatch swap +source could not prove a stable fresh page; inspect warnings and do not invent +a fallback. The requested limit is a cap: the server may return a shorter page +with `has_more` and a warning to remain within the 64 KiB response budget. + +Call Wallet Research with the retained public Graph test address: + +``` +curl -sS --max-time 90 -X POST http://127.0.0.1:8799 \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -H "MCP-Protocol-Version: 2025-06-18" \ + -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"research_wallet","arguments":{"chain_id":8453,"address":"0x5cb3787a9c9c7547451ca3e6d8702453de35fe01","window":"24h","limit":5}}}' +``` + +Surface section coverage and every warning. A partial response may contain +valid Graph positions while the dedicated Nuthatch wallet view is unavailable. + +### Cleanup — close the session + +Close the Streamable HTTP session even when a later check fails: + +``` +curl -sS -X DELETE http://127.0.0.1:8799 \ + -H "Authorization: Bearer " \ + -H "mcp-session-id: $SID" \ + -H "MCP-Protocol-Version: 2025-06-18" +``` + +## Connect a client + +Use `https://mcp.ikodo.dev` plus an environment-backed bearer token. See +[`docs/connect.md`](connect.md) for tested Claude Code, OpenCode, and Codex +configuration examples. Nuthatch is not a second client connection. + +For Claude Code, `claude mcp list` should print +`deeptrace: ... (HTTP) - ✔ Connected`. + +Remove with: + +``` +claude mcp remove deeptrace +``` + +## Verify the Nuthatch source + +Run these checks on CT 104 with +`NUTHATCH_BASE_URL=http://127.0.0.1:8288`. Production DeepTrace uses this +co-located loopback URL, so Nuthatch remains unavailable from the public +internet. An operator may also use the separately restricted tailnet Serve +endpoint to diagnose Nuthatch itself, but it is not an MCP client URL and is not +part of normal request routing. + +Three checks pass on a healthy deployment. + +### 1. /ready + +``` +curl -sS $NUTHATCH_BASE_URL/ready +``` + +Verified: + +``` +{"lag_blocks":45848,"ready":true,"sealed_through":49065709,"stalled":false,"tip":49111557} +``` + +`200` means ready. `503` maps to status `stale` in the adapter. +For operations, HTTP 200 is not sufficient: compare `last_block` (or +`sealed_through` on Nuthatch 0.6.1) across polls. The committed watchdog does +this independently of the unreliable `stalled` field. + +### 2. /nest + +``` +curl -sS $NUTHATCH_BASE_URL/nest +``` + +`registry_hash` must equal the registry's `deployment_or_view_id`: + +``` +0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25 +``` + +Verified: matches. + +### 3. /sql + +``` +curl -sS -G --data-urlencode "q=SELECT * FROM pool_swap_freshness LIMIT 1" \ + --data-urlencode "max_rows=1" $NUTHATCH_BASE_URL/sql +``` + +Verified: one row, shape +`{"count":1,"provenance":{...},"rows":[{...}],"truncated":false}`. + +Row fields: `pool_address`, `recent_swap_count_24h`, `last_swap_block`, +`last_swap_block_timestamp`, `last_swap_log_index`, `last_swap_block_hash`, +`last_swap_tx_hash`. + +The LSS and wallet adapters require readable Swap rows. DeepTrace queries +`pool__swap` with the same projection as `pool_swap_search` / +`wallet_swap_activity` (address/hash compared via string literals, never +`CAST(... AS VARCHAR)`). Authored views remain for nest checks and discovery: + +``` +curl -sS -G --data-urlencode \ + "q=SELECT address AS pool_address, block_number, block_hash, block_timestamp, tx_hash AS transaction_hash, log_index, amount0 AS amount0_raw, amount1 AS amount1_raw FROM pool__swap ORDER BY block_number DESC, log_index DESC, transaction_hash ASC LIMIT 1" \ + --data-urlencode "max_rows=1" $NUTHATCH_BASE_URL/sql +``` + +Its receipt must contain the locked pool address, block number/hash/timestamp, +transaction hash, log index, and exact signed `amount0_raw`/`amount1_raw`. +Run the committed `swap_search_parity` and `wallet_activity_parity` nest checks +before accepting a new nest bundle. + +## Troubleshooting + +| Symptom | Cause | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Public URL times out or does not resolve | Confirm the URL is exactly `https://mcp.ikodo.dev` and test public DNS/TLS. A normal user does not need Tailscale. | +| HTTP 401 | Wrong or missing bearer token. The network path is fine — 401 means the server was reached. | +| HTTP 403 `invalid_origin` | A browser or proxy sent an untrusted `Origin`. Native MCP clients normally omit it; browser-based requests must use `https://mcp.ikodo.dev`. | +| HTTP 404 on `/health` or `/ready` | These are private Nuthatch routes, not public MCP routes. Use the MCP root URL; operators run Nuthatch probes on CT 104 loopback. | +| HTTP 400 `missing_session` | `tools/*` sent without the `mcp-session-id` header, or before the `initialized` notification. | +| Server exits at startup | `DEEPTRACE_HTTP_TOKEN` is set but shorter than 32 characters. Omitting it entirely is valid and retires it. | +| `records.json could not be read` | Built with bare `tsc`. Re-run `npm run build`. | +| All sources `unavailable` | `GRAPH_API_KEY` not set in the server's environment. | +| Graph succeeds but Nuthatch is unavailable | Confirm `NUTHATCH_BASE_URL=http://127.0.0.1:8288`, then probe `/ready` locally and inspect `nuthatch.service`. Do not replace loopback with the tailnet hostname. | +| Internal tailnet diagnostic times out | The restricted Tailscale Serve path or ACL identity may be wrong. Test loopback first; this does not affect what URL an MCP user should configure. | +| Connected but zero tools | A stale build is deployed. Rebuild and restart. | + +For an internal tailnet diagnostic, ACL denials can look like timeouts. Check +the same Nuthatch route on `127.0.0.1:8288` before concluding the service is +down. Never ask an external MCP user to join the tailnet as a workaround. + +## CT 104 operations + +Service: `deeptrace-http.service`, `User=deploy`, +`WorkingDirectory=/opt/deeptrace`, `ExecStart=/usr/bin/node dist/http.js`, +`EnvironmentFile=/etc/deeptrace/http.env` (root:root, 0600, holds +`DEEPTRACE_HTTP_TOKEN`, `GRAPH_API_KEY`, `NUTHATCH_BASE_URL`, +`DEEPTRACE_TOKEN_STORE`). +Production sets `NUTHATCH_BASE_URL=http://127.0.0.1:8288`; the public proxy +routes only to the DeepTrace HTTP service. + +### Issued token store + +Self-serve tokens live in `DEEPTRACE_TOKEN_STORE`, set to +`/var/lib/deeptrace/tokens.json` in production. Two things make that path +load-bearing, and both are easy to undo by accident: + +- The unit sets `ProtectSystem=strict`, so the whole filesystem is read-only + to the service. The store therefore needs a directory systemd has explicitly + made writable. A drop-in at + `/etc/systemd/system/deeptrace-http.service.d/10-token-store.conf` supplies + `StateDirectory=deeptrace` and `StateDirectoryMode=0700`, which creates + `/var/lib/deeptrace` owned by `deploy` and writable. Without it, minting a + token fails with `EROFS`. +- The store must sit outside `/opt/deeptrace`, because the deploy rsyncs that + tree with `--delete`. Leaving the store at its in-repo default would discard + every issued token on the next deploy. + +The file holds SHA-256 digests, never tokens, so it is enough to revoke a +credential but not to use one. Revoking is removing its entry and restarting. + +Access is via the Proxmox host; there is no direct SSH into the container: + +``` +ssh root@pve 'pct exec 104 -- systemctl status deeptrace-http.service' +ssh root@pve 'pct exec 104 -- journalctl -u deeptrace-http.service -n 50' +ssh root@pve 'pct exec 104 -- systemctl restart deeptrace-http.service' +``` + +Autodeploy: `deeptrace-pull-deploy.timer` fires every 60s, polls +`origin/develop`, rsyncs to `/opt/deeptrace`, runs `npm ci` and +`npm run build`, then restarts `deeptrace-http.service`. `dist/` and +`node_modules/` are excluded from the rsync `--delete` because they are build +products absent from the git tree; without those excludes a deploy deletes the +running app's runtime. + +Force a deploy: + +``` +ssh root@pve 'pct exec 104 -- systemctl start deeptrace-pull-deploy.service' +``` + +Nuthatch loads its ordered RPC fallbacks from the root-owned +`/etc/default/nuthatch`. Configure independent providers in +`BASE_RPC_URL_PRIMARY`, `BASE_RPC_URL_SECONDARY`, and +`BASE_RPC_URL_TERTIARY`; the service passes them as repeatable `--rpc` +arguments before the credential-free committed fallbacks. Never put keyed RPC +URLs in this document, the repository, probe output, or support logs. + +### Retire the shared token + +`DEEPTRACE_HTTP_TOKEN` is one credential every client shared. A single leak of +it exposes everyone and cannot be revoked for one client alone, so it exists +only to keep pre-migration clients working. Clients now hold their own tokens +from `/auth` or from the OAuth flow, each independently revocable. + +The server treats an absent variable as retirement rather than an error, so no +code change or release is needed. While it is still set, every startup logs a +reminder naming it. + +Before retiring it, confirm nothing depends on it: a client still sending it +will start getting `401`. Check that `/var/lib/deeptrace/tokens.json` holds a +token for each active user, and tell users to take one at + or to re-add the server without a header and +approve the consent page. + +Then remove the line from `/etc/deeptrace/http.env` and restart: + +``` +ssh root@pve 'pct exec 104 -- bash -c "sed -i /^DEEPTRACE_HTTP_TOKEN=/d /etc/deeptrace/http.env"' +ssh root@pve 'pct exec 104 -- systemctl restart deeptrace-http.service' +ssh root@pve 'pct exec 104 -- journalctl -u deeptrace-http.service -n 20' +``` + +The reminder line disappears from the log once it is gone. Verify that an +issued token still returns HTTP 200 from `initialize` and that the old shared +value now returns `401`. + +To rotate rather than retire it, write a fresh +`openssl rand -hex 32` into `DEEPTRACE_HTTP_TOKEN`, restart, and reissue to +every client — which is the cost that retiring it removes. + +## Known gaps + +1. Nuthatch can temporarily trail the chain tip during backfill or an RPC + outage. `nuthatch-watchdog.timer` detects ten minutes without indexed-block + progress and emits a structured alert; see `docs/deployment.md` for the + report-only recovery procedure. `compare_pools` invokes the live freshness + adapter and truthfully reports that source as stale or unavailable until it + catches up, producing a `partial` `compare_pools` result and a `failed` + `find_large_swaps` result rather than hiding the gap. +2. The automated test suite remains offline. Operators run `npm run smoke:mcp` + against the public URL as the post-deploy live integration check. +3. `compare_pools` over-fetches eight daily snapshots so a window is never short + a day. When the current partial UTC day has no snapshot yet, all eight + returned days are complete and the response carries a + "8 completed days provided; using the 7 most recent" warning. The aggregate is + still exactly the requested window. diff --git a/docs/source-scope.md b/docs/source-scope.md new file mode 100644 index 0000000..948d27e --- /dev/null +++ b/docs/source-scope.md @@ -0,0 +1,132 @@ +# Live Source Scope — Messari standardized subgraphs on Base + +## Status + +**Current as of 2026-07-26.** Both public tools read Messari standardized +subgraphs (`source_type: "standardized_subgraph"`) on Base. The native +Uniswap-V3-lineage selection that MVP-0 previously shipped is retired; its +record is kept at the end of this document. + +- `compare_pools` binds one Messari `dex-amm` deployment twice, once per + WETH/USDC fee tier, and composes it with the Nuthatch swap-freshness view. +- `compare_lending_markets` runs one Messari lending query template against + three unrelated Base lending protocols on the native-USDC market. + +Base publishes exactly one healthy Messari `dex-amm` deployment, so a +two-protocol DEX comparison inside the standardized category is not available. +Rather than mix schema tiers, DEX breadth comes from two fee tiers of the same +standardized source, and cross-protocol reuse of the standard is demonstrated +by the lending tool. + +## Locked inputs + +- Chain: Base (`chain_id` 8453). +- Pool pair: WETH `0x4200000000000000000000000000000000000006` (18 decimals) / + native USDC `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` (6 decimals). +- Lending market asset: native USDC `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`. +- Schema tier: Tier A, Messari standardized. Every record pins the publisher's + `schemaVersion` and `methodologyVersion` in `records.json`. + +## `compare_pools` sources + +Query `tier-a-dex-pool-metrics-v1`, subgraph +`FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS`, deployment +`QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh`, schema `4.0.1`, methodology +`1.0.0`. + +| `source_id` | Pool | Fee tier | +| :--- | :--- | :--- | +| `messari-uniswap-v3-base-fee030` | `0x6c561b446416e1a00e8e93e221854d6ea4171372` | 30 bps (`feePercentage` `0.3`) | +| `messari-uniswap-v3-base-fee005` | `0xd0b53d9277642d899df5c87a3966a349a798f224` | 5 bps (`feePercentage` `0.05`) | + +Nuthatch continues to supply the swap-freshness fact for the 0.3% pool through +`nuthatch-pool-swaps`. + +### Field mapping and its one methodology caveat + +| Canonical field | Messari source | +| :--- | :--- | +| `pool_address` | `liquidityPool.id` | +| `token0` / `token1` | `liquidityPool.inputTokens[0]` / `[1]` — note `decimals` is an `Int` here, not the `String` native subgraphs return | +| `fee_tier_bps` | `liquidityPool.fees` entry with `feeType: "FIXED_TRADING_FEE"`, whose `feePercentage` is a percent string scaled to basis points | +| `tvl_usd` | `liquidityPool.totalValueLockedUSD` | +| `volume_usd_*` | `liquidityPoolDailySnapshots.dailyVolumeUSD` | +| `fees_usd_*` | `liquidityPoolDailySnapshots.dailyTotalRevenueUSD` | + +**The `dex-amm` standard has no per-day fee field.** `dailyTotalFeesUSD` does not +exist; the closest published value is `dailyTotalRevenueUSD`, the sum of +supply-side and protocol-side revenue accrued that day. On both compared pools +`dailyProtocolSideRevenueUSD` is `0`, so the value equals LP fees today — but +that is a property of those pools, not a conversion DeepTrace performs. The +mapping is recorded in `M0_POOL_METRICS_METHODOLOGY.fees_usd`. + +Snapshots are keyed by `day`, the count of days since the unix epoch, rather +than by a midnight timestamp. The adapter multiplies by 86 400 before handing +snapshots to the shared completed-UTC-day aggregator, so window semantics are +identical across both schema tiers. + +## `compare_lending_markets` sources + +Query `tier-a-lending-market-metrics-v1`, one document served unchanged by all +three deployments. + +| `source_id` | Protocol | Subgraph ID | Deployment ID | Schema | +| :--- | :--- | :--- | :--- | :--- | +| `messari-aave-v3-base` | Aave v3 | `D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9` | `Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL` | `3.1.0` | +| `messari-seamless-base` | Seamless Protocol | `2u4mWUV4xS19ef1MbnxZHWLLMwdPxtVifH46JbonXwXP` | `QmPSmTkJPSKLFn46YdgwMKV5K2c9a3pkWnzDCC4ccCLAXE` | `3.1.0` | +| `messari-moonwell-base` | Moonwell | `33ex1ExmYQtwGVwri1AP3oMFPGSce6YbocBP7fWbsBrg` | `QmeE6TgfRmK2iLAgCLBeXuxJQ2VXLFAeHVMTvmnECiFw7y` | `2.0.1` | + +Rates are the source-reported `Market.rates` entries, in percent, passed through +as decimal strings. `LENDER`/`VARIABLE` and `BORROWER`/`VARIABLE` are present on +all three; `BORROWER`/`STABLE` is absent on Moonwell and is reported as `null` +rather than zero. The Seamless USDC market currently reports `isActive: false`; +it is returned with that flag and a warning rather than dropped, because the +balances and rates it reports are still real. + +Compound v3 was rejected: the published Base query ID answers with +`network: MAINNET`, so it cannot be part of a Base-locked comparison. Because a +deployment listed for one network can index another, the lending query reads +`lendingProtocols.network` back and the adapter rejects any response that does +not self-report `BASE`. All three shipped deployments do. + +## Deployment assertion + +Every accepted Graph response must report `_meta.deployment` equal to the +registry-pinned hash using an exact, case-sensitive comparison. A mismatch maps +to `status: "unsupported"`, `data: null`, and a warning naming both hashes. +Reliable freshness from the same `_meta` response may be retained. Lending +responses additionally have to self-report the expected network; see above. + +## Evidence and limitations + +Tier-A pool captures live under `tests/integration/__evidence__/m3/` and are +replayed by the unit suite, which also asserts the captured request text still +equals the shipped query constant. Re-capture with: + +```sh +GRAPH_API_KEY=... node scripts/m3/capture-tier-a-evidence.mjs +``` + +The earlier Tier-B scouting sweep stays under +`tests/integration/__evidence__/m2/`. Captures are point-in-time observations; +publishers may update the deployment behind a stable subgraph ID, which is +exactly what the deployment assertion exists to catch. + +## Superseded: the M2 native Tier-B selection + +The prior scope compared two native Uniswap-V3-lineage deployments — Uniswap V3 +`QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR` (pool +`0x6c561b44…`, 30 bps) and PancakeSwap V3 +`QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g` (pool `0x72ab388e…`, 1 bps) — +after the original three-source criterion went unmet and Aerodrome +(`GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM`) exceeded the binding 15-second +pool-discovery deadline. That sweep also established why the other candidates +were unusable: SushiSwap's `factories` marker was coincidental and its lineage +has no compatible `Pool`/`PoolDayData`; Aerodrome Slipstream renames or omits +required pool and day fields; Balancer V2, BaseSwap V2, and the first Pancake +candidate matched neither supported tier; four community candidates returned no +usable `_meta` deployment. + +No profile binds the native tier today, but the `m2-tier-b-metrics-v1` query and +its adapter path remain implemented and tested, so a native deployment can be +compared again through a `records.json` plus `compare-pools.json` edit alone. diff --git a/docs/wallet-source-scope.md b/docs/wallet-source-scope.md new file mode 100644 index 0000000..f402db6 --- /dev/null +++ b/docs/wallet-source-scope.md @@ -0,0 +1,63 @@ +# Wallet Research source scope + +Wallet Research is Base-only and source-bounded. It does not claim complete +wallet history, balances, ownership, P&L, or protocol coverage. + +## Graph coverage + +The selected Graph capability is the Messari Aave v3 Base standardized +deployment: + +- source ID: `messari-aave-v3-base-wallet` +- gateway subgraph ID: `D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9` +- asserted deployment: `Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL` +- schema/methodology: `3.1.0` / `1.1.0` +- supported entities: `accounts`, `positions`, `positionSnapshots` + +A read-only probe on 2026-07-26 verified `accounts`, `positions`, account +filtering, asset/market legs, position side, raw balance, opening block/time, +and latest `PositionSnapshot.balanceUSD`. The public test address +`0x5cb3787a9c9c7547451ca3e6d8702453de35fe01` returned an open WETH collateral +position. The adapter rechecks `_meta.deployment`, account identity, response +shape, and freshness on every call. + +Only supported open positions with `balance_gt: "0"` are returned. USD values +are copied from the latest Graph position snapshot. DeepTrace does not derive a +price or revalue a position. + +## Nuthatch coverage + +The dedicated `wallet_swap_activity` view documents the sender/recipient-aware +Swap projection from the one allowlisted Uniswap v3 WETH/USDC pool: + +`0x6c561b446416e1a00e8e93e221854d6ea4171372` + +The live adapter queries the equivalent `pool__swap` projection directly (same +columns as the view) and filters only a validated lowercase public address, +applies a 24-hour or 7-day window anchored to the frozen indexed snapshot, and +uses deterministic block/log/hash cursor pagination. Address and hash filters +use string literals — never `CAST(... AS VARCHAR)`, which Nuthatch/DuckDB +rejects with HTTP 400. Window aggregates are calculated from the same complete +bounded snapshot rather than from one activity page. A window above the 5,000-row +safety ceiling fails the Nuthatch section explicitly instead of returning +incomplete aggregates. Returned assets and observable flows describe only those +indexed swap events. + +Authored nest views and parity checks remain the deploy gate for nest bundles. +Until a live receipt is verified in the target environment, treat unexpected +`/sql` HTTP failures as source-local unavailability rather than “no activity.” + +## Section matrix + +| Section | Graph | Nuthatch | Meaning | +| :--- | :--- | :--- | :--- | +| `positions` | Aave v3 positions | — | Supported open lending positions | +| `activity` | — | Indexed pool swaps | Sender/recipient-involved events | +| `counterparties` | — | Indexed pool swaps | Event peer only | +| `observable_flows` | — | Indexed pool swaps | Classified swap input/output | +| `observed_assets` | Position legs | Swap legs | Observed, not complete balances | +| `protocol_usage` | Position count | Activity count | Supported-source usage only | + +A `complete` response requires at least one returned Graph position and one +returned Nuthatch activity. Operational success with no load-bearing fact is +reported as `partial` with an explicit warning. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..53b444e --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import eslint from "@eslint/js"; +import prettier from "eslint-config-prettier/flat"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.{js,mjs}"], + extends: [eslint.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, + { + files: ["**/*.ts"], + extends: [eslint.configs.recommended, tseslint.configs.recommendedTypeChecked], + languageOptions: { + globals: globals.node, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + prettier, +); diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..15f4e1e Binary files /dev/null and b/logo.png differ diff --git a/nest/abis/pool.json b/nest/abis/pool.json new file mode 100644 index 0000000..905e300 --- /dev/null +++ b/nest/abis/pool.json @@ -0,0 +1,988 @@ +[ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "indexed": true, + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + } + ], + "name": "Burn", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": true, + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "indexed": true, + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount0", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount1", + "type": "uint128" + } + ], + "name": "Collect", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount0", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount1", + "type": "uint128" + } + ], + "name": "CollectProtocol", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "paid0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "paid1", + "type": "uint256" + } + ], + "name": "Flash", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint16", + "name": "observationCardinalityNextOld", + "type": "uint16" + }, + { + "indexed": false, + "internalType": "uint16", + "name": "observationCardinalityNextNew", + "type": "uint16" + } + ], + "name": "IncreaseObservationCardinalityNext", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint160", + "name": "sqrtPriceX96", + "type": "uint160" + }, + { + "indexed": false, + "internalType": "int24", + "name": "tick", + "type": "int24" + } + ], + "name": "Initialize", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "indexed": true, + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "amount", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + } + ], + "name": "Mint", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": false, + "internalType": "uint8", + "name": "feeProtocol0Old", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "feeProtocol1Old", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "feeProtocol0New", + "type": "uint8" + }, + { + "indexed": false, + "internalType": "uint8", + "name": "feeProtocol1New", + "type": "uint8" + } + ], + "name": "SetFeeProtocol", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "indexed": false, + "internalType": "int256", + "name": "amount0", + "type": "int256" + }, + { + "indexed": false, + "internalType": "int256", + "name": "amount1", + "type": "int256" + }, + { + "indexed": false, + "internalType": "uint160", + "name": "sqrtPriceX96", + "type": "uint160" + }, + { + "indexed": false, + "internalType": "uint128", + "name": "liquidity", + "type": "uint128" + }, + { + "indexed": false, + "internalType": "int24", + "name": "tick", + "type": "int24" + } + ], + "name": "Swap", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "internalType": "uint128", + "name": "amount", + "type": "uint128" + } + ], + "name": "burn", + "outputs": [ + { + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "internalType": "uint128", + "name": "amount0Requested", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "amount1Requested", + "type": "uint128" + } + ], + "name": "collect", + "outputs": [ + { + "internalType": "uint128", + "name": "amount0", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "amount1", + "type": "uint128" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint128", + "name": "amount0Requested", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "amount1Requested", + "type": "uint128" + } + ], + "name": "collectProtocol", + "outputs": [ + { + "internalType": "uint128", + "name": "amount0", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "amount1", + "type": "uint128" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "factory", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "fee", + "outputs": [ + { + "internalType": "uint24", + "name": "", + "type": "uint24" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "feeGrowthGlobal0X128", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "feeGrowthGlobal1X128", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "flash", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "observationCardinalityNext", + "type": "uint16" + } + ], + "name": "increaseObservationCardinalityNext", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint160", + "name": "sqrtPriceX96", + "type": "uint160" + } + ], + "name": "initialize", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "liquidity", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "maxLiquidityPerTick", + "outputs": [ + { + "internalType": "uint128", + "name": "", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + }, + { + "internalType": "uint128", + "name": "amount", + "type": "uint128" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "mint", + "outputs": [ + { + "internalType": "uint256", + "name": "amount0", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "amount1", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "name": "observations", + "outputs": [ + { + "internalType": "uint32", + "name": "blockTimestamp", + "type": "uint32" + }, + { + "internalType": "int56", + "name": "tickCumulative", + "type": "int56" + }, + { + "internalType": "uint160", + "name": "secondsPerLiquidityCumulativeX128", + "type": "uint160" + }, + { + "internalType": "bool", + "name": "initialized", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint32[]", + "name": "secondsAgos", + "type": "uint32[]" + } + ], + "name": "observe", + "outputs": [ + { + "internalType": "int56[]", + "name": "tickCumulatives", + "type": "int56[]" + }, + { + "internalType": "uint160[]", + "name": "secondsPerLiquidityCumulativeX128s", + "type": "uint160[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "name": "positions", + "outputs": [ + { + "internalType": "uint128", + "name": "liquidity", + "type": "uint128" + }, + { + "internalType": "uint256", + "name": "feeGrowthInside0LastX128", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "feeGrowthInside1LastX128", + "type": "uint256" + }, + { + "internalType": "uint128", + "name": "tokensOwed0", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "tokensOwed1", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "protocolFees", + "outputs": [ + { + "internalType": "uint128", + "name": "token0", + "type": "uint128" + }, + { + "internalType": "uint128", + "name": "token1", + "type": "uint128" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint8", + "name": "feeProtocol0", + "type": "uint8" + }, + { + "internalType": "uint8", + "name": "feeProtocol1", + "type": "uint8" + } + ], + "name": "setFeeProtocol", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "slot0", + "outputs": [ + { + "internalType": "uint160", + "name": "sqrtPriceX96", + "type": "uint160" + }, + { + "internalType": "int24", + "name": "tick", + "type": "int24" + }, + { + "internalType": "uint16", + "name": "observationIndex", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "observationCardinality", + "type": "uint16" + }, + { + "internalType": "uint16", + "name": "observationCardinalityNext", + "type": "uint16" + }, + { + "internalType": "uint8", + "name": "feeProtocol", + "type": "uint8" + }, + { + "internalType": "bool", + "name": "unlocked", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int24", + "name": "tickLower", + "type": "int24" + }, + { + "internalType": "int24", + "name": "tickUpper", + "type": "int24" + } + ], + "name": "snapshotCumulativesInside", + "outputs": [ + { + "internalType": "int56", + "name": "tickCumulativeInside", + "type": "int56" + }, + { + "internalType": "uint160", + "name": "secondsPerLiquidityInsideX128", + "type": "uint160" + }, + { + "internalType": "uint32", + "name": "secondsInside", + "type": "uint32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "recipient", + "type": "address" + }, + { + "internalType": "bool", + "name": "zeroForOne", + "type": "bool" + }, + { + "internalType": "int256", + "name": "amountSpecified", + "type": "int256" + }, + { + "internalType": "uint160", + "name": "sqrtPriceLimitX96", + "type": "uint160" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "swap", + "outputs": [ + { + "internalType": "int256", + "name": "amount0", + "type": "int256" + }, + { + "internalType": "int256", + "name": "amount1", + "type": "int256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int16", + "name": "", + "type": "int16" + } + ], + "name": "tickBitmap", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "tickSpacing", + "outputs": [ + { + "internalType": "int24", + "name": "", + "type": "int24" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "int24", + "name": "", + "type": "int24" + } + ], + "name": "ticks", + "outputs": [ + { + "internalType": "uint128", + "name": "liquidityGross", + "type": "uint128" + }, + { + "internalType": "int128", + "name": "liquidityNet", + "type": "int128" + }, + { + "internalType": "uint256", + "name": "feeGrowthOutside0X128", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "feeGrowthOutside1X128", + "type": "uint256" + }, + { + "internalType": "int56", + "name": "tickCumulativeOutside", + "type": "int56" + }, + { + "internalType": "uint160", + "name": "secondsPerLiquidityOutsideX128", + "type": "uint160" + }, + { + "internalType": "uint32", + "name": "secondsOutside", + "type": "uint32" + }, + { + "internalType": "bool", + "name": "initialized", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "token0", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "token1", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/nest/checks/decimal_columns.sql b/nest/checks/decimal_columns.sql new file mode 100644 index 0000000..fcaf51d --- /dev/null +++ b/nest/checks/decimal_columns.sql @@ -0,0 +1,10 @@ +-- decimal_columns: sampled amount decimal siblings are populated when their +-- overflow flags are false. Checks the last 100 swaps. +SELECT + COUNT(*) AS total_sampled, + COUNT(amount0_dec) AS has_amount0_dec, + COUNT(amount1_dec) AS has_amount1_dec, + SUM(CASE WHEN amount0_dec IS NULL THEN 1 ELSE 0 END) AS amount0_dec_nulls, + SUM(CASE WHEN amount1_dec IS NULL THEN 1 ELSE 0 END) AS amount1_dec_nulls +FROM pool__swap +WHERE block_number >= (SELECT MAX(block_number) - 1000 FROM pool__swap); diff --git a/nest/checks/expected/decimal_columns.json b/nest/checks/expected/decimal_columns.json new file mode 100644 index 0000000..881fbfe --- /dev/null +++ b/nest/checks/expected/decimal_columns.json @@ -0,0 +1,9 @@ +[ + { + "amount0_dec_nulls": "0", + "amount1_dec_nulls": "0", + "has_amount0_dec": 35, + "has_amount1_dec": 35, + "total_sampled": 35 + } +] diff --git a/nest/checks/expected/hot_sealed_no_duplicates.json b/nest/checks/expected/hot_sealed_no_duplicates.json new file mode 100644 index 0000000..ee4b3f1 --- /dev/null +++ b/nest/checks/expected/hot_sealed_no_duplicates.json @@ -0,0 +1,5 @@ +[ + { + "duplicate_count": 0 + } +] diff --git a/nest/checks/expected/latest_swap_identity.json b/nest/checks/expected/latest_swap_identity.json new file mode 100644 index 0000000..50379f5 --- /dev/null +++ b/nest/checks/expected/latest_swap_identity.json @@ -0,0 +1,9 @@ +[ + { + "block_match": true, + "hash_match": true, + "log_index_match": true, + "timestamp_match": true, + "tx_match": true + } +] diff --git a/nest/checks/expected/provenance_not_null.json b/nest/checks/expected/provenance_not_null.json new file mode 100644 index 0000000..0508159 --- /dev/null +++ b/nest/checks/expected/provenance_not_null.json @@ -0,0 +1,11 @@ +[ + { + "has_block_hash": 35, + "has_block_number": 35, + "has_block_timestamp": 35, + "has_log_index": 35, + "has_seq": 35, + "has_tx_hash": 35, + "total_rows": 35 + } +] diff --git a/nest/checks/expected/recent_count_24h.json b/nest/checks/expected/recent_count_24h.json new file mode 100644 index 0000000..75ae726 --- /dev/null +++ b/nest/checks/expected/recent_count_24h.json @@ -0,0 +1,5 @@ +[ + { + "count_match": true + } +] diff --git a/nest/checks/expected/swap_search_parity.json b/nest/checks/expected/swap_search_parity.json new file mode 100644 index 0000000..cfb687a --- /dev/null +++ b/nest/checks/expected/swap_search_parity.json @@ -0,0 +1,5 @@ +[ + { + "mismatches": 0 + } +] diff --git a/nest/checks/expected/view_shape.json b/nest/checks/expected/view_shape.json new file mode 100644 index 0000000..2f992d8 --- /dev/null +++ b/nest/checks/expected/view_shape.json @@ -0,0 +1,12 @@ +[ + { + "has_block": 1, + "has_block_hash": 1, + "has_count": 1, + "has_log_index": 1, + "has_pool_address": 1, + "has_timestamp": 1, + "has_tx_hash": 1, + "row_count": 1 + } +] diff --git a/nest/checks/expected/wallet_activity_parity.json b/nest/checks/expected/wallet_activity_parity.json new file mode 100644 index 0000000..cfb687a --- /dev/null +++ b/nest/checks/expected/wallet_activity_parity.json @@ -0,0 +1,5 @@ +[ + { + "mismatches": 0 + } +] diff --git a/nest/checks/hot_sealed_no_duplicates.sql b/nest/checks/hot_sealed_no_duplicates.sql new file mode 100644 index 0000000..2b3ec23 --- /dev/null +++ b/nest/checks/hot_sealed_no_duplicates.sql @@ -0,0 +1,10 @@ +-- hot_sealed_no_duplicates: no duplicate logical event identity exists +-- across the hot/sealed storage seam. +SELECT + COUNT(*) AS duplicate_count +FROM ( + SELECT block_number, tx_hash, log_index, COUNT(*) AS cnt + FROM pool__swap + GROUP BY block_number, tx_hash, log_index + HAVING COUNT(*) > 1 +) dups; diff --git a/nest/checks/latest_swap_identity.sql b/nest/checks/latest_swap_identity.sql new file mode 100644 index 0000000..385b41e --- /dev/null +++ b/nest/checks/latest_swap_identity.sql @@ -0,0 +1,15 @@ +-- latest_swap_identity: the view's latest-swap fields match the raw table's +-- latest row ordered by (block_number DESC, log_index DESC). +SELECT + v.last_swap_block = r.block_number AS block_match, + v.last_swap_block_timestamp = r.block_timestamp AS timestamp_match, + v.last_swap_block_hash = r.block_hash AS hash_match, + v.last_swap_tx_hash = r.tx_hash AS tx_match, + v.last_swap_log_index = r.log_index AS log_index_match +FROM pool_swap_freshness v +CROSS JOIN ( + SELECT block_number, block_timestamp, block_hash, tx_hash, log_index + FROM pool__swap + ORDER BY block_number DESC, log_index DESC + LIMIT 1 +) r; diff --git a/nest/checks/provenance_not_null.sql b/nest/checks/provenance_not_null.sql new file mode 100644 index 0000000..5269f63 --- /dev/null +++ b/nest/checks/provenance_not_null.sql @@ -0,0 +1,12 @@ +-- provenance_not_null: every sampled swap row has populated block and +-- transaction provenance columns. +SELECT + COUNT(*) AS total_rows, + COUNT(block_number) AS has_block_number, + COUNT(block_hash) AS has_block_hash, + COUNT(block_timestamp) AS has_block_timestamp, + COUNT(tx_hash) AS has_tx_hash, + COUNT(log_index) AS has_log_index, + COUNT(_seq) AS has_seq +FROM pool__swap +WHERE block_number >= (SELECT MAX(block_number) - 1000 FROM pool__swap); diff --git a/nest/checks/recent_count_24h.sql b/nest/checks/recent_count_24h.sql new file mode 100644 index 0000000..fdbb563 --- /dev/null +++ b/nest/checks/recent_count_24h.sql @@ -0,0 +1,9 @@ +-- recent_count_24h: the view's 24h count equals an independent raw-table count +-- over the same anchored window. +SELECT + v.recent_swap_count_24h = ( + SELECT COUNT(*) + FROM pool__swap + WHERE block_timestamp >= (SELECT MAX(block_timestamp) - 86400 FROM pool__swap) + ) AS count_match +FROM pool_swap_freshness v; diff --git a/nest/checks/swap_search_parity.sql b/nest/checks/swap_search_parity.sql new file mode 100644 index 0000000..d708b18 --- /dev/null +++ b/nest/checks/swap_search_parity.sql @@ -0,0 +1,13 @@ +-- swap_search_parity: every search-view row matches its raw event receipt. +SELECT + COUNT(*) AS mismatches +FROM pool_swap_search v +JOIN pool__swap r + ON v.block_number = r.block_number + AND v.transaction_hash = r.tx_hash + AND v.log_index = r.log_index +WHERE v.pool_address <> r.address + OR v.block_hash <> r.block_hash + OR v.block_timestamp <> r.block_timestamp + OR v.amount0_raw <> r.amount0 + OR v.amount1_raw <> r.amount1; diff --git a/nest/checks/view_shape.sql b/nest/checks/view_shape.sql new file mode 100644 index 0000000..a98f1f4 --- /dev/null +++ b/nest/checks/view_shape.sql @@ -0,0 +1,11 @@ +-- view_shape: the freshness view returns exactly one row with all seven columns. +SELECT + COUNT(*) AS row_count, + COUNT(pool_address) AS has_pool_address, + COUNT(recent_swap_count_24h) AS has_count, + COUNT(last_swap_block) AS has_block, + COUNT(last_swap_block_timestamp) AS has_timestamp, + COUNT(last_swap_block_hash) AS has_block_hash, + COUNT(last_swap_tx_hash) AS has_tx_hash, + COUNT(last_swap_log_index) AS has_log_index +FROM pool_swap_freshness; diff --git a/nest/checks/wallet_activity_parity.sql b/nest/checks/wallet_activity_parity.sql new file mode 100644 index 0000000..5002432 --- /dev/null +++ b/nest/checks/wallet_activity_parity.sql @@ -0,0 +1,15 @@ +-- wallet_activity_parity: sender/recipient-aware view rows match raw receipts. +SELECT + COUNT(*) AS mismatches +FROM wallet_swap_activity v +JOIN pool__swap r + ON v.block_number = r.block_number + AND v.transaction_hash = r.tx_hash + AND v.log_index = r.log_index +WHERE v.pool_address <> r.address + OR v.block_hash <> r.block_hash + OR v.block_timestamp <> r.block_timestamp + OR v.sender <> r.sender + OR v.recipient <> r.recipient + OR v.amount0_raw <> r.amount0 + OR v.amount1_raw <> r.amount1; diff --git a/nest/llms.txt b/nest/llms.txt new file mode 100644 index 0000000..3e13513 --- /dev/null +++ b/nest/llms.txt @@ -0,0 +1,32 @@ +# nuthatch nest on base + +A self-hosted blockchain index. Query it locally; there is no third-party API. + +## Contracts +- `pool` = 0x6c561b446416e1a00e8e93e221854d6ea4171372 + +## Tables (one per contract event) +- `pool__burn` - Burn(address,int24,int24,uint128,uint256,uint256) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, owner, tickLower, tickUpper, amount, amount0, amount1) +- `pool__collect` - Collect(address,address,int24,int24,uint128,uint128) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, owner, recipient, tickLower, tickUpper, amount0, amount1) +- `pool__collect_protocol` - CollectProtocol(address,address,uint128,uint128) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, sender, recipient, amount0, amount1) +- `pool__flash` - Flash(address,address,uint256,uint256,uint256,uint256) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, sender, recipient, amount0, amount1, paid0, paid1) +- `pool__increase_observation_cardinality_next` - IncreaseObservationCardinalityNext(uint16,uint16) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, observationCardinalityNextOld, observationCardinalityNextNew) +- `pool__initialize` - Initialize(uint160,int24) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, sqrtPriceX96, tick) +- `pool__mint` - Mint(address,address,int24,int24,uint128,uint256,uint256) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, sender, owner, tickLower, tickUpper, amount, amount0, amount1) +- `pool__set_fee_protocol` - SetFeeProtocol(uint8,uint8,uint8,uint8) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, feeProtocol0Old, feeProtocol1Old, feeProtocol0New, feeProtocol1New) +- `pool__swap` - Swap(address,address,int256,int256,uint160,uint128,int24) (block_number, block_hash, block_timestamp, tx_hash, log_index, address, _seq, sender, recipient, amount0, amount1, sqrtPriceX96, liquidity, tick) +- `pool_swap_search` - read-only raw swap receipt view for DeepTrace Large Swap Search keyset scans +- `wallet_swap_activity` - read-only sender/recipient swap view for source-bounded Wallet Research + +## Live HTTP API (run `nuthatch dev`) +- `GET /` index status +- `GET /tables` every table with its columns +- `GET /table/{name}?limit=N` recent rows of one table (hot + sealed) +- `GET /entity/{id}` one row by id (`{block:012}-{logindex:06}`) +- `GET /sql?q=SELECT...` read-only SQL; each table is a view named `{alias}__{event}` +- `GET /balances?limit=N` top holder balances (when an ERC-20 Transfer table is present) +- `GET /balance/{address}` one address's derived balance + +## MCP (for coding agents) +Run `nuthatch mcp` (stdio) to expose tools: status, schema, tables, table, sql, entity, +balance, top_balances. Fully offline against the local instance; nothing phones home. diff --git a/nest/nuthatch.toml b/nest/nuthatch.toml new file mode 100644 index 0000000..1addbf3 --- /dev/null +++ b/nest/nuthatch.toml @@ -0,0 +1,16 @@ +[nest] +name = "deeptrace-pool-freshness" +chain = "base" +chain_id = 8453 +rpc_urls = [ + "https://base-rpc.publicnode.com", + "https://base.drpc.org", + "https://base-pokt.nodies.app", +] +schema_version = 1 + +[[contracts]] +alias = "pool" +address = "0x6c561b446416e1a00e8e93e221854d6ea4171372" +start_block = 48756851 +abi = "abis/pool.json" diff --git a/nest/schema.json b/nest/schema.json new file mode 100644 index 0000000..b2e877b --- /dev/null +++ b/nest/schema.json @@ -0,0 +1,719 @@ +{ + "registry_hash": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "tables": [ + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": true, + "name": "owner", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": true, + "name": "tickLower", + "sol_type": "int24", + "storage": "i64" + }, + { + "indexed": true, + "name": "tickUpper", + "sol_type": "int24", + "storage": "i64" + }, + { + "indexed": false, + "name": "amount", + "sol_type": "uint128", + "storage": "word16" + }, + { + "indexed": false, + "name": "amount0", + "sol_type": "uint256", + "storage": "word32" + }, + { + "indexed": false, + "name": "amount1", + "sol_type": "uint256", + "storage": "word32" + } + ], + "event": "Burn(address,int24,int24,uint128,uint256,uint256)", + "table": "pool__burn", + "topic0": "0x0c396cd989a39f4459b5fa1aed6a9a8dcdbc45908acfd67e028cd568da98982c" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": true, + "name": "owner", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": false, + "name": "recipient", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": true, + "name": "tickLower", + "sol_type": "int24", + "storage": "i64" + }, + { + "indexed": true, + "name": "tickUpper", + "sol_type": "int24", + "storage": "i64" + }, + { + "indexed": false, + "name": "amount0", + "sol_type": "uint128", + "storage": "word16" + }, + { + "indexed": false, + "name": "amount1", + "sol_type": "uint128", + "storage": "word16" + } + ], + "event": "Collect(address,address,int24,int24,uint128,uint128)", + "table": "pool__collect", + "topic0": "0x70935338e69775456a85ddef226c395fb668b63fa0115f5f20610b388e6ca9c0" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": true, + "name": "sender", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": true, + "name": "recipient", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": false, + "name": "amount0", + "sol_type": "uint128", + "storage": "word16" + }, + { + "indexed": false, + "name": "amount1", + "sol_type": "uint128", + "storage": "word16" + } + ], + "event": "CollectProtocol(address,address,uint128,uint128)", + "table": "pool__collect_protocol", + "topic0": "0x596b573906218d3411850b26a6b437d6c4522fdb43d2d2386263f86d50b8b151" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": true, + "name": "sender", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": true, + "name": "recipient", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": false, + "name": "amount0", + "sol_type": "uint256", + "storage": "word32" + }, + { + "indexed": false, + "name": "amount1", + "sol_type": "uint256", + "storage": "word32" + }, + { + "indexed": false, + "name": "paid0", + "sol_type": "uint256", + "storage": "word32" + }, + { + "indexed": false, + "name": "paid1", + "sol_type": "uint256", + "storage": "word32" + } + ], + "event": "Flash(address,address,uint256,uint256,uint256,uint256)", + "table": "pool__flash", + "topic0": "0xbdbdb71d7860376ba52b25a5028beea23581364a40522f6bcfb86bb1f2dca633" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "observationCardinalityNextOld", + "sol_type": "uint16", + "storage": "u64" + }, + { + "indexed": false, + "name": "observationCardinalityNextNew", + "sol_type": "uint16", + "storage": "u64" + } + ], + "event": "IncreaseObservationCardinalityNext(uint16,uint16)", + "table": "pool__increase_observation_cardinality_next", + "topic0": "0xac49e518f90a358f652e4400164f05a5d8f7e35e7747279bc3a93dbf584e125a" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "sqrtPriceX96", + "sol_type": "uint160", + "storage": "word32" + }, + { + "indexed": false, + "name": "tick", + "sol_type": "int24", + "storage": "i64" + } + ], + "event": "Initialize(uint160,int24)", + "table": "pool__initialize", + "topic0": "0x98636036cb66a9c19a37435efc1e90142190214e8abeb821bdba3f2990dd4c95" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "sender", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": true, + "name": "owner", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": true, + "name": "tickLower", + "sol_type": "int24", + "storage": "i64" + }, + { + "indexed": true, + "name": "tickUpper", + "sol_type": "int24", + "storage": "i64" + }, + { + "indexed": false, + "name": "amount", + "sol_type": "uint128", + "storage": "word16" + }, + { + "indexed": false, + "name": "amount0", + "sol_type": "uint256", + "storage": "word32" + }, + { + "indexed": false, + "name": "amount1", + "sol_type": "uint256", + "storage": "word32" + } + ], + "event": "Mint(address,address,int24,int24,uint128,uint256,uint256)", + "table": "pool__mint", + "topic0": "0x7a53080ba414158be7ec69b987b5fb7d07dee101fe85488f0853ae16239d0bde" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "feeProtocol0Old", + "sol_type": "uint8", + "storage": "u64" + }, + { + "indexed": false, + "name": "feeProtocol1Old", + "sol_type": "uint8", + "storage": "u64" + }, + { + "indexed": false, + "name": "feeProtocol0New", + "sol_type": "uint8", + "storage": "u64" + }, + { + "indexed": false, + "name": "feeProtocol1New", + "sol_type": "uint8", + "storage": "u64" + } + ], + "event": "SetFeeProtocol(uint8,uint8,uint8,uint8)", + "table": "pool__set_fee_protocol", + "topic0": "0x973d8d92bb299f4af6ce49b52a8adb85ae46b9f214c4c4fc06ac77401237b133" + }, + { + "alias": "pool", + "columns": [ + { + "indexed": false, + "name": "block_number", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "block_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "block_timestamp", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "tx_hash", + "sol_type": "implicit", + "storage": "bytes32" + }, + { + "indexed": false, + "name": "log_index", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": false, + "name": "address", + "sol_type": "implicit", + "storage": "address" + }, + { + "indexed": false, + "name": "_seq", + "sol_type": "implicit", + "storage": "u64" + }, + { + "indexed": true, + "name": "sender", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": true, + "name": "recipient", + "sol_type": "address", + "storage": "address" + }, + { + "indexed": false, + "name": "amount0", + "sol_type": "int256", + "storage": "word32" + }, + { + "indexed": false, + "name": "amount1", + "sol_type": "int256", + "storage": "word32" + }, + { + "indexed": false, + "name": "sqrtPriceX96", + "sol_type": "uint160", + "storage": "word32" + }, + { + "indexed": false, + "name": "liquidity", + "sol_type": "uint128", + "storage": "word16" + }, + { + "indexed": false, + "name": "tick", + "sol_type": "int24", + "storage": "i64" + } + ], + "event": "Swap(address,address,int256,int256,uint160,uint128,int24)", + "table": "pool__swap", + "topic0": "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67" + } + ] +} diff --git a/nest/semantic.toml b/nest/semantic.toml new file mode 100644 index 0000000..b90afbb --- /dev/null +++ b/nest/semantic.toml @@ -0,0 +1,48 @@ +# Semantic metadata for the nest/ freshness view. +# +# Describes the single-row result returned by +# `views/pool_swap_freshness.sql` against the Nuthatch instance indexing the +# Uniswap V3 Base WETH/USDC 0.05% pool. + +[view] +name = "pool_swap_freshness" +sql = "views/pool_swap_freshness.sql" +table = "pool__swap" +instance = "https://" + +[view.pool] +chain = "base" +address = "0x6c561b446416e1a00e8e93e221854d6ea4171372" +fee_tier = "0.05%" +protocol = "uniswap-v3" + +[view.freshness] +# The 24h window is anchored on MAX(block_timestamp) - 86400, never on a +# wall clock, so the result is deterministic for a given sealed state. +anchor = "MAX(block_timestamp)" +window_seconds = 86400 +latest_order = ["block_number DESC", "log_index DESC"] +row_count = 1 + +[view.aliases] +pool_address = "Lowercase constant of the indexed pool." +recent_swap_count_24h = "Count of swaps inside the 24h window ending at the anchor." +last_swap_block = "block_number of the latest swap." +last_swap_block_timestamp = "block_timestamp (unix seconds) of the latest swap." +last_swap_block_hash = "block_hash of the latest swap." +last_swap_tx_hash = "tx_hash of the latest swap." +last_swap_log_index = "log_index of the latest swap." + +[view.pool_swap_search] +sql = "views/pool_swap_search.sql" +table = "pool__swap" +order = ["block_number DESC", "log_index DESC", "transaction_hash ASC"] +threshold = "Exact selected-token thresholding occurs after DeepTrace normalization." +pagination = "Bounded keyset scans freeze the first-page searchable head." + +[view.wallet_swap_activity] +sql = "views/wallet_swap_activity.sql" +table = "pool__swap" +scope = "Only sender or recipient matches against the explicitly indexed pool." +order = ["block_number DESC", "log_index DESC", "transaction_hash ASC"] +pagination = "Fixed-snapshot keyset pages are bound to the requested wallet and window." diff --git a/nest/views/pool_swap_freshness.sql b/nest/views/pool_swap_freshness.sql new file mode 100644 index 0000000..af2c075 --- /dev/null +++ b/nest/views/pool_swap_freshness.sql @@ -0,0 +1,26 @@ +-- pool_swap_freshness: one row summarizing recent swap activity for the +-- Uniswap V3 Base WETH/USDC 0.05% pool. Anchored to the latest indexed +-- swap timestamp — no wall clock, no CURRENT_TIMESTAMP, no now(). +-- Described in semantic.toml under [view.pool_swap_freshness]. + +CREATE VIEW pool_swap_freshness AS +SELECT + '0x6c561b446416e1a00e8e93e221854d6ea4171372' AS pool_address, + ( + SELECT COUNT(*) + FROM pool__swap + WHERE block_timestamp >= ( + SELECT MAX(block_timestamp) - 86400 FROM pool__swap + ) + ) AS recent_swap_count_24h, + latest.block_number AS last_swap_block, + latest.block_timestamp AS last_swap_block_timestamp, + latest.block_hash AS last_swap_block_hash, + latest.tx_hash AS last_swap_tx_hash, + latest.log_index AS last_swap_log_index +FROM ( + SELECT block_number, block_timestamp, block_hash, tx_hash, log_index + FROM pool__swap + ORDER BY block_number DESC, log_index DESC + LIMIT 1 +) AS latest; diff --git a/nest/views/pool_swap_search.sql b/nest/views/pool_swap_search.sql new file mode 100644 index 0000000..d43d7d9 --- /dev/null +++ b/nest/views/pool_swap_search.sql @@ -0,0 +1,15 @@ +-- pool_swap_search: canonical raw fields needed by the bounded Large Swap +-- Search adapter. Thresholding remains in DeepTrace so int256 pool deltas are +-- compared with exact BigInt arithmetic after canonical normalization. + +CREATE VIEW pool_swap_search AS +SELECT + address AS pool_address, + block_number, + block_hash, + block_timestamp, + tx_hash AS transaction_hash, + log_index, + amount0 AS amount0_raw, + amount1 AS amount1_raw +FROM pool__swap; diff --git a/nest/views/wallet_swap_activity.sql b/nest/views/wallet_swap_activity.sql new file mode 100644 index 0000000..d897d59 --- /dev/null +++ b/nest/views/wallet_swap_activity.sql @@ -0,0 +1,17 @@ +-- wallet_swap_activity: sender/recipient-aware rows for the bounded Wallet +-- Research adapter. Wallet filtering and fixed-snapshot pagination remain in +-- DeepTrace; this view exposes only the indexed allowlisted pool. + +CREATE VIEW wallet_swap_activity AS +SELECT + address AS pool_address, + block_number, + block_hash, + block_timestamp, + tx_hash AS transaction_hash, + log_index, + sender, + recipient, + amount0 AS amount0_raw, + amount1 AS amount1_raw +FROM pool__swap; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e750bf7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3575 @@ +{ + "name": "deeptrace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deeptrace", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.0.0", + "eslint": "^10.8.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.7.0", + "prettier": "3.9.6", + "typescript": "^5.9.0", + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.10" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b221f8f --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "deeptrace", + "version": "0.1.0", + "private": true, + "description": "Graph research MCP for builders and AI agents", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsc && node scripts/copy-build-assets.mjs", + "format": "prettier . --write", + "format:check": "prettier . --check", + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix", + "start": "node dist/index.js", + "smoke:mcp": "node scripts/mcp-smoke.mjs", + "watch:nuthatch": "node scripts/m4/nuthatch-watchdog.mjs", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit && tsc -p scripts/m2/tsconfig.json" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.0.0", + "eslint": "^10.8.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.7.0", + "prettier": "3.9.6", + "typescript": "^5.9.0", + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.10" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3" + } +} diff --git a/scripts/copy-build-assets.mjs b/scripts/copy-build-assets.mjs new file mode 100644 index 0000000..6936f1a --- /dev/null +++ b/scripts/copy-build-assets.mjs @@ -0,0 +1,44 @@ +import { mkdir, readdir, stat, copyFile } from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const srcDir = join(root, "src"); +const distDir = join(root, "dist"); + +// tsc emits only JS, so every non-source file the runtime reads is copied here. +const ASSET_EXTENSIONS = [".json", ".html", ".woff2"]; + +async function findAssetFiles(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await findAssetFiles(abs))); + } else if (entry.isFile() && ASSET_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) { + files.push(abs); + } + } + return files; +} + +try { + await stat(srcDir); +} catch { + console.error(`copy-build-assets: src/ not found at ${srcDir}`); + process.exit(1); +} + +const files = await findAssetFiles(srcDir); +for (const src of files) { + const rel = relative(srcDir, src); + const dest = join(distDir, rel); + try { + await mkdir(dirname(dest), { recursive: true }); + await copyFile(src, dest); + } catch (err) { + console.error(`copy-build-assets: failed to copy ${rel}: ${err}`); + process.exit(1); + } +} diff --git a/scripts/m2/candidates.json b/scripts/m2/candidates.json new file mode 100644 index 0000000..0af0534 --- /dev/null +++ b/scripts/m2/candidates.json @@ -0,0 +1,100 @@ +[ + { + "slug": "uniswap-v3-base-native", + "protocol_name": "Uniswap V3", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "explorer_url": "https://thegraph.com/explorer/subgraphs/GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "publisher": "Uniswap" + }, + { + "slug": "uniswap-v3-base-native-recent", + "protocol_name": "Uniswap V3", + "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "explorer_url": "https://thegraph.com/explorer/subgraphs/VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "publisher": "Graph Explorer publisher 0xd3bb…9d0632" + }, + { + "slug": "uniswap-v3-base-messari", + "protocol_name": "Uniswap V3", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "explorer_url": "https://thegraph.com/explorer/subgraphs/FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "publisher": "Messari standardized-subgraph publisher" + }, + { + "slug": "sushiswap-v3-base", + "protocol_name": "SushiSwap V3", + "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "explorer_url": "https://thegraph.com/explorer/subgraphs/H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "publisher": "SushiSwap" + }, + { + "slug": "aerodrome-slipstream-base", + "protocol_name": "Aerodrome Slipstream", + "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "explorer_url": "https://thegraph.com/explorer/subgraphs/EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "publisher": "Graph Explorer publisher 0x29ff…100662" + }, + { + "slug": "aerodrome-base-full", + "protocol_name": "Aerodrome", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "explorer_url": "https://thegraph.com/explorer/subgraphs/GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "publisher": "Graph Explorer publisher 0xa4c6…05cc95" + }, + { + "slug": "balancer-v2-base", + "protocol_name": "Balancer V2", + "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "explorer_url": "https://thegraph.com/explorer/subgraphs/E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "publisher": "Balancer" + }, + { + "slug": "base-slipstream-community", + "protocol_name": "Aerodrome Slipstream", + "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", + "explorer_url": "https://thegraph.com/explorer/subgraphs/5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", + "publisher": "Graph Explorer publisher 0x1080…4f5b07" + }, + { + "slug": "pancakeswap-v3-base", + "protocol_name": "PancakeSwap V3", + "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "explorer_url": "https://thegraph.com/explorer/subgraphs/84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "publisher": "Graph Explorer publisher 0x3acc…499c23" + }, + { + "slug": "baseswap-v2-base", + "protocol_name": "BaseSwap V2", + "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "explorer_url": "https://thegraph.com/explorer/subgraphs/SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "publisher": "Graph Explorer publisher 0x4f1a…76928d" + }, + { + "slug": "sushiswap-v3-base-community-a", + "protocol_name": "SushiSwap V3", + "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", + "explorer_url": "https://thegraph.com/explorer/subgraphs/8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", + "publisher": "Graph Explorer publisher 0x3eb4…6a09e3" + }, + { + "slug": "sushiswap-v3-base-community-b", + "protocol_name": "SushiSwap V3", + "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", + "explorer_url": "https://thegraph.com/explorer/subgraphs/9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", + "publisher": "Graph Explorer publisher 0x3acc…499c23" + }, + { + "slug": "pancakeswap-exchange-v3-base", + "protocol_name": "PancakeSwap V3", + "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", + "explorer_url": "https://thegraph.com/explorer/subgraphs/8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", + "publisher": "Graph Explorer publisher 0xb165…84629a" + }, + { + "slug": "exchange-v3-base", + "protocol_name": "PancakeSwap V3 candidate", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "explorer_url": "https://thegraph.com/explorer/subgraphs/BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "publisher": "Graph Explorer publisher 0xd099…e348d4" + } +] diff --git a/scripts/m2/lib/evidence.ts b/scripts/m2/lib/evidence.ts new file mode 100644 index 0000000..c95aea6 --- /dev/null +++ b/scripts/m2/lib/evidence.ts @@ -0,0 +1,78 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { GATEWAY_HOST, type GraphResponse } from "./gateway.ts"; +import type { QueryTemplate } from "./queries.ts"; + +export const EVIDENCE_ROOT = "tests/integration/__evidence__/m2"; + +export interface EvidenceDocument { + readonly captured_at: string; + readonly gateway_host: typeof GATEWAY_HOST; + readonly subgraph_id: string; + readonly query_id: string; + readonly request: { + readonly query: string; + readonly variables: Readonly>; + }; + readonly response: unknown; + readonly transport: { + readonly http_status: number | null; + readonly latency_ms: number; + readonly error: GraphResponse["error"]; + }; +} + +export async function writeEvidence( + slug: string, + filename: string, + subgraphId: string, + template: QueryTemplate, + variables: Readonly>, + result: GraphResponse, + root = EVIDENCE_ROOT, +): Promise { + const directory = path.join(root, slug); + await mkdir(directory, { recursive: true }); + const document: EvidenceDocument = { + captured_at: new Date().toISOString(), + gateway_host: GATEWAY_HOST, + subgraph_id: subgraphId, + query_id: template.queryId, + request: { query: template.query, variables }, + response: result.body, + transport: { + http_status: result.status, + latency_ms: result.latencyMs, + error: result.error, + }, + }; + await writeFile(path.join(directory, filename), `${JSON.stringify(document, null, 2)}\n`, "utf8"); +} + +export async function readManifest(): Promise> { + try { + return JSON.parse(await readFile(path.join(EVIDENCE_ROOT, "manifest.json"), "utf8")) as Record< + string, + unknown + >; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return {}; + } + throw error; + } +} + +export async function writeManifest(manifest: Readonly>): Promise { + await mkdir(EVIDENCE_ROOT, { recursive: true }); + await writeFile( + path.join(EVIDENCE_ROOT, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); +} + +export async function politeGap(): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)); +} diff --git a/scripts/m2/lib/gateway.ts b/scripts/m2/lib/gateway.ts new file mode 100644 index 0000000..e9f5f02 --- /dev/null +++ b/scripts/m2/lib/gateway.ts @@ -0,0 +1,135 @@ +import { readFile } from "node:fs/promises"; + +import type { QueryTemplate } from "./queries.ts"; + +export const GATEWAY_HOST = "gateway.thegraph.com" as const; +const GATEWAY_ORIGIN = `https://${GATEWAY_HOST}/api`; +const TIMEOUT_MS = 15_000; + +export interface GraphResponse { + readonly status: number | null; + readonly body: unknown; + readonly error: { + readonly kind: "timeout" | "transport" | "invalid_json"; + readonly message: string; + } | null; + readonly latencyMs: number; +} + +function parseEnv(contents: string): Map { + const values = new Map(); + for (const rawLine of contents.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + const normalized = line.startsWith("export ") ? line.slice(7) : line; + const separator = normalized.indexOf("="); + if (separator < 1) continue; + const name = normalized.slice(0, separator).trim(); + let value = normalized.slice(separator + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + values.set(name, value); + } + return values; +} + +async function readEnvFile(envPath: string): Promise> { + try { + return parseEnv(await readFile(envPath, "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return new Map(); + } + throw error; + } +} + +export async function loadGraphApiKey(envPath = ".env"): Promise { + const fromEnv = process.env.GRAPH_API_KEY; + if (fromEnv) { + return fromEnv; + } + + const key = (await readEnvFile(envPath)).get("GRAPH_API_KEY"); + if (!key) { + throw new Error("GRAPH_API_KEY is unset or empty."); + } + return key; +} + +function safeErrorMessage(error: unknown): string { + if (error instanceof Error && error.name === "AbortError") { + return "Graph gateway request exceeded the 15 second timeout."; + } + return "Graph gateway request failed; details were redacted."; +} + +export async function postGraphQuery( + subgraphId: string, + template: QueryTemplate, + variables: Readonly> = {}, + options: { + readonly apiKey?: string; + readonly fetchImpl?: typeof fetch; + readonly gatewayOrigin?: string; + } = {}, +): Promise { + const apiKey = options.apiKey ?? (await loadGraphApiKey()); + const fetchImpl = options.fetchImpl ?? fetch; + const gatewayOrigin = options.gatewayOrigin ?? GATEWAY_ORIGIN; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + const startedAt = performance.now(); + + try { + const response = await fetchImpl( + `${gatewayOrigin}/subgraphs/id/${encodeURIComponent(subgraphId)}`, + { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ query: template.query, variables }), + signal: controller.signal, + }, + ); + const text = await response.text(); + try { + return { + status: response.status, + body: JSON.parse(text) as unknown, + error: null, + latencyMs: Math.round(performance.now() - startedAt), + }; + } catch { + return { + status: response.status, + body: null, + error: { + kind: "invalid_json", + message: "Graph gateway returned a non-JSON response.", + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } + } catch (error) { + const timedOut = error instanceof Error && error.name === "AbortError"; + return { + status: null, + body: null, + error: { + kind: timedOut ? "timeout" : "transport", + message: safeErrorMessage(error), + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/scripts/m2/lib/pool-probe.ts b/scripts/m2/lib/pool-probe.ts new file mode 100644 index 0000000..f3f785b --- /dev/null +++ b/scripts/m2/lib/pool-probe.ts @@ -0,0 +1,7 @@ +import type { GraphResponse } from "./gateway.ts"; + +export function poolProbeFailureMessage(error: GraphResponse["error"]): string { + return error + ? error.message + : "The common tier query returned no pool; candidate is incompatible."; +} diff --git a/scripts/m2/lib/queries.ts b/scripts/m2/lib/queries.ts new file mode 100644 index 0000000..a3c0eb7 --- /dev/null +++ b/scripts/m2/lib/queries.ts @@ -0,0 +1,160 @@ +export interface QueryTemplate { + readonly queryId: string; + readonly query: string; +} + +const META = ` + _meta { + block { number timestamp hash } + hasIndexingErrors + deployment + }`; + +export const metaQuery: QueryTemplate = { + queryId: "m2-meta-v1", + query: `query M2Meta {${META}\n}`, +}; + +export const tierAMarkerQuery: QueryTemplate = { + queryId: "m2-tier-a-marker-v2", + query: `query M2TierAMarker {${META} + dexAmmProtocols(first: 1) { + id name schemaVersion methodologyVersion network + } +}`, +}; + +export const tierBMarkerQuery: QueryTemplate = { + queryId: "m2-tier-b-marker-v3", + query: `query M2TierBMarker {${META} + factories(first: 1) { id } +}`, +}; + +export const tokenQuery: QueryTemplate = { + queryId: "m2-tokens-v2", + query: `query M2Tokens($ids: [Bytes!]!) {${META} + tokens(where: { id_in: $ids }) { id symbol name decimals } +}`, +}; + +export const tokenIntrospectionQuery: QueryTemplate = { + queryId: "m2-token-introspection-v1", + query: `query M2TokenIntrospection {${META} + __type(name: "Token") { fields { name type { kind name ofType { kind name } } } } +}`, +}; + +export const tierBLineageQuery: QueryTemplate = { + queryId: "m2-tier-b-lineage-v1", + query: `query M2TierBLineage {${META} + factoryType: __type(name: "Factory") { fields { name } } + poolType: __type(name: "Pool") { fields { name } } + dayType: __type(name: "PoolDayData") { fields { name } } +}`, +}; + +export const tierASnapshotIntrospectionQuery: QueryTemplate = { + queryId: "m2-tier-a-snapshot-introspection-v1", + query: `query M2TierASnapshotIntrospection {${META} + __type(name: "LiquidityPoolDailySnapshot") { fields { name } } +}`, +}; + +export const tierBPoolsQuery: QueryTemplate = { + queryId: "m2-tier-b-pools-v2", + query: `query M2TierBPools($token0: Bytes!, $token1: Bytes!) {${META} + pools( + first: 100 + where: { token0: $token0, token1: $token1 } + orderBy: totalValueLockedUSD + orderDirection: desc + ) { + id feeTier totalValueLockedUSD + token0 { id symbol decimals } + token1 { id symbol decimals } + } +}`, +}; + +export const tierAPoolsQuery: QueryTemplate = { + queryId: "m2-tier-a-pools-v2", + query: `query M2TierAPools($tokens: [Bytes!]!) {${META} + liquidityPools( + first: 100 + where: { inputTokens_contains: $tokens } + orderBy: totalValueLockedUSD + orderDirection: desc + ) { + id name totalValueLockedUSD + inputTokens { id symbol decimals } + } +}`, +}; + +export const tierBSnapshotsQuery: QueryTemplate = { + queryId: "m2-tier-b-snapshots-v2", + query: `query M2TierBSnapshots($pool: Bytes!) {${META} + poolDayDatas( + first: 7 + orderBy: date + orderDirection: desc + where: { pool: $pool } + ) { date volumeUSD feesUSD tvlUSD } +}`, +}; + +export const tierASnapshotsQuery: QueryTemplate = { + queryId: "m2-tier-a-snapshots-v2", + query: `query M2TierASnapshots($pool: String!) {${META} + liquidityPoolDailySnapshots( + first: 7 + orderBy: timestamp + orderDirection: desc + where: { pool: $pool } + ) { + timestamp dailyVolumeUSD dailyTotalFeesUSD totalValueLockedUSD + } +}`, +}; + +export const tierBMetricsQuery: QueryTemplate = { + queryId: "m2-tier-b-metrics-v1", + query: `query M2TierBMetrics($pool: ID!) {${META} + pool(id: $pool) { + id feeTier totalValueLockedUSD + token0 { id symbol decimals } + token1 { id symbol decimals } + } + poolDayDatas( + first: 7 + orderBy: date + orderDirection: desc + where: { pool: $pool } + ) { date volumeUSD feesUSD tvlUSD } +}`, +}; + +export const tierAMetricsQuery: QueryTemplate = { + queryId: "m2-tier-a-metrics-v1", + query: `query M2TierAMetrics($pool: ID!) {${META} + liquidityPool(id: $pool) { + id name totalValueLockedUSD + inputTokens { id symbol decimals } + } + liquidityPoolDailySnapshots( + first: 7 + orderBy: timestamp + orderDirection: desc + where: { pool: $pool } + ) { + timestamp dailyVolumeUSD dailyTotalFeesUSD totalValueLockedUSD + } +}`, +}; + +export const TOKEN_IDS = [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", +] as const; diff --git a/scripts/m2/probe-pools.ts b/scripts/m2/probe-pools.ts new file mode 100644 index 0000000..115a46a --- /dev/null +++ b/scripts/m2/probe-pools.ts @@ -0,0 +1,167 @@ +import { politeGap, readManifest, writeEvidence, writeManifest } from "./lib/evidence.ts"; +import { loadGraphApiKey, postGraphQuery } from "./lib/gateway.ts"; +import { poolProbeFailureMessage } from "./lib/pool-probe.ts"; +import { + tierAMetricsQuery, + tierAPoolsQuery, + tierASnapshotsQuery, + tierASnapshotIntrospectionQuery, + tierBMetricsQuery, + tierBPoolsQuery, + tierBSnapshotsQuery, +} from "./lib/queries.ts"; + +const WETH = "0x4200000000000000000000000000000000000006"; +const NATIVE_USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; + +interface Selection { + readonly slug: string; + readonly subgraph_id: string; + readonly tier: "A" | "B"; + pool_address?: string; + pool_probe_error?: string; +} + +interface Candidate { + readonly slug: string; + readonly subgraph_id: string; + readonly tier: "A" | "B" | "unknown" | "collision"; + readonly verdict: string; + readonly token_probe?: { readonly answered?: boolean }; +} + +function object(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function firstPoolId(body: unknown, field: string): string | null { + const data = object(object(body)?.data); + const pools = data?.[field]; + if (!Array.isArray(pools)) return null; + const first = object(pools[0]); + return typeof first?.id === "string" ? first.id : null; +} + +const manifest = await readManifest(); +const finalSelections = (manifest.final_selections ?? []) as Selection[]; +const candidates = (manifest.candidates ?? []) as Candidate[]; +const selectedTier = + finalSelections[0]?.tier ?? + (candidates.filter( + ({ verdict, tier, token_probe: tokenProbe }) => + verdict === "healthy" && tier === "A" && tokenProbe?.answered, + ).length >= 3 + ? "A" + : "B"); +const selections: Selection[] = + finalSelections.length > 0 + ? finalSelections + : candidates + .filter( + ({ verdict, tier, token_probe: tokenProbe }) => + verdict === "healthy" && tier === selectedTier && tokenProbe?.answered, + ) + .map(({ slug, subgraph_id: subgraphId, tier }): Selection => ({ + slug, + subgraph_id: subgraphId, + tier: tier as "A" | "B", + })); +if (selections.length < 3) { + throw new Error("Fewer than three healthy, token-compatible candidates exist in one tier."); +} +if (new Set(selections.map(({ tier }) => tier)).size !== 1) { + throw new Error("Final selections mix schema tiers."); +} + +const pair = object(manifest.token_pair); +const quoteToken = typeof pair?.quote_token === "string" ? pair.quote_token : NATIVE_USDC; +const apiKey = await loadGraphApiKey(); + +for (const selection of selections) { + const poolsTemplate = selection.tier === "A" ? tierAPoolsQuery : tierBPoolsQuery; + const poolVariables = + selection.tier === "A" ? { tokens: [WETH, quoteToken] } : { token0: WETH, token1: quoteToken }; + const pools = await postGraphQuery(selection.subgraph_id, poolsTemplate, poolVariables, { + apiKey, + }); + await writeEvidence( + selection.slug, + "05-pools.json", + selection.subgraph_id, + poolsTemplate, + poolVariables, + pools, + ); + + let pool = selection.pool_address; + if (!pool) { + pool = + firstPoolId(pools.body, selection.tier === "A" ? "liquidityPools" : "pools") ?? undefined; + } + if (!pool) { + selection.pool_probe_error = poolProbeFailureMessage(pools.error); + await politeGap(); + continue; + } + selection.pool_address = pool; + await politeGap(); + + if (selection.tier === "A") { + const introspection = await postGraphQuery( + selection.subgraph_id, + tierASnapshotIntrospectionQuery, + {}, + { apiKey }, + ); + await writeEvidence( + selection.slug, + "05a-tier-a-snapshot-schema.json", + selection.subgraph_id, + tierASnapshotIntrospectionQuery, + {}, + introspection, + ); + await politeGap(); + } + + const snapshotsTemplate = selection.tier === "A" ? tierASnapshotsQuery : tierBSnapshotsQuery; + const snapshotVariables = { pool }; + const snapshots = await postGraphQuery( + selection.subgraph_id, + snapshotsTemplate, + snapshotVariables, + { apiKey }, + ); + await writeEvidence( + selection.slug, + "06-snapshots.json", + selection.subgraph_id, + snapshotsTemplate, + snapshotVariables, + snapshots, + ); + await politeGap(); + + const metricsTemplate = selection.tier === "A" ? tierAMetricsQuery : tierBMetricsQuery; + const metrics = await postGraphQuery( + selection.subgraph_id, + metricsTemplate, + { pool }, + { apiKey }, + ); + await writeEvidence( + selection.slug, + "07-common-metrics.json", + selection.subgraph_id, + metricsTemplate, + { pool }, + metrics, + ); + await politeGap(); +} + +await writeManifest({ + ...manifest, + pool_probe_finished_at: new Date().toISOString(), + pool_candidates: selections, +}); diff --git a/scripts/m2/probe-tokens.ts b/scripts/m2/probe-tokens.ts new file mode 100644 index 0000000..1d5424c --- /dev/null +++ b/scripts/m2/probe-tokens.ts @@ -0,0 +1,64 @@ +import { readManifest, politeGap, writeEvidence, writeManifest } from "./lib/evidence.ts"; +import { loadGraphApiKey, postGraphQuery } from "./lib/gateway.ts"; +import { TOKEN_IDS, tokenIntrospectionQuery, tokenQuery } from "./lib/queries.ts"; + +interface Candidate { + readonly slug: string; + readonly subgraph_id: string; + readonly verdict: string; + token_probe?: Record; +} + +function hasGraphErrors(body: unknown): boolean { + return ( + typeof body === "object" && + body !== null && + Array.isArray((body as { errors?: unknown }).errors) + ); +} + +const manifest = await readManifest(); +const candidates = (manifest.candidates ?? []) as Candidate[]; +const apiKey = await loadGraphApiKey(); + +for (const candidate of candidates.filter(({ verdict }) => verdict === "healthy")) { + const variables = { ids: TOKEN_IDS }; + const result = await postGraphQuery(candidate.subgraph_id, tokenQuery, variables, { apiKey }); + await writeEvidence( + candidate.slug, + "04-tokens.json", + candidate.subgraph_id, + tokenQuery, + variables, + result, + ); + candidate.token_probe = { + query_id: tokenQuery.queryId, + answered: !hasGraphErrors(result.body) && result.error === null, + }; + + if (hasGraphErrors(result.body)) { + await politeGap(); + const introspection = await postGraphQuery( + candidate.subgraph_id, + tokenIntrospectionQuery, + {}, + { apiKey }, + ); + await writeEvidence( + candidate.slug, + "04a-token-introspection.json", + candidate.subgraph_id, + tokenIntrospectionQuery, + {}, + introspection, + ); + } + await politeGap(); +} + +await writeManifest({ + ...manifest, + token_probe_finished_at: new Date().toISOString(), + candidates, +}); diff --git a/scripts/m2/run-sweep.ts b/scripts/m2/run-sweep.ts new file mode 100644 index 0000000..ace297c --- /dev/null +++ b/scripts/m2/run-sweep.ts @@ -0,0 +1,154 @@ +import { readFile } from "node:fs/promises"; + +import { politeGap, writeEvidence, writeManifest } from "./lib/evidence.ts"; +import { loadGraphApiKey, postGraphQuery } from "./lib/gateway.ts"; +import { metaQuery, tierAMarkerQuery, tierBLineageQuery, tierBMarkerQuery } from "./lib/queries.ts"; + +interface Candidate { + readonly slug: string; + readonly protocol_name: string; + readonly subgraph_id: string; + readonly explorer_url: string; + readonly publisher: string; +} + +interface Meta { + readonly block?: { + readonly number?: number; + readonly timestamp?: number; + readonly hash?: string; + }; + readonly hasIndexingErrors?: boolean; + readonly deployment?: string; +} + +function object(value: unknown): Record | null { + return typeof value === "object" && value !== null ? (value as Record) : null; +} + +function dataFrom(body: unknown): Record | null { + return object(object(body)?.data); +} + +function metaFrom(body: unknown): Meta | null { + return object(dataFrom(body)?._meta); +} + +function markerAnswered(body: unknown, field: string): boolean { + const data = dataFrom(body); + return data !== null && Object.hasOwn(data, field); +} + +const candidates = JSON.parse( + await readFile(new URL("./candidates.json", import.meta.url), "utf8"), +) as Candidate[]; +const apiKey = await loadGraphApiKey(); +const sweptAt = new Date().toISOString(); +const results: Array> = []; + +for (const candidate of candidates) { + const metaResult = await postGraphQuery(candidate.subgraph_id, metaQuery, {}, { apiKey }); + await writeEvidence( + candidate.slug, + "01-meta.json", + candidate.subgraph_id, + metaQuery, + {}, + metaResult, + ); + await politeGap(); + + const tierAResult = await postGraphQuery(candidate.subgraph_id, tierAMarkerQuery, {}, { apiKey }); + await writeEvidence( + candidate.slug, + "02-tier-a-marker.json", + candidate.subgraph_id, + tierAMarkerQuery, + {}, + tierAResult, + ); + await politeGap(); + + const tierBResult = await postGraphQuery(candidate.subgraph_id, tierBMarkerQuery, {}, { apiKey }); + await writeEvidence( + candidate.slug, + "03-tier-b-marker.json", + candidate.subgraph_id, + tierBMarkerQuery, + {}, + tierBResult, + ); + + const tierA = markerAnswered(tierAResult.body, "dexAmmProtocols"); + const tierB = markerAnswered(tierBResult.body, "factories"); + let tier: "A" | "B" | "unknown" | "collision" = "unknown"; + if (tierA && tierB) tier = "collision"; + else if (tierA) tier = "A"; + else if (tierB) tier = "B"; + + if (tier === "B") { + await politeGap(); + const lineage = await postGraphQuery(candidate.subgraph_id, tierBLineageQuery, {}, { apiKey }); + await writeEvidence( + candidate.slug, + "03a-tier-b-lineage.json", + candidate.subgraph_id, + tierBLineageQuery, + {}, + lineage, + ); + } + + results.push({ + ...candidate, + deployment_id: metaFrom(metaResult.body)?.deployment ?? null, + indexed_block: metaFrom(metaResult.body)?.block?.number ?? null, + indexed_block_timestamp: metaFrom(metaResult.body)?.block?.timestamp ?? null, + has_indexing_errors: metaFrom(metaResult.body)?.hasIndexingErrors ?? null, + tier, + }); + await politeGap(); +} + +const referenceBlock = Math.max( + ...results + .map((candidate) => candidate.indexed_block) + .filter((block): block is number => typeof block === "number"), +); +const capturedAtSeconds = Math.floor(Date.now() / 1000); + +for (const candidate of results) { + const block = candidate.indexed_block; + const timestamp = candidate.indexed_block_timestamp; + const hasErrors = candidate.has_indexing_errors === true; + let verdict: "healthy" | "suspect" | "reject" = "reject"; + let reason = "missing usable _meta"; + + if (typeof block === "number" && typeof timestamp === "number") { + const blockLag = referenceBlock - block; + const ageSeconds = capturedAtSeconds - timestamp; + if (hasErrors || blockLag > 5_000 || ageSeconds > 3_600) { + verdict = "reject"; + reason = hasErrors + ? "hasIndexingErrors is true" + : `lag=${blockLag} blocks, age=${ageSeconds}s`; + } else if (blockLag > 500 || ageSeconds > 900) { + verdict = "suspect"; + reason = `lag=${blockLag} blocks, age=${ageSeconds}s`; + } else { + verdict = "healthy"; + reason = `lag=${blockLag} blocks, age=${ageSeconds}s`; + } + } + + candidate.verdict = verdict; + candidate.verdict_reason = reason; +} + +await writeManifest({ + sweep_started_at: sweptAt, + sweep_finished_at: new Date().toISOString(), + reference_block: Number.isFinite(referenceBlock) ? referenceBlock : null, + candidates: results, + final_selections: [], +}); diff --git a/scripts/m2/tsconfig.json b/scripts/m2/tsconfig.json new file mode 100644 index 0000000..d7ee156 --- /dev/null +++ b/scripts/m2/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/scripts/m3/capture-tier-a-evidence.mjs b/scripts/m3/capture-tier-a-evidence.mjs new file mode 100644 index 0000000..9f99edf --- /dev/null +++ b/scripts/m3/capture-tier-a-evidence.mjs @@ -0,0 +1,71 @@ +/** + * Captures live Tier-A compare_pools evidence for the two locked WETH/USDC fee + * tiers, using the exact query text `src/sources/graph/queries.ts` ships. + * + * The unit suite replays these captures instead of calling the network, and + * asserts the recorded `request.query` still matches the shipped constant, so + * a query edit that is not re-captured fails loudly. + * + * Usage: GRAPH_API_KEY=... node scripts/m3/capture-tier-a-evidence.mjs + */ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const evidenceRoot = join(root, "tests/integration/__evidence__/m3"); + +const apiKey = process.env.GRAPH_API_KEY; +if (apiKey === undefined || apiKey.trim() === "") { + console.error("capture-tier-a-evidence: GRAPH_API_KEY is unset"); + process.exit(1); +} + +const queriesSource = await readFile(join(root, "src/sources/graph/queries.ts"), "utf8"); +const queryMatch = /export const TIER_A_METRICS_QUERY = `([\s\S]*?)`;/.exec(queriesSource); +if (queryMatch === null) { + console.error("capture-tier-a-evidence: could not read TIER_A_METRICS_QUERY"); + process.exit(1); +} +const metaMatch = /^const META = `([\s\S]*?)`;$/m.exec(queriesSource); +if (metaMatch === null) { + console.error("capture-tier-a-evidence: could not read META"); + process.exit(1); +} +const query = queryMatch[1].replace("${META}", metaMatch[1]); + +const records = JSON.parse(await readFile(join(root, "src/registry/records.json"), "utf8")); +const profile = JSON.parse(await readFile(join(root, "src/registry/compare-pools.json"), "utf8")); + +for (const binding of profile.sources) { + const record = records.find((candidate) => candidate.source_id === binding.source_id); + const startedAt = performance.now(); + const response = await fetch( + `https://gateway.thegraph.com/api/subgraphs/id/${record.locator.subgraph_id}`, + { + method: "POST", + headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ query, variables: { pool: binding.pool_address } }), + }, + ); + const body = await response.json(); + const latencyMs = Math.round(performance.now() - startedAt); + + const capture = { + captured_at: new Date().toISOString(), + gateway_host: record.locator.gateway_host, + subgraph_id: record.locator.subgraph_id, + query_id: binding.query_id, + request: { query, variables: { pool: binding.pool_address } }, + response: body, + transport: { http_status: response.status, latency_ms: latencyMs, error: null }, + }; + + const destination = join(evidenceRoot, binding.source_id, "01-pool-metrics.json"); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, `${JSON.stringify(capture, null, 2)}\n`, "utf8"); + console.log( + `${binding.source_id}: http ${response.status}, deployment ${body?.data?._meta?.deployment}, ` + + `${body?.data?.liquidityPoolDailySnapshots?.length ?? 0} snapshots`, + ); +} diff --git a/scripts/m4/README.md b/scripts/m4/README.md new file mode 100644 index 0000000..b36c572 --- /dev/null +++ b/scripts/m4/README.md @@ -0,0 +1,30 @@ +# M4 contract probe + +`contract-probe.mjs` captures the installed Nuthatch CLI and read-only HTTP +surface. For P0, clone an existing non-production nest into a disposable +temporary directory and compare two independent runs: + +```sh +node scripts/m4/contract-probe.mjs \ + --binary /home/arch/.local/bin/nuthatch \ + --nest-source /path/to/a/local/nest \ + --repeat 2 +``` + +The source nest is never started or modified. Each run uses `nuthatch init +--from` to create a temporary copy, starts that copy on a free loopback port, +probes it, stops it, and removes it. The comparison ignores capture timestamps, +request duration, `/metrics`, and changing index-watermark fields. + +For the later read-only smoke test, replace `--nest-source` with `--base-url`. +The harness does not write evidence files; callers retain its complete JSON +stdout as the raw capture and derive Task 2 evidence from that. + +In a filesystem-only sandbox that forbids loopback listeners, add `--offline`. +That still proves the CLI capture and disposable `init --from` cycle are +repeatable, but deliberately leaves the `http` array empty; it is not a +substitute for Task 2's HTTP capture on the dev box. + +`nuthatch-watchdog.mjs` is a one-shot operational probe for a systemd timer. +It compares the `/ready` indexed block with a persisted high-water mark and +does not trust the endpoint's `stalled` boolean. See `docs/deployment.md`. diff --git a/scripts/m4/contract-probe.mjs b/scripts/m4/contract-probe.mjs new file mode 100644 index 0000000..6819ee8 --- /dev/null +++ b/scripts/m4/contract-probe.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const binary = option("--binary", "/home/arch/.local/bin/nuthatch"); +const outDir = path.resolve(option("--out-dir", "tests/integration/__evidence__/m4")); +const source = path.resolve(option("--nest-source", "scripts/m4/local-fixture")); +const fixedBaseUrl = option("--base-url"); +const table = option("--table", "probe__probe"); +const repeat = Number(option("--repeat", "2")); +const host = option("--host", "127.0.0.1"); +const endpoint = option("--endpoint", "/"); + +function option(name, fallback) { + const at = process.argv.indexOf(name); + if (at === -1) return fallback; + if (!process.argv[at + 1]) throw new Error(`missing value for ${name}`); + return process.argv[at + 1]; +} + +function redact(text) { + return text + .replaceAll(/([?&](?:key|api_?key|token|secret|password)=)[^&\s]+/gi, "$1") + .replaceAll(/https?:\/\/[^/\s]+\/(?:v\d\/)?[A-Za-z0-9_-]{20,}/g, "") + .replaceAll(/127\.0\.0\.1:\d+/g, "127.0.0.1:") + .replaceAll(/\/tmp\/nuthatch-contract-probe-[A-Za-z0-9_-]+/g, ""); +} + +async function command(args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(binary, args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + const timer = setTimeout(() => child.kill("SIGTERM"), options.timeout ?? 20_000); + child.once("error", reject); + child.once("close", (exitCode, signal) => { + clearTimeout(timer); + resolve({ + command: `nuthatch ${args.join(" ")}`, + exit_code: exitCode, + signal, + stdout: redact(stdout), + stderr: redact(stderr), + }); + }); + }); +} + +async function freePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") return reject(new Error("no loopback port")); + server.close(() => resolve(address.port)); + }); + }); +} + +function background(program, args) { + const child = spawn(program, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + return { + child, + get stdout() { + return stdout; + }, + get stderr() { + return stderr; + }, + }; +} + +async function stop(handle) { + if (!handle || handle.child.exitCode !== null) return; + handle.child.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => handle.child.once("close", resolve)), + new Promise((resolve) => setTimeout(resolve, 3_000)), + ]); + if (handle.child.exitCode === null) handle.child.kill("SIGKILL"); +} + +async function waitFor(baseUrl, handle) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (handle.child.exitCode !== null) { + throw new Error(`nuthatch dev exited\n${handle.stdout}\n${handle.stderr}`); + } + try { + if ((await fetch(new URL("/health", baseUrl))).ok) return; + } catch { + /* retry */ + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("nuthatch dev health timeout"); +} + +async function request(method, baseUrl, route, query, parameter = "q") { + const url = new URL(route, baseUrl); + if (query) url.searchParams.set(parameter, query); + const response = await fetch(url, { method }); + const text = await response.text(); + let body = text; + try { + body = JSON.parse(text); + } catch { + /* not JSON */ + } + return { + method, + path: route, + status: response.status, + content_type: response.headers.get("content-type"), + allow: response.headers.get("allow"), + response_fields: + body && typeof body === "object" && !Array.isArray(body) ? Object.keys(body).sort() : null, + body, + }; +} + +function normalize(value) { + return JSON.parse( + redact(JSON.stringify(value)) + .replaceAll(/20\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z/g, "") + .replaceAll(/"(tip|last_block|sealed_through)"\s*:\s*\d+/g, '"$1":""'), + ); +} + +async function writeEvidence(name, value) { + await writeFile(path.join(outDir, name), `${JSON.stringify(normalize(value), null, 2)}\n`); +} + +async function cliEvidence() { + const captures = {}; + for (const args of [ + ["--version"], + ["init", "--help"], + ["dev", "--help"], + ["sql", "--help"], + ["check", "--help"], + ["nest", "--help"], + ["mcp", "--help"], + ]) + captures[args.join(" ")] = await command(args); + return captures; +} + +async function httpEvidence(baseUrl) { + const endpoints = {}; + for (const route of ["/health", "/ready", "/nest", "/schema", "/tables", "/metrics"]) { + const result = await request("GET", baseUrl, route); + if (route === "/metrics") { + result.body = String(result.body) + .split("\n") + .filter((line) => line && !line.startsWith("#")) + .map((line) => line.split(/[ {]/)[0]) + .filter((name, index, all) => all.indexOf(name) === index) + .sort(); + } + endpoints[route] = result; + } + const query = `SELECT * FROM "${table}" ORDER BY block_number LIMIT 2`; + const parameters = {}; + for (const name of ["q", "query", "sql"]) { + const result = await request("GET", baseUrl, "/sql", query, name); + parameters[name] = { status: result.status, response_fields: result.response_fields }; + } + return { + target: baseUrl, + endpoints, + sql: { + get: await request("GET", baseUrl, "/sql", query), + post: await request("POST", baseUrl, "/sql", query), + tested_query_parameters: parameters, + real_query_parameter_names: Object.entries(parameters) + .filter(([, result]) => result.status === 200) + .map(([name]) => name), + }, + explain: await request("GET", baseUrl, "/explain", `SELECT count(*) FROM "${table}"`), + }; +} + +async function guardEvidence(baseUrl) { + const row = await request("GET", baseUrl, "/sql", "SELECT i FROM range(20000) t(i)"); + const bytes = await request("GET", baseUrl, "/sql", "SELECT repeat('x', 12000000) payload"); + const timeout = await request( + "GET", + baseUrl, + "/sql", + "SELECT count(*) FROM range(1000000000) a, range(1000000000) b", + ); + const concurrent = await Promise.all( + Array.from({ length: 16 }, () => + request( + "GET", + baseUrl, + "/sql", + `SELECT count(*) FROM "${table}" a, "${table}" b, "${table}" c`, + ), + ), + ); + return { + timeout: { status: timeout.status, body: timeout.body }, + row: { + status: row.status, + rows_returned: Array.isArray(row.body?.rows) ? row.body.rows.length : null, + truncated: row.body?.truncated ?? null, + error: row.body?.error ?? null, + }, + byte: { + status: bytes.status, + response_bytes: Buffer.byteLength(JSON.stringify(bytes.body)), + error: bytes.body?.error ?? null, + }, + concurrency: { + requests: concurrent.length, + active: concurrent.some((result) => result.status !== 200), + statuses: [...new Set(concurrent.map((result) => result.status))].sort(), + errors: [...new Set(concurrent.map((result) => result.body?.error).filter(Boolean))].sort(), + }, + }; +} + +async function schemaEvidence(baseUrl, nestDir) { + const generated = JSON.parse(await readFile(path.join(nestDir, "schema.json"), "utf8")); + const describe = await request("GET", baseUrl, "/sql", `DESCRIBE SELECT * FROM "${table}"`); + const decimalQuery = `SELECT amount_dec, signedAmount_dec FROM "${table}" LIMIT 1`; + return { + generated_schema: generated, + live_describe: describe.body, + decimal_siblings_and_overflow_flags: await request("GET", baseUrl, "/sql", decimalQuery), + }; +} + +async function checkEvidence(nestDir) { + await mkdir(path.join(nestDir, "checks", "expected"), { recursive: true }); + await writeFile( + path.join(nestDir, "checks", "contract.sql"), + `SELECT count(*) AS rows FROM "${table}";\n`, + ); + const update = await command(["check", "--update", "--dir", nestDir]); + let fixture = null; + try { + fixture = JSON.parse( + await readFile(path.join(nestDir, "checks", "expected", "contract.json"), "utf8"), + ); + } catch { + /* fixture absent */ + } + return { + update, + recorded_path: "checks/expected/contract.json", + recorded_value: fixture, + verification: await command(["check", "--dir", nestDir]), + }; +} + +async function localCapture() { + const workDir = await mkdtemp(path.join(tmpdir(), "nuthatch-contract-probe-")); + const nestDir = path.join(workDir, "nest"); + const rpcPort = await freePort(); + const apiPort = await freePort(); + const rpc = background("node", [path.resolve("scripts/m4/fake-rpc.mjs"), String(rpcPort)]); + let dev; + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + const init = await command(["init", "--from", source, "--dir", nestDir]); + if (init.exit_code !== 0) throw new Error(init.stderr); + const configPath = path.join(nestDir, "nest.star"); + const config = (await readFile(configPath, "utf8")).replace( + "http://127.0.0.1:1", + `http://127.0.0.1:${rpcPort}`, + ); + await writeFile(configPath, config); + const schema = await command(["schema", "--dir", nestDir]); + if (schema.exit_code !== 0) throw new Error(schema.stderr); + const baseUrl = `http://${host}:${apiPort}${endpoint}`; + dev = background(binary, [ + "dev", + "--dir", + nestDir, + "--listen", + `${host}:${apiPort}`, + "--backfill", + "20", + "--seal-direct", + "--concurrency", + "2", + "--no-admin", + ]); + await waitFor(baseUrl, dev); + await new Promise((resolve) => setTimeout(resolve, 500)); + const evidence = { + http: await httpEvidence(baseUrl), + guards: await guardEvidence(baseUrl), + schema: await schemaEvidence(baseUrl, nestDir), + devFlags: { + authored_start_block: 1, + backfill_argument: 20, + seal_direct: true, + concurrency: 2, + rpc_get_logs_ranges: rpc.stdout + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line)), + }, + }; + await stop(dev); + dev = null; + evidence.check = await checkEvidence(nestDir); + await writeFile(configPath, config.replace(`http://127.0.0.1:${rpcPort}`, "${PROBE_RPC_URL}")); + const env = await command(["dev", "--dir", nestDir, "--listen", `${host}:${apiPort}`], { + timeout: 2_000, + env: { ...process.env, PROBE_RPC_URL: `http://127.0.0.1:${rpcPort}` }, + }); + evidence.configEnv = { + placeholder: "${PROBE_RPC_URL}", + variable_was_set: true, + supports_rpc_urls_env_expansion: !`${env.stdout}\n${env.stderr}`.includes("relative URL"), + run: env, + }; + return evidence; + } finally { + await stop(dev); + await stop(rpc); + await rm(workDir, { recursive: true, force: true }); + } +} + +await mkdir(outDir, { recursive: true }); +const cli = await cliEvidence(); +const runs = []; +for (let index = 0; index < repeat; index += 1) { + runs.push( + fixedBaseUrl + ? { http: await httpEvidence(fixedBaseUrl), guards: await guardEvidence(fixedBaseUrl) } + : await localCapture(), + ); +} +const accepted = runs.every( + (run) => JSON.stringify(normalize(run)) === JSON.stringify(normalize(runs[0])), +); +await writeEvidence("cli.json", cli); +await writeEvidence("http.json", runs[0].http); +await writeEvidence("guards.json", runs[0].guards); +if (runs[0].schema) await writeEvidence("schema.json", runs[0].schema); +if (runs[0].check) await writeEvidence("check-update.json", runs[0].check); +if (runs[0].configEnv) await writeEvidence("config-env.json", runs[0].configEnv); +if (runs[0].devFlags) await writeEvidence("dev-flags.json", runs[0].devFlags); +await writeEvidence("acceptance.json", { + runs: repeat, + identical_except_timestamps_and_volatile_metrics: accepted, +}); +if (!accepted) process.exitCode = 1; diff --git a/scripts/m4/fake-rpc.mjs b/scripts/m4/fake-rpc.mjs new file mode 100644 index 0000000..4382463 --- /dev/null +++ b/scripts/m4/fake-rpc.mjs @@ -0,0 +1,68 @@ +import { createServer } from "node:http"; + +const port = Number(process.argv[2] ?? "18545"); +const tip = 200; +const topic0 = "0x540e15f17778b95f45eb462185bc3a5774bd244524acfb8be672ab5980ddeb37"; +const word = (value) => value.toString(16).padStart(64, "0"); +const hash = (value) => `0x${word(value)}`; +const block = (number) => ({ + number: `0x${number.toString(16)}`, + hash: hash(number + 10_000), + parentHash: hash(number + 9_999), + timestamp: `0x${(1_700_000_000 + number).toString(16)}`, + transactions: [], +}); + +const server = createServer(async (request, response) => { + let text = ""; + for await (const chunk of request) text += String(chunk); + const call = JSON.parse(text); + let result; + if (call.method === "eth_chainId") result = "0x1"; + else if (call.method === "eth_blockNumber") result = `0x${tip.toString(16)}`; + else if (call.method === "eth_getBlockByNumber") { + const tag = call.params[0]; + const number = + tag === "latest" || tag === "finalized" || tag === "safe" + ? tip + : Number.parseInt(String(tag), 16); + result = block(number); + } else if (call.method === "eth_getLogs") { + const filter = call.params[0]; + const from = Number.parseInt(filter.fromBlock ?? "0x0", 16); + const to = + filter.toBlock === "latest" + ? tip + : Number.parseInt(filter.toBlock ?? `0x${tip.toString(16)}`, 16); + console.log(JSON.stringify({ method: "eth_getLogs", from, to })); + result = Array.from({ length: Math.max(0, to - from + 1) }, (_, offset) => { + const number = from + offset; + return { + address: "0x0000000000000000000000000000000000000001", + topics: [topic0, `0x${word(2)}`], + data: `0x${word(number)}${word(number)}`, + blockNumber: `0x${number.toString(16)}`, + transactionHash: hash(number + 20_000), + transactionIndex: "0x0", + blockHash: hash(number + 10_000), + logIndex: "0x0", + removed: false, + }; + }); + } else if (call.method === "eth_getCode") result = "0x01"; + else { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + jsonrpc: "2.0", + id: call.id, + error: { code: -32601, message: call.method }, + }), + ); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ jsonrpc: "2.0", id: call.id, result })); +}); + +server.listen(port, "127.0.0.1", () => console.log(`fake RPC listening on ${port}`)); diff --git a/scripts/m4/http-probe-lib.mjs b/scripts/m4/http-probe-lib.mjs new file mode 100644 index 0000000..bb0a59a --- /dev/null +++ b/scripts/m4/http-probe-lib.mjs @@ -0,0 +1,43 @@ +const MAX_ROWS_ERROR = + /(?:max[_ ]rows|maximum rows|row limit).*(?:50000|50,000|exceed|at most|less)/i; + +export const GUARD_QUERY = "SELECT count(*) FROM pool__swap a, pool__swap b, pool__swap c"; +export const MAX_ROWS_QUERY = "SELECT * FROM pool__swap LIMIT 1"; + +export function requireBaseUrl(value) { + if (!value) { + throw new Error("NUTHATCH_BASE_URL is required (for example, http://127.0.0.1:8288)."); + } + + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("NUTHATCH_BASE_URL must use http: or https:."); + } + return url.toString(); +} + +export function isMaxRowsRejection(result) { + return ( + result.status !== null && + result.status >= 400 && + result.status < 500 && + typeof result.body === "string" && + MAX_ROWS_ERROR.test(result.body) + ); +} + +export function isFreshnessViewAvailable(endpoints) { + return endpoints["/sql"]?.status === 200 && endpoints["/explain"]?.status === 200; +} + +export function buildAcceptance({ endpoints, maxRows, postSql }) { + return { + freshness_view_available: isFreshnessViewAvailable(endpoints), + max_rows_rejection_verified: maxRows.rejected, + post_sql_rejected: postSql.rejected, + }; +} + +export function isProbeAccepted(acceptance) { + return Object.values(acceptance).every(Boolean); +} diff --git a/scripts/m4/http-probe.mjs b/scripts/m4/http-probe.mjs new file mode 100644 index 0000000..98b8692 --- /dev/null +++ b/scripts/m4/http-probe.mjs @@ -0,0 +1,165 @@ +#!/usr/bin/env node + +// HTTP probe for the Nuthatch 0.6.1 read-only API surface. +// +// Probes a fixed set of GET endpoints, asserts that POST /sql is rejected, +// exercises the concurrency guard, and verifies max_rows rejection. +// +// Usage: +// node scripts/m4/http-probe.mjs +// NUTHATCH_BASE_URL=http://127.0.0.1:8080 node scripts/m4/http-probe.mjs +// +// Output: a single JSON object on stdout. No secrets, keyed URLs, or admin +// tokens are read or printed. The base URL must be supplied via +// NUTHATCH_BASE_URL; it is never defaulted to avoid baking internal +// infrastructure names into committed source. + +const BASE_URL = process.env.NUTHATCH_BASE_URL ?? ""; +const REQUEST_TIMEOUT_MS = 15_000; +const BODY_TRUNCATE_BYTES = 2048; +const CONCURRENCY_PROBE_COUNT = 3; +const MAX_ROWS_REJECT = 50_001; +const EXPLAIN_QUERY = "SELECT * FROM pool_swap_freshness LIMIT 1"; +const SQL_QUERY = "SELECT * FROM pool_swap_freshness LIMIT 1"; + +function redactUrl(url) { + return String(url).replaceAll( + /([?&](?:key|api_?key|token|secret|password)=)[^&\s]+/gi, + "$1", + ); +} + +function truncateBody(text) { + const buffer = Buffer.from(text, "utf8"); + if (buffer.byteLength <= BODY_TRUNCATE_BYTES) return text; + return `${buffer.subarray(0, BODY_TRUNCATE_BYTES).toString("utf8")}…`; +} + +async function probe(method, url, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const startedAt = Date.now(); + let response; + try { + response = await fetch(url, { + method, + headers: options.headers ?? {}, + body: options.body ?? null, + signal: controller.signal, + redirect: "error", + }); + } catch (cause) { + clearTimeout(timer); + return { + method, + url: redactUrl(url), + status: null, + content_type: null, + duration_ms: Date.now() - startedAt, + error: cause instanceof Error ? cause.name : String(cause), + body: null, + }; + } + clearTimeout(timer); + const text = await response.text(); + return { + method, + url: redactUrl(url), + status: response.status, + content_type: response.headers.get("content-type"), + duration_ms: Date.now() - startedAt, + error: null, + body: truncateBody(text), + }; +} + +function join(baseUrl, path) { + return new URL(path, baseUrl).toString(); +} + +async function probeGetEndpoints() { + const endpoints = {}; + const routes = ["/health", "/ready", "/nest", "/schema", "/tables", "/metrics"]; + for (const route of routes) { + endpoints[route] = await probe("GET", join(BASE_URL, route)); + } + endpoints["/explain"] = await probe( + "GET", + `${join(BASE_URL, "/explain")}?q=${encodeURIComponent(EXPLAIN_QUERY)}`, + ); + endpoints["/sql"] = await probe( + "GET", + `${join(BASE_URL, "/sql")}?q=${encodeURIComponent(SQL_QUERY)}&max_rows=1`, + ); + return endpoints; +} + +async function probePostSqlRejected() { + // POST /sql must be rejected — the read-only surface only accepts GET. + const url = `${join(BASE_URL, "/sql")}?q=${encodeURIComponent(SQL_QUERY)}`; + const result = await probe("POST", url, { + headers: { "content-type": "application/json" }, + body: JSON.stringify({ q: SQL_QUERY }), + }); + return { + url: redactUrl(url), + status: result.status, + rejected: result.status === null ? false : result.status >= 400 && result.status < 500, + body: result.body, + }; +} + +async function probeMaxRowsRejection() { + // max_rows above the configured ceiling must be rejected. + const url = `${join(BASE_URL, "/sql")}?q=${encodeURIComponent(SQL_QUERY)}&max_rows=${MAX_ROWS_REJECT}`; + const result = await probe("GET", url); + return { + url: redactUrl(url), + max_rows_requested: MAX_ROWS_REJECT, + status: result.status, + rejected: result.status === null ? false : result.status >= 400 && result.status < 500, + body: result.body, + }; +} + +async function probeConcurrencyGuard() { + // Fire N simultaneous /sql requests and observe how many execute concurrently. + // The Nuthatch dev server caps in-flight SQL with a concurrency guard; we + // approximate "concurrent execution" by overlapping issue windows and + // counting responses whose duration overlaps another in flight. + const url = `${join(BASE_URL, "/sql")}?q=${encodeURIComponent(SQL_QUERY)}&max_rows=1`; + const issuedAt = Date.now(); + const results = await Promise.all( + Array.from({ length: CONCURRENCY_PROBE_COUNT }, () => probe("GET", url)), + ); + const statuses = results.map((result) => result.status); + const guardRejected = statuses.filter( + (status) => status !== null && (status === 429 || status === 503), + ).length; + const ok = statuses.filter((status) => status === 200).length; + const maxObservedOverlap = Math.max(...results.map((result) => (result.status === 200 ? 1 : 0))); + return { + url: redactUrl(url), + requests: CONCURRENCY_PROBE_COUNT, + issued_within_ms: Date.now() - issuedAt, + statuses, + ok_count: ok, + guard_rejected_count: guardRejected, + // Best-effort lower bound on observed concurrency: 1 if any request + // succeeded, else 0. A precise count needs server-side metrics and is + // captured separately via /metrics. + observed_concurrency_lower_bound: maxObservedOverlap, + }; +} + +const output = { + target_host: new URL(BASE_URL).host, + probe_version: "0.6.1", + generated_at: new Date().toISOString(), + endpoints: await probeGetEndpoints(), + post_sql: await probePostSqlRejected(), + max_rows: await probeMaxRowsRejection(), + concurrency: await probeConcurrencyGuard(), +}; + +process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); diff --git a/scripts/m4/local-fixture/abis/probe.json b/scripts/m4/local-fixture/abis/probe.json new file mode 100644 index 0000000..aa716a5 --- /dev/null +++ b/scripts/m4/local-fixture/abis/probe.json @@ -0,0 +1,12 @@ +[ + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "who", "type": "address" }, + { "indexed": false, "name": "amount", "type": "uint256" }, + { "indexed": false, "name": "signedAmount", "type": "int256" } + ], + "name": "Probe", + "type": "event" + } +] diff --git a/scripts/m4/local-fixture/nest.star b/scripts/m4/local-fixture/nest.star new file mode 100644 index 0000000..3c72617 --- /dev/null +++ b/scripts/m4/local-fixture/nest.star @@ -0,0 +1,15 @@ +nest( + name = "contract-probe", + chain = "mainnet", + chain_id = 1, + rpc_urls = ["http://127.0.0.1:1"], + contracts = [ + contract( + alias = "probe", + address = "0x0000000000000000000000000000000000000001", + start_block = 1, + abi = "abis/probe.json", + events = ["Probe"], + ), + ], +) diff --git a/scripts/m4/nuthatch-watchdog-lib.mjs b/scripts/m4/nuthatch-watchdog-lib.mjs new file mode 100644 index 0000000..ca00a76 --- /dev/null +++ b/scripts/m4/nuthatch-watchdog-lib.mjs @@ -0,0 +1,129 @@ +const NON_NEGATIVE_INTEGER = /^\d+$/; + +function requireNonNegativeInteger(value, field) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative safe integer.`); + } + return value; +} + +export function positiveIntegerSetting(value, fallback, name) { + if (value === undefined || value === "") { + return fallback; + } + if (!NON_NEGATIVE_INTEGER.test(value)) { + throw new Error(`${name} must be a positive integer.`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer.`); + } + return parsed; +} + +export function parseReadyPayload(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("/ready must return a JSON object."); + } + + const lastBlock = + value.last_block === undefined + ? requireNonNegativeInteger(value.sealed_through, "sealed_through") + : requireNonNegativeInteger(value.last_block, "last_block"); + const tip = requireNonNegativeInteger(value.tip, "tip"); + if (tip < lastBlock) { + throw new Error("/ready tip must not be behind last_block."); + } + if (typeof value.ready !== "boolean") { + throw new Error("/ready ready must be a boolean."); + } + + return { + ready: value.ready, + lastBlock, + tip, + lagBlocks: tip - lastBlock, + reportedStalled: typeof value.stalled === "boolean" ? value.stalled : null, + }; +} + +export function parseWatchdogState(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Watchdog state must be a JSON object."); + } + if (value.version !== 1) { + throw new Error("Watchdog state version is unsupported."); + } + + const state = { + version: 1, + high_water_block: requireNonNegativeInteger(value.high_water_block, "high_water_block"), + last_block: requireNonNegativeInteger(value.last_block, "last_block"), + last_observed_at: requireNonNegativeInteger(value.last_observed_at, "last_observed_at"), + last_progress_at: requireNonNegativeInteger(value.last_progress_at, "last_progress_at"), + }; + if ( + state.last_block > state.high_water_block || + state.last_progress_at > state.last_observed_at + ) { + throw new Error("Watchdog state invariants are invalid."); + } + return state; +} + +export function evaluateProgress(previous, sample, observedAt, stallAfterSeconds) { + requireNonNegativeInteger(observedAt, "observedAt"); + requireNonNegativeInteger(stallAfterSeconds, "stallAfterSeconds"); + if (stallAfterSeconds === 0) { + throw new Error("stallAfterSeconds must be positive."); + } + if (previous !== null && observedAt < previous.last_observed_at) { + throw new Error("Watchdog clock moved backwards."); + } + + const highWater = Math.max(previous?.high_water_block ?? sample.lastBlock, sample.lastBlock); + const progressed = previous !== null && sample.lastBlock > previous.high_water_block; + const caughtUp = sample.lagBlocks === 0; + const lastProgressAt = + previous === null || progressed || caughtUp ? observedAt : previous.last_progress_at; + const secondsSinceProgress = observedAt - lastProgressAt; + const regressed = previous !== null && sample.lastBlock < previous.high_water_block; + const noProgress = + previous !== null && !caughtUp && !regressed && secondsSinceProgress >= stallAfterSeconds; + + let reason = "waiting"; + if (previous === null) { + reason = "baseline"; + } else if (regressed) { + reason = "regressed"; + } else if (progressed) { + reason = "progress"; + } else if (caughtUp) { + reason = "caught_up"; + } else if (noProgress) { + reason = "no_progress"; + } + + const alert = !sample.ready || regressed || noProgress; + return { + state: { + version: 1, + high_water_block: highWater, + last_block: sample.lastBlock, + last_observed_at: observedAt, + last_progress_at: lastProgressAt, + }, + report: { + event: "nuthatch_backfill_watchdog", + status: alert ? "alert" : "ok", + reason: !sample.ready ? "not_ready" : reason, + ready: sample.ready, + reported_stalled: sample.reportedStalled, + last_block: sample.lastBlock, + high_water_block: highWater, + tip: sample.tip, + lag_blocks: sample.lagBlocks, + seconds_since_progress: secondsSinceProgress, + }, + }; +} diff --git a/scripts/m4/nuthatch-watchdog.mjs b/scripts/m4/nuthatch-watchdog.mjs new file mode 100644 index 0000000..18f0f73 --- /dev/null +++ b/scripts/m4/nuthatch-watchdog.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { + evaluateProgress, + parseReadyPayload, + parseWatchdogState, + positiveIntegerSetting, +} from "./nuthatch-watchdog-lib.mjs"; + +const DEFAULT_READY_URL = "http://127.0.0.1:8288/ready"; +const DEFAULT_STATE_FILE = "/var/lib/nuthatch/.deeptrace-watchdog-state.json"; +const DEFAULT_STALL_AFTER_SECONDS = 600; +const DEFAULT_TIMEOUT_MS = 5_000; +const MAX_READY_BYTES = 32 * 1024; + +function readyUrl(value) { + const url = new URL(value ?? DEFAULT_READY_URL); + const loopback = + url.protocol === "http:" && + (url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]"); + if ( + !loopback || + url.username !== "" || + url.password !== "" || + url.pathname !== "/ready" || + url.search !== "" || + url.hash !== "" + ) { + throw new Error("NUTHATCH_WATCHDOG_READY_URL must be a credential-free loopback /ready URL."); + } + return url; +} + +async function fetchReady(url, timeoutMs) { + const response = await fetch(url, { + method: "GET", + redirect: "error", + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new Error(`Nuthatch /ready returned HTTP ${String(response.status)}.`); + } + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_READY_BYTES) { + throw new Error("Nuthatch /ready response exceeded the byte ceiling."); + } + const text = await response.text(); + if (Buffer.byteLength(text) > MAX_READY_BYTES) { + throw new Error("Nuthatch /ready response exceeded the byte ceiling."); + } + try { + return parseReadyPayload(JSON.parse(text)); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error("Nuthatch /ready returned invalid JSON.", { cause: error }); + } + throw error; + } +} + +async function loadState(stateFile) { + try { + return parseWatchdogState(JSON.parse(await readFile(stateFile, "utf8"))); + } catch (error) { + if (error && typeof error === "object" && error.code === "ENOENT") { + return null; + } + throw error; + } +} + +async function saveState(stateFile, state) { + await mkdir(path.dirname(stateFile), { recursive: true }); + const temporary = `${stateFile}.${String(process.pid)}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 }); + await rename(temporary, stateFile); + } finally { + await rm(temporary, { force: true }); + } +} + +function safeFailure(error) { + const known = + error instanceof Error && + (error.message.startsWith("NUTHATCH_WATCHDOG_") || + error.message.startsWith("Nuthatch /ready") || + error.message.startsWith("/ready") || + error.message.startsWith("Watchdog")); + return { + event: "nuthatch_backfill_watchdog", + status: "error", + reason: known ? error.message : "Nuthatch /ready request failed; details were redacted.", + }; +} + +try { + const url = readyUrl(process.env.NUTHATCH_WATCHDOG_READY_URL); + const stateFile = process.env.NUTHATCH_WATCHDOG_STATE_FILE ?? DEFAULT_STATE_FILE; + const stallAfterSeconds = positiveIntegerSetting( + process.env.NUTHATCH_WATCHDOG_STALL_AFTER_SECONDS, + DEFAULT_STALL_AFTER_SECONDS, + "NUTHATCH_WATCHDOG_STALL_AFTER_SECONDS", + ); + const timeoutMs = positiveIntegerSetting( + process.env.NUTHATCH_WATCHDOG_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + "NUTHATCH_WATCHDOG_TIMEOUT_MS", + ); + const sample = await fetchReady(url, timeoutMs); + const previous = await loadState(stateFile); + const outcome = evaluateProgress( + previous, + sample, + Math.floor(Date.now() / 1000), + stallAfterSeconds, + ); + await saveState(stateFile, outcome.state); + console.log(JSON.stringify(outcome.report)); + process.exitCode = outcome.report.status === "alert" ? 2 : 0; +} catch (error) { + console.error(JSON.stringify(safeFailure(error))); + process.exitCode = 1; +} diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs new file mode 100644 index 0000000..1a85f3a --- /dev/null +++ b/scripts/mcp-smoke.mjs @@ -0,0 +1,351 @@ +// One-command MCP smoke test. Run with: +// DEEPTRACE_MCP_URL=... DEEPTRACE_HTTP_TOKEN=... npm run smoke:mcp +// +// Uses only built-in fetch and node:process. No new dependencies. +// Never prints the token, any Authorization header, or a session id. + +import process from "node:process"; + +const SHORT_TIMEOUT_MS = 20_000; +const CALL_TIMEOUT_MS = 90_000; + +const MCP_URL = process.env.DEEPTRACE_MCP_URL ?? ""; +const TOKEN = process.env.DEEPTRACE_HTTP_TOKEN ?? ""; + +const missing = []; +if (MCP_URL === "") missing.push("DEEPTRACE_MCP_URL"); +if (TOKEN === "") missing.push("DEEPTRACE_HTTP_TOKEN"); +if (missing.length > 0) { + console.error(`missing required environment variables: ${missing.join(", ")}`); + process.exit(1); +} + +const origin = (() => { + try { + const u = new URL(MCP_URL); + return `${u.protocol}//${u.host}`; + } catch { + console.error(`DEEPTRACE_MCP_URL is not a valid URL`); + process.exit(1); + } +})(); + +const JSON_HEADERS = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", +}; + +const authHeaders = () => ({ + ...JSON_HEADERS, + Authorization: `Bearer ${TOKEN}`, +}); + +let pass = 0; +let fail = 0; + +function report(label, ok, detail) { + const status = ok ? "PASS" : "FAIL"; + const padded = label.padEnd(28); + console.log(`${padded} ${status} ${detail}`); + if (ok) pass += 1; + else fail += 1; +} + +async function runCheck(label, fn) { + let ok = false; + let detail; + try { + ({ ok, detail } = await fn()); + } catch (err) { + detail = `error: ${err.message}`; + } + report(label, ok, detail); + return ok; +} + +function parseSse(text) { + for (const line of text.split("\n")) { + const trimmed = line.replace(/\r$/, ""); + if (trimmed.startsWith("data: ")) { + const payload = trimmed.slice("data: ".length); + if (payload === "" || payload === "[DONE]") continue; + try { + return JSON.parse(payload); + } catch { + // skip non-JSON data lines + } + } + } + return null; +} + +function parseJsonRpc(text) { + const sseMessage = parseSse(text); + if (sseMessage !== null) return sseMessage; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +async function postJson(url, body, { headers, timeoutMs }) { + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await res.text(); + return { status: res.status, headers: res.headers, text }; +} + +const initializeParams = { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "smoke", version: "1" }, +}; + +// Check 1: auth gate (no Authorization header) -> expect 401 +await runCheck("auth gate (no token)", async () => { + const { status } = await postJson( + MCP_URL, + { jsonrpc: "2.0", id: 1, method: "initialize", params: initializeParams }, + { headers: JSON_HEADERS, timeoutMs: SHORT_TIMEOUT_MS }, + ); + return { ok: status === 401, detail: `status=${status} (expected 401)` }; +}); + +// Check 2: unknown path GET /health -> expect 404 +await runCheck("unknown path", async () => { + const res = await fetch(`${origin}/health`, { + method: "GET", + signal: AbortSignal.timeout(SHORT_TIMEOUT_MS), + }); + return { ok: res.status === 404, detail: `status=${res.status} (expected 404)` }; +}); + +// Check 3: initialize -> expect 200 and non-empty mcp-session-id +let sessionId = ""; +let negotiatedProtocolVersion = ""; +const initOk = await runCheck("initialize", async () => { + const { status, headers, text } = await postJson( + MCP_URL, + { jsonrpc: "2.0", id: 2, method: "initialize", params: initializeParams }, + { headers: authHeaders(), timeoutMs: SHORT_TIMEOUT_MS }, + ); + const sid = headers.get("mcp-session-id") ?? ""; + if (status !== 200) { + return { ok: false, detail: `status=${status} (expected 200)` }; + } + if (sid === "") { + return { ok: false, detail: `status=200 sid=(empty)` }; + } + + sessionId = sid; + const message = parseJsonRpc(text); + const protocolVersion = message?.result?.protocolVersion; + if (protocolVersion !== initializeParams.protocolVersion) { + return { ok: false, detail: `status=200 invalid protocolVersion` }; + } + negotiatedProtocolVersion = protocolVersion; + return { ok: true, detail: `status=200 session=established` }; +}); + +const sessionHeaders = () => ({ + ...authHeaders(), + "mcp-session-id": sessionId, + "MCP-Protocol-Version": negotiatedProtocolVersion || initializeParams.protocolVersion, +}); + +async function closeSession() { + try { + const response = await fetch(MCP_URL, { + method: "DELETE", + headers: sessionHeaders(), + signal: AbortSignal.timeout(SHORT_TIMEOUT_MS), + }); + await response.text(); + } catch { + // Session termination is best effort and must not hide the smoke result. + } +} + +if (!initOk || sessionId === "") { + if (sessionId !== "") await closeSession(); + console.log("initialize failed; skipping remaining checks"); + console.log(`${pass} passed, ${fail} failed`); + process.exit(1); +} + +const EXPECTED_TOOLS = [ + "compare_pools", + "compare_lending_markets", + "find_large_swaps", + "research_wallet", +]; + +// Checks 6+: tools/call each public tool with its locked allowlisted args. +// Pool/lending/wallet calls pass on complete or partial. Large-swap calls require a +// complete 1/1 coverage settlement (develop's stricter LSS smoke gate). +// Wallet calls also require at least one successful source of the two requested. +async function callTool({ id, name, args, requestedKey, successKey, requireComplete = false }) { + const { status, text } = await postJson( + MCP_URL, + { jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }, + { headers: sessionHeaders(), timeoutMs: CALL_TIMEOUT_MS }, + ); + if (status !== 200) { + return { ok: false, detail: `status=${status} (expected 200)` }; + } + const msg = parseJsonRpc(text); + const rawText = msg?.result?.content?.[0]?.text; + if (typeof rawText !== "string") { + return { ok: false, detail: `status=200 no content[0].text` }; + } + let parsed; + try { + parsed = JSON.parse(rawText); + } catch (err) { + return { ok: false, detail: `result not JSON: ${err.message}` }; + } + const st = parsed?.status; + const coverage = parsed?.coverage ?? {}; + const success = coverage[successKey]; + const requested = coverage[requestedKey]; + const covStr = + typeof success === "number" && typeof requested === "number" + ? `${success}/${requested}` + : "?/?"; + const ok = requireComplete + ? st === "complete" && covStr === "1/1" + : st === "complete" || st === "partial"; + return { ok, detail: `status=${st} ${covStr}` }; +} + +try { + // Check 4: notifications/initialized -> expect 202 + await runCheck("notifications/initialized", async () => { + const { status } = await postJson( + MCP_URL, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { headers: sessionHeaders(), timeoutMs: SHORT_TIMEOUT_MS }, + ); + return { ok: status === 202, detail: `status=${status} (expected 202)` }; + }); + + // Check 5: tools/list -> SSE payload advertises all public tools + await runCheck("tools/list", async () => { + const { status, text } = await postJson( + MCP_URL, + { jsonrpc: "2.0", id: 3, method: "tools/list" }, + { headers: sessionHeaders(), timeoutMs: SHORT_TIMEOUT_MS }, + ); + const msg = parseJsonRpc(text); + const tools = msg?.result?.tools ?? []; + const names = tools.map((t) => t?.name).filter((n) => typeof n === "string"); + const missingTools = EXPECTED_TOOLS.filter((n) => !names.includes(n)); + const ok = status === 200 && missingTools.length === 0; + let detail = `tools=[${names.join(",")}]`; + if (status !== 200) detail = `status=${status} (expected 200)`; + else if (missingTools.length > 0) detail += ` missing=[${missingTools.join(",")}]`; + return { ok, detail }; + }); + + await runCheck("tools/call compare_pools", () => + callTool({ + id: 4, + name: "compare_pools", + args: { + chain_id: 8453, + token0: "0x4200000000000000000000000000000000000006", + token1: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + window: "24h", + ranked_by: "tvl_usd", + }, + requestedKey: "requested_deployments", + successKey: "successful_deployments", + }), + ); + + await runCheck("tools/call compare_lending_markets", () => + callTool({ + id: 5, + name: "compare_lending_markets", + args: { + chain_id: 8453, + market_token: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + ranked_by: "tvl_usd", + }, + requestedKey: "requested_sources", + successKey: "successful_sources", + }), + ); + + await runCheck("tools/call find_large_swaps", () => + callTool({ + id: 6, + name: "find_large_swaps", + args: { + chain_id: 8453, + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + threshold_token: "0x4200000000000000000000000000000000000006", + min_amount: "1", + limit: 1, + }, + requestedKey: "requested_sources", + successKey: "successful_sources", + requireComplete: true, + }), + ); + + await runCheck("tools/call research_wallet", async () => { + const { status, text } = await postJson( + MCP_URL, + { + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { + name: "research_wallet", + arguments: { + chain_id: 8453, + address: "0x5cb3787a9c9c7547451ca3e6d8702453de35fe01", + window: "24h", + limit: 5, + }, + }, + }, + { headers: sessionHeaders(), timeoutMs: CALL_TIMEOUT_MS }, + ); + if (status !== 200) { + return { ok: false, detail: `status=${status} (expected 200)` }; + } + const msg = parseJsonRpc(text); + const rawText = msg?.result?.content?.[0]?.text; + if (typeof rawText !== "string") { + return { ok: false, detail: `status=200 no content[0].text` }; + } + let parsed; + try { + parsed = JSON.parse(rawText); + } catch (err) { + return { ok: false, detail: `result not JSON: ${err.message}` }; + } + const st = parsed?.status; + const coverage = parsed?.coverage ?? {}; + const success = coverage.successful_sources; + const requested = coverage.requested_sources; + const covStr = + typeof success === "number" && typeof requested === "number" + ? `${success}/${requested}` + : "?/?"; + const ok = (st === "complete" || st === "partial") && success >= 1 && requested === 2; + return { ok, detail: `status=${st} ${covStr}` }; + }); +} finally { + await closeSession(); +} + +console.log(`${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1); diff --git a/skills/deeptrace-pool-research/SKILL.md b/skills/deeptrace-pool-research/SKILL.md new file mode 100644 index 0000000..e6f29d7 --- /dev/null +++ b/skills/deeptrace-pool-research/SKILL.md @@ -0,0 +1,227 @@ +--- +name: deeptrace-pool-research +description: Research Base DeFi through the DeepTrace MCP server — compare locked WETH/USDC Uniswap V3 fee tiers with Graph metrics and Nuthatch freshness, compare Base USDC lending markets across Aave v3, Seamless, and Moonwell, find large swaps with exact WETH/USDC thresholds and stable cursors, or inspect source-bounded wallet activity and Aave positions. Use when an end user asks to compare pools, rank by TVL, volume, or fees, compare lending supply or borrow rates, find whale-sized swaps, paginate swap history, research a public wallet, inspect 24h or 7d metrics, verify source freshness, explain partial results, or distinguish Graph and Nuthatch evidence. +--- + +# DeepTrace research + +DeepTrace exposes four read-only MCP tools. Read `structuredContent` when +available; otherwise parse the JSON object in the text result. + +| Ask | Tool | +| :--------------------------------------------------------------------------------------------------------- | :------------------------ | +| Which WETH/USDC pool has more TVL, volume, or fees; which fee tier is busier; how fresh is pool data | `compare_pools` | +| Where to lend or borrow USDC on Base; supply or borrow APY; which protocol holds the most USDC | `compare_lending_markets` | +| Swaps of at least N WETH or USDC in the locked Uniswap V3 pool; whale-sized trades; paginated swap history | `find_large_swaps` | +| Source-bounded activity and verified Aave v3 positions for a public Base wallet | `research_wallet` | + +For `compare_pools` or `compare_lending_markets`, complete or partial records +are in `data.pools` or `data.markets`, plus the optional Nuthatch fact from +`data.nuthatch_freshness_fact` on `compare_pools`. For `find_large_swaps`, a +complete page is in `data.swaps`. For `research_wallet`, inspect each requested +section and its section-level coverage. For any `failed` result, `data` is null. + +Anything else — other chains, other pairs, other assets, unsupported protocols, +or pools outside the locked Uniswap V3 WETH/USDC scope — is out of scope. Say so +plainly instead of substituting a tool that answers a different question. + +## Connect safely + +- Use the canonical remote endpoint `https://mcp.ikodo.dev`. +- Use only the DeepTrace connection the user has configured or explicitly + approved. Verify the hostname exactly before sending a credential. +- Send the access token as an `Authorization: Bearer` header through the + client's secret or environment-variable support. +- Never ask the user to paste a token into chat. Never put a token in a URL, + prompt, answer, repository, or log. +- Nuthatch is only accessible via the DeepTrace MCP server. Do not connect to + Nuthatch from the user's device; DeepTrace queries the private Nuthatch + service on the server. +- If the tool is unavailable, explain that the client must support remote + Streamable HTTP MCP with a Bearer header. Do not substitute direct Graph, + Nuthatch, or price-API calls. + +## Build the request + +Every tool has a fully locked scope; every argument is an allowlisted literal. + +`compare_pools`: + +- `chain_id`: `8453` +- `token0`: `0x4200000000000000000000000000000000000006` (WETH) +- `token1`: `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` (native USDC) +- `window`: `24h` or `7d`; default to `24h` +- `ranked_by`: `tvl_usd`, `volume_usd`, or `fees_usd`; default to `volume_usd` +- `top_n`: integer from `1` to `3`; default to `3` + +The compared pools are the Uniswap V3 WETH/USDC 0.3% (`0x6c561b44…`) and 0.05% +(`0xd0b53d92…`) tiers, both served by one Messari `dex-amm` deployment. + +`compare_lending_markets`: + +- `chain_id`: `8453` +- `market_token`: `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` (native USDC) +- `ranked_by`: `tvl_usd`, `total_deposit_balance_usd`, + `total_borrow_balance_usd`, `lender_variable_rate_percent`, or + `borrower_variable_rate_percent`; default to `tvl_usd` +- `top_n`: integer from `1` to `3`; default to `3` + +The compared protocols are Aave v3, Seamless, and Moonwell, all on the +native-USDC market, through one shared query template. + +`find_large_swaps`: + +- `chain_id`: `8453` +- `pool_address`: `0x6c561b446416e1a00e8e93e221854d6ea4171372` +- `threshold_token`: WETH + `0x4200000000000000000000000000000000000006` or native USDC + `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` +- `min_amount`: a positive decimal string in human units of the selected token +- `limit`: integer `1`–`100`; default `25` +- `cursor`: omit on the first page; then copy `pagination.next_cursor` exactly + +Translate “day”, “daily”, or “last 24 hours” to `24h`. Translate “week” or +“last seven days” to `7d`. Ask one concise question only when the ranking +metric materially changes the answer and cannot be inferred. + +Translate a request such as “swaps of at least 10 WETH” to WETH plus +`min_amount: "10"`. Do not translate a dollar request into WETH or USDC without +asking which supported token threshold the user wants. V1 performs no price +join and returns `usd_notional: null`. The threshold is always in human units +of WETH or USDC, never USD. + +When `pagination.has_more` is true, continue only with the returned opaque +cursor and the same chain, pool, threshold token, and minimum amount. Never +decode, edit, synthesize, or reuse a cursor with a different request. The +requested limit is a maximum: a page may return fewer rows with `has_more` and +a warning when the 64 KiB response budget requires a shorter page. + +Refuse unsupported chains, pairs, windows, tokens, or metrics by stating the +exact supported scope. Never silently change the requested assets. + +## Build a wallet-research request + +- `chain_id`: `8453` +- `address`: a lowercase public Ethereum address +- `sections`: any of `activity`, `counterparties`, `protocol_usage`, + `observable_flows`, `observed_assets`, and `positions`; defaults to all +- `window`: `24h` or `7d`; applies to indexed Nuthatch activity +- `limit`: integer `1`–`50`; default `25` +- `cursor`: omit on the first page, then copy `pagination.next_cursor` exactly + +Wallet Research is intentionally source-bounded. Nuthatch covers only the +registered Uniswap V3 pool; The Graph position facts come only from the +registered Aave v3 standardized subgraph. `observed_assets` are assets seen in +those supported facts, not complete wallet balances. Never claim complete +portfolio, transaction-history, P&L, ownership, or unsupported protocol +coverage. + +Continue a wallet page only with the returned opaque cursor and the same wallet +and window. A `complete` response requires load-bearing Graph and Nuthatch +facts. For `partial`, preserve usable sections and explain unavailable section +coverage. + +## Interpret the sources + +- Treat Graph subgraphs as the source of pool TVL, volume, and fee metrics, and + of lending balances and rates. Preserve every financial value as the exact + returned decimal string. +- Windows are completed UTC days. `24h` is the most recent completed UTC day, + not a rolling 24 hours, and `7d` is an exact sum of seven consecutive + completed days. When those days are unavailable the aggregate is `null`, not a + shorter window. +- `fees_usd` is that day's total fee revenue as the Messari `dex-amm` standard + publishes it (`dailyTotalRevenueUSD`, supply-side plus protocol-side), because + the standard has no per-day fee field. It is a USD amount, never a yield. +- Lending rates are percentages exactly as each protocol reports them, which the + response states as `rate_basis: "percent_apy"`. Republish the string. +- A market flagged `is_active: false` is still reported. Pass the flag along + rather than hiding or silently discarding the market. +- Treat Nuthatch as an independent freshness fact for indexed Uniswap V3 + `Swap` events on the registered pool. Use + `data.nuthatch_freshness_fact.recent_swap_count_24h`, its last swap block and + timestamp, and the corresponding freshness record only as returned. +- Treat `recent_swap_count_24h` as a distinct Nuthatch 24-hour fact. Do not + equate it with Graph volume, fees, transaction count, or a `7d` financial + window. +- Do not claim Nuthatch supplies USD metrics, covers every pool, or proves + parity with a subgraph. +- Do not compare Graph and Nuthatch block heights as parity evidence unless the + response explicitly returns a parity result. +- For `find_large_swaps`, treat `amount_in_raw` and `amount_out_raw` as signed + pool deltas and the human amounts as exact normalized strings. The selected + token's absolute delta is greater than or equal to `min_amount`. +- Preserve swap order, transaction hash, log index, block, timestamp, source + ID, and cursor exactly. Do not deduplicate or reorder a returned page. +- For `research_wallet`, keep Nuthatch activity separate from Graph positions. + Use each section's coverage and source IDs. Treat observable flows as + event-classified facts from the indexed pool, not as net wallet flows. +- Join freshness and provenance to results by `source_id`. Name the source, + returned `deployment_or_view_id`, query ID, and warnings when they affect + confidence. Do not infer which kind the combined identifier represents — + never call a `0x…` nest `deployment_or_view_id` a “view” (it is the nest + registry hash). For Nuthatch wallet activity, the allowlisted relation name + is `wallet_swap_activity` (registry `locator.view_id`); when that source is + unavailable, say so from coverage/warnings and still cite `source_id`, + `deployment_or_view_id`, and `query_id` without inventing that activity was + empty. + +`coverage` names differ per tool: `requested_deployments` / +`successful_deployments` plus `nuthatch_available` for `compare_pools`, and +`requested_sources` / `successful_sources` for `compare_lending_markets` and +`find_large_swaps`. A source is `stale` past 300 seconds of lag. +`compare_pools` and `compare_lending_markets` return `pagination: null`. +`find_large_swaps` returns opaque cursor pagination. + +## Present the answer + +1. Lead with what was compared, the window and ranking metric where they apply, + or the selected token threshold for a swap search, and the overall + `complete`, `partial`, or `failed` status. +2. List each returned pool or market in rank order — for pools, protocol, pool + address, TVL, volume, fees, and source IDs; for markets, protocol, market id, + TVL, deposits, borrows, and both variable rates. Show `null` as unavailable. +3. Report the Nuthatch freshness fact separately when present. If it is + unavailable or stale, say so before explaining the usable Graph results. +4. Surface every warning in plain language. Distinguish missing data from stale + data and operational failure. +5. Include concise provenance for consequential claims. Keep exact hashes, + addresses, block numbers, and decimal strings intact. + +For a `partial` result, answer with the usable evidence and its limitation. +For a `failed` result, do not rank pools or markets or invent a fallback. +Describe the requested scope from the tool invocation. If the response omits a +request field, do not claim the response independently attests to that field. + +Do not compare across tool calls made with different `window` or `ranked_by` +values unless the user asked for exactly that, and say so if you do. + +For a large-swap page, lead with the selected token threshold and status, then +list each swap in returned order with direction, exact input/output amounts, +transaction hash, block/log identity, and source ID. State when a complete page +contains zero matches. Mention `has_more` and offer to continue when a cursor +is available; do not expose the opaque cursor unless the client needs it for +the next tool call. + +For wallet research, lead with the public address, requested window/sections, +and status. Summarize supported activity and positions separately, surface +section coverage and warnings, and describe assets as observed rather than +owned or complete balances. When Nuthatch wallet activity is unavailable, do +not report `deployment_or_view_id` as a “view”; cite it as +`deployment_or_view_id` (nest registry hash) plus `query_id` +`nuthatch-wallet-swap-activity-v1`, and keep counterparties/activity as +unavailable rather than empty. + +## Preserve evidence integrity + +- Never replace nulls with estimates or derive USD values from other fields. +- Never convert financial strings through floating-point arithmetic. +- Never convert a WETH or USDC threshold, amount, or pool delta into USD. +- Never infer a fee tier by dividing fees by volume. The pool records carry no + fee-tier field; the two pools are told apart by `pool_address`. +- Never turn pool fees into a yield, and never convert a lending rate between + APR and APY or into a decimal fraction. +- Never suppress warnings or describe stale/unavailable data as fresh. +- Never invent methodology or schema versions when the response returns null. +- Never call Nuthatch SQL directly, accept SQL from a user, or imply the large + swap search covers another pool, token, chain, or complete market history. diff --git a/skills/deeptrace-pool-research/agents/openai.yaml b/skills/deeptrace-pool-research/agents/openai.yaml new file mode 100644 index 0000000..2d8b300 --- /dev/null +++ b/skills/deeptrace-pool-research/agents/openai.yaml @@ -0,0 +1,12 @@ +interface: + display_name: "DeepTrace Base Research" + short_description: "Compare Base pools, lending markets, swaps, and wallets" + default_prompt: "Use $deeptrace-pool-research to compare Base WETH/USDC pools, Base USDC lending markets, find token-thresholded swaps, or inspect source-bounded wallet activity and Aave positions with verified freshness and provenance." + +dependencies: + tools: + - type: "mcp" + value: "deeptrace" + description: "Read-only Base pool metrics, USDC lending markets, Nuthatch freshness, large-swap pages, and bounded wallet facts" + transport: "streamable_http" + url: "https://mcp.ikodo.dev" diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..174b7e9 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,22 @@ +/** Overrideable gateway defaults for FND-03. */ +export const GATEWAY_DEFAULTS = { + rateLimitMaxRequests: 30, + rateLimitWindowMs: 60_000, + sourceTimeoutMs: 5_000, +} as const; + +/** Practical deployment ceilings for environment overrides. */ +export const GATEWAY_MAXIMUMS = { + rateLimitMaxRequests: 300, + rateLimitWindowMs: 3_600_000, + sourceTimeoutMs: 8_000, +} as const; + +export const GATEWAY_ENV_VARS = { + rateLimitMaxRequests: "DEEPTRACE_RATE_LIMIT_MAX_REQUESTS", + rateLimitWindowMs: "DEEPTRACE_RATE_LIMIT_WINDOW_MS", + sourceTimeoutMs: "DEEPTRACE_SOURCE_TIMEOUT_MS", +} as const; + +/** Inclusive upper bound for gateway positive-integer settings. */ +export const MAX_BOUNDED_POSITIVE_INTEGER = Number.MAX_SAFE_INTEGER; diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..10eed38 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,75 @@ +import { ConfigurationError } from "../errors/application-error.js"; +import { GATEWAY_DEFAULTS, GATEWAY_ENV_VARS, GATEWAY_MAXIMUMS } from "./defaults.js"; +import { parseBoundedPositiveInteger } from "./positive-integer.js"; + +export interface GatewayConfig { + readonly rateLimitMaxRequests: number; + readonly rateLimitWindowMs: number; + readonly sourceTimeoutMs: number; +} + +type EnvSource = Record; + +function readOptionalBoundedPositiveInteger( + env: EnvSource, + variableName: string, + fallback: number, + maximum: number, + invalidNames: string[], +): number { + const raw = env[variableName]; + if (raw === undefined || raw.trim() === "") { + return fallback; + } + + try { + return parseBoundedPositiveInteger(raw, variableName, maximum); + } catch (error) { + if (error instanceof ConfigurationError) { + invalidNames.push(variableName); + return fallback; + } + throw error; + } +} + +/** + * Loads gateway settings from the environment. + * Missing or blank overrides keep documented defaults. + * Invalid overrides throw ConfigurationError naming the variables only. + */ +export function loadGatewayConfig(env: EnvSource = process.env): GatewayConfig { + const invalidNames: string[] = []; + + const rateLimitMaxRequests = readOptionalBoundedPositiveInteger( + env, + GATEWAY_ENV_VARS.rateLimitMaxRequests, + GATEWAY_DEFAULTS.rateLimitMaxRequests, + GATEWAY_MAXIMUMS.rateLimitMaxRequests, + invalidNames, + ); + const rateLimitWindowMs = readOptionalBoundedPositiveInteger( + env, + GATEWAY_ENV_VARS.rateLimitWindowMs, + GATEWAY_DEFAULTS.rateLimitWindowMs, + GATEWAY_MAXIMUMS.rateLimitWindowMs, + invalidNames, + ); + const sourceTimeoutMs = readOptionalBoundedPositiveInteger( + env, + GATEWAY_ENV_VARS.sourceTimeoutMs, + GATEWAY_DEFAULTS.sourceTimeoutMs, + GATEWAY_MAXIMUMS.sourceTimeoutMs, + invalidNames, + ); + + if (invalidNames.length > 0) { + throw new ConfigurationError(invalidNames); + } + + return { + rateLimitMaxRequests, + rateLimitWindowMs, + sourceTimeoutMs, + }; +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..f6ba4c1 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,12 @@ +export { + GATEWAY_DEFAULTS, + GATEWAY_ENV_VARS, + GATEWAY_MAXIMUMS, + MAX_BOUNDED_POSITIVE_INTEGER, +} from "./defaults.js"; +export { loadGatewayConfig, type GatewayConfig } from "./env.js"; +export { + assertBoundedPositiveInteger, + isBoundedPositiveInteger, + parseBoundedPositiveInteger, +} from "./positive-integer.js"; diff --git a/src/config/positive-integer.ts b/src/config/positive-integer.ts new file mode 100644 index 0000000..12495d7 --- /dev/null +++ b/src/config/positive-integer.ts @@ -0,0 +1,51 @@ +import { ConfigurationError } from "../errors/application-error.js"; +import { MAX_BOUNDED_POSITIVE_INTEGER } from "./defaults.js"; + +/** + * Returns true when `value` is a finite integer in [1, maximum]. + * Rejects fractional, NaN, and infinite inputs without inspecting string forms. + */ +export function isBoundedPositiveInteger( + value: number, + maximum = MAX_BOUNDED_POSITIVE_INTEGER, +): boolean { + return Number.isFinite(value) && Number.isInteger(value) && value >= 1 && value <= maximum; +} + +/** + * Asserts a numeric option is a bounded positive integer. + * Throws ConfigurationError naming `variableName` without embedding the raw value. + */ +export function assertBoundedPositiveInteger( + value: number, + variableName: string, + maximum = MAX_BOUNDED_POSITIVE_INTEGER, +): number { + if (!isBoundedPositiveInteger(value, maximum)) { + throw new ConfigurationError([variableName]); + } + return value; +} + +/** + * Parses an environment string as a bounded positive integer. + * Throws ConfigurationError naming `variableName` without embedding the raw value. + */ +export function parseBoundedPositiveInteger( + raw: string, + variableName: string, + maximum = MAX_BOUNDED_POSITIVE_INTEGER, +): number { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + throw new ConfigurationError([variableName]); + } + + // Reject scientific notation and other non-decimal-integer spellings. + if (!/^[+-]?\d+$/.test(trimmed)) { + throw new ConfigurationError([variableName]); + } + + const value = Number(trimmed); + return assertBoundedPositiveInteger(value, variableName, maximum); +} diff --git a/src/errors/application-error.ts b/src/errors/application-error.ts new file mode 100644 index 0000000..3f863ce --- /dev/null +++ b/src/errors/application-error.ts @@ -0,0 +1,34 @@ +import { ErrorCode, type ErrorCode as ErrorCodeValue } from "./codes.js"; + +export class ApplicationError extends Error { + readonly code: ErrorCodeValue; + + constructor(code: ErrorCodeValue, message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ApplicationError"; + this.code = code; + } +} + +export class ConfigurationError extends ApplicationError { + readonly variableNames: readonly string[]; + + constructor(variableNames: readonly string[]) { + const names = [...variableNames]; + super( + ErrorCode.INVALID_CONFIGURATION, + names.length === 1 + ? `Invalid configuration for ${names[0]}` + : `Invalid configuration for ${names.join(", ")}`, + ); + this.name = "ConfigurationError"; + this.variableNames = names; + } +} + +export class RateLimitError extends ApplicationError { + constructor() { + super(ErrorCode.RATE_LIMITED, "Rate limit exceeded"); + this.name = "RateLimitError"; + } +} diff --git a/src/errors/codes.ts b/src/errors/codes.ts new file mode 100644 index 0000000..131bd33 --- /dev/null +++ b/src/errors/codes.ts @@ -0,0 +1,6 @@ +export const ErrorCode = { + INVALID_CONFIGURATION: "INVALID_CONFIGURATION", + RATE_LIMITED: "RATE_LIMITED", +} as const; + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..643ae2f --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,2 @@ +export { ApplicationError, ConfigurationError, RateLimitError } from "./application-error.js"; +export { ErrorCode } from "./codes.js"; diff --git a/src/gateway/clock.ts b/src/gateway/clock.ts new file mode 100644 index 0000000..836da46 --- /dev/null +++ b/src/gateway/clock.ts @@ -0,0 +1,4 @@ +/** Millisecond clock used by gateway primitives. Inject in tests. */ +export type Clock = () => number; + +export const systemClock: Clock = () => Date.now(); diff --git a/src/gateway/index.ts b/src/gateway/index.ts new file mode 100644 index 0000000..1b10d7b --- /dev/null +++ b/src/gateway/index.ts @@ -0,0 +1,2 @@ +export { systemClock, type Clock } from "./clock.js"; +export { FixedWindowRateLimiter, type FixedWindowRateLimiterOptions } from "./rate-limiter.js"; diff --git a/src/gateway/rate-limiter.ts b/src/gateway/rate-limiter.ts new file mode 100644 index 0000000..83eb832 --- /dev/null +++ b/src/gateway/rate-limiter.ts @@ -0,0 +1,68 @@ +import { RateLimitError } from "../errors/application-error.js"; +import { assertBoundedPositiveInteger } from "../config/positive-integer.js"; +import { systemClock, type Clock } from "./clock.js"; + +export interface FixedWindowRateLimiterOptions { + readonly maxRequests: number; + readonly windowMs: number; + readonly clock?: Clock; +} + +interface WindowState { + windowStartMs: number; + count: number; +} + +/** + * In-memory fixed-window rate limiter with an injected clock. + * Rejected executions throw RateLimitError and never invoke the callback. + */ +export class FixedWindowRateLimiter { + private readonly maxRequests: number; + private readonly windowMs: number; + private readonly clock: Clock; + private readonly windows = new Map(); + private lastCleanupMs: number | undefined; + + constructor(options: FixedWindowRateLimiterOptions) { + this.maxRequests = assertBoundedPositiveInteger(options.maxRequests, "maxRequests"); + this.windowMs = assertBoundedPositiveInteger(options.windowMs, "windowMs"); + this.clock = options.clock ?? systemClock; + } + + get trackedKeyCount(): number { + return this.windows.size; + } + + async execute(key: string, callback: () => T | Promise): Promise { + const now = this.clock(); + this.pruneExpiredWindows(now); + const state = this.windows.get(key); + + if (state === undefined || now - state.windowStartMs >= this.windowMs) { + this.windows.set(key, { windowStartMs: now, count: 1 }); + return await callback(); + } + + if (state.count >= this.maxRequests) { + throw new RateLimitError(); + } + + state.count += 1; + return await callback(); + } + + private pruneExpiredWindows(now: number): void { + if (this.lastCleanupMs !== undefined && now - this.lastCleanupMs < this.windowMs) { + return; + } + + for (const [key, state] of this.windows) { + if (now - state.windowStartMs >= this.windowMs) { + this.windows.delete(key); + } + } + + this.lastCleanupMs = now; + } +} diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..a57c572 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,24 @@ +import { HTTP_ENV_VARS, loadHttpConfig } from "./http/config.js"; +import { createHttpServer, listen } from "./http/server.js"; +import { installShutdownHandlers } from "./mcp/lifecycle.js"; + +async function main(): Promise { + const config = loadHttpConfig(); + const runtime = createHttpServer(config); + await listen(runtime, config); + console.error( + `[deeptrace] MCP HTTP transport listening on ${config.host}:${String(config.port)}`, + ); + if (config.sharedToken !== undefined) { + console.error( + `[deeptrace] ${HTTP_ENV_VARS.sharedToken} is still accepted. One leak of it exposes every client and cannot be revoked alone. Retire it by removing the variable; clients mint their own token at /auth.`, + ); + } + installShutdownHandlers(runtime); +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown startup error"; + console.error(`[deeptrace] Failed to start MCP HTTP transport: ${message}`); + process.exitCode = 1; +}); diff --git a/src/http/auth.ts b/src/http/auth.ts new file mode 100644 index 0000000..810ed79 --- /dev/null +++ b/src/http/auth.ts @@ -0,0 +1,46 @@ +import { timingSafeEqual } from "node:crypto"; + +const BEARER_PREFIX = /^Bearer (.+)$/; + +/** + * Compares a presented credential against the expected one without leaking + * length or content through timing. Returns false for any malformed header. + * + * An undefined `sharedToken` means the shared deployment credential has been + * retired, leaving issued per-client tokens as the only way in. + */ +export function isAuthorized( + authorizationHeader: string | undefined, + sharedToken: string | undefined, + issuedTokens?: { verify(token: string): boolean }, +): boolean { + if (authorizationHeader === undefined) { + return false; + } + + const credential = BEARER_PREFIX.exec(authorizationHeader.trim())?.[1]; + if (credential === undefined) { + return false; + } + + // Per-client tokens and the shared deployment token are accepted together so + // existing clients keep working while the shared one is being retired. + if (issuedTokens?.verify(credential) === true) { + return true; + } + + if (sharedToken === undefined) { + return false; + } + + const presented = Buffer.from(credential, "utf8"); + const expected = Buffer.from(sharedToken, "utf8"); + + // timingSafeEqual throws on length mismatch, so the lengths are compared + // first. Token length is not secret; the token itself is. + if (presented.length !== expected.length) { + return false; + } + + return timingSafeEqual(presented, expected); +} diff --git a/src/http/config.ts b/src/http/config.ts new file mode 100644 index 0000000..e386790 --- /dev/null +++ b/src/http/config.ts @@ -0,0 +1,119 @@ +import { parseBoundedPositiveInteger } from "../config/positive-integer.js"; +import { ConfigurationError } from "../errors/application-error.js"; + +/** Overrideable HTTP transport defaults. */ +export const HTTP_DEFAULTS = { + host: "127.0.0.1", + port: 8787, + sessionIdleTimeoutMs: 1_800_000, + sessionSweepIntervalMs: 60_000, +} as const; + +/** Highest accepted values for HTTP transport settings. */ +export const HTTP_MAXIMUMS = { + port: 65_535, + // Node timers clamp larger delays to 1 ms, so never accept them. + sessionIdleTimeoutMs: 2_147_483_647, + sessionSweepIntervalMs: 2_147_483_647, +} as const; + +export const HTTP_ENV_VARS = { + host: "DEEPTRACE_HTTP_HOST", + port: "DEEPTRACE_HTTP_PORT", + sessionIdleTimeoutMs: "DEEPTRACE_HTTP_SESSION_IDLE_TIMEOUT_MS", + sessionSweepIntervalMs: "DEEPTRACE_HTTP_SESSION_SWEEP_INTERVAL_MS", + sharedToken: "DEEPTRACE_HTTP_TOKEN", +} as const; + +/** Shortest bearer token accepted, so a stray value cannot be brute forced. */ +export const MIN_TOKEN_LENGTH = 32; + +export interface HttpConfig { + readonly host: string; + readonly port: number; + readonly sessionIdleTimeoutMs: number; + readonly sessionSweepIntervalMs: number; + /** + * The single credential every client shared before per-client tokens + * existed. Undefined once it is retired, which leaves issued tokens as the + * only way in. One leak of this value compromises every client at once and + * cannot be revoked selectively, so it is kept only for migration. + */ + readonly sharedToken: string | undefined; +} + +type EnvSource = Record; + +function readOptionalBoundedPositiveInteger( + env: EnvSource, + variableName: string, + fallback: number, + maximum: number, + invalidNames: string[], +): number { + const raw = env[variableName]; + if (raw === undefined || raw.trim() === "") { + return fallback; + } + + try { + return parseBoundedPositiveInteger(raw, variableName, maximum); + } catch (error) { + if (error instanceof ConfigurationError) { + invalidNames.push(variableName); + return fallback; + } + throw error; + } +} + +/** + * Loads HTTP transport settings from the environment. + * + * Omitting the shared token retires it rather than failing: clients hold their + * own issued tokens, so the listener stays authenticated without it. Retiring + * it is therefore an operational step — remove the variable — and needs no + * code change. A value that is present but too short is still a + * misconfiguration, since a weak shared secret is worse than none. + * Invalid overrides throw ConfigurationError naming the variables only. + */ +export function loadHttpConfig(env: EnvSource = process.env): HttpConfig { + const invalidNames: string[] = []; + + const rawHost = env[HTTP_ENV_VARS.host]?.trim(); + const host = rawHost === undefined || rawHost === "" ? HTTP_DEFAULTS.host : rawHost; + + const port = readOptionalBoundedPositiveInteger( + env, + HTTP_ENV_VARS.port, + HTTP_DEFAULTS.port, + HTTP_MAXIMUMS.port, + invalidNames, + ); + const sessionIdleTimeoutMs = readOptionalBoundedPositiveInteger( + env, + HTTP_ENV_VARS.sessionIdleTimeoutMs, + HTTP_DEFAULTS.sessionIdleTimeoutMs, + HTTP_MAXIMUMS.sessionIdleTimeoutMs, + invalidNames, + ); + const sessionSweepIntervalMs = readOptionalBoundedPositiveInteger( + env, + HTTP_ENV_VARS.sessionSweepIntervalMs, + HTTP_DEFAULTS.sessionSweepIntervalMs, + HTTP_MAXIMUMS.sessionSweepIntervalMs, + invalidNames, + ); + + const rawSharedToken = env[HTTP_ENV_VARS.sharedToken]?.trim() ?? ""; + const sharedToken = rawSharedToken === "" ? undefined : rawSharedToken; + if (sharedToken !== undefined && sharedToken.length < MIN_TOKEN_LENGTH) { + invalidNames.push(HTTP_ENV_VARS.sharedToken); + } + + if (invalidNames.length > 0) { + throw new ConfigurationError(invalidNames); + } + + return { host, port, sessionIdleTimeoutMs, sessionSweepIntervalMs, sharedToken }; +} diff --git a/src/http/connection-page.ts b/src/http/connection-page.ts new file mode 100644 index 0000000..53d8633 --- /dev/null +++ b/src/http/connection-page.ts @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +/** + * The page is served from the same origin as the MCP bearer endpoint, so it + * stays scriptless. `font-src 'self'` is the only relaxation: it admits the + * three same-origin typefaces below and nothing else. Styles remain inline + * because `style-src` does not list 'self'. + */ +const SECURITY_HEADERS = { + "cache-control": "no-store", + "content-security-policy": + "default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; frame-ancestors 'none'; img-src 'none'; script-src 'none'; style-src 'unsafe-inline'", + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "permissions-policy": + "accelerometer=(), autoplay=(), camera=(), clipboard-read=(), clipboard-write=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()", + "referrer-policy": "no-referrer", + vary: "Accept", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", +} as const; + +function readPublicAsset(name: string): Buffer { + return readFileSync(new URL(`./public/${name}`, import.meta.url)); +} + +const CONNECTION_PAGE = readPublicAsset("index.html"); + +/** + * An exact-match table rather than a filesystem lookup. Request paths are only + * ever compared against these keys, so no user-supplied string reaches the + * filesystem and path traversal is not expressible. + */ +const FONT_ASSETS = new Map([ + ["/assets/text.woff2", readPublicAsset("text.woff2")], + ["/assets/text-italic.woff2", readPublicAsset("text-italic.woff2")], + ["/assets/mono.woff2", readPublicAsset("mono.woff2")], +]); + +function accepts(request: IncomingMessage, mediaType: string): boolean { + return ( + request.headers.accept?.split(",").some((value) => { + const [type, ...parameters] = value.split(";").map((part) => part.trim().toLowerCase()); + if (type !== mediaType) { + return false; + } + const quality = parameters.find((parameter) => parameter.startsWith("q=")); + return quality === undefined || Number(quality.slice(2)) > 0; + }) ?? false + ); +} + +/** + * Treat only a plain HTML GET as a setup-page visit. MCP clients that ask for + * the event-stream media type must continue through the transport auth path. + */ +export function acceptsConnectionPage(request: IncomingMessage): boolean { + return ( + request.method === "GET" && + accepts(request, "text/html") && + !accepts(request, "text/event-stream") + ); +} + +export function respondConnectionPage(response: ServerResponse): void { + response.writeHead(200, { + ...SECURITY_HEADERS, + "content-language": "en", + "content-type": "text/html; charset=utf-8", + }); + response.end(CONNECTION_PAGE); +} + +/** True when the path names one of the connection page's own typefaces. */ +export function isFontAssetRequest(request: IncomingMessage, pathname: string): boolean { + return (request.method === "GET" || request.method === "HEAD") && FONT_ASSETS.has(pathname); +} + +export function respondFontAsset( + request: IncomingMessage, + pathname: string, + response: ServerResponse, +): void { + const asset = FONT_ASSETS.get(pathname); + if (asset === undefined) { + return; + } + + response.writeHead(200, { + "cache-control": "public, max-age=31536000, immutable", + "content-length": asset.byteLength, + "content-type": "font/woff2", + "cross-origin-resource-policy": "same-origin", + "x-content-type-options": "nosniff", + }); + + if (request.method === "HEAD") { + response.end(); + return; + } + response.end(asset); +} diff --git a/src/http/oauth.ts b/src/http/oauth.ts new file mode 100644 index 0000000..e6e5a4b --- /dev/null +++ b/src/http/oauth.ts @@ -0,0 +1,342 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +import type { TokenStore } from "./token-store.js"; + +/** + * OAuth 2.1 authorization code + PKCE, served from the resource server itself. + * + * Running both roles in one process removes the parts that usually make OAuth + * expensive: there is no second host, no signing key, and no JWKS, because the + * resource server can look an opaque token straight up in the token store + * instead of verifying a signature. What it buys is the browser round trip — + * clients open a page, approve, and receive the token themselves, so nobody + * copies a secret by hand. + */ + +const ISSUER = "https://mcp.ikodo.dev"; +export const SCOPE = "deeptrace:read"; + +export const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource"; +export const AUTHORIZATION_SERVER_PATH = "/.well-known/oauth-authorization-server"; +export const REGISTER_PATH = "/oauth/register"; +export const AUTHORIZE_PATH = "/oauth/authorize"; +export const TOKEN_PATH = "/oauth/token"; + +/** An authorization code is redeemed seconds later, in one round trip. */ +const CODE_TTL_MS = 60_000; + +const PAGE_HEADERS = { + "cache-control": "no-store", + "content-security-policy": + "default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'none'; script-src 'none'; style-src 'unsafe-inline'", + "referrer-policy": "no-referrer", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", +} as const; + +interface RegisteredClient { + readonly redirectUris: readonly string[]; + readonly name: string; +} + +interface PendingCode { + readonly clientId: string; + readonly redirectUri: string; + readonly challenge: string; + readonly expiresAt: number; +} + +const clients = new Map(); +const codes = new Map(); + +/** + * Only loopback callbacks are accepted. A public client cannot keep a secret, + * so the redirect target is the only thing binding a code to its requester — + * an off-host URI would let a stranger's server receive the code. + */ +function isLoopbackRedirect(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + return url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost"); +} + +function json(response: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + response.writeHead(status, { + "cache-control": "no-store", + "content-length": Buffer.byteLength(payload), + "content-type": "application/json", + "x-content-type-options": "nosniff", + }); + response.end(payload); +} + +async function readForm(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(chunk as Buffer); + } + return new URLSearchParams(Buffer.concat(chunks).toString("utf8")); +} + +function escapeHtml(value: string): string { + return value.replace( + /[&<>"']/gu, + (character) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character] ?? + character, + ); +} + +const STYLE = ``; + +function page(title: string, body: string): string { + return ` + + +${title}${STYLE} +
${body}
+`; +} + +function consentPage(clientName: string, hidden: string): string { + return page( + "Connect DeepTrace", + `

Connect DeepTrace

+
Requesting access${escapeHtml(clientName)}
+

This grants read-only pool research. Approving returns a token to the client +automatically — nothing to copy.

+
    +
  • Compare Base WETH/USDC pools
  • +
  • Search large swaps
  • +
  • Cannot trade, sign, or send transactions
  • +
  • Cannot read your files or your wallet
  • +
+
${hidden}
`, + ); +} + +function errorPage(message: string): string { + return page("Cannot connect", `

Cannot connect

${escapeHtml(message)}

`); +} + +function respondPage(response: ServerResponse, status: number, html: string): void { + response.writeHead(status, { ...PAGE_HEADERS, "content-type": "text/html; charset=utf-8" }); + response.end(html); +} + +export function isOAuthRequest(pathname: string): boolean { + return ( + pathname === PROTECTED_RESOURCE_PATH || + pathname === AUTHORIZATION_SERVER_PATH || + pathname === REGISTER_PATH || + pathname === AUTHORIZE_PATH || + pathname === TOKEN_PATH + ); +} + +/** Discovery, registration, consent, and redemption. All unauthenticated: a + * client cannot present a token before this flow has issued one. */ +export async function respondOAuth( + request: IncomingMessage, + url: URL, + response: ServerResponse, + store: TokenStore, + now: () => number = Date.now, +): Promise { + const method = request.method ?? "GET"; + + if (url.pathname === PROTECTED_RESOURCE_PATH) { + json(response, 200, { + resource: ISSUER, + authorization_servers: [ISSUER], + scopes_supported: [SCOPE], + bearer_methods_supported: ["header"], + }); + return; + } + + if (url.pathname === AUTHORIZATION_SERVER_PATH) { + json(response, 200, { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}${AUTHORIZE_PATH}`, + token_endpoint: `${ISSUER}${TOKEN_PATH}`, + registration_endpoint: `${ISSUER}${REGISTER_PATH}`, + scopes_supported: [SCOPE], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["none"], + }); + return; + } + + // RFC 7591. MCP clients have no pre-issued client_id, so they register on + // first contact. Public clients only: no secret is issued or accepted. + if (url.pathname === REGISTER_PATH) { + if (method !== "POST") { + json(response, 405, { error: "invalid_request" }); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(chunk as Buffer); + } + let metadata: { redirect_uris?: unknown; client_name?: unknown }; + try { + metadata = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as typeof metadata; + } catch { + json(response, 400, { error: "invalid_client_metadata" }); + return; + } + const redirectUris = Array.isArray(metadata.redirect_uris) + ? metadata.redirect_uris.filter( + (value): value is string => typeof value === "string" && isLoopbackRedirect(value), + ) + : []; + if (redirectUris.length === 0) { + json(response, 400, { + error: "invalid_redirect_uri", + error_description: "Only loopback redirect URIs are accepted", + }); + return; + } + const clientId = randomUUID(); + clients.set(clientId, { + redirectUris, + name: typeof metadata.client_name === "string" ? metadata.client_name : "an MCP client", + }); + json(response, 201, { + client_id: clientId, + client_id_issued_at: Math.floor(now() / 1000), + redirect_uris: redirectUris, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }); + return; + } + + if (url.pathname === AUTHORIZE_PATH) { + const parameters = method === "POST" ? await readForm(request) : url.searchParams; + const clientId = parameters.get("client_id") ?? ""; + const redirectUri = parameters.get("redirect_uri") ?? ""; + const challenge = parameters.get("code_challenge") ?? ""; + const state = parameters.get("state") ?? ""; + const client = clients.get(clientId); + + // A bad redirect_uri can never be redirected to: doing so is the open + // redirect. These failures render here instead. + if (client === undefined || !client.redirectUris.includes(redirectUri)) { + respondPage(response, 400, errorPage("This client or its callback is not recognised.")); + return; + } + if (parameters.get("code_challenge_method") !== "S256" || challenge === "") { + respondPage(response, 400, errorPage("This client did not use PKCE with S256.")); + return; + } + + if (method === "GET") { + const hidden = ( + [ + ["client_id", clientId], + ["redirect_uri", redirectUri], + ["code_challenge", challenge], + ["code_challenge_method", "S256"], + ["state", state], + ] as const + ) + .map(([name, value]) => ``) + .join(""); + respondPage(response, 200, consentPage(client.name, hidden)); + return; + } + + const code = randomUUID(); + codes.set(code, { + clientId, + redirectUri, + challenge, + expiresAt: now() + CODE_TTL_MS, + }); + const target = new URL(redirectUri); + target.searchParams.set("code", code); + if (state !== "") { + target.searchParams.set("state", state); + } + response.writeHead(302, { "cache-control": "no-store", location: target.toString() }); + response.end(); + return; + } + + // TOKEN_PATH + if (method !== "POST") { + json(response, 405, { error: "invalid_request" }); + return; + } + const form = await readForm(request); + const code = form.get("code") ?? ""; + const pending = codes.get(code); + codes.delete(code); // single use, whether or not it validates + + if ( + form.get("grant_type") !== "authorization_code" || + pending === undefined || + pending.expiresAt < now() || + pending.clientId !== form.get("client_id") || + pending.redirectUri !== form.get("redirect_uri") + ) { + json(response, 400, { error: "invalid_grant" }); + return; + } + + const verifier = form.get("code_verifier") ?? ""; + const computed = createHash("sha256").update(verifier, "utf8").digest("base64url"); + if (verifier === "" || computed !== pending.challenge) { + json(response, 400, { error: "invalid_grant", error_description: "PKCE verification failed" }); + return; + } + + json(response, 200, { + access_token: store.mint(), + token_type: "Bearer", + scope: SCOPE, + }); +} + +/** Test seam: registrations and codes are process-global. */ +export function resetOAuthState(): void { + clients.clear(); + codes.clear(); +} diff --git a/src/http/public/index.html b/src/http/public/index.html new file mode 100644 index 0000000..60ccdf6 --- /dev/null +++ b/src/http/public/index.html @@ -0,0 +1,368 @@ + + + + + + +Connect DeepTrace to your AI + + + + + + +
+ +
+ + + + deeptrace + + +
+ +

Compare Base liquidity pools with verified Graph metrics and independent Nuthatch freshness.

+

Connect DeepTrace to Claude Code, OpenCode, or Codex. No local installation, no Graph API key, no Tailscale.

+
+ MCP URL + https://mcp.ikodo.dev +
+ +
+

Get an access token

Step one
+

Take a token

+

No wallet, no account, no email. The token is yours alone — if it leaks, only yours is revoked. Export it as DEEPTRACE_TOKEN in the shell that launches your AI client.

+
+
ShellClick to select
+
$ export DEEPTRACE_TOKEN="<your-token>"
+
+
+ Never +

Never paste a token into a chat, put it in a URL, or commit it. This page has no token field and writes nothing to your browser — any site asking you to type a DeepTrace token is not this one.

+
+

Lost it? Take another. Tokens are stored only as hashes, so a lost one cannot be recovered — but replacing it costs nothing.

+
+ +
+

Connect and verify

Step two
+ +
+ Claude Code +
+
+
Add the serverClick to select
+
$ claude mcp add --transport http deeptrace \
+    https://mcp.ikodo.dev \
+    --header "Authorization: Bearer $DEEPTRACE_TOKEN"
+
+
+
Config
~/.claude.json
+
Verify
claude mcp list
+
Expect
deeptrace · connected, with compare_pools, compare_lending_markets, find_large_swaps, and research_wallet offered in a new session.
+
+
+
+ +
+ OpenCode +
+
+
opencode.jsonClick to select
+
{
+  "mcp": {
+    "deeptrace": {
+      "type": "remote",
+      "url": "https://mcp.ikodo.dev",
+      "enabled": true,
+      "headers": { "Authorization": "Bearer {env:DEEPTRACE_TOKEN}" }
+    }
+  }
+}
+
+
+
Config
~/.config/opencode/opencode.json, or opencode.json in the project root.
+
Verify
Run opencode, then /mcp.
+
Expect
deeptrace connected, exposing all four read-only research tools.
+
+
+
+ +
+ Codex +
+
+
config.tomlClick to select
+
experimental_use_rmcp_client = true
+
+[mcp_servers.deeptrace]
+url = "https://mcp.ikodo.dev"
+bearer_token_env_var = "DEEPTRACE_TOKEN"
+
+
+
Config
~/.codex/config.toml
+
Verify
codex mcp list
+
Expect
deeptrace listed and enabled. Streamable HTTP needs the RMCP client flag.
+
+
+
+ +
+ Any other MCP client +
+
+
Transport
Streamable HTTP
+
URL
https://mcp.ikodo.dev
+
Header
Authorization: Bearer <token>
+
+
+
Verify with curlClick to select
+
$ curl -sS -X POST https://mcp.ikodo.dev \
+    -H "Authorization: Bearer $DEEPTRACE_TOKEN" \
+    -H "Content-Type: application/json" \
+    -H "Accept: application/json, text/event-stream" \
+    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
+
+
+
Expect
A JSON-RPC result listing compare_pools, compare_lending_markets, find_large_swaps, and research_wallet. Without a token this endpoint answers 401 — that response still proves you reached it.
+
+
+
+ +

The canonical URL is https://mcp.ikodo.dev. The older /mcp path remains only for existing client configurations.

+
+ +
+

Ask the first question

Step three
+
+
Paste into your AI clientClick to select
+
Compare Base WETH/USDC pools over the last 24 hours by volume.
+Show the ranked pools, which sources answered, whether Nuthatch
+is fresh, and every warning.
+
+

You should get back ranked Graph metrics, source coverage, provenance identifiers, and a separate Nuthatch freshness fact.

+
+ +
+

What comes back

Evidence
+

Every field below is real: the addresses, the source identifiers, the methodology version. Only the money is missing, and that is deliberate — a tool that refuses to invent metrics should not invent them on its own front page.

+
+
compare_pools · 24h · by volumePartial
+
+

Ranked pools

+
1 · uniswap-v3 volume_usd
+
   pool_address0x6c561b…4171372
+
2 · pancakeswap-v3 volume_usd
+
   pool_address0x72ab38…011c2d38
+

Coverage

+
requested_deployments2
+
successful_deployments1
+
nuthatch_availabletrue
+

Provenance

+
source_iduniswap-v3-base-native
+
query_idm2-tier-b-metrics-v1
+
methodology_versioncompleted-utc-days-v1
+

Nuthatch freshness — never a financial value

+
last_swap_block
+
recent_swap_count_24h
+
statusfresh
+

Warnings

+
1exchange-v3-base did not answer
+
+

Withheld. Real values arrive only from a real query.

+
+

This example is partial on purpose. One source timed out, so the ranking honestly covers one of two pools and says so. A result that always succeeds would prove nothing.

+
+ +
+

The optional skill

Not required
+

DeepTrace works the moment MCP is connected. The skill only teaches your AI to preserve exact decimal strings, keep Graph metrics distinct from Nuthatch freshness, and surface partial coverage. Connecting MCP does not automatically install or load the skill.

+
+
RecommendedClick to select
+
$ npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research
+
+
+
Review first
The installer detects supported agents and asks where to install. Read the skill before approving it.
+
Then restart
Start a new AI session after installing.
+
No secrets
Installing the skill does not configure MCP or store your token.
+
Fallback
If npx skills is unavailable, download the folder from GitHub into your client's skills directory.
+
+
+ +
+

What it covers

Current limits
+
+
Network
Base · chain 8453
+
Pair
WETH / native USDC
+
Windows
24 hours and 7 days
+
Rank by
TVL, volume, or fees — up to three pools
+
The Graph
Supplies every financial metric
+
Nuthatch
Supplies swap-index freshness, large-swap receipts, and source-bounded wallet activity. Graph and Nuthatch stay separate.
+
Tools
compare_pools, compare_lending_markets, find_large_swaps, and research_wallet are live. get_dex_metrics is post-MVP.
+
Never writes
DeepTrace cannot trade, sign, or submit a transaction.
+
Never invents
No model runs inside DeepTrace. Missing data stays missing.
+
Never reprices
USD values come from the source that reported them.
+
+
+ +
+

When something is wrong

Common cases
+
+
401
Token missing, invalid, or expired. Check that DEEPTRACE_TOKEN is exported in the shell that launched your client.
+
403
Untrusted request origin. Connect from your AI client, not from a web page.
+
404
Check the hostname: ikodo.dev, not icodo.dev.
+
Timeout
Verify network and hostname. Tailscale is not required and will not help.
+
partial
Not an error. Read coverage and warnings to see which source was unavailable.
+
No skill
Restart the AI client after installing it.
+
OAuth only
Clients that require OAuth are unsupported until wallet sign-in ships. Use one that sends bearer headers.
+
+
+ + + +
+ + diff --git a/src/http/public/mono.woff2 b/src/http/public/mono.woff2 new file mode 100644 index 0000000..8f315a3 Binary files /dev/null and b/src/http/public/mono.woff2 differ diff --git a/src/http/public/text-italic.woff2 b/src/http/public/text-italic.woff2 new file mode 100644 index 0000000..14cce97 Binary files /dev/null and b/src/http/public/text-italic.woff2 differ diff --git a/src/http/public/text.woff2 b/src/http/public/text.woff2 new file mode 100644 index 0000000..8f7e887 Binary files /dev/null and b/src/http/public/text.woff2 differ diff --git a/src/http/server.ts b/src/http/server.ts new file mode 100644 index 0000000..65eb208 --- /dev/null +++ b/src/http/server.ts @@ -0,0 +1,479 @@ +import { randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; + +import { loadGatewayConfig } from "../config/env.js"; +import { FixedWindowRateLimiter } from "../gateway/index.js"; +import { createMcpServer } from "../mcp/server.js"; +import { createLiveComparePoolsSources, createLiveLargeSwapSource } from "../tools/index.js"; +import { isAuthorized } from "./auth.js"; +import type { HttpConfig } from "./config.js"; +import { join } from "node:path"; + +import { + AUTHORIZATION_SERVER_PATH, + AUTHORIZE_PATH, + isOAuthRequest, + PROTECTED_RESOURCE_PATH, + REGISTER_PATH, + respondOAuth, + TOKEN_PATH, +} from "./oauth.js"; +import { isTokenIssueRequest, respondTokenIssue } from "./token-issue.js"; +import { TokenStore } from "./token-store.js"; +import { + acceptsConnectionPage, + isFontAssetRequest, + respondConnectionPage, + respondFontAsset, +} from "./connection-page.js"; + +/** Root is canonical; /mcp remains an alias for existing client configs. */ +const MCP_PATHS = new Set(["/", "/mcp"]); +const ALLOWED_ORIGINS = new Set(["https://mcp.ikodo.dev"]); +const SESSION_HEADER = "mcp-session-id"; +/** Bounds memory held by abandoned sessions that never send DELETE. */ +const MAX_SESSIONS = 64; + +interface Session { + readonly transport: StreamableHTTPServerTransport; + readonly close: () => Promise; + activeRequests: number; + lastActivityAt: number; +} + +interface OpenedSession { + readonly transport: StreamableHTTPServerTransport; + /** True once onsessioninitialized registered the session in the map. */ + readonly wasRegistered: () => boolean; + /** Marks the initialize request complete when it registered a session. */ + readonly finishRequest: () => void; + /** Closes both the transport and the McpServer. Safe to call once. */ + readonly dispose: () => Promise; +} + +export interface HttpServerOptions { + readonly now?: () => number; + readonly scheduleSessionSweep?: (sweep: () => Promise, intervalMs: number) => () => void; + /** Defaults to the path in DEEPTRACE_TOKEN_STORE. Injected by tests. */ + readonly tokenStore?: TokenStore; +} + +export interface HttpRuntime { + readonly server: Server; + close(): Promise; +} + +function scheduleSessionSweep(sweep: () => Promise, intervalMs: number): () => void { + let sweepInProgress = false; + const interval = setInterval(() => { + if (sweepInProgress) { + return; + } + sweepInProgress = true; + void sweep() + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown session cleanup error"; + console.error(`[deeptrace] Session cleanup failed: ${message}`); + }) + .finally(() => { + sweepInProgress = false; + }); + }, intervalMs); + interval.unref(); + return () => { + clearInterval(interval); + }; +} + +function respondJson( + response: ServerResponse, + status: number, + code: string, + message: string, +): void { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { code, message } })); +} + +/** + * Client-to-server OAuth calls carry whatever origin their client sets — a + * CLI, an editor shell, a loopback callback — and none of those can be + * enumerated. Their defence is PKCE and single-use codes, not an allowlist, + * so applying one here rejects working clients and nothing else. + * + * The allowlist still covers the MCP endpoint and the consent form, where a + * cross-site POST is the actual threat. + */ +const ORIGIN_FREE_PATHS = new Set([ + PROTECTED_RESOURCE_PATH, + AUTHORIZATION_SERVER_PATH, + REGISTER_PATH, + TOKEN_PATH, +]); + +function requiresAllowedOrigin(request: IncomingMessage, pathname: string): boolean { + if (ORIGIN_FREE_PATHS.has(pathname)) { + return false; + } + // Opening the consent screen is a top-level navigation, which sends no + // origin; submitting it is the request worth guarding. + if (pathname === AUTHORIZE_PATH) { + return request.method === "POST"; + } + return true; +} + +function hasAllowedOrigin(request: IncomingMessage): boolean { + const origin = request.headers.origin; + return origin === undefined || ALLOWED_ORIGINS.has(origin); +} + +function isBrowserNavigation(request: IncomingMessage): boolean { + if (request.method !== "GET" || request.headers["sec-fetch-mode"] !== "navigate") { + return false; + } + + return ( + request.headers.accept + ?.split(",") + .some((value) => value.trim().split(";", 1)[0]?.toLowerCase() === "text/html") ?? false + ); +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(chunk as Buffer); + } + if (chunks.length === 0) { + return undefined; + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +/** + * Serves the MCP Streamable HTTP transport behind a bearer token. + * + * Each initialize request gets its own McpServer/transport pair keyed by + * session id, so concurrent clients never share conversation state. + */ +export function createHttpServer(config: HttpConfig, options: HttpServerOptions = {}): HttpRuntime { + const sessions = new Map(); + const pendingCloses = new Set>(); + const now = options.now ?? Date.now; + /** In-flight opens that have reserved a slot but not yet registered. */ + let pendingOpens = 0; + let shuttingDown = false; + let shutdownPromise: Promise | undefined; + + // Built once per process, not once per session. A limiter constructed inside + // createMcpServer would give every session its own private window, so the + // configured ceiling would be multiplied by the number of live sessions + // instead of protecting the upstream gateway. Loading the gateway config here + // also fails fast at startup rather than per request. + const tokenStore = + options.tokenStore ?? + new TokenStore( + process.env.DEEPTRACE_TOKEN_STORE ?? join(process.cwd(), ".deeptrace-tokens.json"), + ); + + const gatewayConfig = loadGatewayConfig(); + const rateLimiter = new FixedWindowRateLimiter({ + maxRequests: gatewayConfig.rateLimitMaxRequests, + windowMs: gatewayConfig.rateLimitWindowMs, + }); + const sources = createLiveComparePoolsSources({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); + const largeSwapSource = createLiveLargeSwapSource({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); + + const closeSession = (sessionId: string, expectedSession?: Session): Promise => { + const session = sessions.get(sessionId); + if (session === undefined || (expectedSession !== undefined && session !== expectedSession)) { + return Promise.resolve(); + } + sessions.delete(sessionId); + const closePromise = session.close(); + pendingCloses.add(closePromise); + void closePromise.then( + () => { + pendingCloses.delete(closePromise); + }, + () => { + pendingCloses.delete(closePromise); + }, + ); + return closePromise; + }; + + const openSession = async (): Promise => { + const mcpServer = createMcpServer({ + gatewayConfig, + rateLimiter, + sources, + largeSwapSource, + }); + let registeredSession: Session | undefined; + let closePromise: Promise | undefined; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + const session: Session = { + transport, + activeRequests: 1, + lastActivityAt: now(), + close: () => { + closePromise ??= (async () => { + try { + await transport.close(); + } finally { + await mcpServer.close(); + } + })(); + return closePromise; + }, + }; + registeredSession = session; + sessions.set(sessionId, session); + if (shuttingDown) { + void closeSession(sessionId, session); + } + }, + onsessionclosed: (sessionId) => { + void closeSession(sessionId).catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown session cleanup error"; + console.error(`[deeptrace] Session cleanup failed: ${message}`); + }); + }, + }); + + // The SDK types onclose/onerror/onmessage as always-present accessors that + // may hold undefined, while Transport declares them optional. Those differ + // under exactOptionalPropertyTypes even though the runtime shape matches. + await mcpServer.connect(transport as Transport); + return { + transport, + wasRegistered: () => registeredSession !== undefined, + finishRequest: () => { + if (registeredSession !== undefined) { + registeredSession.activeRequests -= 1; + registeredSession.lastActivityAt = now(); + } + }, + dispose: () => { + closePromise ??= (async () => { + try { + await transport.close(); + } finally { + await mcpServer.close(); + } + })(); + return closePromise; + }, + }; + }; + + const sweepIdleSessions = async (): Promise => { + const sweepAt = now(); + const staleSessions = [...sessions.entries()].filter( + ([, session]) => + session.activeRequests === 0 && + sweepAt - session.lastActivityAt >= config.sessionIdleTimeoutMs, + ); + await Promise.all( + staleSessions.map(([sessionId, session]) => closeSession(sessionId, session)), + ); + }; + + const cancelSessionSweep = (options.scheduleSessionSweep ?? scheduleSessionSweep)( + sweepIdleSessions, + config.sessionSweepIntervalMs, + ); + + const server = createServer((request, response) => { + void (async () => { + try { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); + if (requiresAllowedOrigin(request, url.pathname) && !hasAllowedOrigin(request)) { + respondJson(response, 403, "invalid_origin", "Origin is not allowed"); + return; + } + + // The connection page's typefaces. Served before the MCP path check + // because they are the only non-MCP paths this origin answers. + if (isFontAssetRequest(request, url.pathname)) { + respondFontAsset(request, url.pathname, response); + return; + } + + // Unauthenticated by design: a caller cannot present a token before + // this endpoint has given them one. + if (isTokenIssueRequest(request, url.pathname)) { + respondTokenIssue(request, response, tokenStore); + return; + } + + // Discovery, consent, and code redemption. Unauthenticated for the same + // reason, and ahead of the MCP paths so the flow is reachable. + if (isOAuthRequest(url.pathname)) { + await respondOAuth(request, url, response, tokenStore, now); + return; + } + + if (!MCP_PATHS.has(url.pathname)) { + respondJson(response, 404, "not_found", "Unknown endpoint"); + return; + } + + if (url.pathname === "/" && acceptsConnectionPage(request)) { + respondConnectionPage(response); + return; + } + + // A top-level browser visit cannot supply an MCP bearer token and + // should not trigger the browser's native credential dialog. Keep this + // branch narrow so programmatic MCP requests retain the auth challenge. + if (isBrowserNavigation(request)) { + respondJson(response, 404, "not_found", "This endpoint is available to MCP clients"); + return; + } + + if (!isAuthorized(request.headers.authorization, config.sharedToken, tokenStore)) { + // resource_metadata is how a client discovers the browser flow. + // Without it the 401 is a dead end and the user copies a token by hand. + response.setHeader( + "www-authenticate", + `Bearer realm="deeptrace", resource_metadata="https://mcp.ikodo.dev${PROTECTED_RESOURCE_PATH}"`, + ); + respondJson(response, 401, "unauthorized", "Missing or invalid bearer token"); + return; + } + + if (shuttingDown) { + respondJson(response, 503, "shutting_down", "Server is shutting down"); + return; + } + + const sessionId = request.headers[SESSION_HEADER]; + const existing = typeof sessionId === "string" ? sessions.get(sessionId) : undefined; + + if (existing !== undefined) { + // POST may contain long-running tool work and must not be reaped + // mid-request. A standalone SSE GET is only activity when it starts; + // otherwise an abandoned open stream could pin a session forever. + const blocksIdleExpiration = request.method === "POST"; + if (blocksIdleExpiration) { + existing.activeRequests += 1; + } + existing.lastActivityAt = now(); + try { + await existing.transport.handleRequest(request, response); + } finally { + if (blocksIdleExpiration) { + existing.activeRequests -= 1; + } + existing.lastActivityAt = now(); + } + return; + } + + if (request.method !== "POST") { + respondJson(response, 400, "missing_session", "Unknown or missing session id"); + return; + } + + const body = await readBody(request); + if (!isInitializeRequest(body)) { + respondJson(response, 400, "missing_session", "Unknown or missing session id"); + return; + } + + if (sessions.size + pendingOpens >= MAX_SESSIONS) { + respondJson(response, 503, "session_limit", "Too many active sessions"); + return; + } + + pendingOpens += 1; + try { + const session = await openSession(); + try { + await session.transport.handleRequest(request, response, body); + } finally { + session.finishRequest(); + if (!session.wasRegistered()) { + await session.dispose(); + } + } + } finally { + pendingOpens -= 1; + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown request error"; + console.error(`[deeptrace] HTTP request failed: ${message}`); + if (!response.headersSent) { + respondJson(response, 500, "internal_error", "Request failed"); + } else { + response.end(); + } + } + })(); + }); + + return { + server, + close() { + shutdownPromise ??= (async () => { + shuttingDown = true; + cancelSessionSweep(); + + // Stop accepting connections immediately, then close both the sessions + // already registered and any initialize request that finishes while + // the HTTP server drains. + const serverClose = new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }); + const failures: unknown[] = []; + const settle = async (promises: readonly Promise[]): Promise => { + const results = await Promise.allSettled(promises); + for (const result of results) { + if (result.status === "rejected") { + failures.push(result.reason); + } + } + }; + + await settle([...sessions.keys()].map((sessionId) => closeSession(sessionId))); + await settle([serverClose]); + await settle([...sessions.keys()].map((sessionId) => closeSession(sessionId))); + await settle([...pendingCloses]); + + if (failures.length > 0) { + throw failures[0]; + } + })(); + return shutdownPromise; + }, + }; +} + +export function listen(runtime: HttpRuntime, config: HttpConfig): Promise { + return new Promise((resolve, reject) => { + runtime.server.once("error", reject); + runtime.server.listen(config.port, config.host, () => { + runtime.server.off("error", reject); + resolve(); + }); + }); +} diff --git a/src/http/token-issue.ts b/src/http/token-issue.ts new file mode 100644 index 0000000..109c424 --- /dev/null +++ b/src/http/token-issue.ts @@ -0,0 +1,124 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +import type { TokenStore } from "./token-store.js"; + +export const ISSUE_PATH = "/auth"; + +/** + * The connection page forbids forms outright. This page needs exactly one, so + * it carries its own policy with `form-action 'self'` and nothing else added. + * + * The referrer policy is `same-origin` rather than `no-referrer` because + * `no-referrer` is the one policy that makes a browser send `Origin: null`. + * This page's own form would then be rejected by the origin allowlist that + * protects it. `same-origin` still sends nothing to other sites. + */ +const PAGE_HEADERS = { + "cache-control": "no-store", + "content-security-policy": + "default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'none'; script-src 'none'; style-src 'unsafe-inline'", + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "referrer-policy": "same-origin", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", +} as const; + +const STYLE = ``; + +function page(title: string, body: string): string { + return ` + + +${title}${STYLE} +
${body}
+ +`; +} + +const FORM_PAGE = page( + "Get a DeepTrace token", + `

Get a token

+

DeepTrace is read-only and open. Take a token, put it in your AI client, and start +comparing Base pools. No wallet, no account, no email.

+

Your token is yours alone. If it leaks, only yours is revoked — everyone else keeps working.

+
`, +); + +/** Same length as the value so revealing it does not reflow the page. */ +function cover(value: string): string { + return "*".repeat(value.length); +} + +function issuedPage(token: string): string { + const stars = cover(token); + return page( + "Your DeepTrace token", + `

Your token

+
${token}
+

Covered by asterisks until you point at it. One click selects the whole token, then +Cmd/Ctrl+C copies it.

+

Export it, then follow the setup guide for your client:

+
export DEEPTRACE_TOKEN="${token}"
+

Never paste it into a chat, put it in a URL, or commit it.

`, + ); +} + +export function isTokenIssueRequest(request: IncomingMessage, pathname: string): boolean { + return (request.method === "GET" || request.method === "POST") && pathname === ISSUE_PATH; +} + +/** + * Minting is unmetered. Access is open by design, and a token grants nothing a + * caller could not already take by asking again, so a per-address cap only ever + * blocked legitimate reconnection. Spend is bounded where it is actually + * incurred: the request rate limiter in front of the tools. + */ +export function respondTokenIssue( + request: IncomingMessage, + response: ServerResponse, + store: TokenStore, +): void { + if (request.method === "GET") { + response.writeHead(200, { ...PAGE_HEADERS, "content-type": "text/html; charset=utf-8" }); + response.end(FORM_PAGE); + return; + } + + response.writeHead(201, { ...PAGE_HEADERS, "content-type": "text/html; charset=utf-8" }); + response.end(issuedPage(store.mint())); +} diff --git a/src/http/token-store.ts b/src/http/token-store.ts new file mode 100644 index 0000000..fd6d10c --- /dev/null +++ b/src/http/token-store.ts @@ -0,0 +1,114 @@ +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +/** + * Per-client bearer tokens, so one leaked credential revokes alone instead of + * locking out everyone. Tokens are stored only as SHA-256 digests: the file is + * enough to revoke a token but not to use one. + * + * Lookup is by digest rather than by comparison, so verification cost does not + * grow with the number of issued tokens and no timing-safe compare is needed — + * an attacker cannot work backwards from a digest to the secret. + */ + +/** Marks the secret for scanners, and makes a leaked string identifiable. */ +const TOKEN_PREFIX = "dt_"; +const TOKEN_BYTES = 32; + +export interface TokenRecord { + readonly createdAt: number; + lastUsedAt: number | null; +} + +function digest(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export class TokenStore { + private readonly records = new Map(); + + constructor( + private readonly path: string, + private readonly now: () => number = Date.now, + ) { + this.load(); + } + + private load(): void { + let raw: string; + try { + raw = readFileSync(this.path, "utf8"); + } catch { + // A missing file is the empty store, not an error: the first mint + // creates it. Any other read failure surfaces on the next write. + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Starting empty would revoke every issued token silently, so the + // corruption is announced on stderr. stdout carries MCP frames. + console.error(`[deeptrace] Ignoring unreadable token store at ${this.path}`); + return; + } + if (parsed === null || typeof parsed !== "object") { + return; + } + for (const [hash, value] of Object.entries(parsed as Record)) { + if (value === null || typeof value !== "object") { + continue; + } + const record = value as Partial; + if (typeof record.createdAt !== "number") { + continue; + } + this.records.set(hash, { + createdAt: record.createdAt, + lastUsedAt: typeof record.lastUsedAt === "number" ? record.lastUsedAt : null, + }); + } + } + + private persist(): void { + mkdirSync(dirname(this.path), { recursive: true }); + writeFileSync(this.path, JSON.stringify(Object.fromEntries(this.records)), { + encoding: "utf8", + mode: 0o600, + }); + // writeFileSync only applies mode when it creates the file, so an existing + // file keeps whatever permissions it had. + chmodSync(this.path, 0o600); + } + + /** Returns the plaintext token. It is never recoverable after this call. */ + mint(): string { + const token = TOKEN_PREFIX + randomBytes(TOKEN_BYTES).toString("base64url"); + this.records.set(digest(token), { createdAt: this.now(), lastUsedAt: null }); + this.persist(); + return token; + } + + verify(presented: string): boolean { + const record = this.records.get(digest(presented)); + if (record === undefined) { + return false; + } + record.lastUsedAt = this.now(); + return true; + } + + revoke(hash: string): boolean { + if (!this.records.delete(hash)) { + return false; + } + this.persist(); + return true; + } + + size(): number { + return this.records.size; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..86e7211 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { installShutdownHandlers, startMcpServer } from "./mcp/lifecycle.js"; + +async function main(): Promise { + const runtime = await startMcpServer(new StdioServerTransport()); + installShutdownHandlers(runtime); +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown startup error"; + console.error(`[deeptrace] Failed to start MCP server: ${message}`); + process.exitCode = 1; +}); diff --git a/src/mcp/lifecycle.ts b/src/mcp/lifecycle.ts new file mode 100644 index 0000000..b950695 --- /dev/null +++ b/src/mcp/lifecycle.ts @@ -0,0 +1,57 @@ +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +import { createMcpServer, type CreateMcpServerOptions } from "./server.js"; + +export type ShutdownSignal = "SIGINT" | "SIGTERM"; + +export interface McpServerRuntime { + readonly server: ReturnType; + close(): Promise; +} + +export interface ShutdownSignalTarget { + once(signal: ShutdownSignal, listener: () => void): unknown; + off(signal: ShutdownSignal, listener: () => void): unknown; +} + +export async function startMcpServer( + transport: Transport, + options: CreateMcpServerOptions = {}, +): Promise { + const server = createMcpServer(options); + await server.connect(transport); + + let closePromise: Promise | undefined; + + return { + server, + close() { + closePromise ??= server.close(); + return closePromise; + }, + }; +} + +export function installShutdownHandlers( + runtime: Pick, + signalTarget: ShutdownSignalTarget = process, + onError: (error: unknown) => void = (error) => { + const message = error instanceof Error ? error.message : "Unknown shutdown error"; + console.error(`[deeptrace] Failed to stop MCP server: ${message}`); + process.exitCode = 1; + }, +): () => void { + let shutdownPromise: Promise | undefined; + + const shutdown = (): void => { + shutdownPromise ??= runtime.close().catch(onError); + }; + + signalTarget.once("SIGINT", shutdown); + signalTarget.once("SIGTERM", shutdown); + + return () => { + signalTarget.off("SIGINT", shutdown); + signalTarget.off("SIGTERM", shutdown); + }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..88e9fb2 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,500 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { loadGatewayConfig, type GatewayConfig } from "../config/env.js"; +import { RateLimitError } from "../errors/application-error.js"; +import { FixedWindowRateLimiter } from "../gateway/index.js"; +import { + M0_CORE_POLICY, + M0_LENDING_RANKING_METRICS, + M0_RANKING_METRICS, + M0_TIME_WINDOWS, +} from "../policy/index.js"; +import { + compareLendingResponseSchema, + comparePoolsResponseSchema, + coverageSchema, + findLargeSwapsResponseSchema, + largeSwapCoverageSchema, + largeSwapPaginationSchema, + largeSwapSearchDataSchema, + lendingComparisonDataSchema, + lendingCoverageSchema, + poolComparisonDataSchema, + researchWalletResponseSchema, + resultFreshnessSchema, + resultProvenanceSchema, + walletResearchCoverageSchema, + walletResearchDataSchema, + walletResearchPaginationSchema, +} from "../schemas/index.js"; +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; +import { LSS_SCOPE } from "../scope/large-swaps.js"; +import { + WALLET_RESEARCH_SCOPE, + WALLET_RESEARCH_SECTIONS, + WALLET_RESEARCH_WINDOWS, +} from "../scope/wallet-research.js"; +import { + CompareLendingRequestError, + ComparePoolsRequestError, + FindLargeSwapsToolError, + LargeSwapQueryError, + ResearchWalletToolError, + WalletResearchQueryError, + createLiveCompareLendingSources, + createLiveComparePoolsSources, + createLiveLargeSwapSource, + createLiveWalletSources, + executeCompareLending, + executeComparePools, + executeFindLargeSwaps, + executeResearchWallet, + type CompareLendingSourceGateway, + type ComparePoolsSourceGateway, + type LargeSwapSourceGateway, + type WalletResearchSourceGateway, +} from "../tools/index.js"; + +export const serverInfo = { + name: "deeptrace", + version: "0.1.0", +} as const; + +export const COMPARE_POOLS_TOOL_NAME = "compare_pools" as const; +export const COMPARE_LENDING_MARKETS_TOOL_NAME = "compare_lending_markets" as const; +export const FIND_LARGE_SWAPS_TOOL_NAME = "find_large_swaps" as const; +export const RESEARCH_WALLET_TOOL_NAME = "research_wallet" as const; + +export const serverInstructions = + "DeepTrace is read-only for locked Base (8453) scopes. Graph subgraphs supply pool financial metrics, lending rates, and supported Aave positions; Nuthatch supplies independent freshness, large-swap receipts, and indexed wallet activity. Use compare_pools, compare_lending_markets, find_large_swaps, or research_wallet. Observed assets are not complete balances. Preserve decimal strings, status, warnings, freshness, source_ids, and provenance; never present partial results as complete or failed swaps as data."; + +const comparePoolsInputSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID).describe("Base mainnet chain ID; must be 8453."), + token0: z + .literal(M0_COMPARE_POOLS_SCOPE.token0.address) + .describe("Native Base WETH address; this locked value is required."), + token1: z + .literal(M0_COMPARE_POOLS_SCOPE.token1.address) + .describe("Native Base USDC address; this locked value is required."), + window: z + .enum(M0_TIME_WINDOWS) + .optional() + .describe("Metric window: 24h or 7d. Defaults to 24h."), + ranked_by: z + .enum(M0_RANKING_METRICS) + .optional() + .describe("Rank by Graph-reported tvl_usd, volume_usd, or fees_usd. Defaults to volume_usd."), + top_n: z + .number() + .int() + .min(1) + .max(M0_CORE_POLICY.topN.maximum) + .optional() + .describe("Number of ranked pools to return, from 1 to 3. Defaults to 3."), + }) + .strict(); + +const compareLendingInputSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID).describe("Base mainnet chain ID; must be 8453."), + market_token: z + .literal(M0_CORE_POLICY.lending.marketToken) + .describe("Native Base USDC address; this locked lending market token is required."), + ranked_by: z + .enum(M0_LENDING_RANKING_METRICS) + .optional() + .describe( + "Rank by Graph-reported tvl_usd, deposit/borrow balances, or variable rates. Defaults to tvl_usd.", + ), + top_n: z + .number() + .int() + .min(1) + .max(M0_CORE_POLICY.lending.topN.maximum) + .optional() + .describe("Number of ranked markets to return, within the locked lending top_n bound."), + }) + .strict(); + +const findLargeSwapsInputSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID).describe("Base mainnet chain ID; must be 8453."), + pool_address: z + .literal(LSS_SCOPE.poolAddress) + .describe("Locked Base Uniswap V3 WETH/USDC pool address."), + threshold_token: z + .union([z.literal(LSS_SCOPE.tokens.weth.address), z.literal(LSS_SCOPE.tokens.usdc.address)]) + .describe("WETH or native USDC address whose absolute pool delta is thresholded."), + min_amount: z + .string() + .regex(/^(?:0\.\d*[1-9]\d*|[1-9]\d*(?:\.\d+)?)$/) + .describe("Positive exact decimal threshold in human token units; never USD."), + limit: z + .number() + .int() + .min(1) + .max(LSS_SCOPE.limit.maximum) + .optional() + .describe("Page size from 1 to 100. Defaults to 25."), + cursor: z + .string() + .min(1) + .max(LSS_SCOPE.cursor.maximumLength) + .nullable() + .optional() + .describe("Opaque next_cursor from a prior response; omit for the first page."), + }) + .strict(); + +const researchWalletInputSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID).describe("Base mainnet chain ID; must be 8453."), + address: z + .string() + .regex(/^0x[0-9a-f]{40}$/) + .describe("Lowercase public wallet address."), + sections: z + .array(z.enum(WALLET_RESEARCH_SECTIONS)) + .min(1) + .max(WALLET_RESEARCH_SECTIONS.length) + .optional() + .describe("Supported sections to return; defaults to every section."), + window: z + .enum(WALLET_RESEARCH_WINDOWS) + .optional() + .describe("Recent Nuthatch activity window; defaults to 24h."), + limit: z + .number() + .int() + .min(1) + .max(WALLET_RESEARCH_SCOPE.limit.maximum) + .optional() + .describe("Maximum activity and position records; defaults to 25."), + cursor: z + .string() + .min(1) + .max(WALLET_RESEARCH_SCOPE.cursor.maximumLength) + .nullable() + .optional() + .describe("Opaque activity next_cursor; omit for the first page."), + }) + .strict(); + +// The MCP SDK advertises and validates object-root output schemas. Keep the +// authoritative discriminated-union schema as the final refinement. +const comparePoolsOutputSchema = z + .object({ + status: z.enum(["complete", "partial", "failed"]), + data: poolComparisonDataSchema.nullable(), + coverage: coverageSchema, + freshness: z + .array(resultFreshnessSchema) + .length(M0_CORE_POLICY.coverage.expectedGraphResults + 1), + provenance: z + .array(resultProvenanceSchema) + .length(M0_CORE_POLICY.coverage.expectedGraphResults + 1), + warnings: z.array(z.string().min(1)), + pagination: z.null(), + }) + .strict() + .superRefine((response, context) => { + const validation = comparePoolsResponseSchema.safeParse(response); + if (!validation.success) { + context.addIssue({ + code: "custom", + message: validation.error.message, + }); + } + }); + +const compareLendingOutputSchema = z + .object({ + status: z.enum(["complete", "partial", "failed"]), + data: lendingComparisonDataSchema.nullable(), + coverage: lendingCoverageSchema, + freshness: z + .array(resultFreshnessSchema) + .length(M0_CORE_POLICY.lending.coverage.expectedSources), + provenance: z + .array(resultProvenanceSchema) + .length(M0_CORE_POLICY.lending.coverage.expectedSources), + warnings: z.array(z.string().min(1)), + pagination: z.null(), + }) + .strict() + .superRefine((response, context) => { + const validation = compareLendingResponseSchema.safeParse(response); + if (!validation.success) { + context.addIssue({ + code: "custom", + message: validation.error.message, + }); + } + }); + +const findLargeSwapsOutputSchema = z + .object({ + status: z.enum(["complete", "failed"]), + data: largeSwapSearchDataSchema.nullable(), + coverage: largeSwapCoverageSchema, + freshness: z.array(resultFreshnessSchema).length(1), + provenance: z.array(resultProvenanceSchema).length(1), + warnings: z.array(z.string().min(1)), + pagination: largeSwapPaginationSchema, + }) + .strict() + .superRefine((response, context) => { + const validation = findLargeSwapsResponseSchema.safeParse(response); + if (!validation.success) { + context.addIssue({ + code: "custom", + message: validation.error.message, + }); + } + }); + +const researchWalletOutputSchema = z + .object({ + status: z.enum(["complete", "partial", "failed"]), + data: walletResearchDataSchema.nullable(), + coverage: walletResearchCoverageSchema, + freshness: z.array(resultFreshnessSchema).length(2), + provenance: z.array(resultProvenanceSchema).length(2), + warnings: z.array(z.string().min(1)), + pagination: walletResearchPaginationSchema, + }) + .strict() + .superRefine((response, context) => { + const validation = researchWalletResponseSchema.safeParse(response); + if (!validation.success) { + context.addIssue({ code: "custom", message: validation.error.message }); + } + }); + +export interface CreateMcpServerOptions { + readonly gatewayConfig?: GatewayConfig; + readonly rateLimiter?: FixedWindowRateLimiter; + readonly sources?: ComparePoolsSourceGateway; + readonly lendingSources?: CompareLendingSourceGateway; + readonly largeSwapSource?: LargeSwapSourceGateway; + readonly walletSources?: WalletResearchSourceGateway; + readonly rateLimitKey?: string; + readonly lendingRateLimitKey?: string; + readonly largeSwapRateLimitKey?: string; + readonly walletRateLimitKey?: string; +} + +function toolErrorResult(message: string) { + return { + isError: true as const, + content: [{ type: "text" as const, text: message }], + }; +} + +export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer { + const gatewayConfig = options.gatewayConfig ?? loadGatewayConfig(); + const rateLimiter = + options.rateLimiter ?? + new FixedWindowRateLimiter({ + maxRequests: gatewayConfig.rateLimitMaxRequests, + windowMs: gatewayConfig.rateLimitWindowMs, + }); + const sources = + options.sources ?? + createLiveComparePoolsSources({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); + const lendingSources = + options.lendingSources ?? + createLiveCompareLendingSources({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); + const largeSwapSource = + options.largeSwapSource ?? + createLiveLargeSwapSource({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); + const walletSources = + options.walletSources ?? + createLiveWalletSources({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); + const rateLimitKey = options.rateLimitKey ?? "compare_pools"; + const lendingRateLimitKey = options.lendingRateLimitKey ?? COMPARE_LENDING_MARKETS_TOOL_NAME; + const largeSwapRateLimitKey = options.largeSwapRateLimitKey ?? "find_large_swaps"; + const walletRateLimitKey = options.walletRateLimitKey ?? RESEARCH_WALLET_TOOL_NAME; + + const server = new McpServer(serverInfo, { + instructions: serverInstructions, + }); + + server.registerTool( + COMPARE_POOLS_TOOL_NAME, + { + title: "Compare Base WETH/USDC pools", + description: + "Rank the locked Base (chain 8453) native WETH/USDC pools by Graph-reported TVL, volume, or fees for 24h or 7d. Nuthatch adds independent freshness facts only. Read-only; preserve status, warnings, freshness, and provenance.", + inputSchema: comparePoolsInputSchema, + outputSchema: comparePoolsOutputSchema, + annotations: { + title: "Compare Base WETH/USDC pools", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => { + try { + const response = await rateLimiter.execute(rateLimitKey, () => + executeComparePools(args, sources), + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(response), + }, + ], + structuredContent: response, + }; + } catch (error) { + if (error instanceof RateLimitError) { + return toolErrorResult(error.message); + } + if (error instanceof ComparePoolsRequestError) { + return toolErrorResult(error.message); + } + const message = error instanceof Error ? error.message : "compare_pools failed."; + return toolErrorResult(message); + } + }, + ); + + server.registerTool( + COMPARE_LENDING_MARKETS_TOOL_NAME, + { + title: "Compare lending markets", + description: + "Compare Base USDC lending markets across Aave v3, Seamless, and Moonwell Messari standardized subgraphs. Read-only; preserve status, warnings, freshness, and provenance.", + inputSchema: compareLendingInputSchema, + outputSchema: compareLendingOutputSchema, + annotations: { + title: "Compare lending markets", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => { + try { + const response = await rateLimiter.execute(lendingRateLimitKey, () => + executeCompareLending(args, lendingSources), + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(response), + }, + ], + structuredContent: response, + }; + } catch (error) { + if (error instanceof RateLimitError) { + return toolErrorResult(error.message); + } + if (error instanceof CompareLendingRequestError) { + return toolErrorResult(error.message); + } + const message = error instanceof Error ? error.message : "compare_lending_markets failed."; + return toolErrorResult(message); + } + }, + ); + + server.registerTool( + FIND_LARGE_SWAPS_TOOL_NAME, + { + title: "Find large Base WETH/USDC swaps", + description: + "Return stable cursor pages of normalized swaps from the locked Base Uniswap V3 WETH/USDC pool. Filter by an exact WETH or USDC human-unit threshold; no USD conversion. Read-only; preserve status, warnings, freshness, and provenance.", + inputSchema: findLargeSwapsInputSchema, + outputSchema: findLargeSwapsOutputSchema, + annotations: { + title: "Find large Base WETH/USDC swaps", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => { + try { + const response = await rateLimiter.execute(largeSwapRateLimitKey, () => + executeFindLargeSwaps(args, largeSwapSource), + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(response), + }, + ], + structuredContent: response, + }; + } catch (error) { + if (error instanceof RateLimitError) { + return toolErrorResult(error.message); + } + if (error instanceof LargeSwapQueryError || error instanceof FindLargeSwapsToolError) { + return toolErrorResult(error.message); + } + return toolErrorResult("find_large_swaps failed."); + } + }, + ); + + server.registerTool( + RESEARCH_WALLET_TOOL_NAME, + { + title: "Research a supported Base wallet", + description: + "Return source-bounded Base wallet activity from the indexed Uniswap V3 pool and verified Aave v3 positions from The Graph. Observed assets are not complete balances. Read-only.", + inputSchema: researchWalletInputSchema, + outputSchema: researchWalletOutputSchema, + annotations: { + title: "Research a supported Base wallet", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (args) => { + try { + const response = await rateLimiter.execute(walletRateLimitKey, () => + executeResearchWallet(args, walletSources), + ); + return { + content: [{ type: "text" as const, text: JSON.stringify(response) }], + structuredContent: response, + }; + } catch (error) { + if (error instanceof RateLimitError) { + return toolErrorResult(error.message); + } + if (error instanceof WalletResearchQueryError || error instanceof ResearchWalletToolError) { + return toolErrorResult(error.message); + } + return toolErrorResult("research_wallet failed."); + } + }, + ); + + return server; +} diff --git a/src/metrics/decimal-order.ts b/src/metrics/decimal-order.ts new file mode 100644 index 0000000..bbf2815 --- /dev/null +++ b/src/metrics/decimal-order.ts @@ -0,0 +1,38 @@ +import { DecimalParseError, parseDecimal } from "../sources/graph/decimal.js"; + +/** + * Compare two non-negative decimal strings exactly. + * Returns negative when left < right, zero when equal, positive when left > right. + */ +export function compareDecimalStrings(left: string, right: string): number { + const leftValue = parseDecimal(left); + const rightValue = parseDecimal(right); + const scale = Math.max(leftValue.scale, rightValue.scale); + const leftScaled = BigInt(leftValue.integerPart + leftValue.fractionalPart.padEnd(scale, "0")); + const rightScaled = BigInt(rightValue.integerPart + rightValue.fractionalPart.padEnd(scale, "0")); + + if (leftScaled === rightScaled) { + return 0; + } + return leftScaled < rightScaled ? -1 : 1; +} + +/** + * Descending metric order with nulls last. + * Malformed decimals raise `DecimalParseError` from the shared parser. + */ +export function compareMetricDescNullsLast(left: string | null, right: string | null): number { + if (left === null && right === null) { + return 0; + } + if (left === null) { + return 1; + } + if (right === null) { + return -1; + } + + return -compareDecimalStrings(left, right); +} + +export { DecimalParseError }; diff --git a/src/metrics/index.ts b/src/metrics/index.ts new file mode 100644 index 0000000..0cafa7e --- /dev/null +++ b/src/metrics/index.ts @@ -0,0 +1,4 @@ +export { compareDecimalStrings, compareMetricDescNullsLast } from "./decimal-order.js"; +export { M0_POOL_METRICS_METHODOLOGY, type M0PoolMetricsMethodology } from "./methodology.js"; +export { rankCanonicalPools, type RankPoolsOptions } from "./rank.js"; +export { rankLendingMarkets, type RankLendingMarketsOptions } from "./rank-lending.js"; diff --git a/src/metrics/methodology.ts b/src/metrics/methodology.ts new file mode 100644 index 0000000..936eea5 --- /dev/null +++ b/src/metrics/methodology.ts @@ -0,0 +1,34 @@ +import { M0_RANKING_TIE_BREAK } from "../policy/index.js"; + +/** + * MVP-0 compare_pools metric methodology for source-reported USD values. + * + * H4 handoff (primary-owned): DeepTrace does not reprice, average across + * sources, or label fee-to-TVL ratios as APR/APY. + */ +export const M0_POOL_METRICS_METHODOLOGY = { + id: "compare-pools-source-reported-usd-v1", + unit: "usd", + window_methodology: "completed-utc-days-v1", + tvl_usd: { + selection: "source_reported_tvl_usd_passthrough", + description: "Pass through the Graph-reported pool TVL USD decimal string or null.", + }, + volume_usd: { + selection: "source_reported_window_volume_usd_passthrough", + description: + "Pass through the selected completed-UTC-day window volume USD decimal string or null.", + }, + fees_usd: { + selection: "source_reported_window_total_revenue_usd_passthrough", + description: + "Pass through the selected completed-UTC-day window fee revenue USD decimal string or null. The Messari dex-amm standard has no per-day fee field, so this is dailyTotalRevenueUSD: supply-side plus protocol-side revenue accrued that day. On the compared Uniswap V3 Base pools the protocol-side share is zero, so the value equals LP fees, but that is a property of those pools and not a conversion DeepTrace performs.", + }, + ranking: { + tie_break: M0_RANKING_TIE_BREAK, + }, + /** Explicit non-goals for MVP-0. */ + non_goals: ["apr", "apy", "fee_to_tvl_ratio_as_yield"] as const, +} as const; + +export type M0PoolMetricsMethodology = typeof M0_POOL_METRICS_METHODOLOGY; diff --git a/src/metrics/rank-lending.ts b/src/metrics/rank-lending.ts new file mode 100644 index 0000000..3c3814b --- /dev/null +++ b/src/metrics/rank-lending.ts @@ -0,0 +1,125 @@ +import { M0_CORE_POLICY, type M0LendingRankingMetric } from "../policy/index.js"; +import { NormalizationError } from "../normalization/error.js"; +import type { LendingMarketRecord } from "../schemas/compare-lending.js"; +import type { + LendingMarketSourceData, + LendingMarketSourceResult, +} from "../schemas/source-adapter.js"; + +import { compareMetricDescNullsLast } from "./decimal-order.js"; + +type SuccessfulLendingResult = Extract; + +function compareStringsAsc(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +function metricValue( + data: LendingMarketSourceData, + rankedBy: M0LendingRankingMetric, +): string | null { + switch (rankedBy) { + case "tvl_usd": + return data.tvl_usd; + case "total_deposit_balance_usd": + return data.total_deposit_balance_usd; + case "total_borrow_balance_usd": + return data.total_borrow_balance_usd; + case "lender_variable_rate_percent": + return data.lender_variable_rate_percent; + case "borrower_variable_rate_percent": + return data.borrower_variable_rate_percent; + default: { + const exhaustive: never = rankedBy; + throw new NormalizationError(`Unsupported lending ranking metric: ${String(exhaustive)}`); + } + } +} + +function compareForRanking( + left: SuccessfulLendingResult, + right: SuccessfulLendingResult, + rankedBy: M0LendingRankingMetric, +): number { + const byMetric = compareMetricDescNullsLast( + metricValue(left.data, rankedBy), + metricValue(right.data, rankedBy), + ); + if (byMetric !== 0) { + return byMetric; + } + + const byProtocol = compareStringsAsc(left.protocol, right.protocol); + if (byProtocol !== 0) { + return byProtocol; + } + + const byMarket = compareStringsAsc(left.data.market_id, right.data.market_id); + if (byMarket !== 0) { + return byMarket; + } + + return compareStringsAsc(left.source_id, right.source_id); +} + +function toLendingMarketRecord(result: SuccessfulLendingResult, rank: number): LendingMarketRecord { + return { + chain_id: result.chain_id, + protocol: result.protocol, + market_id: result.data.market_id, + market_name: result.data.market_name, + input_token: { + chain_id: result.chain_id, + address: result.data.input_token.address, + symbol: result.data.input_token.symbol, + decimals: result.data.input_token.decimals, + }, + is_active: result.data.is_active, + can_borrow_from: result.data.can_borrow_from, + can_use_as_collateral: result.data.can_use_as_collateral, + tvl_usd: result.data.tvl_usd, + total_deposit_balance_usd: result.data.total_deposit_balance_usd, + total_borrow_balance_usd: result.data.total_borrow_balance_usd, + lender_variable_rate_percent: result.data.lender_variable_rate_percent, + borrower_variable_rate_percent: result.data.borrower_variable_rate_percent, + borrower_stable_rate_percent: result.data.borrower_stable_rate_percent, + rank, + source_ids: [result.source_id], + }; +} + +export interface RankLendingMarketsOptions { + readonly rankedBy: M0LendingRankingMetric; + readonly topN?: number; +} + +/** + * Ranks the markets that answered by the requested metric with deterministic + * tie-breaks and returns Top-N `LendingMarketRecord`s. + * + * Each protocol contributes at most one market for the locked asset, so unlike + * `compare_pools` there is nothing to deduplicate across sources. + */ +export function rankLendingMarkets( + results: readonly LendingMarketSourceResult[], + options: RankLendingMarketsOptions, +): LendingMarketRecord[] { + const topN = options.topN ?? M0_CORE_POLICY.lending.topN.default; + if (!Number.isInteger(topN) || topN < 1 || topN > M0_CORE_POLICY.lending.topN.maximum) { + throw new NormalizationError( + `topN must be an integer between 1 and ${String(M0_CORE_POLICY.lending.topN.maximum)}`, + ); + } + + const successful = results.filter( + (result): result is SuccessfulLendingResult => result.status === "ok", + ); + const ordered = [...successful].sort((left, right) => + compareForRanking(left, right, options.rankedBy), + ); + + return ordered.slice(0, topN).map((result, index) => toLendingMarketRecord(result, index + 1)); +} diff --git a/src/metrics/rank.ts b/src/metrics/rank.ts new file mode 100644 index 0000000..db66d94 --- /dev/null +++ b/src/metrics/rank.ts @@ -0,0 +1,106 @@ +import { M0_CORE_POLICY, type M0RankingMetric } from "../policy/index.js"; +import type { PoolComparisonRecord } from "../schemas/compare-pools.js"; +import { deduplicateCanonicalPools } from "../normalization/dedupe.js"; +import { toPoolComparisonRecord } from "../normalization/convert.js"; +import { NormalizationError } from "../normalization/error.js"; +import type { CanonicalPoolCandidate } from "../normalization/types.js"; + +import { compareMetricDescNullsLast } from "./decimal-order.js"; + +function compareStringsAsc(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +function leastSourceId(candidate: CanonicalPoolCandidate): string { + const sorted = [...candidate.source_ids].sort(compareStringsAsc); + const least = sorted[0]; + if (least === undefined) { + throw new NormalizationError("Canonical pool candidate requires at least one source_id"); + } + return least; +} + +function metricValue(candidate: CanonicalPoolCandidate, rankedBy: M0RankingMetric): string | null { + switch (rankedBy) { + case "tvl_usd": + return candidate.tvl_usd; + case "volume_usd": + return candidate.volume_usd; + case "fees_usd": + return candidate.fees_usd; + default: { + const exhaustive: never = rankedBy; + throw new NormalizationError(`Unsupported ranking metric: ${String(exhaustive)}`); + } + } +} + +function compareForRanking( + left: CanonicalPoolCandidate, + right: CanonicalPoolCandidate, + rankedBy: M0RankingMetric, +): number { + const byMetric = compareMetricDescNullsLast( + metricValue(left, rankedBy), + metricValue(right, rankedBy), + ); + if (byMetric !== 0) { + return byMetric; + } + + const byProtocol = compareStringsAsc(left.protocol, right.protocol); + if (byProtocol !== 0) { + return byProtocol; + } + + const byPool = compareStringsAsc(left.pool_address, right.pool_address); + if (byPool !== 0) { + return byPool; + } + + return compareStringsAsc(leastSourceId(left), leastSourceId(right)); +} + +export interface RankPoolsOptions { + readonly rankedBy: M0RankingMetric; + readonly topN?: number; +} + +/** + * Deduplicate canonical pool candidates, rank by the requested metric with + * deterministic tie-breaks, and return Top-N `PoolComparisonRecord`s. + */ +export function rankCanonicalPools( + candidates: readonly CanonicalPoolCandidate[], + options: RankPoolsOptions, +): PoolComparisonRecord[] { + const topN = options.topN ?? M0_CORE_POLICY.topN.default; + if (!Number.isInteger(topN) || topN < 1 || topN > M0_CORE_POLICY.topN.maximum) { + throw new NormalizationError( + `topN must be an integer between 1 and ${String(M0_CORE_POLICY.topN.maximum)}`, + ); + } + + if (candidates.length > 0) { + const window = candidates[0]!.window; + if (candidates.some((candidate) => candidate.window !== window)) { + throw new NormalizationError("Cannot rank canonical pools across mixed windows"); + } + const chainId = candidates[0]!.chain_id; + if (candidates.some((candidate) => candidate.chain_id !== chainId)) { + throw new NormalizationError("Cannot rank canonical pools across mixed chains"); + } + } + + const deduped = deduplicateCanonicalPools(candidates); + const ordered = [...deduped].sort((left, right) => + compareForRanking(left, right, options.rankedBy), + ); + + return ordered + .slice(0, topN) + .map((candidate, index) => toPoolComparisonRecord(candidate, index + 1)); +} diff --git a/src/normalization/address.ts b/src/normalization/address.ts new file mode 100644 index 0000000..95c609f --- /dev/null +++ b/src/normalization/address.ts @@ -0,0 +1,14 @@ +import { NormalizationError } from "./error.js"; + +const ETHEREUM_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; + +/** + * Validates a 20-byte hex address and returns its canonical lowercase form. + */ +export function normalizeAddress(address: string): string { + if (!ETHEREUM_ADDRESS_PATTERN.test(address)) { + throw new NormalizationError(`Invalid Ethereum address: ${address}`); + } + + return address.toLowerCase(); +} diff --git a/src/normalization/convert.ts b/src/normalization/convert.ts new file mode 100644 index 0000000..3d920b7 --- /dev/null +++ b/src/normalization/convert.ts @@ -0,0 +1,83 @@ +import type { M0TimeWindow } from "../policy/index.js"; +import type { PoolComparisonRecord } from "../schemas/compare-pools.js"; +import { BASE_CHAIN_ID, type PoolSourceResult } from "../schemas/source-adapter.js"; + +import { normalizeAddress } from "./address.js"; +import { NormalizationError } from "./error.js"; +import { normalizeCanonicalPair } from "./pair.js"; +import type { CanonicalPoolCandidate } from "./types.js"; + +function selectWindowMetrics( + data: Extract["data"], + window: M0TimeWindow, +): { volume_usd: string | null; fees_usd: string | null } { + switch (window) { + case "24h": + return { + volume_usd: data.volume_usd_24h, + fees_usd: data.fees_usd_24h, + }; + case "7d": + return { + volume_usd: data.volume_usd_7d, + fees_usd: data.fees_usd_7d, + }; + default: + throw new NormalizationError(`Unsupported normalization window: ${String(window)}`); + } +} + +/** + * Converts a successful pool source result into a rank-free canonical candidate. + * Failed source results return null and are never partially converted. + * + * USD decimal strings and null are passed through byte-for-byte with no + * Number/parseFloat/arithmetic/repricing. + */ +export function convertPoolSourceResult( + result: PoolSourceResult, + window: M0TimeWindow, +): CanonicalPoolCandidate | null { + if (result.status !== "ok") { + return null; + } + + if (result.chain_id !== BASE_CHAIN_ID) { + throw new NormalizationError( + `Unsupported chain_id for pool conversion: ${String(result.chain_id)}`, + ); + } + + const { data } = result; + const metrics = selectWindowMetrics(data, window); + + return { + chain_id: BASE_CHAIN_ID, + protocol: result.protocol, + pool_address: normalizeAddress(data.pool_address), + pair: normalizeCanonicalPair(result.chain_id, data.token0, data.token1), + tvl_usd: data.tvl_usd, + volume_usd: metrics.volume_usd, + fees_usd: metrics.fees_usd, + window, + source_ids: [result.source_id], + }; +} + +/** + * Attaches an explicit caller-supplied rank. Ranking logic is out of scope for + * M0-03A and must not be inferred here. + */ +export function toPoolComparisonRecord( + candidate: CanonicalPoolCandidate, + rank: number, +): PoolComparisonRecord { + if (!Number.isInteger(rank) || rank < 1) { + throw new NormalizationError(`Rank must be a positive integer, received: ${String(rank)}`); + } + + return { + ...candidate, + rank, + }; +} diff --git a/src/normalization/dedupe.ts b/src/normalization/dedupe.ts new file mode 100644 index 0000000..3325441 --- /dev/null +++ b/src/normalization/dedupe.ts @@ -0,0 +1,120 @@ +import { normalizeAddress } from "./address.js"; +import { pairIdentity, poolIdentity } from "./identities.js"; +import { NormalizationError } from "./error.js"; +import { normalizeCanonicalPair } from "./pair.js"; +import type { CanonicalPoolCandidate } from "./types.js"; + +function compareStrings(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +function leastSourceId(candidate: CanonicalPoolCandidate): string { + const sorted = [...candidate.source_ids].sort(compareStrings); + const least = sorted[0]; + if (least === undefined) { + throw new NormalizationError("Canonical pool candidate requires at least one source_id"); + } + return least; +} + +function candidateTieBreaker(candidate: CanonicalPoolCandidate): string { + return JSON.stringify([ + candidate.protocol, + candidate.pair, + candidate.window, + candidate.tvl_usd, + candidate.volume_usd, + candidate.fees_usd, + [...candidate.source_ids].sort(compareStrings), + ]); +} + +function compareCandidates(left: CanonicalPoolCandidate, right: CanonicalPoolCandidate): number { + const bySource = compareStrings(leastSourceId(left), leastSourceId(right)); + if (bySource !== 0) { + return bySource; + } + + return compareStrings(candidateTieBreaker(left), candidateTieBreaker(right)); +} + +function uniqueSortedSourceIds(candidates: readonly CanonicalPoolCandidate[]): string[] { + return [...new Set(candidates.flatMap((candidate) => candidate.source_ids))].sort(compareStrings); +} + +function assertCompatibleGroup(group: readonly CanonicalPoolCandidate[]): void { + const first = group[0]; + if (first === undefined) { + throw new NormalizationError("Cannot merge an empty pool group"); + } + + const expectedPair = pairIdentity(first.chain_id, first.pair[0].address, first.pair[1].address); + + for (const candidate of group.slice(1)) { + const candidatePair = pairIdentity( + candidate.chain_id, + candidate.pair[0].address, + candidate.pair[1].address, + ); + if (candidatePair !== expectedPair || candidate.window !== first.window) { + throw new NormalizationError( + `Duplicate pool candidates disagree on pair identity or window: ${poolIdentity( + first.chain_id, + first.pool_address, + )}`, + ); + } + } +} + +function mergePoolGroup(group: readonly CanonicalPoolCandidate[]): CanonicalPoolCandidate { + assertCompatibleGroup(group); + const ordered = [...group].sort(compareCandidates); + const primary = ordered[0]; + if (primary === undefined) { + throw new NormalizationError("Cannot merge an empty pool group"); + } + + return { + ...primary, + pool_address: normalizeAddress(primary.pool_address), + pair: normalizeCanonicalPair(primary.chain_id, primary.pair[0], primary.pair[1]), + source_ids: uniqueSortedSourceIds(group), + }; +} + +/** + * Collapses candidates that share the same chain + pool address. + * + * Collapse is independent of input order. Conflicting non-identity fields keep + * the primary record's values; financial fields are never synthesized across + * sources. Differing chain/pool identities remain separate. + */ +export function deduplicateCanonicalPools( + candidates: readonly CanonicalPoolCandidate[], +): CanonicalPoolCandidate[] { + const groups = new Map(); + + for (const candidate of candidates) { + const key = poolIdentity(candidate.chain_id, candidate.pool_address); + const group = groups.get(key); + if (group === undefined) { + groups.set(key, [candidate]); + } else { + group.push(candidate); + } + } + + return [...groups.values()] + .map((group) => mergePoolGroup(group)) + .sort((left, right) => { + if (left.chain_id !== right.chain_id) { + return left.chain_id - right.chain_id; + } + + return compareStrings(left.pool_address, right.pool_address); + }); +} diff --git a/src/normalization/error.ts b/src/normalization/error.ts new file mode 100644 index 0000000..535153f --- /dev/null +++ b/src/normalization/error.ts @@ -0,0 +1,6 @@ +export class NormalizationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "NormalizationError"; + } +} diff --git a/src/normalization/identities.ts b/src/normalization/identities.ts new file mode 100644 index 0000000..5cc03dc --- /dev/null +++ b/src/normalization/identities.ts @@ -0,0 +1,56 @@ +import { normalizeAddress } from "./address.js"; +import { NormalizationError } from "./error.js"; + +const TRANSACTION_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/; + +/** + * Chain-aware token identity. Address casing does not affect the key. + */ +export function tokenIdentity(chainId: number, address: string): string { + return `${chainId}:${normalizeAddress(address)}`; +} + +/** + * Chain-aware pool identity. Address casing does not affect the key. + */ +export function poolIdentity(chainId: number, poolAddress: string): string { + return `${chainId}:${normalizeAddress(poolAddress)}`; +} + +/** + * Chain-aware pair identity. Token order and address casing do not affect the key. + */ +export function pairIdentity(chainId: number, addressA: string, addressB: string): string { + const left = normalizeAddress(addressA); + const right = normalizeAddress(addressB); + + if (left === right) { + throw new NormalizationError("Pair identity requires two distinct token addresses"); + } + + const [first, second] = left < right ? [left, right] : [right, left]; + return `${chainId}:${first}:${second}`; +} + +/** + * Chain-aware on-chain event identity. Transaction-hash casing does not affect the key. + */ +export function swapEventIdentity( + chainId: number, + transactionHash: string, + logIndex: number, +): string { + if (!Number.isInteger(chainId) || chainId < 1) { + throw new NormalizationError(`Event identity requires a positive chain ID: ${String(chainId)}`); + } + if (!TRANSACTION_HASH_PATTERN.test(transactionHash)) { + throw new NormalizationError(`Invalid transaction hash: ${transactionHash}`); + } + if (!Number.isInteger(logIndex) || logIndex < 0) { + throw new NormalizationError( + `Event identity requires a non-negative log index: ${String(logIndex)}`, + ); + } + + return `${chainId}:${transactionHash.toLowerCase()}:${String(logIndex)}`; +} diff --git a/src/normalization/index.ts b/src/normalization/index.ts new file mode 100644 index 0000000..a053284 --- /dev/null +++ b/src/normalization/index.ts @@ -0,0 +1,18 @@ +export { normalizeAddress } from "./address.js"; +export { convertPoolSourceResult, toPoolComparisonRecord } from "./convert.js"; +export { deduplicateCanonicalPools } from "./dedupe.js"; +export { NormalizationError } from "./error.js"; +export { pairIdentity, poolIdentity, swapEventIdentity, tokenIdentity } from "./identities.js"; +export { bindComparePoolsGraphResult, bindComparePoolsGraphResults } from "./live-binding.js"; +export { normalizeCanonicalPair, normalizeCanonicalToken } from "./pair.js"; +export { + deduplicateSwapEvents, + filterSwapsByTokenThreshold, + normalizeDeduplicateAndFilterSwaps, + normalizeLssSwapEvent, + swapMeetsTokenThreshold, + type LssThresholdToken, + type RawLssSwapEvent, +} from "./swaps.js"; +export { DUPLICATE_POOL_COLLAPSE_POLICY, type CanonicalPoolCandidate } from "./types.js"; +export type { SourceTokenInput } from "./pair.js"; diff --git a/src/normalization/live-binding.ts b/src/normalization/live-binding.ts new file mode 100644 index 0000000..05dd718 --- /dev/null +++ b/src/normalization/live-binding.ts @@ -0,0 +1,130 @@ +import type { M0TimeWindow } from "../policy/index.js"; +import type { PoolSourceResult, TokenMetadata } from "../schemas/source-adapter.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; + +import { convertPoolSourceResult } from "./convert.js"; +import { NormalizationError } from "./error.js"; +import type { CanonicalPoolCandidate } from "./types.js"; + +type ScopedGraphSource = (typeof M0_COMPARE_POOLS_SCOPE.graphSources)[number]; + +function findScopedGraphSource(sourceId: string): ScopedGraphSource | undefined { + return M0_COMPARE_POOLS_SCOPE.graphSources.find((source) => source.source_id === sourceId); +} + +function assertLockedTokenMetadata(token: TokenMetadata): void { + const expected = + token.address === M0_COMPARE_POOLS_SCOPE.token0.address + ? M0_COMPARE_POOLS_SCOPE.token0 + : token.address === M0_COMPARE_POOLS_SCOPE.token1.address + ? M0_COMPARE_POOLS_SCOPE.token1 + : null; + + if ( + expected === null || + token.symbol !== expected.symbol || + token.decimals !== expected.decimals + ) { + throw new NormalizationError( + "Pool token metadata does not match the locked compare_pools pair.", + ); + } +} + +function assertScopedGraphResult(result: PoolSourceResult, scoped: ScopedGraphSource): void { + if (result.chain_id !== M0_COMPARE_POOLS_SCOPE.chainId) { + throw new NormalizationError( + `Source "${result.source_id}" is outside the locked compare_pools chain.`, + ); + } + + if (result.protocol !== scoped.protocol) { + throw new NormalizationError( + `Source "${result.source_id}" protocol does not match the locked Graph scope.`, + ); + } + + if (result.provenance.deployment_or_view_id !== scoped.deployment_or_view_id) { + throw new NormalizationError( + `Source "${result.source_id}" deployment does not match the locked Graph scope.`, + ); + } + + if (result.provenance.query_id !== scoped.query_id) { + throw new NormalizationError( + `Source "${result.source_id}" query_id does not match the locked Graph scope.`, + ); + } + + if (result.status !== "ok") { + return; + } + + if (result.data.pool_address !== scoped.pool_address) { + throw new NormalizationError( + `Source "${result.source_id}" pool address does not match the locked Graph scope.`, + ); + } + + const { token0, token1 } = result.data; + if ( + token0.address === token1.address || + (token0.address !== M0_COMPARE_POOLS_SCOPE.token0.address && + token0.address !== M0_COMPARE_POOLS_SCOPE.token1.address) || + (token1.address !== M0_COMPARE_POOLS_SCOPE.token0.address && + token1.address !== M0_COMPARE_POOLS_SCOPE.token1.address) + ) { + throw new NormalizationError( + `Source "${result.source_id}" tokens are outside the locked compare_pools pair.`, + ); + } + + assertLockedTokenMetadata(token0); + assertLockedTokenMetadata(token1); +} + +/** + * Binds a Graph `PoolSourceResult` to the locked compare_pools allowlist, then + * converts successful results with the pure M0-03A normalizer. + * + * Out-of-scope chain, source, deployment, pool, or pair selections fail before + * metric selection. Non-`ok` in-scope results still return `null`. + */ +export function bindComparePoolsGraphResult( + result: PoolSourceResult, + window: M0TimeWindow, +): CanonicalPoolCandidate | null { + if (result.source_id === M0_COMPARE_POOLS_SCOPE.nuthatchSourceId) { + throw new NormalizationError( + "Nuthatch results are not Graph pool candidates for compare_pools normalization.", + ); + } + + const scoped = findScopedGraphSource(result.source_id); + if (scoped === undefined) { + throw new NormalizationError( + `Source "${result.source_id}" is not in the locked compare_pools Graph allowlist.`, + ); + } + + assertScopedGraphResult(result, scoped); + return convertPoolSourceResult(result, window); +} + +/** + * Binds and converts every Graph result. Failed/timeout sources contribute no + * candidate. Out-of-scope sources throw before any candidate is returned. + */ +export function bindComparePoolsGraphResults( + results: readonly PoolSourceResult[], + window: M0TimeWindow, +): CanonicalPoolCandidate[] { + const candidates: CanonicalPoolCandidate[] = []; + for (const result of results) { + const candidate = bindComparePoolsGraphResult(result, window); + if (candidate !== null) { + candidates.push(candidate); + } + } + return candidates; +} diff --git a/src/normalization/pair.ts b/src/normalization/pair.ts new file mode 100644 index 0000000..f95ff94 --- /dev/null +++ b/src/normalization/pair.ts @@ -0,0 +1,48 @@ +import type { CanonicalPair, CanonicalToken } from "../schemas/compare-pools.js"; +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +import { normalizeAddress } from "./address.js"; +import { NormalizationError } from "./error.js"; + +export interface SourceTokenInput { + address: string; + symbol: string; + decimals: number; +} + +/** + * Builds a canonical token on the MVP-0 Base chain with a lowercase address. + */ +export function normalizeCanonicalToken(chainId: number, token: SourceTokenInput): CanonicalToken { + if (chainId !== BASE_CHAIN_ID) { + throw new NormalizationError(`Unsupported chain_id for canonical token: ${chainId}`); + } + if (!Number.isInteger(token.decimals) || token.decimals < 0 || token.decimals > 255) { + throw new NormalizationError(`Invalid canonical token decimals: ${String(token.decimals)}`); + } + + return { + chain_id: BASE_CHAIN_ID, + address: normalizeAddress(token.address), + symbol: token.symbol, + decimals: token.decimals, + }; +} + +/** + * Orders a token pair by ascending address, independent of source token order. + */ +export function normalizeCanonicalPair( + chainId: number, + tokenA: SourceTokenInput, + tokenB: SourceTokenInput, +): CanonicalPair { + const left = normalizeCanonicalToken(chainId, tokenA); + const right = normalizeCanonicalToken(chainId, tokenB); + + if (left.address === right.address) { + throw new NormalizationError("Pair tokens must differ"); + } + + return left.address < right.address ? [left, right] : [right, left]; +} diff --git a/src/normalization/swaps.ts b/src/normalization/swaps.ts new file mode 100644 index 0000000..d9bb789 --- /dev/null +++ b/src/normalization/swaps.ts @@ -0,0 +1,290 @@ +import { swapEventSchema, type SwapEvent } from "../schemas/large-swaps.js"; +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; +import { LSS_SCOPE } from "../scope/large-swaps.js"; +import { parseDecimal } from "../sources/graph/decimal.js"; + +import { normalizeAddress } from "./address.js"; +import { NormalizationError } from "./error.js"; +import { swapEventIdentity } from "./identities.js"; + +const SIGNED_INTEGER_PATTERN = /^(?:0|-?[1-9]\d*)$/; +const TRANSACTION_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/; + +export type LssThresholdToken = + typeof LSS_SCOPE.tokens.weth.address | typeof LSS_SCOPE.tokens.usdc.address; + +/** + * Capability-neutral raw Uniswap V3 swap row consumed by LSS normalization. + * The later Nuthatch adapter owns mapping its view rows into this shape. + */ +export interface RawLssSwapEvent { + readonly chain_id: number; + readonly protocol: string; + readonly pool: string; + readonly transaction_hash: string; + readonly log_index: number; + readonly block_number: number; + readonly timestamp: number; + readonly amount0_raw: string; + readonly amount1_raw: string; + readonly source_id: string; +} + +function parseSignedInteger(value: string, field: string): bigint { + if (!SIGNED_INTEGER_PATTERN.test(value)) { + throw new NormalizationError(`${field} must be a canonical signed integer string`); + } + return BigInt(value); +} + +function compareStrings(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +function assertNonNegativeInteger(value: number, field: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new NormalizationError(`${field} must be a non-negative integer`); + } +} + +function tokenWithChain(token: typeof LSS_SCOPE.tokens.weth | typeof LSS_SCOPE.tokens.usdc) { + return { + chain_id: BASE_CHAIN_ID, + ...token, + }; +} + +function formatBaseUnits(value: bigint, decimals: number): string { + const digits = (value < 0n ? -value : value).toString(); + if (decimals === 0) { + return digits; + } + + const padded = digits.padStart(decimals + 1, "0"); + const integerPart = padded.slice(0, -decimals); + const fractionalPart = padded.slice(-decimals).replace(/0+$/, ""); + return fractionalPart === "" ? integerPart : `${integerPart}.${fractionalPart}`; +} + +function compareParsedDecimals( + left: ReturnType, + right: ReturnType, +): number { + const scale = Math.max(left.scale, right.scale); + const leftScaled = BigInt(left.integerPart + left.fractionalPart.padEnd(scale, "0")); + const rightScaled = BigInt(right.integerPart + right.fractionalPart.padEnd(scale, "0")); + if (leftScaled === rightScaled) { + return 0; + } + return leftScaled < rightScaled ? -1 : 1; +} + +function validateRawScope(row: RawLssSwapEvent): { + readonly pool: typeof LSS_SCOPE.poolAddress; + readonly transactionHash: string; +} { + if (row.chain_id !== BASE_CHAIN_ID) { + throw new NormalizationError(`Unsupported swap chain: ${String(row.chain_id)}`); + } + if (row.protocol !== LSS_SCOPE.protocol) { + throw new NormalizationError(`Unsupported swap protocol: ${row.protocol}`); + } + + const pool = normalizeAddress(row.pool); + if (pool !== LSS_SCOPE.poolAddress) { + throw new NormalizationError(`Unsupported swap pool: ${pool}`); + } + if (!TRANSACTION_HASH_PATTERN.test(row.transaction_hash)) { + throw new NormalizationError(`Invalid swap transaction hash: ${row.transaction_hash}`); + } + + assertNonNegativeInteger(row.log_index, "log_index"); + assertNonNegativeInteger(row.block_number, "block_number"); + assertNonNegativeInteger(row.timestamp, "timestamp"); + if (row.source_id === "" || row.source_id.trim() !== row.source_id) { + throw new NormalizationError("source_id must be non-empty without surrounding whitespace"); + } + + return { + pool: LSS_SCOPE.poolAddress, + transactionHash: row.transaction_hash.toLowerCase(), + }; +} + +/** + * Normalizes one raw Uniswap V3 pool-delta row into the LSS `SwapEvent`. + * + * Positive pool deltas are the input asset; negative pool deltas are the output + * asset. Human amounts are exact absolute base-unit conversions using `BigInt`. + */ +export function normalizeLssSwapEvent(row: RawLssSwapEvent): SwapEvent { + const { pool, transactionHash } = validateRawScope(row); + const amount0 = parseSignedInteger(row.amount0_raw, "amount0_raw"); + const amount1 = parseSignedInteger(row.amount1_raw, "amount1_raw"); + + if (amount0 === 0n || amount1 === 0n || amount0 > 0n === amount1 > 0n) { + throw new NormalizationError("Swap pool deltas must be non-zero and have opposite signs"); + } + + const token0 = tokenWithChain(LSS_SCOPE.tokens.weth); + const token1 = tokenWithChain(LSS_SCOPE.tokens.usdc); + const token0IsInput = amount0 > 0n; + const inputToken = token0IsInput ? token0 : token1; + const outputToken = token0IsInput ? token1 : token0; + const inputRaw = token0IsInput ? amount0 : amount1; + const outputRaw = token0IsInput ? amount1 : amount0; + + const parsed = swapEventSchema.safeParse({ + chain_id: BASE_CHAIN_ID, + protocol: LSS_SCOPE.protocol, + pool, + transaction_hash: transactionHash, + log_index: row.log_index, + block_number: row.block_number, + timestamp: row.timestamp, + asset_in: inputToken, + asset_out: outputToken, + amount_in: formatBaseUnits(inputRaw, inputToken.decimals), + amount_out: formatBaseUnits(outputRaw, outputToken.decimals), + amount_in_raw: inputRaw.toString(), + amount_out_raw: outputRaw.toString(), + usd_notional: null, + source_id: row.source_id, + }); + + if (!parsed.success) { + throw new NormalizationError("Normalized swap failed the canonical schema", { + cause: parsed.error, + }); + } + return parsed.data; +} + +function stableSwapValue(event: SwapEvent): string { + return JSON.stringify(event); +} + +/** + * Collapses exact duplicate events by chain + transaction hash + log index. + * Conflicting rows with the same on-chain identity are rejected. + */ +export function deduplicateSwapEvents(events: readonly SwapEvent[]): SwapEvent[] { + const byIdentity = new Map(); + + for (const event of events) { + const parsed = swapEventSchema.safeParse(event); + if (!parsed.success) { + throw new NormalizationError("Cannot deduplicate an invalid canonical swap", { + cause: parsed.error, + }); + } + + const identity = swapEventIdentity( + parsed.data.chain_id, + parsed.data.transaction_hash, + parsed.data.log_index, + ); + const existing = byIdentity.get(identity); + if (existing !== undefined && stableSwapValue(existing) !== stableSwapValue(parsed.data)) { + throw new NormalizationError(`Conflicting swap rows share event identity: ${identity}`); + } + byIdentity.set(identity, parsed.data); + } + + return [...byIdentity.entries()] + .sort(([left], [right]) => compareStrings(left, right)) + .map(([, event]) => event); +} + +function assertThresholdToken(token: string): asserts token is LssThresholdToken { + if (token !== LSS_SCOPE.tokens.weth.address && token !== LSS_SCOPE.tokens.usdc.address) { + throw new NormalizationError(`Unsupported threshold token: ${token}`); + } +} + +function thresholdRawAmount(event: SwapEvent, thresholdToken: LssThresholdToken): bigint { + const isInput = + event.asset_in.address === thresholdToken + ? true + : event.asset_out.address === thresholdToken + ? false + : null; + if (isInput === null) { + throw new NormalizationError("Threshold token is absent from the canonical swap"); + } + + const raw = isInput ? event.amount_in_raw : event.amount_out_raw; + if (raw === undefined) { + throw new NormalizationError("Threshold comparison requires preserved raw token amounts"); + } + + const signed = parseSignedInteger(raw, isInput ? "amount_in_raw" : "amount_out_raw"); + if ((isInput && signed <= 0n) || (!isInput && signed >= 0n)) { + throw new NormalizationError("Canonical swap raw signs disagree with token direction"); + } + return signed < 0n ? -signed : signed; +} + +function rawAmountAsHuman(rawAmount: bigint, thresholdToken: LssThresholdToken): string { + const decimals = + thresholdToken === LSS_SCOPE.tokens.weth.address + ? LSS_SCOPE.tokens.weth.decimals + : LSS_SCOPE.tokens.usdc.decimals; + return formatBaseUnits(rawAmount, decimals); +} + +/** + * Compares the exact absolute selected-token pool delta to a positive human-unit + * threshold. No JavaScript number conversion or USD pricing is used. + */ +export function swapMeetsTokenThreshold( + event: SwapEvent, + thresholdToken: string, + minAmount: string, +): boolean { + assertThresholdToken(thresholdToken); + if (minAmount.trim() !== minAmount) { + throw new NormalizationError("min_amount must not contain surrounding whitespace"); + } + + let threshold: ReturnType; + try { + threshold = parseDecimal(minAmount); + } catch (error) { + throw new NormalizationError("min_amount must be a base-10 decimal string", { + cause: error, + }); + } + if (threshold.canonical === "0") { + throw new NormalizationError("min_amount must be positive"); + } + + const rawAmount = thresholdRawAmount(event, thresholdToken); + const humanAmount = parseDecimal(rawAmountAsHuman(rawAmount, thresholdToken)); + return compareParsedDecimals(humanAmount, threshold) >= 0; +} + +export function filterSwapsByTokenThreshold( + events: readonly SwapEvent[], + thresholdToken: string, + minAmount: string, +): SwapEvent[] { + return events.filter((event) => swapMeetsTokenThreshold(event, thresholdToken, minAmount)); +} + +/** + * LSS-02 pipeline: normalize, collapse duplicate identities, then apply the exact + * selected-token threshold. LSS-03 owns response ordering and pagination. + */ +export function normalizeDeduplicateAndFilterSwaps( + rows: readonly RawLssSwapEvent[], + thresholdToken: string, + minAmount: string, +): SwapEvent[] { + const normalized = rows.map(normalizeLssSwapEvent); + const deduplicated = deduplicateSwapEvents(normalized); + return filterSwapsByTokenThreshold(deduplicated, thresholdToken, minAmount); +} diff --git a/src/normalization/types.ts b/src/normalization/types.ts new file mode 100644 index 0000000..db11de0 --- /dev/null +++ b/src/normalization/types.ts @@ -0,0 +1,32 @@ +import type { M0TimeWindow } from "../policy/index.js"; +import type { CanonicalPair } from "../schemas/compare-pools.js"; +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +/** + * Rank-free canonical pool candidate produced by M0-03A normalization. + * + * Ranking belongs to M0-04. Callers that need `PoolComparisonRecord` must + * supply an explicit rank via `toPoolComparisonRecord`. + */ +export interface CanonicalPoolCandidate { + chain_id: typeof BASE_CHAIN_ID; + protocol: string; + pool_address: string; + pair: CanonicalPair; + tvl_usd: string | null; + volume_usd: string | null; + fees_usd: string | null; + window: M0TimeWindow; + source_ids: string[]; +} + +/** + * Duplicate collapse policy for equal `chain_id` + `pool_address`: + * + * - Primary record = candidate whose least `source_id` is lexicographically first. + * - Protocol, pair, window, and USD fields come only from the primary. + * - `source_ids` become the sorted unique union across the group. + * - USD values are never averaged, summed, maxed, or otherwise synthesized. + */ +export const DUPLICATE_POOL_COLLAPSE_POLICY = + "primary_by_least_source_id_passthrough_metrics_merge_sorted_source_ids" as const; diff --git a/src/policy/index.ts b/src/policy/index.ts new file mode 100644 index 0000000..0519b88 --- /dev/null +++ b/src/policy/index.ts @@ -0,0 +1,11 @@ +export { + M0_CORE_POLICY, + M0_LENDING_RANKING_METRICS, + M0_RANKING_METRICS, + M0_RANKING_TIE_BREAK, + M0_TIME_WINDOWS, + M0_WARNING_ORDER, + type M0LendingRankingMetric, + type M0RankingMetric, + type M0TimeWindow, +} from "./m0.js"; diff --git a/src/policy/m0.ts b/src/policy/m0.ts new file mode 100644 index 0000000..a1c60aa --- /dev/null +++ b/src/policy/m0.ts @@ -0,0 +1,82 @@ +import { GATEWAY_DEFAULTS, GATEWAY_MAXIMUMS } from "../config/defaults.js"; +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +export const M0_TIME_WINDOWS = ["24h", "7d"] as const; +export type M0TimeWindow = (typeof M0_TIME_WINDOWS)[number]; + +export const M0_RANKING_METRICS = ["tvl_usd", "volume_usd", "fees_usd"] as const; +export type M0RankingMetric = (typeof M0_RANKING_METRICS)[number]; + +export const M0_LENDING_RANKING_METRICS = [ + "tvl_usd", + "total_deposit_balance_usd", + "total_borrow_balance_usd", + "lender_variable_rate_percent", + "borrower_variable_rate_percent", +] as const; +export type M0LendingRankingMetric = (typeof M0_LENDING_RANKING_METRICS)[number]; + +export const M0_RANKING_TIE_BREAK = [ + "requested_metric_desc_nulls_last", + "protocol_asc", + "pool_address_asc", + "source_id_asc", +] as const; + +export const M0_WARNING_ORDER = ["source_order", "warning_text"] as const; + +/** + * Product limits and deterministic behavior locked by M0-01A. + * + * Live pair and deployment binding lands in M0-02B (`src/scope/compare-pools.ts`). + * A verified Nuthatch freshness fact remains outstanding and must not be invented. + */ +export const M0_CORE_POLICY = { + chainId: BASE_CHAIN_ID, + defaultWindow: "24h" satisfies M0TimeWindow, + defaultRankingMetric: "volume_usd" satisfies M0RankingMetric, + topN: { + default: 3, + maximum: 3, + }, + gateway: { + rateLimit: { + defaultMaxRequests: GATEWAY_DEFAULTS.rateLimitMaxRequests, + maximumMaxRequests: GATEWAY_MAXIMUMS.rateLimitMaxRequests, + defaultWindowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + maximumWindowMs: GATEWAY_MAXIMUMS.rateLimitWindowMs, + resetPolicy: "fixed_window", + }, + sourceTimeoutMs: GATEWAY_DEFAULTS.sourceTimeoutMs, + maximumSourceTimeoutMs: GATEWAY_MAXIMUMS.sourceTimeoutMs, + endToEndTimeoutMs: 15_000, + maximumResponseBytes: 65_536, + }, + freshness: { + qualityStaleAfterSeconds: 300, + enforcementLayer: "core_quality", + preservesAdapterStatus: true, + }, + coverage: { + /** Two WETH/USDC fee-tier pools, both served by one Messari deployment. */ + expectedGraphResults: 2, + requiresNuthatchForComplete: true, + minimumGraphResultsForPartial: 1, + }, + /** + * `compare_lending_markets` compares one asset across every selected + * protocol, so the market token is locked rather than requested. + */ + lending: { + marketToken: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + defaultRankingMetric: "tvl_usd" satisfies M0LendingRankingMetric, + topN: { + default: 3, + maximum: 3, + }, + coverage: { + expectedSources: 3, + minimumSourcesForPartial: 1, + }, + }, +} as const; diff --git a/src/quality/error.ts b/src/quality/error.ts new file mode 100644 index 0000000..4289114 --- /dev/null +++ b/src/quality/error.ts @@ -0,0 +1,6 @@ +export class QualityError extends Error { + constructor(message: string) { + super(message); + this.name = "QualityError"; + } +} diff --git a/src/quality/index.ts b/src/quality/index.ts new file mode 100644 index 0000000..5df5644 --- /dev/null +++ b/src/quality/index.ts @@ -0,0 +1,3 @@ +export { QualityError } from "./error.js"; +export { settleComparePoolsResult, type SettleComparePoolsInput } from "./settle.js"; +export { settleCompareLendingResult, type SettleCompareLendingInput } from "./settle-lending.js"; diff --git a/src/quality/settle-lending.ts b/src/quality/settle-lending.ts new file mode 100644 index 0000000..221d9a7 --- /dev/null +++ b/src/quality/settle-lending.ts @@ -0,0 +1,245 @@ +import type { M0LendingRankingMetric } from "../policy/index.js"; +import { M0_CORE_POLICY, M0_WARNING_ORDER } from "../policy/index.js"; +import type { + CompareLendingResponse, + LendingCoverage, + LendingMarketRecord, + LendingToken, +} from "../schemas/compare-lending.js"; +import type { ResultFreshness, ResultProvenance } from "../schemas/compare-pools.js"; +import type { LendingMarketSourceResult, SourceFreshness } from "../schemas/source-adapter.js"; +import { M0_COMPARE_LENDING_SCOPE } from "../scope/compare-lending.js"; + +import { QualityError } from "./error.js"; + +function compareStrings(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +function sourceOrderIndex(sourceId: string): number { + const index = M0_COMPARE_LENDING_SCOPE.sources.findIndex( + (source) => source.source_id === sourceId, + ); + return index >= 0 ? index : Number.MAX_SAFE_INTEGER; +} + +function warningSourceId(warning: string): string { + for (const source of M0_COMPARE_LENDING_SCOPE.sources) { + if (warning.includes(source.source_id)) { + return source.source_id; + } + } + return ""; +} + +function sortWarnings(warnings: readonly string[]): string[] { + const unique = [...new Set(warnings)]; + return unique.sort((left, right) => { + if (M0_WARNING_ORDER[0] === "source_order") { + const bySource = + sourceOrderIndex(warningSourceId(left)) - sourceOrderIndex(warningSourceId(right)); + if (bySource !== 0) { + return bySource; + } + } + return compareStrings(left, right); + }); +} + +function toObservedFreshness(sourceId: string, freshness: SourceFreshness): ResultFreshness { + const lagSeconds = freshness.queried_at - freshness.indexed_block_timestamp; + if (lagSeconds < 0) { + throw new QualityError( + `Source "${sourceId}" queried_at is earlier than indexed_block_timestamp.`, + ); + } + const status = lagSeconds > M0_CORE_POLICY.freshness.qualityStaleAfterSeconds ? "stale" : "fresh"; + return { + source_id: sourceId, + status, + indexed_block: freshness.indexed_block, + indexed_block_timestamp: freshness.indexed_block_timestamp, + indexed_block_hash: freshness.indexed_block_hash ?? null, + queried_at: freshness.queried_at, + lag_seconds: lagSeconds, + }; +} + +function resultFreshness(result: LendingMarketSourceResult): ResultFreshness { + if (result.status !== "ok" || result.freshness === null) { + return { source_id: result.source_id, status: "unavailable" }; + } + return toObservedFreshness(result.source_id, result.freshness); +} + +function resultProvenance(result: LendingMarketSourceResult): ResultProvenance { + return { + source_id: result.source_id, + source_type: result.source_type, + protocol: result.protocol, + chain_id: result.chain_id, + deployment_or_view_id: result.provenance.deployment_or_view_id, + schema_version: result.provenance.schema_version, + methodology_version: result.provenance.methodology_version, + query_id: result.provenance.query_id, + }; +} + +function settlementWarnings( + results: readonly LendingMarketSourceResult[], + freshness: readonly ResultFreshness[], +): string[] { + const warnings: string[] = []; + + for (const result of results) { + warnings.push(...result.warnings); + if (result.status === "timeout") { + warnings.push(`${result.source_id} timed out`); + } else if (result.status === "error") { + warnings.push(`${result.source_id} returned an error`); + } else if (result.status === "unsupported") { + warnings.push(`${result.source_id} is unsupported for this request`); + } else if (result.status === "stale") { + warnings.push(`${result.source_id} reported adapter-stale status`); + } + } + + for (const entry of freshness) { + if (entry.status === "stale") { + warnings.push(`${entry.source_id} exceeded the freshness threshold`); + } + } + + return sortWarnings(warnings); +} + +function determineStatus(input: { + readonly successfulSources: number; + readonly freshness: readonly ResultFreshness[]; + readonly marketCount: number; +}): "complete" | "partial" | "failed" { + if (input.marketCount === 0 || input.successfulSources === 0) { + return "failed"; + } + + const hasDegradedCoverage = + input.successfulSources < M0_CORE_POLICY.lending.coverage.expectedSources || + input.freshness.some((entry) => entry.status !== "fresh"); + + return hasDegradedCoverage ? "partial" : "complete"; +} + +export interface SettleCompareLendingInput { + readonly marketToken: LendingToken; + readonly rankedBy: M0LendingRankingMetric; + readonly markets: readonly LendingMarketRecord[]; + readonly sourceResults: readonly LendingMarketSourceResult[]; +} + +/** + * Settles independent lending source results into the public + * compare_lending_markets quality envelope. + * + * Does not re-rank markets. Quality freshness uses the core lag threshold + * without rewriting adapter-owned source status. + */ +export function settleCompareLendingResult( + input: SettleCompareLendingInput, +): CompareLendingResponse { + if (input.sourceResults.length !== M0_CORE_POLICY.lending.coverage.expectedSources) { + throw new QualityError( + `Expected ${String(M0_CORE_POLICY.lending.coverage.expectedSources)} lending source results.`, + ); + } + + const expectedIds = M0_COMPARE_LENDING_SCOPE.sources.map((source) => source.source_id); + const observedIds = input.sourceResults.map((result) => result.source_id); + if ( + observedIds.length !== new Set(observedIds).size || + expectedIds.some((sourceId) => !observedIds.includes(sourceId)) + ) { + throw new QualityError( + "Lending results must cover the locked compare_lending_markets source allowlist.", + ); + } + + const ordered = expectedIds.map((sourceId) => + input.sourceResults.find((result) => result.source_id === sourceId)!, + ); + + const okCount = ordered.filter((result) => result.status === "ok").length; + if (okCount > 0 && input.markets.length === 0) { + throw new QualityError( + "Successful lending source results require ranked market records before settlement.", + ); + } + if (input.markets.length > okCount) { + throw new QualityError( + "Ranked market count cannot exceed the number of successful lending source results.", + ); + } + + const freshness: ResultFreshness[] = ordered.map(resultFreshness); + const provenance: ResultProvenance[] = ordered.map(resultProvenance); + + // Counts sources that answered, not records returned: Top-N truncation is a + // caller's choice, so it must not read as missing source coverage. + const coverage: LendingCoverage = { + requested_sources: M0_CORE_POLICY.lending.coverage.expectedSources, + successful_sources: okCount, + }; + + const status = determineStatus({ + successfulSources: coverage.successful_sources, + freshness, + marketCount: input.markets.length, + }); + + const warnings = settlementWarnings(ordered, freshness); + if (input.markets.length < okCount) { + warnings.push( + `Top-N truncated ranked markets from ${String(okCount)} to ${String(input.markets.length)}.`, + ); + } + const orderedWarnings = sortWarnings(warnings); + if (status === "partial" && orderedWarnings.length === 0) { + throw new QualityError("Partial responses require at least one warning."); + } + + if (status === "failed") { + return { + status, + data: null, + coverage: { + requested_sources: M0_CORE_POLICY.lending.coverage.expectedSources, + successful_sources: 0, + }, + freshness, + provenance, + warnings: + orderedWarnings.length > 0 + ? orderedWarnings + : sortWarnings(["No valid lending market record was available"]), + pagination: null, + }; + } + + return { + status, + data: { + chain_id: M0_COMPARE_LENDING_SCOPE.chainId, + market_token: input.marketToken, + ranked_by: input.rankedBy, + rate_basis: "percent_apy", + markets: [...input.markets], + }, + coverage, + freshness, + provenance, + warnings: orderedWarnings, + pagination: null, + }; +} diff --git a/src/quality/settle.ts b/src/quality/settle.ts new file mode 100644 index 0000000..b5c7cbc --- /dev/null +++ b/src/quality/settle.ts @@ -0,0 +1,352 @@ +import type { M0RankingMetric, M0TimeWindow } from "../policy/index.js"; +import { M0_CORE_POLICY, M0_WARNING_ORDER } from "../policy/index.js"; +import { getSourceById } from "../registry/index.js"; +import type { + CanonicalPair, + ComparePoolsResponse, + Coverage, + NuthatchFreshnessFact, + PoolComparisonRecord, + ResultFreshness, + ResultProvenance, +} from "../schemas/compare-pools.js"; +import type { + NuthatchSourceResult, + PoolSourceResult, + SourceFreshness, +} from "../schemas/source-adapter.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; +import { NUTHATCH_FRESHNESS_QUERY_ID } from "../sources/nuthatch/freshness-query.js"; + +import { QualityError } from "./error.js"; + +function compareStrings(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +function sourceOrderIndex(sourceId: string): number { + const graphIndex = M0_COMPARE_POOLS_SCOPE.graphSources.findIndex( + (source) => source.source_id === sourceId, + ); + if (graphIndex >= 0) { + return graphIndex; + } + if (sourceId === M0_COMPARE_POOLS_SCOPE.nuthatchSourceId) { + return M0_COMPARE_POOLS_SCOPE.graphSources.length; + } + return Number.MAX_SAFE_INTEGER; +} + +function warningSourceId(warning: string): string { + for (const source of M0_COMPARE_POOLS_SCOPE.graphSources) { + if (warning.includes(source.source_id)) { + return source.source_id; + } + } + if (warning.includes(M0_COMPARE_POOLS_SCOPE.nuthatchSourceId)) { + return M0_COMPARE_POOLS_SCOPE.nuthatchSourceId; + } + return ""; +} + +function sortWarnings(warnings: readonly string[]): string[] { + const unique = [...new Set(warnings)]; + return unique.sort((left, right) => { + if (M0_WARNING_ORDER[0] === "source_order") { + const bySource = + sourceOrderIndex(warningSourceId(left)) - sourceOrderIndex(warningSourceId(right)); + if (bySource !== 0) { + return bySource; + } + } + return compareStrings(left, right); + }); +} + +function toObservedFreshness(sourceId: string, freshness: SourceFreshness): ResultFreshness { + const lagSeconds = freshness.queried_at - freshness.indexed_block_timestamp; + if (lagSeconds < 0) { + throw new QualityError( + `Source "${sourceId}" queried_at is earlier than indexed_block_timestamp.`, + ); + } + const status = lagSeconds > M0_CORE_POLICY.freshness.qualityStaleAfterSeconds ? "stale" : "fresh"; + return { + source_id: sourceId, + status, + indexed_block: freshness.indexed_block, + indexed_block_timestamp: freshness.indexed_block_timestamp, + indexed_block_hash: freshness.indexed_block_hash ?? null, + queried_at: freshness.queried_at, + lag_seconds: lagSeconds, + }; +} + +function graphResultFreshness(result: PoolSourceResult): ResultFreshness { + if (result.status !== "ok" || result.freshness === null) { + return { source_id: result.source_id, status: "unavailable" }; + } + return toObservedFreshness(result.source_id, result.freshness); +} + +function graphResultProvenance(result: PoolSourceResult): ResultProvenance { + return { + source_id: result.source_id, + source_type: result.source_type, + protocol: result.protocol, + chain_id: result.chain_id, + deployment_or_view_id: result.provenance.deployment_or_view_id, + schema_version: result.provenance.schema_version, + methodology_version: result.provenance.methodology_version, + query_id: result.provenance.query_id, + }; +} + +function nuthatchUnavailableProvenance(): ResultProvenance { + const record = getSourceById(M0_COMPARE_POOLS_SCOPE.nuthatchSourceId); + if (record === undefined || record.source_type !== "nuthatch_view") { + throw new QualityError( + `Registry is missing Nuthatch provenance for "${M0_COMPARE_POOLS_SCOPE.nuthatchSourceId}".`, + ); + } + return { + source_id: record.source_id, + source_type: record.source_type, + protocol: record.protocol, + chain_id: record.chain_id, + deployment_or_view_id: record.deployment_or_view_id, + schema_version: record.schema_version, + methodology_version: record.methodology_version, + query_id: NUTHATCH_FRESHNESS_QUERY_ID, + }; +} + +function nuthatchResultProvenance(result: NuthatchSourceResult): ResultProvenance { + return { + source_id: result.source_id, + source_type: result.source_type, + protocol: result.protocol, + chain_id: result.chain_id, + deployment_or_view_id: result.provenance.deployment_or_view_id, + schema_version: result.provenance.schema_version, + methodology_version: result.provenance.methodology_version, + query_id: result.provenance.query_id, + }; +} + +function nuthatchFreshnessEntry(result: NuthatchSourceResult | null): ResultFreshness { + // Public quality freshness for Nuthatch is only observed when the adapter + // returned ok data. Non-ok results with retained freshness stay unavailable + // so fact/coverage/freshness stay schema-aligned. + if (result === null || result.status !== "ok" || result.freshness === null) { + return { + source_id: result?.source_id ?? M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, + status: "unavailable", + }; + } + return toObservedFreshness(result.source_id, result.freshness); +} + +function nuthatchFact(result: NuthatchSourceResult | null): NuthatchFreshnessFact | null { + if (result === null || result.status !== "ok") { + return null; + } + return { + pool_address: result.data.pool_address, + recent_swap_count_24h: result.data.recent_swap_count_24h, + last_swap_block: result.data.last_swap_block, + last_swap_block_timestamp: result.data.last_swap_block_timestamp, + source_id: result.source_id, + }; +} + +function settlementWarnings( + graphResults: readonly PoolSourceResult[], + nuthatchResult: NuthatchSourceResult | null, + freshness: readonly ResultFreshness[], +): string[] { + const warnings: string[] = []; + + for (const result of graphResults) { + warnings.push(...result.warnings); + if (result.status === "timeout") { + warnings.push(`${result.source_id} timed out`); + } else if (result.status === "error") { + warnings.push(`${result.source_id} returned an error`); + } else if (result.status === "unsupported") { + warnings.push(`${result.source_id} is unsupported for this request`); + } else if (result.status === "stale") { + warnings.push(`${result.source_id} reported adapter-stale status`); + } + } + + for (const entry of freshness) { + if (entry.status === "stale") { + warnings.push(`${entry.source_id} exceeded the freshness threshold`); + } + } + + if (nuthatchResult === null) { + warnings.push( + `${M0_COMPARE_POOLS_SCOPE.nuthatchSourceId} live freshness fact is not yet verified`, + ); + } else { + warnings.push(...nuthatchResult.warnings); + if (nuthatchResult.status !== "ok") { + warnings.push(`${nuthatchResult.source_id} was unavailable`); + } + } + + return sortWarnings(warnings); +} + +function determineStatus(input: { + readonly successfulDeployments: number; + readonly nuthatchAvailable: boolean; + readonly freshness: readonly ResultFreshness[]; + readonly poolCount: number; +}): "complete" | "partial" | "failed" { + if (input.poolCount === 0 || input.successfulDeployments === 0) { + return "failed"; + } + + const hasDegradedCoverage = + input.successfulDeployments < M0_CORE_POLICY.coverage.expectedGraphResults || + (M0_CORE_POLICY.coverage.requiresNuthatchForComplete && !input.nuthatchAvailable) || + input.freshness.some((entry) => entry.status !== "fresh"); + + return hasDegradedCoverage ? "partial" : "complete"; +} + +export interface SettleComparePoolsInput { + readonly pair: CanonicalPair; + readonly window: M0TimeWindow; + readonly rankedBy: M0RankingMetric; + readonly pools: readonly PoolComparisonRecord[]; + readonly graphResults: readonly PoolSourceResult[]; + /** Pass `null` when Nuthatch is unverified or not queried. Do not invent a fact. */ + readonly nuthatchResult: NuthatchSourceResult | null; +} + +/** + * Settles independent Graph (+ optional Nuthatch) source results into the public + * compare_pools quality envelope. + * + * Does not re-rank pools. Quality freshness uses the core lag threshold without + * rewriting adapter-owned source status. + */ +export function settleComparePoolsResult(input: SettleComparePoolsInput): ComparePoolsResponse { + if (input.graphResults.length !== M0_CORE_POLICY.coverage.expectedGraphResults) { + throw new QualityError( + `Expected ${String(M0_CORE_POLICY.coverage.expectedGraphResults)} Graph source results.`, + ); + } + + const expectedIds = M0_COMPARE_POOLS_SCOPE.graphSources.map((source) => source.source_id); + const observedIds = input.graphResults.map((result) => result.source_id); + if ( + observedIds.length !== new Set(observedIds).size || + expectedIds.some((sourceId) => !observedIds.includes(sourceId)) + ) { + throw new QualityError("Graph results must cover the locked compare_pools source allowlist."); + } + + const orderedGraph = expectedIds.map((sourceId) => + input.graphResults.find((result) => result.source_id === sourceId)!, + ); + + const okGraphCount = orderedGraph.filter((result) => result.status === "ok").length; + if (okGraphCount > 0 && input.pools.length === 0) { + throw new QualityError( + "Successful Graph source results require ranked pool records before settlement.", + ); + } + if (input.pools.length > okGraphCount) { + throw new QualityError( + "Ranked pool count cannot exceed the number of successful Graph source results.", + ); + } + + const nuthatchAvailable = input.nuthatchResult !== null && input.nuthatchResult.status === "ok"; + const fact = nuthatchFact(input.nuthatchResult); + + if (nuthatchAvailable !== (fact !== null)) { + throw new QualityError("Nuthatch availability must match freshness-fact presence."); + } + + const freshness: ResultFreshness[] = [ + ...orderedGraph.map(graphResultFreshness), + nuthatchFreshnessEntry(input.nuthatchResult), + ]; + const provenance: ResultProvenance[] = [ + ...orderedGraph.map(graphResultProvenance), + input.nuthatchResult === null + ? nuthatchUnavailableProvenance() + : nuthatchResultProvenance(input.nuthatchResult), + ]; + + // Counts deployments that answered, not records returned: Top-N truncation is + // a caller's choice, so it must not read as missing source coverage. + const coverage: Coverage = { + requested_deployments: M0_CORE_POLICY.coverage.expectedGraphResults, + successful_deployments: okGraphCount, + nuthatch_available: nuthatchAvailable, + }; + + const status = determineStatus({ + successfulDeployments: coverage.successful_deployments, + nuthatchAvailable, + freshness, + poolCount: input.pools.length, + }); + + const warnings = settlementWarnings(orderedGraph, input.nuthatchResult, freshness); + if (input.pools.length < okGraphCount) { + warnings.push( + `Top-N truncated ranked pools from ${String(okGraphCount)} to ${String(input.pools.length)}.`, + ); + } + const orderedWarnings = sortWarnings(warnings); + if (status === "partial" && orderedWarnings.length === 0) { + throw new QualityError("Partial responses require at least one warning."); + } + + if (status === "failed") { + return { + status, + data: null, + coverage: { + requested_deployments: M0_CORE_POLICY.coverage.expectedGraphResults, + successful_deployments: 0, + nuthatch_available: nuthatchAvailable, + }, + freshness, + provenance, + warnings: + orderedWarnings.length > 0 + ? orderedWarnings + : sortWarnings(["No valid Graph pool record was available"]), + pagination: null, + }; + } + + return { + status, + data: { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + pair: input.pair, + window: input.window, + ranked_by: input.rankedBy, + pools: [...input.pools], + nuthatch_freshness_fact: fact, + }, + coverage, + freshness, + provenance, + warnings: orderedWarnings, + pagination: null, + }; +} diff --git a/src/registry/compare-lending.json b/src/registry/compare-lending.json new file mode 100644 index 0000000..4cefbe0 --- /dev/null +++ b/src/registry/compare-lending.json @@ -0,0 +1,25 @@ +{ + "profile_id": "compare-lending-base-usdc-v1", + "chain_id": 8453, + "market_token": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "sources": [ + { + "source_id": "messari-aave-v3-base", + "query_id": "tier-a-lending-market-metrics-v1", + "schema_contract_id": "tier-a-lending-market-metrics-v1", + "priority": 1 + }, + { + "source_id": "messari-seamless-base", + "query_id": "tier-a-lending-market-metrics-v1", + "schema_contract_id": "tier-a-lending-market-metrics-v1", + "priority": 2 + }, + { + "source_id": "messari-moonwell-base", + "query_id": "tier-a-lending-market-metrics-v1", + "schema_contract_id": "tier-a-lending-market-metrics-v1", + "priority": 3 + } + ] +} diff --git a/src/registry/compare-pools.json b/src/registry/compare-pools.json new file mode 100644 index 0000000..14d2f64 --- /dev/null +++ b/src/registry/compare-pools.json @@ -0,0 +1,23 @@ +{ + "profile_id": "compare-pools-base-weth-usdc-v2", + "chain_id": 8453, + "token0": "0x4200000000000000000000000000000000000006", + "token1": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "window_methodology": "completed-utc-days-v1", + "sources": [ + { + "source_id": "messari-uniswap-v3-base-fee030", + "pool_address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "query_id": "tier-a-dex-pool-metrics-v1", + "schema_contract_id": "tier-a-dex-pool-metrics-v1", + "priority": 1 + }, + { + "source_id": "messari-uniswap-v3-base-fee005", + "pool_address": "0xd0b53d9277642d899df5c87a3966a349a798f224", + "query_id": "tier-a-dex-pool-metrics-v1", + "schema_contract_id": "tier-a-dex-pool-metrics-v1", + "priority": 2 + } + ] +} diff --git a/src/registry/index.test.ts b/src/registry/index.test.ts new file mode 100644 index 0000000..4075416 --- /dev/null +++ b/src/registry/index.test.ts @@ -0,0 +1,594 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + RegistryConfigurationError, + getActiveComparePoolGraphSources, + getSourceById, + resetRegistryCache, +} from "./index.js"; +import type { SourceRegistryRecord } from "./types.js"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + resetRegistryCache(); + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function writeJsonFile(name: string, contents: string): URL { + const directory = mkdtempSync(join(tmpdir(), "deeptrace-registry-")); + temporaryDirectories.push(directory); + const file = join(directory, name); + writeFileSync(file, contents, "utf8"); + return pathToFileURL(file); +} + +const graphRecord = { + source_id: "test-graph-a", + category: "dex", + protocol: "Test DEX A", + chain_id: 8453, + source_type: "native_subgraph", + deployment_or_view_id: "QmTestDeploymentA", + schema_version: null, + methodology_version: null, + supported_entities: ["Pool", "PoolDayData", "Token"], + status: "active", + locator: { + kind: "graph_subgraph", + gateway_host: "gateway.thegraph.com", + subgraph_id: "TestSubgraphA", + }, +} satisfies SourceRegistryRecord; + +const nuthatchRecord = { + source_id: "test-nuthatch", + category: "dex", + protocol: "Test DEX A", + chain_id: 8453, + source_type: "nuthatch_view", + deployment_or_view_id: "test-view-hash", + schema_version: "1.0.0", + methodology_version: "1.0.0", + supported_entities: ["Swap"], + status: "active", + locator: { + kind: "nuthatch_view", + base_url_env: "NUTHATCH_BASE_URL", + view_id: "pool-swaps", + }, +} satisfies SourceRegistryRecord; + +const graphRecordB = { + ...graphRecord, + source_id: "test-graph-b", + protocol: "Test DEX B", + deployment_or_view_id: "QmTestDeploymentB", + locator: { ...graphRecord.locator, subgraph_id: "TestSubgraphB" }, +} satisfies SourceRegistryRecord; + +const records = [graphRecord, graphRecordB, nuthatchRecord]; + +const QUERY_ID = "test-pool-metrics-v1"; +const SCHEMA_CONTRACT_ID = "test-pool-metrics-shape-v1"; + +function binding(sourceId: string, poolSuffix: string, priority: number) { + return { + source_id: sourceId, + pool_address: `0x${poolSuffix.repeat(40).slice(0, 40)}`, + query_id: QUERY_ID, + schema_contract_id: SCHEMA_CONTRACT_ID, + priority, + }; +} + +const profile = { + profile_id: "test-compare-pools-v1", + chain_id: 8453, + token0: "0x4200000000000000000000000000000000000006", + token1: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + window_methodology: "completed-utc-days-v1", + sources: [binding("test-graph-a", "a", 1), binding("test-graph-b", "b", 2)], +}; + +describe("getSourceById", () => { + it("returns a Graph record parsed from an injected value", () => { + const record = getSourceById("test-graph-a", { records: [graphRecord, nuthatchRecord] }); + + expect(record).toEqual(graphRecord); + }); + + it("returns a Nuthatch record, which is not Graph-specific", () => { + const record = getSourceById("test-nuthatch", { records: [graphRecord, nuthatchRecord] }); + + expect(record?.source_type).toBe("nuthatch_view"); + }); + + it("returns an inactive record so callers can report why it is unusable", () => { + const inactive = { ...graphRecord, status: "inactive" } satisfies SourceRegistryRecord; + + expect(getSourceById("test-graph-a", { records: [inactive] })?.status).toBe("inactive"); + }); + + it("returns undefined for an unknown source id", () => { + expect(getSourceById("absent", { records: [graphRecord] })).toBeUndefined(); + }); + + it("reads records from an injected file URL", () => { + const location = writeJsonFile("records.json", JSON.stringify([graphRecord])); + + expect(getSourceById("test-graph-a", { records: location })).toEqual(graphRecord); + }); + + it("returns a deeply frozen record", () => { + const record = getSourceById("test-graph-a", { records: [graphRecord] }); + + expect(Object.isFrozen(record)).toBe(true); + expect(Object.isFrozen(record?.locator)).toBe(true); + expect(Object.isFrozen(record?.supported_entities)).toBe(true); + }); +}); + +describe("getActiveComparePoolGraphSources", () => { + it("joins exactly two bindings to their registry records", () => { + const sources = getActiveComparePoolGraphSources({ records, profile }); + + expect(sources).toHaveLength(2); + expect(sources.map((source) => source.source_id)).toEqual(["test-graph-a", "test-graph-b"]); + }); + + it("orders by priority regardless of the order written in the profile", () => { + const reversed = { + ...profile, + sources: [binding("test-graph-b", "b", 2), binding("test-graph-a", "a", 1)], + }; + + const sources = getActiveComparePoolGraphSources({ records, profile: reversed }); + + expect(sources.map((source) => source.priority)).toEqual([1, 2]); + expect(sources.map((source) => source.source_id)).toEqual(["test-graph-a", "test-graph-b"]); + }); + + it("carries the locator and pin the client and deployment check need", () => { + const [first] = getActiveComparePoolGraphSources({ records, profile }); + + expect(first?.record.locator).toEqual({ + kind: "graph_subgraph", + gateway_host: "gateway.thegraph.com", + subgraph_id: "TestSubgraphA", + }); + expect(first?.record.deployment_or_view_id).toBe("QmTestDeploymentA"); + expect(first?.record.source_type).toBe("native_subgraph"); + }); + + it("carries the profile-level token pair and window methodology", () => { + const [first] = getActiveComparePoolGraphSources({ records, profile }); + + expect(first?.profile_id).toBe("test-compare-pools-v1"); + expect(first?.token0).toBe("0x4200000000000000000000000000000000000006"); + expect(first?.token1).toBe("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"); + expect(first?.window_methodology).toBe("completed-utc-days-v1"); + }); + + it("resolves both bindings to one shared query and response contract", () => { + const sources = getActiveComparePoolGraphSources({ records, profile }); + + expect(new Set(sources.map((source) => source.query_id))).toEqual(new Set([QUERY_ID])); + expect(new Set(sources.map((source) => source.schema_contract_id))).toEqual( + new Set([SCHEMA_CONTRACT_ID]), + ); + }); + + it("reads the profile from an injected file URL", () => { + const location = writeJsonFile("compare-pools.json", JSON.stringify(profile)); + + expect(getActiveComparePoolGraphSources({ records, profile: location })).toHaveLength(2); + }); + + it("accepts entity requirements every record covers", () => { + const sources = getActiveComparePoolGraphSources({ + records, + profile, + requiredEntities: ["Pool", "PoolDayData"], + }); + + expect(sources).toHaveLength(2); + }); + + it("returns deeply frozen sources", () => { + const [first] = getActiveComparePoolGraphSources({ records, profile }); + + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first?.record)).toBe(true); + }); +}); +describe("record validation", () => { + it("rejects a missing registry file with the path in the diagnostic", () => { + const missing = new URL("file:///deeptrace-does-not-exist/records.json"); + + expect(() => getSourceById("test-graph-a", { records: missing })).toThrow( + RegistryConfigurationError, + ); + }); + + it("rejects malformed JSON", () => { + const location = writeJsonFile("records.json", "{ not json"); + + expect(() => getSourceById("test-graph-a", { records: location })).toThrow( + /records\.json: is not valid JSON/, + ); + }); + + it("rejects a registry that is not an array", () => { + expect(() => getSourceById("test-graph-a", { records: { sources: [] } })).toThrow( + RegistryConfigurationError, + ); + }); + + it("rejects an empty registry", () => { + expect(() => getSourceById("test-graph-a", { records: [] })).toThrow( + RegistryConfigurationError, + ); + }); + + it("names the failing index and field", () => { + const invalid = [graphRecord, { ...graphRecord, source_id: "test-graph-b", protocol: "" }]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + /records\.json\[1\]\.protocol/, + ); + }); + + it("rejects a gateway host outside the allowlist", () => { + const invalid = [ + { ...graphRecord, locator: { ...graphRecord.locator, gateway_host: "evil.example.com" } }, + ]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + /records\.json\[0\]\.locator\.gateway_host/, + ); + }); + + it("rejects a chain other than Base", () => { + const invalid = [{ ...graphRecord, chain_id: 1 }]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + /records\.json\[0\]\.chain_id/, + ); + }); + + it("rejects an empty subgraph id", () => { + const invalid = [{ ...graphRecord, locator: { ...graphRecord.locator, subgraph_id: "" } }]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + /records\.json\[0\]\.locator\.subgraph_id/, + ); + }); + + it("rejects an empty pinned deployment id", () => { + const invalid = [{ ...graphRecord, deployment_or_view_id: "" }]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + /records\.json\[0\]\.deployment_or_view_id/, + ); + }); + + it("rejects unknown keys rather than silently dropping them", () => { + const invalid = [{ ...graphRecord, gateway_url: "https://gateway.thegraph.com/x" }]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + RegistryConfigurationError, + ); + }); + + it("rejects a Graph locator paired with a Nuthatch source type", () => { + const invalid = [{ ...graphRecord, source_type: "nuthatch_view" }]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + RegistryConfigurationError, + ); + }); + + it("rejects duplicate source ids", () => { + const invalid = [graphRecord, { ...graphRecord, deployment_or_view_id: "QmOther" }]; + + expect(() => getSourceById("test-graph-a", { records: invalid })).toThrow( + /duplicate source_id "test-graph-a"/, + ); + }); + + it("reports every invalid field in one error", () => { + const invalid = [{ ...graphRecord, protocol: "", supported_entities: [] }]; + + try { + getSourceById("test-graph-a", { records: invalid }); + expect.unreachable("expected a registry configuration error"); + } catch (error) { + expect(error).toBeInstanceOf(RegistryConfigurationError); + expect((error as RegistryConfigurationError).issues.length).toBeGreaterThan(1); + } + }); +}); + +function expectProfileRejected(profileOverride: unknown, pattern: RegExp): void { + expect(() => getActiveComparePoolGraphSources({ records, profile: profileOverride })).toThrow( + pattern, + ); +} + +describe("compare-pools profile validation", () => { + it("rejects a missing profile file", () => { + const missing = new URL("file:///deeptrace-does-not-exist/compare-pools.json"); + + expect(() => getActiveComparePoolGraphSources({ records, profile: missing })).toThrow( + RegistryConfigurationError, + ); + }); + + it("rejects malformed profile JSON", () => { + const location = writeJsonFile("compare-pools.json", "{ not json"); + + expectProfileRejected(location, /compare-pools\.json: is not valid JSON/); + }); + + it("rejects fewer than two bindings", () => { + expectProfileRejected( + { ...profile, sources: profile.sources.slice(0, 1) }, + /compare-pools\.json\.sources/, + ); + }); + + it("rejects more than two bindings", () => { + expectProfileRejected( + { ...profile, sources: [...profile.sources, binding("test-graph-a", "d", 3)] }, + /compare-pools\.json\.sources/, + ); + }); + + it("rejects a duplicate source binding", () => { + expectProfileRejected( + { + ...profile, + sources: [binding("test-graph-a", "a", 1), binding("test-graph-a", "b", 2)], + }, + /sources\[1\]\.source_id: "test-graph-a" duplicates sources\[0\]/, + ); + }); + + it("rejects a duplicate pool address", () => { + expectProfileRejected( + { + ...profile, + sources: [binding("test-graph-a", "a", 1), binding("test-graph-b", "a", 2)], + }, + /sources\[1\]\.pool_address: .* duplicates sources\[0\]/, + ); + }); + + it("rejects a duplicate priority, which would make output order unstable", () => { + expectProfileRejected( + { + ...profile, + sources: [binding("test-graph-a", "a", 1), binding("test-graph-b", "b", 1)], + }, + /sources\[1\]\.priority: "1" duplicates sources\[0\]/, + ); + }); + + it("rejects a binding that names an unknown source", () => { + expectProfileRejected( + { + ...profile, + sources: [binding("test-graph-a", "a", 1), binding("absent-source", "c", 2)], + }, + /sources\[1\]\.source_id: "absent-source" is not present in records\.json/, + ); + }); + + it("rejects a binding to an inactive record", () => { + const withInactive = [{ ...graphRecordB, status: "inactive" }, graphRecord]; + + expect(() => getActiveComparePoolGraphSources({ records: withInactive, profile })).toThrow( + /sources\[1\]\.source_id: "test-graph-b" is inactive/, + ); + }); + + it("rejects a binding to a non-Graph record", () => { + expectProfileRejected( + { + ...profile, + sources: [binding("test-graph-a", "a", 1), binding("test-nuthatch", "c", 2)], + }, + /sources\[1\]\.source_id: "test-nuthatch" is not a Graph source/, + ); + }); + + it("rejects bindings that do not share one query template", () => { + expectProfileRejected( + { + ...profile, + sources: [ + binding("test-graph-a", "a", 1), + { ...binding("test-graph-b", "b", 2), query_id: "other-query-v1" }, + ], + }, + /sources\[1\]\.query_id: query_id "other-query-v1" does not match sources\[0\]/, + ); + }); + + it("rejects bindings that do not share one response contract", () => { + expectProfileRejected( + { + ...profile, + sources: [ + binding("test-graph-a", "a", 1), + { ...binding("test-graph-b", "b", 2), schema_contract_id: "other-shape-v1" }, + ], + }, + /sources\[1\]\.schema_contract_id: schema_contract_id "other-shape-v1"/, + ); + }); + + it("rejects a mixed schema tier across the selected set", () => { + const mixedTier = [graphRecord, { ...graphRecordB, source_type: "standardized_subgraph" }]; + + expect(() => getActiveComparePoolGraphSources({ records: mixedTier, profile })).toThrow( + /source_type "standardized_subgraph" does not match sources\[0\] "native_subgraph"/, + ); + }); + + it("rejects a checksummed pool address", () => { + expectProfileRejected( + { + ...profile, + sources: [ + { + ...binding("test-graph-a", "a", 1), + pool_address: "0x6C561B446416E1A00E8E93E221854D6EA4171372", + }, + binding("test-graph-b", "b", 2), + ], + }, + /sources\[0\]\.pool_address/, + ); + }); + + it("rejects a truncated pool address", () => { + expectProfileRejected( + { + ...profile, + sources: [ + { ...binding("test-graph-a", "a", 1), pool_address: "0xabc" }, + binding("test-graph-b", "b", 2), + ], + }, + /sources\[0\]\.pool_address/, + ); + }); + + it("rejects a token pair that names the same token twice", () => { + expectProfileRejected( + { ...profile, token1: profile.token0 }, + /compare-pools\.json\.token1: must differ from token0/, + ); + }); + + it("rejects a chain other than Base", () => { + expectProfileRejected({ ...profile, chain_id: 1 }, /compare-pools\.json\.chain_id/); + }); + + it("rejects a non-positive priority", () => { + expectProfileRejected( + { + ...profile, + sources: [ + { ...binding("test-graph-a", "a", 1), priority: 0 }, + binding("test-graph-b", "b", 2), + ], + }, + /sources\[0\]\.priority/, + ); + }); + + it("rejects unknown profile keys", () => { + expectProfileRejected( + { ...profile, gateway_url: "https://example.com" }, + /compare-pools\.json/, + ); + }); + + it("rejects a record that does not support every entity the query needs", () => { + const withoutDayData = [ + graphRecord, + { ...graphRecordB, supported_entities: ["Pool", "Token"] }, + ]; + + expect(() => + getActiveComparePoolGraphSources({ + records: withoutDayData, + profile, + requiredEntities: ["Pool", "PoolDayData"], + }), + ).toThrow(/sources\[1\]\.source_id: "test-graph-b" does not support PoolDayData/); + }); + + it("reports every profile problem in one error", () => { + try { + getActiveComparePoolGraphSources({ + records, + profile: { + ...profile, + token1: profile.token0, + sources: [binding("test-graph-a", "a", 1), binding("test-graph-a", "b", 1)], + }, + }); + expect.unreachable("expected a registry configuration error"); + } catch (error) { + expect(error).toBeInstanceOf(RegistryConfigurationError); + expect((error as RegistryConfigurationError).issues.length).toBeGreaterThan(2); + } + }); +}); + +describe("shipped registry and profile", () => { + // Exercises the default-path load (no injection) against the committed + // artifacts so a deploy-time regression in records.json or compare-pools.json + // is caught here rather than in the adapter. + it("loads the two locked Graph bindings in priority order", () => { + const sources = getActiveComparePoolGraphSources(); + + expect(sources).toHaveLength(2); + expect(sources.map((source) => source.source_id)).toEqual([ + "messari-uniswap-v3-base-fee030", + "messari-uniswap-v3-base-fee005", + ]); + expect(sources.map((source) => source.priority)).toEqual([1, 2]); + }); + + it("compares two fee tiers of one standardized deployment", () => { + const sources = getActiveComparePoolGraphSources(); + + expect(new Set(sources.map((source) => source.record.source_type))).toEqual( + new Set(["standardized_subgraph"]), + ); + expect(new Set(sources.map((source) => source.record.locator.subgraph_id)).size).toBe(1); + expect(new Set(sources.map((source) => source.pool_address)).size).toBe(2); + expect(sources.every((source) => source.record.schema_version === "4.0.1")).toBe(true); + }); + + it("pins the locked WETH/USDC pair and Base chain", () => { + const [first] = getActiveComparePoolGraphSources(); + + expect(first?.record.chain_id).toBe(8453); + expect(first?.token0).toBe("0x4200000000000000000000000000000000000006"); + expect(first?.token1).toBe("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"); + }); + + it("binds each profile pool to its registry deployment pin", () => { + const sources = getActiveComparePoolGraphSources(); + + for (const source of sources) { + expect(source.record.deployment_or_view_id).toMatch(/^Qm[1-9A-HJ-NP-Za-km-z]{44}$/); + expect(source.pool_address).toMatch(/^0x[0-9a-f]{40}$/); + expect(source.record.locator.kind).toBe("graph_subgraph"); + } + }); + + it("shares one query and schema contract across both bindings", () => { + const sources = getActiveComparePoolGraphSources(); + const queryIds = new Set(sources.map((source) => source.query_id)); + const schemaContractIds = new Set(sources.map((source) => source.schema_contract_id)); + + expect(queryIds.size).toBe(1); + expect(schemaContractIds.size).toBe(1); + expect([...queryIds][0]).toBe("tier-a-dex-pool-metrics-v1"); + expect([...schemaContractIds][0]).toBe("tier-a-dex-pool-metrics-v1"); + }); +}); diff --git a/src/registry/index.ts b/src/registry/index.ts new file mode 100644 index 0000000..6359b7c --- /dev/null +++ b/src/registry/index.ts @@ -0,0 +1,602 @@ +import { readFileSync } from "node:fs"; + +import { z } from "zod"; + +import { ApplicationError } from "../errors/application-error.js"; +import { ErrorCode } from "../errors/codes.js"; +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; +import type { GraphSourceRegistryRecord, SourceCategory, SourceRegistryRecord } from "./types.js"; + +/** + * Repository configuration is an untrusted deploy input: it is validated at + * load time and never asserted into shape. Every failure names the offending + * JSON path so a bad deploy is diagnosable without reading the loader. + */ +export class RegistryConfigurationError extends ApplicationError { + readonly issues: readonly string[]; + + constructor(issues: readonly string[]) { + const detail = [...issues]; + super(ErrorCode.INVALID_CONFIGURATION, `Invalid registry configuration: ${detail.join("; ")}`); + this.name = "RegistryConfigurationError"; + this.issues = detail; + } +} + +/** + * The only gateway host a Graph locator may name. The client builds its URL + * from this validated host, so an unlisted host never reaches the network. + */ +export const GRAPH_GATEWAY_HOST_ALLOWLIST = ["gateway.thegraph.com"] as const; + +const RECORDS_LABEL = "records.json"; +const COMPARE_POOLS_LABEL = "compare-pools.json"; +const COMPARE_LENDING_LABEL = "compare-lending.json"; + +const DEFAULT_RECORDS_URL = new URL("./records.json", import.meta.url); +const DEFAULT_PROFILE_URL = new URL("./compare-pools.json", import.meta.url); +const DEFAULT_LENDING_PROFILE_URL = new URL("./compare-lending.json", import.meta.url); + +/** + * `compare_pools` compares exactly two fee tiers of the locked pair. Enforcing + * the count here makes the selection a load-time invariant. + */ +export const COMPARE_POOLS_SOURCE_COUNT = 2; + +/** + * `compare_lending_markets` fans out to exactly three Base lending protocols. + * A deploy that drops one must fail loudly rather than compare fewer. + */ +export const COMPARE_LENDING_SOURCE_COUNT = 3; + +const nonEmptyStringSchema = z + .string() + .min(1) + .refine((value) => value.trim() === value, "Must not have leading or trailing whitespace"); + +const lowercaseAddressSchema = z + .string() + .regex(/^0x[0-9a-f]{40}$/, "Expected a lowercase 20-byte hexadecimal address"); + +const registryRecordBaseShape = { + source_id: nonEmptyStringSchema, + category: z.enum(["dex", "lending"]), + protocol: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + deployment_or_view_id: nonEmptyStringSchema, + schema_version: nonEmptyStringSchema.nullable(), + methodology_version: nonEmptyStringSchema.nullable(), + supported_entities: z.array(nonEmptyStringSchema).min(1), + status: z.enum(["active", "inactive"]), +}; + +const graphSourceRegistryRecordSchema = z + .object({ + ...registryRecordBaseShape, + source_type: z.enum(["standardized_subgraph", "native_subgraph"]), + locator: z + .object({ + kind: z.literal("graph_subgraph"), + gateway_host: z.enum(GRAPH_GATEWAY_HOST_ALLOWLIST), + subgraph_id: nonEmptyStringSchema, + }) + .strict(), + }) + .strict(); + +const nuthatchSourceRegistryRecordSchema = z + .object({ + ...registryRecordBaseShape, + source_type: z.literal("nuthatch_view"), + locator: z + .object({ + kind: z.literal("nuthatch_view"), + base_url_env: z.literal("NUTHATCH_BASE_URL"), + view_id: nonEmptyStringSchema, + }) + .strict(), + }) + .strict(); + +const sourceRegistryRecordsSchema = z + .array( + z.discriminatedUnion("source_type", [ + graphSourceRegistryRecordSchema, + nuthatchSourceRegistryRecordSchema, + ]), + ) + .min(1); + +const comparePoolBindingSchema = z + .object({ + source_id: nonEmptyStringSchema, + pool_address: lowercaseAddressSchema, + query_id: nonEmptyStringSchema, + schema_contract_id: nonEmptyStringSchema, + priority: z.number().int().positive(), + }) + .strict(); + +const comparePoolsProfileSchema = z + .object({ + profile_id: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + token0: lowercaseAddressSchema, + token1: lowercaseAddressSchema, + window_methodology: nonEmptyStringSchema, + sources: z.array(comparePoolBindingSchema).length(COMPARE_POOLS_SOURCE_COUNT), + }) + .strict(); + +const compareLendingBindingSchema = z + .object({ + source_id: nonEmptyStringSchema, + query_id: nonEmptyStringSchema, + schema_contract_id: nonEmptyStringSchema, + priority: z.number().int().positive(), + }) + .strict(); + +const compareLendingProfileSchema = z + .object({ + profile_id: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + /** The single market asset every selected protocol is compared on. */ + market_token: lowercaseAddressSchema, + sources: z.array(compareLendingBindingSchema).length(COMPARE_LENDING_SOURCE_COUNT), + }) + .strict(); + +/** + * One MVP request profile binding a registry source to the pool, query, and + * response contract it is queried with. Kept out of `SourceRegistryRecord` so + * the reusable source record does not absorb MVP-specific request details. + */ +export type ComparePoolBinding = z.infer; + +export type ComparePoolsProfile = z.infer; + +export type CompareLendingBinding = z.infer; + +export type CompareLendingProfile = z.infer; + +/** A binding joined to its active Graph record. Adapters consume only this. */ +export interface ComparePoolGraphSource { + readonly profile_id: string; + readonly source_id: string; + readonly priority: number; + readonly pool_address: string; + readonly token0: string; + readonly token1: string; + readonly window_methodology: string; + readonly query_id: string; + readonly schema_contract_id: string; + readonly record: GraphSourceRegistryRecord; +} + +/** The lending analogue of {@link ComparePoolGraphSource}. */ +export interface CompareLendingGraphSource { + readonly profile_id: string; + readonly source_id: string; + readonly priority: number; + readonly market_token: string; + readonly query_id: string; + readonly schema_contract_id: string; + readonly record: GraphSourceRegistryRecord; +} + +/** + * JSON locations to read, or already-parsed values supplied by a test. + * Omitting a field loads the file that ships next to this module. + */ +export interface RegistryLoadOptions { + readonly records?: unknown; + readonly profile?: unknown; + /** + * Entities the selected query needs. The tool that owns the query owns the + * real list; passing none skips the coverage check rather than guessing. + */ + readonly requiredEntities?: readonly string[]; +} + +function formatIssuePath(label: string, path: readonly PropertyKey[]): string { + const rendered = path + .map((segment) => + typeof segment === "number" ? `[${String(segment)}]` : `.${String(segment)}`, + ) + .join(""); + return `${label}${rendered}`; +} + +function toIssues(label: string, error: z.ZodError): string[] { + return error.issues.map((issue) => `${formatIssuePath(label, issue.path)}: ${issue.message}`); +} + +function readJson(location: URL, label: string): unknown { + let text: string; + try { + text = readFileSync(location, "utf8"); + } catch (cause) { + throw new RegistryConfigurationError([ + `${label}: could not be read at ${location.href} (${(cause as Error).message})`, + ]); + } + + try { + return JSON.parse(text); + } catch (cause) { + throw new RegistryConfigurationError([ + `${label}: is not valid JSON (${(cause as Error).message})`, + ]); + } +} + +function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return value; + } + for (const nested of Object.values(value)) { + deepFreeze(nested); + } + return Object.freeze(value); +} + +function addOnce(seen: Set, value: string): boolean { + if (seen.has(value)) { + return false; + } + seen.add(value); + return true; +} + +function parseRecords(source: unknown, label: string): readonly SourceRegistryRecord[] { + const parsed = sourceRegistryRecordsSchema.safeParse(source); + if (!parsed.success) { + throw new RegistryConfigurationError(toIssues(label, parsed.error)); + } + + const records: readonly SourceRegistryRecord[] = parsed.data; + + const seen = new Set(); + const duplicates = records + .filter((record) => !addOnce(seen, record.source_id)) + .map((record) => `${label}: duplicate source_id "${record.source_id}"`); + if (duplicates.length > 0) { + throw new RegistryConfigurationError(duplicates); + } + + return deepFreeze(records); +} + +function parseProfile(source: unknown): ComparePoolsProfile { + const parsed = comparePoolsProfileSchema.safeParse(source); + if (!parsed.success) { + throw new RegistryConfigurationError(toIssues(COMPARE_POOLS_LABEL, parsed.error)); + } + + return deepFreeze(parsed.data); +} + +function parseLendingProfile(source: unknown): CompareLendingProfile { + const parsed = compareLendingProfileSchema.safeParse(source); + if (!parsed.success) { + throw new RegistryConfigurationError(toIssues(COMPARE_LENDING_LABEL, parsed.error)); + } + + return deepFreeze(parsed.data); +} + +let cachedRecords: readonly SourceRegistryRecord[] | undefined; +let cachedProfile: ComparePoolsProfile | undefined; +let cachedLendingProfile: CompareLendingProfile | undefined; + +function loadRecords(source: unknown): readonly SourceRegistryRecord[] { + if (source === undefined) { + cachedRecords ??= parseRecords(readJson(DEFAULT_RECORDS_URL, RECORDS_LABEL), RECORDS_LABEL); + return cachedRecords; + } + + if (source instanceof URL) { + return parseRecords(readJson(source, RECORDS_LABEL), RECORDS_LABEL); + } + + return parseRecords(source, RECORDS_LABEL); +} + +function loadProfile(source: unknown): ComparePoolsProfile { + if (source === undefined) { + cachedProfile ??= parseProfile(readJson(DEFAULT_PROFILE_URL, COMPARE_POOLS_LABEL)); + return cachedProfile; + } + + if (source instanceof URL) { + return parseProfile(readJson(source, COMPARE_POOLS_LABEL)); + } + + return parseProfile(source); +} + +function loadLendingProfile(source: unknown): CompareLendingProfile { + if (source === undefined) { + cachedLendingProfile ??= parseLendingProfile( + readJson(DEFAULT_LENDING_PROFILE_URL, COMPARE_LENDING_LABEL), + ); + return cachedLendingProfile; + } + + if (source instanceof URL) { + return parseLendingProfile(readJson(source, COMPARE_LENDING_LABEL)); + } + + return parseLendingProfile(source); +} + +/** + * Drops the memoized default-location loads. Only the shipped files are + * cached; values injected through {@link RegistryLoadOptions} are parsed on + * every call. + */ +export function resetRegistryCache(): void { + cachedRecords = undefined; + cachedProfile = undefined; + cachedLendingProfile = undefined; +} + +/** + * Looks up a registry record of any type or status. Callers that need an + * active Graph binding use {@link getActiveComparePoolGraphSources} instead. + */ +export function getSourceById( + sourceId: string, + options: RegistryLoadOptions = {}, +): SourceRegistryRecord | undefined { + return loadRecords(options.records).find((record) => record.source_id === sourceId); +} + +type BindingPath = (index: number, field: string) => string; + +function bindingPathFor(label: string): BindingPath { + return (index, field) => `${label}.sources[${String(index)}].${field}`; +} + +const bindingPath = bindingPathFor(COMPARE_POOLS_LABEL); +const lendingBindingPath = bindingPathFor(COMPARE_LENDING_LABEL); + +function collectDuplicateIssues>( + bindings: readonly TBinding[], + field: keyof TBinding & string, + path: BindingPath, +): string[] { + const firstIndexByValue = new Map(); + const issues: string[] = []; + + bindings.forEach((binding, index) => { + const value = String(binding[field]); + const firstIndex = firstIndexByValue.get(value); + if (firstIndex === undefined) { + firstIndexByValue.set(value, index); + return; + } + issues.push(`${path(index, field)}: "${value}" duplicates sources[${String(firstIndex)}]`); + }); + + return issues; +} + +/** + * The same query text must serve every selected deployment, so the values that + * define that contract have to be identical across the set. + */ +function collectSharedValueIssues( + values: readonly string[], + path: (index: number) => string, + description: string, +): string[] { + const [expected] = values; + if (expected === undefined) { + return []; + } + + return values.flatMap((value, index) => + value === expected + ? [] + : [`${path(index)}: ${description} "${value}" does not match sources[0] "${expected}"`], + ); +} + +function isGraphRecord(record: SourceRegistryRecord): record is GraphSourceRegistryRecord { + return record.locator.kind === "graph_subgraph"; +} + +/** + * Resolves one binding to the active Graph record it names, appending a named + * issue instead of throwing so a bad deploy reports every problem at once. + */ +function resolveGraphRecord( + sourceId: string, + path: string, + input: { + readonly records: readonly SourceRegistryRecord[]; + readonly category: SourceCategory; + readonly requiredEntities: readonly string[]; + readonly issues: string[]; + }, +): GraphSourceRegistryRecord | undefined { + const record = input.records.find((candidate) => candidate.source_id === sourceId); + + if (record === undefined) { + input.issues.push(`${path}: "${sourceId}" is not present in ${RECORDS_LABEL}`); + return undefined; + } + if (!isGraphRecord(record)) { + input.issues.push(`${path}: "${sourceId}" is not a Graph source`); + return undefined; + } + if (record.category !== input.category) { + input.issues.push( + `${path}: "${sourceId}" is category "${record.category}", expected "${input.category}"`, + ); + return undefined; + } + if (record.status !== "active") { + input.issues.push(`${path}: "${sourceId}" is ${record.status}`); + return undefined; + } + + const missingEntities = input.requiredEntities.filter( + (entity) => !record.supported_entities.includes(entity), + ); + if (missingEntities.length > 0) { + input.issues.push(`${path}: "${sourceId}" does not support ${missingEntities.join(", ")}`); + return undefined; + } + + return record; +} + +/** + * The one query text must serve every selected deployment, so query, response + * contract, and schema tier all have to agree across the joined set. + */ +function collectSharedContractIssues( + bindings: readonly { readonly query_id: string; readonly schema_contract_id: string }[], + joinedSourceTypes: readonly string[], + path: BindingPath, +): string[] { + return [ + ...collectSharedValueIssues( + bindings.map((binding) => binding.query_id), + (index) => path(index, "query_id"), + "query_id", + ), + ...collectSharedValueIssues( + bindings.map((binding) => binding.schema_contract_id), + (index) => path(index, "schema_contract_id"), + "schema_contract_id", + ), + ...collectSharedValueIssues( + joinedSourceTypes, + (index) => path(index, "source_id"), + "source_type", + ), + ]; +} + +/** + * Joins the active `compare_pools` profile to its registry records and returns + * the bindings in ascending priority order. Throws on any invalid local + * configuration: a bad deploy must fail loudly rather than query fewer sources. + */ +export function getActiveComparePoolGraphSources( + options: RegistryLoadOptions = {}, +): readonly ComparePoolGraphSource[] { + const records = loadRecords(options.records); + const profile = loadProfile(options.profile); + const requiredEntities = options.requiredEntities ?? []; + + const issues: string[] = [ + ...(profile.token0 === profile.token1 + ? [`${COMPARE_POOLS_LABEL}.token1: must differ from token0 "${profile.token0}"`] + : []), + ...collectDuplicateIssues(profile.sources, "source_id", bindingPath), + ...collectDuplicateIssues(profile.sources, "pool_address", bindingPath), + ...collectDuplicateIssues(profile.sources, "priority", bindingPath), + ]; + + const joined: ComparePoolGraphSource[] = []; + + profile.sources.forEach((binding, index) => { + const record = resolveGraphRecord(binding.source_id, bindingPath(index, "source_id"), { + records, + category: "dex", + requiredEntities, + issues, + }); + if (record === undefined) { + return; + } + + joined.push({ + profile_id: profile.profile_id, + source_id: binding.source_id, + priority: binding.priority, + pool_address: binding.pool_address, + token0: profile.token0, + token1: profile.token1, + window_methodology: profile.window_methodology, + query_id: binding.query_id, + schema_contract_id: binding.schema_contract_id, + record, + }); + }); + + issues.push( + ...collectSharedContractIssues( + profile.sources, + joined.map((source) => source.record.source_type), + bindingPath, + ), + ); + + if (issues.length > 0) { + throw new RegistryConfigurationError(issues); + } + + return deepFreeze(joined.sort((left, right) => left.priority - right.priority)); +} + +/** + * Lending analogue of {@link getActiveComparePoolGraphSources}: joins the + * `compare_lending_markets` profile to its registry records and returns the + * bindings in ascending priority order. + */ +export function getActiveCompareLendingGraphSources( + options: RegistryLoadOptions = {}, +): readonly CompareLendingGraphSource[] { + const records = loadRecords(options.records); + const profile = loadLendingProfile(options.profile); + const requiredEntities = options.requiredEntities ?? []; + + const issues: string[] = [ + ...collectDuplicateIssues(profile.sources, "source_id", lendingBindingPath), + ...collectDuplicateIssues(profile.sources, "priority", lendingBindingPath), + ]; + + const joined: CompareLendingGraphSource[] = []; + + profile.sources.forEach((binding, index) => { + const record = resolveGraphRecord(binding.source_id, lendingBindingPath(index, "source_id"), { + records, + category: "lending", + requiredEntities, + issues, + }); + if (record === undefined) { + return; + } + + joined.push({ + profile_id: profile.profile_id, + source_id: binding.source_id, + priority: binding.priority, + market_token: profile.market_token, + query_id: binding.query_id, + schema_contract_id: binding.schema_contract_id, + record, + }); + }); + + issues.push( + ...collectSharedContractIssues( + profile.sources, + joined.map((source) => source.record.source_type), + lendingBindingPath, + ), + ); + + if (issues.length > 0) { + throw new RegistryConfigurationError(issues); + } + + return deepFreeze(joined.sort((left, right) => left.priority - right.priority)); +} diff --git a/src/registry/records.json b/src/registry/records.json new file mode 100644 index 0000000..aeca159 --- /dev/null +++ b/src/registry/records.json @@ -0,0 +1,155 @@ +[ + { + "source_id": "messari-uniswap-v3-base-fee030", + "category": "dex", + "protocol": "uniswap-v3", + "chain_id": 8453, + "source_type": "standardized_subgraph", + "deployment_or_view_id": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + "schema_version": "4.0.1", + "methodology_version": "1.0.0", + "supported_entities": ["dexAmmProtocols", "liquidityPool", "liquidityPoolDailySnapshots"], + "status": "active", + "locator": { + "kind": "graph_subgraph", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS" + } + }, + { + "source_id": "messari-uniswap-v3-base-fee005", + "category": "dex", + "protocol": "uniswap-v3", + "chain_id": 8453, + "source_type": "standardized_subgraph", + "deployment_or_view_id": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + "schema_version": "4.0.1", + "methodology_version": "1.0.0", + "supported_entities": ["dexAmmProtocols", "liquidityPool", "liquidityPoolDailySnapshots"], + "status": "active", + "locator": { + "kind": "graph_subgraph", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS" + } + }, + { + "source_id": "nuthatch-pool-swaps", + "category": "dex", + "protocol": "uniswap-v3", + "chain_id": 8453, + "source_type": "nuthatch_view", + "deployment_or_view_id": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "schema_version": null, + "methodology_version": null, + "supported_entities": ["pool_swap_freshness"], + "status": "active", + "locator": { + "kind": "nuthatch_view", + "base_url_env": "NUTHATCH_BASE_URL", + "view_id": "pool_swap_freshness" + } + }, + { + "source_id": "nuthatch-large-swaps", + "category": "dex", + "protocol": "uniswap-v3", + "chain_id": 8453, + "source_type": "nuthatch_view", + "deployment_or_view_id": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "schema_version": null, + "methodology_version": "exact-pool-delta-keyset-v1", + "supported_entities": ["pool_swap_search"], + "status": "active", + "locator": { + "kind": "nuthatch_view", + "base_url_env": "NUTHATCH_BASE_URL", + "view_id": "pool_swap_search" + } + }, + { + "source_id": "messari-aave-v3-base", + "category": "lending", + "protocol": "aave-v3", + "chain_id": 8453, + "source_type": "standardized_subgraph", + "deployment_or_view_id": "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + "schema_version": "3.1.0", + "methodology_version": "1.1.0", + "supported_entities": ["lendingProtocols", "markets", "rates"], + "status": "active", + "locator": { + "kind": "graph_subgraph", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9" + } + }, + { + "source_id": "messari-seamless-base", + "category": "lending", + "protocol": "seamless-protocol", + "chain_id": 8453, + "source_type": "standardized_subgraph", + "deployment_or_view_id": "QmPSmTkJPSKLFn46YdgwMKV5K2c9a3pkWnzDCC4ccCLAXE", + "schema_version": "3.1.0", + "methodology_version": "1.0.0", + "supported_entities": ["lendingProtocols", "markets", "rates"], + "status": "active", + "locator": { + "kind": "graph_subgraph", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "2u4mWUV4xS19ef1MbnxZHWLLMwdPxtVifH46JbonXwXP" + } + }, + { + "source_id": "messari-moonwell-base", + "category": "lending", + "protocol": "moonwell", + "chain_id": 8453, + "source_type": "standardized_subgraph", + "deployment_or_view_id": "QmeE6TgfRmK2iLAgCLBeXuxJQ2VXLFAeHVMTvmnECiFw7y", + "schema_version": "2.0.1", + "methodology_version": "1.0.0", + "supported_entities": ["lendingProtocols", "markets", "rates"], + "status": "active", + "locator": { + "kind": "graph_subgraph", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "33ex1ExmYQtwGVwri1AP3oMFPGSce6YbocBP7fWbsBrg" + } + }, + { + "source_id": "messari-aave-v3-base-wallet", + "category": "lending", + "protocol": "aave-v3", + "chain_id": 8453, + "source_type": "standardized_subgraph", + "deployment_or_view_id": "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + "schema_version": "3.1.0", + "methodology_version": "1.1.0", + "supported_entities": ["accounts", "positions", "positionSnapshots"], + "status": "active", + "locator": { + "kind": "graph_subgraph", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9" + } + }, + { + "source_id": "nuthatch-wallet-activity", + "category": "dex", + "protocol": "uniswap-v3", + "chain_id": 8453, + "source_type": "nuthatch_view", + "deployment_or_view_id": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "schema_version": null, + "methodology_version": "bounded-wallet-swap-keyset-v1", + "supported_entities": ["wallet_swap_activity"], + "status": "active", + "locator": { + "kind": "nuthatch_view", + "base_url_env": "NUTHATCH_BASE_URL", + "view_id": "wallet_swap_activity" + } + } +] diff --git a/src/registry/types.ts b/src/registry/types.ts new file mode 100644 index 0000000..1117d40 --- /dev/null +++ b/src/registry/types.ts @@ -0,0 +1,37 @@ +import type { SourceType } from "../schemas/source-adapter.js"; + +export type SourceCategory = "dex" | "lending"; + +export type RegistrySourceStatus = "active" | "inactive"; + +interface SourceRegistryRecordBase { + source_id: string; + category: SourceCategory; + protocol: string; + chain_id: 8453; + deployment_or_view_id: string; + schema_version: string | null; + methodology_version: string | null; + supported_entities: readonly string[]; + status: RegistrySourceStatus; +} + +export interface GraphSourceRegistryRecord extends SourceRegistryRecordBase { + source_type: Extract; + locator: { + kind: "graph_subgraph"; + gateway_host: "gateway.thegraph.com"; + subgraph_id: string; + }; +} + +export interface NuthatchSourceRegistryRecord extends SourceRegistryRecordBase { + source_type: Extract; + locator: { + kind: "nuthatch_view"; + base_url_env: "NUTHATCH_BASE_URL"; + view_id: string; + }; +} + +export type SourceRegistryRecord = GraphSourceRegistryRecord | NuthatchSourceRegistryRecord; diff --git a/src/schemas/compare-lending-request.ts b/src/schemas/compare-lending-request.ts new file mode 100644 index 0000000..1d9ff45 --- /dev/null +++ b/src/schemas/compare-lending-request.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY, M0_LENDING_RANKING_METRICS } from "../policy/index.js"; +import { BASE_CHAIN_ID } from "./source-adapter.js"; + +/** + * Public `compare_lending_markets` request schema bound to the locked Base + * USDC market asset. Unknown fields and out-of-scope tokens/chains are + * rejected. + */ +export const compareLendingRequestSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + market_token: z.literal(M0_CORE_POLICY.lending.marketToken), + ranked_by: z + .enum(M0_LENDING_RANKING_METRICS) + .default(M0_CORE_POLICY.lending.defaultRankingMetric), + top_n: z + .number() + .int() + .min(1) + .max(M0_CORE_POLICY.lending.topN.maximum) + .default(M0_CORE_POLICY.lending.topN.default), + }) + .strict(); + +export type CompareLendingRequest = z.infer; +export type CompareLendingRequestInput = z.input; diff --git a/src/schemas/compare-lending.ts b/src/schemas/compare-lending.ts new file mode 100644 index 0000000..0b437fe --- /dev/null +++ b/src/schemas/compare-lending.ts @@ -0,0 +1,224 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY, M0_LENDING_RANKING_METRICS } from "../policy/index.js"; +import { resultFreshnessSchema, resultProvenanceSchema } from "./compare-pools.js"; +import { BASE_CHAIN_ID } from "./source-adapter.js"; + +const nonEmptyStringSchema = z + .string() + .min(1) + .refine((value) => value.trim() === value, "Must not have leading or trailing whitespace"); + +const nonNegativeIntegerSchema = z.number().int().nonnegative(); +const positiveIntegerSchema = z.number().int().positive(); +const ethereumAddressSchema = z + .string() + .regex(/^0x[0-9a-f]{40}$/, "Expected a lowercase 20-byte hexadecimal address"); +const financialValueSchema = z + .string() + .regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "Expected a non-negative decimal string") + .nullable(); + +function hasUniqueValues(values: readonly string[]): boolean { + return new Set(values).size === values.length; +} + +const sourceIdsSchema = z + .array(nonEmptyStringSchema) + .min(1) + .refine(hasUniqueValues, "Expected unique source IDs"); + +const lendingTokenSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + address: ethereumAddressSchema, + symbol: nonEmptyStringSchema, + decimals: nonNegativeIntegerSchema.max(255), + }) + .strict(); + +export const lendingMarketRecordSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + protocol: nonEmptyStringSchema, + market_id: ethereumAddressSchema, + market_name: nonEmptyStringSchema.nullable(), + input_token: lendingTokenSchema, + is_active: z.boolean(), + can_borrow_from: z.boolean(), + can_use_as_collateral: z.boolean(), + tvl_usd: financialValueSchema, + total_deposit_balance_usd: financialValueSchema, + total_borrow_balance_usd: financialValueSchema, + lender_variable_rate_percent: financialValueSchema, + borrower_variable_rate_percent: financialValueSchema, + borrower_stable_rate_percent: financialValueSchema, + rank: positiveIntegerSchema.max(M0_CORE_POLICY.lending.topN.maximum), + source_ids: sourceIdsSchema, + }) + .strict(); + +export const lendingCoverageSchema = z + .object({ + requested_sources: z.literal(M0_CORE_POLICY.lending.coverage.expectedSources), + successful_sources: nonNegativeIntegerSchema.max( + M0_CORE_POLICY.lending.coverage.expectedSources, + ), + }) + .strict(); + +export const lendingComparisonDataSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + market_token: lendingTokenSchema, + ranked_by: z.enum(M0_LENDING_RANKING_METRICS), + /** + * Rates are republished exactly as each protocol reports them, so the unit + * is stated rather than converted. + */ + rate_basis: z.literal("percent_apy"), + markets: z + .array(lendingMarketRecordSchema) + .min(M0_CORE_POLICY.lending.coverage.minimumSourcesForPartial) + .max(M0_CORE_POLICY.lending.topN.maximum), + }) + .strict(); + +const responseQualityShape = { + coverage: lendingCoverageSchema, + freshness: z + .array(resultFreshnessSchema) + .length(M0_CORE_POLICY.lending.coverage.expectedSources) + .refine( + (entries) => hasUniqueValues(entries.map(({ source_id }) => source_id)), + "Expected one freshness entry per source", + ), + provenance: z + .array(resultProvenanceSchema) + .length(M0_CORE_POLICY.lending.coverage.expectedSources) + .refine( + (entries) => hasUniqueValues(entries.map(({ source_id }) => source_id)), + "Expected one provenance entry per source", + ), + warnings: z.array(nonEmptyStringSchema), + pagination: z.null(), +}; + +const successfulResponseSchema = (status: "complete" | "partial") => + z + .object({ + status: z.literal(status), + data: lendingComparisonDataSchema, + ...responseQualityShape, + }) + .strict(); + +const failedResponseSchema = z + .object({ + status: z.literal("failed"), + data: z.null(), + ...responseQualityShape, + }) + .strict(); + +export const compareLendingResponseSchema = z + .discriminatedUnion("status", [ + successfulResponseSchema("complete"), + successfulResponseSchema("partial"), + failedResponseSchema, + ]) + .superRefine((response, context) => { + const provenanceIds = new Set(response.provenance.map(({ source_id }) => source_id)); + const freshnessIds = new Set(response.freshness.map(({ source_id }) => source_id)); + const freshnessById = new Map( + response.freshness.map((freshness) => [freshness.source_id, freshness]), + ); + + if ( + provenanceIds.size !== freshnessIds.size || + [...provenanceIds].some((sourceId) => !freshnessIds.has(sourceId)) + ) { + context.addIssue({ + code: "custom", + message: "Every provenance source must have one freshness entry", + path: ["freshness"], + }); + } + + if (response.data === null) { + if (response.coverage.successful_sources !== 0) { + context.addIssue({ + code: "custom", + message: "Failed responses cannot report successful sources", + path: ["coverage", "successful_sources"], + }); + } + return; + } + + // Top-N may return fewer records than answered, never more. + if (response.coverage.successful_sources < response.data.markets.length) { + context.addIssue({ + code: "custom", + message: "Market records cannot outnumber the successful sources", + path: ["coverage", "successful_sources"], + }); + } + + for (const [index, market] of response.data.markets.entries()) { + if (market.rank !== index + 1) { + context.addIssue({ + code: "custom", + message: "Market records must be ordered by ascending rank starting at one", + path: ["data", "markets", index, "rank"], + }); + } + + if ( + market.source_ids.some( + (sourceId) => + !provenanceIds.has(sourceId) || freshnessById.get(sourceId)?.status === "unavailable", + ) + ) { + context.addIssue({ + code: "custom", + message: "Market records must reference observed sources present in provenance", + path: ["data", "markets", index, "source_ids"], + }); + } + } + + const hasDegradedCoverage = + response.coverage.successful_sources < M0_CORE_POLICY.lending.coverage.expectedSources || + response.freshness.some(({ status }) => status !== "fresh"); + + if (response.status === "complete" && hasDegradedCoverage) { + context.addIssue({ + code: "custom", + message: "Complete responses require every source to be successful and fresh", + path: ["status"], + }); + } + + if (response.status === "partial" && !hasDegradedCoverage) { + context.addIssue({ + code: "custom", + message: "Partial responses require missing, stale, or unavailable coverage", + path: ["status"], + }); + } + + if (response.status === "partial" && response.warnings.length === 0) { + context.addIssue({ + code: "custom", + message: "Partial responses require an explicit warning", + path: ["warnings"], + }); + } + }); + +export type LendingToken = z.infer; +export type LendingMarketRecord = z.infer; +export type LendingCoverage = z.infer; +export type LendingComparisonData = z.infer; +export type CompareLendingResponse = z.infer; diff --git a/src/schemas/compare-pools-request.ts b/src/schemas/compare-pools-request.ts new file mode 100644 index 0000000..ad3a6a2 --- /dev/null +++ b/src/schemas/compare-pools-request.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY, M0_RANKING_METRICS, M0_TIME_WINDOWS } from "../policy/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; +import { BASE_CHAIN_ID } from "./source-adapter.js"; + +/** + * Public `compare_pools` request schema bound to the locked Base WETH/USDC pair. + * Unknown fields and out-of-scope pairs/chains are rejected. + */ +export const comparePoolsRequestSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + token0: z.literal(M0_COMPARE_POOLS_SCOPE.token0.address), + token1: z.literal(M0_COMPARE_POOLS_SCOPE.token1.address), + window: z.enum(M0_TIME_WINDOWS).default(M0_CORE_POLICY.defaultWindow), + ranked_by: z.enum(M0_RANKING_METRICS).default(M0_CORE_POLICY.defaultRankingMetric), + top_n: z + .number() + .int() + .min(1) + .max(M0_CORE_POLICY.topN.maximum) + .default(M0_CORE_POLICY.topN.default), + }) + .strict(); + +export type ComparePoolsRequest = z.infer; +export type ComparePoolsRequestInput = z.input; diff --git a/src/schemas/compare-pools.ts b/src/schemas/compare-pools.ts new file mode 100644 index 0000000..eca8039 --- /dev/null +++ b/src/schemas/compare-pools.ts @@ -0,0 +1,424 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY, M0_RANKING_METRICS, M0_TIME_WINDOWS } from "../policy/index.js"; +import { BASE_CHAIN_ID, sourceTypeSchema } from "./source-adapter.js"; + +const nonEmptyStringSchema = z + .string() + .min(1) + .refine((value) => value.trim() === value, "Must not have leading or trailing whitespace"); + +const nonNegativeIntegerSchema = z.number().int().nonnegative(); +const positiveIntegerSchema = z.number().int().positive(); +const ethereumAddressSchema = z + .string() + .regex(/^0x[0-9a-f]{40}$/, "Expected a lowercase 20-byte hexadecimal address"); +const blockHashSchema = z + .string() + .regex(/^0x[0-9a-f]{64}$/, "Expected a lowercase 32-byte hexadecimal hash"); +const financialValueSchema = z + .string() + .regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "Expected a non-negative decimal string") + .nullable(); + +function hasUniqueValues(values: readonly string[]): boolean { + return new Set(values).size === values.length; +} + +const sourceIdsSchema = z + .array(nonEmptyStringSchema) + .min(1) + .refine(hasUniqueValues, "Expected unique source IDs"); + +export const canonicalTokenSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + address: ethereumAddressSchema, + symbol: nonEmptyStringSchema, + decimals: nonNegativeIntegerSchema.max(255), + }) + .strict(); + +function tokensEqual( + left: z.infer, + right: z.infer, +): boolean { + return ( + left.chain_id === right.chain_id && + left.address === right.address && + left.symbol === right.symbol && + left.decimals === right.decimals + ); +} + +export const canonicalPairSchema = z + .tuple([canonicalTokenSchema, canonicalTokenSchema]) + .refine(([tokenA, tokenB]) => tokenA.address !== tokenB.address, "Pair tokens must differ"); + +export const poolComparisonRecordSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + protocol: nonEmptyStringSchema, + pool_address: ethereumAddressSchema, + pair: canonicalPairSchema, + tvl_usd: financialValueSchema, + volume_usd: financialValueSchema, + fees_usd: financialValueSchema, + window: z.enum(M0_TIME_WINDOWS), + rank: positiveIntegerSchema.max(M0_CORE_POLICY.topN.maximum), + source_ids: sourceIdsSchema, + }) + .strict(); + +export const coverageSchema = z + .object({ + requested_deployments: z.literal(M0_CORE_POLICY.coverage.expectedGraphResults), + successful_deployments: nonNegativeIntegerSchema.max( + M0_CORE_POLICY.coverage.expectedGraphResults, + ), + nuthatch_available: z.boolean(), + }) + .strict(); + +const observedFreshnessSchema = z + .object({ + source_id: nonEmptyStringSchema, + status: z.enum(["fresh", "stale"]), + indexed_block: nonNegativeIntegerSchema, + indexed_block_timestamp: nonNegativeIntegerSchema, + indexed_block_hash: blockHashSchema.nullable(), + queried_at: nonNegativeIntegerSchema, + lag_seconds: nonNegativeIntegerSchema, + }) + .strict() + .superRefine((freshness, context) => { + if ( + freshness.queried_at < freshness.indexed_block_timestamp || + freshness.lag_seconds !== freshness.queried_at - freshness.indexed_block_timestamp + ) { + context.addIssue({ + code: "custom", + message: "Lag seconds must equal query time minus indexed block time", + path: ["lag_seconds"], + }); + } + + const isQualityStale = + freshness.lag_seconds > M0_CORE_POLICY.freshness.qualityStaleAfterSeconds; + if ( + (freshness.status === "stale" && !isQualityStale) || + (freshness.status === "fresh" && isQualityStale) + ) { + context.addIssue({ + code: "custom", + message: "Freshness status must match the core quality threshold", + path: ["status"], + }); + } + }); + +const unavailableFreshnessSchema = z + .object({ + source_id: nonEmptyStringSchema, + status: z.literal("unavailable"), + }) + .strict(); + +export const resultFreshnessSchema = z.discriminatedUnion("status", [ + observedFreshnessSchema, + unavailableFreshnessSchema, +]); + +export const resultProvenanceSchema = z + .object({ + source_id: nonEmptyStringSchema, + source_type: sourceTypeSchema, + protocol: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + deployment_or_view_id: nonEmptyStringSchema, + schema_version: nonEmptyStringSchema.nullable(), + methodology_version: nonEmptyStringSchema.nullable(), + query_id: nonEmptyStringSchema, + }) + .strict(); + +export const nuthatchFreshnessFactSchema = z + .object({ + pool_address: ethereumAddressSchema, + recent_swap_count_24h: nonNegativeIntegerSchema, + last_swap_block: nonNegativeIntegerSchema, + last_swap_block_timestamp: nonNegativeIntegerSchema, + source_id: nonEmptyStringSchema, + }) + .strict(); + +export const poolComparisonDataSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + pair: canonicalPairSchema, + window: z.enum(M0_TIME_WINDOWS), + ranked_by: z.enum(M0_RANKING_METRICS), + pools: z + .array(poolComparisonRecordSchema) + .min(M0_CORE_POLICY.coverage.minimumGraphResultsForPartial) + .max(M0_CORE_POLICY.topN.maximum), + nuthatch_freshness_fact: nuthatchFreshnessFactSchema.nullable(), + }) + .strict(); + +const responseQualityShape = { + coverage: coverageSchema, + freshness: z + .array(resultFreshnessSchema) + .length(M0_CORE_POLICY.coverage.expectedGraphResults + 1) + .refine( + (entries) => hasUniqueValues(entries.map(({ source_id }) => source_id)), + "Expected one freshness entry per source", + ), + provenance: z + .array(resultProvenanceSchema) + .length(M0_CORE_POLICY.coverage.expectedGraphResults + 1) + .refine( + (entries) => hasUniqueValues(entries.map(({ source_id }) => source_id)), + "Expected one provenance entry per source", + ), + warnings: z.array(nonEmptyStringSchema), + pagination: z.null(), +}; + +const successfulResponseSchema = (status: "complete" | "partial") => + z + .object({ + status: z.literal(status), + data: poolComparisonDataSchema, + ...responseQualityShape, + }) + .strict(); + +const failedResponseSchema = z + .object({ + status: z.literal("failed"), + data: z.null(), + ...responseQualityShape, + }) + .strict(); + +export const comparePoolsResponseSchema = z + .discriminatedUnion("status", [ + successfulResponseSchema("complete"), + successfulResponseSchema("partial"), + failedResponseSchema, + ]) + .superRefine((response, context) => { + const provenanceIds = new Set(response.provenance.map(({ source_id }) => source_id)); + const freshnessIds = new Set(response.freshness.map(({ source_id }) => source_id)); + const freshnessById = new Map( + response.freshness.map((freshness) => [freshness.source_id, freshness]), + ); + const provenanceById = new Map( + response.provenance.map((provenance) => [provenance.source_id, provenance]), + ); + const referencedIds = [...response.freshness.map(({ source_id }) => source_id)]; + const graphSourceCount = response.provenance.filter( + ({ source_type }) => source_type !== "nuthatch_view", + ).length; + const nuthatchSourceCount = response.provenance.length - graphSourceCount; + + if ( + graphSourceCount !== M0_CORE_POLICY.coverage.expectedGraphResults || + nuthatchSourceCount !== 1 + ) { + context.addIssue({ + code: "custom", + message: `Provenance must contain ${String(M0_CORE_POLICY.coverage.expectedGraphResults)} Graph sources and one Nuthatch source`, + path: ["provenance"], + }); + } + + if ( + provenanceIds.size !== freshnessIds.size || + [...provenanceIds].some((sourceId) => !freshnessIds.has(sourceId)) + ) { + context.addIssue({ + code: "custom", + message: "Every provenance source must have one freshness entry", + path: ["freshness"], + }); + } + + for (const freshness of response.freshness) { + if ( + freshness.status !== "unavailable" && + provenanceById.get(freshness.source_id)?.source_type === "nuthatch_view" && + freshness.indexed_block_hash === null + ) { + context.addIssue({ + code: "custom", + message: "Observed Nuthatch freshness requires an indexed block hash", + path: ["freshness"], + }); + break; + } + } + + if (response.data !== null) { + referencedIds.push( + ...response.data.pools.flatMap(({ source_ids }) => source_ids), + ...(response.data.nuthatch_freshness_fact === null + ? [] + : [response.data.nuthatch_freshness_fact.source_id]), + ); + + // Top-N may return fewer records than answered, never more. + if (response.coverage.successful_deployments < response.data.pools.length) { + context.addIssue({ + code: "custom", + message: "Pool records cannot outnumber the successful deployments", + path: ["coverage", "successful_deployments"], + }); + } + + for (const [index, pool] of response.data.pools.entries()) { + if ( + pool.rank !== index + 1 || + pool.window !== response.data.window || + !tokensEqual(pool.pair[0], response.data.pair[0]) || + !tokensEqual(pool.pair[1], response.data.pair[1]) + ) { + context.addIssue({ + code: "custom", + message: "Pool records must match response pair, window, and rank order", + path: ["data", "pools", index], + }); + } + + if ( + pool.source_ids.some( + (sourceId) => + provenanceById.get(sourceId)?.source_type === "nuthatch_view" || + freshnessById.get(sourceId)?.status === "unavailable", + ) + ) { + context.addIssue({ + code: "custom", + message: "Pool financial records must reference observed Graph sources", + path: ["data", "pools", index, "source_ids"], + }); + } + } + + if ( + response.coverage.nuthatch_available !== + (response.data.nuthatch_freshness_fact !== null) + ) { + context.addIssue({ + code: "custom", + message: "Nuthatch coverage must match freshness fact availability", + path: ["coverage", "nuthatch_available"], + }); + } + + const hasDegradedCoverage = + response.coverage.successful_deployments < M0_CORE_POLICY.coverage.expectedGraphResults || + (M0_CORE_POLICY.coverage.requiresNuthatchForComplete && + !response.coverage.nuthatch_available) || + response.freshness.some(({ status }) => status !== "fresh"); + + if (response.status === "complete" && hasDegradedCoverage) { + context.addIssue({ + code: "custom", + message: "Complete responses require full fresh Graph and Nuthatch coverage", + path: ["status"], + }); + } + + if (response.status === "partial" && !hasDegradedCoverage) { + context.addIssue({ + code: "custom", + message: "Partial responses require missing, stale, or unavailable coverage", + path: ["status"], + }); + } + + if (response.status === "partial" && response.warnings.length === 0) { + context.addIssue({ + code: "custom", + message: "Partial responses require an explicit warning", + path: ["warnings"], + }); + } + } else if (response.coverage.successful_deployments !== 0) { + context.addIssue({ + code: "custom", + message: "Failed responses cannot report successful deployments", + path: ["coverage", "successful_deployments"], + }); + } + + for (const sourceId of referencedIds) { + if (!provenanceIds.has(sourceId)) { + context.addIssue({ + code: "custom", + message: "Referenced source ID must exist in provenance", + path: ["provenance"], + }); + break; + } + } + + const nuthatchIds = new Set( + response.provenance + .filter(({ source_type }) => source_type === "nuthatch_view") + .map(({ source_id }) => source_id), + ); + const nuthatchSourceId = [...nuthatchIds][0]; + const nuthatchFreshness = + nuthatchSourceId === undefined ? undefined : freshnessById.get(nuthatchSourceId); + const nuthatchFact = response.data === null ? null : response.data.nuthatch_freshness_fact; + + if ( + nuthatchFact !== null && + (!nuthatchIds.has(nuthatchFact.source_id) || + freshnessById.get(nuthatchFact.source_id)?.status === "unavailable") + ) { + context.addIssue({ + code: "custom", + message: "Nuthatch fact must reference an observed Nuthatch source", + path: ["data", "nuthatch_freshness_fact", "source_id"], + }); + } + + if ( + response.data !== null && + ((nuthatchFreshness?.status === "fresh" && nuthatchFact === null) || + (nuthatchFreshness?.status === "unavailable" && nuthatchFact !== null)) + ) { + context.addIssue({ + code: "custom", + message: "Nuthatch fact availability must match Nuthatch freshness", + path: ["data", "nuthatch_freshness_fact"], + }); + } + + if ( + response.data === null && + response.coverage.nuthatch_available !== + (nuthatchFreshness !== undefined && nuthatchFreshness.status !== "unavailable") + ) { + context.addIssue({ + code: "custom", + message: "Failed response Nuthatch coverage must match observed freshness", + path: ["coverage", "nuthatch_available"], + }); + } + }); + +export type CanonicalToken = z.infer; +export type CanonicalPair = z.infer; +export type PoolComparisonRecord = z.infer; +export type Coverage = z.infer; +export type ResultFreshness = z.infer; +export type ResultProvenance = z.infer; +export type NuthatchFreshnessFact = z.infer; +export type PoolComparisonData = z.infer; +export type ComparePoolsResponse = z.infer; diff --git a/src/schemas/index.ts b/src/schemas/index.ts new file mode 100644 index 0000000..2c34b4d --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1,81 @@ +export { + compareLendingResponseSchema, + lendingComparisonDataSchema, + lendingCoverageSchema, + lendingMarketRecordSchema, + type CompareLendingResponse, + type LendingComparisonData, + type LendingCoverage, + type LendingMarketRecord, + type LendingToken, +} from "./compare-lending.js"; +export { + compareLendingRequestSchema, + type CompareLendingRequest, + type CompareLendingRequestInput, +} from "./compare-lending-request.js"; +export { + canonicalPairSchema, + canonicalTokenSchema, + comparePoolsResponseSchema, + coverageSchema, + nuthatchFreshnessFactSchema, + poolComparisonDataSchema, + poolComparisonRecordSchema, + resultFreshnessSchema, + resultProvenanceSchema, + type CanonicalPair, + type CanonicalToken, + type ComparePoolsResponse, + type Coverage, + type NuthatchFreshnessFact, + type PoolComparisonData, + type PoolComparisonRecord, + type ResultFreshness, + type ResultProvenance, +} from "./compare-pools.js"; +export { + comparePoolsRequestSchema, + type ComparePoolsRequest, + type ComparePoolsRequestInput, +} from "./compare-pools-request.js"; +export { + findLargeSwapsRequestSchema, + type FindLargeSwapsRequest, + type FindLargeSwapsRequestInput, +} from "./large-swaps-request.js"; +export { + findLargeSwapsResponseSchema, + largeSwapCoverageSchema, + largeSwapPaginationSchema, + largeSwapSearchDataSchema, + lssAssetSchema, + swapEventSchema, + type FindLargeSwapsResponse, + type LargeSwapCoverage, + type LargeSwapPagination, + type LargeSwapSearchData, + type LssAsset, + type SwapEvent, +} from "./large-swaps.js"; +export { + researchWalletRequestSchema, + type ResearchWalletRequest, + type ResearchWalletRequestInput, +} from "./wallet-research-request.js"; +export { + defiPositionSchema, + researchWalletResponseSchema, + walletActivitySchema, + walletAssetLegSchema, + walletResearchCoverageSchema, + walletResearchDataSchema, + walletResearchPaginationSchema, + type DeFiPosition, + type ResearchWalletResponse, + type WalletActivity, + type WalletAssetLeg, + type WalletResearchCoverage, + type WalletResearchData, + type WalletResearchPagination, +} from "./wallet-research.js"; diff --git a/src/schemas/large-swaps-request.ts b/src/schemas/large-swaps-request.ts new file mode 100644 index 0000000..848e748 --- /dev/null +++ b/src/schemas/large-swaps-request.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { LSS_SCOPE } from "../scope/large-swaps.js"; +import { BASE_CHAIN_ID } from "./source-adapter.js"; + +const positiveDecimalStringSchema = z + .string() + .regex( + /^(?:0\.\d*[1-9]\d*|[1-9]\d*(?:\.\d+)?)$/, + "Expected a positive decimal string in human units", + ); + +const opaqueCursorSchema = z + .string() + .min(1) + .max(LSS_SCOPE.cursor.maximumLength) + .refine((value) => value.trim() === value, "Cursor must not contain surrounding whitespace"); + +/** + * Public `find_large_swaps` request bound to the locked Base Uniswap V3 pool. + * Unknown fields and out-of-scope chains, pools, or threshold tokens are rejected. + */ +export const findLargeSwapsRequestSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + pool_address: z.literal(LSS_SCOPE.poolAddress), + threshold_token: z.union([ + z.literal(LSS_SCOPE.tokens.weth.address), + z.literal(LSS_SCOPE.tokens.usdc.address), + ]), + min_amount: positiveDecimalStringSchema, + limit: z.number().int().min(1).max(LSS_SCOPE.limit.maximum).default(LSS_SCOPE.limit.default), + cursor: opaqueCursorSchema.nullable().default(null), + }) + .strict(); + +export type FindLargeSwapsRequest = z.infer; +export type FindLargeSwapsRequestInput = z.input; diff --git a/src/schemas/large-swaps.ts b/src/schemas/large-swaps.ts new file mode 100644 index 0000000..795f869 --- /dev/null +++ b/src/schemas/large-swaps.ts @@ -0,0 +1,243 @@ +import { z } from "zod"; + +import { LSS_SCOPE } from "../scope/large-swaps.js"; +import { resultFreshnessSchema, resultProvenanceSchema } from "./compare-pools.js"; +import { BASE_CHAIN_ID } from "./source-adapter.js"; + +const nonEmptyStringSchema = z + .string() + .min(1) + .refine((value) => value.trim() === value, "Must not have leading or trailing whitespace"); + +const nonNegativeIntegerSchema = z.number().int().nonnegative(); +const transactionHashSchema = z + .string() + .regex(/^0x[0-9a-f]{64}$/, "Expected a lowercase 32-byte hexadecimal hash"); +const positiveDecimalStringSchema = z + .string() + .regex(/^(?:0\.\d*[1-9]\d*|[1-9]\d*(?:\.\d+)?)$/, "Expected a positive decimal string"); +const signedIntegerStringSchema = z + .string() + .regex(/^(?:0|-?[1-9]\d*)$/, "Expected a signed base-10 integer string"); +const opaqueCursorSchema = z + .string() + .min(1) + .max(LSS_SCOPE.cursor.maximumLength) + .refine((value) => value.trim() === value, "Cursor must not contain surrounding whitespace"); + +const wethAssetSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + address: z.literal(LSS_SCOPE.tokens.weth.address), + symbol: z.literal(LSS_SCOPE.tokens.weth.symbol), + decimals: z.literal(LSS_SCOPE.tokens.weth.decimals), + }) + .strict(); + +const usdcAssetSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + address: z.literal(LSS_SCOPE.tokens.usdc.address), + symbol: z.literal(LSS_SCOPE.tokens.usdc.symbol), + decimals: z.literal(LSS_SCOPE.tokens.usdc.decimals), + }) + .strict(); + +export const lssAssetSchema = z.union([wethAssetSchema, usdcAssetSchema]); + +export const swapEventSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + protocol: z.literal(LSS_SCOPE.protocol), + pool: z.literal(LSS_SCOPE.poolAddress), + transaction_hash: transactionHashSchema, + log_index: nonNegativeIntegerSchema, + block_number: nonNegativeIntegerSchema, + timestamp: nonNegativeIntegerSchema, + asset_in: lssAssetSchema, + asset_out: lssAssetSchema, + amount_in: positiveDecimalStringSchema, + amount_out: positiveDecimalStringSchema, + amount_in_raw: signedIntegerStringSchema.optional(), + amount_out_raw: signedIntegerStringSchema.optional(), + usd_notional: z.null(), + source_id: nonEmptyStringSchema, + }) + .strict() + .refine((event) => event.asset_in.address !== event.asset_out.address, { + message: "Swap input and output assets must differ", + path: ["asset_out"], + }); + +function eventIdentity(event: z.infer): string { + return `${event.chain_id}:${event.transaction_hash}:${String(event.log_index)}`; +} + +const swapEventsSchema = z + .array(swapEventSchema) + .max(LSS_SCOPE.limit.maximum) + .refine( + (events) => new Set(events.map(eventIdentity)).size === events.length, + "Expected unique swap event identities", + ); + +export const largeSwapSearchDataSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + pool_address: z.literal(LSS_SCOPE.poolAddress), + threshold_token: z.union([ + z.literal(LSS_SCOPE.tokens.weth.address), + z.literal(LSS_SCOPE.tokens.usdc.address), + ]), + min_amount: positiveDecimalStringSchema, + swaps: swapEventsSchema, + }) + .strict(); + +export const largeSwapCoverageSchema = z + .object({ + requested_sources: z.literal(1), + successful_sources: z.union([z.literal(0), z.literal(1)]), + }) + .strict(); + +export const largeSwapPaginationSchema = z + .object({ + limit: z.number().int().min(1).max(LSS_SCOPE.limit.maximum), + returned: z.number().int().min(0).max(LSS_SCOPE.limit.maximum), + has_more: z.boolean(), + next_cursor: opaqueCursorSchema.nullable(), + }) + .strict() + .superRefine((pagination, context) => { + if (pagination.returned > pagination.limit) { + context.addIssue({ + code: "custom", + message: "Returned count cannot exceed the page limit", + path: ["returned"], + }); + } + + if (pagination.has_more !== (pagination.next_cursor !== null)) { + context.addIssue({ + code: "custom", + message: "A next cursor is required exactly when more results are available", + path: ["next_cursor"], + }); + } + }); + +const responseQualityShape = { + coverage: largeSwapCoverageSchema, + freshness: z.array(resultFreshnessSchema).length(1), + provenance: z.array(resultProvenanceSchema).length(1), + warnings: z.array(nonEmptyStringSchema), + pagination: largeSwapPaginationSchema, +}; + +const completeResponseSchema = z + .object({ + status: z.literal("complete"), + data: largeSwapSearchDataSchema, + ...responseQualityShape, + }) + .strict(); + +const failedResponseSchema = z + .object({ + status: z.literal("failed"), + data: z.null(), + ...responseQualityShape, + }) + .strict(); + +export const findLargeSwapsResponseSchema = z + .discriminatedUnion("status", [completeResponseSchema, failedResponseSchema]) + .superRefine((response, context) => { + const freshness = response.freshness[0]; + const provenance = response.provenance[0]; + + if ( + freshness === undefined || + provenance === undefined || + freshness.source_id !== provenance.source_id + ) { + context.addIssue({ + code: "custom", + message: "Freshness and provenance must describe the same source", + path: ["freshness"], + }); + return; + } + + if (provenance.source_type !== "nuthatch_view") { + context.addIssue({ + code: "custom", + message: "Large Swap Search requires Nuthatch provenance", + path: ["provenance"], + }); + } + + if (freshness.status !== "unavailable" && freshness.indexed_block_hash === null) { + context.addIssue({ + code: "custom", + message: "Observed Nuthatch freshness requires an indexed block hash", + path: ["freshness"], + }); + } + + if (response.status === "complete") { + if (response.coverage.successful_sources !== 1 || freshness.status !== "fresh") { + context.addIssue({ + code: "custom", + message: "Complete responses require one fresh successful source", + path: ["coverage"], + }); + } + + if (response.pagination.returned !== response.data.swaps.length) { + context.addIssue({ + code: "custom", + message: "Returned count must equal the number of swaps", + path: ["pagination", "returned"], + }); + } + + if (response.data.swaps.some(({ source_id }) => source_id !== provenance.source_id)) { + context.addIssue({ + code: "custom", + message: "Every swap must reference the response provenance source", + path: ["data", "swaps"], + }); + } + } else { + if ( + response.coverage.successful_sources !== 0 || + freshness.status !== "unavailable" || + response.pagination.returned !== 0 || + response.pagination.has_more || + response.pagination.next_cursor !== null + ) { + context.addIssue({ + code: "custom", + message: "Failed responses cannot report source success or page results", + path: ["status"], + }); + } + + if (response.warnings.length === 0) { + context.addIssue({ + code: "custom", + message: "Failed responses require an explicit warning", + path: ["warnings"], + }); + } + } + }); + +export type LssAsset = z.infer; +export type SwapEvent = z.infer; +export type LargeSwapSearchData = z.infer; +export type LargeSwapCoverage = z.infer; +export type LargeSwapPagination = z.infer; +export type FindLargeSwapsResponse = z.infer; diff --git a/src/schemas/source-adapter.ts b/src/schemas/source-adapter.ts new file mode 100644 index 0000000..a4b104a --- /dev/null +++ b/src/schemas/source-adapter.ts @@ -0,0 +1,208 @@ +import { z } from "zod"; + +export const BASE_CHAIN_ID = 8453 as const; + +const nonEmptyStringSchema = z + .string() + .min(1) + .refine((value) => value.trim() === value, "Must not have leading or trailing whitespace"); + +const nonNegativeIntegerSchema = z.number().int().nonnegative(); +const nonNegativeNumberSchema = z.number().nonnegative(); + +const ethereumAddressSchema = z + .string() + .regex(/^0x[0-9a-f]{40}$/, "Expected a lowercase 20-byte hexadecimal address"); + +const blockHashSchema = z + .string() + .regex(/^0x[0-9a-f]{64}$/, "Expected a lowercase 32-byte hexadecimal hash"); + +const nonNegativeDecimalStringSchema = z + .string() + .regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/, "Expected a non-negative decimal string"); + +const financialValueSchema = nonNegativeDecimalStringSchema.nullable(); + +export const sourceStatusSchema = z.enum(["ok", "timeout", "error", "unsupported", "stale"]); +export const failureSourceStatusSchema = z.enum(["timeout", "error", "unsupported", "stale"]); +export const sourceTypeSchema = z.enum([ + "standardized_subgraph", + "native_subgraph", + "nuthatch_view", +]); + +export const sourceFreshnessSchema = z + .object({ + indexed_block: nonNegativeIntegerSchema, + indexed_block_timestamp: nonNegativeIntegerSchema, + indexed_block_hash: blockHashSchema.optional(), + queried_at: nonNegativeIntegerSchema, + has_indexing_errors: z.boolean().optional(), + }) + .strict(); + +const nuthatchSourceFreshnessSchema = sourceFreshnessSchema.extend({ + indexed_block_hash: blockHashSchema, +}); + +export const sourceProvenanceSchema = z + .object({ + deployment_or_view_id: nonEmptyStringSchema, + schema_version: nonEmptyStringSchema.nullable(), + methodology_version: nonEmptyStringSchema.nullable(), + query_id: nonEmptyStringSchema, + }) + .strict(); + +export const tokenMetadataSchema = z + .object({ + address: ethereumAddressSchema, + symbol: nonEmptyStringSchema, + decimals: nonNegativeIntegerSchema, + }) + .strict(); + +export const poolSourceDataSchema = z + .object({ + pool_address: ethereumAddressSchema, + token0: tokenMetadataSchema, + token1: tokenMetadataSchema, + fee_tier_bps: nonNegativeIntegerSchema.nullable(), + tvl_usd: financialValueSchema, + volume_usd_24h: financialValueSchema, + volume_usd_7d: financialValueSchema, + fees_usd_24h: financialValueSchema, + fees_usd_7d: financialValueSchema, + }) + .strict(); + +export const nuthatchFreshnessDataSchema = z + .object({ + pool_address: ethereumAddressSchema, + recent_swap_count_24h: nonNegativeIntegerSchema, + last_swap_block: nonNegativeIntegerSchema, + last_swap_block_timestamp: nonNegativeIntegerSchema, + last_swap_block_hash: blockHashSchema, + last_swap_tx_hash: blockHashSchema, + last_swap_log_index: nonNegativeIntegerSchema, + }) + .strict(); + +export type SourceStatus = z.infer; +export type FailureSourceStatus = z.infer; +export type SourceType = z.infer; +export type SourceFreshness = z.infer; +export type SourceProvenance = z.infer; +export type TokenMetadata = z.infer; +export type PoolSourceData = z.infer; +export type NuthatchFreshnessData = z.infer; + +interface SourceResultBase { + source_id: string; + source_type: SourceType; + protocol: string; + chain_id: typeof BASE_CHAIN_ID; + provenance: SourceProvenance; + warnings: string[]; + latency_ms: number; +} + +export interface SuccessfulSourceResult extends SourceResultBase { + status: "ok"; + data: T; + freshness: SourceFreshness; +} + +export interface FailedSourceResult extends SourceResultBase { + status: FailureSourceStatus; + data: null; + freshness: SourceFreshness | null; +} + +export type SourceResult = SuccessfulSourceResult | FailedSourceResult; + +const sourceResultBaseShape = { + source_id: nonEmptyStringSchema, + protocol: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + provenance: sourceProvenanceSchema, + warnings: z.array(nonEmptyStringSchema), + latency_ms: nonNegativeNumberSchema, +}; + +function createSourceResultSchema< + TSourceType extends z.ZodType, + TData extends z.ZodType, + TFreshness extends z.ZodType, +>(sourceType: TSourceType, data: TData, freshness: TFreshness) { + const successfulResultSchema = z + .object({ + ...sourceResultBaseShape, + source_type: sourceType, + status: z.literal("ok"), + data, + freshness, + }) + .strict(); + + const failedResultSchema = z + .object({ + ...sourceResultBaseShape, + source_type: sourceType, + status: failureSourceStatusSchema, + data: z.null(), + freshness: freshness.nullable(), + }) + .strict(); + + return z.discriminatedUnion("status", [successfulResultSchema, failedResultSchema]); +} + +export const poolSourceResultSchema = createSourceResultSchema( + z.enum(["standardized_subgraph", "native_subgraph"]), + poolSourceDataSchema, + sourceFreshnessSchema, +); + +export const nuthatchSourceResultSchema = createSourceResultSchema( + z.literal("nuthatch_view"), + nuthatchFreshnessDataSchema, + nuthatchSourceFreshnessSchema, +); + +export type PoolSourceResult = z.infer; +export type NuthatchSourceResult = z.infer; + +/** + * One lending market of a single input token on one protocol. + * + * Rates are percent APY exactly as the source reports them; a rate the source + * does not publish stays `null` rather than being defaulted to zero, because + * "no stable rate offered" and "a stable rate of 0%" are different facts. + */ +export const lendingMarketSourceDataSchema = z + .object({ + market_id: ethereumAddressSchema, + market_name: nonEmptyStringSchema.nullable(), + input_token: tokenMetadataSchema, + is_active: z.boolean(), + can_borrow_from: z.boolean(), + can_use_as_collateral: z.boolean(), + tvl_usd: financialValueSchema, + total_deposit_balance_usd: financialValueSchema, + total_borrow_balance_usd: financialValueSchema, + lender_variable_rate_percent: financialValueSchema, + borrower_variable_rate_percent: financialValueSchema, + borrower_stable_rate_percent: financialValueSchema, + }) + .strict(); + +export const lendingMarketSourceResultSchema = createSourceResultSchema( + z.enum(["standardized_subgraph", "native_subgraph"]), + lendingMarketSourceDataSchema, + sourceFreshnessSchema, +); + +export type LendingMarketSourceData = z.infer; +export type LendingMarketSourceResult = z.infer; diff --git a/src/schemas/wallet-research-request.ts b/src/schemas/wallet-research-request.ts new file mode 100644 index 0000000..7451e9f --- /dev/null +++ b/src/schemas/wallet-research-request.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +import { + WALLET_RESEARCH_SCOPE, + WALLET_RESEARCH_SECTIONS, + WALLET_RESEARCH_WINDOWS, +} from "../scope/wallet-research.js"; +import { BASE_CHAIN_ID } from "./source-adapter.js"; + +const walletAddressSchema = z + .string() + .regex(/^0x[0-9a-f]{40}$/, "Expected a lowercase public Ethereum address"); + +const cursorSchema = z + .string() + .min(1) + .max(WALLET_RESEARCH_SCOPE.cursor.maximumLength) + .refine((value) => value.trim() === value, "Cursor must not contain surrounding whitespace"); + +export const researchWalletRequestSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + address: walletAddressSchema, + sections: z + .array(z.enum(WALLET_RESEARCH_SECTIONS)) + .min(1) + .max(WALLET_RESEARCH_SECTIONS.length) + .refine((sections) => new Set(sections).size === sections.length, "Sections must be unique") + .default([...WALLET_RESEARCH_SECTIONS]), + window: z.enum(WALLET_RESEARCH_WINDOWS).default("24h"), + limit: z + .number() + .int() + .min(1) + .max(WALLET_RESEARCH_SCOPE.limit.maximum) + .default(WALLET_RESEARCH_SCOPE.limit.default), + cursor: cursorSchema.nullable().default(null), + }) + .strict(); + +export type ResearchWalletRequest = z.infer; +export type ResearchWalletRequestInput = z.input; diff --git a/src/schemas/wallet-research.ts b/src/schemas/wallet-research.ts new file mode 100644 index 0000000..6712d78 --- /dev/null +++ b/src/schemas/wallet-research.ts @@ -0,0 +1,227 @@ +import { z } from "zod"; + +import { + WALLET_RESEARCH_SCOPE, + WALLET_RESEARCH_SECTIONS, + WALLET_RESEARCH_WINDOWS, +} from "../scope/wallet-research.js"; +import { resultFreshnessSchema, resultProvenanceSchema } from "./compare-pools.js"; +import { BASE_CHAIN_ID } from "./source-adapter.js"; + +const nonEmptyStringSchema = z + .string() + .min(1) + .refine((value) => value.trim() === value, "Must not have surrounding whitespace"); +const addressSchema = z.string().regex(/^0x[0-9a-f]{40}$/); +const hashSchema = z.string().regex(/^0x[0-9a-f]{64}$/); +const nonNegativeIntegerSchema = z.number().int().nonnegative(); +const nonNegativeDecimalSchema = z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/); +const rawAmountSchema = z.string().regex(/^(?:0|[1-9]\d*)$/); +const sourceIdsSchema = z + .array(nonEmptyStringSchema) + .min(1) + .refine((values) => new Set(values).size === values.length, "Source IDs must be unique"); + +export const walletAssetLegSchema = z + .object({ + token_address: addressSchema, + symbol: nonEmptyStringSchema, + decimals: nonNegativeIntegerSchema.max(255), + role: z.enum(["swap_in", "swap_out", "supplied", "collateral", "borrowed"]), + raw_amount: rawAmountSchema, + normalized_amount: nonNegativeDecimalSchema, + usd_price: nonNegativeDecimalSchema.nullable(), + usd_value: nonNegativeDecimalSchema.nullable(), + }) + .strict(); + +export const walletActivitySchema = z + .object({ + id: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + wallet_address: addressSchema, + protocol: z.literal(WALLET_RESEARCH_SCOPE.nuthatch.protocol), + activity_type: z.enum(["swap_sent", "swap_received"]), + transaction_hash: hashSchema, + log_index: nonNegativeIntegerSchema, + block_number: nonNegativeIntegerSchema, + timestamp: nonNegativeIntegerSchema, + counterparty: addressSchema.nullable(), + assets: z.array(walletAssetLegSchema).length(2), + source_id: z.literal(WALLET_RESEARCH_SCOPE.nuthatch.sourceId), + }) + .strict(); + +export const defiPositionSchema = z + .object({ + id: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + protocol: z.literal(WALLET_RESEARCH_SCOPE.graph.protocol), + position_type: z.enum(["lending_supply", "lending_collateral", "lending_borrow"]), + container: z + .object({ + address: addressSchema, + name: nonEmptyStringSchema.nullable(), + }) + .strict(), + assets: z.array(walletAssetLegSchema).length(1), + valuation: z + .object({ + usd_value: nonNegativeDecimalSchema.nullable(), + }) + .strict(), + observed_at: z + .object({ + block_number: nonNegativeIntegerSchema, + timestamp: nonNegativeIntegerSchema, + }) + .strict(), + source_ids: sourceIdsSchema, + }) + .strict(); + +const observedAssetSchema = z + .object({ + token_address: addressSchema, + symbol: nonEmptyStringSchema, + decimals: nonNegativeIntegerSchema.max(255), + source_ids: sourceIdsSchema, + }) + .strict(); + +const counterpartySchema = z + .object({ + address: addressSchema, + interaction_count: z.number().int().positive(), + source_ids: sourceIdsSchema, + }) + .strict(); + +const protocolUsageSchema = z + .object({ + protocol: nonEmptyStringSchema, + activity_count: nonNegativeIntegerSchema, + position_count: nonNegativeIntegerSchema, + source_ids: sourceIdsSchema, + }) + .strict(); + +const observableFlowSchema = z + .object({ + direction: z.enum(["inflow", "outflow"]), + token_address: addressSchema, + symbol: nonEmptyStringSchema, + raw_amount: rawAmountSchema, + normalized_amount: nonNegativeDecimalSchema, + source_ids: sourceIdsSchema, + }) + .strict(); + +export const walletResearchDataSchema = z + .object({ + chain_id: z.literal(BASE_CHAIN_ID), + wallet_address: addressSchema, + window: z.enum(WALLET_RESEARCH_WINDOWS), + requested_sections: z.array(z.enum(WALLET_RESEARCH_SECTIONS)).min(1), + activity: z.array(walletActivitySchema).max(WALLET_RESEARCH_SCOPE.limit.maximum), + counterparties: z.array(counterpartySchema), + protocol_usage: z.array(protocolUsageSchema), + observable_flows: z.array(observableFlowSchema), + observed_assets: z.array(observedAssetSchema), + positions: z.array(defiPositionSchema).max(WALLET_RESEARCH_SCOPE.limit.maximum), + }) + .strict(); + +const sectionCoverageSchema = z + .object({ + section: z.enum(WALLET_RESEARCH_SECTIONS), + requested: z.boolean(), + status: z.enum(["complete", "partial", "unavailable", "unsupported", "not_requested"]), + source_ids: z.array(nonEmptyStringSchema), + }) + .strict(); + +export const walletResearchCoverageSchema = z + .object({ + requested_sources: z.literal(2), + successful_sources: z.number().int().min(0).max(2), + sections: z.array(sectionCoverageSchema).length(WALLET_RESEARCH_SECTIONS.length), + }) + .strict(); + +export const walletResearchPaginationSchema = z + .object({ + limit: z.number().int().min(1).max(WALLET_RESEARCH_SCOPE.limit.maximum), + returned: z.number().int().min(0).max(WALLET_RESEARCH_SCOPE.limit.maximum), + has_more: z.boolean(), + next_cursor: z.string().min(1).max(WALLET_RESEARCH_SCOPE.cursor.maximumLength).nullable(), + }) + .strict() + .refine((page) => page.has_more === (page.next_cursor !== null), { + message: "next_cursor must be present exactly when has_more is true", + path: ["next_cursor"], + }); + +const qualityShape = { + coverage: walletResearchCoverageSchema, + freshness: z.array(resultFreshnessSchema).length(2), + provenance: z.array(resultProvenanceSchema).length(2), + warnings: z.array(nonEmptyStringSchema), + pagination: walletResearchPaginationSchema, +}; + +export const researchWalletResponseSchema = z + .discriminatedUnion("status", [ + z + .object({ + status: z.literal("complete"), + data: walletResearchDataSchema, + ...qualityShape, + }) + .strict(), + z + .object({ + status: z.literal("partial"), + data: walletResearchDataSchema, + ...qualityShape, + }) + .strict(), + z + .object({ + status: z.literal("failed"), + data: z.null(), + ...qualityShape, + }) + .strict(), + ]) + .superRefine((response, context) => { + if (response.pagination.returned !== (response.data?.activity.length ?? 0)) { + context.addIssue({ + code: "custom", + message: "Pagination returned count must equal activity length", + path: ["pagination", "returned"], + }); + } + if (response.status === "complete" && response.coverage.successful_sources !== 2) { + context.addIssue({ + code: "custom", + message: "Complete wallet research requires two successful sources", + path: ["status"], + }); + } + if (response.status === "failed" && response.coverage.successful_sources !== 0) { + context.addIssue({ + code: "custom", + message: "Failed wallet research cannot report successful sources", + path: ["coverage", "successful_sources"], + }); + } + }); + +export type WalletAssetLeg = z.infer; +export type WalletActivity = z.infer; +export type DeFiPosition = z.infer; +export type WalletResearchData = z.infer; +export type WalletResearchCoverage = z.infer; +export type WalletResearchPagination = z.infer; +export type ResearchWalletResponse = z.infer; diff --git a/src/scope/compare-lending.ts b/src/scope/compare-lending.ts new file mode 100644 index 0000000..9d34421 --- /dev/null +++ b/src/scope/compare-lending.ts @@ -0,0 +1,39 @@ +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +/** + * Locked MVP-0 compare_lending_markets allowlist. + * + * Values mirror `src/registry/compare-lending.json` and `records.json`. + * Source order is the profile priority order and is what settlement uses to + * order freshness, provenance, and warnings deterministically. + */ +export const M0_COMPARE_LENDING_SCOPE = { + chainId: BASE_CHAIN_ID, + marketToken: { + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + symbol: "USDC", + decimals: 6, + }, + sources: [ + { + source_id: "messari-aave-v3-base", + protocol: "aave-v3", + deployment_or_view_id: "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + query_id: "tier-a-lending-market-metrics-v1", + }, + { + source_id: "messari-seamless-base", + protocol: "seamless-protocol", + deployment_or_view_id: "QmPSmTkJPSKLFn46YdgwMKV5K2c9a3pkWnzDCC4ccCLAXE", + query_id: "tier-a-lending-market-metrics-v1", + }, + { + source_id: "messari-moonwell-base", + protocol: "moonwell", + deployment_or_view_id: "QmeE6TgfRmK2iLAgCLBeXuxJQ2VXLFAeHVMTvmnECiFw7y", + query_id: "tier-a-lending-market-metrics-v1", + }, + ], +} as const; + +export type M0CompareLendingScope = typeof M0_COMPARE_LENDING_SCOPE; diff --git a/src/scope/compare-pools.ts b/src/scope/compare-pools.ts new file mode 100644 index 0000000..95991b8 --- /dev/null +++ b/src/scope/compare-pools.ts @@ -0,0 +1,43 @@ +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +/** + * Locked compare_pools allowlist for Graph-side binding. + * + * Both Graph sources read the same Messari `dex-amm` standardized deployment; + * they differ only in which WETH/USDC fee tier they bind. Values mirror + * `src/registry/compare-pools.json` and `records.json`. The Nuthatch source ID + * resolves the active registry-pinned freshness view. + */ +export const M0_COMPARE_POOLS_SCOPE = { + chainId: BASE_CHAIN_ID, + token0: { + address: "0x4200000000000000000000000000000000000006", + symbol: "WETH", + decimals: 18, + }, + token1: { + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + symbol: "USDC", + decimals: 6, + }, + graphSources: [ + { + source_id: "messari-uniswap-v3-base-fee030", + protocol: "uniswap-v3", + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + query_id: "tier-a-dex-pool-metrics-v1", + deployment_or_view_id: "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + }, + { + source_id: "messari-uniswap-v3-base-fee005", + protocol: "uniswap-v3", + pool_address: "0xd0b53d9277642d899df5c87a3966a349a798f224", + query_id: "tier-a-dex-pool-metrics-v1", + deployment_or_view_id: "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + }, + ], + /** Active registry-pinned source for the Nuthatch freshness fact. */ + nuthatchSourceId: "nuthatch-pool-swaps", +} as const; + +export type M0ComparePoolsScope = typeof M0_COMPARE_POOLS_SCOPE; diff --git a/src/scope/index.ts b/src/scope/index.ts new file mode 100644 index 0000000..0989ff4 --- /dev/null +++ b/src/scope/index.ts @@ -0,0 +1,9 @@ +export { M0_COMPARE_LENDING_SCOPE, type M0CompareLendingScope } from "./compare-lending.js"; +export { M0_COMPARE_POOLS_SCOPE, type M0ComparePoolsScope } from "./compare-pools.js"; +export { LSS_SCOPE, type LssScope } from "./large-swaps.js"; +export { + WALLET_RESEARCH_SCOPE, + WALLET_RESEARCH_SECTIONS, + WALLET_RESEARCH_WINDOWS, + type WalletResearchSection, +} from "./wallet-research.js"; diff --git a/src/scope/large-swaps.ts b/src/scope/large-swaps.ts new file mode 100644 index 0000000..c8dbd61 --- /dev/null +++ b/src/scope/large-swaps.ts @@ -0,0 +1,44 @@ +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +/** + * Locked Large Swap Search v1 allowlist. + * + * Live Nuthatch bindings resolve through the registry record named below. + */ +export const LSS_SCOPE = { + chainId: BASE_CHAIN_ID, + protocol: "uniswap-v3", + poolAddress: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + source: { + sourceId: "nuthatch-large-swaps", + viewId: "pool_swap_search", + queryId: "nuthatch-pool-swap-search-v1", + methodologyVersion: "exact-pool-delta-keyset-v1", + }, + tokens: { + weth: { + address: "0x4200000000000000000000000000000000000006", + symbol: "WETH", + decimals: 18, + }, + usdc: { + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + symbol: "USDC", + decimals: 6, + }, + }, + limit: { + default: 25, + maximum: 100, + }, + cursor: { + version: 1, + maximumLength: 2_048, + }, + scan: { + batchSize: 256, + maximumRows: 50_000, + }, +} as const; + +export type LssScope = typeof LSS_SCOPE; diff --git a/src/scope/wallet-research.ts b/src/scope/wallet-research.ts new file mode 100644 index 0000000..f90523b --- /dev/null +++ b/src/scope/wallet-research.ts @@ -0,0 +1,48 @@ +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; +import { LSS_SCOPE } from "./large-swaps.js"; + +export const WALLET_RESEARCH_SECTIONS = [ + "activity", + "counterparties", + "protocol_usage", + "observable_flows", + "observed_assets", + "positions", +] as const; + +export type WalletResearchSection = (typeof WALLET_RESEARCH_SECTIONS)[number]; + +export const WALLET_RESEARCH_WINDOWS = ["24h", "7d"] as const; + +export const WALLET_RESEARCH_SCOPE = { + chainId: BASE_CHAIN_ID, + sections: WALLET_RESEARCH_SECTIONS, + windows: WALLET_RESEARCH_WINDOWS, + limit: { + default: 25, + maximum: 50, + }, + cursor: { + version: 1, + maximumLength: 2_048, + }, + scan: { + maximumRows: 5_000, + }, + graph: { + sourceId: "messari-aave-v3-base-wallet", + protocol: "aave-v3", + subgraphId: "D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9", + deploymentId: "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + queryId: "messari-lending-wallet-positions-v1", + }, + nuthatch: { + sourceId: "nuthatch-wallet-activity", + protocol: LSS_SCOPE.protocol, + viewId: "wallet_swap_activity", + queryId: "nuthatch-wallet-swap-activity-v1", + methodologyVersion: "bounded-wallet-swap-keyset-v1", + }, + poolAddress: LSS_SCOPE.poolAddress, + tokens: LSS_SCOPE.tokens, +} as const; diff --git a/src/sources/graph/adapter.ts b/src/sources/graph/adapter.ts new file mode 100644 index 0000000..d0c7485 --- /dev/null +++ b/src/sources/graph/adapter.ts @@ -0,0 +1,501 @@ +import { GATEWAY_DEFAULTS, GATEWAY_MAXIMUMS } from "../../config/defaults.js"; +import type { ComparePoolGraphSource } from "../../registry/index.js"; +import { + BASE_CHAIN_ID, + type PoolSourceData, + type PoolSourceResult, + type SourceFreshness, + type SourceProvenance, + type TokenMetadata, +} from "../../schemas/source-adapter.js"; + +import { aggregateDailySnapshots, type DailySnapshot } from "./aggregation.js"; +import { assertDeployment, deploymentMismatchWarning } from "./deployment-assertion.js"; +import { + TIER_A_METRICS_QUERY, + TIER_A_METRICS_QUERY_ID, + TIER_B_METRICS_QUERY, + TIER_B_METRICS_QUERY_ID, +} from "./queries.js"; +import { + isRecord, + NON_NEGATIVE_DECIMAL, + NON_NEGATIVE_INT_STRING, + normalizeAddress, + parseFinancial, + parseFreshness, + parseToken, + resolveGraphApiKey, +} from "./response.js"; +import { postGraphGateway, type GraphTransportResult } from "./transport.js"; + +const SECONDS_PER_DAY = 86_400; + +export interface FetchComparePoolGraphOptions { + /** Bearer token for the Graph gateway. Prefer injection in tests. */ + readonly apiKey?: string; + readonly fetchImpl?: typeof fetch; + readonly timeoutMs?: number; + /** Unix seconds used as aggregation "now" and `queried_at`. */ + readonly nowSeconds?: number; + readonly gatewayOrigin?: string; + readonly env?: Readonly>; +} + +/** Everything a metrics query yields before window aggregation is applied. */ +interface ParsedPoolMetrics { + readonly pool_address: string; + readonly token0: TokenMetadata; + readonly token1: TokenMetadata; + readonly fee_tier_bps: number | null; + readonly tvl_usd: string | null; + readonly snapshots: readonly DailySnapshot[]; +} + +/** + * One supported metrics query: the document to send, the response field that + * carries the pool entity, and the parser that flattens the two schema tiers + * onto one shape. + */ +interface PoolMetricsQuery { + readonly query: string; + readonly poolField: string; + readonly parse: ( + data: Record, + pool: Record, + ) => ParsedPoolMetrics | null; +} + +function provenanceFor( + source: ComparePoolGraphSource, + deploymentOrViewId: string, +): SourceProvenance { + return { + deployment_or_view_id: deploymentOrViewId, + schema_version: source.record.schema_version, + methodology_version: source.record.methodology_version, + query_id: source.query_id, + }; +} + +function failedResult( + source: ComparePoolGraphSource, + status: Extract["status"], + options: { + readonly warnings: readonly string[]; + readonly latencyMs: number; + readonly freshness: SourceFreshness | null; + readonly deploymentOrViewId?: string; + }, +): PoolSourceResult { + return { + source_id: source.source_id, + source_type: source.record.source_type, + protocol: source.record.protocol, + chain_id: BASE_CHAIN_ID, + status, + data: null, + freshness: options.freshness, + provenance: provenanceFor( + source, + options.deploymentOrViewId ?? source.record.deployment_or_view_id, + ), + warnings: [...options.warnings], + latency_ms: options.latencyMs, + }; +} + +/** Native `feeTier` is expressed in hundredths of a basis point (3000 = 30 bps). */ +function parseNativeFeeTierBps(raw: unknown): number | null { + if (typeof raw !== "string" || !NON_NEGATIVE_INT_STRING.test(raw)) { + return null; + } + const feeTier = Number(raw); + if (!Number.isSafeInteger(feeTier) || feeTier < 0 || feeTier % 100 !== 0) { + return null; + } + return feeTier / 100; +} + +/** + * Messari expresses a fee as a percentage decimal string ("0.3" = 30 bps). + * Scaling by 100 digitwise keeps the conversion off IEEE-754; a tier finer + * than one basis point has no integer representation and stays null. + */ +function parsePercentageFeeBps(raw: unknown): number | null { + if (typeof raw !== "string" || !NON_NEGATIVE_DECIMAL.test(raw)) { + return null; + } + const [whole = "0", fraction = ""] = raw.split("."); + if (fraction.length > 2) { + return null; + } + const bps = Number(`${whole}${fraction.padEnd(2, "0")}`); + return Number.isSafeInteger(bps) && bps >= 0 ? bps : null; +} + +function parseTradingFeeBps(raw: unknown): number | null { + if (!Array.isArray(raw)) { + return null; + } + const trading = raw.filter( + (entry) => isRecord(entry) && entry.feeType === "FIXED_TRADING_FEE", + ) as Record[]; + if (trading.length !== 1) { + return null; + } + return parsePercentageFeeBps(trading[0]!.feePercentage); +} + +/** Reads a `volumeUSD`-style metric that the source may legitimately omit. */ +function parseOptionalMetric(raw: unknown): { value: string | null; valid: boolean } { + if (raw === null || raw === undefined) { + return { value: null, valid: true }; + } + if (typeof raw !== "string") { + return { value: null, valid: false }; + } + return { value: raw, valid: true }; +} + +function parseTierBSnapshots(raw: unknown): DailySnapshot[] | null { + if (!Array.isArray(raw)) { + return null; + } + const days: DailySnapshot[] = []; + for (const entry of raw) { + if (!isRecord(entry)) { + return null; + } + if (typeof entry.date !== "number" || !Number.isInteger(entry.date) || entry.date < 0) { + return null; + } + const volume = parseOptionalMetric(entry.volumeUSD); + const fees = parseOptionalMetric(entry.feesUSD); + if (!volume.valid || !fees.valid) { + return null; + } + days.push({ date: entry.date, volumeUSD: volume.value, feesUSD: fees.value }); + } + return days; +} + +/** + * Messari snapshots are keyed by `day`, the count of days since the unix + * epoch. Aggregation works in UTC-midnight seconds, so convert on the way in. + */ +function parseTierASnapshots(raw: unknown): DailySnapshot[] | null { + if (!Array.isArray(raw)) { + return null; + } + const days: DailySnapshot[] = []; + for (const entry of raw) { + if (!isRecord(entry)) { + return null; + } + if (typeof entry.day !== "number" || !Number.isInteger(entry.day) || entry.day < 0) { + return null; + } + const volume = parseOptionalMetric(entry.dailyVolumeUSD); + const revenue = parseOptionalMetric(entry.dailyTotalRevenueUSD); + if (!volume.valid || !revenue.valid) { + return null; + } + days.push({ + date: entry.day * SECONDS_PER_DAY, + volumeUSD: volume.value, + feesUSD: revenue.value, + }); + } + return days; +} + +function parseTierAPool( + data: Record, + pool: Record, +): ParsedPoolMetrics | null { + const poolAddress = typeof pool.id === "string" ? normalizeAddress(pool.id) : null; + const snapshots = parseTierASnapshots(data.liquidityPoolDailySnapshots); + if (poolAddress === null || snapshots === null || !Array.isArray(pool.inputTokens)) { + return null; + } + + // A two-sided pool is the only shape the locked pair can be checked against. + if (pool.inputTokens.length !== 2) { + return null; + } + const token0 = parseToken(pool.inputTokens[0]); + const token1 = parseToken(pool.inputTokens[1]); + if (token0 === null || token1 === null) { + return null; + } + + return { + pool_address: poolAddress, + token0, + token1, + fee_tier_bps: parseTradingFeeBps(pool.fees), + tvl_usd: parseFinancial(pool.totalValueLockedUSD), + snapshots, + }; +} + +function parseTierBPool( + data: Record, + pool: Record, +): ParsedPoolMetrics | null { + const poolAddress = typeof pool.id === "string" ? normalizeAddress(pool.id) : null; + const token0 = parseToken(pool.token0); + const token1 = parseToken(pool.token1); + const snapshots = parseTierBSnapshots(data.poolDayDatas); + if (poolAddress === null || token0 === null || token1 === null || snapshots === null) { + return null; + } + + return { + pool_address: poolAddress, + token0, + token1, + fee_tier_bps: parseNativeFeeTierBps(pool.feeTier), + tvl_usd: parseFinancial(pool.totalValueLockedUSD), + snapshots, + }; +} + +const POOL_METRICS_QUERIES: Readonly> = { + [TIER_A_METRICS_QUERY_ID]: { + query: TIER_A_METRICS_QUERY, + poolField: "liquidityPool", + parse: parseTierAPool, + }, + [TIER_B_METRICS_QUERY_ID]: { + query: TIER_B_METRICS_QUERY, + poolField: "pool", + parse: parseTierBPool, + }, +}; + +/** + * Queries one locked compare-pools Graph binding and maps it to `PoolSourceResult`. + * Operational and shape failures stay inside this boundary as non-`ok` statuses. + */ +export async function fetchComparePoolGraphSource( + source: ComparePoolGraphSource, + options: FetchComparePoolGraphOptions = {}, +): Promise { + const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000); + const requestedTimeoutMs = options.timeoutMs ?? GATEWAY_DEFAULTS.sourceTimeoutMs; + const timeoutMs = Math.min(Math.max(1, requestedTimeoutMs), GATEWAY_MAXIMUMS.sourceTimeoutMs); + + const selected = POOL_METRICS_QUERIES[source.query_id]; + if (selected === undefined) { + return failedResult(source, "unsupported", { + warnings: [ + `Unsupported Graph query_id "${source.query_id}"; expected one of ${Object.keys(POOL_METRICS_QUERIES).join(", ")}.`, + ], + latencyMs: 0, + freshness: null, + }); + } + + if (source.record.locator.kind !== "graph_subgraph") { + return failedResult(source, "unsupported", { + warnings: ["Compare-pools Graph adapter requires a graph_subgraph locator."], + latencyMs: 0, + freshness: null, + }); + } + + const apiKey = resolveGraphApiKey(options); + if (apiKey === null) { + return failedResult(source, "error", { + warnings: ["GRAPH_API_KEY is unset or empty."], + latencyMs: 0, + freshness: null, + }); + } + + const poolVariable = normalizeAddress(source.pool_address); + if (poolVariable === null) { + return failedResult(source, "unsupported", { + warnings: ["Registry pool_address is not a valid lowercaseable Ethereum address."], + latencyMs: 0, + freshness: null, + }); + } + + const gatewayOrigin = + options.gatewayOrigin ?? `https://${source.record.locator.gateway_host}/api`; + + const transport: GraphTransportResult = await postGraphGateway( + source.record.locator.subgraph_id, + selected.query, + { pool: poolVariable }, + { + apiKey, + timeoutMs, + gatewayOrigin, + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }, + ); + + if (transport.error?.kind === "timeout") { + return failedResult(source, "timeout", { + warnings: [transport.error.message], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (transport.error !== null) { + return failedResult(source, "error", { + warnings: [transport.error.message], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (!isRecord(transport.body)) { + return failedResult(source, "error", { + warnings: ["Graph gateway returned an unexpected response body."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (Array.isArray(transport.body.errors) && transport.body.errors.length > 0) { + return failedResult(source, "error", { + warnings: ["Graph gateway returned GraphQL errors."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + const data = transport.body.data; + if (!isRecord(data)) { + return failedResult(source, "error", { + warnings: ["Graph gateway response is missing a data object."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (!isRecord(data._meta) || typeof data._meta.deployment !== "string") { + return failedResult(source, "unsupported", { + warnings: ["Graph response is missing a usable _meta.deployment."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + const freshness = parseFreshness(data._meta, nowSeconds); + const deploymentCheck = assertDeployment( + source.record.deployment_or_view_id, + data._meta.deployment, + ); + + if (!deploymentCheck.ok) { + return failedResult(source, "unsupported", { + warnings: [deploymentMismatchWarning(deploymentCheck.expected, deploymentCheck.actual)], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: deploymentCheck.actual, + }); + } + + const poolEntity = data[selected.poolField]; + if (poolEntity === null || poolEntity === undefined) { + return failedResult(source, "unsupported", { + warnings: [`Pool ${poolVariable} was not found on the registered Graph deployment.`], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + if (!isRecord(poolEntity)) { + return failedResult(source, "unsupported", { + warnings: ["Graph pool payload has an unexpected shape."], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + if (freshness === null) { + return failedResult(source, "unsupported", { + warnings: ["Graph response is missing usable _meta block freshness."], + latencyMs: transport.latencyMs, + freshness: null, + deploymentOrViewId: data._meta.deployment, + }); + } + + const parsed = selected.parse(data, poolEntity); + if (parsed === null) { + return failedResult(source, "unsupported", { + warnings: ["Graph pool metrics payload failed shape validation."], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + if (parsed.pool_address !== poolVariable) { + return failedResult(source, "unsupported", { + warnings: [ + `Graph pool id "${parsed.pool_address}" does not match the registry pool "${poolVariable}".`, + ], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + const expectedToken0 = normalizeAddress(source.token0); + const expectedToken1 = normalizeAddress(source.token1); + if ( + expectedToken0 === null || + expectedToken1 === null || + parsed.token0.address !== expectedToken0 || + parsed.token1.address !== expectedToken1 + ) { + return failedResult(source, "unsupported", { + warnings: ["Graph pool tokens do not match the locked compare-pools pair."], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + const aggregation = aggregateDailySnapshots(parsed.snapshots, nowSeconds); + const warnings = aggregation.warnings.map((warning) => warning.message); + if (freshness.has_indexing_errors === true) { + warnings.push("Graph _meta.hasIndexingErrors is true for this response."); + } + + const poolData: PoolSourceData = { + pool_address: parsed.pool_address, + token0: parsed.token0, + token1: parsed.token1, + fee_tier_bps: parsed.fee_tier_bps, + tvl_usd: parsed.tvl_usd, + volume_usd_24h: aggregation.aggregates.volume_usd_24h, + volume_usd_7d: aggregation.aggregates.volume_usd_7d, + fees_usd_24h: aggregation.aggregates.fees_usd_24h, + fees_usd_7d: aggregation.aggregates.fees_usd_7d, + }; + + return { + source_id: source.source_id, + source_type: source.record.source_type, + protocol: source.record.protocol, + chain_id: BASE_CHAIN_ID, + status: "ok", + data: poolData, + freshness, + provenance: provenanceFor(source, data._meta.deployment), + warnings, + latency_ms: transport.latencyMs, + }; +} diff --git a/src/sources/graph/aggregation.test.ts b/src/sources/graph/aggregation.test.ts new file mode 100644 index 0000000..b211c2f --- /dev/null +++ b/src/sources/graph/aggregation.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; + +import { aggregateDailySnapshots, utcDayId } from "./aggregation.js"; +import type { DailySnapshot } from "./aggregation.js"; + +const DAY = 86_400; +const REF = 1_784_984_000; + +function day(offset: number, volume: string | null, fees: string | null): DailySnapshot { + return { date: utcDayId(REF) - offset * DAY, volumeUSD: volume, feesUSD: fees }; +} + +function sevenConsecutiveDays(): DailySnapshot[] { + return Array.from({ length: 7 }, (_, i) => day(7 - i, "100.00", "1.00")); +} + +describe("aggregateDailySnapshots — 24h", () => { + it("maps the latest completed day to 24h volume and fees", () => { + const result = aggregateDailySnapshots( + [day(2, "200", "2"), day(1, "300", "3"), day(0, "999", "9")], + REF, + ); + + expect(result.aggregates.volume_usd_24h).toBe("300"); + expect(result.aggregates.fees_usd_24h).toBe("3"); + }); + + it("excludes the current partial day from 24h", () => { + const partial = day(0, "500", "5"); + const completed = day(1, "300", "3"); + const result = aggregateDailySnapshots([completed, partial], REF); + + expect(result.aggregates.volume_usd_24h).toBe("300"); + expect(result.aggregates.fees_usd_24h).toBe("3"); + }); +}); + +describe("aggregateDailySnapshots — 7d", () => { + it("sums exactly seven consecutive completed days", () => { + const result = aggregateDailySnapshots(sevenConsecutiveDays(), REF); + + expect(result.aggregates.volume_usd_7d).toBe("700"); + expect(result.aggregates.fees_usd_7d).toBe("7"); + }); + + it("sums values beyond IEEE-754 precision without float loss", () => { + const snapshots: DailySnapshot[] = [ + { + date: utcDayId(REF) - 7 * DAY, + volumeUSD: "1837918.971826772337839586279587621", + feesUSD: "5513.756915480317013518758838762854", + }, + { + date: utcDayId(REF) - 6 * DAY, + volumeUSD: "51474578.61622885677419282699983982", + feesUSD: "154423.7358486865703225784809995228", + }, + { + date: utcDayId(REF) - 5 * DAY, + volumeUSD: "40635517.7678439140561290160817718", + feesUSD: "121906.5533035317421683870482453143", + }, + { + date: utcDayId(REF) - 4 * DAY, + volumeUSD: "64491334.84868823906340059812757887", + feesUSD: "193474.0045460647171902017943827393", + }, + { + date: utcDayId(REF) - 3 * DAY, + volumeUSD: "34752470.9371506173065599289573975", + feesUSD: "104257.4128114518519196797868721919", + }, + { + date: utcDayId(REF) - 2 * DAY, + volumeUSD: "95158308.81850063901666160232434262", + feesUSD: "285474.9264555019170499848069730268", + }, + { + date: utcDayId(REF) - 1 * DAY, + volumeUSD: "24701335.71650390345286524940436904", + feesUSD: "74104.00714951171035859574821310743", + }, + ]; + + const result = aggregateDailySnapshots(snapshots, REF); + + expect(result.aggregates.volume_usd_7d).toBe("313051465.676742942007648808174887271"); + expect(result.aggregates.fees_usd_7d).toBe("939154.397030228826022946424524665384"); + }); + + it("validates volume and fees independently so one null does not erase the other", () => { + const snapshots: DailySnapshot[] = Array.from({ length: 7 }, (_, i) => ({ + date: utcDayId(REF) - (7 - i) * DAY, + volumeUSD: "100", + feesUSD: null, + })); + + const result = aggregateDailySnapshots(snapshots, REF); + + expect(result.aggregates.volume_usd_7d).toBe("700"); + expect(result.aggregates.fees_usd_7d).toBeNull(); + }); + + it("nulls 7d volume when one day in the window is null without under-summing", () => { + const snapshots = sevenConsecutiveDays(); + snapshots[3] = day(4, null, "1.00"); + + const result = aggregateDailySnapshots(snapshots, REF); + + expect(result.aggregates.volume_usd_7d).toBeNull(); + expect(result.aggregates.fees_usd_7d).toBe("7"); + expect( + result.warnings.some( + (warning) => warning.code === "null_metric" && warning.message.includes("volume_usd_7d"), + ), + ).toBe(true); + }); + + it("nulls 7d fees when one day in the window is malformed without under-summing", () => { + const snapshots = sevenConsecutiveDays(); + snapshots[2] = day(5, "100.00", "not-a-decimal"); + + const result = aggregateDailySnapshots(snapshots, REF); + + expect(result.aggregates.volume_usd_7d).toBe("700"); + expect(result.aggregates.fees_usd_7d).toBeNull(); + expect( + result.warnings.some( + (warning) => warning.code === "malformed_metric" && warning.message.includes("fees_usd_7d"), + ), + ).toBe(true); + }); +}); + +describe("aggregateDailySnapshots — ordering and gaps", () => { + it("handles unordered snapshots", () => { + const ordered = sevenConsecutiveDays(); + const shuffled = [ + ordered[3]!, + ordered[0]!, + ordered[6]!, + ordered[1]!, + ordered[5]!, + ordered[2]!, + ordered[4]!, + ]; + const result = aggregateDailySnapshots(shuffled, REF); + + expect(result.aggregates.volume_usd_7d).toBe("700"); + }); + + it("returns null 7d for only six days of history", () => { + const six = sevenConsecutiveDays().slice(1); + const result = aggregateDailySnapshots(six, REF); + + expect(result.aggregates.volume_usd_7d).toBeNull(); + expect(result.warnings.some((w) => w.code === "insufficient_7d_history")).toBe(true); + }); + + it("returns null 7d for an interior gap within the 7-day window", () => { + const days = sevenConsecutiveDays(); + days[3] = day(3, "100", "1"); + days.splice(3, 0, { date: utcDayId(REF) - 3 * DAY + DAY / 2, volumeUSD: "0", feesUSD: "0" }); + const result = aggregateDailySnapshots(days, REF); + + expect(result.aggregates.volume_usd_7d).toBeNull(); + expect(result.warnings.some((w) => w.code === "interior_gap")).toBe(true); + }); + + it("sums 7d when the gap is outside the 7-day window", () => { + const seven = sevenConsecutiveDays(); + const older = day(10, "999", "9"); + const result = aggregateDailySnapshots([older, ...seven], REF); + + expect(result.aggregates.volume_usd_7d).toBe("700"); + expect(result.warnings.some((w) => w.code === "interior_gap")).toBe(true); + }); + + it("collapses a duplicate day before aggregating", () => { + const days = sevenConsecutiveDays(); + const dup = { ...days[2]! }; + const result = aggregateDailySnapshots([...days, dup], REF); + + expect(result.aggregates.volume_usd_7d).toBe("700"); + expect(result.warnings.some((w) => w.code === "duplicate_day")).toBe(true); + }); + + it("uses only the 7 most recent when eight days are provided", () => { + const eight = [...sevenConsecutiveDays(), day(8, "999", "9")]; + const result = aggregateDailySnapshots(eight, REF); + + expect(result.aggregates.volume_usd_7d).toBe("700"); + expect(result.warnings.some((w) => w.code === "too_many_days")).toBe(true); + }); +}); + +describe("aggregateDailySnapshots — null and malformed", () => { + it("returns null 24h when the latest day has null volume but valid fees", () => { + const result = aggregateDailySnapshots([day(1, null, "3"), day(0, "999", "9")], REF); + + expect(result.aggregates.volume_usd_24h).toBeNull(); + expect(result.aggregates.fees_usd_24h).toBe("3"); + }); + + it("returns null for malformed volume but valid fees", () => { + const result = aggregateDailySnapshots([day(1, "not-a-decimal", "3"), day(0, "999", "9")], REF); + + expect(result.aggregates.volume_usd_24h).toBeNull(); + expect(result.aggregates.fees_usd_24h).toBe("3"); + expect(result.warnings.some((w) => w.code === "malformed_metric")).toBe(true); + }); + + it("returns null for valid volume but malformed fees", () => { + const result = aggregateDailySnapshots([day(1, "300", "bad"), day(0, "999", "9")], REF); + + expect(result.aggregates.volume_usd_24h).toBe("300"); + expect(result.aggregates.fees_usd_24h).toBeNull(); + }); +}); + +describe("aggregateDailySnapshots — empty and partial", () => { + it("returns all null with a warning for no snapshots", () => { + const result = aggregateDailySnapshots([], REF); + + expect(result.aggregates).toEqual({ + volume_usd_24h: null, + fees_usd_24h: null, + volume_usd_7d: null, + fees_usd_7d: null, + }); + expect(result.warnings.some((w) => w.code === "no_completed_days")).toBe(true); + }); + + it("returns all null when only the partial current day exists", () => { + const result = aggregateDailySnapshots([day(0, "999", "9")], REF); + + expect(result.aggregates.volume_usd_24h).toBeNull(); + expect(result.warnings.some((w) => w.code === "no_completed_days")).toBe(true); + }); +}); + +describe("aggregateDailySnapshots — UTC boundaries", () => { + it("handles UTC month boundary", () => { + const monthEnd: DailySnapshot = { date: 1_784_841_600, volumeUSD: "100", feesUSD: "1" }; + const monthStart: DailySnapshot = { date: 1_784_841_600 + DAY, volumeUSD: "200", feesUSD: "2" }; + const ref = monthStart.date + DAY; + + const result = aggregateDailySnapshots([monthEnd, monthStart], ref); + + expect(result.aggregates.volume_usd_24h).toBe("200"); + }); + + it("handles UTC year boundary and leap day", () => { + const leap: DailySnapshot = { date: 1_982_889_600, volumeUSD: "50", feesUSD: "0.5" }; + const after: DailySnapshot = { date: 1_982_889_600 + DAY, volumeUSD: "60", feesUSD: "0.6" }; + const ref = after.date + DAY; + + const result = aggregateDailySnapshots([leap, after], ref); + + expect(result.aggregates.volume_usd_24h).toBe("60"); + }); +}); + +describe("aggregateDailySnapshots — immutability", () => { + it("does not mutate the input array", () => { + const input = sevenConsecutiveDays(); + const inputCopy = [...input]; + + aggregateDailySnapshots(input, REF); + + expect(input).toEqual(inputCopy); + }); + + it("does not mutate input snapshot objects", () => { + const input = sevenConsecutiveDays(); + const snapshot = input[3]!; + const original = { ...snapshot }; + + aggregateDailySnapshots(input, REF); + + expect(snapshot).toEqual(original); + }); +}); diff --git a/src/sources/graph/aggregation.ts b/src/sources/graph/aggregation.ts new file mode 100644 index 0000000..fad2acb --- /dev/null +++ b/src/sources/graph/aggregation.ts @@ -0,0 +1,262 @@ +import { DecimalParseError, parseDecimal, sumDecimals } from "./decimal.js"; + +/** + * Completed UTC daily snapshot as reported by a Tier-B Graph deployment. + * `date` is the UTC midnight timestamp (unix seconds) of the day the + * snapshot summarizes. Volume and fees are source-reported USD decimal + * strings, or `null` when the source did not report them for that day. + */ +export interface DailySnapshot { + readonly date: number; + readonly volumeUSD: string | null; + readonly feesUSD: string | null; +} + +export interface WindowAggregates { + /** Most recent completed UTC day's volume, or null if unavailable. */ + readonly volume_usd_24h: string | null; + /** Most recent completed UTC day's fees, or null if unavailable. */ + readonly fees_usd_24h: string | null; + /** Exact sum of seven consecutive completed UTC days, or null. */ + readonly volume_usd_7d: string | null; + readonly fees_usd_7d: string | null; +} + +export interface AggregationWarning { + readonly code: + | "no_completed_days" + | "insufficient_7d_history" + | "interior_gap" + | "duplicate_day" + | "too_many_days" + | "null_metric" + | "malformed_metric"; + readonly message: string; +} + +export interface AggregationResult { + readonly aggregates: WindowAggregates; + readonly warnings: readonly AggregationWarning[]; +} + +const SECONDS_PER_DAY = 86_400; + +export function utcDayId(timestampSeconds: number): number { + return Math.floor(timestampSeconds / SECONDS_PER_DAY) * SECONDS_PER_DAY; +} + +function isCompletedDay(dayId: number, referenceTimestampSeconds: number): boolean { + return dayId + SECONDS_PER_DAY <= referenceTimestampSeconds; +} + +function sortSnapshots(snapshots: readonly DailySnapshot[]): DailySnapshot[] { + return [...snapshots].sort((a, b) => a.date - b.date); +} + +function deduplicateByDate(snapshots: readonly DailySnapshot[]): { + unique: DailySnapshot[]; + duplicates: number; +} { + const seen = new Map(); + let duplicates = 0; + for (const snapshot of snapshots) { + if (seen.has(snapshot.date)) { + duplicates += 1; + } else { + seen.set(snapshot.date, snapshot); + } + } + return { unique: [...seen.values()], duplicates }; +} + +function detectInteriorGap(unique: readonly DailySnapshot[]): boolean { + for (let i = 1; i < unique.length; i++) { + if (unique[i]!.date - unique[i - 1]!.date !== SECONDS_PER_DAY) { + return true; + } + } + return false; +} + +function sumMetric(values: readonly string[]): string | null { + if (values.length === 0) { + return null; + } + try { + return sumDecimals(values); + } catch (error) { + if (error instanceof DecimalParseError) { + return null; + } + throw error; + } +} + +function collectMetricValues( + days: readonly DailySnapshot[], + field: "volumeUSD" | "feesUSD", +): { values: string[]; hadNull: boolean; hadMalformed: boolean } { + const values: string[] = []; + let hadNull = false; + let hadMalformed = false; + for (const day of days) { + const raw = day[field]; + if (raw === null) { + hadNull = true; + continue; + } + try { + parseDecimal(raw); + values.push(raw); + } catch (error) { + if (error instanceof DecimalParseError) { + hadMalformed = true; + } else { + throw error; + } + } + } + return { values, hadNull, hadMalformed }; +} + +function finalizeWindowAggregate( + collected: { values: string[]; hadNull: boolean; hadMalformed: boolean }, + eligible: boolean, + expectedCount: number, +): string | null { + if ( + !eligible || + collected.hadNull || + collected.hadMalformed || + collected.values.length !== expectedCount + ) { + return null; + } + return sumMetric(collected.values); +} + +/** + * Aggregate completed UTC daily snapshots into 24h and 7d window values. + * + * Semantics (docs/M3_PLAN.md § Agent 3A, § Time-window semantics): + * - the current partial UTC day is never presented as a completed 24h value; + * - 24h = the most recent completed UTC day; + * - 7d = exact sum of seven consecutive completed UTC days ending at the 24h day; + * - missing, duplicated, non-consecutive, null, or malformed inputs make only + * the affected aggregate `null` — never a substitute or estimate. + * + * `referenceTimestampSeconds` anchors "current day" so tests are deterministic. + */ +export function aggregateDailySnapshots( + snapshots: readonly DailySnapshot[], + referenceTimestampSeconds: number, +): AggregationResult { + const warnings: AggregationWarning[] = []; + const empty: AggregationResult = { + aggregates: { + volume_usd_24h: null, + fees_usd_24h: null, + volume_usd_7d: null, + fees_usd_7d: null, + }, + warnings, + }; + + if (snapshots.length === 0) { + warnings.push({ code: "no_completed_days", message: "No daily snapshots provided" }); + return empty; + } + + const sorted = sortSnapshots(snapshots); + const { unique, duplicates } = deduplicateByDate(sorted); + if (duplicates > 0) { + warnings.push({ + code: "duplicate_day", + message: `${duplicates} duplicate day(s) collapsed before aggregation`, + }); + } + + const completed = unique.filter((day) => isCompletedDay(day.date, referenceTimestampSeconds)); + if (completed.length === 0) { + warnings.push({ + code: "no_completed_days", + message: "No completed UTC day available before the reference timestamp", + }); + return empty; + } + + const hasGap = detectInteriorGap(completed); + if (hasGap) { + warnings.push({ + code: "interior_gap", + message: "Completed days contain a gap; 7d window is checked independently", + }); + } + + if (completed.length > 7) { + warnings.push({ + code: "too_many_days", + message: `${completed.length} completed days provided; using the 7 most recent`, + }); + } + + const recentSeven = completed.slice(-7); + const latestDay = completed[completed.length - 1]!; + + const volume24hValues = collectMetricValues([latestDay], "volumeUSD"); + const fees24hValues = collectMetricValues([latestDay], "feesUSD"); + const volume7dValues = collectMetricValues(recentSeven, "volumeUSD"); + const fees7dValues = collectMetricValues(recentSeven, "feesUSD"); + + for (const { hadNull, hadMalformed, field } of [ + { ...volume24hValues, field: "volume_usd_24h" as const }, + { ...fees24hValues, field: "fees_usd_24h" as const }, + { ...volume7dValues, field: "volume_usd_7d" as const }, + { ...fees7dValues, field: "fees_usd_7d" as const }, + ]) { + if (hadNull) { + warnings.push({ + code: "null_metric", + message: `${field} had null inputs; affected aggregate is null`, + }); + } + if (hadMalformed) { + warnings.push({ + code: "malformed_metric", + message: `${field} had malformed decimal inputs; affected aggregate is null`, + }); + } + } + + const sevenConsecutive = + recentSeven.length === 7 && + recentSeven.every((day, i) => { + if (i === 0) return true; + return day.date - recentSeven[i - 1]!.date === SECONDS_PER_DAY; + }); + + const volume7d = finalizeWindowAggregate(volume7dValues, sevenConsecutive, recentSeven.length); + const fees7d = finalizeWindowAggregate(fees7dValues, sevenConsecutive, recentSeven.length); + + if (!sevenConsecutive && completed.length >= 7) { + warnings.push({ + code: "insufficient_7d_history", + message: "Seven consecutive completed days are not available; 7d aggregates are null", + }); + } else if (completed.length < 7) { + warnings.push({ + code: "insufficient_7d_history", + message: `Only ${completed.length} completed day(s) available; 7d aggregates are null`, + }); + } + + return { + aggregates: { + volume_usd_24h: finalizeWindowAggregate(volume24hValues, true, 1), + fees_usd_24h: finalizeWindowAggregate(fees24hValues, true, 1), + volume_usd_7d: volume7d, + fees_usd_7d: fees7d, + }, + warnings, + }; +} diff --git a/src/sources/graph/decimal.test.ts b/src/sources/graph/decimal.test.ts new file mode 100644 index 0000000..c2cb4bf --- /dev/null +++ b/src/sources/graph/decimal.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { DecimalParseError, canonicalizeDecimal, parseDecimal, sumDecimals } from "./decimal.js"; + +describe("parseDecimal", () => { + it("parses a canonical integer", () => { + expect(parseDecimal("42")).toEqual({ + canonical: "42", + integerPart: "42", + fractionalPart: "", + scale: 0, + }); + }); + + it("parses zero", () => { + expect(parseDecimal("0")).toEqual({ + canonical: "0", + integerPart: "0", + fractionalPart: "", + scale: 0, + }); + }); + + it("parses a value with fractional digits", () => { + expect(parseDecimal("1.5")).toEqual({ + canonical: "1.5", + integerPart: "1", + fractionalPart: "5", + scale: 1, + }); + }); + + it("strips leading zeros from the integer part", () => { + expect(parseDecimal("007").canonical).toBe("7"); + expect(parseDecimal("000.5").canonical).toBe("0.5"); + }); + + it("strips trailing zeros from the fractional part", () => { + expect(parseDecimal("1.500").canonical).toBe("1.5"); + expect(parseDecimal("1.000").canonical).toBe("1"); + expect(parseDecimal("0.000").canonical).toBe("0"); + }); + + it("trims surrounding whitespace", () => { + expect(parseDecimal(" 1.5 ").canonical).toBe("1.5"); + }); + + it("preserves fractional scale beyond IEEE-754 precision", () => { + const value = "1837918.971826772337839586279587621"; + expect(parseDecimal(value).scale).toBe(27); + expect(parseDecimal(value).canonical).toBe(value); + }); + + it.each([ + ["", "empty"], + [" ", "empty"], + ["-1", "sign"], + ["+1", "sign"], + ["1e5", "exponent"], + ["1E5", "exponent"], + ["NaN", "nan"], + ["Infinity", "infinity"], + ["1.2.3", "multiple dots"], + [".5", "leading dot"], + ["1.", "trailing dot"], + ["abc", "non-numeric"], + ["0x1", "hex"], + ])("rejects malformed input %s (%s)", (input) => { + expect(() => parseDecimal(input)).toThrow(DecimalParseError); + }); +}); + +describe("canonicalizeDecimal", () => { + it("canonicalizes leading and trailing zeros", () => { + expect(canonicalizeDecimal("007.500")).toBe("7.5"); + expect(canonicalizeDecimal("000000")).toBe("0"); + expect(canonicalizeDecimal("0.000000")).toBe("0"); + }); +}); + +describe("sumDecimals", () => { + it("returns 0 for an empty list", () => { + expect(sumDecimals([])).toBe("0"); + }); + + it("sums two integers", () => { + expect(sumDecimals(["1", "2"])).toBe("3"); + }); + + it("sums values at the same scale", () => { + expect(sumDecimals(["1.5", "2.5"])).toBe("4"); + }); + + it("sums values at differing scales without float loss", () => { + expect(sumDecimals(["0.1", "0.2"])).toBe("0.3"); + }); + + it("sums values beyond IEEE-754 precision", () => { + const a = "1837918.971826772337839586279587621"; + const b = "51474578.61622885677419282699983982"; + expect(sumDecimals([a, b])).toBe("53312497.588055629112032413279427441"); + }); + + it("handles mixed integer and fractional inputs", () => { + expect(sumDecimals(["100", "0.001", "0.999"])).toBe("101"); + }); + + it("canonicalizes inputs before summing", () => { + expect(sumDecimals(["007.500", "000.500"])).toBe("8"); + }); + + it("produces a canonical result with no trailing zeros", () => { + expect(sumDecimals(["1.1", "1.9", "1.0"])).toBe("4"); + }); + + it("rejects malformed input in any position", () => { + expect(() => sumDecimals(["1", "NaN", "2"])).toThrow(DecimalParseError); + }); +}); diff --git a/src/sources/graph/decimal.ts b/src/sources/graph/decimal.ts new file mode 100644 index 0000000..2a66fa8 --- /dev/null +++ b/src/sources/graph/decimal.ts @@ -0,0 +1,117 @@ +/** + * Exact decimal-string arithmetic for Graph-reported USD values. + * + * Financial values arrive as base-10 decimal strings (e.g. + * "1837918.971826772337839586279587621"). They must never pass through + * JavaScript `number` — IEEE-754 cannot represent them. This module + * validates, canonicalizes, and sums them using `BigInt` only. + * + * Rules (binding, from docs/M3_PLAN.md § Agent 3A): + * - accept only canonical non-negative base-10 decimal strings; + * - reject `NaN`, infinities, signs, exponent notation, and malformed input; + * - canonicalize leading/trailing zeros so byte-equality is meaningful; + * - add at a shared fractional scale using `BigInt`, never `number`. + */ + +export class DecimalParseError extends Error { + constructor( + readonly input: string, + message: string, + ) { + super(message); + this.name = "DecimalParseError"; + } +} + +const DECIMAL_PATTERN = /^[0-9]+(\.[0-9]+)?$/; + +export interface DecimalValue { + /** Canonical string form — no leading zeros (except "0"), no trailing zeros after the dot. */ + readonly canonical: string; + /** Integer digits before the decimal point, canonical (no leading zeros). */ + readonly integerPart: string; + /** Fractional digits after the decimal point, may be empty string (meaning scale 0). */ + readonly fractionalPart: string; + /** Number of fractional digits. */ + readonly scale: number; +} + +/** + * Parse and validate a non-negative base-10 decimal string. + * Throws `DecimalParseError` on any malformed input. + */ +export function parseDecimal(input: string): DecimalValue { + if (typeof input !== "string") { + throw new DecimalParseError(String(input), "Decimal must be a string"); + } + const trimmed = input.trim(); + if (trimmed === "") { + throw new DecimalParseError(input, "Decimal must not be empty"); + } + if (!DECIMAL_PATTERN.test(trimmed)) { + throw new DecimalParseError( + input, + "Decimal must be a non-negative base-10 string without sign or exponent", + ); + } + + const [integerRaw, fractionalRaw = ""] = trimmed.split("."); + const integerPart = (integerRaw ?? "").replace(/^0+(?=\d)/, "") || "0"; + const fractionalPart = fractionalRaw.replace(/0+$/, ""); + + const canonical = + fractionalPart === "" + ? integerPart + : integerPart === "0" && fractionalPart === "" + ? "0" + : `${integerPart}.${fractionalPart}`; + + return { + canonical, + integerPart, + fractionalPart, + scale: fractionalPart.length, + }; +} + +/** + * Canonicalize a decimal string in place (parse + return canonical form). + * Throws on malformed input. + */ +export function canonicalizeDecimal(input: string): string { + return parseDecimal(input).canonical; +} + +/** + * Sum a list of non-negative decimal strings exactly, using `BigInt`. + * The result carries the maximum fractional scale of any input. + * Throws `DecimalParseError` if any input is malformed. + */ +export function sumDecimals(inputs: readonly string[]): string { + if (inputs.length === 0) { + return "0"; + } + + const parsed = inputs.map(parseDecimal); + const maxScale = parsed.reduce((max, value) => Math.max(max, value.scale), 0); + + const asScaledBigInt = parsed.map((value) => { + const padded = value.fractionalPart.padEnd(maxScale, "0"); + return BigInt(value.integerPart + padded); + }); + + const total = asScaledBigInt.reduce((sum, value) => sum + value, 0n); + const totalStr = total.toString(); + + if (maxScale === 0) { + return totalStr; + } + + const padded = totalStr.padStart(maxScale + 1, "0"); + const integerPart = padded.slice(0, padded.length - maxScale); + let fractionalPart = padded.slice(padded.length - maxScale); + + fractionalPart = fractionalPart.replace(/0+$/, ""); + + return fractionalPart === "" ? integerPart : `${integerPart}.${fractionalPart}`; +} diff --git a/src/sources/graph/deployment-assertion.ts b/src/sources/graph/deployment-assertion.ts new file mode 100644 index 0000000..e994d96 --- /dev/null +++ b/src/sources/graph/deployment-assertion.ts @@ -0,0 +1,27 @@ +/** + * Compares the registry-pinned deployment with the deployment reported by + * `_meta` on the same GraphQL response. M3 maps a mismatch to `unsupported` + * with null data. Reliable freshness from that response may be retained. + */ +export type DeploymentAssertion = + | { readonly ok: true } + | { + readonly ok: false; + readonly expected: string; + readonly actual: string; + }; + +export function assertDeployment(expected: string, actual: string): DeploymentAssertion { + if (expected === actual) { + return { ok: true }; + } + + return { ok: false, expected, actual }; +} + +/** + * Builds a credential-free diagnostic using only deployment identifiers. + */ +export function deploymentMismatchWarning(expected: string, actual: string): string { + return `Graph deployment mismatch: expected "${expected}", received "${actual}".`; +} diff --git a/src/sources/graph/index.ts b/src/sources/graph/index.ts new file mode 100644 index 0000000..1196cb1 --- /dev/null +++ b/src/sources/graph/index.ts @@ -0,0 +1,41 @@ +export { fetchComparePoolGraphSource, type FetchComparePoolGraphOptions } from "./adapter.js"; +export { + aggregateDailySnapshots, + utcDayId, + type AggregationResult, + type AggregationWarning, + type DailySnapshot, + type WindowAggregates, +} from "./aggregation.js"; +export { + assertDeployment, + deploymentMismatchWarning, + type DeploymentAssertion, +} from "./deployment-assertion.js"; +export { + fetchCompareLendingGraphSource, + type FetchCompareLendingGraphOptions, +} from "./lending-adapter.js"; +export { + TIER_A_LENDING_METRICS_QUERY, + TIER_A_LENDING_METRICS_QUERY_ID, +} from "./lending-queries.js"; +export { + TIER_A_METRICS_QUERY, + TIER_A_METRICS_QUERY_ID, + TIER_B_METRICS_QUERY, + TIER_B_METRICS_QUERY_ID, +} from "./queries.js"; +export { + GRAPH_GATEWAY_ORIGIN, + postGraphGateway, + type GraphTransportError, + type GraphTransportResult, + type PostGraphGatewayOptions, +} from "./transport.js"; +export { + WALLET_POSITIONS_QUERY, + fetchWalletGraphPositions, + walletGraphProvenance, + type FetchWalletGraphOptions, +} from "./wallet-adapter.js"; diff --git a/src/sources/graph/lending-adapter.ts b/src/sources/graph/lending-adapter.ts new file mode 100644 index 0000000..6f545c1 --- /dev/null +++ b/src/sources/graph/lending-adapter.ts @@ -0,0 +1,459 @@ +import { GATEWAY_DEFAULTS, GATEWAY_MAXIMUMS } from "../../config/defaults.js"; +import type { CompareLendingGraphSource } from "../../registry/index.js"; +import { + BASE_CHAIN_ID, + type LendingMarketSourceData, + type LendingMarketSourceResult, + type SourceFreshness, + type SourceProvenance, + type TokenMetadata, +} from "../../schemas/source-adapter.js"; + +import { assertDeployment, deploymentMismatchWarning } from "./deployment-assertion.js"; +import { + TIER_A_LENDING_METRICS_QUERY, + TIER_A_LENDING_METRICS_QUERY_ID, +} from "./lending-queries.js"; +import { + isRecord, + normalizeAddress, + parseFinancial, + parseFreshness, + parseToken, + resolveGraphApiKey, +} from "./response.js"; +import { postGraphGateway, type GraphTransportResult } from "./transport.js"; + +export interface FetchCompareLendingGraphOptions { + /** Bearer token for the Graph gateway. Prefer injection in tests. */ + readonly apiKey?: string; + readonly fetchImpl?: typeof fetch; + readonly timeoutMs?: number; + /** Unix seconds recorded as `queried_at`. */ + readonly nowSeconds?: number; + readonly gatewayOrigin?: string; + readonly env?: Readonly>; +} + +/** The rate sides and types the Messari lending standard publishes. */ +type RateSide = "LENDER" | "BORROWER"; +type RateType = "VARIABLE" | "STABLE"; + +interface SelectedRate { + readonly value: string | null; + readonly warning: string | null; +} + +/** + * The `network` every shipped binding must self-report. Messari deployments + * are published per network but a subgraph can be indexed against a different + * one than its listing implies — the Compound v3 "base" deployment reports + * MAINNET — so the response is required to agree with the locked scope. + */ +const EXPECTED_NETWORK = "BASE" as const; + +interface ParsedMarket { + readonly market_id: string; + readonly market_name: string | null; + readonly input_token: TokenMetadata; + readonly is_active: boolean; + readonly can_borrow_from: boolean; + readonly can_use_as_collateral: boolean; + readonly tvl_usd: string | null; + readonly total_deposit_balance_usd: string | null; + readonly total_borrow_balance_usd: string | null; + readonly rates: readonly Record[]; +} + +function provenanceFor( + source: CompareLendingGraphSource, + deploymentOrViewId: string, +): SourceProvenance { + return { + deployment_or_view_id: deploymentOrViewId, + schema_version: source.record.schema_version, + methodology_version: source.record.methodology_version, + query_id: source.query_id, + }; +} + +function failedResult( + source: CompareLendingGraphSource, + status: Extract["status"], + options: { + readonly warnings: readonly string[]; + readonly latencyMs: number; + readonly freshness: SourceFreshness | null; + readonly deploymentOrViewId?: string; + }, +): LendingMarketSourceResult { + return { + source_id: source.source_id, + source_type: source.record.source_type, + protocol: source.record.protocol, + chain_id: BASE_CHAIN_ID, + status, + data: null, + freshness: options.freshness, + provenance: provenanceFor( + source, + options.deploymentOrViewId ?? source.record.deployment_or_view_id, + ), + warnings: [...options.warnings], + latency_ms: options.latencyMs, + }; +} + +/** `null` for an absent value, `undefined` for one that failed validation. */ +function parseOptionalFinancial(raw: unknown): string | null | undefined { + if (raw === null || raw === undefined) { + return null; + } + return parseFinancial(raw) ?? undefined; +} + +/** `null` for an absent or blank name, `undefined` for a non-string one. */ +function parseMarketName(raw: unknown): string | null | undefined { + if (raw === null || raw === undefined) { + return null; + } + if (typeof raw !== "string") { + return undefined; + } + const name = raw.trim(); + return name === "" ? null : name; +} + +/** Reads the network off the single protocol entity, or `null` if unreadable. */ +function parseProtocolNetwork(raw: unknown): string | null { + if (!Array.isArray(raw) || raw.length !== 1 || !isRecord(raw[0])) { + return null; + } + const network = raw[0].network; + return typeof network === "string" && network !== "" ? network : null; +} + +function parseMarket(raw: Record): ParsedMarket | null { + const marketId = typeof raw.id === "string" ? normalizeAddress(raw.id) : null; + const marketName = parseMarketName(raw.name); + const inputToken = parseToken(raw.inputToken); + const tvlUsd = parseOptionalFinancial(raw.totalValueLockedUSD); + const depositUsd = parseOptionalFinancial(raw.totalDepositBalanceUSD); + const borrowUsd = parseOptionalFinancial(raw.totalBorrowBalanceUSD); + + if ( + marketId === null || + marketName === undefined || + inputToken === null || + typeof raw.isActive !== "boolean" || + typeof raw.canBorrowFrom !== "boolean" || + typeof raw.canUseAsCollateral !== "boolean" || + tvlUsd === undefined || + depositUsd === undefined || + borrowUsd === undefined || + !Array.isArray(raw.rates) || + !raw.rates.every(isRecord) + ) { + return null; + } + + return { + market_id: marketId, + market_name: marketName, + input_token: inputToken, + is_active: raw.isActive, + can_borrow_from: raw.canBorrowFrom, + can_use_as_collateral: raw.canUseAsCollateral, + tvl_usd: tvlUsd, + total_deposit_balance_usd: depositUsd, + total_borrow_balance_usd: borrowUsd, + rates: raw.rates, + }; +} + +/** + * Picks the single rate for one side/type pair. + * + * An absent pair is a real protocol fact (Moonwell publishes no stable borrow + * rate) and stays silent. A malformed or ambiguous pair yields `null` plus a + * warning, because both a defaulted value and an arbitrary pick would invent a + * number the source never reported. + */ +function selectRate( + rates: readonly Record[], + side: RateSide, + type: RateType, + sourceId: string, +): SelectedRate { + const matches = rates.filter((rate) => rate.side === side && rate.type === type); + + if (matches.length === 0) { + return { value: null, warning: null }; + } + if (matches.length > 1) { + return { + value: null, + warning: `${sourceId} reported ${String(matches.length)} ${side}/${type} rates for the selected market.`, + }; + } + + const value = parseFinancial(matches[0]!.rate); + if (value === null) { + return { + value: null, + warning: `${sourceId} reported a ${side}/${type} rate that failed decimal validation.`, + }; + } + return { value, warning: null }; +} + +/** + * Queries one locked compare-lending Graph binding and maps it to a + * `LendingMarketSourceResult`. Operational and shape failures stay inside this + * boundary as non-`ok` statuses. + */ +export async function fetchCompareLendingGraphSource( + source: CompareLendingGraphSource, + options: FetchCompareLendingGraphOptions = {}, +): Promise { + const nowSeconds = options.nowSeconds ?? Math.floor(Date.now() / 1000); + const requestedTimeoutMs = options.timeoutMs ?? GATEWAY_DEFAULTS.sourceTimeoutMs; + const timeoutMs = Math.min(Math.max(1, requestedTimeoutMs), GATEWAY_MAXIMUMS.sourceTimeoutMs); + + if (source.query_id !== TIER_A_LENDING_METRICS_QUERY_ID) { + return failedResult(source, "unsupported", { + warnings: [ + `Unsupported Graph query_id "${source.query_id}"; expected "${TIER_A_LENDING_METRICS_QUERY_ID}".`, + ], + latencyMs: 0, + freshness: null, + }); + } + + if (source.record.locator.kind !== "graph_subgraph") { + return failedResult(source, "unsupported", { + warnings: ["Compare-lending Graph adapter requires a graph_subgraph locator."], + latencyMs: 0, + freshness: null, + }); + } + + const apiKey = resolveGraphApiKey(options); + if (apiKey === null) { + return failedResult(source, "error", { + warnings: ["GRAPH_API_KEY is unset or empty."], + latencyMs: 0, + freshness: null, + }); + } + + const marketToken = normalizeAddress(source.market_token); + if (marketToken === null) { + return failedResult(source, "unsupported", { + warnings: ["Registry market_token is not a valid lowercaseable Ethereum address."], + latencyMs: 0, + freshness: null, + }); + } + + const gatewayOrigin = + options.gatewayOrigin ?? `https://${source.record.locator.gateway_host}/api`; + + const transport: GraphTransportResult = await postGraphGateway( + source.record.locator.subgraph_id, + TIER_A_LENDING_METRICS_QUERY, + { token: marketToken }, + { + apiKey, + timeoutMs, + gatewayOrigin, + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }, + ); + + if (transport.error?.kind === "timeout") { + return failedResult(source, "timeout", { + warnings: [transport.error.message], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (transport.error !== null) { + return failedResult(source, "error", { + warnings: [transport.error.message], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (!isRecord(transport.body)) { + return failedResult(source, "error", { + warnings: ["Graph gateway returned an unexpected response body."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (Array.isArray(transport.body.errors) && transport.body.errors.length > 0) { + return failedResult(source, "error", { + warnings: ["Graph gateway returned GraphQL errors."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + const data = transport.body.data; + if (!isRecord(data)) { + return failedResult(source, "error", { + warnings: ["Graph gateway response is missing a data object."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + if (!isRecord(data._meta) || typeof data._meta.deployment !== "string") { + return failedResult(source, "unsupported", { + warnings: ["Graph response is missing a usable _meta.deployment."], + latencyMs: transport.latencyMs, + freshness: null, + }); + } + + const freshness = parseFreshness(data._meta, nowSeconds); + const deploymentCheck = assertDeployment( + source.record.deployment_or_view_id, + data._meta.deployment, + ); + + if (!deploymentCheck.ok) { + return failedResult(source, "unsupported", { + warnings: [deploymentMismatchWarning(deploymentCheck.expected, deploymentCheck.actual)], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: deploymentCheck.actual, + }); + } + + if (freshness === null) { + return failedResult(source, "unsupported", { + warnings: ["Graph response is missing usable _meta block freshness."], + latencyMs: transport.latencyMs, + freshness: null, + deploymentOrViewId: data._meta.deployment, + }); + } + + const network = parseProtocolNetwork(data.lendingProtocols); + if (network === null) { + return failedResult(source, "unsupported", { + warnings: ["Graph response is missing a usable lendingProtocols.network."], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + if (network !== EXPECTED_NETWORK) { + return failedResult(source, "unsupported", { + warnings: [ + `Graph deployment self-reports network "${network}", expected "${EXPECTED_NETWORK}".`, + ], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + if (!Array.isArray(data.markets) || !data.markets.every(isRecord)) { + return failedResult(source, "unsupported", { + warnings: ["Graph markets payload has an unexpected shape."], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + const matches = data.markets.filter( + (market) => parseToken(market.inputToken)?.address === marketToken, + ); + + if (matches.length === 0) { + return failedResult(source, "unsupported", { + warnings: [ + `No market for input token ${marketToken} was found on the registered Graph deployment.`, + ], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + if (matches.length > 1) { + // Selecting one of several would be an invented editorial choice, so the + // ambiguity is surfaced instead. + return failedResult(source, "unsupported", { + warnings: [ + `Graph deployment reported ${String(matches.length)} markets for input token ${marketToken}.`, + ], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + const market = parseMarket(matches[0]!); + if (market === null) { + return failedResult(source, "unsupported", { + warnings: ["Graph lending market payload failed shape validation."], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + const lenderVariable = selectRate(market.rates, "LENDER", "VARIABLE", source.source_id); + const borrowerVariable = selectRate(market.rates, "BORROWER", "VARIABLE", source.source_id); + const borrowerStable = selectRate(market.rates, "BORROWER", "STABLE", source.source_id); + + const warnings = [ + lenderVariable.warning, + borrowerVariable.warning, + borrowerStable.warning, + ].filter((warning): warning is string => warning !== null); + + if (freshness.has_indexing_errors === true) { + warnings.push("Graph _meta.hasIndexingErrors is true for this response."); + } + if (!market.is_active) { + warnings.push(`${source.source_id} market ${market.market_id} is reported as inactive.`); + } + + const marketData: LendingMarketSourceData = { + market_id: market.market_id, + market_name: market.market_name, + input_token: market.input_token, + is_active: market.is_active, + can_borrow_from: market.can_borrow_from, + can_use_as_collateral: market.can_use_as_collateral, + tvl_usd: market.tvl_usd, + total_deposit_balance_usd: market.total_deposit_balance_usd, + total_borrow_balance_usd: market.total_borrow_balance_usd, + lender_variable_rate_percent: lenderVariable.value, + borrower_variable_rate_percent: borrowerVariable.value, + borrower_stable_rate_percent: borrowerStable.value, + }; + + return { + source_id: source.source_id, + source_type: source.record.source_type, + protocol: source.record.protocol, + chain_id: BASE_CHAIN_ID, + status: "ok", + data: marketData, + freshness, + provenance: provenanceFor(source, data._meta.deployment), + warnings, + latency_ms: transport.latencyMs, + }; +} diff --git a/src/sources/graph/lending-queries.ts b/src/sources/graph/lending-queries.ts new file mode 100644 index 0000000..f619234 --- /dev/null +++ b/src/sources/graph/lending-queries.ts @@ -0,0 +1,44 @@ +/** + * Locked metrics query for compare_lending_markets Graph sources. + * + * Query identity must stay aligned with `src/registry/compare-lending.json`: + * the profile names the query a binding is served with, and the adapter + * refuses any id it does not implement. + */ + +/** + * Tier-A lending metrics over the Messari `lending-protocol` standard, which + * every shipped `compare_lending_markets` binding uses. + * + * Field notes that are easy to get wrong: + * - `lendingProtocols.network` is read back and checked against the locked + * scope, because a Messari deployment listed for one network can be indexed + * against another; + * - `markets` is filtered by `inputToken` rather than fetched by id, because a + * market address differs per protocol while the compared asset does not; + * - five markets are requested so a deployment that exposes more than one + * market for the same input token is visible to the adapter instead of being + * silently reduced to the first row; + * - `inputToken.decimals` is an Int here, unlike the native schemas where it is + * a string; + * - `rates` is a set, not a fixed triple: a protocol that offers no stable + * borrow rate simply omits that entry. + */ +export const TIER_A_LENDING_METRICS_QUERY_ID = "tier-a-lending-market-metrics-v1" as const; + +export const TIER_A_LENDING_METRICS_QUERY = `query TierALendingMarketMetrics($token: String!) { + _meta { + block { number timestamp hash } + hasIndexingErrors + deployment + } + lendingProtocols(first: 1) { + id name slug schemaVersion methodologyVersion network type lendingType + } + markets(first: 5, where: { inputToken: $token }) { + id name isActive canBorrowFrom canUseAsCollateral + totalValueLockedUSD totalDepositBalanceUSD totalBorrowBalanceUSD + inputToken { id symbol decimals } + rates { id side type rate } + } +}`; diff --git a/src/sources/graph/queries.ts b/src/sources/graph/queries.ts new file mode 100644 index 0000000..a8b21c9 --- /dev/null +++ b/src/sources/graph/queries.ts @@ -0,0 +1,65 @@ +/** + * Locked metrics queries for compare_pools Graph sources. + * + * Query identity must stay aligned with `src/registry/compare-pools.json`: + * the profile names the query a binding is served with, and the adapter + * refuses any id it does not implement. + */ + +const META = `_meta { + block { number timestamp hash } + hasIndexingErrors + deployment + }`; + +/** + * Tier-A metrics over the Messari `dex-amm` standard, which is what + * `compare_pools` ships against. + * + * Field notes that are easy to get wrong: + * - snapshots expose `day` (days since the unix epoch), not a midnight + * timestamp, so the adapter converts before aggregating; + * - the standard has no per-day fee field. `dailyTotalRevenueUSD` is the + * supply-side plus protocol-side revenue accrued that day, which is the + * value mapped onto `fees_usd`; + * - eight days are requested so seven completed UTC days survive after the + * in-progress day is discarded. + */ +export const TIER_A_METRICS_QUERY_ID = "tier-a-dex-pool-metrics-v1" as const; + +export const TIER_A_METRICS_QUERY = `query TierADexPoolMetrics($pool: ID!) { + ${META} + liquidityPool(id: $pool) { + id totalValueLockedUSD + inputTokens { id symbol decimals } + fees { feePercentage feeType } + } + liquidityPoolDailySnapshots( + first: 8 + orderBy: day + orderDirection: desc + where: { pool: $pool } + ) { day dailyVolumeUSD dailyTotalRevenueUSD } +}`; + +/** + * Tier-B metrics over the native Uniswap-V3-style schema. No shipped profile + * binds it today; it stays implemented so a native deployment can be compared + * again without reopening the adapter. + */ +export const TIER_B_METRICS_QUERY_ID = "m3-tier-b-metrics-v2" as const; + +export const TIER_B_METRICS_QUERY = `query M2TierBMetrics($pool: ID!) { + ${META} + pool(id: $pool) { + id feeTier totalValueLockedUSD + token0 { id symbol decimals } + token1 { id symbol decimals } + } + poolDayDatas( + first: 8 + orderBy: date + orderDirection: desc + where: { pool: $pool } + ) { date volumeUSD feesUSD tvlUSD } +}`; diff --git a/src/sources/graph/response.ts b/src/sources/graph/response.ts new file mode 100644 index 0000000..2172e97 --- /dev/null +++ b/src/sources/graph/response.ts @@ -0,0 +1,120 @@ +import type { SourceFreshness, TokenMetadata } from "../../schemas/source-adapter.js"; + +/** + * Shape parsing shared by every Graph adapter. + * + * Gateway responses are untrusted input: nothing here asserts a shape, and a + * value that fails validation becomes `null` so the calling adapter can report + * a non-`ok` status instead of publishing a half-parsed fact. + */ + +const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; +const BLOCK_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/; +const NON_NEGATIVE_DECIMAL = /^(?:0|[1-9]\d*)(?:\.\d+)?$/; +const NON_NEGATIVE_INT_STRING = /^(?:0|[1-9]\d*)$/; + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function normalizeAddress(value: string): string | null { + if (!ADDRESS_PATTERN.test(value)) { + return null; + } + return value.toLowerCase(); +} + +/** Accepts only canonical non-negative USD decimal strings, never a number. */ +export function parseFinancial(raw: unknown): string | null { + if (typeof raw !== "string" || !NON_NEGATIVE_DECIMAL.test(raw)) { + return null; + } + return raw; +} + +/** + * Token decimals arrive as a string on native subgraphs and as an Int on + * Messari standardized subgraphs, so both encodings are accepted here. + */ +function parseDecimals(raw: unknown): number | null { + if (typeof raw === "number") { + return Number.isSafeInteger(raw) && raw >= 0 ? raw : null; + } + if (typeof raw !== "string" || !NON_NEGATIVE_INT_STRING.test(raw)) { + return null; + } + const decimals = Number(raw); + return Number.isSafeInteger(decimals) && decimals >= 0 ? decimals : null; +} + +export function parseToken(raw: unknown): TokenMetadata | null { + if (!isRecord(raw)) { + return null; + } + if (typeof raw.id !== "string" || typeof raw.symbol !== "string") { + return null; + } + const address = normalizeAddress(raw.id); + const symbol = raw.symbol.trim(); + const decimals = parseDecimals(raw.decimals); + if (address === null || symbol === "" || decimals === null) { + return null; + } + return { address, symbol, decimals }; +} + +export function parseFreshness( + meta: Record, + queriedAt: number, +): SourceFreshness | null { + if (!isRecord(meta.block)) { + return null; + } + const block = meta.block; + if ( + typeof block.number !== "number" || + !Number.isInteger(block.number) || + block.number < 0 || + typeof block.timestamp !== "number" || + !Number.isInteger(block.timestamp) || + block.timestamp < 0 + ) { + return null; + } + + const freshness: SourceFreshness = { + indexed_block: block.number, + indexed_block_timestamp: block.timestamp, + queried_at: queriedAt, + }; + + if (typeof block.hash === "string" && BLOCK_HASH_PATTERN.test(block.hash)) { + freshness.indexed_block_hash = block.hash.toLowerCase(); + } + + if (typeof meta.hasIndexingErrors === "boolean") { + freshness.has_indexing_errors = meta.hasIndexingErrors; + } + + return freshness; +} + +/** + * Resolves the Graph gateway credential from an explicit option first so tests + * never depend on ambient environment, then from the environment. + */ +export function resolveGraphApiKey(options: { + readonly apiKey?: string; + readonly env?: Readonly>; +}): string | null { + if (options.apiKey !== undefined && options.apiKey.trim() !== "") { + return options.apiKey; + } + const fromEnv = (options.env ?? process.env).GRAPH_API_KEY; + if (fromEnv !== undefined && fromEnv.trim() !== "") { + return fromEnv; + } + return null; +} + +export { NON_NEGATIVE_DECIMAL, NON_NEGATIVE_INT_STRING }; diff --git a/src/sources/graph/transport.ts b/src/sources/graph/transport.ts new file mode 100644 index 0000000..fe68ce2 --- /dev/null +++ b/src/sources/graph/transport.ts @@ -0,0 +1,110 @@ +import { GATEWAY_DEFAULTS, GATEWAY_MAXIMUMS } from "../../config/defaults.js"; + +export const GRAPH_GATEWAY_ORIGIN = "https://gateway.thegraph.com/api" as const; + +export interface GraphTransportError { + readonly kind: "timeout" | "transport" | "invalid_json" | "http"; + readonly message: string; +} + +export interface GraphTransportResult { + readonly status: number | null; + readonly body: unknown; + readonly error: GraphTransportError | null; + readonly latencyMs: number; +} + +export interface PostGraphGatewayOptions { + readonly apiKey: string; + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly gatewayOrigin?: string; +} + +function safeTransportMessage(error: unknown, timedOut: boolean): string { + if (timedOut) { + return "Graph gateway request exceeded the configured timeout."; + } + if (error instanceof Error && error.name === "AbortError") { + return "Graph gateway request was aborted."; + } + return "Graph gateway request failed; details were redacted."; +} + +/** + * POSTs a GraphQL document to a Graph Network gateway subgraph endpoint. + * Authorization uses a Bearer header; the API key never appears in the URL. + */ +export async function postGraphGateway( + subgraphId: string, + query: string, + variables: Readonly>, + options: PostGraphGatewayOptions, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const gatewayOrigin = options.gatewayOrigin ?? GRAPH_GATEWAY_ORIGIN; + const requestedTimeoutMs = options.timeoutMs ?? GATEWAY_DEFAULTS.sourceTimeoutMs; + const timeoutMs = Math.min(Math.max(1, requestedTimeoutMs), GATEWAY_MAXIMUMS.sourceTimeoutMs); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = performance.now(); + + try { + const response = await fetchImpl( + `${gatewayOrigin}/subgraphs/id/${encodeURIComponent(subgraphId)}`, + { + method: "POST", + headers: { + authorization: `Bearer ${options.apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }, + ); + const text = await response.text(); + try { + const body = JSON.parse(text) as unknown; + if (response.status < 200 || response.status >= 300) { + return { + status: response.status, + body, + error: { + kind: "http", + message: `Graph gateway returned HTTP ${String(response.status)}.`, + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } + return { + status: response.status, + body, + error: null, + latencyMs: Math.round(performance.now() - startedAt), + }; + } catch { + return { + status: response.status, + body: null, + error: { + kind: "invalid_json", + message: "Graph gateway returned a non-JSON response.", + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } + } catch (error) { + const timedOut = error instanceof Error && error.name === "AbortError"; + return { + status: null, + body: null, + error: { + kind: timedOut ? "timeout" : "transport", + message: safeTransportMessage(error, timedOut), + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/sources/graph/wallet-adapter.ts b/src/sources/graph/wallet-adapter.ts new file mode 100644 index 0000000..680b7b9 --- /dev/null +++ b/src/sources/graph/wallet-adapter.ts @@ -0,0 +1,340 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY } from "../../policy/index.js"; +import type { GraphSourceRegistryRecord } from "../../registry/types.js"; +import { + defiPositionSchema, + type DeFiPosition, + type ResultFreshness, + type ResultProvenance, +} from "../../schemas/index.js"; +import type { ResearchWalletRequest } from "../../schemas/wallet-research-request.js"; +import { BASE_CHAIN_ID } from "../../schemas/source-adapter.js"; +import { WALLET_RESEARCH_SCOPE } from "../../scope/wallet-research.js"; +import type { WalletGraphResult, WalletSourceFailureStatus } from "../../tools/wallet-sources.js"; + +import { postGraphGateway } from "./transport.js"; + +export const WALLET_POSITIONS_QUERY = `query WalletPositions($wallet: String!, $limit: Int!) { + _meta { + block { number timestamp hash } + hasIndexingErrors + deployment + } + lendingProtocols(first: 1) { + schemaVersion + methodologyVersion + network + } + positions( + first: $limit + orderBy: id + orderDirection: asc + where: { account: $wallet, balance_gt: "0" } + ) { + id + account { id } + market { id name } + asset { id symbol decimals } + side + type + isCollateral + balance + blockNumberOpened + timestampOpened + snapshots(first: 1, orderBy: blockNumber, orderDirection: desc) { + balance + balanceUSD + blockNumber + timestamp + } + } +}`; + +const hashSchema = z.string().regex(/^0x[0-9a-fA-F]{64}$/); +const addressSchema = z.string().regex(/^0x[0-9a-fA-F]{40}$/); +const integerStringSchema = z.string().regex(/^(?:0|[1-9]\d*)$/); +const decimalStringSchema = z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d+)?$/); + +const graphPayloadSchema = z + .object({ + _meta: z + .object({ + block: z + .object({ + number: z.number().int().nonnegative(), + timestamp: z.number().int().nonnegative(), + hash: hashSchema.nullable().optional(), + }) + .passthrough(), + hasIndexingErrors: z.boolean().optional(), + deployment: z.string().min(1), + }) + .passthrough(), + lendingProtocols: z + .array( + z + .object({ + schemaVersion: z.string().min(1), + methodologyVersion: z.string().min(1), + network: z.literal("BASE"), + }) + .passthrough(), + ) + .length(1), + positions: z.array( + z + .object({ + id: z.string().min(1), + account: z.object({ id: addressSchema }).passthrough(), + market: z + .object({ + id: addressSchema, + name: z.string().min(1).nullable(), + }) + .passthrough(), + asset: z + .object({ + id: addressSchema, + symbol: z.string().min(1), + decimals: z.number().int().nonnegative().max(255), + }) + .passthrough(), + side: z.enum(["COLLATERAL", "BORROWER"]), + type: z.string().nullable(), + isCollateral: z.boolean().nullable(), + balance: integerStringSchema, + blockNumberOpened: integerStringSchema, + timestampOpened: integerStringSchema, + snapshots: z.array( + z + .object({ + balance: integerStringSchema, + balanceUSD: decimalStringSchema.nullable(), + blockNumber: integerStringSchema, + timestamp: integerStringSchema, + }) + .passthrough(), + ), + }) + .passthrough(), + ), + }) + .passthrough(); + +function provenance(record: GraphSourceRegistryRecord): ResultProvenance { + return { + source_id: record.source_id, + source_type: record.source_type, + protocol: record.protocol, + chain_id: BASE_CHAIN_ID, + deployment_or_view_id: record.deployment_or_view_id, + schema_version: record.schema_version, + methodology_version: record.methodology_version, + query_id: WALLET_RESEARCH_SCOPE.graph.queryId, + }; +} + +export function walletGraphProvenance(record: GraphSourceRegistryRecord): ResultProvenance { + return provenance(record); +} + +function failure( + record: GraphSourceRegistryRecord, + status: WalletSourceFailureStatus, + warning: string, +): WalletGraphResult { + return { + status, + positions: null, + freshness: { source_id: record.source_id, status: "unavailable" }, + provenance: provenance(record), + warnings: [warning], + }; +} + +function baseUnits(raw: string, decimals: number): string { + const padded = raw.padStart(decimals + 1, "0"); + if (decimals === 0) { + return padded; + } + const whole = padded.slice(0, -decimals).replace(/^0+(?=\d)/, ""); + const fraction = padded.slice(-decimals).replace(/0+$/, ""); + return fraction === "" ? whole : `${whole}.${fraction}`; +} + +function mapPosition( + raw: z.infer["positions"][number], + record: GraphSourceRegistryRecord, +): DeFiPosition { + const snapshot = raw.snapshots[0]; + const collateral = raw.side === "COLLATERAL" && raw.isCollateral !== false; + const positionType = + raw.side === "BORROWER" + ? "lending_borrow" + : collateral + ? "lending_collateral" + : "lending_supply"; + const role = raw.side === "BORROWER" ? "borrowed" : collateral ? "collateral" : "supplied"; + const observedBlock = Number(snapshot?.blockNumber ?? raw.blockNumberOpened); + const observedTimestamp = Number(snapshot?.timestamp ?? raw.timestampOpened); + const rawAmount = snapshot?.balance ?? raw.balance; + + return defiPositionSchema.parse({ + id: raw.id, + chain_id: BASE_CHAIN_ID, + protocol: WALLET_RESEARCH_SCOPE.graph.protocol, + position_type: positionType, + container: { + address: raw.market.id.toLowerCase(), + name: raw.market.name, + }, + assets: [ + { + token_address: raw.asset.id.toLowerCase(), + symbol: raw.asset.symbol, + decimals: raw.asset.decimals, + role, + raw_amount: rawAmount, + normalized_amount: baseUnits(rawAmount, raw.asset.decimals), + usd_price: null, + usd_value: snapshot?.balanceUSD ?? null, + }, + ], + valuation: { + usd_value: snapshot?.balanceUSD ?? null, + }, + observed_at: { + block_number: observedBlock, + timestamp: observedTimestamp, + }, + source_ids: [record.source_id], + }); +} + +export interface FetchWalletGraphOptions { + readonly apiKey?: string; + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly nowSeconds?: number; + readonly gatewayOrigin?: string; +} + +export async function fetchWalletGraphPositions( + record: GraphSourceRegistryRecord, + request: ResearchWalletRequest, + options: FetchWalletGraphOptions = {}, +): Promise { + if ( + record.source_id !== WALLET_RESEARCH_SCOPE.graph.sourceId || + record.category !== "lending" || + record.status !== "active" || + record.locator.subgraph_id !== WALLET_RESEARCH_SCOPE.graph.subgraphId || + !record.supported_entities.includes("positions") + ) { + return failure(record, "unsupported", "Wallet Graph registry capability is unsupported."); + } + const apiKey = options.apiKey ?? process.env.GRAPH_API_KEY; + if (apiKey === undefined || apiKey.trim() === "") { + return failure( + record, + "error", + "Wallet Graph source is unavailable because GRAPH_API_KEY is unset.", + ); + } + + const transport = await postGraphGateway( + record.locator.subgraph_id, + WALLET_POSITIONS_QUERY, + { wallet: request.address, limit: request.limit + 1 }, + { + apiKey, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + ...(options.gatewayOrigin !== undefined ? { gatewayOrigin: options.gatewayOrigin } : {}), + }, + ); + if (transport.error !== null) { + return failure( + record, + transport.error.kind === "timeout" ? "timeout" : "error", + transport.error.message, + ); + } + const body = z + .object({ data: z.unknown(), errors: z.array(z.unknown()).optional() }) + .passthrough() + .safeParse(transport.body); + if (!body.success || (body.data.errors?.length ?? 0) > 0) { + return failure(record, "error", "Wallet Graph query returned errors."); + } + const parsed = graphPayloadSchema.safeParse(body.data.data); + const protocol = parsed.success ? parsed.data.lendingProtocols[0] : undefined; + if ( + !parsed.success || + parsed.data._meta.deployment !== record.deployment_or_view_id || + protocol?.schemaVersion !== record.schema_version || + protocol?.methodologyVersion !== record.methodology_version || + parsed.data.positions.some(({ account }) => account.id.toLowerCase() !== request.address) + ) { + return failure( + record, + "unsupported", + "Wallet Graph response failed its deployment or shape contract.", + ); + } + + const now = options.nowSeconds ?? Math.floor(Date.now() / 1_000); + const block = parsed.data._meta.block; + const lag = now - block.timestamp; + if (!Number.isSafeInteger(lag) || lag < 0) { + return failure(record, "unsupported", "Wallet Graph freshness metadata is invalid."); + } + const status = + lag > M0_CORE_POLICY.freshness.qualityStaleAfterSeconds + ? ("stale" as const) + : ("fresh" as const); + const freshness: ResultFreshness = { + source_id: record.source_id, + status, + indexed_block: block.number, + indexed_block_timestamp: block.timestamp, + indexed_block_hash: block.hash?.toLowerCase() ?? null, + queried_at: now, + lag_seconds: lag, + }; + const warnings = [ + ...(parsed.data._meta.hasIndexingErrors === true + ? ["Wallet Graph source reports indexing errors."] + : []), + ...(status === "stale" ? ["Wallet Graph source is stale."] : []), + ...(parsed.data.positions.length > request.limit + ? [ + `Wallet Graph positions were truncated to the requested limit of ${String(request.limit)}.`, + ] + : []), + ]; + if (status === "stale") { + return { + status: "stale", + positions: null, + freshness, + provenance: provenance(record), + warnings, + }; + } + + try { + return { + status: "ok", + positions: parsed.data.positions + .slice(0, request.limit) + .map((position) => mapPosition(position, record)), + freshness, + provenance: provenance(record), + warnings, + }; + } catch { + return failure(record, "unsupported", "Wallet Graph positions failed canonical normalization."); + } +} diff --git a/src/sources/nuthatch/adapter-failures.test.ts b/src/sources/nuthatch/adapter-failures.test.ts new file mode 100644 index 0000000..5569d3e --- /dev/null +++ b/src/sources/nuthatch/adapter-failures.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; + +import { fetchNuthatchFreshness } from "./adapter.js"; +import { + BLOCK_HASH, + err, + OTHER_HASH, + QUERIED_AT, + RECORD, + REGISTRY_HASH, + ROW, + run, + scriptClient, +} from "./adapter-fixtures.js"; +import type { NuthatchClient } from "./client.js"; + +describe("fetchNuthatchFreshness — /nest drift", () => { + it("maps registry_hash mismatch to unsupported and records the observed hash in provenance", async () => { + const result = await run({ + nest: { + ok: true, + status: 200, + body: { registry_hash: OTHER_HASH, name: "other", table_count: 9 }, + latencyMs: 2, + }, + }); + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).not.toBeNull(); + expect(result.provenance.deployment_or_view_id).toBe(OTHER_HASH); + const warning = result.warnings.join(" "); + expect(warning).toContain(REGISTRY_HASH); + expect(warning).toContain(OTHER_HASH); + }); + + it("maps a missing registry_hash to unsupported", async () => { + const result = await run({ + nest: { ok: true, status: 200, body: { name: "x" }, latencyMs: 2 }, + }); + expect(result.status).toBe("unsupported"); + expect(result.freshness).not.toBeNull(); + }); + + it("maps a malformed registry_hash to unsupported", async () => { + const result = await run({ + nest: { ok: true, status: 200, body: { registry_hash: "0xshort" }, latencyMs: 2 }, + }); + expect(result.status).toBe("unsupported"); + }); + + it("maps /nest transport failure to error while retaining freshness", async () => { + const result = await run({ nest: err("transport") }); + expect(result.status).toBe("error"); + expect(result.freshness).not.toBeNull(); + }); + + it("maps /nest timeout to timeout while retaining freshness", async () => { + const result = await run({ nest: err("timeout") }); + expect(result.status).toBe("timeout"); + expect(result.freshness).not.toBeNull(); + }); +}); + +describe("fetchNuthatchFreshness — /schema drift", () => { + it("maps a non-string schema body to unsupported", async () => { + const result = await run({ + schema: { ok: true, status: 200, body: { tables: [] }, latencyMs: 2 }, + }); + expect(result.status).toBe("unsupported"); + expect(result.freshness).not.toBeNull(); + }); + + it("maps an empty schema body to unsupported", async () => { + const result = await run({ schema: { ok: true, status: 200, body: " ", latencyMs: 2 } }); + expect(result.status).toBe("unsupported"); + }); + + it("maps /schema transport failure to error", async () => { + const result = await run({ schema: err("transport") }); + expect(result.status).toBe("error"); + }); + + it("maps /schema timeout to timeout", async () => { + const result = await run({ schema: err("timeout") }); + expect(result.status).toBe("timeout"); + }); +}); + +describe("fetchNuthatchFreshness — /sql row drift", () => { + it("maps zero rows to unsupported with null freshness", async () => { + const result = await run({ sql: { ok: true, status: 200, body: { rows: [] }, latencyMs: 1 } }); + expect(result.status).toBe("unsupported"); + expect(result.freshness).toBeNull(); + }); + + it("maps more than one row to unsupported", async () => { + const result = await run({ + sql: { ok: true, status: 200, body: { rows: [ROW, ROW] }, latencyMs: 1 }, + }); + expect(result.status).toBe("unsupported"); + }); + + it("maps a malformed pool_address to unsupported", async () => { + const result = await run({ + sql: { + ok: true, + status: 200, + body: { rows: [{ ...ROW, pool_address: "0xdeadbeef" }] }, + latencyMs: 1, + }, + }); + expect(result.status).toBe("unsupported"); + }); + + it("maps a malformed block hash to unsupported", async () => { + const result = await run({ + sql: { + ok: true, + status: 200, + body: { rows: [{ ...ROW, last_swap_block_hash: "0xshort" }] }, + latencyMs: 1, + }, + }); + expect(result.status).toBe("unsupported"); + }); + + it("maps a numeric-string count to unsupported", async () => { + const result = await run({ + sql: { + ok: true, + status: 200, + body: { rows: [{ ...ROW, recent_swap_count_24h: "42" }] }, + latencyMs: 1, + }, + }); + expect(result.status).toBe("unsupported"); + }); + + it("maps /sql transport failure to error with null freshness", async () => { + const result = await run({ sql: err("transport") }); + expect(result.status).toBe("error"); + expect(result.freshness).toBeNull(); + }); + + it("maps /sql timeout to timeout with null freshness", async () => { + const result = await run({ sql: err("timeout") }); + expect(result.status).toBe("timeout"); + expect(result.freshness).toBeNull(); + }); + + it("maps /sql invalid_json to error", async () => { + const result = await run({ sql: err("invalid_json") }); + expect(result.status).toBe("error"); + }); + + it("maps /sql oversize to error", async () => { + const result = await run({ sql: err("oversize") }); + expect(result.status).toBe("error"); + }); + + it("maps an unexpected /sql HTTP 400 to error", async () => { + const result = await run({ sql: err("http", 400) }); + expect(result.status).toBe("error"); + }); +}); + +describe("fetchNuthatchFreshness — status precedence", () => { + it("timeout takes precedence over unsupported when both occur", async () => { + const result = await run({ + nest: err("timeout"), + sql: { ok: true, status: 200, body: { rows: [] }, latencyMs: 1 }, + }); + expect(result.status).toBe("timeout"); + }); + + it("unsupported takes precedence over error when both occur", async () => { + const result = await run({ + nest: { ok: true, status: 200, body: { registry_hash: OTHER_HASH }, latencyMs: 1 }, + schema: err("transport"), + }); + expect(result.status).toBe("unsupported"); + }); + + it("retains freshness from a valid /sql row on a non-ok aggregate status", async () => { + const result = await run({ schema: err("transport") }); + expect(result.status).toBe("error"); + expect(result.freshness).toEqual({ + indexed_block: 48756852, + indexed_block_timestamp: 1784303051, + indexed_block_hash: BLOCK_HASH, + queried_at: QUERIED_AT, + }); + }); +}); + +describe("fetchNuthatchFreshness — registry record validation", () => { + it("rejects a record bound to the wrong view without calling the client", async () => { + let calls = 0; + const client: NuthatchClient = { + ...scriptClient({}), + health: () => (calls++, Promise.resolve({ ok: true, status: 200, body: "", latencyMs: 0 })), + ready: () => ( + calls++, + Promise.resolve({ ok: true, status: 200, body: { ready: true }, latencyMs: 0 }) + ), + nest: () => (calls++, Promise.resolve({ ok: true, status: 200, body: {}, latencyMs: 0 })), + schema: () => (calls++, Promise.resolve({ ok: true, status: 200, body: "", latencyMs: 0 })), + explain: () => (calls++, Promise.resolve({ ok: true, status: 200, body: "", latencyMs: 0 })), + sql: () => ( + calls++, + Promise.resolve({ ok: true, status: 200, body: { rows: [] }, latencyMs: 0 }) + ), + }; + const result = await fetchNuthatchFreshness({ + client, + clock: () => QUERIED_AT, + record: { ...RECORD, locator: { ...RECORD.locator, view_id: "other_view" } }, + }); + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).toBeNull(); + expect(calls).toBe(0); + }); + + it("rejects an inactive record", async () => { + const result = await fetchNuthatchFreshness({ + client: scriptClient({}), + clock: () => QUERIED_AT, + record: { ...RECORD, status: "inactive" }, + }); + expect(result.status).toBe("unsupported"); + }); +}); + +describe("fetchNuthatchFreshness — boundary safety", () => { + it("never lets a client throw escape the adapter boundary", async () => { + const client: NuthatchClient = { + health: () => Promise.resolve({ ok: true, status: 200, body: "", latencyMs: 0 }), + ready: () => { + throw new Error("boom"); + }, + nest: () => Promise.resolve({ ok: true, status: 200, body: {}, latencyMs: 0 }), + schema: () => Promise.resolve({ ok: true, status: 200, body: "", latencyMs: 0 }), + explain: () => Promise.resolve({ ok: true, status: 200, body: "", latencyMs: 0 }), + sql: () => Promise.resolve({ ok: true, status: 200, body: { rows: [] }, latencyMs: 0 }), + }; + const result = await fetchNuthatchFreshness({ + client, + clock: () => QUERIED_AT, + record: RECORD, + }); + expect(result.status).toBe("error"); + expect(result.data).toBeNull(); + expect(result.warnings.join(" ")).not.toContain("boom"); + }); + + it("redacts base URL, admin tokens, and SQL from warnings through the real client", async () => { + const { createNuthatchClient } = await import("./client.js"); + const secretFetch = (() => + Promise.reject( + new Error( + "https://wallet-intel.example.ts.net admin-token-leak SELECT * FROM pool_swap_freshness", + ), + )) as unknown as typeof fetch; + const client = createNuthatchClient({ + baseUrl: "https://wallet-intel.example.ts.net", + fetchImpl: secretFetch, + timeoutMs: 5_000, + }); + const result = await fetchNuthatchFreshness({ + client, + clock: () => QUERIED_AT, + record: RECORD, + }); + expect(result.status).toBe("error"); + const warnings = result.warnings.join(" "); + expect(warnings).not.toContain("https://"); + expect(warnings).not.toContain("admin-token"); + expect(warnings).not.toContain("SELECT"); + expect(warnings).not.toContain("wallet-intel"); + }); +}); diff --git a/src/sources/nuthatch/adapter-fixtures.ts b/src/sources/nuthatch/adapter-fixtures.ts new file mode 100644 index 0000000..1b99d23 --- /dev/null +++ b/src/sources/nuthatch/adapter-fixtures.ts @@ -0,0 +1,136 @@ +/** + * Shared fixtures and a scripted fake client for the Nuthatch adapter tests. + * Kept out of the test files so each matrix row can be covered in a focused + * commit without exceeding the repo's per-commit line guard. + */ +import type { NuthatchSourceRegistryRecord } from "../../registry/types.js"; +import { fetchNuthatchFreshness } from "./adapter.js"; +import type { NuthatchClient, NuthatchHttpResult } from "./client.js"; + +export const REGISTRY_HASH = "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25"; +export const OTHER_HASH = "0x0000000000000000000000000000000000000000000000000000000000000abc"; +export const POOL = "0x6c561b446416e1a00e8e93e221854d6ea4171372"; +export const BLOCK_HASH = "0xc2fd40c2f6b9a9b1e59737d6053354885b3336ac801a3f7c0340902420ec9dc2"; +export const TX_HASH = "0x5a02a9e244c5191381b04066377bd9b06cec47b962074200dc0d9a4702357733"; + +export const ROW = { + pool_address: POOL, + recent_swap_count_24h: 42, + last_swap_block: 48756852, + last_swap_block_timestamp: 1784303051, + last_swap_block_hash: BLOCK_HASH, + last_swap_tx_hash: TX_HASH, + last_swap_log_index: 21, +} as const; + +export const READY_OK: NuthatchHttpResult = { + ok: true, + status: 200, + body: { ready: true }, + latencyMs: 1, +}; +export const NEST_OK: NuthatchHttpResult = { + ok: true, + status: 200, + body: { + chain: "base", + chain_id: 8453, + registry_hash: REGISTRY_HASH, + name: "deeptrace-pool-freshness", + table_count: 9, + }, + latencyMs: 2, +}; +export const SCHEMA_OK: NuthatchHttpResult = { + ok: true, + status: 200, + body: "nuthatch data model\n\nTABLES...", + latencyMs: 2, +}; +export const SQL_OK: NuthatchHttpResult = { + ok: true, + status: 200, + body: { count: 1, provenance: {}, rows: [ROW], truncated: false }, + latencyMs: 3, +}; + +export const QUERIED_AT = 1_784_995_000; + +export const RECORD = { + source_id: "nuthatch-pool-swaps", + category: "dex", + protocol: "uniswap-v3", + chain_id: 8453, + source_type: "nuthatch_view", + deployment_or_view_id: REGISTRY_HASH, + schema_version: null, + methodology_version: null, + supported_entities: ["pool_swap_freshness"], + status: "active", + locator: { + kind: "nuthatch_view", + base_url_env: "NUTHATCH_BASE_URL", + view_id: "pool_swap_freshness", + }, +} as const satisfies NuthatchSourceRegistryRecord; + +export interface Script { + readonly ready?: NuthatchHttpResult | NuthatchHttpResult[]; + readonly nest?: NuthatchHttpResult | NuthatchHttpResult[]; + readonly schema?: NuthatchHttpResult | NuthatchHttpResult[]; + readonly sql?: NuthatchHttpResult | NuthatchHttpResult[]; +} + +function asQueue( + value: NuthatchHttpResult | NuthatchHttpResult[] | undefined, +): NuthatchHttpResult[] { + if (value === undefined) { + return [READY_OK]; + } + return Array.isArray(value) ? [...value] : [value]; +} + +export function scriptClient(script: Script): NuthatchClient { + const readyQueue = asQueue(script.ready); + const nestQueue = asQueue(script.nest ?? NEST_OK); + const schemaQueue = asQueue(script.schema ?? SCHEMA_OK); + const sqlQueue = asQueue(script.sql ?? SQL_OK); + + function pop(queue: NuthatchHttpResult[]): NuthatchHttpResult { + const next = queue.shift(); + if (next === undefined) { + return { + ok: false, + status: null, + error: { kind: "transport", message: "script exhausted" }, + latencyMs: 0, + }; + } + return next; + } + + return { + health: () => Promise.resolve(pop(readyQueue)), + ready: () => Promise.resolve(pop(readyQueue)), + nest: () => Promise.resolve(pop(nestQueue)), + schema: () => Promise.resolve(pop(schemaQueue)), + explain: () => Promise.resolve(pop(schemaQueue)), + sql: () => Promise.resolve(pop(sqlQueue)), + }; +} + +export function err( + kind: "timeout" | "transport" | "http" | "invalid_json" | "oversize", + status: number | null = null, + message = "scripted failure", +): NuthatchHttpResult { + return { ok: false, status, error: { kind, message }, latencyMs: 1 }; +} + +export function run(script: Script) { + return fetchNuthatchFreshness({ + client: scriptClient(script), + clock: () => QUERIED_AT, + record: RECORD, + }); +} diff --git a/src/sources/nuthatch/adapter.test.ts b/src/sources/nuthatch/adapter.test.ts new file mode 100644 index 0000000..4c9723a --- /dev/null +++ b/src/sources/nuthatch/adapter.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; + +import { fetchNuthatchFreshness } from "./adapter.js"; +import { + BLOCK_HASH, + err, + NEST_OK, + POOL, + QUERIED_AT, + RECORD, + READY_OK, + REGISTRY_HASH, + ROW, + run, + SCHEMA_OK, + scriptClient, + SQL_OK, +} from "./adapter-fixtures.js"; +import { NUTHATCH_FRESHNESS_QUERY, NUTHATCH_FRESHNESS_QUERY_ID } from "./freshness-query.js"; +import type { NuthatchClient, NuthatchHttpResult } from "./client.js"; + +describe("fetchNuthatchFreshness — ok path", () => { + it("returns ok with normalized row, freshness, and provenance", async () => { + const result = await run({}); + expect(result.source_id).toBe("nuthatch-pool-swaps"); + expect(result.source_type).toBe("nuthatch_view"); + expect(result.protocol).toBe("uniswap-v3"); + expect(result.chain_id).toBe(8453); + expect(result.status).toBe("ok"); + expect(result.latency_ms).toBeGreaterThanOrEqual(0); + if (result.status !== "ok") { + return; + } + expect(result.data).toEqual({ + pool_address: POOL, + recent_swap_count_24h: 42, + last_swap_block: 48756852, + last_swap_block_timestamp: 1784303051, + last_swap_block_hash: BLOCK_HASH, + last_swap_tx_hash: "0x5a02a9e244c5191381b04066377bd9b06cec47b962074200dc0d9a4702357733", + last_swap_log_index: 21, + }); + expect(result.freshness).toEqual({ + indexed_block: 48756852, + indexed_block_timestamp: 1784303051, + indexed_block_hash: BLOCK_HASH, + queried_at: QUERIED_AT, + }); + expect(result.provenance).toEqual({ + deployment_or_view_id: REGISTRY_HASH, + schema_version: null, + methodology_version: null, + query_id: NUTHATCH_FRESHNESS_QUERY_ID, + }); + expect(result.warnings).toEqual([]); + }); + + it("lowercases a mixed-case pool address and hashes from the row", async () => { + const mixedRow = { + ...ROW, + pool_address: "0x6C561B446416E1A00E8E93E221854D6EA4171372", + last_swap_block_hash: "0xC2FD40C2F6B9A9B1E59737D6053354885B3336AC801A3F7C0340902420EC9DC2", + }; + const result = await run({ + sql: { ok: true, status: 200, body: { rows: [mixedRow] }, latencyMs: 1 }, + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.data.pool_address).toBe(POOL); + expect(result.data.last_swap_block_hash).toBe(BLOCK_HASH); + } + }); + + it("passes the fixed SQL and max_rows=1 to /sql", async () => { + const calls: Array<{ query: string; maxRows: number }> = []; + const client: NuthatchClient = { + ...scriptClient({}), + sql: (query, maxRows) => { + calls.push({ query, maxRows }); + return Promise.resolve(SQL_OK); + }, + }; + await fetchNuthatchFreshness({ client, clock: () => QUERIED_AT, record: RECORD }); + expect(calls).toEqual([{ query: NUTHATCH_FRESHNESS_QUERY, maxRows: 1 }]); + }); + + it("uses the injected clock for queried_at", async () => { + const result = await run({}); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.freshness.queried_at).toBe(QUERIED_AT); + } + }); +}); + +describe("fetchNuthatchFreshness — /ready 503 stale path", () => { + const READY_503: NuthatchHttpResult = { + ok: false, + status: 503, + error: { kind: "http", message: "/ready returned HTTP 503." }, + latencyMs: 1, + }; + + it("returns stale with retained freshness when /sql still answers a valid row", async () => { + const result = await run({ ready: READY_503 }); + expect(result.status).toBe("stale"); + expect(result.data).toBeNull(); + expect(result.freshness).toEqual({ + indexed_block: 48756852, + indexed_block_timestamp: 1784303051, + indexed_block_hash: BLOCK_HASH, + queried_at: QUERIED_AT, + }); + // /nest and /schema are not consulted on the stale path; provenance falls + // back to the registry-pinned values. + expect(result.provenance.deployment_or_view_id).toBe(REGISTRY_HASH); + }); + + it("returns stale with null freshness when /sql fails on transport", async () => { + const result = await run({ ready: READY_503, sql: err("transport") }); + expect(result.status).toBe("stale"); + expect(result.data).toBeNull(); + expect(result.freshness).toBeNull(); + }); + + it("returns stale with null freshness when /sql times out", async () => { + const result = await run({ ready: READY_503, sql: err("timeout") }); + expect(result.status).toBe("stale"); + expect(result.freshness).toBeNull(); + }); + + it("returns stale with null freshness when /sql returns zero rows", async () => { + const result = await run({ + ready: READY_503, + sql: { ok: true, status: 200, body: { rows: [] }, latencyMs: 1 }, + }); + expect(result.status).toBe("stale"); + expect(result.freshness).toBeNull(); + }); +}); + +describe("fetchNuthatchFreshness — readiness failures", () => { + it("maps /ready timeout to timeout with null freshness", async () => { + const result = await run({ ready: err("timeout") }); + expect(result.status).toBe("timeout"); + expect(result.data).toBeNull(); + expect(result.freshness).toBeNull(); + }); + + it("maps /ready transport failure to error", async () => { + const result = await run({ ready: err("transport") }); + expect(result.status).toBe("error"); + }); + + it("maps an unexpected /ready HTTP 500 to error", async () => { + const result = await run({ ready: err("http", 500) }); + expect(result.status).toBe("error"); + }); + + it("maps /ready invalid_json to error", async () => { + const result = await run({ ready: err("invalid_json") }); + expect(result.status).toBe("error"); + }); + + it("maps /ready oversize to error", async () => { + const result = await run({ ready: err("oversize") }); + expect(result.status).toBe("error"); + }); + + it("does not call /nest, /schema, or /sql when /ready fails non-503", async () => { + const calls: string[] = []; + const client: NuthatchClient = { + health: () => Promise.resolve(READY_OK), + ready: () => (calls.push("ready"), Promise.resolve(err("transport"))), + nest: () => (calls.push("nest"), Promise.resolve(NEST_OK)), + schema: () => (calls.push("schema"), Promise.resolve(SCHEMA_OK)), + explain: () => Promise.resolve(SCHEMA_OK), + sql: () => (calls.push("sql"), Promise.resolve(SQL_OK)), + }; + const result = await fetchNuthatchFreshness({ + client, + clock: () => QUERIED_AT, + record: RECORD, + }); + expect(result.status).toBe("error"); + expect(calls).toEqual(["ready"]); + }); +}); diff --git a/src/sources/nuthatch/adapter.ts b/src/sources/nuthatch/adapter.ts new file mode 100644 index 0000000..b6814a5 --- /dev/null +++ b/src/sources/nuthatch/adapter.ts @@ -0,0 +1,374 @@ +import type { NuthatchSourceRegistryRecord } from "../../registry/types.js"; +import { + BASE_CHAIN_ID, + type FailureSourceStatus, + type NuthatchFreshnessData, + type NuthatchSourceResult, + type SourceProvenance, +} from "../../schemas/source-adapter.js"; + +import type { NuthatchClient, NuthatchHttpResult } from "./client.js"; +import { + NUTHATCH_FRESHNESS_QUERY, + NUTHATCH_FRESHNESS_QUERY_ID, + NUTHATCH_FRESHNESS_VIEW, +} from "./freshness-query.js"; +import { + parseFreshnessRows, + parseNest, + parseSchema, + type FreshnessParseResult, + type NestParseResult, + type SchemaParseResult, +} from "./response.js"; + +export interface NuthatchAdapterDeps { + readonly client: NuthatchClient; + /** Epoch-second clock; inject for deterministic tests. */ + readonly clock: () => number; + readonly record: NuthatchSourceRegistryRecord; +} + +interface CallFailure { + readonly status: FailureSourceStatus; + readonly warning: string; +} + +function provenanceFor( + record: NuthatchSourceRegistryRecord, + observedViewId: string, + schemaVersion: string | null, +): SourceProvenance { + return { + deployment_or_view_id: observedViewId, + schema_version: schemaVersion, + methodology_version: record.methodology_version, + query_id: NUTHATCH_FRESHNESS_QUERY_ID, + }; +} + +function failedResult( + record: NuthatchSourceRegistryRecord, + status: Extract["status"], + options: { + readonly warnings: readonly string[]; + readonly latencyMs: number; + readonly freshness: NuthatchFreshness | null; + readonly observedViewId?: string; + readonly schemaVersion?: string | null; + }, +): NuthatchSourceResult { + return { + source_id: record.source_id, + source_type: record.source_type, + protocol: record.protocol, + chain_id: BASE_CHAIN_ID, + status, + data: null, + freshness: options.freshness, + provenance: provenanceFor( + record, + options.observedViewId ?? record.deployment_or_view_id, + options.schemaVersion ?? record.schema_version, + ), + warnings: [...options.warnings], + latency_ms: options.latencyMs, + }; +} + +/** + * Builds a contract-valid Nuthatch failure with registry-backed provenance. + * Live gateway setup failures use this rather than dropping the source or + * inventing a freshness observation. + */ +export function createNuthatchFailureResult( + record: NuthatchSourceRegistryRecord, + status: Extract["status"], + warning: string, +): NuthatchSourceResult { + return failedResult(record, status, { + warnings: [warning], + latencyMs: 0, + freshness: null, + }); +} + +function classifyHttpFailure(result: NuthatchHttpErr): FailureSourceStatus { + if (result.error.kind === "timeout") { + return "timeout"; + } + return "error"; +} + +type NuthatchHttpErr = Extract; + +function classifySqlFailure(result: NuthatchHttpErr): FailureSourceStatus { + return classifyHttpFailure(result); +} + +function classifyMetadataFailure(result: NuthatchHttpErr): FailureSourceStatus { + return classifyHttpFailure(result); +} + +/** + * Freshness shape required on every Nuthatch result: the indexed block hash is + * mandatory (the frozen `nuthatchSourceFreshnessSchema` re-asserts it as + * non-optional). The base `SourceFreshness` type leaves it optional, so this + * alias carries the stricter requirement through the adapter. + */ +interface NuthatchFreshness { + readonly indexed_block: number; + readonly indexed_block_timestamp: number; + readonly indexed_block_hash: string; + readonly queried_at: number; + readonly has_indexing_errors?: boolean; +} + +function deriveFreshness(row: NuthatchFreshnessData, queriedAt: number): NuthatchFreshness { + return { + indexed_block: row.last_swap_block, + indexed_block_timestamp: row.last_swap_block_timestamp, + indexed_block_hash: row.last_swap_block_hash, + queried_at: queriedAt, + }; +} + +function precedence(statuses: readonly FailureSourceStatus[]): FailureSourceStatus { + if (statuses.includes("timeout")) { + return "timeout"; + } + if (statuses.includes("unsupported")) { + return "unsupported"; + } + return "error"; +} + +/** + * Queries the Nuthatch freshness view and maps every operational or shape + * failure into a contract-valid {@link NuthatchSourceResult}. No exception + * escapes this boundary; a programmer-unexpected throw becomes `error`. + */ +export async function fetchNuthatchFreshness( + deps: NuthatchAdapterDeps, +): Promise { + const startedAt = performance.now(); + const { client, clock, record } = deps; + + const totalLatency = () => Math.round(performance.now() - startedAt); + + try { + if ( + record.source_type !== "nuthatch_view" || + record.chain_id !== BASE_CHAIN_ID || + record.status !== "active" || + record.locator.kind !== "nuthatch_view" || + record.locator.view_id !== NUTHATCH_FRESHNESS_VIEW + ) { + return failedResult(record, "unsupported", { + warnings: [ + `Registry record is not an active nuthatch_view bound to ${NUTHATCH_FRESHNESS_VIEW}.`, + ], + latencyMs: totalLatency(), + freshness: null, + }); + } + + const ready = await client.ready(); + + if (!ready.ok) { + if (ready.status === 503) { + // Readiness 503 takes precedence: still call /sql once for last-known + // freshness, then return stale regardless of SQL outcome. + const sql = await client.sql(NUTHATCH_FRESHNESS_QUERY, 1); + let freshness: NuthatchFreshness | null = null; + if (sql.ok) { + const parsed = parseFreshnessRows(sql.body); + if (parsed.ok) { + freshness = deriveFreshness(parsed.row, clock()); + } + } + return failedResult(record, "stale", { + warnings: [ + "/ready returned HTTP 503; freshness retained from last-known /sql row when available.", + ], + latencyMs: totalLatency(), + freshness, + }); + } + + const status = classifyHttpFailure(ready); + return failedResult(record, status, { + warnings: [ready.error.message], + latencyMs: totalLatency(), + freshness: null, + }); + } + + const [nest, schema, sql] = await Promise.all([ + client.nest(), + client.schema(), + client.sql(NUTHATCH_FRESHNESS_QUERY, 1), + ]); + + const queriedAt = clock(); + + // Parse each response. Failures are tracked as outcomes with precedence + // timeout > unsupported > error. Freshness is retained only from a valid + // /sql row, even on a non-ok aggregate status. + let observedViewId = record.deployment_or_view_id; + let schemaVersion: string | null = record.schema_version; + const outcomes: CallFailure[] = []; + const warnings: string[] = []; + + // /nest + const nestOutcome = evaluateNest(nest, record); + if (nestOutcome.status !== "ok") { + outcomes.push({ status: nestOutcome.status, warning: nestOutcome.warning }); + } + if (nestOutcome.warning !== "") { + warnings.push(nestOutcome.warning); + } + if (nest.ok) { + const parsed = parseNest(nest.body); + if (parsed.ok) { + observedViewId = parsed.registryHash; + } + } + + // /schema + const schemaOutcome = evaluateSchema(schema); + if (schemaOutcome.status !== "ok") { + outcomes.push({ status: schemaOutcome.status, warning: schemaOutcome.warning }); + } + if (schemaOutcome.warning !== "") { + warnings.push(schemaOutcome.warning); + } + if (schema.ok) { + const parsed = parseSchema(schema.body); + if (parsed.ok) { + schemaVersion = parsed.schemaVersion; + } + } + + // /sql + let freshness: NuthatchFreshness | null = null; + let rowData: NuthatchFreshnessData | null = null; + const sqlOutcome = evaluateSql(sql); + if (sqlOutcome.status !== "ok") { + outcomes.push({ status: sqlOutcome.status, warning: sqlOutcome.warning }); + } + if (sqlOutcome.warning !== "") { + warnings.push(sqlOutcome.warning); + } + if (sql.ok) { + const parsed = parseFreshnessRows(sql.body); + if (parsed.ok) { + rowData = parsed.row; + freshness = deriveFreshness(parsed.row, queriedAt); + } + } + + if (outcomes.length > 0) { + const status = precedence(outcomes.map((o) => o.status)); + return failedResult(record, status, { + warnings, + latencyMs: totalLatency(), + freshness, + observedViewId, + schemaVersion, + }); + } + + // All calls succeeded and parsed. rowData and freshness are non-null here. + if (rowData === null || freshness === null) { + // Defensive: should be unreachable given the outcomes check above. + return failedResult(record, "error", { + warnings, + latencyMs: totalLatency(), + freshness, + observedViewId, + schemaVersion, + }); + } + + return { + source_id: record.source_id, + source_type: record.source_type, + protocol: record.protocol, + chain_id: BASE_CHAIN_ID, + status: "ok", + data: rowData, + freshness, + provenance: provenanceFor(record, observedViewId, schemaVersion), + warnings, + latency_ms: totalLatency(), + }; + } catch (error) { + const message = + error instanceof Error + ? `Nuthatch adapter failed unexpectedly: ${error.name}` + : "Nuthatch adapter failed unexpectedly; details were redacted."; + return failedResult(record, "error", { + warnings: [message], + latencyMs: totalLatency(), + freshness: null, + }); + } +} + +function evaluateNest( + result: NuthatchHttpResult, + record: NuthatchSourceRegistryRecord, +): { status: FailureSourceStatus | "ok"; warning: string } { + if (!result.ok) { + return { + status: classifyMetadataFailure(result), + warning: result.error.message, + }; + } + const parsed: NestParseResult = parseNest(result.body); + if (!parsed.ok) { + return { status: "unsupported", warning: parsed.failure.message }; + } + if (parsed.registryHash !== record.deployment_or_view_id) { + return { + status: "unsupported", + warning: `Nest registry_hash mismatch: expected "${record.deployment_or_view_id}", received "${parsed.registryHash}".`, + }; + } + return { status: "ok", warning: "" }; +} + +function evaluateSchema(result: NuthatchHttpResult): { + status: FailureSourceStatus | "ok"; + warning: string; +} { + if (!result.ok) { + return { + status: classifyMetadataFailure(result), + warning: result.error.message, + }; + } + const parsed: SchemaParseResult = parseSchema(result.body); + if (!parsed.ok) { + return { status: "unsupported", warning: parsed.failure.message }; + } + return { status: "ok", warning: "" }; +} + +function evaluateSql(result: NuthatchHttpResult): { + status: FailureSourceStatus | "ok"; + warning: string; +} { + if (!result.ok) { + return { + status: classifySqlFailure(result), + warning: result.error.message, + }; + } + const parsed: FreshnessParseResult = parseFreshnessRows(result.body); + if (!parsed.ok) { + return { status: "unsupported", warning: parsed.failure.message }; + } + return { status: "ok", warning: "" }; +} diff --git a/src/sources/nuthatch/client.test.ts b/src/sources/nuthatch/client.test.ts new file mode 100644 index 0000000..c511604 --- /dev/null +++ b/src/sources/nuthatch/client.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it } from "vitest"; + +import { + createNuthatchClient, + httpErrorDetail, + NUTHATCH_DEFAULT_TIMEOUT_MS, + NUTHATCH_MAX_ROWS_CEILING, + NUTHATCH_MAX_ROWS_FLOOR, + resetNuthatchSemaphores, + type NuthatchHttpResult, +} from "./client.js"; + +interface FetchCall { + readonly url: URL; + readonly init: RequestInit; +} + +function jsonResponse( + status: number, + body: unknown, + headers: Record = {}, +): Response { + const text = JSON.stringify(body); + return new Response(text, { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function textResponse( + status: number, + body: string, + headers: Record = {}, +): Response { + return new Response(body, { + status, + headers: { "content-type": "text/plain; charset=utf-8", ...headers }, + }); +} + +function makeRecordingFetch(responses: Response[]): { + fetchImpl: typeof fetch; + calls: FetchCall[]; +} { + const calls: FetchCall[] = []; + const fetchImpl: typeof fetch = (input, init) => { + const inputUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + calls.push({ url: new URL(inputUrl), init: init ?? {} }); + const next = responses.shift(); + if (next === undefined) { + return Promise.resolve(new Response("no scripted response", { status: 599 })); + } + return Promise.resolve(next); + }; + return { fetchImpl, calls }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("createNuthatchClient", () => { + it("rejects an invalid baseUrl at construction", () => { + expect(() => createNuthatchClient({ baseUrl: "not-a-url" })).toThrow(); + }); + + it("rejects a baseUrl with embedded credentials", () => { + expect(() => createNuthatchClient({ baseUrl: "https://user:pass@example.test" })).toThrow(); + }); + + it("rejects non-loopback HTTP", () => { + expect(() => createNuthatchClient({ baseUrl: "http://example.test" })).toThrow(); + }); + + it("allows loopback HTTP for local tests", () => { + expect(() => createNuthatchClient({ baseUrl: "http://127.0.0.1:8080" })).not.toThrow(); + }); + + it("normalizes a single trailing slash from the base URL", async () => { + const { fetchImpl, calls } = makeRecordingFetch([jsonResponse(200, { ready: true })]); + const client = createNuthatchClient({ + baseUrl: "https://example.test/", + fetchImpl, + }); + await client.ready(); + expect(calls[0]?.url.href).toBe("https://example.test/ready"); + }); +}); + +describe("nuthatch client URL encoding", () => { + it("encodes the /sql query and max_rows via URLSearchParams, never raw concatenation", async () => { + const { fetchImpl, calls } = makeRecordingFetch([jsonResponse(200, { rows: [] })]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + await client.sql("SELECT * FROM pool_swap_freshness LIMIT 1", 1); + const call = calls[0]; + expect(call).toBeDefined(); + expect(call?.init.method).toBe("GET"); + expect(call?.url.pathname).toBe("/sql"); + expect(call?.url.searchParams.get("q")).toBe("SELECT * FROM pool_swap_freshness LIMIT 1"); + expect(call?.url.searchParams.get("max_rows")).toBe("1"); + }); + + it("encodes /explain with the q parameter only", async () => { + const { fetchImpl, calls } = makeRecordingFetch([jsonResponse(200, { valid: true })]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + await client.explain("SELECT 1"); + expect(calls[0]?.url.pathname).toBe("/explain"); + expect(calls[0]?.url.searchParams.get("q")).toBe("SELECT 1"); + expect(calls[0]?.url.searchParams.has("max_rows")).toBe(false); + }); + + it("issues GET with no body and no authorization header", async () => { + const { fetchImpl, calls } = makeRecordingFetch([textResponse(200, "ok")]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + await client.health(); + const init = calls[0]?.init; + expect(init?.method).toBe("GET"); + expect(init?.body).toBeUndefined(); + expect(init?.redirect).toBe("error"); + const headers = init?.headers; + const auth = + headers instanceof Headers + ? headers.get("authorization") + : typeof headers === "object" && headers !== null + ? (headers as Record).authorization + : null; + expect(auth).toBeNull(); + }); +}); + +describe("nuthatch client result mapping", () => { + it("returns ok with parsed JSON body for application/json 2xx", async () => { + const { fetchImpl } = makeRecordingFetch([jsonResponse(200, { ready: true })]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + const result = await client.ready(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.status).toBe(200); + expect(result.body).toEqual({ ready: true }); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + } + }); + + it("returns ok with string body for text/plain 2xx", async () => { + const { fetchImpl } = makeRecordingFetch([textResponse(200, "ok")]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + const result = await client.health(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.body).toBe("ok"); + expect(result.status).toBe(200); + } + }); + + it("maps a non-2xx HTTP response to an http error", async () => { + const { fetchImpl } = makeRecordingFetch([jsonResponse(500, { error: "boom" })]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + const result = await client.ready(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("http"); + expect(result.status).toBe(500); + expect(result.error.message).toContain("/ready"); + expect(result.error.message).toContain("boom"); + expect(result.error.message).not.toContain("https://example.test"); + } + }); + + it("includes a truncated Catalog Error detail from /sql HTTP 400 bodies", async () => { + const detail = + 'Catalog Error: Table with name wallet_swap_activity does not exist! Did you mean "pool__swap"?'; + const { fetchImpl } = makeRecordingFetch([jsonResponse(400, { error: detail })]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + const result = await client.sql("SELECT 1", 1); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe(`/sql returned HTTP 400: ${detail}`); + } + expect(httpErrorDetail({ error: `${"x".repeat(300)}` })?.endsWith("…")).toBe(true); + }); + + it("maps invalid JSON to invalid_json", async () => { + const { fetchImpl } = makeRecordingFetch([ + new Response("not-json", { status: 200, headers: { "content-type": "application/json" } }), + ]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + const result = await client.nest(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("invalid_json"); + } + }); + + it("maps an oversized declared Content-Length to oversize without reading the body", async () => { + const { fetchImpl, calls } = makeRecordingFetch([ + new Response("", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": String(64 * 1024 * 1024 + 1), + }, + }), + ]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + const result = await client.schema(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("oversize"); + } + // The byte ceiling must reject before the body is consumed. + expect(calls.length).toBe(1); + }); + + it("redacts transport failures to an endpoint-named message", async () => { + const fetchImpl = (() => + Promise.reject(new Error("ECONNREFUSED details"))) as unknown as typeof fetch; + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + const result = await client.nest(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("transport"); + expect(result.error.message).toContain("/nest"); + expect(result.error.message).not.toContain("ECONNREFUSED"); + } + }); + + it("treats maxRows outside 1..50000 as an invalid_argument without calling fetch", async () => { + const { fetchImpl, calls } = makeRecordingFetch([]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + for (const bad of [0, -1, 1.5, NUTHATCH_MAX_ROWS_CEILING + 1, Number.NaN]) { + const result = await client.sql("SELECT 1", bad); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("invalid_argument"); + } + } + expect(calls.length).toBe(0); + }); + + it("accepts the row-limit floor and ceiling", async () => { + const { fetchImpl, calls } = makeRecordingFetch([ + jsonResponse(200, { rows: [] }), + jsonResponse(200, { rows: [] }), + ]); + const client = createNuthatchClient({ baseUrl: "https://example.test", fetchImpl }); + await client.sql("SELECT 1", NUTHATCH_MAX_ROWS_FLOOR); + await client.sql("SELECT 1", NUTHATCH_MAX_ROWS_CEILING); + expect(calls[0]?.url.searchParams.get("max_rows")).toBe(String(NUTHATCH_MAX_ROWS_FLOOR)); + expect(calls[1]?.url.searchParams.get("max_rows")).toBe(String(NUTHATCH_MAX_ROWS_CEILING)); + }); + + it("defaults the timeout to 15000ms", () => { + expect(NUTHATCH_DEFAULT_TIMEOUT_MS).toBe(15_000); + }); +}); + +describe("nuthatch client timeout", () => { + it("maps an aborted request to a timeout error", async () => { + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }); + const client = createNuthatchClient({ + baseUrl: "https://example.test", + fetchImpl, + timeoutMs: 30, + }); + const result = await client.nest(); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.kind).toBe("timeout"); + expect(result.status).toBeNull(); + } + }); +}); + +describe("nuthatch per-origin SQL limiter", () => { + interface PendingFetch { + readonly url: URL; + readonly resolve: (response: Response) => void; + } + + function makeControlledFetch(): { fetchImpl: typeof fetch; pending: PendingFetch[] } { + const pending: PendingFetch[] = []; + const fetchImpl: typeof fetch = (input, init) => { + return new Promise((resolve) => { + const inputUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + pending.push({ url: new URL(inputUrl), resolve }); + // Touch the signal so AbortController does not warn about unhandled abort. + void init?.signal; + }); + }; + return { fetchImpl, pending }; + } + + it("allows at most two concurrent /sql requests per origin and queues the third", async () => { + resetNuthatchSemaphores(); + const { fetchImpl, pending } = makeControlledFetch(); + const client = createNuthatchClient({ + baseUrl: "https://example.test", + fetchImpl, + timeoutMs: 5_000, + }); + + const p1 = client.sql("SELECT 1", 1); + const p2 = client.sql("SELECT 2", 1); + const p3 = client.sql("SELECT 3", 1); + + // Yield so acquisition + fetch dispatch settles. + await sleep(0); + expect(pending.length).toBe(2); + + // Release one permit; the queued third request must then start. + pending[0]?.resolve(jsonResponse(200, { rows: [] })); + await p1; + await sleep(0); + expect(pending.length).toBe(3); + + pending[1]?.resolve(jsonResponse(200, { rows: [] })); + pending[2]?.resolve(jsonResponse(200, { rows: [] })); + await Promise.all([p2, p3]); + }); + + it("releases a permit when an in-flight request is aborted", async () => { + resetNuthatchSemaphores(); + const inflight: Array<(err: Error) => void> = []; + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_, reject) => { + inflight.push(reject); + init?.signal?.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + }); + const client = createNuthatchClient({ + baseUrl: "https://example.test", + fetchImpl, + timeoutMs: 20, + }); + + const p1 = client.sql("SELECT 1", 1); + const p2 = client.sql("SELECT 2", 1); + // Third will time out while queued unless a permit frees. + const p3 = client.sql("SELECT 3", 1); + await sleep(0); + + // Wait for p1 to time out (20ms). The permit must release and p3 must + // acquire it rather than timing out while queued. + const r1: NuthatchHttpResult = await p1; + expect(r1.ok).toBe(false); + + // p3 should now be in flight (permit released by p1's timeout). + await sleep(0); + // p2 is still holding the second permit; resolve it so the suite cleans up. + inflight[0]?.(new Error("done")); + await p2.catch(() => undefined); + + // p3 acquired the released permit; let it complete by aborting via timeout. + const r3 = await p3; + expect(r3.ok).toBe(false); + }); +}); diff --git a/src/sources/nuthatch/client.ts b/src/sources/nuthatch/client.ts new file mode 100644 index 0000000..ee4d8d2 --- /dev/null +++ b/src/sources/nuthatch/client.ts @@ -0,0 +1,433 @@ +/** + * Bounded read-only HTTP client for Nuthatch v0.6.1 GET endpoints. + * + * The client is dependency-injected: tests pass a fake `fetch`, production + * passes the global. It never throws for operational conditions (timeout, + * transport failure, HTTP error, invalid JSON, oversized response); every + * failure is returned as a discriminated {@link NuthatchHttpResult}. The only + * throws are programmer errors (invalid arguments to the constructor or + * method calls), which the adapter boundary catches and maps to `error`. + */ + +export const NUTHATCH_DEFAULT_TIMEOUT_MS = 15_000 as const; +export const NUTHATCH_MAX_ROWS_FLOOR = 1 as const; +export const NUTHATCH_MAX_ROWS_CEILING = 50_000 as const; +export const NUTHATCH_RESPONSE_BYTE_CEILING = 64 * 1024 * 1024; + +export type NuthatchHttpErrorKind = + "timeout" | "transport" | "http" | "invalid_json" | "oversize" | "invalid_argument"; + +export interface NuthatchHttpError { + readonly kind: NuthatchHttpErrorKind; + readonly message: string; +} + +export interface NuthatchHttpOk { + readonly ok: true; + readonly status: number; + readonly body: unknown; + readonly latencyMs: number; +} + +export interface NuthatchHttpErr { + readonly ok: false; + readonly status: number | null; + readonly error: NuthatchHttpError; + readonly latencyMs: number; +} + +export type NuthatchHttpResult = NuthatchHttpOk | NuthatchHttpErr; + +export interface NuthatchClientOptions { + readonly baseUrl: string; + readonly fetchImpl?: typeof fetch; + readonly timeoutMs?: number; +} + +interface Permit { + readonly release: () => void; +} + +interface Semaphore { + available: number; + queue: Array<() => void>; +} + +const semaphores = new Map(); + +function originKey(url: URL): string { + return `${url.protocol}//${url.host}`; +} + +function getSemaphore(origin: string): Semaphore { + let sem = semaphores.get(origin); + if (sem === undefined) { + sem = { available: 2, queue: [] }; + semaphores.set(origin, sem); + } + return sem; +} + +function drain(sem: Semaphore): void { + while (sem.available > 0 && sem.queue.length > 0) { + const next = sem.queue.shift(); + if (next !== undefined) { + sem.available -= 1; + next(); + } + } +} + +function acquireWithDeadline(origin: string, deadlineAt: number): Promise { + return new Promise((resolve) => { + const sem = getSemaphore(origin); + const remaining = deadlineAt - Date.now(); + if (remaining <= 0) { + resolve(null); + return; + } + + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + // The queued callback will no-op when invoked because `settled` is true. + resolve(null); + }, remaining); + + sem.queue.push(() => { + if (settled) { + // Permit arrived after the deadline expired; release immediately so + // another queued caller can use it. + sem.available += 1; + drain(sem); + return; + } + settled = true; + clearTimeout(timer); + resolve({ + release: () => { + sem.available += 1; + drain(sem); + }, + }); + }); + drain(sem); + }); +} + +interface NormalizedBase { + readonly url: URL; + readonly origin: string; +} + +function normalizeBaseUrl(raw: string): NormalizedBase { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("Nuthatch baseUrl is not a valid URL."); + } + + if (url.username !== "" || url.password !== "") { + throw new Error("Nuthatch baseUrl must not embed credentials."); + } + + const isLoopbackHttp = + url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"); + if (url.protocol !== "https:" && !isLoopbackHttp) { + throw new Error("Nuthatch baseUrl must use HTTPS, except loopback HTTP for local tests."); + } + + // Normalize away a single trailing slash on the path root so endpoint paths + // compose without producing `//`. + if (url.pathname.endsWith("/") && url.pathname !== "/") { + url.pathname = url.pathname.replace(/\/+$/, ""); + } + + return { url, origin: originKey(url) }; +} + +function isJsonContentType(response: Response): boolean { + const ct = response.headers.get("content-type"); + if (ct === null) { + return false; + } + return ct.toLowerCase().includes("application/json"); +} + +async function readBoundedText( + response: Response, + ceiling: number, +): Promise<{ ok: true; text: string } | { ok: false; reason: "oversize" }> { + const declared = response.headers.get("content-length"); + if (declared !== null) { + const parsed = Number(declared); + if (Number.isFinite(parsed) && parsed > ceiling) { + return { ok: false, reason: "oversize" }; + } + } + + if (response.body === null) { + const text = await response.text(); + if (text.length > ceiling) { + return { ok: false, reason: "oversize" }; + } + return { ok: true, text }; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let received = 0; + let text = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) { + break; + } + if (value !== undefined) { + received += value.length; + if (received > ceiling) { + try { + await reader.cancel(); + } catch { + /* ignore */ + } + return { ok: false, reason: "oversize" }; + } + text += decoder.decode(value, { stream: true }); + } + } + text += decoder.decode(); + } finally { + try { + reader.releaseLock(); + } catch { + /* ignore */ + } + } + + return { ok: true, text }; +} + +function safeErrorMessage(error: unknown, endpoint: string, timedOut: boolean): string { + if (timedOut) { + return `${endpoint} request exceeded the configured timeout.`; + } + if (error instanceof Error && error.name === "AbortError") { + return `${endpoint} request was aborted.`; + } + return `${endpoint} request failed; details were redacted.`; +} + +const HTTP_ERROR_DETAIL_MAX = 240; + +/** + * Pull a short, credential-free detail from a Nuthatch error body so adapters + * can distinguish Catalog Error (missing view) from other HTTP failures. + */ +export function httpErrorDetail(body: unknown): string | null { + let raw: string | null = null; + if (typeof body === "string" && body.trim() !== "") { + raw = body.trim(); + } else if (body !== null && typeof body === "object" && !Array.isArray(body)) { + const error = (body as { readonly error?: unknown }).error; + if (typeof error === "string" && error.trim() !== "") { + raw = error.trim(); + } + } + if (raw === null) { + return null; + } + const compact = raw.replace(/\s+/g, " "); + if (compact.length <= HTTP_ERROR_DETAIL_MAX) { + return compact; + } + return `${compact.slice(0, HTTP_ERROR_DETAIL_MAX)}…`; +} + +export interface NuthatchClient { + readonly health: () => Promise; + readonly ready: () => Promise; + readonly nest: () => Promise; + readonly schema: () => Promise; + readonly explain: (query: string) => Promise; + readonly sql: (query: string, maxRows: number) => Promise; +} + +export function createNuthatchClient(options: NuthatchClientOptions): NuthatchClient { + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? NUTHATCH_DEFAULT_TIMEOUT_MS; + const base = normalizeBaseUrl(options.baseUrl); + + async function request( + endpoint: string, + path: string, + query: URLSearchParams | null, + options: { readonly consumeSqlPermit: boolean }, + ): Promise { + const url = new URL(path, base.url); + if (query !== null) { + url.search = query.toString(); + } + + const deadlineAt = Date.now() + timeoutMs; + const startedAt = performance.now(); + + let permit: Permit | null = null; + if (options.consumeSqlPermit) { + permit = await acquireWithDeadline(base.origin, deadlineAt); + if (permit === null) { + return { + ok: false, + status: null, + error: { + kind: "timeout", + message: `${endpoint} request exceeded the configured timeout while queued.`, + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } + } + + const controller = new AbortController(); + const remaining = Math.max(1, deadlineAt - Date.now()); + const timer = setTimeout(() => controller.abort(), remaining); + + try { + const response = await fetchImpl(url, { + method: "GET", + signal: controller.signal, + redirect: "error", + }); + + const bounded = await readBoundedText(response, NUTHATCH_RESPONSE_BYTE_CEILING); + if (!bounded.ok) { + return { + ok: false, + status: response.status, + error: { kind: "oversize", message: `${endpoint} response exceeded the byte ceiling.` }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } + + const contentTypeJson = isJsonContentType(response); + let body: unknown; + if (contentTypeJson) { + try { + body = JSON.parse(bounded.text) as unknown; + } catch { + return { + ok: false, + status: response.status, + error: { kind: "invalid_json", message: `${endpoint} returned a non-JSON response.` }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } + } else { + body = bounded.text; + } + + if (response.status < 200 || response.status >= 300) { + const detail = httpErrorDetail(body); + return { + ok: false, + status: response.status, + error: { + kind: "http", + message: + detail === null + ? `${endpoint} returned HTTP ${String(response.status)}.` + : `${endpoint} returned HTTP ${String(response.status)}: ${detail}`, + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } + + return { + ok: true, + status: response.status, + body, + latencyMs: Math.round(performance.now() - startedAt), + }; + } catch (error) { + const timedOut = error instanceof Error && error.name === "AbortError"; + return { + ok: false, + status: null, + error: { + kind: timedOut ? "timeout" : "transport", + message: safeErrorMessage(error, endpoint, timedOut), + }, + latencyMs: Math.round(performance.now() - startedAt), + }; + } finally { + clearTimeout(timer); + if (permit !== null) { + permit.release(); + } + } + } + + function validateMaxRows(maxRows: number): NuthatchHttpErr | null { + if ( + !Number.isInteger(maxRows) || + maxRows < NUTHATCH_MAX_ROWS_FLOOR || + maxRows > NUTHATCH_MAX_ROWS_CEILING + ) { + return { + ok: false, + status: null, + error: { + kind: "invalid_argument", + message: `max_rows must be an integer in 1..50000; received ${String(maxRows)}.`, + }, + latencyMs: 0, + }; + } + return null; + } + + return { + health() { + return request("/health", "/health", null, { consumeSqlPermit: false }); + }, + ready() { + return request("/ready", "/ready", null, { consumeSqlPermit: false }); + }, + nest() { + return request("/nest", "/nest", null, { consumeSqlPermit: false }); + }, + schema() { + return request("/schema", "/schema", null, { consumeSqlPermit: false }); + }, + explain(query: string) { + const params = new URLSearchParams(); + params.set("q", query); + return request("/explain", "/explain", params, { consumeSqlPermit: false }); + }, + sql(query: string, maxRows: number) { + const invalid = validateMaxRows(maxRows); + if (invalid !== null) { + return Promise.resolve(invalid); + } + const params = new URLSearchParams(); + params.set("q", query); + params.set("max_rows", String(maxRows)); + return request("/sql", "/sql", params, { consumeSqlPermit: true }); + }, + }; +} + +/** + * Drops every shared per-origin SQL semaphore. Tests that assert on + * concurrency isolation call this between cases so a prior test's released + * permits do not mask a fresh client's behavior. + */ +export function resetNuthatchSemaphores(): void { + semaphores.clear(); +} diff --git a/src/sources/nuthatch/freshness-query.ts b/src/sources/nuthatch/freshness-query.ts new file mode 100644 index 0000000..e1ab1fe --- /dev/null +++ b/src/sources/nuthatch/freshness-query.ts @@ -0,0 +1,19 @@ +/** + * Locked freshness query for the M5 Nuthatch adapter. The same string is used + * by `/sql`, `/explain`, unit tests, CI, and the live smoke; no caller may + * alter the SQL or interpolate a user-supplied value. + */ +export const NUTHATCH_FRESHNESS_QUERY_ID = "nuthatch-pool-swap-freshness-v1" as const; + +/** + * Deployed view name on the M4 Nuthatch nest. The adapter rejects any registry + * record whose `locator.view_id` differs from this constant. + */ +export const NUTHATCH_FRESHNESS_VIEW = "pool_swap_freshness" as const; + +/** + * Fixed read-only SQL selecting exactly one row from the deployed freshness + * view. The selected pool belongs to the registry/view configuration; M5 does + * not interpolate a user-supplied address. + */ +export const NUTHATCH_FRESHNESS_QUERY = "SELECT * FROM pool_swap_freshness LIMIT 1" as const; diff --git a/src/sources/nuthatch/large-swaps-adapter.ts b/src/sources/nuthatch/large-swaps-adapter.ts new file mode 100644 index 0000000..930ee08 --- /dev/null +++ b/src/sources/nuthatch/large-swaps-adapter.ts @@ -0,0 +1,430 @@ +import { M0_CORE_POLICY } from "../../policy/index.js"; +import type { NuthatchSourceRegistryRecord } from "../../registry/types.js"; +import type { ResultFreshness, ResultProvenance, SwapEvent } from "../../schemas/index.js"; +import { BASE_CHAIN_ID } from "../../schemas/source-adapter.js"; +import { LSS_SCOPE } from "../../scope/large-swaps.js"; +import { + deduplicateSwapEvents, + normalizeLssSwapEvent, + NormalizationError, + swapMeetsTokenThreshold, +} from "../../normalization/index.js"; +import type { + LargeSwapSourceFailureStatus, + LargeSwapSourceResult, +} from "../../tools/large-swaps-source.js"; +import type { LargeSwapQueryContext } from "../../tools/large-swaps-query.js"; + +import type { NuthatchClient, NuthatchHttpResult } from "./client.js"; +import { + buildLargeSwapHeadQuery, + buildLargeSwapScanQuery, + NUTHATCH_LARGE_SWAP_QUERY_ID, + NUTHATCH_LARGE_SWAP_VIEW, +} from "./large-swaps-query.js"; +import { + parseLargeSwapReady, + parseLargeSwapReceipt, + type NuthatchLargeSwapRow, +} from "./large-swaps-response.js"; +import { parseNest, parseSchema } from "./response.js"; + +export interface NuthatchLargeSwapAdapterDeps { + readonly client: NuthatchClient; + readonly clock: () => number; + readonly record: NuthatchSourceRegistryRecord; +} + +function provenanceFor( + record: NuthatchSourceRegistryRecord, + observedViewId = record.deployment_or_view_id, + schemaVersion: string | null = record.schema_version, +): ResultProvenance { + return { + source_id: record.source_id, + source_type: record.source_type, + protocol: record.protocol, + chain_id: BASE_CHAIN_ID, + deployment_or_view_id: observedViewId, + schema_version: schemaVersion, + methodology_version: record.methodology_version, + query_id: NUTHATCH_LARGE_SWAP_QUERY_ID, + }; +} + +export function largeSwapSourceProvenance(record: NuthatchSourceRegistryRecord): ResultProvenance { + return provenanceFor(record); +} + +function failed( + record: NuthatchSourceRegistryRecord, + status: LargeSwapSourceFailureStatus, + warning: string, + options: { + readonly observedViewId?: string; + readonly schemaVersion?: string | null; + } = {}, +): LargeSwapSourceResult { + return { + status, + events: null, + indexedHead: null, + freshness: { + source_id: record.source_id, + status: "unavailable", + }, + provenance: provenanceFor( + record, + options.observedViewId ?? record.deployment_or_view_id, + options.schemaVersion === undefined ? record.schema_version : options.schemaVersion, + ), + warnings: [warning], + }; +} + +export function createLargeSwapSourceFailure( + record: NuthatchSourceRegistryRecord, + status: LargeSwapSourceFailureStatus, + warning: string, +): LargeSwapSourceResult { + return failed(record, status, warning); +} + +function httpFailureStatus(result: Extract) { + return result.error.kind === "timeout" ? ("timeout" as const) : ("error" as const); +} + +function rowPosition(row: NuthatchLargeSwapRow) { + return { + block_number: row.block_number, + log_index: row.log_index, + transaction_hash: row.transaction_hash, + }; +} + +function normalizeRow(row: NuthatchLargeSwapRow, sourceId: string) { + return normalizeLssSwapEvent({ + chain_id: BASE_CHAIN_ID, + protocol: LSS_SCOPE.protocol, + pool: row.pool_address, + transaction_hash: row.transaction_hash, + log_index: row.log_index, + block_number: row.block_number, + timestamp: row.block_timestamp, + amount0_raw: row.amount0_raw, + amount1_raw: row.amount1_raw, + source_id: sourceId, + }); +} + +function validateRecord(record: NuthatchSourceRegistryRecord): string | null { + if ( + record.source_id !== LSS_SCOPE.source.sourceId || + record.source_type !== "nuthatch_view" || + record.protocol !== LSS_SCOPE.protocol || + record.chain_id !== BASE_CHAIN_ID || + record.status !== "active" || + record.locator.kind !== "nuthatch_view" || + record.locator.view_id !== NUTHATCH_LARGE_SWAP_VIEW || + !record.supported_entities.includes(NUTHATCH_LARGE_SWAP_VIEW) || + record.methodology_version !== LSS_SCOPE.source.methodologyVersion + ) { + return `Registry record is not the active ${NUTHATCH_LARGE_SWAP_VIEW} capability.`; + } + return null; +} + +async function metadata(deps: NuthatchLargeSwapAdapterDeps): Promise< + | { + readonly ok: true; + readonly indexedHead: number; + readonly observedViewId: string; + readonly schemaVersion: string | null; + } + | { readonly ok: false; readonly result: LargeSwapSourceResult } +> { + const readyResult = await deps.client.ready(); + if (!readyResult.ok) { + return { + ok: false, + result: failed( + deps.record, + readyResult.status === 503 ? "stale" : httpFailureStatus(readyResult), + readyResult.status === 503 + ? "Nuthatch is not ready to serve a stable large-swap snapshot." + : readyResult.error.message, + ), + }; + } + const ready = parseLargeSwapReady(readyResult.body); + if (ready === null) { + return { + ok: false, + result: failed(deps.record, "unsupported", "Nuthatch /ready response shape is unsupported."), + }; + } + if (!ready.ready) { + return { + ok: false, + result: failed(deps.record, "stale", "Nuthatch is not ready to serve large swaps."), + }; + } + + const [nestResult, schemaResult] = await Promise.all([deps.client.nest(), deps.client.schema()]); + if (!nestResult.ok) { + return { + ok: false, + result: failed(deps.record, httpFailureStatus(nestResult), nestResult.error.message), + }; + } + const nest = parseNest(nestResult.body); + if (!nest.ok) { + return { + ok: false, + result: failed(deps.record, "unsupported", nest.failure.message), + }; + } + if (nest.registryHash !== deps.record.deployment_or_view_id) { + return { + ok: false, + result: failed( + deps.record, + "unsupported", + "Nuthatch registry hash does not match the configured large-swap capability.", + { observedViewId: nest.registryHash }, + ), + }; + } + + if (!schemaResult.ok) { + return { + ok: false, + result: failed(deps.record, httpFailureStatus(schemaResult), schemaResult.error.message, { + observedViewId: nest.registryHash, + }), + }; + } + const schema = parseSchema(schemaResult.body); + if (!schema.ok) { + return { + ok: false, + result: failed(deps.record, "unsupported", schema.failure.message, { + observedViewId: nest.registryHash, + }), + }; + } + + return { + ok: true, + indexedHead: ready.indexedHead, + observedViewId: nest.registryHash, + schemaVersion: schema.schemaVersion, + }; +} + +/** + * Reads a stable, bounded keyset window from the allowlisted swap-search view. + * It scans until enough exact post-normalization matches exist for one page + * plus lookahead, or until the source is exhausted. + */ +export async function fetchNuthatchLargeSwapCandidates( + deps: NuthatchLargeSwapAdapterDeps, + context: LargeSwapQueryContext, +): Promise { + const startedAt = performance.now(); + const deadlineExceeded = () => + performance.now() - startedAt >= M0_CORE_POLICY.gateway.endToEndTimeoutMs; + const invalidRecord = validateRecord(deps.record); + if (invalidRecord !== null) { + return failed(deps.record, "unsupported", invalidRecord); + } + + try { + const meta = await metadata(deps); + if (!meta.ok) { + return meta.result; + } + if (deadlineExceeded()) { + return failed(deps.record, "timeout", "Large-swap source deadline expired.", { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + + const headResult = await deps.client.sql(buildLargeSwapHeadQuery(meta.indexedHead), 1); + if (!headResult.ok) { + return failed(deps.record, httpFailureStatus(headResult), headResult.error.message, { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + const headReceipt = parseLargeSwapReceipt(headResult.body, deps.record.deployment_or_view_id); + if (headReceipt === null) { + return failed(deps.record, "unsupported", "Large-swap head receipt is invalid.", { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + const head = headReceipt.rows[0]; + if (head === undefined || headReceipt.rows.length !== 1) { + return failed( + deps.record, + "unsupported", + "Large-swap head receipt must contain exactly one event.", + { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }, + ); + } + + // The cursor freezes the latest searchable swap. The /ready indexed head + // bounds every query but does not expose a block hash or timestamp. + const currentHead = head.block_number; + const snapshotHead = context.snapshotHead ?? currentHead; + if (snapshotHead > currentHead || snapshotHead > meta.indexedHead) { + return failed( + deps.record, + "unsupported", + "Large-swap source is behind the cursor snapshot.", + { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }, + ); + } + + const queriedAt = deps.clock(); + const lagSeconds = queriedAt - head.block_timestamp; + if (!Number.isSafeInteger(queriedAt) || queriedAt < 0 || lagSeconds < 0) { + return failed(deps.record, "unsupported", "Large-swap source freshness is invalid.", { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + if (lagSeconds > M0_CORE_POLICY.freshness.qualityStaleAfterSeconds) { + return failed(deps.record, "stale", "Large-swap source is stale.", { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + + const freshness: Extract & { + readonly status: "fresh"; + } = { + source_id: deps.record.source_id, + status: "fresh", + indexed_block: currentHead, + indexed_block_timestamp: head.block_timestamp, + indexed_block_hash: head.block_hash, + queried_at: queriedAt, + lag_seconds: lagSeconds, + }; + + const requiredMatches = context.request.limit + 1 + (context.lastEvent === null ? 0 : 1); + let after = context.lastEvent; + let includeAfter = context.lastEvent !== null; + let scanned = 0; + let normalized: SwapEvent[] = []; + + for (;;) { + if (deadlineExceeded()) { + return failed(deps.record, "timeout", "Large-swap source deadline expired.", { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + if (scanned >= LSS_SCOPE.scan.maximumRows) { + return failed( + deps.record, + "error", + "Large-swap bounded scan reached its safety ceiling before proving page completion.", + { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }, + ); + } + const queryLimit = Math.min(LSS_SCOPE.scan.batchSize, LSS_SCOPE.scan.maximumRows - scanned); + const query = buildLargeSwapScanQuery({ + snapshotHead, + after, + includeAfter, + limit: queryLimit, + }); + const pageResult = await deps.client.sql(query, queryLimit); + if (!pageResult.ok) { + return failed(deps.record, httpFailureStatus(pageResult), pageResult.error.message, { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + const receipt = parseLargeSwapReceipt(pageResult.body, deps.record.deployment_or_view_id); + if (receipt === null || receipt.asOf < snapshotHead || receipt.rows.length > queryLimit) { + return failed(deps.record, "unsupported", "Large-swap page receipt is invalid.", { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }); + } + + scanned += receipt.rows.length; + let matches: SwapEvent[]; + try { + normalized = deduplicateSwapEvents([ + ...normalized, + ...receipt.rows.map((row) => normalizeRow(row, deps.record.source_id)), + ]); + matches = normalized.filter((event) => + swapMeetsTokenThreshold( + event, + context.request.threshold_token, + context.request.min_amount, + ), + ); + } catch (error) { + if (error instanceof NormalizationError) { + return failed( + deps.record, + "unsupported", + "Large-swap receipt violates canonical swap semantics.", + { + observedViewId: meta.observedViewId, + schemaVersion: meta.schemaVersion, + }, + ); + } + throw error; + } + if (matches.length >= requiredMatches || receipt.rows.length < queryLimit) { + return { + status: "ok", + events: matches, + indexedHead: currentHead, + freshness, + provenance: provenanceFor(deps.record, meta.observedViewId, meta.schemaVersion), + warnings: [], + }; + } + + const last = receipt.rows.at(-1); + if (last === undefined) { + return { + status: "ok", + events: matches, + indexedHead: currentHead, + freshness, + provenance: provenanceFor(deps.record, meta.observedViewId, meta.schemaVersion), + warnings: [], + }; + } + after = rowPosition(last); + includeAfter = false; + } + } catch { + return failed( + deps.record, + "error", + "Large-swap adapter failed unexpectedly; details were redacted.", + ); + } +} diff --git a/src/sources/nuthatch/large-swaps-query.ts b/src/sources/nuthatch/large-swaps-query.ts new file mode 100644 index 0000000..0492f0b --- /dev/null +++ b/src/sources/nuthatch/large-swaps-query.ts @@ -0,0 +1,93 @@ +import { LSS_SCOPE } from "../../scope/large-swaps.js"; +import type { LargeSwapEventPosition } from "../../tools/large-swaps-query.js"; + +const TRANSACTION_HASH_PATTERN = /^0x[0-9a-f]{64}$/; + +/** + * Project the indexed pool Swap table into the large-swap receipt shape. + * Query `pool__swap` directly (same projection as `pool_swap_search`) so scans + * stay live when authored views are missing. Order and keyset-compare `tx_hash` + * with string literals — do not CAST bytes32 to VARCHAR (Nuthatch/DuckDB + * returns HTTP 400 for that cast). + */ +const SELECT_COLUMNS = `SELECT + address AS pool_address, + block_number, + block_hash, + block_timestamp, + tx_hash AS transaction_hash, + log_index, + amount0 AS amount0_raw, + amount1 AS amount1_raw +FROM pool__swap`; + +function safeInteger(value: number, name: string): string { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer.`); + } + return String(value); +} + +function assertPosition(position: LargeSwapEventPosition): void { + safeInteger(position.block_number, "position.block_number"); + safeInteger(position.log_index, "position.log_index"); + if (!TRANSACTION_HASH_PATTERN.test(position.transaction_hash)) { + throw new Error("position.transaction_hash must be a lowercase transaction hash."); + } +} + +export function buildLargeSwapHeadQuery(indexedHead: number): string { + const head = safeInteger(indexedHead, "indexedHead"); + return `${SELECT_COLUMNS} +WHERE block_number <= ${head} +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC +LIMIT 1`; +} + +export interface LargeSwapScanQueryOptions { + readonly snapshotHead: number; + readonly after: LargeSwapEventPosition | null; + readonly includeAfter: boolean; + readonly limit: number; +} + +/** + * Builds only from validated numeric coordinates and a lowercase hash. Pool, + * token, threshold, and arbitrary request text never enter the SQL template. + */ +export function buildLargeSwapScanQuery(options: LargeSwapScanQueryOptions): string { + const snapshotHead = safeInteger(options.snapshotHead, "snapshotHead"); + if ( + !Number.isSafeInteger(options.limit) || + options.limit < 1 || + options.limit > LSS_SCOPE.scan.batchSize + ) { + throw new Error("Large-swap scan limit is outside the internal batch bound."); + } + + let keyset = ""; + if (options.after !== null) { + assertPosition(options.after); + const block = safeInteger(options.after.block_number, "after.block_number"); + const log = safeInteger(options.after.log_index, "after.log_index"); + const hashOperator = options.includeAfter ? ">=" : ">"; + keyset = ` + AND ( + block_number < ${block} + OR (block_number = ${block} AND log_index < ${log}) + OR ( + block_number = ${block} + AND log_index = ${log} + AND tx_hash ${hashOperator} '${options.after.transaction_hash}' + ) + )`; + } + + return `${SELECT_COLUMNS} +WHERE block_number <= ${snapshotHead}${keyset} +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC +LIMIT ${String(options.limit)}`; +} + +export const NUTHATCH_LARGE_SWAP_QUERY_ID = LSS_SCOPE.source.queryId; +export const NUTHATCH_LARGE_SWAP_VIEW = LSS_SCOPE.source.viewId; diff --git a/src/sources/nuthatch/large-swaps-response.ts b/src/sources/nuthatch/large-swaps-response.ts new file mode 100644 index 0000000..f0d7f7f --- /dev/null +++ b/src/sources/nuthatch/large-swaps-response.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +const safeNonNegativeIntegerSchema = z + .number() + .int() + .nonnegative() + .refine(Number.isSafeInteger, "Expected a safe integer"); +const hashSchema = z.string().regex(/^0x[0-9a-fA-F]{64}$/); +const addressSchema = z.string().regex(/^0x[0-9a-fA-F]{40}$/); +const signedIntegerSchema = z + .string() + .max(79) + .regex(/^(?:0|-?[1-9]\d*)$/); + +const readySchema = z + .object({ + ready: z.boolean(), + last_block: safeNonNegativeIntegerSchema.optional(), + sealed_through: safeNonNegativeIntegerSchema.optional(), + tip: safeNonNegativeIntegerSchema, + }) + .passthrough() + .superRefine((value, context) => { + if (value.last_block === undefined && value.sealed_through === undefined) { + context.addIssue({ + code: "custom", + message: "Expected last_block or sealed_through", + }); + } + }); + +export interface LargeSwapReady { + readonly ready: boolean; + readonly indexedHead: number; + readonly tip: number; +} + +export function parseLargeSwapReady(value: unknown): LargeSwapReady | null { + const parsed = readySchema.safeParse(value); + if (!parsed.success) { + return null; + } + const indexedHead = parsed.data.last_block ?? parsed.data.sealed_through; + if (indexedHead === undefined || parsed.data.tip < indexedHead) { + return null; + } + return { + ready: parsed.data.ready, + indexedHead, + tip: parsed.data.tip, + }; +} + +const rowSchema = z + .object({ + pool_address: addressSchema, + block_number: safeNonNegativeIntegerSchema, + block_hash: hashSchema, + block_timestamp: safeNonNegativeIntegerSchema, + transaction_hash: hashSchema, + log_index: safeNonNegativeIntegerSchema, + amount0_raw: signedIntegerSchema, + amount1_raw: signedIntegerSchema, + }) + .strict(); + +const receiptSchema = z + .object({ + count: safeNonNegativeIntegerSchema, + provenance: z + .object({ + as_of: safeNonNegativeIntegerSchema, + registry_hash: hashSchema, + sealed_through: safeNonNegativeIntegerSchema, + source: z.string().min(1), + }) + .passthrough(), + rows: z.array(rowSchema), + truncated: z.boolean(), + }) + .passthrough(); + +export type NuthatchLargeSwapRow = z.infer; + +export interface NuthatchLargeSwapReceipt { + readonly asOf: number; + readonly rows: readonly NuthatchLargeSwapRow[]; +} + +export function parseLargeSwapReceipt( + value: unknown, + expectedRegistryHash: string, +): NuthatchLargeSwapReceipt | null { + const parsed = receiptSchema.safeParse(value); + if (!parsed.success) { + return null; + } + const receipt = parsed.data; + if ( + receipt.truncated || + receipt.count !== receipt.rows.length || + receipt.provenance.registry_hash.toLowerCase() !== expectedRegistryHash.toLowerCase() || + receipt.rows.some((row) => row.block_number > receipt.provenance.as_of) + ) { + return null; + } + + return { + asOf: receipt.provenance.as_of, + rows: receipt.rows.map((row) => ({ + ...row, + pool_address: row.pool_address.toLowerCase(), + block_hash: row.block_hash.toLowerCase(), + transaction_hash: row.transaction_hash.toLowerCase(), + })), + }; +} diff --git a/src/sources/nuthatch/response.test.ts b/src/sources/nuthatch/response.test.ts new file mode 100644 index 0000000..e0c827d --- /dev/null +++ b/src/sources/nuthatch/response.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; + +import { parseFreshnessRows, parseNest, parseSchema } from "./response.js"; + +const VALID_ROW = { + pool_address: "0x6C561b446416e1A00e8E93e221854d6eA4171372", + recent_swap_count_24h: 42, + last_swap_block: 48756852, + last_swap_block_timestamp: 1784303051, + last_swap_block_hash: "0xC2FD40c2f6b9a9b1e59737d6053354885b3336ac801a3f7c0340902420ec9dc2", + last_swap_tx_hash: "0x5A02a9e244c5191381b04066377bd9b06cec47b962074200dc0d9a4702357733", + last_swap_log_index: 21, +} as const; + +function envelope(row: unknown): unknown { + const rows = Array.isArray(row) ? row : [row]; + return { count: rows.length, provenance: {}, rows, truncated: false }; +} + +describe("parseFreshnessRows", () => { + it("normalizes valid hex fields to lowercase and returns the row", () => { + const result = parseFreshnessRows(envelope(VALID_ROW)); + expect(result).toEqual({ + ok: true, + row: { + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + recent_swap_count_24h: 42, + last_swap_block: 48756852, + last_swap_block_timestamp: 1784303051, + last_swap_block_hash: "0xc2fd40c2f6b9a9b1e59737d6053354885b3336ac801a3f7c0340902420ec9dc2", + last_swap_tx_hash: "0x5a02a9e244c5191381b04066377bd9b06cec47b962074200dc0d9a4702357733", + last_swap_log_index: 21, + }, + }); + }); + + it("rejects zero rows as empty (not shape)", () => { + const result = parseFreshnessRows(envelope([])); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.kind).toBe("empty"); + } + }); + + it("rejects more than one row as multi (not shape)", () => { + const result = parseFreshnessRows(envelope([VALID_ROW, VALID_ROW])); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.kind).toBe("multi"); + } + }); + + it("rejects a body that is not an object as shape drift", () => { + expect(parseFreshnessRows(null).ok).toBe(false); + expect(parseFreshnessRows("rows").ok).toBe(false); + expect(parseFreshnessRows(7).ok).toBe(false); + }); + + it("rejects an envelope whose rows field is missing or wrong type", () => { + const missing = parseFreshnessRows({ count: 0 }); + expect(missing.ok).toBe(false); + if (!missing.ok) { + expect(missing.failure.kind).toBe("shape"); + } + expect(parseFreshnessRows({ rows: "not-an-array" }).ok).toBe(false); + }); + + it.each([ + ["pool_address", { ...VALID_ROW, pool_address: "0xdeadbeef" }], + [ + "pool_address non-hex", + { ...VALID_ROW, pool_address: "0xZZ561b446416e1a00e8e93e221854d6ea4171372" }, + ], + ["last_swap_block_hash", { ...VALID_ROW, last_swap_block_hash: "0xshort" }], + ["last_swap_tx_hash", { ...VALID_ROW, last_swap_tx_hash: "0xshort" }], + ["recent_swap_count as string", { ...VALID_ROW, recent_swap_count_24h: "42" }], + ["last_swap_block negative", { ...VALID_ROW, last_swap_block: -1 }], + ["last_swap_block_timestamp float", { ...VALID_ROW, last_swap_block_timestamp: 1.5 }], + [ + "last_swap_log_index unsafe", + { ...VALID_ROW, last_swap_log_index: Number.MAX_SAFE_INTEGER + 1 }, + ], + ["row not an object", "not-an-object"], + ])("classifies %s as shape drift", (_label, row) => { + const result = parseFreshnessRows(envelope(row)); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.kind).toBe("shape"); + } + }); + + it("rejects numeric strings where the contract requires a number", () => { + const result = parseFreshnessRows(envelope({ ...VALID_ROW, last_swap_block: "48756852" })); + expect(result.ok).toBe(false); + }); + + it("rejects null rows and missing columns", () => { + expect(parseFreshnessRows(envelope(null)).ok).toBe(false); + const missingColumn = { ...VALID_ROW } as Record; + delete missingColumn.last_swap_tx_hash; + expect(parseFreshnessRows(envelope(missingColumn)).ok).toBe(false); + }); + + it("does not silently coerce extra columns into the row", () => { + const result = parseFreshnessRows(envelope({ ...VALID_ROW, extra: true })); + // Extra columns are tolerated at the row level; the contract is enforced + // by the zod schema on the final SourceResult, not the parser. This test + // pins current behavior so a future tightening is intentional. + expect(result.ok).toBe(true); + }); +}); + +describe("parseNest", () => { + const VALID_NEST = { + chain: "base", + chain_id: 8453, + contracts: [{ address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", alias: "pool" }], + factories: [], + name: "deeptrace-pool-freshness", + registry_hash: "0x46E57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + table_count: 9, + templates: [], + webhooks: [], + } as const; + + it("extracts and lowercases the registry hash", () => { + const result = parseNest(VALID_NEST); + expect(result).toEqual({ + ok: true, + registryHash: "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + nestName: "deeptrace-pool-freshness", + tableCount: 9, + }); + }); + + it("rejects a missing or malformed registry_hash as shape drift", () => { + expect(parseNest({ ...VALID_NEST, registry_hash: undefined }).ok).toBe(false); + expect(parseNest({ ...VALID_NEST, registry_hash: "0xshort" }).ok).toBe(false); + expect(parseNest({}).ok).toBe(false); + expect(parseNest(null).ok).toBe(false); + }); + + it("tolerates missing name and table_count but still requires registry_hash", () => { + const minimal = { registry_hash: VALID_NEST.registry_hash }; + const result = parseNest(minimal); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.nestName).toBeNull(); + expect(result.tableCount).toBeNull(); + } + }); +}); + +describe("parseSchema", () => { + it("accepts a non-empty schema document and reports schema_version null for 0.6.1", () => { + const result = parseSchema("nuthatch data model\n\nTABLES..."); + expect(result).toEqual({ + ok: true, + schemaText: "nuthatch data model\n\nTABLES...", + schemaVersion: null, + }); + }); + + it("rejects non-string bodies as shape drift", () => { + expect(parseSchema(null).ok).toBe(false); + expect(parseSchema({ tables: [] }).ok).toBe(false); + expect(parseSchema(42).ok).toBe(false); + }); + + it("rejects an empty schema body as shape drift", () => { + expect(parseSchema("").ok).toBe(false); + expect(parseSchema(" \n ").ok).toBe(false); + }); +}); diff --git a/src/sources/nuthatch/response.ts b/src/sources/nuthatch/response.ts new file mode 100644 index 0000000..6f5ab16 --- /dev/null +++ b/src/sources/nuthatch/response.ts @@ -0,0 +1,199 @@ +import type { NuthatchFreshnessData } from "../../schemas/source-adapter.js"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; +const HASH_RE = /^0x[0-9a-fA-F]{64}$/; + +/** + * Failure kinds the response parsers can emit. `shape` covers schema drift + * (renamed/missing/extra columns, wrong types); the adapter maps `shape` to + * `unsupported`, never to `error`. `empty` and `multi` are row-count drift. + */ +export type FreshnessParseFailureKind = "empty" | "multi" | "shape"; + +export interface FreshnessParseFailure { + readonly kind: FreshnessParseFailureKind; + readonly message: string; +} + +export type FreshnessParseResult = + | { readonly ok: true; readonly row: NuthatchFreshnessData } + | { readonly ok: false; readonly failure: FreshnessParseFailure }; + +export interface NestParseFailure { + readonly kind: "shape"; + readonly message: string; +} + +export type NestParseResult = + | { + readonly ok: true; + readonly registryHash: string; + readonly nestName: string | null; + readonly tableCount: number | null; + } + | { readonly ok: false; readonly failure: NestParseFailure }; + +export interface SchemaParseFailure { + readonly kind: "shape"; + readonly message: string; +} + +export type SchemaParseResult = + | { readonly ok: true; readonly schemaText: string; readonly schemaVersion: string | null } + | { readonly ok: false; readonly failure: SchemaParseFailure }; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isNonNegativeSafeInt(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function normalizeHex(value: string): string { + return value.toLowerCase(); +} + +function failShape(message: string): { + readonly ok: false; + readonly failure: { kind: "shape"; message: string }; +} { + return { ok: false, failure: { kind: "shape", message } }; +} + +/** + * Parses the Nuthatch `/sql` envelope for the fixed freshness query. The + * envelope is `{ count, provenance, rows: [...], truncated }`; only `rows` + * is load-bearing for the freshness contract. Exactly one row is required. + */ +export function parseFreshnessRows(body: unknown): FreshnessParseResult { + if (!isRecord(body)) { + return { + ok: false, + failure: { kind: "shape", message: "Freshness /sql body is not an object." }, + }; + } + + const rows = body.rows; + if (!Array.isArray(rows)) { + return { + ok: false, + failure: { kind: "shape", message: "Freshness /sql body is missing a rows array." }, + }; + } + const rowList = rows as readonly unknown[]; + + if (rowList.length === 0) { + return { ok: false, failure: { kind: "empty", message: "Freshness /sql returned zero rows." } }; + } + + if (rowList.length > 1) { + return { + ok: false, + failure: { + kind: "multi", + message: `Freshness /sql returned ${String(rowList.length)} rows; expected exactly one.`, + }, + }; + } + + const raw = rowList[0]; + if (raw === undefined || !isRecord(raw)) { + return { ok: false, failure: { kind: "shape", message: "Freshness row is not an object." } }; + } + + const poolAddress = raw.pool_address; + if (typeof poolAddress !== "string" || !ADDRESS_RE.test(poolAddress)) { + return failShape("Freshness row pool_address must be a 20-byte hex string."); + } + + const lastSwapBlockHash = raw.last_swap_block_hash; + if (typeof lastSwapBlockHash !== "string" || !HASH_RE.test(lastSwapBlockHash)) { + return failShape("Freshness row last_swap_block_hash must be a 32-byte hex string."); + } + + const lastSwapTxHash = raw.last_swap_tx_hash; + if (typeof lastSwapTxHash !== "string" || !HASH_RE.test(lastSwapTxHash)) { + return failShape("Freshness row last_swap_tx_hash must be a 32-byte hex string."); + } + + const recentSwapCount = raw.recent_swap_count_24h; + if (!isNonNegativeSafeInt(recentSwapCount)) { + return failShape("Freshness row recent_swap_count_24h must be a non-negative safe integer."); + } + + const lastSwapBlock = raw.last_swap_block; + if (!isNonNegativeSafeInt(lastSwapBlock)) { + return failShape("Freshness row last_swap_block must be a non-negative safe integer."); + } + + const lastSwapBlockTimestamp = raw.last_swap_block_timestamp; + if (!isNonNegativeSafeInt(lastSwapBlockTimestamp)) { + return failShape( + "Freshness row last_swap_block_timestamp must be a non-negative safe integer.", + ); + } + + const lastSwapLogIndex = raw.last_swap_log_index; + if (!isNonNegativeSafeInt(lastSwapLogIndex)) { + return failShape("Freshness row last_swap_log_index must be a non-negative safe integer."); + } + + const row: NuthatchFreshnessData = { + pool_address: normalizeHex(poolAddress), + recent_swap_count_24h: recentSwapCount, + last_swap_block: lastSwapBlock, + last_swap_block_timestamp: lastSwapBlockTimestamp, + last_swap_block_hash: normalizeHex(lastSwapBlockHash), + last_swap_tx_hash: normalizeHex(lastSwapTxHash), + last_swap_log_index: lastSwapLogIndex, + }; + + return { ok: true, row }; +} + +/** + * Parses `/nest`. Only `registry_hash` is load-bearing for the M5 contract; + * `name` and `table_count` are retained as diagnostic context for warnings. + */ +export function parseNest(body: unknown): NestParseResult { + if (!isRecord(body)) { + return { ok: false, failure: { kind: "shape", message: "Nest body is not an object." } }; + } + + const registryHash = body.registry_hash; + if (typeof registryHash !== "string" || !HASH_RE.test(registryHash)) { + return { + ok: false, + failure: { kind: "shape", message: "Nest registry_hash must be a 32-byte hex string." }, + }; + } + + const nestName = typeof body.name === "string" && body.name.trim() !== "" ? body.name : null; + const tableCount = isNonNegativeSafeInt(body.table_count) ? body.table_count : null; + + return { + ok: true, + registryHash: normalizeHex(registryHash), + nestName, + tableCount, + }; +} + +/** + * Parses `/schema`. In Nuthatch 0.6.1 the endpoint returns a plain-text + * data-model document with no published version field; the parser accepts any + * non-empty string and reports `schemaVersion: null` unless A2 later exposes + * a version key. A non-string or empty body is shape drift → `unsupported`. + */ +export function parseSchema(body: unknown): SchemaParseResult { + if (typeof body !== "string") { + return { ok: false, failure: { kind: "shape", message: "Schema body must be a string." } }; + } + + if (body.trim() === "") { + return { ok: false, failure: { kind: "shape", message: "Schema body is empty." } }; + } + + return { ok: true, schemaText: body, schemaVersion: null }; +} diff --git a/src/sources/nuthatch/wallet-activity-adapter.ts b/src/sources/nuthatch/wallet-activity-adapter.ts new file mode 100644 index 0000000..e13f419 --- /dev/null +++ b/src/sources/nuthatch/wallet-activity-adapter.ts @@ -0,0 +1,320 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY } from "../../policy/index.js"; +import type { NuthatchSourceRegistryRecord } from "../../registry/types.js"; +import { + walletActivitySchema, + type ResultFreshness, + type ResultProvenance, + type WalletActivity, +} from "../../schemas/index.js"; +import { BASE_CHAIN_ID } from "../../schemas/source-adapter.js"; +import { WALLET_RESEARCH_SCOPE } from "../../scope/wallet-research.js"; +import { normalizeLssSwapEvent } from "../../normalization/index.js"; +import type { + WalletActivityResult, + WalletSourceFailureStatus, +} from "../../tools/wallet-sources.js"; +import type { WalletResearchQueryContext } from "../../tools/wallet-query.js"; + +import type { NuthatchClient, NuthatchHttpResult } from "./client.js"; +import { parseLargeSwapReady } from "./large-swaps-response.js"; +import { parseNest, parseSchema } from "./response.js"; +import { + buildWalletActivityHeadQuery, + buildWalletActivityQuery, + NUTHATCH_WALLET_ACTIVITY_QUERY_ID, + NUTHATCH_WALLET_ACTIVITY_VIEW, +} from "./wallet-activity-query.js"; + +const safeIntegerSchema = z.number().int().nonnegative().refine(Number.isSafeInteger); +const hashSchema = z.string().regex(/^0x[0-9a-fA-F]{64}$/); +const addressSchema = z.string().regex(/^0x[0-9a-fA-F]{40}$/); +const signedIntegerSchema = z.string().regex(/^(?:0|-?[1-9]\d*)$/); +const rowSchema = z + .object({ + pool_address: addressSchema, + block_number: safeIntegerSchema, + block_hash: hashSchema, + block_timestamp: safeIntegerSchema, + transaction_hash: hashSchema, + log_index: safeIntegerSchema, + sender: addressSchema, + recipient: addressSchema, + amount0_raw: signedIntegerSchema, + amount1_raw: signedIntegerSchema, + }) + .strict(); +const receiptSchema = z + .object({ + count: safeIntegerSchema, + provenance: z + .object({ + as_of: safeIntegerSchema, + registry_hash: hashSchema, + }) + .passthrough(), + rows: z.array(rowSchema), + truncated: z.boolean(), + }) + .passthrough(); + +type WalletRow = z.infer; + +export interface NuthatchWalletActivityDeps { + readonly client: NuthatchClient; + readonly clock: () => number; + readonly record: NuthatchSourceRegistryRecord; +} + +function provenance(record: NuthatchSourceRegistryRecord): ResultProvenance { + return { + source_id: record.source_id, + source_type: record.source_type, + protocol: record.protocol, + chain_id: BASE_CHAIN_ID, + deployment_or_view_id: record.deployment_or_view_id, + schema_version: record.schema_version, + methodology_version: record.methodology_version, + query_id: NUTHATCH_WALLET_ACTIVITY_QUERY_ID, + }; +} + +export function walletActivityProvenance(record: NuthatchSourceRegistryRecord): ResultProvenance { + return provenance(record); +} + +function failed( + record: NuthatchSourceRegistryRecord, + status: WalletSourceFailureStatus, + warning: string, +): WalletActivityResult { + return { + status, + activities: null, + indexedHead: null, + freshness: { source_id: record.source_id, status: "unavailable" }, + provenance: provenance(record), + warnings: [warning], + }; +} + +export function createWalletActivityFailure( + record: NuthatchSourceRegistryRecord, + status: WalletSourceFailureStatus, + warning: string, +): WalletActivityResult { + return failed(record, status, warning); +} + +function httpStatus(result: Extract) { + return result.error.kind === "timeout" ? ("timeout" as const) : ("error" as const); +} + +function parseReceipt(body: unknown, registryHash: string): readonly WalletRow[] | null { + const parsed = receiptSchema.safeParse(body); + if ( + !parsed.success || + parsed.data.truncated || + parsed.data.count !== parsed.data.rows.length || + parsed.data.provenance.registry_hash.toLowerCase() !== registryHash.toLowerCase() || + parsed.data.rows.some((row) => row.block_number > parsed.data.provenance.as_of) + ) { + return null; + } + return parsed.data.rows.map((row) => ({ + ...row, + pool_address: row.pool_address.toLowerCase(), + block_hash: row.block_hash.toLowerCase(), + transaction_hash: row.transaction_hash.toLowerCase(), + sender: row.sender.toLowerCase(), + recipient: row.recipient.toLowerCase(), + })); +} + +function activityFromRow(row: WalletRow, wallet: string, sourceId: string): WalletActivity { + const swap = normalizeLssSwapEvent({ + chain_id: BASE_CHAIN_ID, + protocol: WALLET_RESEARCH_SCOPE.nuthatch.protocol, + pool: row.pool_address, + transaction_hash: row.transaction_hash, + log_index: row.log_index, + block_number: row.block_number, + timestamp: row.block_timestamp, + amount0_raw: row.amount0_raw, + amount1_raw: row.amount1_raw, + source_id: sourceId, + }); + const recipientMatch = row.recipient === wallet; + const counterparty = recipientMatch ? row.sender : row.recipient; + return walletActivitySchema.parse({ + id: `${BASE_CHAIN_ID}:${swap.transaction_hash}:${String(swap.log_index)}`, + chain_id: BASE_CHAIN_ID, + wallet_address: wallet, + protocol: WALLET_RESEARCH_SCOPE.nuthatch.protocol, + activity_type: recipientMatch ? "swap_received" : "swap_sent", + transaction_hash: swap.transaction_hash, + log_index: swap.log_index, + block_number: swap.block_number, + timestamp: swap.timestamp, + counterparty: counterparty === wallet ? null : counterparty, + assets: [ + { + token_address: swap.asset_in.address, + symbol: swap.asset_in.symbol, + decimals: swap.asset_in.decimals, + role: "swap_in", + raw_amount: swap.amount_in_raw!, + normalized_amount: swap.amount_in, + usd_price: null, + usd_value: null, + }, + { + token_address: swap.asset_out.address, + symbol: swap.asset_out.symbol, + decimals: swap.asset_out.decimals, + role: "swap_out", + raw_amount: (-BigInt(swap.amount_out_raw!)).toString(), + normalized_amount: swap.amount_out, + usd_price: null, + usd_value: null, + }, + ], + source_id: sourceId, + }); +} + +function validRecord(record: NuthatchSourceRegistryRecord): boolean { + return ( + record.source_id === WALLET_RESEARCH_SCOPE.nuthatch.sourceId && + record.status === "active" && + record.locator.view_id === NUTHATCH_WALLET_ACTIVITY_VIEW && + record.supported_entities.includes(NUTHATCH_WALLET_ACTIVITY_VIEW) && + record.methodology_version === WALLET_RESEARCH_SCOPE.nuthatch.methodologyVersion + ); +} + +export async function fetchNuthatchWalletActivity( + deps: NuthatchWalletActivityDeps, + context: WalletResearchQueryContext, +): Promise { + const { client, clock, record } = deps; + if (!validRecord(record)) { + return failed(record, "unsupported", "Wallet Nuthatch registry capability is unsupported."); + } + try { + const readyResult = await client.ready(); + if (!readyResult.ok) { + return failed( + record, + readyResult.status === 503 ? "stale" : httpStatus(readyResult), + readyResult.status === 503 + ? "Nuthatch is not ready for wallet activity." + : readyResult.error.message, + ); + } + const ready = parseLargeSwapReady(readyResult.body); + if (ready === null || !ready.ready) { + return failed(record, "stale", "Nuthatch wallet activity readiness is invalid."); + } + + // Same wait shape as compare_pools freshness: nest, schema, and head /sql + // start together after /ready (freshness uses one SQL; wallet still needs a + // second scan query once the head snapshot is known). + const [nestResult, schemaResult, headResult] = await Promise.all([ + client.nest(), + client.schema(), + client.sql(buildWalletActivityHeadQuery(context.snapshotHead), 1), + ]); + if (!nestResult.ok) { + return failed(record, httpStatus(nestResult), nestResult.error.message); + } + if (!schemaResult.ok) { + return failed(record, httpStatus(schemaResult), schemaResult.error.message); + } + const nest = parseNest(nestResult.body); + const schema = parseSchema(schemaResult.body); + if (!nest.ok || !schema.ok || nest.registryHash !== record.deployment_or_view_id) { + return failed( + record, + "unsupported", + "Nuthatch wallet metadata failed its registry contract.", + ); + } + + if (!headResult.ok) { + return failed(record, httpStatus(headResult), headResult.error.message); + } + const headRows = parseReceipt(headResult.body, record.deployment_or_view_id); + const head = headRows?.[0]; + if (head === undefined || headRows?.length !== 1) { + return failed(record, "unsupported", "Wallet activity head receipt is invalid."); + } + const snapshotHead = context.snapshotHead ?? head.block_number; + if (snapshotHead > ready.indexedHead) { + return failed(record, "unsupported", "Wallet activity source is behind the cursor snapshot."); + } + const windowSeconds = context.request.window === "24h" ? 86_400 : 604_800; + const windowStart = Math.max(0, head.block_timestamp - windowSeconds); + const activityResult = await client.sql( + buildWalletActivityQuery(context, snapshotHead, windowStart), + WALLET_RESEARCH_SCOPE.scan.maximumRows + 1, + ); + if (!activityResult.ok) { + return failed(record, httpStatus(activityResult), activityResult.error.message); + } + const rows = parseReceipt(activityResult.body, record.deployment_or_view_id); + if ( + rows === null || + rows.length > WALLET_RESEARCH_SCOPE.scan.maximumRows || + rows.some( + (row) => + row.sender !== context.request.address && row.recipient !== context.request.address, + ) + ) { + return failed( + record, + rows !== null && rows.length > WALLET_RESEARCH_SCOPE.scan.maximumRows + ? "error" + : "unsupported", + rows !== null && rows.length > WALLET_RESEARCH_SCOPE.scan.maximumRows + ? "Wallet activity window exceeded the bounded scan ceiling." + : "Wallet activity receipt is invalid.", + ); + } + + const queriedAt = clock(); + const lag = queriedAt - head.block_timestamp; + if (!Number.isSafeInteger(lag) || lag < 0) { + return failed(record, "unsupported", "Wallet activity freshness is invalid."); + } + if (lag > M0_CORE_POLICY.freshness.qualityStaleAfterSeconds) { + return failed(record, "stale", "Wallet activity source is stale."); + } + const freshness: ResultFreshness = { + source_id: record.source_id, + status: "fresh", + indexed_block: head.block_number, + indexed_block_timestamp: head.block_timestamp, + indexed_block_hash: head.block_hash, + queried_at: queriedAt, + lag_seconds: lag, + }; + return { + status: "ok", + activities: rows.map((row) => + activityFromRow(row, context.request.address, record.source_id), + ), + indexedHead: head.block_number, + freshness, + provenance: provenance(record), + warnings: [], + }; + } catch { + return failed( + record, + "error", + "Wallet Nuthatch adapter failed unexpectedly; details were redacted.", + ); + } +} diff --git a/src/sources/nuthatch/wallet-activity-query.ts b/src/sources/nuthatch/wallet-activity-query.ts new file mode 100644 index 0000000..689b51a --- /dev/null +++ b/src/sources/nuthatch/wallet-activity-query.ts @@ -0,0 +1,65 @@ +import { WALLET_RESEARCH_SCOPE } from "../../scope/wallet-research.js"; +import type { WalletResearchQueryContext } from "../../tools/wallet-query.js"; + +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; + +/** + * Project the indexed pool Swap table into the wallet-activity receipt shape. + * Query `pool__swap` directly (same projection as `wallet_swap_activity`) so + * the adapter stays live when authored views are missing or fail to load — + * freshness already proves `pool__swap` is queryable. Compare address and hash + * columns with string literals; do not CAST them to VARCHAR (DuckDB rejects + * that cast for address/bytes32 and Nuthatch returns HTTP 400). + */ +const SELECT_COLUMNS = `SELECT + address AS pool_address, + block_number, + block_hash, + block_timestamp, + tx_hash AS transaction_hash, + log_index, + sender, + recipient, + amount0 AS amount0_raw, + amount1 AS amount1_raw +FROM pool__swap`; + +function safeInteger(value: number, field: string): string { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${field} must be a non-negative safe integer.`); + } + return String(value); +} + +export function buildWalletActivityHeadQuery(snapshotHead: number | null): string { + const bound = + snapshotHead === null + ? "" + : `\nWHERE block_number <= ${safeInteger(snapshotHead, "snapshotHead")}`; + return `${SELECT_COLUMNS}${bound} +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC +LIMIT 1`; +} + +export function buildWalletActivityQuery( + context: WalletResearchQueryContext, + snapshotHead: number, + windowStart: number, +): string { + const wallet = context.request.address; + if (!ADDRESS_PATTERN.test(wallet)) { + throw new Error("Wallet address is invalid."); + } + const head = safeInteger(snapshotHead, "snapshotHead"); + const start = safeInteger(windowStart, "windowStart"); + const limit = WALLET_RESEARCH_SCOPE.scan.maximumRows + 1; + return `${SELECT_COLUMNS} +WHERE block_number <= ${head} + AND block_timestamp >= ${start} + AND (sender = '${wallet}' OR recipient = '${wallet}') +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC +LIMIT ${String(limit)}`; +} + +export const NUTHATCH_WALLET_ACTIVITY_QUERY_ID = WALLET_RESEARCH_SCOPE.nuthatch.queryId; +export const NUTHATCH_WALLET_ACTIVITY_VIEW = WALLET_RESEARCH_SCOPE.nuthatch.viewId; diff --git a/src/tools/compare-lending-sources.ts b/src/tools/compare-lending-sources.ts new file mode 100644 index 0000000..2a21bde --- /dev/null +++ b/src/tools/compare-lending-sources.ts @@ -0,0 +1,12 @@ +import type { CompareLendingRequest } from "../schemas/compare-lending-request.js"; +import type { LendingMarketSourceResult } from "../schemas/source-adapter.js"; + +/** + * Injected source boundary for `compare_lending_markets`. + * Live adapters and fixtures implement the same interface. + */ +export interface CompareLendingSourceGateway { + fetchLendingResults( + request: CompareLendingRequest, + ): Promise; +} diff --git a/src/tools/compare-lending.ts b/src/tools/compare-lending.ts new file mode 100644 index 0000000..958c564 --- /dev/null +++ b/src/tools/compare-lending.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY } from "../policy/index.js"; +import { rankLendingMarkets } from "../metrics/index.js"; +import { settleCompareLendingResult } from "../quality/index.js"; +import { + compareLendingRequestSchema, + type CompareLendingRequest, + type CompareLendingRequestInput, +} from "../schemas/compare-lending-request.js"; +import { + compareLendingResponseSchema, + type CompareLendingResponse, + type LendingToken, +} from "../schemas/compare-lending.js"; +import { M0_COMPARE_LENDING_SCOPE } from "../scope/compare-lending.js"; + +import type { CompareLendingSourceGateway } from "./compare-lending-sources.js"; + +export class CompareLendingRequestError extends Error { + constructor(message = "Invalid compare_lending_markets request.") { + super(message); + this.name = "CompareLendingRequestError"; + } +} + +export class CompareLendingToolError extends Error { + constructor(message: string) { + super(message); + this.name = "CompareLendingToolError"; + } +} + +function lockedMarketToken(): LendingToken { + return { + chain_id: M0_COMPARE_LENDING_SCOPE.chainId, + address: M0_COMPARE_LENDING_SCOPE.marketToken.address, + symbol: M0_COMPARE_LENDING_SCOPE.marketToken.symbol, + decimals: M0_COMPARE_LENDING_SCOPE.marketToken.decimals, + }; +} + +/** + * Validate a public request, fetch injected sources, rank, and settle. + * Does not register MCP transport concerns (rate limits belong to the tool wrapper). + */ +export async function executeCompareLending( + rawRequest: CompareLendingRequestInput, + sources: CompareLendingSourceGateway, +): Promise { + let request: CompareLendingRequest; + try { + request = compareLendingRequestSchema.parse(rawRequest); + } catch (error) { + if (error instanceof z.ZodError) { + throw new CompareLendingRequestError(); + } + throw error; + } + + const sourceResults = await sources.fetchLendingResults(request); + + const markets = rankLendingMarkets(sourceResults, { + rankedBy: request.ranked_by, + topN: request.top_n, + }); + + const response = settleCompareLendingResult({ + marketToken: lockedMarketToken(), + rankedBy: request.ranked_by, + markets, + sourceResults, + }); + + let validated: CompareLendingResponse; + try { + validated = compareLendingResponseSchema.parse(response); + } catch (error) { + if (error instanceof z.ZodError) { + throw new CompareLendingToolError( + "compare_lending_markets response failed schema validation.", + ); + } + throw error; + } + + const encoded = JSON.stringify(validated); + if (Buffer.byteLength(encoded, "utf8") > M0_CORE_POLICY.gateway.maximumResponseBytes) { + throw new CompareLendingToolError( + "compare_lending_markets response exceeded the maximum response size.", + ); + } + + return validated; +} + +export type { CompareLendingRequest, CompareLendingRequestInput }; diff --git a/src/tools/compare-pools-sources.ts b/src/tools/compare-pools-sources.ts new file mode 100644 index 0000000..def34a8 --- /dev/null +++ b/src/tools/compare-pools-sources.ts @@ -0,0 +1,15 @@ +import type { ComparePoolsRequest } from "../schemas/compare-pools-request.js"; +import type { NuthatchSourceResult, PoolSourceResult } from "../schemas/source-adapter.js"; + +/** + * Injected source boundary for `compare_pools`. + * Live adapters and fixtures implement the same interface. + */ +export interface ComparePoolsSourceGateway { + fetchGraphResults(request: ComparePoolsRequest): Promise; + /** + * Return `null` when Nuthatch is unverified or not queried. + * Do not invent a live freshness fact. + */ + fetchNuthatchResult(request: ComparePoolsRequest): Promise; +} diff --git a/src/tools/compare-pools.ts b/src/tools/compare-pools.ts new file mode 100644 index 0000000..fdf577d --- /dev/null +++ b/src/tools/compare-pools.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY } from "../policy/index.js"; +import { rankCanonicalPools } from "../metrics/index.js"; +import { bindComparePoolsGraphResults } from "../normalization/index.js"; +import { settleComparePoolsResult } from "../quality/index.js"; +import { + comparePoolsRequestSchema, + type ComparePoolsRequest, + type ComparePoolsRequestInput, +} from "../schemas/compare-pools-request.js"; +import { + comparePoolsResponseSchema, + type CanonicalPair, + type ComparePoolsResponse, +} from "../schemas/compare-pools.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; + +import type { ComparePoolsSourceGateway } from "./compare-pools-sources.js"; + +export class ComparePoolsRequestError extends Error { + constructor(message = "Invalid compare_pools request.") { + super(message); + this.name = "ComparePoolsRequestError"; + } +} + +export class ComparePoolsToolError extends Error { + constructor(message: string) { + super(message); + this.name = "ComparePoolsToolError"; + } +} + +function lockedPair(): CanonicalPair { + return [ + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + address: M0_COMPARE_POOLS_SCOPE.token0.address, + symbol: M0_COMPARE_POOLS_SCOPE.token0.symbol, + decimals: M0_COMPARE_POOLS_SCOPE.token0.decimals, + }, + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + address: M0_COMPARE_POOLS_SCOPE.token1.address, + symbol: M0_COMPARE_POOLS_SCOPE.token1.symbol, + decimals: M0_COMPARE_POOLS_SCOPE.token1.decimals, + }, + ]; +} + +/** + * Validate a public request, fetch injected sources, normalize, rank, and settle. + * Does not register MCP transport concerns (rate limits belong to the tool wrapper). + */ +export async function executeComparePools( + rawRequest: ComparePoolsRequestInput, + sources: ComparePoolsSourceGateway, +): Promise { + let request: ComparePoolsRequest; + try { + request = comparePoolsRequestSchema.parse(rawRequest); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ComparePoolsRequestError(); + } + throw error; + } + + const graphPromise = sources.fetchGraphResults(request); + const nuthatchPromise = sources.fetchNuthatchResult(request); + const [graphResults, nuthatchResult] = await Promise.all([graphPromise, nuthatchPromise]); + + const candidates = bindComparePoolsGraphResults(graphResults, request.window); + const pools = rankCanonicalPools(candidates, { + rankedBy: request.ranked_by, + topN: request.top_n, + }); + + const response = settleComparePoolsResult({ + pair: lockedPair(), + window: request.window, + rankedBy: request.ranked_by, + pools, + graphResults, + nuthatchResult, + }); + + let validated: ComparePoolsResponse; + try { + validated = comparePoolsResponseSchema.parse(response); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ComparePoolsToolError("compare_pools response failed schema validation."); + } + throw error; + } + + const encoded = JSON.stringify(validated); + if (Buffer.byteLength(encoded, "utf8") > M0_CORE_POLICY.gateway.maximumResponseBytes) { + throw new ComparePoolsToolError("compare_pools response exceeded the maximum response size."); + } + + return validated; +} + +export type { ComparePoolsRequest, ComparePoolsRequestInput }; diff --git a/src/tools/find-large-swaps.ts b/src/tools/find-large-swaps.ts new file mode 100644 index 0000000..71ddb19 --- /dev/null +++ b/src/tools/find-large-swaps.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY } from "../policy/index.js"; +import { + findLargeSwapsResponseSchema, + type FindLargeSwapsRequestInput, + type FindLargeSwapsResponse, +} from "../schemas/index.js"; +import { LSS_SCOPE } from "../scope/large-swaps.js"; + +import { + inspectLargeSwapQuery, + queryLargeSwapPage, + type LargeSwapQueryPage, +} from "./large-swaps-query.js"; +import type { + LargeSwapSourceFailure, + LargeSwapSourceGateway, + LargeSwapSourceSuccess, +} from "./large-swaps-source.js"; + +const RESPONSE_BUDGET_WARNING = + "Page was shortened below the requested limit to preserve the response-size budget."; + +export class FindLargeSwapsToolError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "FindLargeSwapsToolError"; + } +} + +function failedResponse( + request: ReturnType["request"], + source: Pick, +): FindLargeSwapsResponse { + return findLargeSwapsResponseSchema.parse({ + status: "failed", + data: null, + coverage: { + requested_sources: 1, + successful_sources: 0, + }, + freshness: [source.freshness], + provenance: [source.provenance], + warnings: [...source.warnings], + pagination: { + limit: request.limit, + returned: 0, + has_more: false, + next_cursor: null, + }, + }); +} + +function assertResponseSize(response: FindLargeSwapsResponse): void { + if ( + Buffer.byteLength(JSON.stringify(response), "utf8") > + M0_CORE_POLICY.gateway.maximumResponseBytes + ) { + throw new FindLargeSwapsToolError( + "find_large_swaps response exceeded the maximum response size.", + ); + } +} + +function completeResponse( + request: ReturnType["request"], + source: LargeSwapSourceSuccess, + page: LargeSwapQueryPage, + shortened: boolean, +): FindLargeSwapsResponse { + return findLargeSwapsResponseSchema.parse({ + status: "complete", + data: { + chain_id: LSS_SCOPE.chainId, + pool_address: LSS_SCOPE.poolAddress, + threshold_token: request.threshold_token, + min_amount: request.min_amount, + swaps: page.swaps, + }, + coverage: { + requested_sources: 1, + successful_sources: 1, + }, + freshness: [source.freshness], + provenance: [source.provenance], + warnings: shortened ? [...source.warnings, RESPONSE_BUDGET_WARNING] : [...source.warnings], + pagination: { + ...page.pagination, + limit: request.limit, + }, + }); +} + +/** + * Validates the request, obtains one bounded source window, applies the stable + * page engine, and settles the sole-source quality envelope. + */ +export async function executeFindLargeSwaps( + rawRequest: FindLargeSwapsRequestInput, + source: LargeSwapSourceGateway, +): Promise { + const context = inspectLargeSwapQuery(rawRequest); + + let sourceResult; + try { + sourceResult = await source.fetchCandidates(context); + } catch { + sourceResult = { + status: "error" as const, + events: null, + indexedHead: null, + freshness: { + source_id: source.provenance.source_id, + status: "unavailable" as const, + }, + provenance: source.provenance, + warnings: ["Large-swap source failed unexpectedly; details were redacted."], + }; + } + + if (sourceResult.status !== "ok") { + const response = failedResponse(context.request, sourceResult); + assertResponseSize(response); + return response; + } + + try { + for (let pageSize = context.request.limit; pageSize >= 1; pageSize -= 1) { + const page = queryLargeSwapPage({ + events: sourceResult.events, + indexedHead: sourceResult.indexedHead, + request: { + ...context.request, + limit: pageSize, + }, + }); + const response = completeResponse( + context.request, + sourceResult, + page, + pageSize < context.request.limit, + ); + if ( + Buffer.byteLength(JSON.stringify(response), "utf8") <= + M0_CORE_POLICY.gateway.maximumResponseBytes + ) { + return response; + } + } + } catch (error) { + if (error instanceof z.ZodError) { + throw new FindLargeSwapsToolError("find_large_swaps response failed schema validation.", { + cause: error, + }); + } + throw error; + } + + throw new FindLargeSwapsToolError( + "find_large_swaps could not fit one event within the maximum response size.", + ); +} diff --git a/src/tools/fixture-lending-sources.ts b/src/tools/fixture-lending-sources.ts new file mode 100644 index 0000000..440b6a1 --- /dev/null +++ b/src/tools/fixture-lending-sources.ts @@ -0,0 +1,19 @@ +import type { LendingMarketSourceResult } from "../schemas/source-adapter.js"; + +import type { CompareLendingSourceGateway } from "./compare-lending-sources.js"; + +/** + * Deterministic fixture gateway for tests and offline MCP demos. + * Callers supply the three locked lending source results. + */ +export function createFixtureCompareLendingSources(options: { + readonly lendingResults: readonly LendingMarketSourceResult[]; + readonly onLendingFetch?: () => void; +}): CompareLendingSourceGateway { + return { + fetchLendingResults() { + options.onLendingFetch?.(); + return Promise.resolve(options.lendingResults); + }, + }; +} diff --git a/src/tools/fixture-sources.ts b/src/tools/fixture-sources.ts new file mode 100644 index 0000000..3f5256c --- /dev/null +++ b/src/tools/fixture-sources.ts @@ -0,0 +1,24 @@ +import type { PoolSourceResult } from "../schemas/source-adapter.js"; + +import type { ComparePoolsSourceGateway } from "./compare-pools-sources.js"; + +/** + * Deterministic fixture gateway for tests and offline MCP demos. + * Callers supply the two locked Graph results; Nuthatch stays null by default. + */ +export function createFixtureComparePoolsSources(options: { + readonly graphResults: readonly PoolSourceResult[]; + readonly onGraphFetch?: () => void; + readonly onNuthatchFetch?: () => void; +}): ComparePoolsSourceGateway { + return { + fetchGraphResults() { + options.onGraphFetch?.(); + return Promise.resolve(options.graphResults); + }, + fetchNuthatchResult() { + options.onNuthatchFetch?.(); + return Promise.resolve(null); + }, + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..64bfa90 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,64 @@ +export type { CompareLendingSourceGateway } from "./compare-lending-sources.js"; +export { + CompareLendingRequestError, + CompareLendingToolError, + executeCompareLending, +} from "./compare-lending.js"; +export type { ComparePoolsSourceGateway } from "./compare-pools-sources.js"; +export { + ComparePoolsRequestError, + ComparePoolsToolError, + executeComparePools, +} from "./compare-pools.js"; +export { createFixtureCompareLendingSources } from "./fixture-lending-sources.js"; +export { createFixtureComparePoolsSources } from "./fixture-sources.js"; +export { + createLiveCompareLendingSources, + type LiveCompareLendingSourcesOptions, +} from "./live-lending-sources.js"; +export { + createLiveComparePoolsSources, + type LiveComparePoolsSourcesOptions, +} from "./live-sources.js"; +export { FindLargeSwapsToolError, executeFindLargeSwaps } from "./find-large-swaps.js"; +export { createLiveLargeSwapSource, type LiveLargeSwapSourceOptions } from "./live-large-swaps.js"; +export type { + LargeSwapSourceFailure, + LargeSwapSourceFailureStatus, + LargeSwapSourceGateway, + LargeSwapSourceResult, + LargeSwapSourceSuccess, +} from "./large-swaps-source.js"; +export { + LargeSwapCursorError, + LargeSwapQueryError, + compareSwapPageOrder, + inspectLargeSwapQuery, + queryLargeSwapPage, + type LargeSwapEventPosition, + type LargeSwapQueryContext, + type LargeSwapQueryInput, + type LargeSwapQueryPage, +} from "./large-swaps-query.js"; +export { createLiveWalletSources, type LiveWalletSourcesOptions } from "./live-wallet-sources.js"; +export { + ResearchWalletToolError, + WalletResearchQueryError, + executeResearchWallet, +} from "./research-wallet.js"; +export type { + WalletActivityFailure, + WalletActivityResult, + WalletActivitySuccess, + WalletGraphFailure, + WalletGraphResult, + WalletGraphSuccess, + WalletResearchSourceGateway, + WalletSourceFailureStatus, +} from "./wallet-sources.js"; +export { + inspectWalletResearchQuery, + paginateWalletActivity, + type WalletEventPosition, + type WalletResearchQueryContext, +} from "./wallet-query.js"; diff --git a/src/tools/large-swaps-query.ts b/src/tools/large-swaps-query.ts new file mode 100644 index 0000000..259889e --- /dev/null +++ b/src/tools/large-swaps-query.ts @@ -0,0 +1,291 @@ +import { z } from "zod"; + +import { deduplicateSwapEvents, filterSwapsByTokenThreshold } from "../normalization/index.js"; +import { + findLargeSwapsRequestSchema, + type FindLargeSwapsRequest, + type FindLargeSwapsRequestInput, +} from "../schemas/large-swaps-request.js"; +import { + largeSwapPaginationSchema, + type LargeSwapPagination, + type SwapEvent, +} from "../schemas/large-swaps.js"; +import { LSS_SCOPE } from "../scope/large-swaps.js"; + +const CURSOR_PREFIX = `lss:v${String(LSS_SCOPE.cursor.version)}:`; +const VERSIONED_CURSOR_PATTERN = /^lss:v(\d+):(.+)$/; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; +const ADDRESS_PATTERN = /^0x[0-9a-f]{40}$/; +const TRANSACTION_HASH_PATTERN = /^0x[0-9a-f]{64}$/; +const POSITIVE_DECIMAL_PATTERN = /^(?:0\.\d*[1-9]\d*|[1-9]\d*(?:\.\d+)?)$/; +const nonNegativeSafeIntegerSchema = z + .number() + .int() + .nonnegative() + .refine(Number.isSafeInteger, "Must be a safe integer."); +const positiveSafeIntegerSchema = z + .number() + .int() + .positive() + .refine(Number.isSafeInteger, "Must be a safe integer."); + +const cursorPayloadSchema = z + .object({ + snapshot_head: nonNegativeSafeIntegerSchema, + chain_id: positiveSafeIntegerSchema, + pool_address: z.string().regex(ADDRESS_PATTERN), + threshold_token: z.string().regex(ADDRESS_PATTERN), + min_amount: z.string().regex(POSITIVE_DECIMAL_PATTERN), + last_event: z + .object({ + block_number: nonNegativeSafeIntegerSchema, + log_index: nonNegativeSafeIntegerSchema, + transaction_hash: z.string().regex(TRANSACTION_HASH_PATTERN), + }) + .strict(), + }) + .strict(); + +type LargeSwapCursorPayload = z.infer; + +export interface LargeSwapEventPosition { + readonly block_number: number; + readonly log_index: number; + readonly transaction_hash: string; +} + +export interface LargeSwapQueryContext { + readonly request: FindLargeSwapsRequest; + readonly snapshotHead: number | null; + readonly lastEvent: LargeSwapEventPosition | null; +} + +export interface LargeSwapQueryPage { + readonly snapshot_head: number; + readonly swaps: SwapEvent[]; + readonly pagination: LargeSwapPagination; +} + +export interface LargeSwapQueryInput { + readonly events: readonly SwapEvent[]; + readonly indexedHead: number; + readonly request: FindLargeSwapsRequestInput; +} + +export class LargeSwapQueryError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "LargeSwapQueryError"; + } +} + +export class LargeSwapCursorError extends LargeSwapQueryError { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "LargeSwapCursorError"; + } +} + +function parseRequest(rawRequest: FindLargeSwapsRequestInput): FindLargeSwapsRequest { + const parsed = findLargeSwapsRequestSchema.safeParse(rawRequest); + if (!parsed.success) { + throw new LargeSwapQueryError("Invalid find_large_swaps query request.", { + cause: parsed.error, + }); + } + return parsed.data; +} + +function assertIndexedHead(indexedHead: number): void { + if (!Number.isSafeInteger(indexedHead) || indexedHead < 0) { + throw new LargeSwapQueryError("Indexed head must be a non-negative safe integer."); + } +} + +function decodeCursor(cursor: string): LargeSwapCursorPayload { + if (cursor.length > LSS_SCOPE.cursor.maximumLength) { + throw new LargeSwapCursorError("Large-swap cursor exceeds the maximum length."); + } + + const match = VERSIONED_CURSOR_PATTERN.exec(cursor); + if (match === null) { + throw new LargeSwapCursorError("Malformed large-swap cursor."); + } + + const [, versionText, encoded] = match; + if (versionText !== String(LSS_SCOPE.cursor.version)) { + throw new LargeSwapCursorError("Unsupported large-swap cursor version."); + } + if (encoded === undefined || !BASE64URL_PATTERN.test(encoded)) { + throw new LargeSwapCursorError("Malformed large-swap cursor."); + } + + try { + const bytes = Buffer.from(encoded, "base64url"); + if (bytes.toString("base64url") !== encoded) { + throw new Error("Cursor payload was not canonical base64url"); + } + + const json = bytes.toString("utf8"); + if (Buffer.from(json, "utf8").toString("base64url") !== encoded) { + throw new Error("Cursor payload was not valid UTF-8"); + } + + const parsed = cursorPayloadSchema.safeParse(JSON.parse(json) as unknown); + if (!parsed.success) { + throw parsed.error; + } + return parsed.data; + } catch (error) { + throw new LargeSwapCursorError("Malformed large-swap cursor.", { cause: error }); + } +} + +function encodeCursor(payload: LargeSwapCursorPayload): string { + const parsed = cursorPayloadSchema.parse(payload); + const encoded = Buffer.from(JSON.stringify(parsed), "utf8").toString("base64url"); + const cursor = `${CURSOR_PREFIX}${encoded}`; + if (cursor.length > LSS_SCOPE.cursor.maximumLength) { + throw new LargeSwapQueryError("Generated large-swap cursor exceeded the maximum length."); + } + return cursor; +} + +function assertCursorScope(cursor: LargeSwapCursorPayload, request: FindLargeSwapsRequest): void { + if ( + cursor.chain_id !== request.chain_id || + cursor.pool_address !== request.pool_address || + cursor.threshold_token !== request.threshold_token || + cursor.min_amount !== request.min_amount + ) { + throw new LargeSwapCursorError("Large-swap cursor does not match the request scope."); + } +} + +/** + * Validates the public request and, when present, opens its opaque continuation + * into the bounded source-query coordinates needed by the live adapter. + */ +export function inspectLargeSwapQuery( + rawRequest: FindLargeSwapsRequestInput, +): LargeSwapQueryContext { + const request = parseRequest(rawRequest); + const decodedCursor = request.cursor === null ? null : decodeCursor(request.cursor); + if (decodedCursor !== null) { + assertCursorScope(decodedCursor, request); + } + return { + request, + snapshotHead: decodedCursor?.snapshot_head ?? null, + lastEvent: decodedCursor?.last_event ?? null, + }; +} + +function compareStrings(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +/** + * Final LSS page order: block descending, log index descending, then transaction + * hash ascending to make the plan's two-key order total and deterministic. + */ +export function compareSwapPageOrder(left: SwapEvent, right: SwapEvent): number { + if (left.block_number !== right.block_number) { + return left.block_number > right.block_number ? -1 : 1; + } + if (left.log_index !== right.log_index) { + return left.log_index > right.log_index ? -1 : 1; + } + return compareStrings(left.transaction_hash, right.transaction_hash); +} + +function cursorMatchesEvent( + cursor: LargeSwapCursorPayload["last_event"], + event: SwapEvent, +): boolean { + return ( + cursor.block_number === event.block_number && + cursor.log_index === event.log_index && + cursor.transaction_hash === event.transaction_hash + ); +} + +function nextCursor( + request: FindLargeSwapsRequest, + snapshotHead: number, + event: SwapEvent, +): string { + return encodeCursor({ + snapshot_head: snapshotHead, + chain_id: request.chain_id, + pool_address: request.pool_address, + threshold_token: request.threshold_token, + min_amount: request.min_amount, + last_event: { + block_number: event.block_number, + log_index: event.log_index, + transaction_hash: event.transaction_hash, + }, + }); +} + +/** + * Produces one deterministic fixed-snapshot page over canonical normalized swaps. + * + * The first page freezes `indexedHead`. Continuations retain that snapshot even + * when the source advances, and fail if the source can no longer serve the frozen + * head or cursor anchor. + */ +export function queryLargeSwapPage(input: LargeSwapQueryInput): LargeSwapQueryPage { + const context = inspectLargeSwapQuery(input.request); + const { request } = context; + assertIndexedHead(input.indexedHead); + + if (context.snapshotHead !== null) { + if (input.indexedHead < context.snapshotHead) { + throw new LargeSwapCursorError("Large-swap source head is behind the cursor snapshot."); + } + } + const snapshotHead = context.snapshotHead ?? input.indexedHead; + + const snapshotEvents = deduplicateSwapEvents(input.events).filter( + (event) => event.block_number <= snapshotHead, + ); + const ordered = filterSwapsByTokenThreshold( + snapshotEvents, + request.threshold_token, + request.min_amount, + ).sort(compareSwapPageOrder); + + let remaining = ordered; + if (context.lastEvent !== null) { + const lastEvent = context.lastEvent; + const anchorIndex = ordered.findIndex((event) => cursorMatchesEvent(lastEvent, event)); + if (anchorIndex < 0) { + throw new LargeSwapCursorError( + "Large-swap cursor anchor is unavailable in the frozen snapshot.", + ); + } + remaining = ordered.slice(anchorIndex + 1); + } + + const swaps = remaining.slice(0, request.limit); + const hasMore = remaining.length > swaps.length; + const last = swaps.at(-1); + const pagination = largeSwapPaginationSchema.parse({ + limit: request.limit, + returned: swaps.length, + has_more: hasMore, + next_cursor: hasMore && last !== undefined ? nextCursor(request, snapshotHead, last) : null, + }); + + return { + snapshot_head: snapshotHead, + swaps, + pagination, + }; +} diff --git a/src/tools/large-swaps-source.ts b/src/tools/large-swaps-source.ts new file mode 100644 index 0000000..57602f3 --- /dev/null +++ b/src/tools/large-swaps-source.ts @@ -0,0 +1,31 @@ +import type { ResultFreshness, ResultProvenance, SwapEvent } from "../schemas/index.js"; + +import type { LargeSwapQueryContext } from "./large-swaps-query.js"; + +export type LargeSwapSourceFailureStatus = "error" | "stale" | "timeout" | "unsupported"; +type ObservedResultFreshness = Extract; + +export interface LargeSwapSourceSuccess { + readonly status: "ok"; + readonly events: readonly SwapEvent[]; + readonly indexedHead: number; + readonly freshness: ObservedResultFreshness & { readonly status: "fresh" }; + readonly provenance: ResultProvenance; + readonly warnings: readonly string[]; +} + +export interface LargeSwapSourceFailure { + readonly status: LargeSwapSourceFailureStatus; + readonly events: null; + readonly indexedHead: null; + readonly freshness: Extract; + readonly provenance: ResultProvenance; + readonly warnings: readonly string[]; +} + +export type LargeSwapSourceResult = LargeSwapSourceSuccess | LargeSwapSourceFailure; + +export interface LargeSwapSourceGateway { + readonly provenance: ResultProvenance; + fetchCandidates(context: LargeSwapQueryContext): Promise; +} diff --git a/src/tools/live-large-swaps.ts b/src/tools/live-large-swaps.ts new file mode 100644 index 0000000..3b0e06d --- /dev/null +++ b/src/tools/live-large-swaps.ts @@ -0,0 +1,94 @@ +import { getSourceById, RegistryConfigurationError } from "../registry/index.js"; +import type { NuthatchSourceRegistryRecord } from "../registry/types.js"; +import { LSS_SCOPE } from "../scope/large-swaps.js"; +import { + createLargeSwapSourceFailure, + fetchNuthatchLargeSwapCandidates, + largeSwapSourceProvenance, +} from "../sources/nuthatch/large-swaps-adapter.js"; +import { createNuthatchClient, type NuthatchClient } from "../sources/nuthatch/client.js"; + +import type { LargeSwapSourceGateway } from "./large-swaps-source.js"; + +type Environment = Readonly>; + +export interface LiveLargeSwapSourceOptions { + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly environment?: Environment; + readonly nuthatchBaseUrl?: string; + readonly clock?: () => number; +} + +function activeLargeSwapRecord(): NuthatchSourceRegistryRecord { + const record = getSourceById(LSS_SCOPE.source.sourceId); + if (record === undefined) { + throw new RegistryConfigurationError([ + `records.json: required LSS source "${LSS_SCOPE.source.sourceId}" is missing`, + ]); + } + if (record.source_type !== "nuthatch_view") { + throw new RegistryConfigurationError([ + `records.json: required LSS source "${LSS_SCOPE.source.sourceId}" has type "${record.source_type}"`, + ]); + } + if (record.status !== "active") { + throw new RegistryConfigurationError([ + `records.json: required LSS source "${LSS_SCOPE.source.sourceId}" is ${record.status}`, + ]); + } + return record; +} + +export function createLiveLargeSwapSource( + options: LiveLargeSwapSourceOptions = {}, +): LargeSwapSourceGateway { + const record = activeLargeSwapRecord(); + const environment = options.environment ?? process.env; + const configuredBaseUrl = options.nuthatchBaseUrl ?? environment[record.locator.base_url_env]; + let client: NuthatchClient | null = null; + let setupWarning: string | null = null; + + if (configuredBaseUrl === undefined || configuredBaseUrl.trim() === "") { + setupWarning = `Large-swap source is unavailable because ${record.locator.base_url_env} is not configured.`; + } else { + try { + client = createNuthatchClient({ + baseUrl: configuredBaseUrl, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }); + } catch { + setupWarning = `Large-swap source initialization rejected ${record.locator.base_url_env}; details were redacted.`; + } + } + + return { + provenance: largeSwapSourceProvenance(record), + async fetchCandidates(context) { + if (client === null) { + return createLargeSwapSourceFailure( + record, + "error", + setupWarning ?? "Large-swap source initialization failed.", + ); + } + try { + return await fetchNuthatchLargeSwapCandidates( + { + client, + clock: options.clock ?? (() => Math.floor(Date.now() / 1_000)), + record, + }, + context, + ); + } catch { + return createLargeSwapSourceFailure( + record, + "error", + "Large-swap live source failed unexpectedly; details were redacted.", + ); + } + }, + }; +} diff --git a/src/tools/live-lending-sources.ts b/src/tools/live-lending-sources.ts new file mode 100644 index 0000000..e67f0a0 --- /dev/null +++ b/src/tools/live-lending-sources.ts @@ -0,0 +1,33 @@ +import { getActiveCompareLendingGraphSources } from "../registry/index.js"; +import type { LendingMarketSourceResult } from "../schemas/source-adapter.js"; +import { fetchCompareLendingGraphSource } from "../sources/graph/index.js"; + +import type { CompareLendingSourceGateway } from "./compare-lending-sources.js"; + +export interface LiveCompareLendingSourcesOptions { + readonly apiKey?: string; + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; +} + +/** + * Live Graph gateway for the locked compare_lending_markets allowlist. + */ +export function createLiveCompareLendingSources( + options: LiveCompareLendingSourcesOptions = {}, +): CompareLendingSourceGateway { + return { + fetchLendingResults(): Promise { + const bindings = getActiveCompareLendingGraphSources(); + return Promise.all( + bindings.map((binding) => + fetchCompareLendingGraphSource(binding, { + ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}), + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }), + ), + ); + }, + }; +} diff --git a/src/tools/live-sources.ts b/src/tools/live-sources.ts new file mode 100644 index 0000000..c6589d3 --- /dev/null +++ b/src/tools/live-sources.ts @@ -0,0 +1,115 @@ +import { + getActiveComparePoolGraphSources, + getSourceById, + RegistryConfigurationError, +} from "../registry/index.js"; +import type { NuthatchSourceRegistryRecord } from "../registry/types.js"; +import type { PoolSourceResult } from "../schemas/source-adapter.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; +import { fetchComparePoolGraphSource } from "../sources/graph/index.js"; +import { + createNuthatchFailureResult, + fetchNuthatchFreshness, +} from "../sources/nuthatch/adapter.js"; +import { createNuthatchClient, type NuthatchClient } from "../sources/nuthatch/client.js"; + +import type { ComparePoolsSourceGateway } from "./compare-pools-sources.js"; + +type Environment = Readonly>; + +export interface LiveComparePoolsSourcesOptions { + readonly apiKey?: string; + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly environment?: Environment; + readonly nuthatchBaseUrl?: string; + /** Epoch-second clock used to timestamp the Nuthatch observation. */ + readonly nuthatchClock?: () => number; +} + +function activeNuthatchRecord(): NuthatchSourceRegistryRecord { + const sourceId = M0_COMPARE_POOLS_SCOPE.nuthatchSourceId; + const record = getSourceById(sourceId); + if (record === undefined) { + throw new RegistryConfigurationError([ + `records.json: required Nuthatch source "${sourceId}" is missing`, + ]); + } + if (record.source_type !== "nuthatch_view") { + throw new RegistryConfigurationError([ + `records.json: required Nuthatch source "${sourceId}" has type "${record.source_type}"`, + ]); + } + if (record.status !== "active") { + throw new RegistryConfigurationError([ + `records.json: required Nuthatch source "${sourceId}" is ${record.status}`, + ]); + } + return record; +} + +/** + * Live Graph and Nuthatch gateway for the locked compare_pools allowlist. + */ +export function createLiveComparePoolsSources( + options: LiveComparePoolsSourcesOptions = {}, +): ComparePoolsSourceGateway { + const nuthatchRecord = activeNuthatchRecord(); + const environment = options.environment ?? process.env; + const configuredBaseUrl = + options.nuthatchBaseUrl ?? environment[nuthatchRecord.locator.base_url_env]; + let nuthatchClient: NuthatchClient | null = null; + let nuthatchSetupWarning: string | null = null; + + if (configuredBaseUrl === undefined || configuredBaseUrl.trim() === "") { + nuthatchSetupWarning = `Nuthatch source is unavailable because ${nuthatchRecord.locator.base_url_env} is not configured.`; + } else { + try { + nuthatchClient = createNuthatchClient({ + baseUrl: configuredBaseUrl, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }); + } catch { + nuthatchSetupWarning = `Nuthatch source initialization rejected ${nuthatchRecord.locator.base_url_env}; details were redacted.`; + } + } + + return { + fetchGraphResults(): Promise { + const bindings = getActiveComparePoolGraphSources(); + return Promise.all( + bindings.map((binding) => + fetchComparePoolGraphSource(binding, { + ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}), + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }), + ), + ); + }, + async fetchNuthatchResult() { + if (nuthatchClient === null) { + return createNuthatchFailureResult( + nuthatchRecord, + "error", + nuthatchSetupWarning ?? "Nuthatch source initialization failed.", + ); + } + + try { + return await fetchNuthatchFreshness({ + client: nuthatchClient, + clock: options.nuthatchClock ?? (() => Math.floor(Date.now() / 1_000)), + record: nuthatchRecord, + }); + } catch { + return createNuthatchFailureResult( + nuthatchRecord, + "error", + "Nuthatch live source failed unexpectedly; details were redacted.", + ); + } + }, + }; +} diff --git a/src/tools/live-wallet-sources.ts b/src/tools/live-wallet-sources.ts new file mode 100644 index 0000000..3a49109 --- /dev/null +++ b/src/tools/live-wallet-sources.ts @@ -0,0 +1,94 @@ +import { getSourceById, RegistryConfigurationError } from "../registry/index.js"; +import type { GraphSourceRegistryRecord, NuthatchSourceRegistryRecord } from "../registry/types.js"; +import { WALLET_RESEARCH_SCOPE } from "../scope/wallet-research.js"; +import { + fetchWalletGraphPositions, + walletGraphProvenance, +} from "../sources/graph/wallet-adapter.js"; +import { createNuthatchClient, type NuthatchClient } from "../sources/nuthatch/client.js"; +import { + createWalletActivityFailure, + fetchNuthatchWalletActivity, + walletActivityProvenance, +} from "../sources/nuthatch/wallet-activity-adapter.js"; + +import type { WalletResearchSourceGateway } from "./wallet-sources.js"; + +type Environment = Readonly>; + +function graphRecord(): GraphSourceRegistryRecord { + const record = getSourceById(WALLET_RESEARCH_SCOPE.graph.sourceId); + if (record === undefined || record.source_type === "nuthatch_view") { + throw new RegistryConfigurationError([ + `records.json: required wallet Graph source "${WALLET_RESEARCH_SCOPE.graph.sourceId}" is missing or invalid`, + ]); + } + return record; +} + +function nuthatchRecord(): NuthatchSourceRegistryRecord { + const record = getSourceById(WALLET_RESEARCH_SCOPE.nuthatch.sourceId); + if (record === undefined || record.source_type !== "nuthatch_view") { + throw new RegistryConfigurationError([ + `records.json: required wallet Nuthatch source "${WALLET_RESEARCH_SCOPE.nuthatch.sourceId}" is missing or invalid`, + ]); + } + return record; +} + +export interface LiveWalletSourcesOptions { + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly environment?: Environment; + readonly graphApiKey?: string; + readonly nuthatchBaseUrl?: string; + readonly clock?: () => number; +} + +export function createLiveWalletSources( + options: LiveWalletSourcesOptions = {}, +): WalletResearchSourceGateway { + const graph = graphRecord(); + const nuthatch = nuthatchRecord(); + const environment = options.environment ?? process.env; + const baseUrl = options.nuthatchBaseUrl ?? environment[nuthatch.locator.base_url_env]; + let client: NuthatchClient | null = null; + if (baseUrl !== undefined && baseUrl.trim() !== "") { + try { + client = createNuthatchClient({ + baseUrl, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + }); + } catch { + client = null; + } + } + const clock = options.clock ?? (() => Math.floor(Date.now() / 1_000)); + + return { + graphProvenance: walletGraphProvenance(graph), + nuthatchProvenance: walletActivityProvenance(nuthatch), + fetchPositions(request) { + const apiKey = options.graphApiKey ?? environment.GRAPH_API_KEY; + return fetchWalletGraphPositions(graph, request, { + apiKey: apiKey ?? "", + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}), + nowSeconds: clock(), + }); + }, + fetchActivity(context) { + if (client === null) { + return Promise.resolve( + createWalletActivityFailure( + nuthatch, + "error", + `Wallet activity source is unavailable because ${nuthatch.locator.base_url_env} is not configured.`, + ), + ); + } + return fetchNuthatchWalletActivity({ client, clock, record: nuthatch }, context); + }, + }; +} diff --git a/src/tools/research-wallet.ts b/src/tools/research-wallet.ts new file mode 100644 index 0000000..c583ce1 --- /dev/null +++ b/src/tools/research-wallet.ts @@ -0,0 +1,352 @@ +import { z } from "zod"; + +import { M0_CORE_POLICY } from "../policy/index.js"; +import { + researchWalletResponseSchema, + type DeFiPosition, + type ResearchWalletRequestInput, + type ResearchWalletResponse, + type WalletActivity, + type WalletResearchData, +} from "../schemas/index.js"; +import { + WALLET_RESEARCH_SCOPE, + WALLET_RESEARCH_SECTIONS, + type WalletResearchSection, +} from "../scope/wallet-research.js"; + +import { + inspectWalletResearchQuery, + paginateWalletActivity, + WalletResearchQueryError, +} from "./wallet-query.js"; +import type { + WalletActivityResult, + WalletGraphResult, + WalletResearchSourceGateway, +} from "./wallet-sources.js"; + +export { WalletResearchQueryError }; + +export class ResearchWalletToolError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "ResearchWalletToolError"; + } +} + +function baseUnits(raw: bigint, decimals: number): string { + const digits = raw.toString().padStart(decimals + 1, "0"); + if (decimals === 0) { + return digits; + } + const whole = digits.slice(0, -decimals).replace(/^0+(?=\d)/, ""); + const fraction = digits.slice(-decimals).replace(/0+$/, ""); + return fraction === "" ? whole : `${whole}.${fraction}`; +} + +function observedAssets(activities: readonly WalletActivity[], positions: readonly DeFiPosition[]) { + const byAddress = new Map< + string, + { token_address: string; symbol: string; decimals: number; source_ids: Set } + >(); + for (const activity of activities) { + for (const asset of activity.assets) { + const entry = byAddress.get(asset.token_address) ?? { + token_address: asset.token_address, + symbol: asset.symbol, + decimals: asset.decimals, + source_ids: new Set(), + }; + entry.source_ids.add(activity.source_id); + byAddress.set(asset.token_address, entry); + } + } + for (const position of positions) { + for (const asset of position.assets) { + const entry = byAddress.get(asset.token_address) ?? { + token_address: asset.token_address, + symbol: asset.symbol, + decimals: asset.decimals, + source_ids: new Set(), + }; + position.source_ids.forEach((sourceId) => entry.source_ids.add(sourceId)); + byAddress.set(asset.token_address, entry); + } + } + return [...byAddress.values()] + .sort((left, right) => left.token_address.localeCompare(right.token_address)) + .map((entry) => ({ ...entry, source_ids: [...entry.source_ids].sort() })); +} + +function counterparties(activities: readonly WalletActivity[]) { + const counts = new Map(); + for (const activity of activities) { + if (activity.counterparty !== null) { + counts.set(activity.counterparty, (counts.get(activity.counterparty) ?? 0) + 1); + } + } + return [...counts] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([address, interaction_count]) => ({ + address, + interaction_count, + source_ids: [WALLET_RESEARCH_SCOPE.nuthatch.sourceId], + })); +} + +function observableFlows(activities: readonly WalletActivity[]) { + const totals = new Map< + string, + { + direction: "inflow" | "outflow"; + token_address: string; + symbol: string; + decimals: number; + raw: bigint; + } + >(); + for (const activity of activities) { + const direction = activity.activity_type === "swap_received" ? "inflow" : "outflow"; + const role = direction === "inflow" ? "swap_out" : "swap_in"; + const asset = activity.assets.find((candidate) => candidate.role === role); + if (asset === undefined) { + continue; + } + const key = `${direction}:${asset.token_address}`; + const entry = totals.get(key) ?? { + direction, + token_address: asset.token_address, + symbol: asset.symbol, + decimals: asset.decimals, + raw: 0n, + }; + entry.raw += BigInt(asset.raw_amount); + totals.set(key, entry); + } + return [...totals.values()] + .sort((left, right) => + `${left.direction}:${left.token_address}`.localeCompare( + `${right.direction}:${right.token_address}`, + ), + ) + .map((entry) => ({ + direction: entry.direction, + token_address: entry.token_address, + symbol: entry.symbol, + raw_amount: entry.raw.toString(), + normalized_amount: baseUnits(entry.raw, entry.decimals), + source_ids: [WALLET_RESEARCH_SCOPE.nuthatch.sourceId], + })); +} + +function protocolUsage(activities: readonly WalletActivity[], positions: readonly DeFiPosition[]) { + const protocols = new Map< + string, + { activity_count: number; position_count: number; source_ids: Set } + >(); + for (const activity of activities) { + const entry = protocols.get(activity.protocol) ?? { + activity_count: 0, + position_count: 0, + source_ids: new Set(), + }; + entry.activity_count += 1; + entry.source_ids.add(activity.source_id); + protocols.set(activity.protocol, entry); + } + for (const position of positions) { + const entry = protocols.get(position.protocol) ?? { + activity_count: 0, + position_count: 0, + source_ids: new Set(), + }; + entry.position_count += 1; + position.source_ids.forEach((sourceId) => entry.source_ids.add(sourceId)); + protocols.set(position.protocol, entry); + } + return [...protocols] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([protocol, entry]) => ({ + protocol, + activity_count: entry.activity_count, + position_count: entry.position_count, + source_ids: [...entry.source_ids].sort(), + })); +} + +function sourcesForSection(section: WalletResearchSection): readonly ("graph" | "nuthatch")[] { + if (section === "positions") { + return ["graph"]; + } + if (section === "activity" || section === "counterparties" || section === "observable_flows") { + return ["nuthatch"]; + } + return ["graph", "nuthatch"]; +} + +function coverageSections( + requested: readonly WalletResearchSection[], + graph: WalletGraphResult, + nuthatch: WalletActivityResult, +) { + return WALLET_RESEARCH_SECTIONS.map((section) => { + const isRequested = requested.includes(section); + const required = sourcesForSection(section); + const successes = required.filter((source) => + source === "graph" ? graph.status === "ok" : nuthatch.status === "ok", + ); + return { + section, + requested: isRequested, + status: !isRequested + ? ("not_requested" as const) + : successes.length === required.length + ? ("complete" as const) + : successes.length > 0 + ? ("partial" as const) + : ("unavailable" as const), + source_ids: isRequested + ? successes.map((source) => + source === "graph" + ? WALLET_RESEARCH_SCOPE.graph.sourceId + : WALLET_RESEARCH_SCOPE.nuthatch.sourceId, + ) + : [], + }; + }); +} + +function requested( + sections: readonly WalletResearchSection[], + section: WalletResearchSection, + value: T[], +): T[] { + return sections.includes(section) ? value : []; +} + +function assertSize(response: ResearchWalletResponse): ResearchWalletResponse { + if ( + Buffer.byteLength(JSON.stringify(response), "utf8") > + M0_CORE_POLICY.gateway.maximumResponseBytes + ) { + throw new ResearchWalletToolError( + "research_wallet response exceeded the maximum response size.", + ); + } + return response; +} + +export async function executeResearchWallet( + input: ResearchWalletRequestInput, + sources: WalletResearchSourceGateway, +): Promise { + const context = inspectWalletResearchQuery(input); + const [graph, nuthatch] = await Promise.all([ + sources.fetchPositions(context.request), + sources.fetchActivity(context), + ]); + const successfulSources = Number(graph.status === "ok") + Number(nuthatch.status === "ok"); + const graphPositions = graph.status === "ok" ? [...graph.positions] : []; + const allActivities = nuthatch.status === "ok" ? [...nuthatch.activities] : []; + const page = + nuthatch.status === "ok" + ? paginateWalletActivity(context, allActivities, nuthatch.indexedHead) + : { + activities: [] as WalletActivity[], + pagination: { + limit: context.request.limit, + returned: 0, + has_more: false, + next_cursor: null, + }, + }; + const warnings = [ + ...graph.warnings, + ...nuthatch.warnings, + ...(graph.status === "ok" && graphPositions.length === 0 + ? ["The selected Graph source returned no supported open positions for this wallet."] + : []), + ...(nuthatch.status === "ok" && allActivities.length === 0 + ? ["The indexed Nuthatch pool returned no wallet activity in the requested window."] + : []), + ]; + const coverage = { + requested_sources: 2 as const, + successful_sources: successfulSources, + sections: coverageSections(context.request.sections, graph, nuthatch), + }; + const quality = { + coverage, + freshness: [graph.freshness, nuthatch.freshness], + provenance: [graph.provenance, nuthatch.provenance], + warnings, + pagination: context.request.sections.includes("activity") + ? page.pagination + : { + limit: context.request.limit, + returned: 0, + has_more: false, + next_cursor: null, + }, + }; + + if (successfulSources === 0) { + return assertSize( + researchWalletResponseSchema.parse({ + status: "failed", + data: null, + ...quality, + }), + ); + } + + const data: WalletResearchData = { + chain_id: WALLET_RESEARCH_SCOPE.chainId, + wallet_address: context.request.address, + window: context.request.window, + requested_sections: context.request.sections, + activity: requested(context.request.sections, "activity", page.activities), + counterparties: requested( + context.request.sections, + "counterparties", + counterparties(allActivities), + ), + protocol_usage: requested( + context.request.sections, + "protocol_usage", + protocolUsage(allActivities, graphPositions), + ), + observable_flows: requested( + context.request.sections, + "observable_flows", + observableFlows(allActivities), + ), + observed_assets: requested( + context.request.sections, + "observed_assets", + observedAssets(allActivities, graphPositions), + ), + positions: requested(context.request.sections, "positions", graphPositions), + }; + const hasGraphFact = graphPositions.length > 0; + const hasNuthatchFact = allActivities.length > 0; + const status = + successfulSources === 2 && hasGraphFact && hasNuthatchFact ? "complete" : "partial"; + try { + return assertSize( + researchWalletResponseSchema.parse({ + status, + data, + ...quality, + }), + ); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ResearchWalletToolError("research_wallet response failed schema validation.", { + cause: error, + }); + } + throw error; + } +} diff --git a/src/tools/wallet-query.ts b/src/tools/wallet-query.ts new file mode 100644 index 0000000..af7f524 --- /dev/null +++ b/src/tools/wallet-query.ts @@ -0,0 +1,171 @@ +import { z } from "zod"; + +import { + researchWalletRequestSchema, + type ResearchWalletRequest, + type ResearchWalletRequestInput, + type WalletActivity, + type WalletResearchPagination, +} from "../schemas/index.js"; +import { WALLET_RESEARCH_SCOPE } from "../scope/wallet-research.js"; + +const CURSOR_PREFIX = `wr:v${String(WALLET_RESEARCH_SCOPE.cursor.version)}:`; +const CURSOR_PATTERN = /^wr:v(\d+):([A-Za-z0-9_-]+)$/; +const hashSchema = z.string().regex(/^0x[0-9a-f]{64}$/); + +const cursorPayloadSchema = z + .object({ + snapshot_head: z.number().int().nonnegative(), + address: z.string().regex(/^0x[0-9a-f]{40}$/), + window: z.enum(WALLET_RESEARCH_SCOPE.windows), + last_event: z + .object({ + block_number: z.number().int().nonnegative(), + log_index: z.number().int().nonnegative(), + transaction_hash: hashSchema, + }) + .strict(), + }) + .strict(); + +type WalletCursorPayload = z.infer; + +export interface WalletEventPosition { + readonly block_number: number; + readonly log_index: number; + readonly transaction_hash: string; +} + +export interface WalletResearchQueryContext { + readonly request: ResearchWalletRequest; + readonly snapshotHead: number | null; + readonly lastEvent: WalletEventPosition | null; +} + +export class WalletResearchQueryError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "WalletResearchQueryError"; + } +} + +function decodeCursor(cursor: string): WalletCursorPayload { + const match = CURSOR_PATTERN.exec(cursor); + if (match === null || match[1] !== String(WALLET_RESEARCH_SCOPE.cursor.version)) { + throw new WalletResearchQueryError("Malformed or unsupported wallet cursor."); + } + try { + const encoded = match[2]!; + const bytes = Buffer.from(encoded, "base64url"); + if (bytes.toString("base64url") !== encoded) { + throw new Error("Non-canonical base64url"); + } + return cursorPayloadSchema.parse(JSON.parse(bytes.toString("utf8")) as unknown); + } catch (error) { + throw new WalletResearchQueryError("Malformed wallet cursor.", { cause: error }); + } +} + +function encodeCursor(payload: WalletCursorPayload): string { + const encoded = Buffer.from(JSON.stringify(cursorPayloadSchema.parse(payload)), "utf8").toString( + "base64url", + ); + const cursor = `${CURSOR_PREFIX}${encoded}`; + if (cursor.length > WALLET_RESEARCH_SCOPE.cursor.maximumLength) { + throw new WalletResearchQueryError("Generated wallet cursor exceeds the maximum length."); + } + return cursor; +} + +export function inspectWalletResearchQuery( + input: ResearchWalletRequestInput, +): WalletResearchQueryContext { + const parsed = researchWalletRequestSchema.safeParse(input); + if (!parsed.success) { + throw new WalletResearchQueryError("Invalid research_wallet request.", { + cause: parsed.error, + }); + } + const cursor = parsed.data.cursor === null ? null : decodeCursor(parsed.data.cursor); + if ( + cursor !== null && + (cursor.address !== parsed.data.address || cursor.window !== parsed.data.window) + ) { + throw new WalletResearchQueryError("Wallet cursor does not match the request scope."); + } + return { + request: parsed.data, + snapshotHead: cursor?.snapshot_head ?? null, + lastEvent: cursor?.last_event ?? null, + }; +} + +function compareEventPosition(left: WalletEventPosition, right: WalletEventPosition): number { + if (left.block_number !== right.block_number) { + return left.block_number > right.block_number ? -1 : 1; + } + if (left.log_index !== right.log_index) { + return left.log_index > right.log_index ? -1 : 1; + } + return left.transaction_hash.localeCompare(right.transaction_hash); +} + +export function paginateWalletActivity( + context: WalletResearchQueryContext, + activities: readonly WalletActivity[], + indexedHead: number, +): { readonly activities: WalletActivity[]; readonly pagination: WalletResearchPagination } { + if (!Number.isSafeInteger(indexedHead) || indexedHead < 0) { + throw new WalletResearchQueryError("Wallet activity indexed head is invalid."); + } + const snapshotHead = context.snapshotHead ?? indexedHead; + if (indexedHead < snapshotHead) { + throw new WalletResearchQueryError("Wallet activity source is behind the cursor snapshot."); + } + + const identities = new Set(); + let ordered = activities + .filter( + (activity) => + activity.wallet_address === context.request.address && + activity.block_number <= snapshotHead, + ) + .filter((activity) => { + const identity = `${activity.transaction_hash}:${String(activity.log_index)}`; + if (identities.has(identity)) { + return false; + } + identities.add(identity); + return true; + }) + .sort(compareEventPosition); + + if (context.lastEvent !== null) { + ordered = ordered.filter((activity) => compareEventPosition(activity, context.lastEvent!) > 0); + } + + const page = ordered.slice(0, context.request.limit); + const hasMore = ordered.length > page.length; + const last = page.at(-1); + return { + activities: page, + pagination: { + limit: context.request.limit, + returned: page.length, + has_more: hasMore, + next_cursor: + hasMore && last !== undefined + ? encodeCursor({ + snapshot_head: snapshotHead, + address: context.request.address, + window: context.request.window, + last_event: { + block_number: last.block_number, + log_index: last.log_index, + transaction_hash: last.transaction_hash, + }, + }) + : null, + }, + }; +} diff --git a/src/tools/wallet-sources.ts b/src/tools/wallet-sources.ts new file mode 100644 index 0000000..19369fc --- /dev/null +++ b/src/tools/wallet-sources.ts @@ -0,0 +1,50 @@ +import type { + DeFiPosition, + ResultFreshness, + ResultProvenance, + WalletActivity, +} from "../schemas/index.js"; +import type { ResearchWalletRequest } from "../schemas/wallet-research-request.js"; + +import type { WalletResearchQueryContext } from "./wallet-query.js"; + +export type WalletSourceFailureStatus = "error" | "stale" | "timeout" | "unsupported"; + +interface WalletSourceQuality { + readonly freshness: ResultFreshness; + readonly provenance: ResultProvenance; + readonly warnings: readonly string[]; +} + +export interface WalletGraphSuccess extends WalletSourceQuality { + readonly status: "ok"; + readonly positions: readonly DeFiPosition[]; +} + +export interface WalletGraphFailure extends WalletSourceQuality { + readonly status: WalletSourceFailureStatus; + readonly positions: null; +} + +export type WalletGraphResult = WalletGraphSuccess | WalletGraphFailure; + +export interface WalletActivitySuccess extends WalletSourceQuality { + readonly status: "ok"; + readonly activities: readonly WalletActivity[]; + readonly indexedHead: number; +} + +export interface WalletActivityFailure extends WalletSourceQuality { + readonly status: WalletSourceFailureStatus; + readonly activities: null; + readonly indexedHead: null; +} + +export type WalletActivityResult = WalletActivitySuccess | WalletActivityFailure; + +export interface WalletResearchSourceGateway { + readonly graphProvenance: ResultProvenance; + readonly nuthatchProvenance: ResultProvenance; + fetchPositions(request: ResearchWalletRequest): Promise; + fetchActivity(context: WalletResearchQueryContext): Promise; +} diff --git a/tests/fixtures/compare-pools-request.ts b/tests/fixtures/compare-pools-request.ts new file mode 100644 index 0000000..20fdfa1 --- /dev/null +++ b/tests/fixtures/compare-pools-request.ts @@ -0,0 +1,16 @@ +import type { ComparePoolsRequestInput } from "../../src/schemas/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; + +/** Minimal valid public request for the locked Graph pair. */ +export const lockedComparePoolsRequest = { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + token0: M0_COMPARE_POOLS_SCOPE.token0.address, + token1: M0_COMPARE_POOLS_SCOPE.token1.address, +} as const satisfies ComparePoolsRequestInput; + +export const lockedComparePoolsRequestWithOptions = { + ...lockedComparePoolsRequest, + window: "7d", + ranked_by: "tvl_usd", + top_n: 2, +} as const satisfies ComparePoolsRequestInput; diff --git a/tests/fixtures/compare-pools.ts b/tests/fixtures/compare-pools.ts new file mode 100644 index 0000000..8d171dc --- /dev/null +++ b/tests/fixtures/compare-pools.ts @@ -0,0 +1,203 @@ +import type { + CanonicalPair, + ComparePoolsResponse, + PoolComparisonRecord, + ResultFreshness, + ResultProvenance, +} from "../../src/schemas/index.js"; + +export const fixturePair = [ + { + chain_id: 8453, + address: "0x1111111111111111111111111111111111111111", + symbol: "TOKEN-A", + decimals: 18, + }, + { + chain_id: 8453, + address: "0x2222222222222222222222222222222222222222", + symbol: "TOKEN-B", + decimals: 6, + }, +] as const satisfies CanonicalPair; + +const graphSourceIds = ["fixture-dex-a", "fixture-dex-b"] as const; +const nuthatchSourceId = "fixture-nuthatch"; + +function graphProvenance(sourceId: string, protocol: string): ResultProvenance { + return { + source_id: sourceId, + source_type: "native_subgraph", + protocol, + chain_id: 8453, + deployment_or_view_id: `fixture-deployment-${sourceId}`, + schema_version: null, + methodology_version: "fixture-pool-metrics-v1", + query_id: "fixture-pool-query-v1", + }; +} + +const graphProvenanceEntries = [ + graphProvenance(graphSourceIds[0], "protocol-a"), + graphProvenance(graphSourceIds[1], "protocol-b"), +] as const; + +const nuthatchProvenance = { + source_id: nuthatchSourceId, + source_type: "nuthatch_view", + protocol: "fixture-nuthatch", + chain_id: 8453, + deployment_or_view_id: "fixture-nuthatch-view", + schema_version: "fixture-v1", + methodology_version: "fixture-swap-freshness-v1", + query_id: "fixture-nuthatch-query-v1", +} as const satisfies ResultProvenance; + +function poolRecord( + rank: number, + sourceId: string, + protocol: string, + poolAddress: string, + values: Pick, +): PoolComparisonRecord { + return { + chain_id: 8453, + protocol, + pool_address: poolAddress, + pair: fixturePair, + ...values, + window: "24h", + rank, + source_ids: [sourceId], + }; +} + +const completePools = [ + poolRecord(1, graphSourceIds[0], "protocol-a", "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { + tvl_usd: "1200000.50", + volume_usd: "450000.25", + fees_usd: "1350.75", + }), + poolRecord(2, graphSourceIds[1], "protocol-b", "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", { + tvl_usd: "900000", + volume_usd: "300000", + fees_usd: "0", + }), +] as const; + +function observedFreshness( + sourceId: string, + indexedBlock: number, + lagSeconds: number, + status: "fresh" | "stale" = "fresh", +): ResultFreshness { + return { + source_id: sourceId, + status, + indexed_block: indexedBlock, + indexed_block_timestamp: 1_700_000_000 - lagSeconds, + indexed_block_hash: null, + queried_at: 1_700_000_000, + lag_seconds: lagSeconds, + }; +} + +const allProvenance = [...graphProvenanceEntries, nuthatchProvenance] as const; + +export const completeComparePoolsFixture = { + status: "complete", + data: { + chain_id: 8453, + pair: fixturePair, + window: "24h", + ranked_by: "volume_usd", + pools: completePools, + nuthatch_freshness_fact: { + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + recent_swap_count_24h: 42, + last_swap_block: 12_345_678, + last_swap_block_timestamp: 1_700_000_000, + source_id: nuthatchSourceId, + }, + }, + coverage: { + requested_deployments: 2, + successful_deployments: 2, + nuthatch_available: true, + }, + freshness: [ + observedFreshness(graphSourceIds[0], 12_345_678, 12), + observedFreshness(graphSourceIds[1], 12_345_670, 28), + { + ...observedFreshness(nuthatchSourceId, 12_345_678, 8), + indexed_block_hash: `0x${"1".repeat(64)}`, + }, + ], + provenance: allProvenance, + warnings: [], + pagination: null, +} as const satisfies ComparePoolsResponse; + +export const partialComparePoolsFixture = { + status: "partial", + data: { + chain_id: 8453, + pair: fixturePair, + window: "24h", + ranked_by: "volume_usd", + pools: [ + completePools[0], + poolRecord(2, graphSourceIds[1], "protocol-b", "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", { + tvl_usd: "900000", + volume_usd: null, + fees_usd: "0", + }), + ], + nuthatch_freshness_fact: null, + }, + coverage: { + requested_deployments: 2, + successful_deployments: 2, + nuthatch_available: false, + }, + freshness: [ + observedFreshness(graphSourceIds[0], 12_345_678, 12), + observedFreshness(graphSourceIds[1], 12_345_000, 600, "stale"), + { + source_id: nuthatchSourceId, + status: "unavailable", + }, + ], + provenance: allProvenance, + warnings: ["fixture-nuthatch was unavailable", "fixture-dex-b exceeded the freshness threshold"], + pagination: null, +} as const satisfies ComparePoolsResponse; + +export const failedComparePoolsFixture = { + status: "failed", + data: null, + coverage: { + requested_deployments: 2, + successful_deployments: 0, + nuthatch_available: false, + }, + freshness: [ + ...graphSourceIds.map((sourceId) => ({ + source_id: sourceId, + status: "unavailable" as const, + })), + { + source_id: nuthatchSourceId, + status: "unavailable", + }, + ], + provenance: allProvenance, + warnings: ["No valid Graph pool record was available"], + pagination: null, +} as const satisfies ComparePoolsResponse; + +export const comparePoolsFixtures = [ + completeComparePoolsFixture, + partialComparePoolsFixture, + failedComparePoolsFixture, +] as const; diff --git a/tests/fixtures/large-swaps-request.ts b/tests/fixtures/large-swaps-request.ts new file mode 100644 index 0000000..0b63826 --- /dev/null +++ b/tests/fixtures/large-swaps-request.ts @@ -0,0 +1,21 @@ +import type { FindLargeSwapsRequestInput } from "../../src/schemas/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; + +export const fixtureLargeSwapCursor = + "lss:v1:eyJzbmFwc2hvdF9oZWFkIjoyMDAwMDEwMCwiY2hhaW5faWQiOjg0NTMsInBvb2xfYWRkcmVzcyI6IjB4NmM1NjFiNDQ2NDE2ZTFhMDBlOGU5M2UyMjE4NTRkNmVhNDE3MTM3MiIsInRocmVzaG9sZF90b2tlbiI6IjB4ODMzNTg5ZmNkNmVkYjZlMDhmNGM3YzMyZDRmNzFiNTRiZGEwMjkxMyIsIm1pbl9hbW91bnQiOiIyNTAwIiwibGFzdF9ldmVudCI6eyJibG9ja19udW1iZXIiOjIwMDAwMDk4LCJsb2dfaW5kZXgiOjcsInRyYW5zYWN0aW9uX2hhc2giOiIweDIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIifX0"; + +export const lockedLargeSwapsRequest = { + chain_id: LSS_SCOPE.chainId, + pool_address: LSS_SCOPE.poolAddress, + threshold_token: LSS_SCOPE.tokens.weth.address, + min_amount: "1.5", +} as const satisfies FindLargeSwapsRequestInput; + +export const lockedLargeSwapsRequestWithOptions = { + chain_id: LSS_SCOPE.chainId, + pool_address: LSS_SCOPE.poolAddress, + threshold_token: LSS_SCOPE.tokens.usdc.address, + min_amount: "2500", + limit: 1, + cursor: fixtureLargeSwapCursor, +} as const satisfies FindLargeSwapsRequestInput; diff --git a/tests/fixtures/large-swaps.ts b/tests/fixtures/large-swaps.ts new file mode 100644 index 0000000..8026dc0 --- /dev/null +++ b/tests/fixtures/large-swaps.ts @@ -0,0 +1,185 @@ +import type { + FindLargeSwapsResponse, + ResultFreshness, + ResultProvenance, + SwapEvent, +} from "../../src/schemas/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; +import { fixtureLargeSwapCursor } from "./large-swaps-request.js"; + +const sourceId = "fixture-nuthatch-swaps"; + +const weth = { + chain_id: LSS_SCOPE.chainId, + ...LSS_SCOPE.tokens.weth, +} as const; + +const usdc = { + chain_id: LSS_SCOPE.chainId, + ...LSS_SCOPE.tokens.usdc, +} as const; + +const provenance = { + source_id: sourceId, + source_type: "nuthatch_view", + protocol: LSS_SCOPE.protocol, + chain_id: LSS_SCOPE.chainId, + deployment_or_view_id: "fixture-swap-search-view", + schema_version: "fixture-v1", + methodology_version: "fixture-swap-normalization-v1", + query_id: "fixture-large-swaps-query-v1", +} as const satisfies ResultProvenance; + +const fresh = { + source_id: sourceId, + status: "fresh", + indexed_block: 20_000_100, + indexed_block_timestamp: 1_750_000_000, + indexed_block_hash: `0x${"a".repeat(64)}`, + queried_at: 1_750_000_020, + lag_seconds: 20, +} as const satisfies ResultFreshness; + +export const wethToUsdcSwapFixture = { + chain_id: LSS_SCOPE.chainId, + protocol: LSS_SCOPE.protocol, + pool: LSS_SCOPE.poolAddress, + transaction_hash: `0x${"1".repeat(64)}`, + log_index: 12, + block_number: 20_000_099, + timestamp: 1_749_999_990, + asset_in: weth, + asset_out: usdc, + amount_in: "2.5", + amount_out: "6250.125", + amount_in_raw: "2500000000000000000", + amount_out_raw: "-6250125000", + usd_notional: null, + source_id: sourceId, +} satisfies SwapEvent; + +export const usdcToWethSwapFixture = { + chain_id: LSS_SCOPE.chainId, + protocol: LSS_SCOPE.protocol, + pool: LSS_SCOPE.poolAddress, + transaction_hash: `0x${"2".repeat(64)}`, + log_index: 7, + block_number: 20_000_098, + timestamp: 1_749_999_980, + asset_in: usdc, + asset_out: weth, + amount_in: "3000.000001", + amount_out: "1.2", + amount_in_raw: "3000000001", + amount_out_raw: "-1200000000000000000", + usd_notional: null, + source_id: sourceId, +} satisfies SwapEvent; + +const quality: Pick< + Extract, + "coverage" | "freshness" | "provenance" | "warnings" +> = { + coverage: { + requested_sources: 1, + successful_sources: 1, + }, + freshness: [fresh], + provenance: [provenance], + warnings: [], +}; + +export const completeLargeSwapsFixture = { + status: "complete", + data: { + chain_id: LSS_SCOPE.chainId, + pool_address: LSS_SCOPE.poolAddress, + threshold_token: LSS_SCOPE.tokens.weth.address, + min_amount: "1.5", + swaps: [wethToUsdcSwapFixture, usdcToWethSwapFixture], + }, + ...quality, + pagination: { + limit: 25, + returned: 2, + has_more: false, + next_cursor: null, + }, +} satisfies FindLargeSwapsResponse; + +export const emptyLargeSwapsFixture = { + status: "complete", + data: { + chain_id: LSS_SCOPE.chainId, + pool_address: LSS_SCOPE.poolAddress, + threshold_token: LSS_SCOPE.tokens.weth.address, + min_amount: "1000000", + swaps: [], + }, + ...quality, + pagination: { + limit: 25, + returned: 0, + has_more: false, + next_cursor: null, + }, +} satisfies FindLargeSwapsResponse; + +export const paginatedLargeSwapsFixture = { + status: "complete", + data: { + chain_id: LSS_SCOPE.chainId, + pool_address: LSS_SCOPE.poolAddress, + threshold_token: LSS_SCOPE.tokens.usdc.address, + min_amount: "2500", + swaps: [usdcToWethSwapFixture], + }, + ...quality, + pagination: { + limit: 1, + returned: 1, + has_more: true, + next_cursor: fixtureLargeSwapCursor, + }, +} satisfies FindLargeSwapsResponse; + +export const failedSourceLargeSwapsFixture = { + status: "failed", + data: null, + coverage: { + requested_sources: 1, + successful_sources: 0, + }, + freshness: [ + { + source_id: sourceId, + status: "unavailable", + }, + ], + provenance: [provenance], + warnings: ["fixture-nuthatch-swaps was unavailable"], + pagination: { + limit: 25, + returned: 0, + has_more: false, + next_cursor: null, + }, +} satisfies FindLargeSwapsResponse; + +/** + * Intentionally invalid canonical page used to prove duplicate identity rejection. + */ +export const duplicateLargeSwapsFixture = { + ...completeLargeSwapsFixture, + data: { + ...completeLargeSwapsFixture.data, + swaps: [wethToUsdcSwapFixture, { ...wethToUsdcSwapFixture }], + }, +} satisfies FindLargeSwapsResponse; + +export const validLargeSwapsFixtures = [ + completeLargeSwapsFixture, + emptyLargeSwapsFixture, + paginatedLargeSwapsFixture, + failedSourceLargeSwapsFixture, +] as const; diff --git a/tests/fixtures/lending-sources.ts b/tests/fixtures/lending-sources.ts new file mode 100644 index 0000000..7138fd0 --- /dev/null +++ b/tests/fixtures/lending-sources.ts @@ -0,0 +1,170 @@ +import type { LendingMarketSourceResult } from "../../src/schemas/source-adapter.js"; + +export type LendingSourceFixture = LendingMarketSourceResult; + +const usdc = { + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + symbol: "USDC", + decimals: 6, +} as const; + +// Point-in-time values captured from the three live Base lending deployments +// at block 49121431/49121432. Rates are percent APY exactly as reported. + +/** Messari Aave v3 Base, USDC market. */ +export const lendingAave = { + source_id: "messari-aave-v3-base", + source_type: "standardized_subgraph", + protocol: "aave-v3", + chain_id: 8453, + status: "ok", + data: { + market_id: "0x4e65fe4dba92790696d040ac24aa414708f5c0ab", + market_name: "Aave Base USDC", + input_token: usdc, + is_active: true, + can_borrow_from: true, + can_use_as_collateral: false, + tvl_usd: "172445303.026266003336", + total_deposit_balance_usd: "172445303.026266003336", + total_borrow_balance_usd: "152467572.25396482662232", + lender_variable_rate_percent: "3.5177249578887369", + borrower_variable_rate_percent: "4.4207374872837901", + borrower_stable_rate_percent: "0", + }, + freshness: { + indexed_block: 49121431, + indexed_block_timestamp: 1785032209, + indexed_block_hash: "0xa39b13862e2eb5f1f904c19c2e88fb5d82f9ee9f5352fd9f108a18e69247fbb1", + queried_at: 1785032214, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + schema_version: "3.1.0", + methodology_version: "1.1.0", + query_id: "tier-a-lending-market-metrics-v1", + }, + warnings: [], + latency_ms: 140, +} satisfies LendingMarketSourceResult; + +/** Messari Seamless Base, USDC market. The live market is flagged inactive. */ +export const lendingSeamless = { + source_id: "messari-seamless-base", + source_type: "standardized_subgraph", + protocol: "seamless-protocol", + chain_id: 8453, + status: "ok", + data: { + market_id: "0x53e240c0f985175da046a62f26d490d1e259036e", + market_name: "Seamless USDC", + input_token: usdc, + is_active: false, + can_borrow_from: true, + can_use_as_collateral: false, + tvl_usd: "205400.2928784070077", + total_deposit_balance_usd: "205400.2928784070077", + total_borrow_balance_usd: "24150.82687434733677", + lender_variable_rate_percent: "0.1106243693385229", + borrower_variable_rate_percent: "1.0452685606279676", + borrower_stable_rate_percent: "8", + }, + freshness: { + indexed_block: 49121431, + indexed_block_timestamp: 1785032209, + indexed_block_hash: "0xa39b13862e2eb5f1f904c19c2e88fb5d82f9ee9f5352fd9f108a18e69247fbb1", + queried_at: 1785032214, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "QmPSmTkJPSKLFn46YdgwMKV5K2c9a3pkWnzDCC4ccCLAXE", + schema_version: "3.1.0", + methodology_version: "1.0.0", + query_id: "tier-a-lending-market-metrics-v1", + }, + warnings: [ + "messari-seamless-base market 0x53e240c0f985175da046a62f26d490d1e259036e is reported as inactive.", + ], + latency_ms: 155, +} satisfies LendingMarketSourceResult; + +/** Messari Moonwell Base, USDC market. Moonwell publishes no stable rate. */ +export const lendingMoonwell = { + source_id: "messari-moonwell-base", + source_type: "standardized_subgraph", + protocol: "moonwell", + chain_id: 8453, + status: "ok", + data: { + market_id: "0xedc817a28e8b93b03976fbd4a3ddbc9f7d176c22", + market_name: "Moonwell USDC", + input_token: usdc, + is_active: true, + can_borrow_from: true, + can_use_as_collateral: true, + tvl_usd: "15066697.09797739573995", + total_deposit_balance_usd: "15066697.09797739573995", + total_borrow_balance_usd: "13154949.96727053476238", + lender_variable_rate_percent: "4.1165790985584", + borrower_variable_rate_percent: "5.2386888310416", + borrower_stable_rate_percent: null, + }, + freshness: { + indexed_block: 49121432, + indexed_block_timestamp: 1785032211, + indexed_block_hash: "0xeaa3038aa8c4bf926ffef0ae5e1c5bbd37007b6b23d2da97918160de41a37ad1", + queried_at: 1785032214, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "QmeE6TgfRmK2iLAgCLBeXuxJQ2VXLFAeHVMTvmnECiFw7y", + schema_version: "2.0.1", + methodology_version: "1.0.0", + query_id: "tier-a-lending-market-metrics-v1", + }, + warnings: [], + latency_ms: 210, +} satisfies LendingMarketSourceResult; + +/** Synthetic timeout over the real Seamless provenance (status flipped). */ +export const lendingSeamlessTimeout = { + source_id: lendingSeamless.source_id, + source_type: lendingSeamless.source_type, + protocol: lendingSeamless.protocol, + chain_id: lendingSeamless.chain_id, + status: "timeout", + data: null, + freshness: null, + provenance: lendingSeamless.provenance, + warnings: [`Synthetic timeout over real ${lendingSeamless.source_id} provenance.`], + latency_ms: 8000, +} satisfies LendingMarketSourceResult; + +/** Synthetic unsupported over the real Moonwell provenance (status flipped). */ +export const lendingMoonwellUnsupported = { + source_id: lendingMoonwell.source_id, + source_type: lendingMoonwell.source_type, + protocol: lendingMoonwell.protocol, + chain_id: lendingMoonwell.chain_id, + status: "unsupported", + data: null, + freshness: null, + provenance: lendingMoonwell.provenance, + warnings: [`Synthetic unsupported over real ${lendingMoonwell.source_id} provenance.`], + latency_ms: 95, +} satisfies LendingMarketSourceResult; + +/** All three protocols answered with fresh data. */ +export const completeLendingScenario = [ + lendingAave, + lendingSeamless, + lendingMoonwell, +] satisfies readonly LendingSourceFixture[]; + +/** One protocol answered; the other two degraded. */ +export const partialLendingScenario = [ + lendingAave, + lendingSeamlessTimeout, + lendingMoonwellUnsupported, +] satisfies readonly LendingSourceFixture[]; diff --git a/tests/fixtures/live-compare-pools.ts b/tests/fixtures/live-compare-pools.ts new file mode 100644 index 0000000..28da08d --- /dev/null +++ b/tests/fixtures/live-compare-pools.ts @@ -0,0 +1,239 @@ +import type { CanonicalPair, ComparePoolsResponse } from "../../src/schemas/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; +import { graphPoolA, graphPoolB, graphPoolCTimeout } from "./sources/index.js"; + +/** + * Live-derived compare_pools response fixtures for Graph-only M0-02B. + * Nuthatch remains explicitly unavailable — no invented live freshness fact. + */ + +const livePair = [ + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + address: M0_COMPARE_POOLS_SCOPE.token0.address, + symbol: M0_COMPARE_POOLS_SCOPE.token0.symbol, + decimals: M0_COMPARE_POOLS_SCOPE.token0.decimals, + }, + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + address: M0_COMPARE_POOLS_SCOPE.token1.address, + symbol: M0_COMPARE_POOLS_SCOPE.token1.symbol, + decimals: M0_COMPARE_POOLS_SCOPE.token1.decimals, + }, +] as const satisfies CanonicalPair; + +const [feeHigh, feeLow] = M0_COMPARE_POOLS_SCOPE.graphSources; +const nuthatchId = M0_COMPARE_POOLS_SCOPE.nuthatchSourceId; + +const feeHighProvenance = { + source_id: feeHigh.source_id, + source_type: "standardized_subgraph" as const, + protocol: feeHigh.protocol, + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + deployment_or_view_id: feeHigh.deployment_or_view_id, + schema_version: graphPoolA.provenance.schema_version, + methodology_version: graphPoolA.provenance.methodology_version, + query_id: feeHigh.query_id, +}; + +const feeLowProvenance = { + source_id: feeLow.source_id, + source_type: "standardized_subgraph" as const, + protocol: feeLow.protocol, + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + deployment_or_view_id: feeLow.deployment_or_view_id, + schema_version: graphPoolB.provenance.schema_version, + methodology_version: graphPoolB.provenance.methodology_version, + query_id: feeLow.query_id, +}; + +const nuthatchProvenance = { + source_id: nuthatchId, + source_type: "nuthatch_view" as const, + protocol: "uniswap-v3", + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + deployment_or_view_id: "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + schema_version: null, + methodology_version: null, + query_id: "nuthatch-pool-swap-freshness-v1", +}; + +const liveProvenance = [feeHighProvenance, feeLowProvenance, nuthatchProvenance] as const; + +function graphFreshness( + result: typeof graphPoolA, + lagSeconds: number, + status: "fresh" | "stale" = "fresh", +) { + return { + source_id: result.source_id, + status, + indexed_block: result.freshness.indexed_block, + indexed_block_timestamp: result.freshness.queried_at - lagSeconds, + indexed_block_hash: result.freshness.indexed_block_hash ?? null, + queried_at: result.freshness.queried_at, + lag_seconds: lagSeconds, + }; +} + +/** Both Graph sources observed; Nuthatch explicitly missing → partial. */ +export const livePartialComparePoolsFixture = { + status: "partial", + data: { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + pair: livePair, + window: "24h", + ranked_by: "volume_usd", + pools: [ + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + protocol: feeHigh.protocol, + pool_address: feeHigh.pool_address, + pair: livePair, + tvl_usd: graphPoolA.data.tvl_usd, + volume_usd: graphPoolA.data.volume_usd_24h, + fees_usd: graphPoolA.data.fees_usd_24h, + window: "24h", + rank: 1, + source_ids: [feeHigh.source_id], + }, + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + protocol: feeLow.protocol, + pool_address: feeLow.pool_address, + pair: livePair, + tvl_usd: graphPoolB.data.tvl_usd, + volume_usd: graphPoolB.data.volume_usd_24h, + fees_usd: graphPoolB.data.fees_usd_24h, + window: "24h", + rank: 2, + source_ids: [feeLow.source_id], + }, + ], + nuthatch_freshness_fact: null, + }, + coverage: { + requested_deployments: 2, + successful_deployments: 2, + nuthatch_available: false, + }, + freshness: [ + graphFreshness(graphPoolA, 5), + graphFreshness(graphPoolB, 5), + { source_id: nuthatchId, status: "unavailable" }, + ], + provenance: liveProvenance, + warnings: ["nuthatch-pool-swaps live freshness fact is not yet verified"], + pagination: null, +} as const satisfies ComparePoolsResponse; + +/** One Graph timeout + one Graph ok; Nuthatch unavailable. */ +export const livePartialOneGraphTimeoutFixture = { + status: "partial", + data: { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + pair: livePair, + window: "24h", + ranked_by: "volume_usd", + pools: [ + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + protocol: feeHigh.protocol, + pool_address: feeHigh.pool_address, + pair: livePair, + tvl_usd: graphPoolA.data.tvl_usd, + volume_usd: graphPoolA.data.volume_usd_24h, + fees_usd: graphPoolA.data.fees_usd_24h, + window: "24h", + rank: 1, + source_ids: [feeHigh.source_id], + }, + ], + nuthatch_freshness_fact: null, + }, + coverage: { + requested_deployments: 2, + successful_deployments: 1, + nuthatch_available: false, + }, + freshness: [ + graphFreshness(graphPoolA, 5), + { source_id: feeLow.source_id, status: "unavailable" }, + { source_id: nuthatchId, status: "unavailable" }, + ], + provenance: liveProvenance, + warnings: [ + `${graphPoolCTimeout.source_id} timed out`, + "nuthatch-pool-swaps live freshness fact is not yet verified", + ], + pagination: null, +} as const satisfies ComparePoolsResponse; + +/** All Graph sources unavailable; Nuthatch unavailable. */ +export const liveFailedComparePoolsFixture = { + status: "failed", + data: null, + coverage: { + requested_deployments: 2, + successful_deployments: 0, + nuthatch_available: false, + }, + freshness: [ + { source_id: feeHigh.source_id, status: "unavailable" }, + { source_id: feeLow.source_id, status: "unavailable" }, + { source_id: nuthatchId, status: "unavailable" }, + ], + provenance: liveProvenance, + warnings: ["No valid Graph pool record was available"], + pagination: null, +} as const satisfies ComparePoolsResponse; + +/** Stale Graph freshness with missing Nuthatch. */ +export const liveStaleGraphComparePoolsFixture = { + status: "partial", + data: { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + pair: livePair, + window: "24h", + ranked_by: "volume_usd", + pools: [ + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + protocol: feeHigh.protocol, + pool_address: feeHigh.pool_address, + pair: livePair, + tvl_usd: graphPoolA.data.tvl_usd, + volume_usd: graphPoolA.data.volume_usd_24h, + fees_usd: null, + window: "24h", + rank: 1, + source_ids: [feeHigh.source_id], + }, + ], + nuthatch_freshness_fact: null, + }, + coverage: { + requested_deployments: 2, + successful_deployments: 1, + nuthatch_available: false, + }, + freshness: [ + graphFreshness(graphPoolA, 600, "stale"), + { source_id: feeLow.source_id, status: "unavailable" }, + { source_id: nuthatchId, status: "unavailable" }, + ], + provenance: liveProvenance, + warnings: [ + `${feeHigh.source_id} exceeded the freshness threshold`, + `${feeLow.source_id} was unavailable`, + "nuthatch-pool-swaps live freshness fact is not yet verified", + ], + pagination: null, +} as const satisfies ComparePoolsResponse; + +export const liveComparePoolsFixtures = [ + livePartialComparePoolsFixture, + livePartialOneGraphTimeoutFixture, + liveFailedComparePoolsFixture, + liveStaleGraphComparePoolsFixture, +] as const; diff --git a/tests/fixtures/sources/index.ts b/tests/fixtures/sources/index.ts new file mode 100644 index 0000000..5f49c0e --- /dev/null +++ b/tests/fixtures/sources/index.ts @@ -0,0 +1,163 @@ +import type { + NuthatchSourceResult, + PoolSourceResult, +} from "../../../src/schemas/source-adapter.js"; + +export type PoolSourceFixture = PoolSourceResult; +export type NuthatchSourceFixture = NuthatchSourceResult; +export type MvpSourceFixture = PoolSourceFixture | NuthatchSourceFixture; + +const weth = { + address: "0x4200000000000000000000000000000000000006", + symbol: "WETH", + decimals: 18, +} as const; + +const usdc = { + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + symbol: "USDC", + decimals: 6, +} as const; + +// Point-in-time values from tests/integration/__evidence__/m3/*/01-pool-metrics.json. +// 24h values are the newest completed UTC day in that capture; 7d aggregates stay +// null here so null-handling paths stay covered without pinning a seven-day sum. + +/** Messari Uniswap V3 Base, 0.3% WETH/USDC tier. */ +export const graphPoolA = { + source_id: "messari-uniswap-v3-base-fee030", + source_type: "standardized_subgraph", + protocol: "uniswap-v3", + chain_id: 8453, + status: "ok", + data: { + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + token0: weth, + token1: usdc, + fee_tier_bps: 30, + tvl_usd: "114861166.2464289945430831254042441", + volume_usd_24h: "7784090.37607948122807978670587", + volume_usd_7d: null, + fees_usd_24h: "23352.27112823844368423936011761", + fees_usd_7d: null, + }, + freshness: { + indexed_block: 49121447, + indexed_block_timestamp: 1785032241, + indexed_block_hash: "0x4c8d9676350cd7754f7eb2a1f2eb8cab1660889c2a141be4a4f6905cc23dd54e", + queried_at: 1785032246, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + schema_version: "4.0.1", + methodology_version: "1.0.0", + query_id: "tier-a-dex-pool-metrics-v1", + }, + warnings: ["Fixture retains null 7d aggregates; live 7d sums come from the adapter."], + latency_ms: 120, +} satisfies PoolSourceResult; + +/** Messari Uniswap V3 Base, 0.05% WETH/USDC tier. */ +export const graphPoolB = { + source_id: "messari-uniswap-v3-base-fee005", + source_type: "standardized_subgraph", + protocol: "uniswap-v3", + chain_id: 8453, + status: "ok", + data: { + pool_address: "0xd0b53d9277642d899df5c87a3966a349a798f224", + token0: weth, + token1: usdc, + fee_tier_bps: 5, + tvl_usd: "10637748.34455860477744937575922415", + volume_usd_24h: "2957180.60438499663534225066392", + volume_usd_7d: null, + fees_usd_24h: "1478.59030219249831767112534419", + fees_usd_7d: null, + }, + freshness: { + indexed_block: 49121448, + indexed_block_timestamp: 1785032243, + indexed_block_hash: "0xe60c2ae734e634f8260a56b337c9a8c233b36c9a361fb53fcf727e680a2d1855", + queried_at: 1785032248, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + schema_version: "4.0.1", + methodology_version: "1.0.0", + query_id: "tier-a-dex-pool-metrics-v1", + }, + warnings: ["Fixture retains null 7d aggregates; live 7d sums come from the adapter."], + latency_ms: 301, +} satisfies PoolSourceResult; + +// Synthetic null-metric variant over the 0.05% tier's real identity. +export const graphPoolC = { + ...graphPoolB, + data: { + ...graphPoolB.data, + fees_usd_24h: null, + }, + warnings: ["Synthetic fixture: fees windows forced null for null-handling coverage."], +} satisfies PoolSourceResult; + +// Synthetic timeout over the 0.05% tier's real provenance (status flipped). +export const graphPoolCTimeout = { + source_id: graphPoolB.source_id, + source_type: graphPoolB.source_type, + protocol: graphPoolB.protocol, + chain_id: graphPoolB.chain_id, + status: "timeout", + data: null, + freshness: null, + provenance: graphPoolB.provenance, + warnings: [`Synthetic timeout over real ${graphPoolB.source_id} provenance.`], + latency_ms: 15000, +} satisfies PoolSourceResult; + +// Shape-only until live Nuthatch evidence lands. Pool matches the 0.3% selection. +export const nuthatchFreshness = { + source_id: "nuthatch-pool-swaps", + source_type: "nuthatch_view", + protocol: "uniswap-v3", + chain_id: 8453, + status: "ok", + data: { + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + recent_swap_count_24h: 321, + last_swap_block: 49121440, + last_swap_block_timestamp: 1785032227, + last_swap_block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + last_swap_tx_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + last_swap_log_index: 7, + }, + freshness: { + indexed_block: 49121440, + indexed_block_timestamp: 1785032227, + indexed_block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + queried_at: 1785032250, + }, + provenance: { + deployment_or_view_id: "fixture-nuthatch-registry-hash", + schema_version: null, + methodology_version: null, + query_id: "nuthatch-pool-swap-freshness-v1", + }, + warnings: ["Shape-only Nuthatch fixture until live evidence."], + latency_ms: 42, +} satisfies NuthatchSourceResult; + +// Two Messari Graph fee tiers + Nuthatch. +export const completeSourceScenario = [ + graphPoolA, + graphPoolB, + nuthatchFreshness, +] satisfies readonly MvpSourceFixture[]; + +export const partialSourceScenario = [ + graphPoolA, + graphPoolCTimeout, + nuthatchFreshness, +] satisfies readonly MvpSourceFixture[]; diff --git a/tests/integration/__evidence__/lss/nuthatch-large-swaps-receipt.json b/tests/integration/__evidence__/lss/nuthatch-large-swaps-receipt.json new file mode 100644 index 0000000..d32b476 --- /dev/null +++ b/tests/integration/__evidence__/lss/nuthatch-large-swaps-receipt.json @@ -0,0 +1,64 @@ +{ + "evidence_kind": "derived_view_receipt_from_retained_live_pool__swap_row", + "source_evidence": "../m4/raw-row-samples.json#latest", + "ready": { + "ready": true, + "sealed_through": 48944374, + "tip": 48944374 + }, + "nest": { + "name": "deeptrace-pool-freshness", + "registry_hash": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "table_count": 9 + }, + "schema": "Nuthatch pool_swap_search receipt schema", + "receipt": { + "count": 1, + "provenance": { + "as_of": 48944374, + "registry_hash": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "sealed_through": 48944374, + "source": "hot+sealed" + }, + "rows": [ + { + "pool_address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "block_number": 48944373, + "block_hash": "0xea960db6cfd80eced3a7f609e52e8e5e16086e48b06b05249e6b9c16270ed839", + "block_timestamp": 1784678093, + "transaction_hash": "0x75d694ea1806d0182c039f676887e0883cc3bd62a6d19b56a4e01fb25ccc4851", + "log_index": 168, + "amount0_raw": "-6123995143586742520", + "amount1_raw": "11804868893" + } + ], + "truncated": false + }, + "expected_swap": { + "chain_id": 8453, + "protocol": "uniswap-v3", + "pool": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "transaction_hash": "0x75d694ea1806d0182c039f676887e0883cc3bd62a6d19b56a4e01fb25ccc4851", + "log_index": 168, + "block_number": 48944373, + "timestamp": 1784678093, + "asset_in": { + "chain_id": 8453, + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": 6 + }, + "asset_out": { + "chain_id": 8453, + "address": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": 18 + }, + "amount_in": "11804.868893", + "amount_out": "6.12399514358674252", + "amount_in_raw": "11804868893", + "amount_out_raw": "-6123995143586742520", + "usd_notional": null, + "source_id": "nuthatch-large-swaps" + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/01-meta.json b/tests/integration/__evidence__/m2/aerodrome-base-full/01-meta.json new file mode 100644 index 0000000..8e69101 --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-base-full/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:31.014Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "query_id": "m2-meta-v1", + "request": { + "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", + "variables": {} + }, + "response": { + "data": { + "_meta": { + "block": { + "number": 49095501, + "timestamp": 1784980349, + "hash": "0xc2c108bd30e1b612b1969daf6768e18d81a5c5db8e952b4b7b951d052ce404e6" + }, + "hasIndexingErrors": false, + "deployment": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 170, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/05-pools.json b/tests/integration/__evidence__/m2/aerodrome-base-full/05-pools.json new file mode 100644 index 0000000..6a8e7f8 --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-base-full/05-pools.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T12:01:53.745Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "query_id": "m2-tier-b-pools-v2", + "request": { + "query": "query M2TierBPools($token0: Bytes!, $token1: Bytes!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n pools(\n first: 100\n where: { token0: $token0, token1: $token1 }\n orderBy: totalValueLockedUSD\n orderDirection: desc\n ) {\n id feeTier totalValueLockedUSD\n token0 { id symbol decimals }\n token1 { id symbol decimals }\n }\n}", + "variables": { + "token0": "0x4200000000000000000000000000000000000006", + "token1": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + }, + "response": null, + "transport": { + "http_status": null, + "latency_ms": 15013, + "error": { + "kind": "timeout", + "message": "Graph gateway request exceeded the 15 second timeout." + } + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/01-meta.json b/tests/integration/__evidence__/m2/exchange-v3-base/01-meta.json new file mode 100644 index 0000000..6f981b1 --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:40.220Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "query_id": "m2-meta-v1", + "request": { + "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", + "variables": {} + }, + "response": { + "data": { + "_meta": { + "block": { + "number": 49095505, + "timestamp": 1784980357, + "hash": "0x1cbee29e4eb7febf872604a744bb304152e364c0ad47b0cd1d159c7e3c97f821" + }, + "hasIndexingErrors": false, + "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 94, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/05-pools.json b/tests/integration/__evidence__/m2/exchange-v3-base/05-pools.json new file mode 100644 index 0000000..56e8f8d --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/05-pools.json @@ -0,0 +1,93 @@ +{ + "captured_at": "2026-07-25T12:01:54.119Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "query_id": "m2-tier-b-pools-v2", + "request": { + "query": "query M2TierBPools($token0: Bytes!, $token1: Bytes!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n pools(\n first: 100\n where: { token0: $token0, token1: $token1 }\n orderBy: totalValueLockedUSD\n orderDirection: desc\n ) {\n id feeTier totalValueLockedUSD\n token0 { id symbol decimals }\n token1 { id symbol decimals }\n }\n}", + "variables": { + "token0": "0x4200000000000000000000000000000000000006", + "token1": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + }, + "response": { + "data": { + "pools": [ + { + "id": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + "feeTier": "100", + "totalValueLockedUSD": "6565424.026253424582404270532672039", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0xb775272e537cc670c65dc852908ad47015244eaf", + "feeTier": "500", + "totalValueLockedUSD": "3272283.989127259480615002871608173", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0xe9d76696f8a35e2e2520e3125875c3af23f1e69c", + "feeTier": "2500", + "totalValueLockedUSD": "71570.00382791781944984300062297703", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0x118efef7db0712f03e067122b8f89e7a4b79262f", + "feeTier": "10000", + "totalValueLockedUSD": "1024.782065381534734392729111513688", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + } + ], + "_meta": { + "block": { + "number": 49095782, + "timestamp": 1784980911, + "hash": "0x2cf0cbf06a1fe031cbc04e1ee7b957b69f87d58763ed599a8b7ebe43d9eaa57a" + }, + "hasIndexingErrors": false, + "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 123, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/06-snapshots.json b/tests/integration/__evidence__/m2/exchange-v3-base/06-snapshots.json new file mode 100644 index 0000000..c53b3b9 --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/06-snapshots.json @@ -0,0 +1,74 @@ +{ + "captured_at": "2026-07-25T12:01:55.658Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "query_id": "m2-tier-b-snapshots-v2", + "request": { + "query": "query M2TierBSnapshots($pool: Bytes!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n poolDayDatas(\n first: 7\n orderBy: date\n orderDirection: desc\n where: { pool: $pool }\n ) { date volumeUSD feesUSD tvlUSD }\n}", + "variables": { + "pool": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38" + } + }, + "response": { + "data": { + "poolDayDatas": [ + { + "date": 1784937600, + "volumeUSD": "6089724.592920848435197967898475142", + "feesUSD": "608.9724592920848435197967898475142", + "tvlUSD": "6565424.026253424582404270532672039" + }, + { + "date": 1784851200, + "volumeUSD": "32025202.23709166157119885275500449", + "feesUSD": "3202.520223709166157119885275500449", + "tvlUSD": "6580900.836816457702075253298554545" + }, + { + "date": 1784764800, + "volumeUSD": "33349972.49500111939439441577167969", + "feesUSD": "3334.997249500111939439441577167969", + "tvlUSD": "6671941.465922013493599972965816957" + }, + { + "date": 1784678400, + "volumeUSD": "33695490.88621037640955046633041583", + "feesUSD": "3369.549088621037640955046633041583", + "tvlUSD": "6657524.389957693533551882417539656" + }, + { + "date": 1784592000, + "volumeUSD": "26495824.283802713986583290749566", + "feesUSD": "2649.5824283802713986583290749566", + "tvlUSD": "6611594.402198065600289084560696241" + }, + { + "date": 1784505600, + "volumeUSD": "40696520.73315173317828762001196342", + "feesUSD": "4069.652073315173317828762001196342", + "tvlUSD": "6594137.684886806037054155458054794" + }, + { + "date": 1784419200, + "volumeUSD": "21906304.6849752113236273751900324", + "feesUSD": "2190.63046849752113236273751900324", + "tvlUSD": "6422564.505918185803136381456531784" + } + ], + "_meta": { + "block": { + "number": 49095782, + "timestamp": 1784980911, + "hash": "0x2cf0cbf06a1fe031cbc04e1ee7b957b69f87d58763ed599a8b7ebe43d9eaa57a" + }, + "hasIndexingErrors": false, + "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 1288, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/07-common-metrics.json b/tests/integration/__evidence__/m2/exchange-v3-base/07-common-metrics.json new file mode 100644 index 0000000..763c3d7 --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/07-common-metrics.json @@ -0,0 +1,89 @@ +{ + "captured_at": "2026-07-25T12:01:56.209Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "query_id": "m2-tier-b-metrics-v1", + "request": { + "query": "query M2TierBMetrics($pool: ID!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n pool(id: $pool) {\n id feeTier totalValueLockedUSD\n token0 { id symbol decimals }\n token1 { id symbol decimals }\n }\n poolDayDatas(\n first: 7\n orderBy: date\n orderDirection: desc\n where: { pool: $pool }\n ) { date volumeUSD feesUSD tvlUSD }\n}", + "variables": { + "pool": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38" + } + }, + "response": { + "data": { + "pool": { + "id": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + "feeTier": "100", + "totalValueLockedUSD": "6565424.026253424582404270532672039", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + "poolDayDatas": [ + { + "date": 1784937600, + "volumeUSD": "6089724.592920848435197967898475142", + "feesUSD": "608.9724592920848435197967898475142", + "tvlUSD": "6565424.026253424582404270532672039" + }, + { + "date": 1784851200, + "volumeUSD": "32025202.23709166157119885275500449", + "feesUSD": "3202.520223709166157119885275500449", + "tvlUSD": "6580900.836816457702075253298554545" + }, + { + "date": 1784764800, + "volumeUSD": "33349972.49500111939439441577167969", + "feesUSD": "3334.997249500111939439441577167969", + "tvlUSD": "6671941.465922013493599972965816957" + }, + { + "date": 1784678400, + "volumeUSD": "33695490.88621037640955046633041583", + "feesUSD": "3369.549088621037640955046633041583", + "tvlUSD": "6657524.389957693533551882417539656" + }, + { + "date": 1784592000, + "volumeUSD": "26495824.283802713986583290749566", + "feesUSD": "2649.5824283802713986583290749566", + "tvlUSD": "6611594.402198065600289084560696241" + }, + { + "date": 1784505600, + "volumeUSD": "40696520.73315173317828762001196342", + "feesUSD": "4069.652073315173317828762001196342", + "tvlUSD": "6594137.684886806037054155458054794" + }, + { + "date": 1784419200, + "volumeUSD": "21906304.6849752113236273751900324", + "feesUSD": "2190.63046849752113236273751900324", + "tvlUSD": "6422564.505918185803136381456531784" + } + ], + "_meta": { + "block": { + "number": 49095784, + "timestamp": 1784980915, + "hash": "0x3d792f0e60742149c644825adb18c76fef43f01e25bc12dfef751de1e9d1bb6d" + }, + "hasIndexingErrors": false, + "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 301, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/manifest.json b/tests/integration/__evidence__/m2/manifest.json new file mode 100644 index 0000000..27f535b --- /dev/null +++ b/tests/integration/__evidence__/m2/manifest.json @@ -0,0 +1,290 @@ +{ + "sweep_started_at": "2026-07-25T11:52:22.841Z", + "sweep_finished_at": "2026-07-25T11:52:41.605Z", + "reference_block": 49095505, + "candidates": [ + { + "slug": "uniswap-v3-base-native", + "protocol_name": "Uniswap V3", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "explorer_url": "https://thegraph.com/explorer/subgraphs/GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "publisher": "Uniswap", + "deployment_id": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", + "indexed_block": 49095495, + "indexed_block_timestamp": 1784980337, + "has_indexing_errors": false, + "tier": "B", + "verdict": "healthy", + "verdict_reason": "lag=10 blocks, age=24s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + }, + { + "slug": "uniswap-v3-base-native-recent", + "protocol_name": "Uniswap V3", + "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "explorer_url": "https://thegraph.com/explorer/subgraphs/VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "publisher": "Graph Explorer publisher 0xd3bb…9d0632", + "deployment_id": "Qmc5N5DW7a99WEpm9aGXSSHotNCSSsTbULSB8YgPreJyE3", + "indexed_block": 49095498, + "indexed_block_timestamp": 1784980343, + "has_indexing_errors": false, + "tier": "B", + "verdict": "healthy", + "verdict_reason": "lag=7 blocks, age=18s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": false + } + }, + { + "slug": "uniswap-v3-base-messari", + "protocol_name": "Uniswap V3", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "explorer_url": "https://thegraph.com/explorer/subgraphs/FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "publisher": "Messari standardized-subgraph publisher", + "deployment_id": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + "indexed_block": 49095498, + "indexed_block_timestamp": 1784980343, + "has_indexing_errors": false, + "tier": "A", + "verdict": "healthy", + "verdict_reason": "lag=7 blocks, age=18s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + }, + { + "slug": "sushiswap-v3-base", + "protocol_name": "SushiSwap V3", + "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "explorer_url": "https://thegraph.com/explorer/subgraphs/H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "publisher": "SushiSwap", + "deployment_id": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK", + "indexed_block": 49095499, + "indexed_block_timestamp": 1784980345, + "has_indexing_errors": false, + "tier": "unknown", + "classification_reason": "factories marker answered, but Pool and PoolDayData lineage types are absent", + "verdict": "healthy", + "verdict_reason": "lag=6 blocks, age=16s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + }, + { + "slug": "aerodrome-slipstream-base", + "protocol_name": "Aerodrome Slipstream", + "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "explorer_url": "https://thegraph.com/explorer/subgraphs/EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "publisher": "Graph Explorer publisher 0x29ff…100662", + "deployment_id": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy", + "indexed_block": 49095499, + "indexed_block_timestamp": 1784980345, + "has_indexing_errors": false, + "tier": "unknown", + "classification_reason": "factories marker answered, but the Pool shape lacks required fee/TVL fields and PoolDayData is absent", + "verdict": "healthy", + "verdict_reason": "lag=6 blocks, age=16s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + }, + { + "slug": "aerodrome-base-full", + "protocol_name": "Aerodrome", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "explorer_url": "https://thegraph.com/explorer/subgraphs/GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "publisher": "Graph Explorer publisher 0xa4c6…05cc95", + "deployment_id": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV", + "indexed_block": 49095501, + "indexed_block_timestamp": 1784980349, + "has_indexing_errors": false, + "tier": "B", + "verdict": "healthy", + "verdict_reason": "lag=4 blocks, age=12s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + }, + { + "slug": "balancer-v2-base", + "protocol_name": "Balancer V2", + "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "explorer_url": "https://thegraph.com/explorer/subgraphs/E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "publisher": "Balancer", + "deployment_id": "QmRKBwBwPKtFz4mQp5jvH44USVprM4C77Nr4m77UGCbGv9", + "indexed_block": 49095501, + "indexed_block_timestamp": 1784980349, + "has_indexing_errors": false, + "tier": "unknown", + "verdict": "healthy", + "verdict_reason": "lag=4 blocks, age=12s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + }, + { + "slug": "base-slipstream-community", + "protocol_name": "Aerodrome Slipstream", + "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", + "explorer_url": "https://thegraph.com/explorer/subgraphs/5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", + "publisher": "Graph Explorer publisher 0x1080…4f5b07", + "deployment_id": null, + "indexed_block": null, + "indexed_block_timestamp": null, + "has_indexing_errors": null, + "tier": "unknown", + "verdict": "reject", + "verdict_reason": "missing usable _meta" + }, + { + "slug": "pancakeswap-v3-base", + "protocol_name": "PancakeSwap V3", + "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "explorer_url": "https://thegraph.com/explorer/subgraphs/84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "publisher": "Graph Explorer publisher 0x3acc…499c23", + "deployment_id": "QmY5Wybn5P1BQ5gnV1QuNiszZNk2fimZatKM1XtvV49fBN", + "indexed_block": 49095502, + "indexed_block_timestamp": 1784980351, + "has_indexing_errors": false, + "tier": "unknown", + "verdict": "healthy", + "verdict_reason": "lag=3 blocks, age=10s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": false + } + }, + { + "slug": "baseswap-v2-base", + "protocol_name": "BaseSwap V2", + "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "explorer_url": "https://thegraph.com/explorer/subgraphs/SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "publisher": "Graph Explorer publisher 0x4f1a…76928d", + "deployment_id": "QmVL9dQdAGfqRbfbjpTXUGZNhFh2rpcPJ8XT5bYXVPvXyr", + "indexed_block": 49095503, + "indexed_block_timestamp": 1784980353, + "has_indexing_errors": false, + "tier": "unknown", + "verdict": "healthy", + "verdict_reason": "lag=2 blocks, age=8s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + }, + { + "slug": "sushiswap-v3-base-community-a", + "protocol_name": "SushiSwap V3", + "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", + "explorer_url": "https://thegraph.com/explorer/subgraphs/8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", + "publisher": "Graph Explorer publisher 0x3eb4…6a09e3", + "deployment_id": null, + "indexed_block": null, + "indexed_block_timestamp": null, + "has_indexing_errors": null, + "tier": "unknown", + "verdict": "reject", + "verdict_reason": "missing usable _meta" + }, + { + "slug": "sushiswap-v3-base-community-b", + "protocol_name": "SushiSwap V3", + "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", + "explorer_url": "https://thegraph.com/explorer/subgraphs/9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", + "publisher": "Graph Explorer publisher 0x3acc…499c23", + "deployment_id": null, + "indexed_block": null, + "indexed_block_timestamp": null, + "has_indexing_errors": null, + "tier": "unknown", + "verdict": "reject", + "verdict_reason": "missing usable _meta" + }, + { + "slug": "pancakeswap-exchange-v3-base", + "protocol_name": "PancakeSwap V3", + "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", + "explorer_url": "https://thegraph.com/explorer/subgraphs/8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", + "publisher": "Graph Explorer publisher 0xb165…84629a", + "deployment_id": null, + "indexed_block": null, + "indexed_block_timestamp": null, + "has_indexing_errors": null, + "tier": "unknown", + "verdict": "reject", + "verdict_reason": "missing usable _meta" + }, + { + "slug": "exchange-v3-base", + "protocol_name": "PancakeSwap V3 candidate", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "explorer_url": "https://thegraph.com/explorer/subgraphs/BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "publisher": "Graph Explorer publisher 0xd099…e348d4", + "deployment_id": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + "indexed_block": 49095505, + "indexed_block_timestamp": 1784980357, + "has_indexing_errors": false, + "tier": "B", + "verdict": "healthy", + "verdict_reason": "lag=0 blocks, age=4s", + "token_probe": { + "query_id": "m2-tokens-v2", + "answered": true + } + } + ], + "token_pair": { + "base_token": "0x4200000000000000000000000000000000000006", + "quote_token": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "quote_symbol": "USDC", + "decision": "native USDC is present with matching identity on the provisional Tier-B candidates" + }, + "milestone_status": "blocked", + "blocker": "Only two deployments passed the native-USDC pool and common-query checks; Aerodrome pool discovery timed out after 15 seconds.", + "final_selections": [], + "validated_selections": [ + { + "slug": "uniswap-v3-base-native", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "tier": "B", + "pool_address": "0x6c561b446416e1a00e8e93e221854d6ea4171372" + }, + { + "slug": "exchange-v3-base", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "tier": "B", + "pool_address": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38" + } + ], + "token_probe_finished_at": "2026-07-25T11:53:58.786Z", + "pool_probe_finished_at": "2026-07-25T12:01:56.460Z", + "pool_candidates": [ + { + "slug": "uniswap-v3-base-native", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "tier": "B", + "pool_address": "0x6c561b446416e1a00e8e93e221854d6ea4171372" + }, + { + "slug": "aerodrome-base-full", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "tier": "B", + "pool_probe_error": "Pool discovery timed out after the 15 second deadline; no retry was attempted." + }, + { + "slug": "exchange-v3-base", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "tier": "B", + "pool_address": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38" + } + ] +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/01-meta.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/01-meta.json new file mode 100644 index 0000000..2ed341b --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:23.096Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-meta-v1", + "request": { + "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", + "variables": {} + }, + "response": { + "data": { + "_meta": { + "block": { + "number": 49095495, + "timestamp": 1784980337, + "hash": "0x308f1913ac848f1921233fda217a733fda7b6f726e23ab3da249ce5012418014" + }, + "hasIndexingErrors": false, + "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 252, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/02-tier-a-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/02-tier-a-marker.json new file mode 100644 index 0000000..1f53fe7 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:23.539Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-tier-a-marker-v2", + "request": { + "query": "query M2TierAMarker {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n dexAmmProtocols(first: 1) {\n id name schemaVersion methodologyVersion network\n }\n}", + "variables": {} + }, + "response": { + "errors": [ + { + "locations": [ + { + "line": 7, + "column": 3 + } + ], + "message": "Type `Query` has no field `dexAmmProtocols`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 192, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/03-tier-b-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/03-tier-b-marker.json new file mode 100644 index 0000000..7748c0e --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/03-tier-b-marker.json @@ -0,0 +1,33 @@ +{ + "captured_at": "2026-07-25T11:52:24.006Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-tier-b-marker-v3", + "request": { + "query": "query M2TierBMarker {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n factories(first: 1) { id }\n}", + "variables": {} + }, + "response": { + "data": { + "factories": [ + { + "id": "0x33128a8fC17869897dcE68Ed026d694621f6FDfD" + } + ], + "_meta": { + "block": { + "number": 49095498, + "timestamp": 1784980343, + "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" + }, + "hasIndexingErrors": false, + "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 217, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/03a-tier-b-lineage.json new file mode 100644 index 0000000..6865482 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/03a-tier-b-lineage.json @@ -0,0 +1,247 @@ +{ + "captured_at": "2026-07-25T11:52:24.463Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-tier-b-lineage-v1", + "request": { + "query": "query M2TierBLineage {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n factoryType: __type(name: \"Factory\") { fields { name } }\n poolType: __type(name: \"Pool\") { fields { name } }\n dayType: __type(name: \"PoolDayData\") { fields { name } }\n}", + "variables": {} + }, + "response": { + "data": { + "_meta": { + "block": { + "number": 49095498, + "timestamp": 1784980343, + "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" + }, + "hasIndexingErrors": false, + "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" + }, + "factoryType": { + "fields": [ + { + "name": "id" + }, + { + "name": "poolCount" + }, + { + "name": "txCount" + }, + { + "name": "totalVolumeUSD" + }, + { + "name": "totalVolumeETH" + }, + { + "name": "totalFeesUSD" + }, + { + "name": "totalFeesETH" + }, + { + "name": "untrackedVolumeUSD" + }, + { + "name": "totalValueLockedUSD" + }, + { + "name": "totalValueLockedETH" + }, + { + "name": "totalValueLockedUSDUntracked" + }, + { + "name": "totalValueLockedETHUntracked" + }, + { + "name": "owner" + } + ] + }, + "poolType": { + "fields": [ + { + "name": "id" + }, + { + "name": "createdAtTimestamp" + }, + { + "name": "createdAtBlockNumber" + }, + { + "name": "token0" + }, + { + "name": "token1" + }, + { + "name": "feeTier" + }, + { + "name": "liquidity" + }, + { + "name": "sqrtPrice" + }, + { + "name": "feeGrowthGlobal0X128" + }, + { + "name": "feeGrowthGlobal1X128" + }, + { + "name": "token0Price" + }, + { + "name": "token1Price" + }, + { + "name": "tick" + }, + { + "name": "observationIndex" + }, + { + "name": "volumeToken0" + }, + { + "name": "volumeToken1" + }, + { + "name": "volumeUSD" + }, + { + "name": "untrackedVolumeUSD" + }, + { + "name": "feesUSD" + }, + { + "name": "txCount" + }, + { + "name": "collectedFeesToken0" + }, + { + "name": "collectedFeesToken1" + }, + { + "name": "collectedFeesUSD" + }, + { + "name": "totalValueLockedToken0" + }, + { + "name": "totalValueLockedToken1" + }, + { + "name": "totalValueLockedETH" + }, + { + "name": "totalValueLockedUSD" + }, + { + "name": "totalValueLockedUSDUntracked" + }, + { + "name": "liquidityProviderCount" + }, + { + "name": "poolHourData" + }, + { + "name": "poolDayData" + }, + { + "name": "mints" + }, + { + "name": "burns" + }, + { + "name": "swaps" + }, + { + "name": "collects" + }, + { + "name": "ticks" + } + ] + }, + "dayType": { + "fields": [ + { + "name": "id" + }, + { + "name": "date" + }, + { + "name": "pool" + }, + { + "name": "liquidity" + }, + { + "name": "sqrtPrice" + }, + { + "name": "token0Price" + }, + { + "name": "token1Price" + }, + { + "name": "tick" + }, + { + "name": "feeGrowthGlobal0X128" + }, + { + "name": "feeGrowthGlobal1X128" + }, + { + "name": "tvlUSD" + }, + { + "name": "volumeToken0" + }, + { + "name": "volumeToken1" + }, + { + "name": "volumeUSD" + }, + { + "name": "feesUSD" + }, + { + "name": "txCount" + }, + { + "name": "open" + }, + { + "name": "high" + }, + { + "name": "low" + }, + { + "name": "close" + } + ] + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 205, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/05-pools.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/05-pools.json new file mode 100644 index 0000000..252be68 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/05-pools.json @@ -0,0 +1,138 @@ +{ + "captured_at": "2026-07-25T12:01:37.439Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-tier-b-pools-v2", + "request": { + "query": "query M2TierBPools($token0: Bytes!, $token1: Bytes!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n pools(\n first: 100\n where: { token0: $token0, token1: $token1 }\n orderBy: totalValueLockedUSD\n orderDirection: desc\n ) {\n id feeTier totalValueLockedUSD\n token0 { id symbol decimals }\n token1 { id symbol decimals }\n }\n}", + "variables": { + "token0": "0x4200000000000000000000000000000000000006", + "token1": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" + } + }, + "response": { + "data": { + "pools": [ + { + "id": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "feeTier": "3000", + "totalValueLockedUSD": "150700095.7707237035076119974091172", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0xd0b53d9277642d899df5c87a3966a349a798f224", + "feeTier": "500", + "totalValueLockedUSD": "30544521.51069483480231607490973948", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0x1c450d7d1fd98a0b04e30decfc83497b33a4f608", + "feeTier": "200", + "totalValueLockedUSD": "8241326.519878764539927109364498741", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0x0b1c2dcbbfa744ebd3fc17ff1a96a1e1eb4b2d69", + "feeTier": "10000", + "totalValueLockedUSD": "1141477.311208862813522218169295476", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0xb4cb800910b228ed3d0834cf79d697127bbb00e5", + "feeTier": "100", + "totalValueLockedUSD": "628090.663783189639560403599809527", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0x18b497f71122622557e4634c47a05647ff859f47", + "feeTier": "300", + "totalValueLockedUSD": "14428.78222114692539389659886698598", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + { + "id": "0x56c8989222ed293e3c4a22628d8bca633ce1eb99", + "feeTier": "400", + "totalValueLockedUSD": "7479.102270753444418497890329365247", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + } + ], + "_meta": { + "block": { + "number": 49095773, + "timestamp": 1784980893, + "hash": "0xfaf4cc0493056e5ccac3f68b9e148cf8e80ee0d67adf333ed27b4a62d17c185c" + }, + "hasIndexingErrors": false, + "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 1509, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/06-snapshots.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/06-snapshots.json new file mode 100644 index 0000000..e9123b7 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/06-snapshots.json @@ -0,0 +1,74 @@ +{ + "captured_at": "2026-07-25T12:01:37.978Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-tier-b-snapshots-v2", + "request": { + "query": "query M2TierBSnapshots($pool: Bytes!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n poolDayDatas(\n first: 7\n orderBy: date\n orderDirection: desc\n where: { pool: $pool }\n ) { date volumeUSD feesUSD tvlUSD }\n}", + "variables": { + "pool": "0x6c561b446416e1a00e8e93e221854d6ea4171372" + } + }, + "response": { + "data": { + "poolDayDatas": [ + { + "date": 1784937600, + "volumeUSD": "1837918.971826772337839586279587621", + "feesUSD": "5513.756915480317013518758838762854", + "tvlUSD": "150700095.1170342141923809669692862" + }, + { + "date": 1784851200, + "volumeUSD": "51474578.61622885677419282699983982", + "feesUSD": "154423.7358486865703225784809995228", + "tvlUSD": "150782950.7682194586397672337848594" + }, + { + "date": 1784764800, + "volumeUSD": "40635517.7678439140561290160817718", + "feesUSD": "121906.5533035317421683870482453143", + "tvlUSD": "150801887.1928319845428659768676178" + }, + { + "date": 1784678400, + "volumeUSD": "64491334.84868823906340059812757887", + "feesUSD": "193474.0045460647171902017943827393", + "tvlUSD": "153073919.2866268475427418873421961" + }, + { + "date": 1784592000, + "volumeUSD": "34752470.9371506173065599289573975", + "feesUSD": "104257.4128114518519196797868721919", + "tvlUSD": "152250244.9065976728664112151915531" + }, + { + "date": 1784505600, + "volumeUSD": "95158308.81850063901666160232434262", + "feesUSD": "285474.9264555019170499848069730268", + "tvlUSD": "150491860.7382473919196925172123004" + }, + { + "date": 1784419200, + "volumeUSD": "24701335.71650390345286524940436904", + "feesUSD": "74104.00714951171035859574821310743", + "tvlUSD": "148622321.2013336355304325682157653" + } + ], + "_meta": { + "block": { + "number": 49095773, + "timestamp": 1784980893, + "hash": "0xfaf4cc0493056e5ccac3f68b9e148cf8e80ee0d67adf333ed27b4a62d17c185c" + }, + "hasIndexingErrors": false, + "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 287, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/07-common-metrics.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/07-common-metrics.json new file mode 100644 index 0000000..1929797 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/07-common-metrics.json @@ -0,0 +1,89 @@ +{ + "captured_at": "2026-07-25T12:01:38.481Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-tier-b-metrics-v1", + "request": { + "query": "query M2TierBMetrics($pool: ID!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n pool(id: $pool) {\n id feeTier totalValueLockedUSD\n token0 { id symbol decimals }\n token1 { id symbol decimals }\n }\n poolDayDatas(\n first: 7\n orderBy: date\n orderDirection: desc\n where: { pool: $pool }\n ) { date volumeUSD feesUSD tvlUSD }\n}", + "variables": { + "pool": "0x6c561b446416e1a00e8e93e221854d6ea4171372" + } + }, + "response": { + "data": { + "pool": { + "id": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "feeTier": "3000", + "totalValueLockedUSD": "150700095.7707237035076119974091172", + "token0": { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": "18" + }, + "token1": { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": "6" + } + }, + "poolDayDatas": [ + { + "date": 1784937600, + "volumeUSD": "1837918.971826772337839586279587621", + "feesUSD": "5513.756915480317013518758838762854", + "tvlUSD": "150700095.1170342141923809669692862" + }, + { + "date": 1784851200, + "volumeUSD": "51474578.61622885677419282699983982", + "feesUSD": "154423.7358486865703225784809995228", + "tvlUSD": "150782950.7682194586397672337848594" + }, + { + "date": 1784764800, + "volumeUSD": "40635517.7678439140561290160817718", + "feesUSD": "121906.5533035317421683870482453143", + "tvlUSD": "150801887.1928319845428659768676178" + }, + { + "date": 1784678400, + "volumeUSD": "64491334.84868823906340059812757887", + "feesUSD": "193474.0045460647171902017943827393", + "tvlUSD": "153073919.2866268475427418873421961" + }, + { + "date": 1784592000, + "volumeUSD": "34752470.9371506173065599289573975", + "feesUSD": "104257.4128114518519196797868721919", + "tvlUSD": "152250244.9065976728664112151915531" + }, + { + "date": 1784505600, + "volumeUSD": "95158308.81850063901666160232434262", + "feesUSD": "285474.9264555019170499848069730268", + "tvlUSD": "150491860.7382473919196925172123004" + }, + { + "date": 1784419200, + "volumeUSD": "24701335.71650390345286524940436904", + "feesUSD": "74104.00714951171035859574821310743", + "tvlUSD": "148622321.2013336355304325682157653" + } + ], + "_meta": { + "block": { + "number": 49095773, + "timestamp": 1784980893, + "hash": "0xfaf4cc0493056e5ccac3f68b9e148cf8e80ee0d67adf333ed27b4a62d17c185c" + }, + "hasIndexingErrors": false, + "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 252, + "error": null + } +} diff --git a/tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee005/01-pool-metrics.json b/tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee005/01-pool-metrics.json new file mode 100644 index 0000000..a2e3669 --- /dev/null +++ b/tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee005/01-pool-metrics.json @@ -0,0 +1,102 @@ +{ + "captured_at": "2026-07-26T02:17:26.667Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "query_id": "tier-a-dex-pool-metrics-v1", + "request": { + "query": "query TierADexPoolMetrics($pool: ID!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n liquidityPool(id: $pool) {\n id totalValueLockedUSD\n inputTokens { id symbol decimals }\n fees { feePercentage feeType }\n }\n liquidityPoolDailySnapshots(\n first: 8\n orderBy: day\n orderDirection: desc\n where: { pool: $pool }\n ) { day dailyVolumeUSD dailyTotalRevenueUSD }\n}", + "variables": { + "pool": "0xd0b53d9277642d899df5c87a3966a349a798f224" + } + }, + "response": { + "data": { + "liquidityPool": { + "id": "0xd0b53d9277642d899df5c87a3966a349a798f224", + "totalValueLockedUSD": "10637748.34455860477744937575922415", + "inputTokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": 18 + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": 6 + } + ], + "fees": [ + { + "feePercentage": "0", + "feeType": "FIXED_PROTOCOL_FEE" + }, + { + "feePercentage": "0.05", + "feeType": "FIXED_LP_FEE" + }, + { + "feePercentage": "0.05", + "feeType": "FIXED_TRADING_FEE" + } + ] + }, + "liquidityPoolDailySnapshots": [ + { + "day": 20659, + "dailyVolumeUSD": "2957180.60438499663534225066392", + "dailyTotalRevenueUSD": "1478.59030219249831767112534419" + }, + { + "day": 20658, + "dailyVolumeUSD": "10715791.05280723646174751297538", + "dailyTotalRevenueUSD": "5357.89552640361823087375650243" + }, + { + "day": 20657, + "dailyVolumeUSD": "10636109.48874302430690318577914", + "dailyTotalRevenueUSD": "5318.05474437151215345159290744" + }, + { + "day": 20656, + "dailyVolumeUSD": "13237756.60815473174881687841199", + "dailyTotalRevenueUSD": "6618.87830407736587440843922806" + }, + { + "day": 20655, + "dailyVolumeUSD": "10437361.40930285020611848278024", + "dailyTotalRevenueUSD": "5218.68070465142510305924140603" + }, + { + "day": 20654, + "dailyVolumeUSD": "19672240.09512502653164336304688", + "dailyTotalRevenueUSD": "9836.12004756251326582168154411" + }, + { + "day": 20653, + "dailyVolumeUSD": "8565447.25037171768225887015337", + "dailyTotalRevenueUSD": "4282.72362518585884112943509008" + }, + { + "day": 20652, + "dailyVolumeUSD": "5147580.7805854123313651961524", + "dailyTotalRevenueUSD": "2573.79039029270616568259809159" + } + ], + "_meta": { + "block": { + "number": 49121448, + "timestamp": 1785032243, + "hash": "0xe60c2ae734e634f8260a56b337c9a8c233b36c9a361fb53fcf727e680a2d1855" + }, + "hasIndexingErrors": false, + "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 1937, + "error": null + } +} diff --git a/tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee030/01-pool-metrics.json b/tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee030/01-pool-metrics.json new file mode 100644 index 0000000..2f32173 --- /dev/null +++ b/tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee030/01-pool-metrics.json @@ -0,0 +1,102 @@ +{ + "captured_at": "2026-07-26T02:17:24.708Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "query_id": "tier-a-dex-pool-metrics-v1", + "request": { + "query": "query TierADexPoolMetrics($pool: ID!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n liquidityPool(id: $pool) {\n id totalValueLockedUSD\n inputTokens { id symbol decimals }\n fees { feePercentage feeType }\n }\n liquidityPoolDailySnapshots(\n first: 8\n orderBy: day\n orderDirection: desc\n where: { pool: $pool }\n ) { day dailyVolumeUSD dailyTotalRevenueUSD }\n}", + "variables": { + "pool": "0x6c561b446416e1a00e8e93e221854d6ea4171372" + } + }, + "response": { + "data": { + "liquidityPool": { + "id": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "totalValueLockedUSD": "114861166.2464289945430831254042441", + "inputTokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": 18 + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "decimals": 6 + } + ], + "fees": [ + { + "feePercentage": "0", + "feeType": "FIXED_PROTOCOL_FEE" + }, + { + "feePercentage": "0.3", + "feeType": "FIXED_LP_FEE" + }, + { + "feePercentage": "0.3", + "feeType": "FIXED_TRADING_FEE" + } + ] + }, + "liquidityPoolDailySnapshots": [ + { + "day": 20659, + "dailyVolumeUSD": "7784090.37607948122807978670587", + "dailyTotalRevenueUSD": "23352.27112823844368423936011761" + }, + { + "day": 20658, + "dailyVolumeUSD": "51476600.4258305355592018621672", + "dailyTotalRevenueUSD": "154429.8012774916066776055865016" + }, + { + "day": 20657, + "dailyVolumeUSD": "40641116.31814111933780456159274", + "dailyTotalRevenueUSD": "121923.34895442335801341368477822" + }, + { + "day": 20656, + "dailyVolumeUSD": "64487332.00676428380098861343859", + "dailyTotalRevenueUSD": "193461.99602029285140296584031577" + }, + { + "day": 20655, + "dailyVolumeUSD": "34752939.09343790516172944306061", + "dailyTotalRevenueUSD": "104258.81728031371548518832918183" + }, + { + "day": 20654, + "dailyVolumeUSD": "95156418.08871210577932181432653", + "dailyTotalRevenueUSD": "285469.25426613631733796544297959" + }, + { + "day": 20653, + "dailyVolumeUSD": "24702930.89526573559234019068566", + "dailyTotalRevenueUSD": "74108.79268579720677702057205698" + }, + { + "day": 20652, + "dailyVolumeUSD": "11001058.02971317212003848445891", + "dailyTotalRevenueUSD": "33003.17408913951636011545337673" + } + ], + "_meta": { + "block": { + "number": 49121447, + "timestamp": 1785032241, + "hash": "0x4c8d9676350cd7754f7eb2a1f2eb8cab1660889c2a141be4a4f6905cc23dd54e" + }, + "hasIndexingErrors": false, + "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 2326, + "error": null + } +} diff --git a/tests/integration/__evidence__/m4/p0-cli-help/check.txt b/tests/integration/__evidence__/m4/p0-cli-help/check.txt new file mode 100644 index 0000000..6f51c76 --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-cli-help/check.txt @@ -0,0 +1,13 @@ +Run a nest's invariant/parity checks (`checks/*.sql`) against recorded expected results + +Usage: nuthatch check [OPTIONS] [NAME] + +Arguments: + [NAME] Optional check-name filter (substring). Omit to run every `checks/*.sql`. E.g. `parity` + +Options: + --dir Nest directory (must contain a `checks/` folder) [default: .] + --update Record current query results as the expected fixtures (`checks/expected/*.json`) + instead of comparing - the authoring mode, run once against known-good sealed + data + -h, --help Print help diff --git a/tests/integration/__evidence__/m4/p0-cli-help/dev.txt b/tests/integration/__evidence__/m4/p0-cli-help/dev.txt new file mode 100644 index 0000000..5b0ca09 --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-cli-help/dev.txt @@ -0,0 +1,35 @@ +Run the indexer: poll logs, store entities, and serve the API + +Usage: nuthatch dev [OPTIONS] + +Options: + --dir Project directory (must contain a nuthatch.toml) [default: .] + --listen Address to bind the HTTP API to [default: 127.0.0.1:8288] + --rpc Override the nest's `rpc_urls` at runtime without editing the + config (repeatable). These are tried first; the nest's configured + endpoints remain as fallback. Point at your own node + --backfill Index only this many blocks back from the tip (recent-history + mode). Explicitly overrides a nest's vendored `start_block`s. + Omit to backfill from deployment when the nest declares start + blocks, else from a default recent window + --seal-direct Backfill finalized history straight to Parquet (skip the hot + store) before tip-following - much faster for a from-deployment + backfill (RFC-0004). The near-tip window still uses the hot path; + the IVM view is rebuilt from the sealed segments + --concurrency Concurrent window fetches during the seal-direct history backfill + (overlaps RPC latency). Try 8-16 against your own node; keep low + on rate-limited public RPC [default: 1] + --window Override the `eth_getLogs` block-window (the chain default + otherwise). For a *sparse* contract over a long backfill - few + events across many blocks - a large window (e.g. 50000) turns + tens of thousands of near-empty requests into a few, so a + from-history backfill finishes in minutes. Keep it under your + provider's max block-range for `getLogs` (many allow 100k+ when + the result set is small); the concurrent backfill fails the range + rather than auto-shrinking it + --no-admin Disable the built-in admin UI (`/_admin/`) entirely - no routes, + for hosted deployments that front their own dashboard (RFC-0010 + Part A). Off-localhost the UI requires `NUTHATCH_ADMIN_TOKEN` to + be set AND each request to present it as `?token=…` (or it + self-disables with a log line) + -h, --help Print help diff --git a/tests/integration/__evidence__/m4/p0-cli-help/init.txt b/tests/integration/__evidence__/m4/p0-cli-help/init.txt new file mode 100644 index 0000000..24dbb66 --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-cli-help/init.txt @@ -0,0 +1,24 @@ +Scaffold an indexer for a contract: resolve its ABI and write a project here + +Usage: nuthatch init [OPTIONS] [ADDRESSES]... + +Arguments: + [ADDRESSES]... One or more contract addresses to index, e.g. 0xA0b8…eB48 (USDC). Omit when using + `--from` + +Options: + --from Initialise from a published nest instead of addresses: a git URL or a local + directory. The nest is self-contained (ABIs vendored), so nothing is resolved + - just cloned/copied + validated + --alias Optional aliases, one per address in order (comma-separated). Defaults to c0, + c1, … + --chain Chain to index, e.g. mainnet, arbitrum-one, base. Omit it and nuthatch probes + each known chain for the contract's bytecode and picks the one it lives on - + you rarely need to say + --rpc Prefer these RPC URL(s) over the chain defaults (repeatable). They're written + first in the nest's `rpc_urls` and also used for ABI/deploy-block resolution + during init, with the built-in chain endpoints kept as fallback. Point at + your own node to dodge public-RPC limits + --dir Directory to scaffold into (defaults to the current directory; for `--from`, + defaults to the nest's own name) [default: .] + -h, --help Print help diff --git a/tests/integration/__evidence__/m4/p0-cli-help/manifest.json b/tests/integration/__evidence__/m4/p0-cli-help/manifest.json new file mode 100644 index 0000000..c287d1d --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-cli-help/manifest.json @@ -0,0 +1,17 @@ +{ + "capture_time": "2026-07-25T13:52:57Z", + "binary_path": "/home/arch/.local/bin/nuthatch", + "binary_version": "nuthatch 0.6.1", + "sha256": "cac413574b1a7c5536c65403abacc0ae9ce699f2580ae01773e28bb2f0d65e89", + "captured_by": "opencode-go/grok-4.5", + "commands": { + "tests/integration/__evidence__/m4/p0-version.txt": "/home/arch/.local/bin/nuthatch --version", + "tests/integration/__evidence__/m4/p0-cli-help/init.txt": "/home/arch/.local/bin/nuthatch init --help", + "tests/integration/__evidence__/m4/p0-cli-help/dev.txt": "/home/arch/.local/bin/nuthatch dev --help", + "tests/integration/__evidence__/m4/p0-cli-help/sql.txt": "/home/arch/.local/bin/nuthatch sql --help", + "tests/integration/__evidence__/m4/p0-cli-help/check.txt": "/home/arch/.local/bin/nuthatch check --help", + "tests/integration/__evidence__/m4/p0-cli-help/nest.txt": "/home/arch/.local/bin/nuthatch nest --help", + "tests/integration/__evidence__/m4/p0-cli-help/mcp.txt": "/home/arch/.local/bin/nuthatch mcp --help" + }, + "secret_scan": "clean" +} diff --git a/tests/integration/__evidence__/m4/p0-cli-help/mcp.txt b/tests/integration/__evidence__/m4/p0-cli-help/mcp.txt new file mode 100644 index 0000000..99360c5 --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-cli-help/mcp.txt @@ -0,0 +1,11 @@ +Serve the Model Context Protocol over stdio (bridges to a running `nuthatch dev`) + +Usage: nuthatch mcp [OPTIONS] + +Options: + --url Base URL of the running `nuthatch dev` HTTP API to bridge to [default: + http://127.0.0.1:8288] + --print-config Print a copy-paste MCP client config (Claude Code `.mcp.json` + the `claude + mcp add` one-liner) and exit, instead of running the stdio server. This is the + "wire it up in one step" helper + -h, --help Print help diff --git a/tests/integration/__evidence__/m4/p0-cli-help/nest.txt b/tests/integration/__evidence__/m4/p0-cli-help/nest.txt new file mode 100644 index 0000000..d730139 --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-cli-help/nest.txt @@ -0,0 +1,29 @@ +Package a nest as a content-addressed blob - the deploy unit (RFC-0012) + +Usage: nuthatch nest + +Commands: + bundle Bundle a nest into one portable, content-addressed `.bundle` file - its authored inputs + (config, ABIs, views, labels, skills) plus a `manifest.json` pinning the expected + decode-registry hash. Share the `.bundle` anywhere (a URL, a file); anyone can `load` it + to run your exact nest, verified by hash. Prints the bundle's content address + load Load a bundle: verify a `.bundle` (or a URL to one, or an unpacked bundle dir) and + install it as a runnable nest. Checks the manifest format, every file's hash, and that + the decode registry regenerated from the inputs matches the manifest - so a loaded nest + decodes exactly as authored. With `--registry`, the positional is a `name[@version]` + reference resolved against that store + publish Publish a `.bundle` to a registry (RFC-0019) under `name@version`, advancing `latest`. + The registry is a decoupled, optional store - a filesystem path now; object storage lands + next. A self-built bundle and `nest load ` never need one. Prints the content + address + diff Classify an update between two nests as compatible or breaking (RFC-0020). Compatible = + additive only (safe to hot-swap on the same endpoint); breaking = a consumer-observable + change (needs a new endpoint). Each argument is a nest directory or a `schema.json` path + upgrade Hot-upgrade a running nest to a compatible new version with zero downtime (RFC-0020): + serve the old version, index the new one concurrently, then atomically flip the endpoint + once it catches up. A breaking update is refused (it needs a new endpoint). The served + address never changes + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help diff --git a/tests/integration/__evidence__/m4/p0-cli-help/sql.txt b/tests/integration/__evidence__/m4/p0-cli-help/sql.txt new file mode 100644 index 0000000..f2f0a3b --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-cli-help/sql.txt @@ -0,0 +1,15 @@ +Query a nest's data with SQL - the live tip and sealed history, one surface. Prints a table + +Usage: nuthatch sql [OPTIONS] [QUERY] + +Arguments: + [QUERY] The SQL query (SELECT/WITH). Tables are `{alias}__{event}`, e.g. `usdc__transfer`. Omit + to open an interactive REPL (`.tables`, `.schema `, history; `.exit` to quit) + +Options: + --dir Nest directory (queried directly when no `nuthatch dev` holds the store) + [default: .] + --url The running instance's API, used when the local store is locked by `nuthatch dev` + [default: http://127.0.0.1:8288] + --json Emit newline-delimited JSON instead of a table (for piping to jq etc.) + -h, --help Print help diff --git a/tests/integration/__evidence__/m4/p0-contract-delta.md b/tests/integration/__evidence__/m4/p0-contract-delta.md new file mode 100644 index 0000000..85871f6 --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-contract-delta.md @@ -0,0 +1,121 @@ +# P0 Contract Delta — installed Nuthatch 0.6.1 vs the M4 plan + +Task 3, partial: CLI surface only. Ruled by Agent 1 against the committed +evidence in `p0-cli-help/`, cross-checked against an independent capture by a +second agent (byte-identical for all six commands). + +Binary: `/home/arch/.local/bin/nuthatch`, `nuthatch 0.6.1`, +sha256 `cac413574b1a7c5536c65403abacc0ae9ce699f2580ae01773e28bb2f0d65e89`. + +The HTTP surface is **not** covered here. Every claim about `/sql`, `/nest`, +`/schema`, `/ready`, guards, implicit columns, and hot ∪ sealed view coverage +remains unverified until the HTTP probes run. + +## Confirmed — the plan was right + +| Plan claim | Evidence | +| :--- | :--- | +| `nuthatch dev --seal-direct --concurrency N` | `dev.txt` — both flags exist, `--seal-direct` is RFC-0004 phased cold start | +| `--window` overrides the `eth_getLogs` range | `dev.txt` | +| `--no-admin` disables the admin UI | `dev.txt` — UI is `/_admin/`; off-localhost it also requires `NUTHATCH_ADMIN_TOKEN` per request | +| Default listener `127.0.0.1:8288` | `dev.txt` | +| `nuthatch check --update` records fixtures | `check.txt` — writes `checks/expected/*.json`, "authoring mode, run once against known-good sealed data" | +| `nuthatch nest bundle` is content-addressed | `nest.txt` — bundles authored inputs plus a `manifest.json` pinning the expected decode-registry hash | +| MCP is stdio-only, bridging to a running `dev` | `mcp.txt` | +| `nuthatch init
--chain base --alias pool` | `init.txt` — addresses positional, `--alias` comma-separated, `--chain` optional | +| Table naming `{alias}__{event}` | `sql.txt` — so the Swap table is `pool__swap` | + +## Corrections — the plan must change + +### 1. `--backfill` overrides vendored start blocks (P0 question 10: answered) + +`dev.txt`: "Index only this many blocks back from the tip (recent-history +mode). **Explicitly overrides a nest's vendored `start_block`s**." + +M4.4 requires a persisted, immutable start block. Therefore `--backfill` must +never appear in production runtime arguments, including +`NUTHATCH_EXTRA_ARGS` in `/etc/default/nuthatch`. The two mechanisms are +alternatives, not complements, and the flag silently wins. + +### 2. `--concurrency` defaults to 1 and the binary warns against high values + +`dev.txt`: "Try 8-16 against your own node; **keep low on rate-limited public +RPC** [default: 1]". + +`DATA_PIPE_PLAN.md` quotes "docs recommend 8–16" as the baseline and this plan +starts at 6. Against credential-free public endpoints, 6 is already toward the +aggressive end of what the binary itself advises. Start lower and raise it only +while watching the 429 rate. + +### 3. Keyed RPC endpoints do not need config interpolation + +`dev.txt`: `--rpc ` — "Override the nest's `rpc_urls` at runtime without +editing the config (repeatable). These are tried first; the nest's configured +endpoints remain as fallback." + +This resolves P0 question 9 and the P3 secrets decision without needing +`${...}` expansion to exist. The committed `nuthatch.toml` carries only +credential-free public endpoints; any keyed endpoint is supplied at runtime +through `--rpc` in `NUTHATCH_EXTRA_ARGS`, which already lives outside the +repository. No sanitized-template materialization step is required. + +## Capabilities the plan does not know about + +These are additive findings from `nest.txt`. None is required by M4, and none +should be adopted without its own evidence — but two of them bear directly on +stages the plan has already specified. + +### `nuthatch nest diff` (RFC-0020) + +Classifies an update between two nests as **compatible** (additive only, safe +to hot-swap) or **breaking** (consumer-observable, needs a new endpoint). Each +argument is a nest directory or a `schema.json`. + +M4.8 currently detects drift only through recorded check fixtures. A structural +diff is a cheaper and more direct gate, and it names the exact property M5 and +M7 care about. Worth evaluating as an addition to the CI workflow — not a +replacement for the fixtures, which catch value drift rather than shape drift. + +### `nuthatch nest upgrade` (RFC-0020) + +"Hot-upgrade a running nest to a compatible new version with zero downtime: +serve the old version, index the new one concurrently, then atomically flip the +endpoint once it catches up. A breaking update is refused. The served address +never changes." + +P8 currently specifies stopping `nuthatch.service` for "the shortest documented +activation window". If this path works as described, the activation window is +zero and a breaking change is refused rather than served. That is a materially +safer deployment than stop-sync-start, and it changes the rollback story too. + +**Do not adopt it on the strength of this help text.** It must be proven on a +disposable nest first, and the interaction with a systemd unit that owns the +process is unexamined. + +### `nuthatch nest load` and `nest publish` (RFC-0012, RFC-0019) + +`load` verifies a bundle — manifest format, every file hash, and that the +decode registry regenerated from the inputs matches the manifest — then +installs it as a runnable nest. This is a stronger P7/P8 staging validation +than "run the same check command against the staged nest", because it verifies +identity rather than behaviour. + +`init --from ` initialises from a published nest with ABIs +vendored and nothing re-resolved, which is a reproducibility path the plan +does not currently use. + +## Still unverified — blocking the rest of task 3 + +1. Is HTTP `/sql` GET-only, and what are its real parameter names? +2. Active timeout, row, byte, and concurrency guards. +3. Exact implicit column names and types, including `_seq`, decimal siblings, + and overflow flags. +4. **Whether authored views read hot and sealed rows together.** The single + most important open question: the entire "one fresh fact only Nuthatch can + provide" premise depends on it. +5. The source of the `/nest` registry hash and its equality rule with the + bundle manifest hash. +6. How `semantic.toml` descriptions surface in `/schema`. + +These need a running disposable nest, which needs an RPC endpoint and an +indexed contract. That is the remainder of task 2. diff --git a/tests/integration/__evidence__/m4/p0-http-capabilities.json b/tests/integration/__evidence__/m4/p0-http-capabilities.json new file mode 100644 index 0000000..2f2829a --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-http-capabilities.json @@ -0,0 +1,101 @@ +{ + "target_host": "", + "probe_version": "0.6.1", + "generated_at": "2026-07-25T15:46:13.192Z", + "endpoints": { + "/health": { + "method": "GET", + "url": "https:///health", + "status": 200, + "content_type": "text/plain; charset=utf-8", + "duration_ms": 250, + "error": null, + "body": "ok" + }, + "/ready": { + "method": "GET", + "url": "https:///ready", + "status": 200, + "content_type": "application/json", + "duration_ms": 56, + "error": null, + "body": "{\"lag_blocks\":334948,\"last_block\":48767558,\"last_poll_unixtime\":1784994360,\"ready\":true,\"sealed_through\":48767558,\"seconds_since_poll\":13,\"stalled\":false,\"tip\":49102506}" + }, + "/nest": { + "method": "GET", + "url": "https:///nest", + "status": 200, + "content_type": "application/json", + "duration_ms": 57, + "error": null, + "body": "{\"chain\":\"base\",\"chain_id\":8453,\"contracts\":[{\"address\":\"0x6c561b446416e1a00e8e93e221854d6ea4171372\",\"alias\":\"pool\"}],\"factories\":[],\"name\":\"deeptrace-pool-freshness\",\"registry_hash\":\"0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25\",\"table_count\":9,\"templates\":[],\"webhooks\":[]}" + }, + "/schema": { + "method": "GET", + "url": "https:///schema", + "status": 200, + "content_type": "text/plain; charset=utf-8", + "duration_ms": 66, + "error": null, + "body": "nuthatch data model\n\nThe `nuthatch-init-7ZaLE4` nest on base. (seeded from the ABI - edit semantic.toml to improve this)\n\nCOVERAGE\n sealed_through = 48767558 (the `sql` tool sees rows at or below this block);\n tip = 48767558 - rows above sealed_through are served by `table`/`entity`, not `sql`.\n\nTABLES (one per contract event; query via the `sql` tool)\n\n pool__burn - `Burn(address,int24,int24,uint128,uint256,uint256)` events emitted by the `pool` contract. (seeded from the ABI - edit semantic.toml to improve this)\n grain: one row per Burn(address,int24,int24,uint128,uint256,uint256) event\n columns: owner (address), tickLower (int24), tickUpper (int24), amount (uint128), amount0 (uint256), amount1 (uint256)\n ⚠ big-int columns (exact text; use the `_dec` companion for SUM/AVG/compare): amount → amount_dec, amount0 → amount0_dec, amount1 → amount1_dec\n ⚠ wide columns (>128-bit) whose `_dec` OVERFLOWS to NULL above 38 digits - use `CAST(col AS DOUBLE)` for math (e.g. sqrtPriceX96): amount0, amount1\n\n pool__collect - `Collect(address,address,int24,int24,uint128,uint128)` events emitted by the `pool` contract. (seeded from the ABI - edit semantic.toml to improve this)\n grain: one row per Collect(address,address,int24,int24,uint128,uint128) event\n columns: owner (address), recipient (address), tickLower (int24), tickUpper (int24), amount0 (uint128), amount1 (uint128)\n ⚠ big-int columns (exact text; use the `_dec` companion for SUM/AVG/compare): amount0 → amount0_dec, amount1 → amount1_dec\n\n pool__collect_protocol - `CollectProtocol(address,address,uint128,uint128)` events emitted by the `pool` contract. (seeded from the ABI - edit semantic.toml to improve this)\n grain: one row per CollectProtocol(address,address,uint128,uint128) event\n columns: sender (address), recipient (address), amount0 (uint128), amount1 (uint128)\n ⚠ big-int columns (exact text; use the `_dec` companion for SUM/AVG/compare): amount0 → amount0_dec, amount1 → amount1_dec\n\n pool__flash - `Flash…" + }, + "/tables": { + "method": "GET", + "url": "https:///tables", + "status": 200, + "content_type": "application/json", + "duration_ms": 70, + "error": null, + "body": "{\"count\":9,\"tables\":[{\"alias\":\"pool\",\"columns\":[{\"indexed\":false,\"name\":\"block_number\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":false,\"name\":\"block_hash\",\"sol_type\":\"implicit\",\"storage\":\"bytes32\"},{\"indexed\":false,\"name\":\"block_timestamp\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":false,\"name\":\"tx_hash\",\"sol_type\":\"implicit\",\"storage\":\"bytes32\"},{\"indexed\":false,\"name\":\"log_index\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":false,\"name\":\"address\",\"sol_type\":\"implicit\",\"storage\":\"address\"},{\"indexed\":false,\"name\":\"_seq\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":true,\"name\":\"owner\",\"sol_type\":\"address\",\"storage\":\"address\"},{\"indexed\":true,\"name\":\"tickLower\",\"sol_type\":\"int24\",\"storage\":\"i64\"},{\"indexed\":true,\"name\":\"tickUpper\",\"sol_type\":\"int24\",\"storage\":\"i64\"},{\"indexed\":false,\"name\":\"amount\",\"sol_type\":\"uint128\",\"storage\":\"word16\"},{\"indexed\":false,\"name\":\"amount0\",\"sol_type\":\"uint256\",\"storage\":\"word32\"},{\"indexed\":false,\"name\":\"amount1\",\"sol_type\":\"uint256\",\"storage\":\"word32\"}],\"event\":\"Burn(address,int24,int24,uint128,uint256,uint256)\",\"table\":\"pool__burn\",\"topic0\":\"0x0c396cd989a39f4459b5fa1aed6a9a8dcdbc45908acfd67e028cd568da98982c\"},{\"alias\":\"pool\",\"columns\":[{\"indexed\":false,\"name\":\"block_number\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":false,\"name\":\"block_hash\",\"sol_type\":\"implicit\",\"storage\":\"bytes32\"},{\"indexed\":false,\"name\":\"block_timestamp\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":false,\"name\":\"tx_hash\",\"sol_type\":\"implicit\",\"storage\":\"bytes32\"},{\"indexed\":false,\"name\":\"log_index\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":false,\"name\":\"address\",\"sol_type\":\"implicit\",\"storage\":\"address\"},{\"indexed\":false,\"name\":\"_seq\",\"sol_type\":\"implicit\",\"storage\":\"u64\"},{\"indexed\":true,\"name\":\"owner\",\"sol_type\":\"address\",\"storage\":\"address\"},{\"indexed\":false,\"name\":\"recipient\",\"sol_type\":\"address\",\"storage\":\"address\"},{\"indexed\":true,\"name\":\"tickLower\",\"sol_type\":\"int24\",\"storage\":\"i64\"},{\"indexed\":true,\"name\":\"tickUpper\",\"sol_type\":\"int24\",\"storage\":\"i64\"},{\"in…" + }, + "/metrics": { + "method": "GET", + "url": "https:///metrics", + "status": 200, + "content_type": "text/plain; version=0.0.4", + "duration_ms": 63, + "error": null, + "body": "# HELP nuthatch_tip_height Latest block height seen from the source.\n# TYPE nuthatch_tip_height gauge\nnuthatch_tip_height 49102506\n# HELP nuthatch_last_block Highest block the indexer has committed.\n# TYPE nuthatch_last_block gauge\nnuthatch_last_block 48767558\n# HELP nuthatch_tip_lag_blocks Blocks the indexer is behind the source tip.\n# TYPE nuthatch_tip_lag_blocks gauge\nnuthatch_tip_lag_blocks 334948\n# HELP nuthatch_sealed_through Highest block sealed to the immutable cold layer.\n# TYPE nuthatch_sealed_through gauge\nnuthatch_sealed_through 48767558\n# HELP nuthatch_rss_bytes Resident set size of this process, in bytes.\n# TYPE nuthatch_rss_bytes gauge\nnuthatch_rss_bytes 112709632\n# HELP nuthatch_last_poll_unixtime Unix time of the last successful source poll (0 = never). Staleness ⇒ RPC stalled.\n# TYPE nuthatch_last_poll_unixtime gauge\nnuthatch_last_poll_unixtime 1784994360\n# HELP nuthatch_alert_outbox_depth Pending alert-webhook deliveries in the durable outbox.\n# TYPE nuthatch_alert_outbox_depth gauge\nnuthatch_alert_outbox_depth 0\n# HELP nuthatch_rows_decoded_total Rows decoded since start.\n# TYPE nuthatch_rows_decoded_total counter\nnuthatch_rows_decoded_total 5706\n# HELP nuthatch_rows_sealed_total Rows sealed to Parquet since start.\n# TYPE nuthatch_rows_sealed_total counter\nnuthatch_rows_sealed_total 5706\n# HELP nuthatch_reorgs_total Reorgs detected and rolled back since start.\n# TYPE nuthatch_reorgs_total counter\nnuthatch_reorgs_total 0\n# HELP nuthatch_http_requests_total HTTP API requests served since start.\n# TYPE nuthatch_http_requests_total counter\nnuthatch_http_requests_total 24\n# HELP nuthatch_sql_queries_total Analytical /sql queries accepted since start.\n# TYPE nuthatch_sql_queries_total counter\nnuthatch_sql_queries_total 4\n# HELP nuthatch_sql_rejections_total Analytical /sql queries rejected (guard: timeout, too-large, over-capacity).\n# TYPE nuthatch_sql_rejections_total counter\nnuthatch_sql_rejections_total 6\n# HELP nuthatch_rpc_requests_total Outbound JSON-RPC requests issued (incl. failover retri…" + }, + "/explain": { + "method": "GET", + "url": "https:///explain?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201", + "status": 400, + "content_type": "application/json", + "duration_ms": 121, + "error": null, + "body": "{\"error\":\"failed to prepare query: Catalog Error: Table with name pool_swap_freshness does not exist!\\nDid you mean \\\"pool__swap\\\"?\\n\\nLINE 1: SELECT * FROM (SELECT * FROM pool_swap_freshness LIMIT 1) AS _explain LIMIT 0\\n ^: Error code 1: Unknown error code\\n\\nhint: no table `pool_swap_freshness`. Call the `schema` tool for the list of tables.\",\"valid\":false}" + }, + "/sql": { + "method": "GET", + "url": "https:///sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=1", + "status": 400, + "content_type": "application/json", + "duration_ms": 120, + "error": null, + "body": "{\"error\":\"failed to prepare query: Catalog Error: Table with name pool_swap_freshness does not exist!\\nDid you mean \\\"pool__swap\\\"?\\n\\nLINE 1: SELECT * FROM pool_swap_freshness LIMIT 1\\n ^: Error code 1: Unknown error code\\n\\nhint: no table `pool_swap_freshness`. Call the `schema` tool for the list of tables.\"}" + } + }, + "post_sql": { + "url": "https:///sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201", + "status": 405, + "rejected": true, + "body": "" + }, + "max_rows": { + "url": "https:///sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=50001", + "max_rows_requested": 50001, + "status": 400, + "rejected": true, + "body": "{\"error\":\"failed to prepare query: Catalog Error: Table with name pool_swap_freshness does not exist!\\nDid you mean \\\"pool__swap\\\"?\\n\\nLINE 1: SELECT * FROM pool_swap_freshness LIMIT 1\\n ^: Error code 1: Unknown error code\\n\\nhint: no table `pool_swap_freshness`. Call the `schema` tool for the list of tables.\"}" + }, + "concurrency": { + "url": "https:///sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=1", + "requests": 3, + "issued_within_ms": 165, + "statuses": [503, 400, 400], + "ok_count": 0, + "guard_rejected_count": 1, + "observed_concurrency_lower_bound": 0 + } +} diff --git a/tests/integration/__evidence__/m4/p0-version.txt b/tests/integration/__evidence__/m4/p0-version.txt new file mode 100644 index 0000000..2bb11db --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-version.txt @@ -0,0 +1 @@ +nuthatch 0.6.1 diff --git a/tests/integration/__evidence__/m4/raw-row-samples.json b/tests/integration/__evidence__/m4/raw-row-samples.json new file mode 100644 index 0000000..ab960c6 --- /dev/null +++ b/tests/integration/__evidence__/m4/raw-row-samples.json @@ -0,0 +1,128 @@ +{ + "earliest": { + "count": 1, + "provenance": { + "as_of": 48944374, + "registry_hash": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "sealed_through": 48944374, + "source": "hot+sealed" + }, + "rows": [ + { + "_seq": 51125264842773, + "address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "amount0": "-43393300574830993", + "amount0_dec": "-43393300574830993", + "amount0_overflow": false, + "amount1": "79200000", + "amount1_dec": "79200000", + "amount1_overflow": false, + "block_hash": "0xc2fd40c2f6b9a9b1e59737d6053354885b3336ac801a3f7c0340902420ec9dc2", + "block_number": 48756852, + "block_timestamp": 1784303051, + "liquidity": "28850225265757730129", + "liquidity_dec": "28850225265757730129", + "liquidity_overflow": false, + "log_index": 21, + "recipient": "0xd0a40c6526acdebd4f6d87931098ff37a9f8e4bf", + "sender": "0x2626664c2603336e57b271c5c0b26f421741e481", + "sqrtPriceX96": "3379702129103275629019155", + "sqrtPriceX96_dec": "3379702129103275629019155", + "sqrtPriceX96_overflow": false, + "table": "pool__swap", + "tick": "-201257", + "tx_hash": "0x5a02a9e244c5191381b04066377bd9b06cec47b962074200dc0d9a4702357733" + } + ], + "truncated": false + }, + "middle": { + "count": 1, + "provenance": { + "as_of": 48944374, + "registry_hash": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "sealed_through": 48944374, + "source": "hot+sealed" + }, + "rows": [ + { + "_seq": 51142551666974, + "address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "amount0": "-965575909855116", + "amount0_dec": "-965575909855116", + "amount0_overflow": false, + "amount1": "1781774", + "amount1_dec": "1781774", + "amount1_overflow": false, + "block_hash": "0x694ceb0bda63a39cbaea92c3d7445e55f6b44493b203c35dbb015bc840cb2e6c", + "block_number": 48773338, + "block_timestamp": 1784336023, + "liquidity": "33222875623211620605", + "liquidity_dec": "33222875623211620605", + "liquidity_overflow": false, + "log_index": 286, + "recipient": "0x9d56591b1bd56a8191a5a376f3dad60a8c31d58a", + "sender": "0x9d56591b1bd56a8191a5a376f3dad60a8c31d58a", + "sqrtPriceX96": "3398287968919117482363015", + "sqrtPriceX96_dec": "3398287968919117482363015", + "sqrtPriceX96_overflow": false, + "table": "pool__swap", + "tick": "-201147", + "tx_hash": "0xeb6264c141c4c712a1fdf19b918382e4f74c5356e505a586d94a750891f55fea" + } + ], + "truncated": false + }, + "latest": { + "count": 1, + "provenance": { + "as_of": 48944374, + "registry_hash": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "sealed_through": 48944374, + "source": "hot+sealed" + }, + "rows": [ + { + "_seq": 51321894863016, + "address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "amount0": "-6123995143586742520", + "amount0_dec": "-6123995143586742520", + "amount0_overflow": false, + "amount1": "11804868893", + "amount1_dec": "11804868893", + "amount1_overflow": false, + "block_hash": "0xea960db6cfd80eced3a7f609e52e8e5e16086e48b06b05249e6b9c16270ed839", + "block_number": 48944373, + "block_timestamp": 1784678093, + "liquidity": "33931442266060233254", + "liquidity_dec": "33931442266060233254", + "liquidity_overflow": false, + "log_index": 168, + "recipient": "0x6158a9d39c343bffcb5ea76e7f6693a76e0cc0fd", + "sender": "0x4a8c4de81714d81740514da9c5c4ee32ac5baf71", + "sqrtPriceX96": "3473297999633404357771264", + "sqrtPriceX96_dec": "3473297999633404357771264", + "sqrtPriceX96_overflow": false, + "table": "pool__swap", + "tick": "-200710", + "tx_hash": "0x75d694ea1806d0182c039f676887e0883cc3bd62a6d19b56a4e01fb25ccc4851" + } + ], + "truncated": false + }, + "seam_duplicates": { + "count": 1, + "provenance": { + "as_of": 48944374, + "registry_hash": "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + "sealed_through": 48944374, + "source": "hot+sealed" + }, + "rows": [ + { + "dup_count": 0 + } + ], + "truncated": false + } +} diff --git a/tests/integration/__evidence__/wr/aave-wallet-positions.json b/tests/integration/__evidence__/wr/aave-wallet-positions.json new file mode 100644 index 0000000..f2485c7 --- /dev/null +++ b/tests/integration/__evidence__/wr/aave-wallet-positions.json @@ -0,0 +1,42 @@ +{ + "captured_at": "2026-07-26", + "source_id": "messari-aave-v3-base-wallet", + "subgraph_id": "D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9", + "deployment": "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + "schema_version": "3.1.0", + "methodology_version": "1.1.0", + "network": "BASE", + "has_indexing_errors": false, + "indexed_block": 49122673, + "indexed_block_timestamp": 1785034693, + "public_test_address": "0x5cb3787a9c9c7547451ca3e6d8702453de35fe01", + "sample_position": { + "id": "0x5cb3787a9c9c7547451ca3e6d8702453de35fe01-0xd4a0e0b9149bcee3c920d2e00b5de09138fd8bb7-COLLATERAL-30", + "account": "0x5cb3787a9c9c7547451ca3e6d8702453de35fe01", + "market": "0xd4a0e0b9149bcee3c920d2e00b5de09138fd8bb7", + "market_name": "Aave Base WETH", + "asset": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "decimals": 18, + "side": "COLLATERAL", + "is_collateral": true, + "balance": "2047785132077819", + "balance_usd": "3.84928314632063870887", + "block_number": 49122604, + "timestamp": 1785034555 + }, + "section_matrix": { + "positions": "go", + "observed_assets": "go_position_derived_only", + "activity": "nuthatch_only", + "counterparties": "nuthatch_only", + "protocol_usage": "composed_supported_sources_only", + "observable_flows": "nuthatch_only" + }, + "limitations": [ + "The sample is a public on-chain address and contains no credentials.", + "Observed assets are not complete wallet balances.", + "The Graph fact is a tip-freshness position read, not historical wallet state.", + "Nuthatch wallet deployment and receipt parity are a separate release gate." + ] +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..648a9e5 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["**/*.ts"] +} diff --git a/tests/unit/compare-lending-registry.test.ts b/tests/unit/compare-lending-registry.test.ts new file mode 100644 index 0000000..cbad2e0 --- /dev/null +++ b/tests/unit/compare-lending-registry.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { + COMPARE_LENDING_SOURCE_COUNT, + getActiveCompareLendingGraphSources, +} from "../../src/registry/index.js"; +import { M0_CORE_POLICY } from "../../src/policy/index.js"; +import { M0_COMPARE_LENDING_SCOPE } from "../../src/scope/index.js"; +import { TIER_A_LENDING_METRICS_QUERY_ID } from "../../src/sources/graph/index.js"; + +describe("shipped compare-lending registry configuration", () => { + const sources = getActiveCompareLendingGraphSources(); + + it("loads exactly three lending bindings in priority order", () => { + expect(sources).toHaveLength(COMPARE_LENDING_SOURCE_COUNT); + expect(sources.map((source) => source.priority)).toEqual([1, 2, 3]); + expect(sources.map((source) => source.source_id)).toEqual([ + "messari-aave-v3-base", + "messari-seamless-base", + "messari-moonwell-base", + ]); + expect(sources.map((source) => source.record.protocol)).toEqual([ + "aave-v3", + "seamless-protocol", + "moonwell", + ]); + }); + + it("serves every binding with one query id, contract, and source type", () => { + for (const source of sources) { + expect(source.query_id).toBe(TIER_A_LENDING_METRICS_QUERY_ID); + expect(source.schema_contract_id).toBe(TIER_A_LENDING_METRICS_QUERY_ID); + expect(source.record.source_type).toBe("standardized_subgraph"); + expect(source.record.category).toBe("lending"); + expect(source.record.status).toBe("active"); + expect(source.market_token).toBe(M0_CORE_POLICY.lending.marketToken); + } + }); + + it("pins the probed deployments and subgraph ids", () => { + expect( + sources.map((source) => [source.record.deployment_or_view_id, source.record.locator]), + ).toEqual([ + [ + "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + { + kind: "graph_subgraph", + gateway_host: "gateway.thegraph.com", + subgraph_id: "D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9", + }, + ], + [ + "QmPSmTkJPSKLFn46YdgwMKV5K2c9a3pkWnzDCC4ccCLAXE", + { + kind: "graph_subgraph", + gateway_host: "gateway.thegraph.com", + subgraph_id: "2u4mWUV4xS19ef1MbnxZHWLLMwdPxtVifH46JbonXwXP", + }, + ], + [ + "QmeE6TgfRmK2iLAgCLBeXuxJQ2VXLFAeHVMTvmnECiFw7y", + { + kind: "graph_subgraph", + gateway_host: "gateway.thegraph.com", + subgraph_id: "33ex1ExmYQtwGVwri1AP3oMFPGSce6YbocBP7fWbsBrg", + }, + ], + ]); + }); + + it("keeps the locked scope allowlist aligned with the shipped profile", () => { + // Settlement orders freshness, provenance, and warnings from the scope, so + // a scope that drifts from the profile would silently misattribute them. + expect( + M0_COMPARE_LENDING_SCOPE.sources.map((source) => ({ + source_id: source.source_id, + protocol: source.protocol, + deployment_or_view_id: source.deployment_or_view_id, + query_id: source.query_id, + })), + ).toEqual( + sources.map((source) => ({ + source_id: source.source_id, + protocol: source.record.protocol, + deployment_or_view_id: source.record.deployment_or_view_id, + query_id: source.query_id, + })), + ); + expect(M0_COMPARE_LENDING_SCOPE.marketToken.address).toBe(M0_CORE_POLICY.lending.marketToken); + }); +}); diff --git a/tests/unit/compare-lending-tool.test.ts b/tests/unit/compare-lending-tool.test.ts new file mode 100644 index 0000000..80aa0a9 --- /dev/null +++ b/tests/unit/compare-lending-tool.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; + +import { compareLendingResponseSchema } from "../../src/schemas/compare-lending.js"; +import type { LendingMarketSourceResult } from "../../src/schemas/source-adapter.js"; +import { M0_COMPARE_LENDING_SCOPE } from "../../src/scope/index.js"; +import { + CompareLendingRequestError, + createFixtureCompareLendingSources, + executeCompareLending, +} from "../../src/tools/index.js"; +import { + completeLendingScenario, + lendingAave, + lendingMoonwell, + lendingMoonwellUnsupported, + lendingSeamless, + lendingSeamlessTimeout, + partialLendingScenario, +} from "../fixtures/lending-sources.js"; + +const lockedRequest = { + chain_id: M0_COMPARE_LENDING_SCOPE.chainId, + market_token: M0_COMPARE_LENDING_SCOPE.marketToken.address, +} as const; + +function sourcesFor(results: readonly LendingMarketSourceResult[], onFetch?: () => void) { + return createFixtureCompareLendingSources({ + lendingResults: results, + ...(onFetch !== undefined ? { onLendingFetch: onFetch } : {}), + }); +} + +describe("executeCompareLending", () => { + it("rejects invalid requests before calling source adapters", async () => { + const onLendingFetch = vi.fn(); + + await expect( + executeCompareLending( + { ...lockedRequest, market_token: "0x4200000000000000000000000000000000000006" } as never, + sourcesFor(completeLendingScenario, onLendingFetch), + ), + ).rejects.toBeInstanceOf(CompareLendingRequestError); + expect(onLendingFetch).not.toHaveBeenCalled(); + }); + + it("returns a complete envelope when all three protocols answer fresh", async () => { + const response = await executeCompareLending( + lockedRequest, + sourcesFor(completeLendingScenario), + ); + + expect(compareLendingResponseSchema.parse(response).status).toBe("complete"); + expect(response.status).toBe("complete"); + expect(response.coverage).toEqual({ requested_sources: 3, successful_sources: 3 }); + expect(response.freshness.map((entry) => entry.status)).toEqual(["fresh", "fresh", "fresh"]); + expect(response.provenance.map((entry) => entry.source_id)).toEqual( + M0_COMPARE_LENDING_SCOPE.sources.map((source) => source.source_id), + ); + expect(response.data?.rate_basis).toBe("percent_apy"); + expect(response.data?.ranked_by).toBe("tvl_usd"); + expect(response.data?.market_token).toEqual({ + chain_id: 8453, + address: M0_COMPARE_LENDING_SCOPE.marketToken.address, + symbol: "USDC", + decimals: 6, + }); + // Default ranking is TVL desc: Aave 172.4M, Moonwell 15.1M, Seamless 205K. + expect(response.data?.markets.map((market) => market.protocol)).toEqual([ + "aave-v3", + "moonwell", + "seamless-protocol", + ]); + expect(response.data?.markets.map((market) => market.rank)).toEqual([1, 2, 3]); + expect(response.data?.markets[0]?.source_ids).toEqual(["messari-aave-v3-base"]); + }); + + it("returns a partial envelope with warnings when two protocols degrade", async () => { + const response = await executeCompareLending(lockedRequest, sourcesFor(partialLendingScenario)); + + expect(compareLendingResponseSchema.parse(response).status).toBe("partial"); + expect(response.coverage.successful_sources).toBe(1); + expect(response.freshness.map((entry) => entry.status)).toEqual([ + "fresh", + "unavailable", + "unavailable", + ]); + expect(response.warnings).toContain("messari-seamless-base timed out"); + expect(response.warnings).toContain("messari-moonwell-base is unsupported for this request"); + expect(response.data?.markets).toHaveLength(1); + expect(response.data?.markets[0]?.protocol).toBe("aave-v3"); + }); + + it("returns a failed envelope with null data when no protocol answers", async () => { + const response = await executeCompareLending( + lockedRequest, + sourcesFor([ + { ...lendingAave, status: "error", data: null, freshness: null, warnings: [] }, + lendingSeamlessTimeout, + lendingMoonwellUnsupported, + ]), + ); + + expect(compareLendingResponseSchema.parse(response).status).toBe("failed"); + expect(response.data).toBeNull(); + expect(response.coverage).toEqual({ requested_sources: 3, successful_sources: 0 }); + expect(response.warnings).toContain("messari-aave-v3-base returned an error"); + expect(response.provenance).toHaveLength(3); + }); + + it("ranks by the requested rate metric with nulls last", async () => { + const response = await executeCompareLending( + { ...lockedRequest, ranked_by: "borrower_stable_rate_percent" as never }, + sourcesFor(completeLendingScenario), + ).catch((error: unknown) => error); + + // borrower_stable_rate_percent is not a supported ranking metric. + expect(response).toBeInstanceOf(CompareLendingRequestError); + + const byLenderRate = await executeCompareLending( + { ...lockedRequest, ranked_by: "lender_variable_rate_percent" }, + sourcesFor(completeLendingScenario), + ); + + // Moonwell 4.116 > Aave 3.517 > Seamless 0.110. + expect(byLenderRate.data?.markets.map((market) => market.protocol)).toEqual([ + "moonwell", + "aave-v3", + "seamless-protocol", + ]); + }); + + it("orders a null ranking metric last", async () => { + const aaveWithoutBorrowBalance = { + ...lendingAave, + data: { ...lendingAave.data, total_borrow_balance_usd: null }, + } satisfies LendingMarketSourceResult; + + const response = await executeCompareLending( + { ...lockedRequest, ranked_by: "total_borrow_balance_usd" }, + sourcesFor([aaveWithoutBorrowBalance, lendingSeamless, lendingMoonwell]), + ); + + expect(response.data?.markets.map((market) => market.protocol)).toEqual([ + "moonwell", + "seamless-protocol", + "aave-v3", + ]); + expect(response.data?.markets[2]?.total_borrow_balance_usd).toBeNull(); + }); + + it("keeps coverage on answered sources when top_n truncates ranked markets", async () => { + const response = await executeCompareLending( + { ...lockedRequest, top_n: 1 }, + sourcesFor(completeLendingScenario), + ); + + // Truncation is the caller's choice, so it is a warning rather than a + // coverage gap: every source still answered and was fresh. + expect(compareLendingResponseSchema.parse(response).status).toBe("complete"); + expect(response.data?.markets).toHaveLength(1); + expect(response.coverage.successful_sources).toBe(3); + expect(response.warnings).toContain("Top-N truncated ranked markets from 3 to 1."); + }); + + it("orders warnings by locked source order then lexically", async () => { + const response = await executeCompareLending(lockedRequest, sourcesFor(partialLendingScenario)); + + const seamlessIndex = response.warnings.findIndex((warning) => + warning.includes("messari-seamless-base"), + ); + const moonwellIndex = response.warnings.findIndex((warning) => + warning.includes("messari-moonwell-base"), + ); + expect(seamlessIndex).toBeGreaterThanOrEqual(0); + expect(seamlessIndex).toBeLessThan(moonwellIndex); + }); +}); diff --git a/tests/unit/compare-pools-request.test.ts b/tests/unit/compare-pools-request.test.ts new file mode 100644 index 0000000..3920afb --- /dev/null +++ b/tests/unit/compare-pools-request.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { comparePoolsRequestSchema } from "../../src/schemas/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; +import { + lockedComparePoolsRequest, + lockedComparePoolsRequestWithOptions, +} from "../fixtures/compare-pools-request.js"; + +describe("compare_pools request schema", () => { + it("accepts the locked Base WETH/USDC request and applies defaults", () => { + expect(comparePoolsRequestSchema.parse(lockedComparePoolsRequest)).toEqual({ + chain_id: 8453, + token0: M0_COMPARE_POOLS_SCOPE.token0.address, + token1: M0_COMPARE_POOLS_SCOPE.token1.address, + window: "24h", + ranked_by: "volume_usd", + top_n: 3, + }); + }); + + it("accepts explicit optional fields within policy bounds", () => { + expect(comparePoolsRequestSchema.parse(lockedComparePoolsRequestWithOptions)).toEqual( + lockedComparePoolsRequestWithOptions, + ); + }); + + it("rejects unknown fields", () => { + expect( + comparePoolsRequestSchema.safeParse({ + ...lockedComparePoolsRequest, + subgraph_id: "must-not-leak", + }).success, + ).toBe(false); + }); + + it("rejects unsupported chain and pair addresses", () => { + expect( + comparePoolsRequestSchema.safeParse({ + ...lockedComparePoolsRequest, + chain_id: 1, + }).success, + ).toBe(false); + expect( + comparePoolsRequestSchema.safeParse({ + ...lockedComparePoolsRequest, + token1: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + }).success, + ).toBe(false); + expect( + comparePoolsRequestSchema.safeParse({ + ...lockedComparePoolsRequest, + token0: "0x4200000000000000000000000000000000000006".toUpperCase(), + }).success, + ).toBe(false); + }); + + it("rejects out-of-bounds top_n and unknown enums", () => { + expect( + comparePoolsRequestSchema.safeParse({ + ...lockedComparePoolsRequest, + top_n: 4, + }).success, + ).toBe(false); + expect( + comparePoolsRequestSchema.safeParse({ + ...lockedComparePoolsRequest, + window: "1h", + }).success, + ).toBe(false); + }); +}); diff --git a/tests/unit/compare-pools-schema.test.ts b/tests/unit/compare-pools-schema.test.ts new file mode 100644 index 0000000..cf6e50b --- /dev/null +++ b/tests/unit/compare-pools-schema.test.ts @@ -0,0 +1,390 @@ +import { describe, expect, it } from "vitest"; + +import { comparePoolsResponseSchema, poolComparisonRecordSchema } from "../../src/schemas/index.js"; +import { + comparePoolsFixtures, + completeComparePoolsFixture, + failedComparePoolsFixture, + partialComparePoolsFixture, +} from "../fixtures/compare-pools.js"; + +describe("compare_pools response schemas", () => { + it("accepts complete, partial, and failed fixtures", () => { + for (const fixture of comparePoolsFixtures) { + expect(comparePoolsResponseSchema.parse(fixture)).toEqual(fixture); + } + }); + + it("preserves measured zero and unavailable financial values", () => { + expect(completeComparePoolsFixture.data.pools[1].fees_usd).toBe("0"); + expect(partialComparePoolsFixture.data.pools[1].volume_usd).toBeNull(); + expect(comparePoolsResponseSchema.parse(completeComparePoolsFixture)).toEqual( + completeComparePoolsFixture, + ); + expect(comparePoolsResponseSchema.parse(partialComparePoolsFixture)).toEqual( + partialComparePoolsFixture, + ); + }); + + it("rejects unknown public response and record fields", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + raw_graph_response: {}, + }).success, + ).toBe(false); + expect( + poolComparisonRecordSchema.safeParse({ + ...completeComparePoolsFixture.data.pools[0], + token0: {}, + }).success, + ).toBe(false); + }); + + it("rejects JavaScript numbers and malformed decimal strings for financial values", () => { + for (const volumeUsd of [1.25, "-1", "01", "1e3"]) { + expect( + poolComparisonRecordSchema.safeParse({ + ...completeComparePoolsFixture.data.pools[0], + volume_usd: volumeUsd, + }).success, + ).toBe(false); + } + }); + + it("rejects failed responses that report successful deployments", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...failedComparePoolsFixture, + coverage: { + ...failedComparePoolsFixture.coverage, + successful_deployments: 1, + }, + }).success, + ).toBe(false); + }); + + it("rejects a successful deployment count that differs from pool records", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...partialComparePoolsFixture, + coverage: { + ...partialComparePoolsFixture.coverage, + successful_deployments: 1, + }, + }).success, + ).toBe(false); + }); + + it("rejects complete responses with degraded coverage", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + data: { + ...completeComparePoolsFixture.data, + nuthatch_freshness_fact: null, + }, + coverage: { + ...completeComparePoolsFixture.coverage, + nuthatch_available: false, + }, + freshness: completeComparePoolsFixture.freshness.map((entry, index) => + index === 2 ? { source_id: entry.source_id, status: "unavailable" } : entry, + ), + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + freshness: completeComparePoolsFixture.freshness.map((entry, index) => + index === 0 + ? { + ...entry, + status: "stale", + indexed_block_timestamp: entry.queried_at - 301, + lag_seconds: 301, + } + : entry, + ), + }).success, + ).toBe(false); + }); + + it("rejects partial responses without degraded coverage", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + status: "partial", + }).success, + ).toBe(false); + }); + + it("requires one freshness entry for every attempted source", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + freshness: completeComparePoolsFixture.freshness.slice(1), + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + freshness: completeComparePoolsFixture.freshness.map((entry, index) => + index === 0 ? { ...entry, source_id: "fixture-unknown" } : entry, + ), + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + provenance: completeComparePoolsFixture.provenance.map((entry, index) => + index === 2 ? { ...entry, source_type: "native_subgraph" } : entry, + ), + }).success, + ).toBe(false); + }); + + it("requires observed Nuthatch freshness to include a block hash", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + freshness: completeComparePoolsFixture.freshness.map((entry, index) => + index === 2 ? { ...entry, indexed_block_hash: null } : entry, + ), + }).success, + ).toBe(false); + }); + + it("enforces the freshness threshold and lag calculation", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + freshness: completeComparePoolsFixture.freshness.map((entry, index) => + index === 0 ? { ...entry, lag_seconds: 301 } : entry, + ), + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...partialComparePoolsFixture, + freshness: partialComparePoolsFixture.freshness.map((entry, index) => + index === 1 ? { ...entry, status: "fresh" } : entry, + ), + }).success, + ).toBe(false); + }); + + it("requires pool records to match response pair, window, and rank order", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + data: { + ...completeComparePoolsFixture.data, + pools: completeComparePoolsFixture.data.pools.map((pool, index) => + index === 0 ? { ...pool, rank: 2 } : pool, + ), + }, + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + data: { + ...completeComparePoolsFixture.data, + pools: completeComparePoolsFixture.data.pools.map((pool, index) => + index === 0 + ? { + ...pool, + pair: [{ ...pool.pair[0], decimals: 6 }, pool.pair[1]], + } + : pool, + ), + }, + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + data: { + ...completeComparePoolsFixture.data, + pools: completeComparePoolsFixture.data.pools.map((pool, index) => + index === 0 ? { ...pool, window: "7d" } : pool, + ), + }, + }).success, + ).toBe(false); + }); + + it("rejects source references absent from provenance", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + data: { + ...completeComparePoolsFixture.data, + pools: completeComparePoolsFixture.data.pools.map((pool, index) => + index === 0 ? { ...pool, source_ids: ["invented-source"] } : pool, + ), + }, + }).success, + ).toBe(false); + }); + + it("requires the Nuthatch fact to reference Nuthatch provenance", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + data: { + ...completeComparePoolsFixture.data, + nuthatch_freshness_fact: { + ...completeComparePoolsFixture.data.nuthatch_freshness_fact, + source_id: "fixture-dex-a", + }, + }, + }).success, + ).toBe(false); + }); + + it("requires Nuthatch coverage to match fact availability", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + coverage: { + ...completeComparePoolsFixture.coverage, + nuthatch_available: false, + }, + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...partialComparePoolsFixture, + coverage: { + ...partialComparePoolsFixture.coverage, + nuthatch_available: true, + }, + }).success, + ).toBe(false); + }); + + it("requires facts and financial records to reference observed sources", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...partialComparePoolsFixture, + data: { + ...partialComparePoolsFixture.data, + pools: [ + partialComparePoolsFixture.data.pools[0], + { + ...partialComparePoolsFixture.data.pools[1], + source_ids: ["fixture-unknown"], + }, + ], + }, + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + status: "partial", + warnings: ["fixture-nuthatch was unavailable"], + freshness: completeComparePoolsFixture.freshness.map((entry, index) => + index === 2 ? { source_id: entry.source_id, status: "unavailable" } : entry, + ), + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...partialComparePoolsFixture, + freshness: partialComparePoolsFixture.freshness.map((entry, index) => + index === 2 + ? { + source_id: entry.source_id, + status: "fresh", + indexed_block: 12_345_678, + indexed_block_timestamp: 1_699_999_992, + indexed_block_hash: `0x${"2".repeat(64)}`, + queried_at: 1_700_000_000, + lag_seconds: 8, + } + : entry, + ), + }).success, + ).toBe(false); + }); + + it("requires failed Nuthatch coverage to match observed freshness", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...failedComparePoolsFixture, + coverage: { + ...failedComparePoolsFixture.coverage, + nuthatch_available: true, + }, + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...failedComparePoolsFixture, + freshness: failedComparePoolsFixture.freshness.map((entry, index) => + index === 2 + ? { + source_id: entry.source_id, + status: "fresh", + indexed_block: 12_345_678, + indexed_block_timestamp: 1_699_999_992, + indexed_block_hash: `0x${"3".repeat(64)}`, + queried_at: 1_700_000_000, + lag_seconds: 8, + } + : entry, + ), + }).success, + ).toBe(false); + }); + + it("rejects Nuthatch provenance for pool financial records", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + data: { + ...completeComparePoolsFixture.data, + pools: completeComparePoolsFixture.data.pools.map((pool, index) => + index === 0 ? { ...pool, source_ids: ["fixture-nuthatch"] } : pool, + ), + }, + }).success, + ).toBe(false); + }); + + it("requires degraded partial responses to include warnings", () => { + expect( + comparePoolsResponseSchema.safeParse({ + ...partialComparePoolsFixture, + warnings: [], + }).success, + ).toBe(false); + }); + + it("rejects uppercase public addresses", () => { + expect( + poolComparisonRecordSchema.safeParse({ + ...completeComparePoolsFixture.data.pools[0], + pool_address: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }).success, + ).toBe(false); + }); + + it("rejects duplicate source IDs and identical pair tokens", () => { + expect( + poolComparisonRecordSchema.safeParse({ + ...completeComparePoolsFixture.data.pools[0], + source_ids: ["fixture-dex-a", "fixture-dex-a"], + }).success, + ).toBe(false); + expect( + poolComparisonRecordSchema.safeParse({ + ...completeComparePoolsFixture.data.pools[0], + pair: [completeComparePoolsFixture.data.pair[0], completeComparePoolsFixture.data.pair[0]], + }).success, + ).toBe(false); + }); +}); diff --git a/tests/unit/compare-pools-scope.test.ts b/tests/unit/compare-pools-scope.test.ts new file mode 100644 index 0000000..c1a205b --- /dev/null +++ b/tests/unit/compare-pools-scope.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { M0_CORE_POLICY } from "../../src/policy/index.js"; +import { getActiveComparePoolGraphSources } from "../../src/registry/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; + +describe("M0 compare_pools live scope", () => { + it("mirrors the active registry Graph bindings", () => { + const sources = getActiveComparePoolGraphSources(); + expect(sources).toHaveLength(M0_COMPARE_POOLS_SCOPE.graphSources.length); + expect(M0_COMPARE_POOLS_SCOPE.graphSources.length).toBe( + M0_CORE_POLICY.coverage.expectedGraphResults, + ); + + for (const [index, scoped] of M0_COMPARE_POOLS_SCOPE.graphSources.entries()) { + const bound = sources[index]; + expect(bound).toBeDefined(); + expect(bound!.source_id).toBe(scoped.source_id); + expect(bound!.pool_address).toBe(scoped.pool_address); + expect(bound!.query_id).toBe(scoped.query_id); + expect(bound!.record.protocol).toBe(scoped.protocol); + expect(bound!.record.deployment_or_view_id).toBe(scoped.deployment_or_view_id); + expect(bound!.token0).toBe(M0_COMPARE_POOLS_SCOPE.token0.address); + expect(bound!.token1).toBe(M0_COMPARE_POOLS_SCOPE.token1.address); + } + }); + + it("locks the production query revision independently of the v1 response contract", () => { + const sources = getActiveComparePoolGraphSources(); + + expect(new Set(sources.map((source) => source.query_id))).toEqual( + new Set(["tier-a-dex-pool-metrics-v1"]), + ); + expect(new Set(sources.map((source) => source.schema_contract_id))).toEqual( + new Set(["tier-a-dex-pool-metrics-v1"]), + ); + expect(new Set(M0_COMPARE_POOLS_SCOPE.graphSources.map((source) => source.query_id))).toEqual( + new Set(["tier-a-dex-pool-metrics-v1"]), + ); + }); + + it("reserves Nuthatch without claiming a verified registry record", () => { + expect(M0_COMPARE_POOLS_SCOPE.nuthatchSourceId).toBe("nuthatch-pool-swaps"); + expect( + getActiveComparePoolGraphSources().some( + (source) => source.source_id === M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, + ), + ).toBe(false); + }); +}); diff --git a/tests/unit/compare-pools-tool.test.ts b/tests/unit/compare-pools-tool.test.ts new file mode 100644 index 0000000..e3b2cdf --- /dev/null +++ b/tests/unit/compare-pools-tool.test.ts @@ -0,0 +1,304 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; + +import { FixedWindowRateLimiter } from "../../src/gateway/index.js"; +import { + COMPARE_LENDING_MARKETS_TOOL_NAME, + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_TOOL_NAME, + RESEARCH_WALLET_TOOL_NAME, +} from "../../src/mcp/server.js"; +import { startMcpServer } from "../../src/mcp/lifecycle.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; +import { + ComparePoolsRequestError, + createFixtureComparePoolsSources, + createLiveComparePoolsSources, + executeComparePools, +} from "../../src/tools/index.js"; +import { lockedComparePoolsRequest } from "../fixtures/compare-pools-request.js"; +import { graphPoolA, graphPoolB, graphPoolCTimeout } from "../fixtures/sources/index.js"; + +describe("executeComparePools", () => { + it("rejects invalid requests before calling source adapters", async () => { + const onGraphFetch = vi.fn(); + const sources = createFixtureComparePoolsSources({ + graphResults: [graphPoolA, graphPoolB], + onGraphFetch, + }); + + await expect( + executeComparePools( + { + ...lockedComparePoolsRequest, + token1: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + } as typeof lockedComparePoolsRequest, + sources, + ), + ).rejects.toBeInstanceOf(ComparePoolsRequestError); + expect(onGraphFetch).not.toHaveBeenCalled(); + }); + + it("starts Graph and Nuthatch without waiting for either source", async () => { + let releaseGraph: ((results: typeof graphResults) => void) | undefined; + const graphResults = [graphPoolA, graphPoolB] as const; + const graphPending = new Promise((resolve) => { + releaseGraph = resolve; + }); + const onGraphFetch = vi.fn(() => graphPending); + const onNuthatchFetch = vi.fn(() => Promise.resolve(null)); + + const execution = executeComparePools(lockedComparePoolsRequest, { + fetchGraphResults: onGraphFetch, + fetchNuthatchResult: onNuthatchFetch, + }); + + expect(onGraphFetch).toHaveBeenCalledTimes(1); + expect(onNuthatchFetch).toHaveBeenCalledTimes(1); + releaseGraph?.(graphResults); + await expect(execution).resolves.toMatchObject({ + coverage: { successful_deployments: 2 }, + }); + }); +}); + +describe("createLiveComparePoolsSources", () => { + it("forwards gateway sourceTimeoutMs to Graph fetches", async () => { + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + + const sources = createLiveComparePoolsSources({ + apiKey: "key", + timeoutMs: 20, + fetchImpl, + }); + const results = await sources.fetchGraphResults(lockedComparePoolsRequest); + + expect(results.length).toBeGreaterThan(0); + expect(results.every((result) => result.status === "timeout")).toBe(true); + }); +}); + +describe("compare_pools MCP tool", () => { + async function withClient( + sources = createFixtureComparePoolsSources({ + graphResults: [graphPoolA, graphPoolB], + }), + rateLimiter?: FixedWindowRateLimiter, + ) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const runtime = await startMcpServer(serverTransport, { + sources, + ...(rateLimiter !== undefined ? { rateLimiter } : {}), + gatewayConfig: { + rateLimitMaxRequests: 30, + rateLimitWindowMs: 60_000, + sourceTimeoutMs: 5_000, + }, + }); + const client = new Client({ + name: "deeptrace-test-client", + version: "0.1.0", + }); + await client.connect(clientTransport); + return { client, runtime }; + } + + it("lists compare_pools alongside lending, large-swap, and wallet tools", async () => { + const { client, runtime } = await withClient(); + try { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual([ + COMPARE_POOLS_TOOL_NAME, + COMPARE_LENDING_MARKETS_TOOL_NAME, + FIND_LARGE_SWAPS_TOOL_NAME, + RESEARCH_WALLET_TOOL_NAME, + ]); + const tool = listed.tools[0]; + expect(tool?.title).toBe("Compare Base WETH/USDC pools"); + expect(tool?.description).toContain("Graph-reported TVL, volume, or fees"); + expect(tool?.description).toContain("Nuthatch adds independent freshness facts only"); + expect(tool?.annotations?.readOnlyHint).toBe(true); + expect(tool?.outputSchema).toMatchObject({ + type: "object", + }); + const inputProperties = tool?.inputSchema.properties as + Record | undefined; + expect(inputProperties?.chain_id?.description).toContain("must be 8453"); + expect(inputProperties?.token0?.description).toContain("WETH address"); + expect(inputProperties?.token1?.description).toContain("USDC address"); + expect(inputProperties?.window?.description).toContain("Defaults to 24h"); + expect(inputProperties?.ranked_by?.description).toContain("Graph-reported"); + expect(inputProperties?.top_n?.description).toContain("Defaults to 3"); + } finally { + await client.close(); + await runtime.close(); + } + }); + + it("returns ranked fixture pools without inventing Nuthatch", async () => { + const onGraphFetch = vi.fn(); + const { client, runtime } = await withClient( + createFixtureComparePoolsSources({ + graphResults: [graphPoolA, graphPoolB], + onGraphFetch, + }), + ); + + try { + const result = await client.callTool({ + name: COMPARE_POOLS_TOOL_NAME, + arguments: lockedComparePoolsRequest, + }); + expect(result.isError).toBeFalsy(); + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; + expect(text).toBeTypeOf("string"); + const body = JSON.parse(text!) as { + status: string; + coverage: { nuthatch_available: boolean; successful_deployments: number }; + data: { pools: Array<{ source_ids: string[] }> } | null; + }; + expect(body.status).toBe("partial"); + expect(body.coverage.nuthatch_available).toBe(false); + expect(body.coverage.successful_deployments).toBe(2); + expect(body.data?.pools[0]?.source_ids).toEqual([graphPoolA.source_id]); + expect(result.structuredContent).toEqual(body); + expect(onGraphFetch).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + await runtime.close(); + } + }); + + it("rejects unsupported pair inputs before calling source adapters", async () => { + const onGraphFetch = vi.fn(); + const { client, runtime } = await withClient( + createFixtureComparePoolsSources({ + graphResults: [graphPoolA, graphPoolB], + onGraphFetch, + }), + ); + + try { + const result = await client.callTool({ + name: COMPARE_POOLS_TOOL_NAME, + arguments: { + ...lockedComparePoolsRequest, + token1: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + }, + }); + expect(result.isError).toBe(true); + expect((result.content as Array<{ text: string }>)[0]?.text).toMatch( + /Input validation error|Invalid arguments/i, + ); + expect(onGraphFetch).not.toHaveBeenCalled(); + } finally { + await client.close(); + await runtime.close(); + } + }); + + it("rejects unknown request fields before calling source adapters", async () => { + const onGraphFetch = vi.fn(); + const { client, runtime } = await withClient( + createFixtureComparePoolsSources({ + graphResults: [graphPoolA, graphPoolB], + onGraphFetch, + }), + ); + + try { + const result = await client.callTool({ + name: COMPARE_POOLS_TOOL_NAME, + arguments: { + ...lockedComparePoolsRequest, + subgraph_id: "must-not-leak", + }, + }); + expect(result.isError).toBe(true); + expect((result.content as Array<{ text: string }>)[0]?.text).toMatch( + /Input validation error|Invalid arguments|unrecognized key/i, + ); + expect(onGraphFetch).not.toHaveBeenCalled(); + } finally { + await client.close(); + await runtime.close(); + } + }); + + it("does not call adapters when rate limited", async () => { + const onGraphFetch = vi.fn(); + const rateLimiter = new FixedWindowRateLimiter({ + maxRequests: 1, + windowMs: 60_000, + clock: (() => { + const now = 1_000; + return () => now; + })(), + }); + const sources = createFixtureComparePoolsSources({ + graphResults: [graphPoolA, graphPoolB], + onGraphFetch, + }); + const { client, runtime } = await withClient(sources, rateLimiter); + + try { + const first = await client.callTool({ + name: COMPARE_POOLS_TOOL_NAME, + arguments: lockedComparePoolsRequest, + }); + expect(first.isError).toBeFalsy(); + expect(onGraphFetch).toHaveBeenCalledTimes(1); + + const second = await client.callTool({ + name: COMPARE_POOLS_TOOL_NAME, + arguments: lockedComparePoolsRequest, + }); + expect(second.isError).toBe(true); + expect((second.content as Array<{ text: string }>)[0]?.text).toMatch(/Rate limit/i); + expect(onGraphFetch).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + await runtime.close(); + } + }); + + it("preserves a successful Graph pool when a sibling times out", async () => { + const { client, runtime } = await withClient( + createFixtureComparePoolsSources({ + graphResults: [graphPoolA, graphPoolCTimeout], + }), + ); + + try { + const result = await client.callTool({ + name: COMPARE_POOLS_TOOL_NAME, + arguments: { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + token0: M0_COMPARE_POOLS_SCOPE.token0.address, + token1: M0_COMPARE_POOLS_SCOPE.token1.address, + ranked_by: "volume_usd", + }, + }); + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; + const body = JSON.parse(text!) as { + status: string; + coverage: { successful_deployments: number }; + data: { pools: Array<{ source_ids: string[] }> } | null; + }; + expect(body.status).toBe("partial"); + expect(body.coverage.successful_deployments).toBe(1); + expect(body.data?.pools[0]?.source_ids).toEqual([graphPoolA.source_id]); + } finally { + await client.close(); + await runtime.close(); + } + }); +}); diff --git a/tests/unit/find-large-swaps-tool.test.ts b/tests/unit/find-large-swaps-tool.test.ts new file mode 100644 index 0000000..1ad2921 --- /dev/null +++ b/tests/unit/find-large-swaps-tool.test.ts @@ -0,0 +1,247 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; + +import { + COMPARE_LENDING_MARKETS_TOOL_NAME, + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_TOOL_NAME, + RESEARCH_WALLET_TOOL_NAME, +} from "../../src/mcp/server.js"; +import { startMcpServer } from "../../src/mcp/lifecycle.js"; +import { M0_CORE_POLICY } from "../../src/policy/index.js"; +import { findLargeSwapsResponseSchema } from "../../src/schemas/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; +import type { LargeSwapSourceGateway, LargeSwapSourceResult } from "../../src/tools/index.js"; +import { createFixtureComparePoolsSources, executeFindLargeSwaps } from "../../src/tools/index.js"; +import { usdcToWethSwapFixture, wethToUsdcSwapFixture } from "../fixtures/large-swaps.js"; +import { lockedLargeSwapsRequest } from "../fixtures/large-swaps-request.js"; + +const PROVENANCE = { + source_id: LSS_SCOPE.source.sourceId, + source_type: "nuthatch_view", + protocol: LSS_SCOPE.protocol, + chain_id: LSS_SCOPE.chainId, + deployment_or_view_id: "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + schema_version: null, + methodology_version: LSS_SCOPE.source.methodologyVersion, + query_id: LSS_SCOPE.source.queryId, +} as const; + +const FRESHNESS = { + source_id: LSS_SCOPE.source.sourceId, + status: "fresh", + indexed_block: 20_000_100, + indexed_block_timestamp: 1_749_999_990, + indexed_block_hash: `0x${"a".repeat(64)}`, + queried_at: 1_750_000_000, + lag_seconds: 10, +} as const; + +const LSS_WETH_TO_USDC = { + ...wethToUsdcSwapFixture, + source_id: LSS_SCOPE.source.sourceId, +}; +const LSS_USDC_TO_WETH = { + ...usdcToWethSwapFixture, + source_id: LSS_SCOPE.source.sourceId, +}; + +function source(result: LargeSwapSourceResult) { + const fetchCandidatesSpy = vi.fn(() => Promise.resolve(result)); + return { + provenance: PROVENANCE, + fetchCandidates: fetchCandidatesSpy, + fetchCandidatesSpy, + } satisfies LargeSwapSourceGateway & { + readonly fetchCandidatesSpy: typeof fetchCandidatesSpy; + }; +} + +function success(events = [LSS_WETH_TO_USDC, LSS_USDC_TO_WETH]) { + return source({ + status: "ok", + events, + indexedHead: 20_000_100, + freshness: FRESHNESS, + provenance: PROVENANCE, + warnings: [], + }); +} + +describe("executeFindLargeSwaps", () => { + it("settles a complete exact-threshold page", async () => { + const response = await executeFindLargeSwaps(lockedLargeSwapsRequest, success()); + + expect(findLargeSwapsResponseSchema.parse(response)).toEqual(response); + expect(response.status).toBe("complete"); + expect(response.data?.swaps).toEqual([LSS_WETH_TO_USDC]); + expect(response.coverage).toEqual({ + requested_sources: 1, + successful_sources: 1, + }); + }); + + it("settles a valid zero-match page as complete", async () => { + const response = await executeFindLargeSwaps( + { ...lockedLargeSwapsRequest, min_amount: "1000000" }, + success([]), + ); + + expect(response).toMatchObject({ + status: "complete", + data: { swaps: [] }, + coverage: { requested_sources: 1, successful_sources: 1 }, + pagination: { returned: 0, has_more: false, next_cursor: null }, + }); + }); + + it("shortens a maximum-limit page without exceeding the response budget or omitting events", async () => { + const events = Array.from({ length: 101 }, (_, logIndex) => ({ + ...LSS_WETH_TO_USDC, + transaction_hash: `0x${logIndex.toString(16).padStart(64, "0")}`, + log_index: logIndex, + amount_in: "9".repeat(60), + amount_out: "8".repeat(60), + amount_in_raw: "9".repeat(78), + amount_out_raw: `-${"8".repeat(78)}`, + })); + const gateway = success(events); + const request = { ...lockedLargeSwapsRequest, limit: 100 } as const; + + const first = await executeFindLargeSwaps(request, gateway); + expect(first.status).toBe("complete"); + expect(Buffer.byteLength(JSON.stringify(first), "utf8")).toBeLessThanOrEqual( + M0_CORE_POLICY.gateway.maximumResponseBytes, + ); + expect(first.pagination).toMatchObject({ + limit: 100, + has_more: true, + }); + expect(first.pagination.returned).toBeLessThan(100); + expect(first.warnings).toContain( + "Page was shortened below the requested limit to preserve the response-size budget.", + ); + + const second = await executeFindLargeSwaps( + { ...request, cursor: first.pagination.next_cursor }, + gateway, + ); + const swaps = [ + ...(first.status === "complete" ? first.data.swaps : []), + ...(second.status === "complete" ? second.data.swaps : []), + ]; + expect(swaps).toHaveLength(events.length); + expect( + new Set(swaps.map(({ transaction_hash, log_index }) => `${transaction_hash}:${log_index}`)) + .size, + ).toBe(events.length); + }); + + it("settles the sole source failing as failed and contains thrown adapters", async () => { + const unavailable = source({ + status: "error", + events: null, + indexedHead: null, + freshness: { source_id: LSS_SCOPE.source.sourceId, status: "unavailable" }, + provenance: PROVENANCE, + warnings: ["fixture source unavailable"], + }); + await expect( + executeFindLargeSwaps(lockedLargeSwapsRequest, unavailable), + ).resolves.toMatchObject({ + status: "failed", + data: null, + warnings: ["fixture source unavailable"], + }); + + const throwing: LargeSwapSourceGateway = { + provenance: PROVENANCE, + fetchCandidates: vi.fn(() => Promise.reject(new Error("secret adapter detail"))), + }; + const contained = await executeFindLargeSwaps(lockedLargeSwapsRequest, throwing); + expect(contained.status).toBe("failed"); + expect(JSON.stringify(contained)).not.toContain("secret adapter detail"); + }); + + it("rejects invalid requests before invoking the source", async () => { + const gateway = success(); + await expect( + executeFindLargeSwaps( + { ...lockedLargeSwapsRequest, min_amount: "1; DROP TABLE pool__swap" }, + gateway, + ), + ).rejects.toThrow(); + expect(gateway.fetchCandidatesSpy).not.toHaveBeenCalled(); + }); +}); + +describe("find_large_swaps MCP tool", () => { + async function withClient(gateway = success()) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const runtime = await startMcpServer(serverTransport, { + sources: createFixtureComparePoolsSources({ graphResults: [] }), + largeSwapSource: gateway, + gatewayConfig: { + rateLimitMaxRequests: 30, + rateLimitWindowMs: 60_000, + sourceTimeoutMs: 5_000, + }, + }); + const client = new Client({ name: "lss-test-client", version: "0.1.0" }); + await client.connect(clientTransport); + return { client, runtime }; + } + + it("lists all implemented tools with the LSS contract", async () => { + const { client, runtime } = await withClient(); + try { + const listed = await client.listTools(); + expect(listed.tools.map(({ name }) => name)).toEqual([ + COMPARE_POOLS_TOOL_NAME, + COMPARE_LENDING_MARKETS_TOOL_NAME, + FIND_LARGE_SWAPS_TOOL_NAME, + RESEARCH_WALLET_TOOL_NAME, + ]); + const tool = listed.tools[2]; + expect(tool?.title).toBe("Find large Base WETH/USDC swaps"); + expect(tool?.description).toContain("no USD conversion"); + expect(tool?.annotations?.readOnlyHint).toBe(true); + const inputProperties = tool?.inputSchema.properties as + Record | undefined; + expect(inputProperties?.chain_id?.description).toContain("8453"); + expect(inputProperties?.min_amount?.description).toContain("never USD"); + expect(inputProperties?.cursor?.description).toContain("next_cursor"); + expect(tool?.outputSchema).toMatchObject({ type: "object" }); + } finally { + await client.close(); + await runtime.close(); + } + }); + + it("returns structured complete results and rejects arbitrary SQL fields", async () => { + const gateway = success(); + const { client, runtime } = await withClient(gateway); + try { + const result = await client.callTool({ + name: FIND_LARGE_SWAPS_TOOL_NAME, + arguments: lockedLargeSwapsRequest, + }); + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toMatchObject({ + status: "complete", + data: { swaps: [LSS_WETH_TO_USDC] }, + }); + + const rejected = await client.callTool({ + name: FIND_LARGE_SWAPS_TOOL_NAME, + arguments: { ...lockedLargeSwapsRequest, sql: "SELECT * FROM pool__swap" }, + }); + expect(rejected.isError).toBe(true); + expect(gateway.fetchCandidatesSpy).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + await runtime.close(); + } + }); +}); diff --git a/tests/unit/gateway-config.test.ts b/tests/unit/gateway-config.test.ts new file mode 100644 index 0000000..44ac4c4 --- /dev/null +++ b/tests/unit/gateway-config.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +import { + GATEWAY_DEFAULTS, + GATEWAY_ENV_VARS, + GATEWAY_MAXIMUMS, + loadGatewayConfig, + parseBoundedPositiveInteger, +} from "../../src/config/index.js"; +import { ConfigurationError } from "../../src/errors/index.js"; + +describe("loadGatewayConfig", () => { + it("returns documented defaults when overrides are absent", () => { + expect(loadGatewayConfig({})).toEqual({ + rateLimitMaxRequests: GATEWAY_DEFAULTS.rateLimitMaxRequests, + rateLimitWindowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + sourceTimeoutMs: GATEWAY_DEFAULTS.sourceTimeoutMs, + }); + }); + + it("applies valid overrides", () => { + expect( + loadGatewayConfig({ + [GATEWAY_ENV_VARS.rateLimitMaxRequests]: "10", + [GATEWAY_ENV_VARS.rateLimitWindowMs]: "120000", + [GATEWAY_ENV_VARS.sourceTimeoutMs]: "2500", + }), + ).toEqual({ + rateLimitMaxRequests: 10, + rateLimitWindowMs: 120_000, + sourceTimeoutMs: 2_500, + }); + }); + + it("accepts each practical deployment ceiling", () => { + expect( + loadGatewayConfig({ + [GATEWAY_ENV_VARS.rateLimitMaxRequests]: String(GATEWAY_MAXIMUMS.rateLimitMaxRequests), + [GATEWAY_ENV_VARS.rateLimitWindowMs]: String(GATEWAY_MAXIMUMS.rateLimitWindowMs), + [GATEWAY_ENV_VARS.sourceTimeoutMs]: String(GATEWAY_MAXIMUMS.sourceTimeoutMs), + }), + ).toEqual({ + rateLimitMaxRequests: GATEWAY_MAXIMUMS.rateLimitMaxRequests, + rateLimitWindowMs: GATEWAY_MAXIMUMS.rateLimitWindowMs, + sourceTimeoutMs: GATEWAY_MAXIMUMS.sourceTimeoutMs, + }); + }); + + it("treats blank overrides as defaults", () => { + expect( + loadGatewayConfig({ + [GATEWAY_ENV_VARS.rateLimitMaxRequests]: " ", + [GATEWAY_ENV_VARS.rateLimitWindowMs]: "", + }), + ).toEqual({ + rateLimitMaxRequests: GATEWAY_DEFAULTS.rateLimitMaxRequests, + rateLimitWindowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + sourceTimeoutMs: GATEWAY_DEFAULTS.sourceTimeoutMs, + }); + }); + + it.each([ + ["fractional", "1.5"], + ["NaN", "NaN"], + ["infinite", "Infinity"], + ["negative infinite", "-Infinity"], + ["zero", "0"], + ["negative", "-3"], + ["non-numeric", "abc"], + ["scientific", "1e3"], + ])("rejects invalid configuration for %s input", (_label, raw) => { + expect(() => + loadGatewayConfig({ + [GATEWAY_ENV_VARS.sourceTimeoutMs]: raw, + }), + ).toThrow(ConfigurationError); + }); + + it("reports all invalid variable names without embedding raw values", () => { + const secretMax = "leaked-secret-max-999"; + const secretWindow = "leaked-secret-window-888"; + + let error: unknown; + try { + loadGatewayConfig({ + [GATEWAY_ENV_VARS.rateLimitMaxRequests]: secretMax, + [GATEWAY_ENV_VARS.rateLimitWindowMs]: secretWindow, + [GATEWAY_ENV_VARS.sourceTimeoutMs]: "5000", + GRAPH_API_KEY: "graph-api-key-should-stay-hidden", + NUTHATCH_ADMIN_TOKEN: "admin-token-should-stay-hidden", + }); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ConfigurationError); + const configurationError = error as ConfigurationError; + expect(configurationError.variableNames).toEqual([ + GATEWAY_ENV_VARS.rateLimitMaxRequests, + GATEWAY_ENV_VARS.rateLimitWindowMs, + ]); + expect(configurationError.message).toContain(GATEWAY_ENV_VARS.rateLimitMaxRequests); + expect(configurationError.message).toContain(GATEWAY_ENV_VARS.rateLimitWindowMs); + expect(configurationError.message).not.toContain(secretMax); + expect(configurationError.message).not.toContain(secretWindow); + expect(configurationError.message).not.toContain("graph-api-key-should-stay-hidden"); + expect(configurationError.message).not.toContain("admin-token-should-stay-hidden"); + }); + + it.each([ + [GATEWAY_ENV_VARS.rateLimitMaxRequests, GATEWAY_MAXIMUMS.rateLimitMaxRequests], + [GATEWAY_ENV_VARS.rateLimitWindowMs, GATEWAY_MAXIMUMS.rateLimitWindowMs], + [GATEWAY_ENV_VARS.sourceTimeoutMs, GATEWAY_MAXIMUMS.sourceTimeoutMs], + ])("rejects %s above its practical deployment ceiling", (variableName, maximum) => { + expect(() => + loadGatewayConfig({ + [variableName]: String(maximum + 1), + }), + ).toThrow(ConfigurationError); + }); +}); + +describe("parseBoundedPositiveInteger", () => { + it.each(["1.25", "NaN", "Infinity", "-Infinity"])( + "rejects %s without echoing the raw value", + (raw) => { + let error: unknown; + try { + parseBoundedPositiveInteger(raw, "TEST_VAR"); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(ConfigurationError); + const configurationError = error as ConfigurationError; + expect(configurationError.message).toContain("TEST_VAR"); + expect(configurationError.message).not.toContain(raw); + }, + ); + + it("honors a caller-supplied upper bound", () => { + expect(parseBoundedPositiveInteger("10", "TEST_VAR", 10)).toBe(10); + expect(() => parseBoundedPositiveInteger("11", "TEST_VAR", 10)).toThrow(ConfigurationError); + }); +}); diff --git a/tests/unit/gateway-errors.test.ts b/tests/unit/gateway-errors.test.ts new file mode 100644 index 0000000..bc07ef7 --- /dev/null +++ b/tests/unit/gateway-errors.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { + ApplicationError, + ConfigurationError, + ErrorCode, + RateLimitError, +} from "../../src/errors/index.js"; + +describe("typed application errors", () => { + it("exposes stable codes for configuration and rate-limit failures", () => { + const configurationError = new ConfigurationError(["DEEPTRACE_SOURCE_TIMEOUT_MS"]); + const rateLimitError = new RateLimitError(); + + expect(configurationError).toBeInstanceOf(ApplicationError); + expect(configurationError.code).toBe(ErrorCode.INVALID_CONFIGURATION); + expect(rateLimitError).toBeInstanceOf(ApplicationError); + expect(rateLimitError.code).toBe(ErrorCode.RATE_LIMITED); + }); + + it("formats configuration errors with variable names only", () => { + const secret = "super-secret-value"; + const error = new ConfigurationError(["GRAPH_API_KEY", "NUTHATCH_ADMIN_TOKEN"]); + + expect(error.message).toBe("Invalid configuration for GRAPH_API_KEY, NUTHATCH_ADMIN_TOKEN"); + expect(error.message).not.toContain(secret); + expect(error.variableNames).toEqual(["GRAPH_API_KEY", "NUTHATCH_ADMIN_TOKEN"]); + }); + + it("keeps RateLimitError messages free of configuration secrets", () => { + const secret = "Bearer sk-live-should-never-appear"; + const error = new RateLimitError(); + + expect(error.message).toBe("Rate limit exceeded"); + expect(error.message).not.toContain(secret); + }); + + it("does not accept caller-provided rate-limit messages", () => { + expect(RateLimitError).toHaveLength(0); + expect(new RateLimitError().message).toBe("Rate limit exceeded"); + }); +}); diff --git a/tests/unit/graph-adapter.test.ts b/tests/unit/graph-adapter.test.ts new file mode 100644 index 0000000..38b0f33 --- /dev/null +++ b/tests/unit/graph-adapter.test.ts @@ -0,0 +1,468 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + getActiveComparePoolGraphSources, + type ComparePoolGraphSource, +} from "../../src/registry/index.js"; +import { poolSourceResultSchema } from "../../src/schemas/source-adapter.js"; +import { + fetchComparePoolGraphSource, + TIER_A_METRICS_QUERY, + TIER_A_METRICS_QUERY_ID, + TIER_B_METRICS_QUERY, + TIER_B_METRICS_QUERY_ID, +} from "../../src/sources/graph/index.js"; +import { sumDecimals } from "../../src/sources/graph/decimal.js"; + +const evidenceRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../integration/__evidence__", +); + +const SECONDS_PER_DAY = 86_400; + +interface TierACapture { + readonly request: { readonly query: string }; + readonly response: { + readonly data: { + readonly liquidityPool: { readonly id: string }; + readonly liquidityPoolDailySnapshots: { + day: number; + dailyVolumeUSD: string; + dailyTotalRevenueUSD: string; + }[]; + readonly _meta: { deployment: string }; + }; + }; +} + +async function loadTierACapture(sourceId: string): Promise { + return JSON.parse( + await readFile(path.join(evidenceRoot, "m3", sourceId, "01-pool-metrics.json"), "utf8"), + ) as TierACapture; +} + +async function loadTierBData(sourceDir: string): Promise> { + const raw = JSON.parse( + await readFile(path.join(evidenceRoot, "m2", sourceDir, "07-common-metrics.json"), "utf8"), + ) as { response: { data: Record } }; + return raw.response.data; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +/** + * A Tier-B binding is not shipped in any profile today, so it is synthesized + * from the M2 native evidence to keep the second schema tier under test. + */ +const tierBSource: ComparePoolGraphSource = { + profile_id: "test-tier-b-v1", + source_id: "uniswap-v3-base-native", + priority: 1, + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + token0: "0x4200000000000000000000000000000000000006", + token1: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + window_methodology: "completed-utc-days-v1", + query_id: TIER_B_METRICS_QUERY_ID, + schema_contract_id: TIER_B_METRICS_QUERY_ID, + record: { + source_id: "uniswap-v3-base-native", + category: "dex", + protocol: "uniswap-v3", + chain_id: 8453, + source_type: "native_subgraph", + deployment_or_view_id: "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", + schema_version: null, + methodology_version: null, + supported_entities: ["pools", "poolDayDatas", "tokens"], + status: "active", + locator: { + kind: "graph_subgraph", + gateway_host: "gateway.thegraph.com", + subgraph_id: "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + }, + }, +}; + +describe("fetchComparePoolGraphSource on the Messari dex-amm standard", () => { + const [feeHigh, feeLow] = getActiveComparePoolGraphSources(); + + it("ships the captured query text, so replayed evidence matches production", async () => { + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + + expect(capture.request.query).toBe(TIER_A_METRICS_QUERY); + }); + + it("requests eight day snapshots so seven completed days survive the partial day", () => { + // The newest snapshot row can be the in-progress UTC day, which + // aggregation discards. Fetching seven would leave six completed days and + // make the 7d window permanently null in production. + expect(TIER_A_METRICS_QUERY).toContain("first: 8"); + }); + + it("maps 0.3%-tier live evidence into an ok PoolSourceResult with exact 7d sums", async () => { + expect(feeHigh).toBeDefined(); + const { response } = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const snapshots = response.data.liquidityPoolDailySnapshots; + // Make the newest captured day the in-progress day so seven complete. + const nowSeconds = snapshots[0]!.day * SECONDS_PER_DAY + 3_600; + const completed = snapshots.slice(1); + + let observedUrl = ""; + let observedAuthorization = ""; + const fetchImpl: typeof fetch = (input, init) => { + observedUrl = + input instanceof Request ? input.url : input instanceof URL ? input.href : String(input); + observedAuthorization = new Headers(init?.headers).get("authorization") ?? ""; + return Promise.resolve(jsonResponse(response)); + }; + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "test-credential-must-not-leak", + fetchImpl, + nowSeconds, + }); + + expect(poolSourceResultSchema.parse(result).status).toBe("ok"); + if (result.status !== "ok") { + return; + } + + expect(result.source_id).toBe("messari-uniswap-v3-base-fee030"); + expect(result.source_type).toBe("standardized_subgraph"); + expect(result.protocol).toBe("uniswap-v3"); + expect(result.data.pool_address).toBe("0x6c561b446416e1a00e8e93e221854d6ea4171372"); + expect(result.data.fee_tier_bps).toBe(30); + expect(result.data.tvl_usd).toBe("114861166.2464289945430831254042441"); + expect(result.data.token0).toEqual({ + address: "0x4200000000000000000000000000000000000006", + symbol: "WETH", + decimals: 18, + }); + expect(result.data.token1.symbol).toBe("USDC"); + expect(result.data.volume_usd_24h).toBe(completed[0]!.dailyVolumeUSD); + expect(result.data.fees_usd_24h).toBe(completed[0]!.dailyTotalRevenueUSD); + expect(result.data.volume_usd_7d).toBe(sumDecimals(completed.map((day) => day.dailyVolumeUSD))); + expect(result.data.fees_usd_7d).toBe( + sumDecimals(completed.map((day) => day.dailyTotalRevenueUSD)), + ); + expect(result.freshness.indexed_block).toBe(49121447); + expect(result.provenance.query_id).toBe(TIER_A_METRICS_QUERY_ID); + expect(result.provenance.schema_version).toBe("4.0.1"); + expect(result.provenance.methodology_version).toBe("1.0.0"); + expect(result.provenance.deployment_or_view_id).toBe( + "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", + ); + expect(observedUrl).toContain("/subgraphs/id/FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS"); + expect(observedUrl).not.toContain("test-credential"); + expect(observedAuthorization).toBe("Bearer test-credential-must-not-leak"); + expect(JSON.stringify(result)).not.toContain("test-credential"); + }); + + it("maps the 0.05% tier from the same deployment and reads fee percentage 0.05 as 5 bps", async () => { + expect(feeLow).toBeDefined(); + const { response } = await loadTierACapture("messari-uniswap-v3-base-fee005"); + const snapshots = response.data.liquidityPoolDailySnapshots; + + const result = await fetchComparePoolGraphSource(feeLow!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse(response)), + nowSeconds: snapshots[0]!.day * SECONDS_PER_DAY + 3_600, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.source_id).toBe("messari-uniswap-v3-base-fee005"); + expect(result.data.pool_address).toBe("0xd0b53d9277642d899df5c87a3966a349a798f224"); + expect(result.data.fee_tier_bps).toBe(5); + }); + + it("converts snapshot day numbers into UTC-midnight day ids", async () => { + const { response } = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const snapshots = response.data.liquidityPoolDailySnapshots; + // One hour past the newest captured day's midnight: that day is still in + // progress, so 24h must fall back to the day before it. + const nowSeconds = snapshots[0]!.day * SECONDS_PER_DAY + 3_600; + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse(response)), + nowSeconds, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.volume_usd_24h).toBe(snapshots[1]!.dailyVolumeUSD); + expect(result.data.volume_usd_24h).not.toBe(snapshots[0]!.dailyVolumeUSD); + }); + + it("reports a null fee tier rather than rounding a sub-basis-point percentage", async () => { + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const response = structuredClone(capture.response) as unknown as { + data: { liquidityPool: { fees: { feePercentage: string; feeType: string }[] } }; + }; + for (const fee of response.data.liquidityPool.fees) { + if (fee.feeType === "FIXED_TRADING_FEE") { + fee.feePercentage = "0.001"; + } + } + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse(response)), + nowSeconds: + capture.response.data.liquidityPoolDailySnapshots[0]!.day * SECONDS_PER_DAY + 3_600, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.fee_tier_bps).toBeNull(); + }); + + it("returns unsupported when the pool is not a two-token pool", async () => { + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const response = structuredClone(capture.response) as unknown as { + data: { liquidityPool: { inputTokens: unknown[] } }; + }; + response.data.liquidityPool.inputTokens.push({ + id: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + symbol: "USDbC", + decimals: 6, + }); + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse(response)), + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/shape validation/i); + }); + + it("locks the dormant Tier-B query revision to the develop rename", () => { + // The newest poolDayDatas row is the in-progress UTC day, which + // aggregation discards. Fetching seven would leave six completed days and + // make the 7d window permanently null in production. + expect(TIER_B_METRICS_QUERY_ID).toBe("m3-tier-b-metrics-v2"); + expect(TIER_B_METRICS_QUERY).toContain("first: 8"); + }); + + it("returns unsupported when pool tokens disagree with the locked pair", async () => { + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const response = structuredClone(capture.response) as unknown as { + data: { liquidityPool: { inputTokens: { id: string; symbol: string; decimals: number }[] } }; + }; + response.data.liquidityPool.inputTokens[1] = { + id: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + symbol: "USDbC", + decimals: 6, + }; + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse(response)), + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/tokens do not match/i); + }); + + it("returns unsupported with retained freshness on deployment mismatch", async () => { + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const response = structuredClone(capture.response) as unknown as { + data: { _meta: { deployment: string } }; + }; + response.data._meta.deployment = "QmWrongDeploymentHashxxxxxxxxxxxxxxxxxxxxxxxxx"; + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse(response)), + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).not.toBeNull(); + expect(result.provenance.deployment_or_view_id).toBe(response.data._meta.deployment); + expect(result.warnings[0]).toMatch(/deployment mismatch/i); + }); + + it("returns unsupported when the pool entity is null", async () => { + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const response = structuredClone(capture.response) as unknown as { + data: { liquidityPool: unknown }; + }; + response.data.liquidityPool = null; + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse(response)), + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).not.toBeNull(); + }); + + it("returns timeout without freshness when the gateway aborts", async () => { + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl, + timeoutMs: 20, + }); + + expect(result.status).toBe("timeout"); + expect(result.data).toBeNull(); + expect(result.freshness).toBeNull(); + expect(result.warnings[0]).toMatch(/timeout/i); + }); + + it("returns error when GRAPH_API_KEY is missing", async () => { + let called = false; + const result = await fetchComparePoolGraphSource(feeHigh!, { + env: {}, + fetchImpl: () => { + called = true; + return Promise.resolve(jsonResponse({ data: {} })); + }, + }); + + expect(called).toBe(false); + expect(result.status).toBe("error"); + expect(result.warnings[0]).toMatch(/GRAPH_API_KEY/); + }); + + it("returns unsupported for an unimplemented query_id without calling the network", async () => { + let called = false; + const result = await fetchComparePoolGraphSource( + { ...feeHigh!, query_id: "other-query-v1" }, + { + apiKey: "key", + fetchImpl: () => { + called = true; + return Promise.resolve(jsonResponse({ data: {} })); + }, + }, + ); + + expect(called).toBe(false); + expect(result.status).toBe("unsupported"); + }); + + it("returns error on GraphQL errors without inventing pool data", async () => { + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ errors: [{ message: "boom" }], data: null })), + }); + + expect(result.status).toBe("error"); + expect(result.data).toBeNull(); + expect(result.freshness).toBeNull(); + }); + + it("returns error on HTTP non-2xx gateway responses", async () => { + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ message: "nope" }, 503)), + }); + + expect(result.status).toBe("error"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/HTTP 503/); + }); + + it("returns error on transport failures", async () => { + const result = await fetchComparePoolGraphSource(feeHigh!, { + apiKey: "key", + fetchImpl: () => Promise.reject(new TypeError("network down")), + }); + + expect(result.status).toBe("error"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/redacted/i); + expect(JSON.stringify(result)).not.toMatch(/network down/); + }); +}); + +describe("fetchComparePoolGraphSource on the native Tier-B schema", () => { + it("still maps native poolDayDatas evidence, including string token decimals", async () => { + const data = await loadTierBData("uniswap-v3-base-native"); + const dayDatas = (data as { poolDayDatas: { date: number; volumeUSD: string }[] }).poolDayDatas; + + const result = await fetchComparePoolGraphSource(tierBSource, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + nowSeconds: dayDatas[0]!.date + 86_400 + 1, + }); + + expect(poolSourceResultSchema.parse(result).status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.source_type).toBe("native_subgraph"); + expect(result.data.fee_tier_bps).toBe(30); + expect(result.data.token0.decimals).toBe(18); + expect(result.data.volume_usd_24h).toBe(dayDatas[0]!.volumeUSD); + }); + + it("reads fee tier 100 as 1 bps on the PancakeSwap capture", async () => { + const data = await loadTierBData("exchange-v3-base"); + const dayDatas = (data as { poolDayDatas: { date: number }[] }).poolDayDatas; + const pancakeSource: ComparePoolGraphSource = { + ...tierBSource, + source_id: "exchange-v3-base", + pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + record: { + ...tierBSource.record, + source_id: "exchange-v3-base", + protocol: "pancakeswap-v3", + deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + locator: { + ...tierBSource.record.locator, + subgraph_id: "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + }, + }, + }; + + const result = await fetchComparePoolGraphSource(pancakeSource, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + nowSeconds: dayDatas[0]!.date + 86_400 + 1, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.fee_tier_bps).toBe(1); + }); + + it("keeps requesting eight native day rows", () => { + expect(TIER_B_METRICS_QUERY).toContain("first: 8"); + }); +}); diff --git a/tests/unit/graph-deployment-assertion.test.ts b/tests/unit/graph-deployment-assertion.test.ts new file mode 100644 index 0000000..ac64d49 --- /dev/null +++ b/tests/unit/graph-deployment-assertion.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + assertDeployment, + deploymentMismatchWarning, +} from "../../src/sources/graph/deployment-assertion.js"; + +describe("assertDeployment", () => { + it("accepts an exact, case-sensitive deployment match", () => { + expect(assertDeployment("QmExpected", "QmExpected")).toEqual({ ok: true }); + }); + + it("returns both hashes on mismatch", () => { + expect(assertDeployment("QmExpected", "QmActual")).toEqual({ + ok: false, + expected: "QmExpected", + actual: "QmActual", + }); + }); + + it("treats an omitted deployment as an empty mismatch", () => { + expect(assertDeployment("QmExpected", "")).toEqual({ + ok: false, + expected: "QmExpected", + actual: "", + }); + }); +}); + +describe("deploymentMismatchWarning", () => { + it("contains both hashes and no unrelated environment value", () => { + const credential = "secret-that-must-not-appear"; + const warning = deploymentMismatchWarning("QmExpected", "QmActual"); + + expect(warning).toMatch(/QmExpected/); + expect(warning).toMatch(/QmActual/); + expect(warning).not.toMatch(new RegExp(credential)); + }); +}); diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts new file mode 100644 index 0000000..00cc9e2 --- /dev/null +++ b/tests/unit/http-transport.test.ts @@ -0,0 +1,891 @@ +import { request as httpRequest } from "node:http"; + +import { describe, expect, it, vi } from "vitest"; + +import { isAuthorized } from "../../src/http/auth.js"; +import { + HTTP_DEFAULTS, + HTTP_ENV_VARS, + HTTP_MAXIMUMS, + MIN_TOKEN_LENGTH, + loadHttpConfig, + type HttpConfig, +} from "../../src/http/config.js"; +import { ConfigurationError } from "../../src/errors/index.js"; +import { + createHttpServer, + listen, + type HttpRuntime, + type HttpServerOptions, +} from "../../src/http/server.js"; + +// Wrap createMcpServer so each spawned McpServer reports its close() through +// closeSpy. This lets the lifecycle tests assert disposal without measuring +// memory, while keeping the real server wiring (tool registration, connect). +const closeSpy = vi.hoisted(() => vi.fn()); +// Toggle to make openSession() fail at createMcpServer() for the reservation +// leak regression test. Defaults to false so other tests are unaffected. +const openSessionFailure = vi.hoisted(() => ({ enabled: false })); + +vi.mock("../../src/mcp/server.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createMcpServer: (...args: Parameters) => { + if (openSessionFailure.enabled) { + throw new Error("openSession forced failure"); + } + const server = actual.createMcpServer(...args); + const originalClose = server.close.bind(server); + server.close = async () => { + closeSpy(); + await originalClose(); + }; + return server; + }, + }; +}); + +const VALID_TOKEN = "a".repeat(MIN_TOKEN_LENGTH); + +describe("loadHttpConfig", () => { + it("returns documented defaults when only the shared token is supplied", () => { + expect(loadHttpConfig({ [HTTP_ENV_VARS.sharedToken]: VALID_TOKEN })).toEqual({ + host: HTTP_DEFAULTS.host, + port: HTTP_DEFAULTS.port, + sessionIdleTimeoutMs: HTTP_DEFAULTS.sessionIdleTimeoutMs, + sessionSweepIntervalMs: HTTP_DEFAULTS.sessionSweepIntervalMs, + sharedToken: VALID_TOKEN, + }); + }); + + it("applies valid overrides", () => { + expect( + loadHttpConfig({ + [HTTP_ENV_VARS.host]: "0.0.0.0", + [HTTP_ENV_VARS.port]: "9000", + [HTTP_ENV_VARS.sessionIdleTimeoutMs]: "120000", + [HTTP_ENV_VARS.sessionSweepIntervalMs]: "30000", + [HTTP_ENV_VARS.sharedToken]: VALID_TOKEN, + }), + ).toEqual({ + host: "0.0.0.0", + port: 9_000, + sessionIdleTimeoutMs: 120_000, + sessionSweepIntervalMs: 30_000, + sharedToken: VALID_TOKEN, + }); + }); + + it("accepts the highest bindable port", () => { + expect( + loadHttpConfig({ + [HTTP_ENV_VARS.port]: String(HTTP_MAXIMUMS.port), + [HTTP_ENV_VARS.sharedToken]: VALID_TOKEN, + }).port, + ).toBe(HTTP_MAXIMUMS.port); + }); + + it("treats blank overrides as absent", () => { + expect( + loadHttpConfig({ + [HTTP_ENV_VARS.host]: " ", + [HTTP_ENV_VARS.port]: " ", + [HTTP_ENV_VARS.sessionIdleTimeoutMs]: " ", + [HTTP_ENV_VARS.sessionSweepIntervalMs]: "", + [HTTP_ENV_VARS.sharedToken]: VALID_TOKEN, + }), + ).toEqual({ + host: HTTP_DEFAULTS.host, + port: HTTP_DEFAULTS.port, + sessionIdleTimeoutMs: HTTP_DEFAULTS.sessionIdleTimeoutMs, + sessionSweepIntervalMs: HTTP_DEFAULTS.sessionSweepIntervalMs, + sharedToken: VALID_TOKEN, + }); + }); + + // Retiring the shared token is an operational step, not a code change: the + // operator removes the variable and issued tokens carry every client. + it("starts with the shared token retired", () => { + expect(loadHttpConfig({}).sharedToken).toBeUndefined(); + }); + + it("treats a blank shared token as retired", () => { + expect(loadHttpConfig({ [HTTP_ENV_VARS.sharedToken]: " " }).sharedToken).toBeUndefined(); + }); + + it("rejects a shared token shorter than the minimum length", () => { + expect(() => + loadHttpConfig({ [HTTP_ENV_VARS.sharedToken]: "a".repeat(MIN_TOKEN_LENGTH - 1) }), + ).toThrow(ConfigurationError); + }); + + it("rejects invalid session cleanup timer overrides", () => { + try { + loadHttpConfig({ + [HTTP_ENV_VARS.sessionIdleTimeoutMs]: "0", + [HTTP_ENV_VARS.sessionSweepIntervalMs]: String(HTTP_MAXIMUMS.sessionSweepIntervalMs + 1), + [HTTP_ENV_VARS.sharedToken]: VALID_TOKEN, + }); + expect.unreachable("expected ConfigurationError"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).variableNames).toEqual([ + HTTP_ENV_VARS.sessionIdleTimeoutMs, + HTTP_ENV_VARS.sessionSweepIntervalMs, + ]); + } + }); + + it("names every invalid variable", () => { + try { + loadHttpConfig({ + [HTTP_ENV_VARS.port]: "0", + [HTTP_ENV_VARS.sharedToken]: "short", + }); + expect.unreachable("expected ConfigurationError"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).variableNames).toEqual([ + HTTP_ENV_VARS.port, + HTTP_ENV_VARS.sharedToken, + ]); + } + }); +}); + +describe("isAuthorized", () => { + it("accepts the exact bearer token", () => { + expect(isAuthorized(`Bearer ${VALID_TOKEN}`, VALID_TOKEN)).toBe(true); + }); + + it("rejects a missing header", () => { + expect(isAuthorized(undefined, VALID_TOKEN)).toBe(false); + }); + + it("rejects a token that differs only in the final byte", () => { + expect(isAuthorized(`Bearer ${"a".repeat(MIN_TOKEN_LENGTH - 1)}b`, VALID_TOKEN)).toBe(false); + }); + + it("rejects a token of a different length", () => { + expect(isAuthorized(`Bearer ${VALID_TOKEN}extra`, VALID_TOKEN)).toBe(false); + }); + + it("rejects a non-bearer scheme carrying the right secret", () => { + expect(isAuthorized(`Basic ${VALID_TOKEN}`, VALID_TOKEN)).toBe(false); + }); + + it("rejects a bare token with no scheme", () => { + expect(isAuthorized(VALID_TOKEN, VALID_TOKEN)).toBe(false); + }); + + it("refuses the retired shared token once it is removed", () => { + expect(isAuthorized(`Bearer ${VALID_TOKEN}`, undefined)).toBe(false); + }); + + it("still admits an issued token with the shared one retired", () => { + const issued = { verify: (token: string) => token === "dt_issued" }; + + expect(isAuthorized("Bearer dt_issued", undefined, issued)).toBe(true); + expect(isAuthorized("Bearer dt_other", undefined, issued)).toBe(false); + }); +}); + +const VALID_TOKEN_32 = "a".repeat(MIN_TOKEN_LENGTH); + +const INITIALIZE_BODY = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "deeptrace-test", version: "0.0.0" }, + }, +} as const; + +interface TestServer { + readonly runtime: HttpRuntime; + readonly base: string; + readonly legacy: string; +} + +interface TestServerOptions { + readonly config?: Partial; + readonly server?: HttpServerOptions; +} + +async function startTestServer(options: TestServerOptions = {}): Promise { + const config: HttpConfig = { + ...HTTP_DEFAULTS, + host: "127.0.0.1", + port: 0, + sharedToken: VALID_TOKEN_32, + ...options.config, + }; + const runtime = createHttpServer(config, options.server); + await listen(runtime, config); + const address = runtime.server.address(); + if (address === null || typeof address === "string") { + throw new Error("test server did not bind to a port"); + } + const origin = `http://127.0.0.1:${address.port}`; + return { runtime, base: `${origin}/`, legacy: `${origin}/mcp` }; +} + +async function postInitialize( + base: string, + options: { readonly accept: string }, +): Promise { + return fetch(base, { + method: "POST", + headers: { + authorization: `Bearer ${VALID_TOKEN_32}`, + "content-type": "application/json", + accept: options.accept, + }, + body: JSON.stringify(INITIALIZE_BODY), + }); +} + +async function postInitialized(base: string, sessionId: string): Promise { + return fetch(base, { + method: "POST", + headers: { + authorization: `Bearer ${VALID_TOKEN_32}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }), + }); +} + +function requireSessionId(response: Response): string { + const sessionId = response.headers.get("mcp-session-id"); + if (sessionId === null) { + throw new Error("expected mcp-session-id response header"); + } + return sessionId; +} + +function createFakeSessionTimer(): { + readonly options: HttpServerOptions; + readonly cancel: ReturnType; + readonly schedule: ReturnType; + advance(ms: number): void; + sweep(): Promise; +} { + let nowMs = 0; + let sweepCallback: (() => Promise) | undefined; + const cancel = vi.fn(); + const schedule = vi.fn((callback: () => Promise) => { + sweepCallback = callback; + return cancel; + }); + + return { + options: { + now: () => nowMs, + scheduleSessionSweep: schedule, + }, + cancel, + schedule, + advance(ms: number) { + nowMs += ms; + }, + async sweep() { + if (sweepCallback === undefined) { + throw new Error("session sweep was not scheduled"); + } + await sweepCallback(); + }, + }; +} + +async function getBrowserNavigation(base: string): Promise<{ + readonly status: number | undefined; + readonly challenge: string | undefined; + readonly headers: Readonly>; + readonly body: string; +}> { + return new Promise((resolve, reject) => { + const request = httpRequest( + base, + { + headers: { + accept: "text/html,application/xhtml+xml", + "sec-fetch-mode": "navigate", + }, + }, + (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => { + resolve({ + status: response.statusCode, + challenge: response.headers["www-authenticate"], + headers: response.headers, + body, + }); + }); + }, + ); + request.on("error", reject); + request.end(); + }); +} + +describe("HTTP routing", () => { + it("keeps /mcp as a compatibility alias", async () => { + const { runtime, legacy } = await startTestServer(); + try { + const response = await postInitialize(legacy, { + accept: "application/json, text/event-stream", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).not.toBeNull(); + await response.text(); + } finally { + await runtime.close(); + } + }); + + it("rejects unknown paths", async () => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(new URL("unknown", base), { + headers: { + authorization: `Bearer ${VALID_TOKEN_32}`, + }, + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toEqual({ + error: { + code: "not_found", + message: "Unknown endpoint", + }, + }); + } finally { + await runtime.close(); + } + }); + + it("serves an unauthenticated connection page only at the canonical root", async () => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(base, { + headers: { accept: "text/html" }, + }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("www-authenticate")).toBeNull(); + expect(body).toContain("Compare Base liquidity pools"); + expect(body).toContain("Claude Code"); + expect(body).toContain("OpenCode"); + expect(body).toContain("Codex"); + expect(body).toContain("compare_pools"); + expect(body).toContain("compare_lending_markets"); + expect(body).toContain("find_large_swaps"); + expect(body).toContain("research_wallet"); + expect(body).toContain("no Tailscale"); + expect(body).toContain( + "npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research", + ); + expect(body).toContain("Installing the skill does not configure MCP or store your token"); + expect(body).toContain("Connecting MCP does not automatically install or load the skill"); + expect(body).not.toMatch(/ would be an off-origin dependency on an authenticated origin. + for (const tag of body.match(/]*>/gu) ?? []) { + expect(tag).toMatch(/rel="preload"[^>]*href="\/assets\/[a-z-]+\.woff2"/u); + } + } finally { + await runtime.close(); + } + }); + + it("serves the connection page typefaces without authentication", async () => { + const { runtime, base } = await startTestServer(); + try { + const origin = base.endsWith("/") ? base.slice(0, -1) : base; + for (const name of ["text.woff2", "text-italic.woff2", "mono.woff2"]) { + const response = await fetch(`${origin}/assets/${name}`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("font/woff2"); + expect(response.headers.get("cache-control")).toContain("immutable"); + expect((await response.arrayBuffer()).byteLength).toBeGreaterThan(0); + } + } finally { + await runtime.close(); + } + }); + + it("rejects asset paths that are not one of the known typefaces", async () => { + const { runtime, base } = await startTestServer(); + try { + const origin = base.endsWith("/") ? base.slice(0, -1) : base; + for (const path of ["/assets/evil.woff2", "/assets/../index.html", "/assets/"]) { + const response = await fetch(`${origin}${path}`); + expect(response.status).toBe(404); + await response.text(); + } + } finally { + await runtime.close(); + } + }); + + it("sets restrictive browser security headers on the connection page", async () => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(base, { + headers: { accept: "text/html" }, + }); + + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-security-policy")).toContain("default-src 'none'"); + expect(response.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); + expect(response.headers.get("cross-origin-opener-policy")).toBe("same-origin"); + expect(response.headers.get("cross-origin-resource-policy")).toBe("same-origin"); + expect(response.headers.get("permissions-policy")).toContain("camera=()"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + expect(response.headers.get("vary")).toBe("Accept"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("x-frame-options")).toBe("DENY"); + await response.text(); + } finally { + await runtime.close(); + } + }); + + it("does not serve HTML when the client explicitly rejects it", async () => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(base, { + headers: { accept: "text/html;q=0" }, + }); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('Bearer realm="deeptrace"'); + await response.text(); + } finally { + await runtime.close(); + } + }); +}); + +describe("HTTP authentication", () => { + it("keeps /mcp browser navigation as a challenge-free JSON 404", async () => { + const { runtime, legacy } = await startTestServer(); + try { + const response = await getBrowserNavigation(legacy); + + expect(response.status).toBe(404); + expect(response.challenge).toBeUndefined(); + expect(JSON.parse(response.body)).toEqual({ + error: { + code: "not_found", + message: "This endpoint is available to MCP clients", + }, + }); + } finally { + await runtime.close(); + } + }); + + it("retains the Bearer challenge for unauthorized MCP requests", async () => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(base, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify(INITIALIZE_BODY), + }); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('Bearer realm="deeptrace"'); + await expect(response.json()).resolves.toEqual({ + error: { + code: "unauthorized", + message: "Missing or invalid bearer token", + }, + }); + } finally { + await runtime.close(); + } + }); + + it("accepts the canonical public Origin", async () => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(base, { + method: "POST", + headers: { + authorization: `Bearer ${VALID_TOKEN_32}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + origin: "https://mcp.ikodo.dev", + }, + body: JSON.stringify(INITIALIZE_BODY), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).not.toBeNull(); + await response.text(); + } finally { + await runtime.close(); + } + }); + + it.each([ + ["POST", "/"], + ["GET", "/"], + ["GET", "/unknown"], + ] as const)("rejects an untrusted Origin on %s %s", async (method, path) => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(new URL(path, base), { + method, + headers: { + authorization: `Bearer ${VALID_TOKEN_32}`, + accept: method === "POST" ? "application/json, text/event-stream" : "text/html", + ...(method === "POST" ? { "content-type": "application/json" } : {}), + origin: "https://attacker.example", + }, + ...(method === "POST" ? { body: JSON.stringify(INITIALIZE_BODY) } : {}), + }); + + expect(response.status).toBe(403); + expect(response.headers.get("www-authenticate")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + error: { + code: "invalid_origin", + message: "Origin is not allowed", + }, + }); + } finally { + await runtime.close(); + } + }); + + it.each(["GET", "DELETE"] as const)( + "does not treat an unauthorized %s event-stream request as a browser visit", + async (method) => { + const { runtime, base } = await startTestServer(); + try { + const response = await fetch(base, { + method, + headers: { + accept: "text/html, text/event-stream", + }, + }); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain('Bearer realm="deeptrace"'); + await expect(response.json()).resolves.toEqual({ + error: { + code: "unauthorized", + message: "Missing or invalid bearer token", + }, + }); + } finally { + await runtime.close(); + } + }, + ); +}); + +describe("HTTP session lifecycle", () => { + it("disposes the McpServer/transport when initialize is rejected (406)", async () => { + const { runtime, base } = await startTestServer(); + closeSpy.mockClear(); + try { + const response = await postInitialize(base, { + accept: "application/json", + }); + + expect(response.status).toBe(406); + await vi.waitFor(() => { + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + } finally { + await runtime.close(); + } + }); + + it("does not accumulate sessions across repeated failed initializes", async () => { + const { runtime, base } = await startTestServer(); + closeSpy.mockClear(); + try { + for (let i = 0; i < 3; i += 1) { + const response = await postInitialize(base, { accept: "application/json" }); + expect(response.status).toBe(406); + } + + await vi.waitFor(() => { + expect(closeSpy).toHaveBeenCalledTimes(3); + }); + + // A subsequent valid initialize must still succeed and register exactly + // one session, proving the failed opens left nothing behind. + const valid = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(valid.status).toBe(200); + expect(valid.headers.get("mcp-session-id")).not.toBeNull(); + // The valid session stays open: close count must not have grown. + expect(closeSpy).toHaveBeenCalledTimes(3); + } finally { + await runtime.close(); + } + }); + + it("refreshes activity and disposes a session only after a full idle timeout", async () => { + const timer = createFakeSessionTimer(); + const { runtime, base } = await startTestServer({ + config: { + sessionIdleTimeoutMs: 1_000, + sessionSweepIntervalMs: 100, + }, + server: timer.options, + }); + closeSpy.mockClear(); + try { + expect(timer.schedule).toHaveBeenCalledWith(expect.any(Function), 100); + const initialized = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(initialized.status).toBe(200); + const sessionId = requireSessionId(initialized); + await initialized.text(); + + timer.advance(999); + const activity = await postInitialized(base, sessionId); + expect(activity.status).toBe(202); + await activity.text(); + + timer.advance(999); + await timer.sweep(); + expect(closeSpy).not.toHaveBeenCalled(); + + timer.advance(1); + await timer.sweep(); + expect(closeSpy).toHaveBeenCalledTimes(1); + } finally { + await runtime.close(); + } + expect(timer.cancel).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("expires an otherwise-idle session with an open SSE stream", async () => { + const timer = createFakeSessionTimer(); + const { runtime, base } = await startTestServer({ + config: { + sessionIdleTimeoutMs: 1_000, + sessionSweepIntervalMs: 100, + }, + server: timer.options, + }); + closeSpy.mockClear(); + try { + const initialized = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + const sessionId = requireSessionId(initialized); + await initialized.text(); + + const stream = await fetch(base, { + headers: { + authorization: `Bearer ${VALID_TOKEN_32}`, + accept: "text/event-stream", + "mcp-session-id": sessionId, + }, + }); + expect(stream.status).toBe(200); + + timer.advance(1_000); + await timer.sweep(); + expect(closeSpy).toHaveBeenCalledTimes(1); + await stream.text(); + } finally { + await runtime.close(); + } + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("releases all session-limit slots after idle expiration", async () => { + const timer = createFakeSessionTimer(); + const { runtime, base } = await startTestServer({ + config: { + sessionIdleTimeoutMs: 1_000, + sessionSweepIntervalMs: 100, + }, + server: timer.options, + }); + closeSpy.mockClear(); + try { + for (let i = 0; i < 64; i += 1) { + const response = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(response.status).toBe(200); + await response.text(); + } + + timer.advance(1_000); + await timer.sweep(); + expect(closeSpy).toHaveBeenCalledTimes(64); + + const replacement = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(replacement.status).toBe(200); + expect(replacement.headers.get("mcp-session-id")).not.toBeNull(); + await replacement.text(); + } finally { + await runtime.close(); + } + expect(closeSpy).toHaveBeenCalledTimes(65); + }); + + it("does not close a session twice when DELETE and the sweep converge", async () => { + const timer = createFakeSessionTimer(); + const { runtime, base } = await startTestServer({ + config: { + sessionIdleTimeoutMs: 1_000, + sessionSweepIntervalMs: 100, + }, + server: timer.options, + }); + closeSpy.mockClear(); + try { + const initialized = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + const sessionId = requireSessionId(initialized); + await initialized.text(); + + const deleted = await fetch(base, { + method: "DELETE", + headers: { + authorization: `Bearer ${VALID_TOKEN_32}`, + "mcp-session-id": sessionId, + }, + }); + expect(deleted.status).toBe(200); + await deleted.text(); + await vi.waitFor(() => { + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + timer.advance(1_000); + await timer.sweep(); + expect(closeSpy).toHaveBeenCalledTimes(1); + } finally { + await runtime.close(); + } + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("cancels the sweep and closes each session once during shutdown", async () => { + const timer = createFakeSessionTimer(); + const { runtime, base } = await startTestServer({ + config: { + sessionIdleTimeoutMs: 1_000, + sessionSweepIntervalMs: 100, + }, + server: timer.options, + }); + closeSpy.mockClear(); + + const initialized = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(initialized.status).toBe(200); + await initialized.text(); + + await runtime.close(); + expect(timer.cancel).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + + timer.advance(1_000); + await timer.sweep(); + await runtime.close(); + expect(timer.cancel).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it("rejects with 503 once MAX_SESSIONS live sessions exist", async () => { + const { runtime, base } = await startTestServer(); + closeSpy.mockClear(); + try { + // Open MAX_SESSIONS successful sessions. Each initialize registers one + // session and leaves the SSE response stream open; reading the body to + // completion lets the server finalize each request. + for (let i = 0; i < 64; i += 1) { + const response = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).not.toBeNull(); + await response.text(); + } + + const overflow = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(overflow.status).toBe(503); + // No new McpServer should have been created for the rejected request. + expect(closeSpy).not.toHaveBeenCalled(); + } finally { + await runtime.close(); + } + }); + + it("releases the session reservation when openSession throws", async () => { + // Regression: pendingOpens += 1 used to sit outside the try, so a failing + // openSession() left the counter incremented forever. After MAX_SESSIONS + // such failures the 503 guard would be permanently true with zero live + // sessions. The fix decrements in a finally that wraps openSession too. + const { runtime, base } = await startTestServer(); + closeSpy.mockClear(); + openSessionFailure.enabled = true; + try { + for (let i = 0; i < 64; i += 1) { + const response = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(response.status).toBe(500); + } + // No session was ever created, so close() must never have run. + expect(closeSpy).not.toHaveBeenCalled(); + + // Re-enable the happy path: a fresh initialize must still succeed, + // proving every failed reservation was returned to the pool. + openSessionFailure.enabled = false; + const valid = await postInitialize(base, { + accept: "application/json, text/event-stream", + }); + expect(valid.status).toBe(200); + expect(valid.headers.get("mcp-session-id")).not.toBeNull(); + } finally { + openSessionFailure.enabled = false; + await runtime.close(); + } + }); +}); diff --git a/tests/unit/large-swaps-adapter.test.ts b/tests/unit/large-swaps-adapter.test.ts new file mode 100644 index 0000000..932c85b --- /dev/null +++ b/tests/unit/large-swaps-adapter.test.ts @@ -0,0 +1,388 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it, vi } from "vitest"; + +import { normalizeLssSwapEvent } from "../../src/normalization/index.js"; +import { getSourceById } from "../../src/registry/index.js"; +import type { NuthatchSourceRegistryRecord } from "../../src/registry/types.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; +import { + fetchNuthatchLargeSwapCandidates, + largeSwapSourceProvenance, +} from "../../src/sources/nuthatch/large-swaps-adapter.js"; +import { + buildLargeSwapHeadQuery, + buildLargeSwapScanQuery, +} from "../../src/sources/nuthatch/large-swaps-query.js"; +import { + parseLargeSwapReady, + parseLargeSwapReceipt, + type NuthatchLargeSwapRow, +} from "../../src/sources/nuthatch/large-swaps-response.js"; +import type { NuthatchClient, NuthatchHttpResult } from "../../src/sources/nuthatch/client.js"; +import type { LargeSwapSourceGateway } from "../../src/tools/index.js"; +import { executeFindLargeSwaps, inspectLargeSwapQuery } from "../../src/tools/index.js"; +import { lockedLargeSwapsRequest } from "../fixtures/large-swaps-request.js"; + +const REGISTRY_HASH = "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25"; +const HEAD_TIMESTAMP = 1_784_678_093; + +function record(): NuthatchSourceRegistryRecord { + const value = getSourceById(LSS_SCOPE.source.sourceId); + if (value?.source_type !== "nuthatch_view") { + throw new Error("missing LSS Nuthatch registry record"); + } + return value; +} + +function row( + nibble: string, + blockNumber: number, + logIndex: number, + amount0Raw = "-6123995143586742520", + amount1Raw = "11804868893", +): NuthatchLargeSwapRow { + return { + pool_address: LSS_SCOPE.poolAddress, + block_number: blockNumber, + block_hash: `0x${nibble.repeat(64)}`, + block_timestamp: HEAD_TIMESTAMP - (48_944_373 - blockNumber) * 2, + transaction_hash: `0x${nibble.repeat(64)}`, + log_index: logIndex, + amount0_raw: amount0Raw, + amount1_raw: amount1Raw, + }; +} + +function receipt(rows: readonly NuthatchLargeSwapRow[], asOf = 48_944_374) { + return { + count: rows.length, + provenance: { + as_of: asOf, + registry_hash: REGISTRY_HASH, + sealed_through: asOf, + source: "hot+sealed", + }, + rows, + truncated: false, + }; +} + +function ok(body: unknown): NuthatchHttpResult { + return { ok: true, status: 200, body, latencyMs: 1 }; +} + +function clientFor(rows: readonly NuthatchLargeSwapRow[]) { + const head = rows[0] ?? row("a", 48_944_373, 1); + const sqlQueries: string[] = []; + const client: NuthatchClient = { + health: vi.fn(() => Promise.resolve(ok({ status: "ok" }))), + ready: vi.fn(() => + Promise.resolve(ok({ ready: true, sealed_through: 48_944_374, tip: 48_944_374 })), + ), + nest: vi.fn(() => Promise.resolve(ok({ registry_hash: REGISTRY_HASH, name: "deeptrace" }))), + schema: vi.fn(() => Promise.resolve(ok("Nuthatch schema"))), + explain: vi.fn(() => Promise.resolve(ok({ valid: true }))), + sql: vi.fn((query: string, maxRows: number) => { + sqlQueries.push(query); + if (sqlQueries.length === 1) { + return Promise.resolve(ok(receipt([head]))); + } + + const snapshotMatch = /WHERE block_number <= (\d+)/.exec(query); + const snapshotHead = snapshotMatch === null ? -1 : Number(snapshotMatch[1]); + const candidates = rows.filter(({ block_number }) => block_number <= snapshotHead); + const anchorMatch = /tx_hash (>=|>) '(0x[0-9a-f]{64})'/.exec(query); + if (anchorMatch === null) { + return Promise.resolve(ok(receipt(candidates.slice(0, maxRows)))); + } + + const [, operator, transactionHash] = anchorMatch; + const anchorIndex = candidates.findIndex( + ({ transaction_hash }) => transaction_hash === transactionHash, + ); + const start = anchorIndex < 0 ? candidates.length : anchorIndex + (operator === ">" ? 1 : 0); + return Promise.resolve(ok(receipt(candidates.slice(start, start + maxRows)))); + }), + }; + return { client, sqlQueries }; +} + +describe("Nuthatch large-swap query boundary", () => { + it("builds deterministic keyset SQL from validated coordinates only", () => { + const query = buildLargeSwapScanQuery({ + snapshotHead: 100, + after: { + block_number: 99, + log_index: 7, + transaction_hash: `0x${"a".repeat(64)}`, + }, + includeAfter: false, + limit: 25, + }); + + expect(query).toContain("FROM pool__swap"); + expect(query).toContain("block_number <= 100"); + expect(query).toContain("block_number < 99"); + expect(query).toContain(`tx_hash > '0x${"a".repeat(64)}'`); + expect(query).not.toContain("CAST("); + expect(query).toContain("LIMIT 25"); + expect(query).not.toContain(lockedLargeSwapsRequest.min_amount); + expect(query).not.toContain(LSS_SCOPE.tokens.weth.address); + }); + + it("rejects unsafe coordinates instead of interpolating them", () => { + expect(() => buildLargeSwapHeadQuery(Number.MAX_SAFE_INTEGER + 1)).toThrow(); + expect(() => + buildLargeSwapScanQuery({ + snapshotHead: 100, + after: { + block_number: 99, + log_index: 7, + transaction_hash: "0x' OR 1=1 --", + }, + includeAfter: false, + limit: 25, + }), + ).toThrow(); + }); + + it("rejects truncated, mismatched, and out-of-snapshot receipts", () => { + const validRows = [row("a", 48_944_373, 8)]; + expect( + parseLargeSwapReceipt({ ...receipt(validRows), truncated: true }, REGISTRY_HASH), + ).toBeNull(); + expect(parseLargeSwapReceipt({ ...receipt(validRows), count: 2 }, REGISTRY_HASH)).toBeNull(); + expect(parseLargeSwapReceipt(receipt(validRows, 48_944_372), REGISTRY_HASH)).toBeNull(); + expect(parseLargeSwapReceipt(receipt(validRows), `0x${"f".repeat(64)}`)).toBeNull(); + expect( + parseLargeSwapReceipt( + receipt([{ ...validRows[0]!, amount0_raw: "1".repeat(80) }]), + REGISTRY_HASH, + ), + ).toBeNull(); + }); +}); + +describe("Nuthatch large-swap adapter", () => { + it("returns exact threshold candidates and fixed source provenance", async () => { + const rows = [ + row("a", 48_944_373, 8), + row("b", 48_944_372, 7, "-500000000000000000", "900000000"), + ]; + const { client, sqlQueries } = clientFor(rows); + const result = await fetchNuthatchLargeSwapCandidates( + { client, clock: () => HEAD_TIMESTAMP + 60, record: record() }, + inspectLargeSwapQuery({ + ...lockedLargeSwapsRequest, + threshold_token: LSS_SCOPE.tokens.usdc.address, + min_amount: "10000", + limit: 1, + }), + ); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + throw new Error("expected source success"); + } + expect(result.events).toHaveLength(1); + expect(result.events[0]?.amount_in).toBe("11804.868893"); + expect(result.indexedHead).toBe(48_944_373); + expect(result.provenance).toEqual(largeSwapSourceProvenance(record())); + expect(result.freshness).toMatchObject({ + status: "fresh", + indexed_block: 48_944_373, + indexed_block_hash: `0x${"a".repeat(64)}`, + }); + expect(sqlQueries).toHaveLength(2); + expect(sqlQueries.every((query) => !query.includes("10000"))).toBe(true); + }); + + it("returns a successful empty candidate window when no event meets the threshold", async () => { + const rows = [row("a", 48_944_373, 8, "-500000000000000000", "900000000")]; + const { client } = clientFor(rows); + const result = await fetchNuthatchLargeSwapCandidates( + { client, clock: () => HEAD_TIMESTAMP + 60, record: record() }, + inspectLargeSwapQuery({ + ...lockedLargeSwapsRequest, + threshold_token: LSS_SCOPE.tokens.usdc.address, + min_amount: "10000", + }), + ); + + expect(result.status).toBe("ok"); + expect(result.events).toEqual([]); + }); + + it("contains unsupported receipts and stale heads as source failures", async () => { + const { client } = clientFor([row("a", 48_944_373, 8)]); + client.nest = vi.fn(() => Promise.resolve(ok({ registry_hash: `0x${"f".repeat(64)}` }))); + const mismatch = await fetchNuthatchLargeSwapCandidates( + { client, clock: () => HEAD_TIMESTAMP + 60, record: record() }, + inspectLargeSwapQuery(lockedLargeSwapsRequest), + ); + expect(mismatch).toMatchObject({ status: "unsupported", events: null }); + + const staleClient = clientFor([row("a", 48_944_373, 8)]).client; + const stale = await fetchNuthatchLargeSwapCandidates( + { + client: staleClient, + clock: () => HEAD_TIMESTAMP + 301, + record: record(), + }, + inspectLargeSwapQuery(lockedLargeSwapsRequest), + ); + expect(stale).toMatchObject({ status: "stale", events: null }); + }); + + it("classifies canonical swap-shape drift as unsupported", async () => { + const { client } = clientFor([row("a", 48_944_373, 8, "500000000000000000", "900000000")]); + const result = await fetchNuthatchLargeSwapCandidates( + { client, clock: () => HEAD_TIMESTAMP + 60, record: record() }, + inspectLargeSwapQuery(lockedLargeSwapsRequest), + ); + + expect(result).toMatchObject({ + status: "unsupported", + events: null, + warnings: ["Large-swap receipt violates canonical swap semantics."], + }); + }); + + it("advances exclusive keysets across sparse internal scan batches", async () => { + const matchIndexes = new Set([10, 300, 600]); + const rows = Array.from({ length: 700 }, (_, index) => { + const hex = index.toString(16).padStart(64, "0"); + return { + ...row( + "a", + 48_944_373 - index, + 700 - index, + matchIndexes.has(index) ? "-2000000000000000000" : "-500000000000000000", + "900000000", + ), + block_hash: `0x${hex}`, + transaction_hash: `0x${hex}`, + }; + }); + const { client, sqlQueries } = clientFor(rows); + const result = await fetchNuthatchLargeSwapCandidates( + { client, clock: () => HEAD_TIMESTAMP + 60, record: record() }, + inspectLargeSwapQuery({ + ...lockedLargeSwapsRequest, + threshold_token: LSS_SCOPE.tokens.weth.address, + min_amount: "1", + limit: 2, + }), + ); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + throw new Error("expected source success"); + } + expect(result.events.map(({ transaction_hash }) => transaction_hash)).toEqual( + [...matchIndexes].map((index) => rows[index]!.transaction_hash), + ); + expect(sqlQueries).toHaveLength(4); + expect(sqlQueries[2]).toContain(`tx_hash > '${rows[255]!.transaction_hash}'`); + expect(sqlQueries[3]).toContain(`tx_hash > '${rows[511]!.transaction_hash}'`); + }); + + it("preserves every event exactly once across live-adapter cursor pages", async () => { + const rows = Array.from({ length: 300 }, (_, index) => { + const hex = index.toString(16).padStart(64, "0"); + return { + ...row("a", 48_944_373 - index, 300 - index), + block_hash: `0x${hex}`, + transaction_hash: `0x${hex}`, + }; + }); + const { client, sqlQueries } = clientFor(rows); + const source: LargeSwapSourceGateway = { + provenance: largeSwapSourceProvenance(record()), + fetchCandidates: (context) => + fetchNuthatchLargeSwapCandidates( + { client, clock: () => HEAD_TIMESTAMP + 60, record: record() }, + context, + ), + }; + const request = { + ...lockedLargeSwapsRequest, + threshold_token: LSS_SCOPE.tokens.weth.address, + min_amount: "1", + limit: 100, + } as const; + + const swaps = []; + const continuationAnchors: string[] = []; + let cursor: string | null = null; + do { + const response = await executeFindLargeSwaps({ ...request, cursor }, source); + expect(response.status).toBe("complete"); + if (response.status !== "complete") { + throw new Error("expected complete page"); + } + swaps.push(...response.data.swaps); + cursor = response.pagination.next_cursor; + if (cursor !== null) { + const last = response.data.swaps.at(-1); + if (last === undefined) { + throw new Error("expected a cursor anchor"); + } + continuationAnchors.push(last.transaction_hash); + } + } while (cursor !== null); + + expect(swaps.map(({ transaction_hash }) => transaction_hash)).toEqual( + rows.map(({ transaction_hash }) => transaction_hash), + ); + expect(new Set(swaps.map(({ transaction_hash }) => transaction_hash)).size).toBe(rows.length); + expect(sqlQueries.some((query) => query.includes("block_number <= 48944373"))).toBe(true); + expect(continuationAnchors.length).toBeGreaterThan(1); + expect( + continuationAnchors.every((anchor) => + sqlQueries.some((query) => query.includes(`tx_hash >= '${anchor}'`)), + ), + ).toBe(true); + }); +}); + +describe("retained Nuthatch receipt parity", () => { + it("maps the retained live row to the committed canonical swap exactly", () => { + const evidence = JSON.parse( + readFileSync( + new URL( + "../integration/__evidence__/lss/nuthatch-large-swaps-receipt.json", + import.meta.url, + ), + "utf8", + ), + ) as { + ready: unknown; + receipt: unknown; + expected_swap: unknown; + }; + expect(parseLargeSwapReady(evidence.ready)).toEqual({ + ready: true, + indexedHead: 48_944_374, + tip: 48_944_374, + }); + const parsed = parseLargeSwapReceipt(evidence.receipt, REGISTRY_HASH); + expect(parsed).not.toBeNull(); + const raw = parsed!.rows[0]!; + expect( + normalizeLssSwapEvent({ + chain_id: LSS_SCOPE.chainId, + protocol: LSS_SCOPE.protocol, + pool: raw.pool_address, + transaction_hash: raw.transaction_hash, + log_index: raw.log_index, + block_number: raw.block_number, + timestamp: raw.block_timestamp, + amount0_raw: raw.amount0_raw, + amount1_raw: raw.amount1_raw, + source_id: LSS_SCOPE.source.sourceId, + }), + ).toEqual(evidence.expected_swap); + }); +}); diff --git a/tests/unit/large-swaps-query.test.ts b/tests/unit/large-swaps-query.test.ts new file mode 100644 index 0000000..3e588b2 --- /dev/null +++ b/tests/unit/large-swaps-query.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "vitest"; + +import { + LargeSwapCursorError, + LargeSwapQueryError, + queryLargeSwapPage, +} from "../../src/tools/index.js"; +import { swapEventIdentity } from "../../src/normalization/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; +import type { SwapEvent } from "../../src/schemas/index.js"; +import { usdcToWethSwapFixture, wethToUsdcSwapFixture } from "../fixtures/large-swaps.js"; +import { + fixtureLargeSwapCursor, + lockedLargeSwapsRequest, +} from "../fixtures/large-swaps-request.js"; + +const CURSOR_PREFIX = `lss:v${String(LSS_SCOPE.cursor.version)}:`; + +function identity(value: SwapEvent): string { + return swapEventIdentity(value.chain_id, value.transaction_hash, value.log_index); +} + +function event( + transactionNibble: string, + blockNumber: number, + logIndex: number, + amountRaw = "2500000000000000000", +): SwapEvent { + return { + ...wethToUsdcSwapFixture, + transaction_hash: `0x${transactionNibble.repeat(64)}`, + block_number: blockNumber, + log_index: logIndex, + amount_in: + amountRaw === "500000000000000000" + ? "0.5" + : amountRaw === "1500000000000000000" + ? "1.5" + : "2.5", + amount_in_raw: amountRaw, + }; +} + +function rewriteCursor( + cursor: string, + mutate: (payload: Record) => Record, +): string { + const encoded = cursor.slice(CURSOR_PREFIX.length); + const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record< + string, + unknown + >; + return `${CURSOR_PREFIX}${Buffer.from(JSON.stringify(mutate(payload)), "utf8").toString( + "base64url", + )}`; +} + +function firstPageCursor(events: readonly SwapEvent[] = [event("a", 105, 4), event("b", 104, 3)]) { + const page = queryLargeSwapPage({ + events, + indexedHead: 105, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1.5", + limit: 1, + }, + }); + expect(page.pagination.next_cursor).not.toBeNull(); + return page.pagination.next_cursor!; +} + +describe("large swap page ordering and thresholds", () => { + it("orders by block desc, log desc, then transaction hash", () => { + const ordered = queryLargeSwapPage({ + events: [event("d", 100, 7), event("a", 100, 5), event("b", 101, 1), event("c", 100, 7)], + indexedHead: 101, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1", + }, + }); + + expect(ordered.swaps.map(identity)).toEqual([ + identity(event("b", 101, 1)), + identity(event("c", 100, 7)), + identity(event("d", 100, 7)), + identity(event("a", 100, 5)), + ]); + }); + + it("deduplicates events and excludes values below the exact threshold", () => { + const exact = event("a", 100, 3, "1500000000000000000"); + const below = event("b", 99, 2, "500000000000000000"); + const page = queryLargeSwapPage({ + events: [exact, { ...exact }, below], + indexedHead: 100, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1.5", + }, + }); + + expect(page.swaps).toEqual([exact]); + expect(page.pagination).toEqual({ + limit: 25, + returned: 1, + has_more: false, + next_cursor: null, + }); + }); + + it("excludes events above the first-page indexed head", () => { + const page = queryLargeSwapPage({ + events: [event("a", 101, 1), event("b", 100, 1)], + indexedHead: 100, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1", + }, + }); + + expect(page.snapshot_head).toBe(100); + expect(page.swaps).toEqual([event("b", 100, 1)]); + }); +}); + +describe("large swap fixed-snapshot pagination", () => { + it("returns stable pages without duplicates, omissions, or newly indexed blocks", () => { + const original = [ + event("a", 105, 4), + event("b", 104, 3), + event("c", 103, 2), + event("d", 102, 1), + ]; + const first = queryLargeSwapPage({ + events: original, + indexedHead: 105, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1", + limit: 2, + }, + }); + + expect(first.swaps).toEqual(original.slice(0, 2)); + expect(first.pagination.has_more).toBe(true); + expect(first.pagination.next_cursor).toMatch(/^lss:v1:[A-Za-z0-9_-]+$/); + + const second = queryLargeSwapPage({ + events: [event("e", 106, 9), ...original], + indexedHead: 106, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1", + limit: 2, + cursor: first.pagination.next_cursor, + }, + }); + + expect(second.snapshot_head).toBe(105); + expect(second.swaps).toEqual(original.slice(2)); + expect(second.pagination).toEqual({ + limit: 2, + returned: 2, + has_more: false, + next_cursor: null, + }); + expect(new Set([...first.swaps, ...second.swaps].map(identity)).size).toBe(4); + }); + + it("rejects a source head behind the frozen snapshot", () => { + const cursor = firstPageCursor(); + expect(() => + queryLargeSwapPage({ + events: [event("a", 105, 4), event("b", 104, 3)], + indexedHead: 104, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1.5", + limit: 1, + cursor, + }, + }), + ).toThrow(LargeSwapCursorError); + }); + + it("rejects a cursor whose anchor is unavailable", () => { + const cursor = firstPageCursor(); + expect(() => + queryLargeSwapPage({ + events: [event("b", 104, 3)], + indexedHead: 105, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1.5", + limit: 1, + cursor, + }, + }), + ).toThrow(LargeSwapCursorError); + }); +}); + +describe("large swap cursor validation", () => { + it("decodes the checked-in v1 continuation fixture", () => { + const page = queryLargeSwapPage({ + events: [usdcToWethSwapFixture], + indexedHead: 20_000_100, + request: { + ...lockedLargeSwapsRequest, + threshold_token: LSS_SCOPE.tokens.usdc.address, + min_amount: "2500", + cursor: fixtureLargeSwapCursor, + }, + }); + + expect(page.snapshot_head).toBe(20_000_100); + expect(page.swaps).toEqual([]); + expect(page.pagination.next_cursor).toBeNull(); + }); + + it("rejects malformed and unsupported-version cursors", () => { + for (const cursor of [ + "not-a-cursor", + "lss:v1:%%%", + "lss:v2:eyJub3QiOiJzdXBwb3J0ZWQifQ", + `${CURSOR_PREFIX}${Buffer.from("{", "utf8").toString("base64url")}`, + ]) { + expect(() => + queryLargeSwapPage({ + events: [], + indexedHead: 105, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1.5", + cursor, + }, + }), + ).toThrow(LargeSwapCursorError); + } + }); + + it("rejects threshold-token and minimum-amount scope mismatches", () => { + const cursor = firstPageCursor(); + + for (const request of [ + { + ...lockedLargeSwapsRequest, + threshold_token: LSS_SCOPE.tokens.usdc.address, + min_amount: "1.5", + cursor, + }, + { + ...lockedLargeSwapsRequest, + min_amount: "1.50", + cursor, + }, + ]) { + expect(() => + queryLargeSwapPage({ + events: [event("a", 105, 4), event("b", 104, 3)], + indexedHead: 105, + request, + }), + ).toThrow(LargeSwapCursorError); + } + }); + + it("rejects tampered chain, pool, and extra cursor fields", () => { + const cursor = firstPageCursor(); + const tampered = [ + rewriteCursor(cursor, (payload) => ({ ...payload, chain_id: 1 })), + rewriteCursor(cursor, (payload) => ({ + ...payload, + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + })), + rewriteCursor(cursor, (payload) => ({ ...payload, injected_sql: "select *" })), + ]; + + for (const value of tampered) { + expect(() => + queryLargeSwapPage({ + events: [event("a", 105, 4), event("b", 104, 3)], + indexedHead: 105, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1.5", + cursor: value, + }, + }), + ).toThrow(LargeSwapCursorError); + } + }); + + it("rejects invalid requests and oversized cursor input before paging", () => { + expect(() => + queryLargeSwapPage({ + events: [], + indexedHead: 105, + request: { + ...lockedLargeSwapsRequest, + min_amount: "0", + }, + }), + ).toThrow(LargeSwapQueryError); + + expect(() => + queryLargeSwapPage({ + events: [], + indexedHead: 105, + request: { + ...lockedLargeSwapsRequest, + min_amount: "1", + cursor: "x".repeat(LSS_SCOPE.cursor.maximumLength + 1), + }, + }), + ).toThrow(LargeSwapQueryError); + }); +}); diff --git a/tests/unit/large-swaps-request.test.ts b/tests/unit/large-swaps-request.test.ts new file mode 100644 index 0000000..7be144c --- /dev/null +++ b/tests/unit/large-swaps-request.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { findLargeSwapsRequestSchema } from "../../src/schemas/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; +import { + lockedLargeSwapsRequest, + lockedLargeSwapsRequestWithOptions, +} from "../fixtures/large-swaps-request.js"; + +describe("find_large_swaps request schema", () => { + it("accepts the locked Base pool request and applies defaults", () => { + expect(findLargeSwapsRequestSchema.parse(lockedLargeSwapsRequest)).toEqual({ + ...lockedLargeSwapsRequest, + limit: 25, + cursor: null, + }); + }); + + it("accepts either pool token and explicit bounded page options", () => { + expect(findLargeSwapsRequestSchema.parse(lockedLargeSwapsRequestWithOptions)).toEqual( + lockedLargeSwapsRequestWithOptions, + ); + }); + + it("rejects unsupported chains, pools, and threshold tokens", () => { + for (const request of [ + { ...lockedLargeSwapsRequest, chain_id: 1 }, + { + ...lockedLargeSwapsRequest, + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + ...lockedLargeSwapsRequest, + threshold_token: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + ...lockedLargeSwapsRequest, + threshold_token: LSS_SCOPE.tokens.weth.address.toUpperCase(), + }, + ]) { + expect(findLargeSwapsRequestSchema.safeParse(request).success).toBe(false); + } + }); + + it("accepts positive exact decimals and rejects zero or malformed amounts", () => { + for (const minAmount of ["0.000001", "1", "1.0", "2500.000001"]) { + expect( + findLargeSwapsRequestSchema.safeParse({ + ...lockedLargeSwapsRequest, + min_amount: minAmount, + }).success, + ).toBe(true); + } + + for (const minAmount of ["0", "0.0", "-1", "+1", "01", "1e3", " 1"]) { + expect( + findLargeSwapsRequestSchema.safeParse({ + ...lockedLargeSwapsRequest, + min_amount: minAmount, + }).success, + ).toBe(false); + } + }); + + it("rejects out-of-bounds limits, blank cursors, and unknown fields", () => { + for (const request of [ + { ...lockedLargeSwapsRequest, limit: 0 }, + { ...lockedLargeSwapsRequest, limit: 101 }, + { ...lockedLargeSwapsRequest, limit: 1.5 }, + { ...lockedLargeSwapsRequest, cursor: "" }, + { ...lockedLargeSwapsRequest, cursor: " cursor " }, + { ...lockedLargeSwapsRequest, cursor: "x".repeat(LSS_SCOPE.cursor.maximumLength + 1) }, + { ...lockedLargeSwapsRequest, sql: "select *" }, + ]) { + expect(findLargeSwapsRequestSchema.safeParse(request).success).toBe(false); + } + }); +}); diff --git a/tests/unit/large-swaps-schema.test.ts b/tests/unit/large-swaps-schema.test.ts new file mode 100644 index 0000000..2b4f981 --- /dev/null +++ b/tests/unit/large-swaps-schema.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { findLargeSwapsResponseSchema, swapEventSchema } from "../../src/schemas/index.js"; +import { + completeLargeSwapsFixture, + duplicateLargeSwapsFixture, + failedSourceLargeSwapsFixture, + validLargeSwapsFixtures, + wethToUsdcSwapFixture, +} from "../fixtures/large-swaps.js"; + +describe("find_large_swaps response schemas", () => { + it("accepts success, empty, paginated, and failed-source fixtures", () => { + for (const fixture of validLargeSwapsFixtures) { + expect(findLargeSwapsResponseSchema.parse(fixture)).toEqual(fixture); + } + }); + + it("rejects duplicate canonical event identities", () => { + expect(findLargeSwapsResponseSchema.safeParse(duplicateLargeSwapsFixture).success).toBe(false); + }); + + it("requires locked identities, exact amounts, and null USD notional", () => { + for (const event of [ + { ...wethToUsdcSwapFixture, transaction_hash: "0xABC" }, + { ...wethToUsdcSwapFixture, amount_in: 2.5 }, + { ...wethToUsdcSwapFixture, amount_in: "2.5e0" }, + { ...wethToUsdcSwapFixture, amount_out_raw: "-01" }, + { ...wethToUsdcSwapFixture, usd_notional: "6250.125" }, + { ...wethToUsdcSwapFixture, debug_row: {} }, + ]) { + expect(swapEventSchema.safeParse(event).success).toBe(false); + } + }); + + it("requires different supported input and output assets", () => { + expect( + swapEventSchema.safeParse({ + ...wethToUsdcSwapFixture, + asset_out: wethToUsdcSwapFixture.asset_in, + }).success, + ).toBe(false); + }); + + it("requires page counts and cursors to match returned swaps", () => { + expect( + findLargeSwapsResponseSchema.safeParse({ + ...completeLargeSwapsFixture, + pagination: { + ...completeLargeSwapsFixture.pagination, + returned: 1, + }, + }).success, + ).toBe(false); + + expect( + findLargeSwapsResponseSchema.safeParse({ + ...completeLargeSwapsFixture, + pagination: { + ...completeLargeSwapsFixture.pagination, + has_more: true, + }, + }).success, + ).toBe(false); + }); + + it("requires complete responses to reference one fresh Nuthatch source", () => { + expect( + findLargeSwapsResponseSchema.safeParse({ + ...completeLargeSwapsFixture, + coverage: { + requested_sources: 1, + successful_sources: 0, + }, + }).success, + ).toBe(false); + + expect( + findLargeSwapsResponseSchema.safeParse({ + ...completeLargeSwapsFixture, + provenance: completeLargeSwapsFixture.provenance.map((entry) => ({ + ...entry, + source_type: "native_subgraph", + })), + }).success, + ).toBe(false); + }); + + it("requires observed Nuthatch freshness to include a block hash", () => { + expect( + findLargeSwapsResponseSchema.safeParse({ + ...completeLargeSwapsFixture, + freshness: completeLargeSwapsFixture.freshness.map((entry) => ({ + ...entry, + indexed_block_hash: null, + })), + }).success, + ).toBe(false); + }); + + it("requires failed responses to remain empty and explain the source failure", () => { + expect( + findLargeSwapsResponseSchema.safeParse({ + ...failedSourceLargeSwapsFixture, + warnings: [], + }).success, + ).toBe(false); + + expect( + findLargeSwapsResponseSchema.safeParse({ + ...failedSourceLargeSwapsFixture, + coverage: { + requested_sources: 1, + successful_sources: 1, + }, + }).success, + ).toBe(false); + }); +}); diff --git a/tests/unit/lending-adapter.test.ts b/tests/unit/lending-adapter.test.ts new file mode 100644 index 0000000..11539bf --- /dev/null +++ b/tests/unit/lending-adapter.test.ts @@ -0,0 +1,546 @@ +import { describe, expect, it } from "vitest"; + +import { getActiveCompareLendingGraphSources } from "../../src/registry/index.js"; +import { lendingMarketSourceResultSchema } from "../../src/schemas/source-adapter.js"; +import { + fetchCompareLendingGraphSource, + TIER_A_LENDING_METRICS_QUERY, + TIER_A_LENDING_METRICS_QUERY_ID, +} from "../../src/sources/graph/index.js"; + +const USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; + +interface RateRow { + id: string; + side: string; + type: string; + rate: unknown; +} + +interface MarketRow { + id: string; + name: unknown; + isActive: unknown; + canBorrowFrom: unknown; + canUseAsCollateral: unknown; + totalValueLockedUSD: unknown; + totalDepositBalanceUSD: unknown; + totalBorrowBalanceUSD: unknown; + inputToken: { id: string; symbol: string; decimals: number }; + rates: RateRow[]; +} + +interface LendingPayload { + _meta: { + block: { number: number; timestamp: number; hash: string }; + hasIndexingErrors: boolean; + deployment: string; + }; + lendingProtocols: Array>; + markets: MarketRow[]; +} + +const BLOCK_HASH = "0xa39b13862e2eb5f1f904c19c2e88fb5d82f9ee9f5352fd9f108a18e69247fbb1"; + +/** Live capture from D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9 at block 49121431. */ +function aavePayload(): LendingPayload { + return { + _meta: { + block: { number: 49121431, timestamp: 1785032209, hash: BLOCK_HASH }, + hasIndexingErrors: false, + deployment: "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + }, + lendingProtocols: [ + { + id: "0xe20fcbdbffc4dd138ce8b2e6fbb6cb49777ad64d", + name: "Aave v3", + slug: "aave-v3", + schemaVersion: "3.1.0", + methodologyVersion: "1.1.0", + network: "BASE", + type: "LENDING", + lendingType: "POOLED", + }, + ], + markets: [ + { + id: "0x4e65fe4dba92790696d040ac24aa414708f5c0ab", + name: "Aave Base USDC", + isActive: true, + canBorrowFrom: true, + canUseAsCollateral: false, + totalValueLockedUSD: "172445303.026266003336", + totalDepositBalanceUSD: "172445303.026266003336", + totalBorrowBalanceUSD: "152467572.25396482662232", + inputToken: { id: USDC, symbol: "USDC", decimals: 6 }, + rates: [ + { id: "BORROWER-STABLE-0x4e65", side: "BORROWER", type: "STABLE", rate: "0" }, + { + id: "BORROWER-VARIABLE-0x4e65", + side: "BORROWER", + type: "VARIABLE", + rate: "4.4207374872837901", + }, + { + id: "LENDER-VARIABLE-0x4e65", + side: "LENDER", + type: "VARIABLE", + rate: "3.5177249578887369", + }, + ], + }, + ], + }; +} + +/** Live capture from 2u4mWUV4xS19ef1MbnxZHWLLMwdPxtVifH46JbonXwXP at block 49121431. */ +function seamlessPayload(): LendingPayload { + return { + _meta: { + block: { number: 49121431, timestamp: 1785032209, hash: BLOCK_HASH }, + hasIndexingErrors: false, + deployment: "QmPSmTkJPSKLFn46YdgwMKV5K2c9a3pkWnzDCC4ccCLAXE", + }, + lendingProtocols: [ + { id: "0x90c5055530c0465abb077fa016a3699a3f53ef99", slug: "seamless", network: "BASE" }, + ], + markets: [ + { + id: "0x53e240c0f985175da046a62f26d490d1e259036e", + name: "Seamless USDC", + isActive: false, + canBorrowFrom: true, + canUseAsCollateral: false, + totalValueLockedUSD: "205400.2928784070077", + totalDepositBalanceUSD: "205400.2928784070077", + totalBorrowBalanceUSD: "24150.82687434733677", + inputToken: { id: USDC, symbol: "USDC", decimals: 6 }, + rates: [ + { id: "BORROWER-STABLE-0x53e2", side: "BORROWER", type: "STABLE", rate: "8" }, + { + id: "BORROWER-VARIABLE-0x53e2", + side: "BORROWER", + type: "VARIABLE", + rate: "1.0452685606279676", + }, + { + id: "LENDER-VARIABLE-0x53e2", + side: "LENDER", + type: "VARIABLE", + rate: "0.1106243693385229", + }, + ], + }, + ], + }; +} + +/** Live capture from 33ex1ExmYQtwGVwri1AP3oMFPGSce6YbocBP7fWbsBrg at block 49121432. */ +function moonwellPayload(): LendingPayload { + return { + _meta: { + block: { + number: 49121432, + timestamp: 1785032211, + hash: "0xeaa3038aa8c4bf926ffef0ae5e1c5bbd37007b6b23d2da97918160de41a37ad1", + }, + hasIndexingErrors: false, + deployment: "QmeE6TgfRmK2iLAgCLBeXuxJQ2VXLFAeHVMTvmnECiFw7y", + }, + lendingProtocols: [ + { id: "0xfbb21d0380bee3312b33c4353c8936a0f13ef26c", slug: "moonwell", network: "BASE" }, + ], + markets: [ + { + id: "0xedc817a28e8b93b03976fbd4a3ddbc9f7d176c22", + name: "Moonwell USDC", + isActive: true, + canBorrowFrom: true, + canUseAsCollateral: true, + totalValueLockedUSD: "15066697.09797739573995", + totalDepositBalanceUSD: "15066697.09797739573995", + totalBorrowBalanceUSD: "13154949.96727053476238", + inputToken: { id: USDC, symbol: "USDC", decimals: 6 }, + rates: [ + { + id: "BORROWER-VARIABLE-0xedc8", + side: "BORROWER", + type: "VARIABLE", + rate: "5.2386888310416", + }, + { + id: "LENDER-VARIABLE-0xedc8", + side: "LENDER", + type: "VARIABLE", + rate: "4.1165790985584", + }, + ], + }, + ], + }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function respondWith(data: unknown): typeof fetch { + return () => Promise.resolve(jsonResponse({ data })); +} + +describe("fetchCompareLendingGraphSource", () => { + const [aave, seamless, moonwell] = getActiveCompareLendingGraphSources(); + + it("binds the shipped profile to three lending sources", () => { + expect(aave?.source_id).toBe("messari-aave-v3-base"); + expect(seamless?.source_id).toBe("messari-seamless-base"); + expect(moonwell?.source_id).toBe("messari-moonwell-base"); + }); + + it("maps the Aave live payload into an ok LendingMarketSourceResult", async () => { + let observedUrl = ""; + let observedAuthorization = ""; + let observedBody = ""; + const fetchImpl: typeof fetch = (input, init) => { + observedUrl = + input instanceof Request ? input.url : input instanceof URL ? input.href : String(input); + observedAuthorization = new Headers(init?.headers).get("authorization") ?? ""; + observedBody = typeof init?.body === "string" ? init.body : ""; + return Promise.resolve(jsonResponse({ data: aavePayload() })); + }; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "test-credential-must-not-leak", + fetchImpl, + nowSeconds: 1_785_032_214, + }); + + expect(lendingMarketSourceResultSchema.parse(result).status).toBe("ok"); + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + + expect(result.source_id).toBe("messari-aave-v3-base"); + expect(result.source_type).toBe("standardized_subgraph"); + expect(result.protocol).toBe("aave-v3"); + expect(result.data.market_id).toBe("0x4e65fe4dba92790696d040ac24aa414708f5c0ab"); + expect(result.data.market_name).toBe("Aave Base USDC"); + // Messari standardized subgraphs return decimals as an Int, not a string. + expect(result.data.input_token).toEqual({ address: USDC, symbol: "USDC", decimals: 6 }); + expect(result.data.is_active).toBe(true); + expect(result.data.can_borrow_from).toBe(true); + expect(result.data.can_use_as_collateral).toBe(false); + expect(result.data.tvl_usd).toBe("172445303.026266003336"); + expect(result.data.total_deposit_balance_usd).toBe("172445303.026266003336"); + expect(result.data.total_borrow_balance_usd).toBe("152467572.25396482662232"); + expect(result.data.lender_variable_rate_percent).toBe("3.5177249578887369"); + expect(result.data.borrower_variable_rate_percent).toBe("4.4207374872837901"); + expect(result.data.borrower_stable_rate_percent).toBe("0"); + expect(result.freshness.indexed_block).toBe(49121431); + expect(result.freshness.queried_at).toBe(1_785_032_214); + expect(result.warnings).toEqual([]); + expect(result.provenance).toEqual({ + deployment_or_view_id: "Qmb5j4tE5deSXCrubQeqeghfrGhyfQBiq9DuZNMfHBjbfL", + schema_version: "3.1.0", + methodology_version: "1.1.0", + query_id: TIER_A_LENDING_METRICS_QUERY_ID, + }); + + expect(observedUrl).toContain("/subgraphs/id/D7mapexM5ZsQckLJai2FawTKXJ7CqYGKM8PErnS3cJi9"); + expect(observedUrl).not.toContain("test-credential"); + expect(observedAuthorization).toBe("Bearer test-credential-must-not-leak"); + expect(observedBody).toContain(USDC); + expect(JSON.stringify(result)).not.toContain("test-credential"); + }); + + it("keeps the Seamless market and warns instead of dropping an inactive one", async () => { + const result = await fetchCompareLendingGraphSource(seamless!, { + apiKey: "key", + fetchImpl: respondWith(seamlessPayload()), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.market_id).toBe("0x53e240c0f985175da046a62f26d490d1e259036e"); + expect(result.data.is_active).toBe(false); + expect(result.data.borrower_stable_rate_percent).toBe("8"); + expect(result.warnings).toEqual([ + "messari-seamless-base market 0x53e240c0f985175da046a62f26d490d1e259036e is reported as inactive.", + ]); + }); + + it("maps a Moonwell payload without a stable rate to null, not zero", async () => { + const result = await fetchCompareLendingGraphSource(moonwell!, { + apiKey: "key", + fetchImpl: respondWith(moonwellPayload()), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.lender_variable_rate_percent).toBe("4.1165790985584"); + expect(result.data.borrower_variable_rate_percent).toBe("5.2386888310416"); + expect(result.data.borrower_stable_rate_percent).toBeNull(); + expect(result.warnings).toEqual([]); + }); + + it("warns when the response reports indexing errors", async () => { + const data = aavePayload(); + data._meta.hasIndexingErrors = true; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("ok"); + expect(result.warnings).toContain("Graph _meta.hasIndexingErrors is true for this response."); + }); + + it("nulls an ambiguous rate rather than picking one of the duplicates", async () => { + const data = aavePayload(); + data.markets[0]!.rates.push({ + id: "LENDER-VARIABLE-duplicate", + side: "LENDER", + type: "VARIABLE", + rate: "9.9", + }); + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.lender_variable_rate_percent).toBeNull(); + expect(result.warnings[0]).toMatch(/2 LENDER\/VARIABLE rates/); + }); + + it("nulls a malformed rate and warns", async () => { + const data = aavePayload(); + data.markets[0]!.rates[2]!.rate = -1; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.lender_variable_rate_percent).toBeNull(); + expect(result.warnings[0]).toMatch(/failed decimal validation/); + }); + + it("returns unsupported with retained freshness on deployment mismatch", async () => { + const data = aavePayload(); + data._meta.deployment = "QmWrongDeploymentHashxxxxxxxxxxxxxxxxxxxxxxxxx"; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).not.toBeNull(); + expect(result.provenance.deployment_or_view_id).toBe(data._meta.deployment); + expect(result.warnings[0]).toMatch(/deployment mismatch/i); + }); + + it("returns unsupported when the deployment self-reports another network", async () => { + // The failure mode that kept Compound v3 out of scope: a deployment + // published for Base that indexes mainnet. + const data = aavePayload(); + data.lendingProtocols[0]!.network = "MAINNET"; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).not.toBeNull(); + expect(result.warnings[0]).toMatch(/self-reports network "MAINNET", expected "BASE"/); + }); + + it("returns unsupported when the protocol network is unreadable", async () => { + const data = aavePayload(); + data.lendingProtocols = []; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/missing a usable lendingProtocols.network/); + }); + + it("returns unsupported when no market matches the locked market token", async () => { + const data = aavePayload(); + data.markets[0]!.inputToken = { + id: "0x4200000000000000000000000000000000000006", + symbol: "WETH", + decimals: 18, + }; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).not.toBeNull(); + expect(result.warnings[0]).toContain(USDC); + }); + + it("returns unsupported rather than choosing between duplicate markets", async () => { + const data = aavePayload(); + data.markets.push({ + ...data.markets[0]!, + id: "0x1111111111111111111111111111111111111111", + }); + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/reported 2 markets/); + }); + + it("returns unsupported when a market flag is not a boolean", async () => { + const data = aavePayload(); + data.markets[0]!.isActive = "true"; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("unsupported"); + expect(result.warnings[0]).toMatch(/failed shape validation/); + }); + + it("returns unsupported when a USD balance is not a decimal string", async () => { + const data = aavePayload(); + data.markets[0]!.totalValueLockedUSD = 172445303.02; + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: respondWith(data), + nowSeconds: 1_785_032_214, + }); + + expect(result.status).toBe("unsupported"); + expect(result.warnings[0]).toMatch(/failed shape validation/); + }); + + it("returns timeout without freshness when the gateway aborts", async () => { + const fetchImpl: typeof fetch = (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const error = new Error("Aborted"); + error.name = "AbortError"; + reject(error); + }); + }); + + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl, + timeoutMs: 20, + }); + + expect(result.status).toBe("timeout"); + expect(result.data).toBeNull(); + expect(result.freshness).toBeNull(); + expect(result.warnings[0]).toMatch(/timeout/i); + }); + + it("returns error on GraphQL errors without inventing market data", async () => { + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ errors: [{ message: "boom" }], data: null })), + }); + + expect(result.status).toBe("error"); + expect(result.data).toBeNull(); + expect(result.freshness).toBeNull(); + }); + + it("returns error on HTTP non-2xx gateway responses", async () => { + const result = await fetchCompareLendingGraphSource(aave!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ message: "nope" }, 503)), + }); + + expect(result.status).toBe("error"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/HTTP 503/); + }); + + it("returns error when GRAPH_API_KEY is missing", async () => { + let called = false; + const result = await fetchCompareLendingGraphSource(aave!, { + env: {}, + fetchImpl: () => { + called = true; + return Promise.resolve(jsonResponse({ data: {} })); + }, + }); + + expect(called).toBe(false); + expect(result.status).toBe("error"); + expect(result.warnings[0]).toMatch(/GRAPH_API_KEY/); + }); + + it("returns unsupported for a non-locked query_id without calling the network", async () => { + let called = false; + const result = await fetchCompareLendingGraphSource( + { ...aave!, query_id: "other-query-v1" }, + { + apiKey: "key", + fetchImpl: () => { + called = true; + return Promise.resolve(jsonResponse({ data: {} })); + }, + }, + ); + + expect(called).toBe(false); + expect(result.status).toBe("unsupported"); + }); + + it("filters markets by input token so a protocol's other markets cannot be selected", () => { + expect(TIER_A_LENDING_METRICS_QUERY).toContain("where: { inputToken: $token }"); + }); +}); diff --git a/tests/unit/live-compare-pools.test.ts b/tests/unit/live-compare-pools.test.ts new file mode 100644 index 0000000..35d097e --- /dev/null +++ b/tests/unit/live-compare-pools.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { comparePoolsResponseSchema } from "../../src/schemas/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; +import { + liveComparePoolsFixtures, + livePartialComparePoolsFixture, +} from "../fixtures/live-compare-pools.js"; +import { graphPoolA, graphPoolB } from "../fixtures/sources/index.js"; + +describe("live-derived compare_pools fixtures", () => { + it("accepts Graph-only live fixtures with explicit missing Nuthatch", () => { + for (const fixture of liveComparePoolsFixtures) { + expect(comparePoolsResponseSchema.parse(fixture)).toEqual(fixture); + expect(fixture.coverage.requested_deployments).toBe(2); + expect(fixture.coverage.nuthatch_available).toBe(false); + expect(fixture.status).not.toBe("complete"); + } + }); + + it("uses locked Graph source and pool identities", () => { + const [partial] = liveComparePoolsFixtures; + expect(partial.data?.pair[0].address).toBe(M0_COMPARE_POOLS_SCOPE.token0.address); + expect(partial.data?.pair[1].address).toBe(M0_COMPARE_POOLS_SCOPE.token1.address); + expect(partial.provenance.map((entry) => entry.source_id)).toEqual([ + ...M0_COMPARE_POOLS_SCOPE.graphSources.map((source) => source.source_id), + M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, + ]); + }); + + it("orders the both-graphs-ok fixture by volume_usd descending", () => { + const pools = livePartialComparePoolsFixture.data.pools; + expect(pools[0]?.rank).toBe(1); + expect(pools[1]?.rank).toBe(2); + expect(pools[0]?.source_ids).toEqual([graphPoolA.source_id]); + expect(pools[1]?.source_ids).toEqual([graphPoolB.source_id]); + expect(pools[0]?.volume_usd).toBe(graphPoolA.data.volume_usd_24h); + expect(pools[1]?.volume_usd).toBe(graphPoolB.data.volume_usd_24h); + }); + + it("does not invent a verified Nuthatch freshness fact", () => { + for (const fixture of liveComparePoolsFixtures) { + if (fixture.data !== null) { + expect(fixture.data.nuthatch_freshness_fact).toBeNull(); + } + const nuthatchFreshness = fixture.freshness.find( + (entry) => entry.source_id === M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, + ); + expect(nuthatchFreshness).toEqual({ + source_id: M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, + status: "unavailable", + }); + } + }); +}); diff --git a/tests/unit/live-large-swaps.test.ts b/tests/unit/live-large-swaps.test.ts new file mode 100644 index 0000000..4441950 --- /dev/null +++ b/tests/unit/live-large-swaps.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; + +import { LSS_SCOPE } from "../../src/scope/index.js"; +import { createLiveLargeSwapSource, executeFindLargeSwaps } from "../../src/tools/index.js"; +import { lockedLargeSwapsRequest } from "../fixtures/large-swaps-request.js"; + +const REGISTRY_HASH = "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25"; +const ROW = { + pool_address: LSS_SCOPE.poolAddress, + block_number: 48_944_373, + block_hash: `0x${"a".repeat(64)}`, + block_timestamp: 1_784_678_093, + transaction_hash: `0x${"b".repeat(64)}`, + log_index: 168, + amount0_raw: "-6123995143586742520", + amount1_raw: "11804868893", +} as const; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function receipt(rows: readonly unknown[]) { + return { + count: rows.length, + provenance: { + as_of: 48_944_374, + registry_hash: REGISTRY_HASH, + sealed_through: 48_944_374, + source: "hot+sealed", + }, + rows, + truncated: false, + }; +} + +describe("live find_large_swaps source", () => { + it("uses the registry URL and real client/adapter path", async () => { + const requests: URL[] = []; + const fetchImpl = vi.fn((input) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + requests.push(url); + switch (url.pathname) { + case "/ready": + return Promise.resolve( + json({ ready: true, sealed_through: 48_944_374, tip: 48_944_374 }), + ); + case "/nest": + return Promise.resolve(json({ registry_hash: REGISTRY_HASH })); + case "/schema": + return Promise.resolve( + new Response("Nuthatch schema", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ); + case "/sql": + return Promise.resolve(json(receipt([ROW]))); + default: + return Promise.resolve(new Response("not found", { status: 404 })); + } + }); + const source = createLiveLargeSwapSource({ + environment: { NUTHATCH_BASE_URL: "https://nuthatch.internal" }, + fetchImpl, + clock: () => ROW.block_timestamp + 60, + }); + + const response = await executeFindLargeSwaps( + { ...lockedLargeSwapsRequest, min_amount: "5", limit: 1 }, + source, + ); + + expect(response).toMatchObject({ + status: "complete", + data: { + swaps: [{ transaction_hash: ROW.transaction_hash, amount_out: "6.12399514358674252" }], + }, + }); + expect(new Set(requests.map(({ pathname }) => pathname))).toEqual( + new Set(["/ready", "/nest", "/schema", "/sql"]), + ); + expect(requests.every(({ origin }) => origin === "https://nuthatch.internal")).toBe(true); + const sqlQueries = requests + .filter(({ pathname }) => pathname === "/sql") + .map(({ searchParams }) => searchParams.get("q") ?? ""); + expect(sqlQueries).toHaveLength(2); + expect(sqlQueries.every((query) => query.includes("pool__swap"))).toBe(true); + expect(sqlQueries.every((query) => !query.includes(LSS_SCOPE.tokens.weth.address))).toBe(true); + }); + + it("returns failed when the source URL is absent and redacts transport details", async () => { + const missing = createLiveLargeSwapSource({ environment: {} }); + await expect(executeFindLargeSwaps(lockedLargeSwapsRequest, missing)).resolves.toMatchObject({ + status: "failed", + warnings: ["Large-swap source is unavailable because NUTHATCH_BASE_URL is not configured."], + }); + + const secretUrl = "https://secret-nuthatch.internal"; + const rejected = createLiveLargeSwapSource({ + environment: { NUTHATCH_BASE_URL: secretUrl }, + fetchImpl: vi.fn(() => Promise.reject(new Error(`connect ${secretUrl} admin-token`))), + }); + const response = await executeFindLargeSwaps(lockedLargeSwapsRequest, rejected); + expect(response.status).toBe("failed"); + expect(JSON.stringify(response)).not.toContain(secretUrl); + expect(JSON.stringify(response)).not.toContain("admin-token"); + }); +}); diff --git a/tests/unit/live-sources.test.ts b/tests/unit/live-sources.test.ts new file mode 100644 index 0000000..6260fb7 --- /dev/null +++ b/tests/unit/live-sources.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; + +import { nuthatchSourceResultSchema } from "../../src/schemas/source-adapter.js"; +import { + BLOCK_HASH, + QUERIED_AT, + REGISTRY_HASH, + ROW, +} from "../../src/sources/nuthatch/adapter-fixtures.js"; +import { NUTHATCH_FRESHNESS_QUERY } from "../../src/sources/nuthatch/freshness-query.js"; +import { createLiveComparePoolsSources } from "../../src/tools/live-sources.js"; +import { lockedComparePoolsRequest } from "../fixtures/compare-pools-request.js"; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("live Nuthatch source", () => { + it("uses the registry-named environment URL and invokes the real client/adapter path", async () => { + const requests: URL[] = []; + const fetchImpl = vi.fn((input) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + requests.push(url); + + switch (url.pathname) { + case "/ready": + return Promise.resolve(json({ ready: true })); + case "/nest": + return Promise.resolve( + json({ registry_hash: REGISTRY_HASH, name: "deeptrace", table_count: 9 }), + ); + case "/schema": + return Promise.resolve( + new Response("nuthatch data model", { + status: 200, + headers: { "content-type": "text/plain" }, + }), + ); + case "/sql": + return Promise.resolve(json({ count: 1, provenance: {}, rows: [ROW], truncated: false })); + default: + return Promise.resolve(new Response("not found", { status: 404 })); + } + }); + const sources = createLiveComparePoolsSources({ + environment: { NUTHATCH_BASE_URL: "https://nuthatch.internal" }, + fetchImpl, + nuthatchClock: () => QUERIED_AT, + }); + + const result = await sources.fetchNuthatchResult(lockedComparePoolsRequest); + + expect(nuthatchSourceResultSchema.parse(result)).toEqual(result); + expect(result).toMatchObject({ + source_id: "nuthatch-pool-swaps", + status: "ok", + data: ROW, + freshness: { + indexed_block_hash: BLOCK_HASH, + queried_at: QUERIED_AT, + }, + provenance: { + deployment_or_view_id: REGISTRY_HASH, + query_id: "nuthatch-pool-swap-freshness-v1", + }, + }); + expect(new Set(requests.map((url) => url.pathname))).toEqual( + new Set(["/ready", "/nest", "/schema", "/sql"]), + ); + expect(requests.every((url) => url.origin === "https://nuthatch.internal")).toBe(true); + const sqlRequest = requests.find((url) => url.pathname === "/sql"); + expect(sqlRequest?.searchParams.get("q")).toBe(NUTHATCH_FRESHNESS_QUERY); + expect(sqlRequest?.searchParams.get("max_rows")).toBe("1"); + }); + + it("returns a typed registry-backed failure when the environment URL is missing", async () => { + const fetchImpl = vi.fn(); + const sources = createLiveComparePoolsSources({ + environment: {}, + fetchImpl, + nuthatchClock: () => QUERIED_AT, + }); + + const result = await sources.fetchNuthatchResult(lockedComparePoolsRequest); + + expect(nuthatchSourceResultSchema.parse(result)).toEqual(result); + expect(result).toMatchObject({ + source_id: "nuthatch-pool-swaps", + source_type: "nuthatch_view", + status: "error", + data: null, + freshness: null, + provenance: { + deployment_or_view_id: REGISTRY_HASH, + query_id: "nuthatch-pool-swap-freshness-v1", + }, + }); + expect(result?.warnings).toEqual([ + "Nuthatch source is unavailable because NUTHATCH_BASE_URL is not configured.", + ]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("contains transport failures as non-ok results without exposing the URL", async () => { + const baseUrl = "https://sensitive-nuthatch.internal"; + const fetchImpl = vi.fn(() => + Promise.reject(new Error(`connect ECONNREFUSED ${baseUrl}`)), + ); + const sources = createLiveComparePoolsSources({ + environment: { NUTHATCH_BASE_URL: baseUrl }, + fetchImpl, + timeoutMs: 20, + nuthatchClock: () => QUERIED_AT, + }); + + const result = await sources.fetchNuthatchResult(lockedComparePoolsRequest); + + expect(nuthatchSourceResultSchema.parse(result)).toEqual(result); + expect(result?.status).toBe("error"); + expect(JSON.stringify(result)).not.toContain(baseUrl); + }); +}); diff --git a/tests/unit/live-wallet-sources.test.ts b/tests/unit/live-wallet-sources.test.ts new file mode 100644 index 0000000..5a0bccd --- /dev/null +++ b/tests/unit/live-wallet-sources.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { researchWalletRequestSchema } from "../../src/schemas/index.js"; +import { createLiveWalletSources } from "../../src/tools/live-wallet-sources.js"; +import { inspectWalletResearchQuery } from "../../src/tools/wallet-query.js"; + +const WALLET = "0x5cb3787a9c9c7547451ca3e6d8702453de35fe01"; + +describe("live wallet sources", () => { + it("returns source-local failures when credentials and Nuthatch are unconfigured", async () => { + const sources = createLiveWalletSources({ environment: {} }); + const request = researchWalletRequestSchema.parse({ + chain_id: 8453, + address: WALLET, + }); + const [graph, nuthatch] = await Promise.all([ + sources.fetchPositions(request), + sources.fetchActivity(inspectWalletResearchQuery(request)), + ]); + + expect(graph).toMatchObject({ + status: "error", + positions: null, + freshness: { status: "unavailable" }, + }); + expect(nuthatch).toMatchObject({ + status: "error", + activities: null, + freshness: { status: "unavailable" }, + }); + expect(JSON.stringify([graph, nuthatch])).not.toContain("undefined"); + }); +}); diff --git a/tests/unit/m0-policy.test.ts b/tests/unit/m0-policy.test.ts new file mode 100644 index 0000000..e16f353 --- /dev/null +++ b/tests/unit/m0-policy.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { GATEWAY_DEFAULTS, GATEWAY_MAXIMUMS } from "../../src/config/index.js"; +import { + M0_CORE_POLICY, + M0_RANKING_METRICS, + M0_RANKING_TIE_BREAK, + M0_TIME_WINDOWS, + M0_WARNING_ORDER, +} from "../../src/policy/index.js"; +import { BASE_CHAIN_ID } from "../../src/schemas/source-adapter.js"; + +describe("M0 core policy", () => { + it("locks request defaults and bounds without live source selections", () => { + expect(M0_CORE_POLICY.chainId).toBe(BASE_CHAIN_ID); + expect(M0_TIME_WINDOWS).toEqual(["24h", "7d"]); + expect(M0_CORE_POLICY.defaultWindow).toBe("24h"); + expect(M0_RANKING_METRICS).toEqual(["tvl_usd", "volume_usd", "fees_usd"]); + expect(M0_CORE_POLICY.defaultRankingMetric).toBe("volume_usd"); + expect(M0_CORE_POLICY.topN).toEqual({ default: 3, maximum: 3 }); + expect(M0_CORE_POLICY).not.toHaveProperty("pair"); + expect(M0_CORE_POLICY).not.toHaveProperty("deployments"); + expect(M0_CORE_POLICY).not.toHaveProperty("nuthatch"); + }); + + it("locks deterministic tie-breaking", () => { + expect(M0_RANKING_TIE_BREAK).toEqual([ + "requested_metric_desc_nulls_last", + "protocol_asc", + "pool_address_asc", + "source_id_asc", + ]); + expect(M0_WARNING_ORDER).toEqual(["source_order", "warning_text"]); + }); + + it("locks complete, partial, and failed coverage boundaries", () => { + expect(M0_CORE_POLICY.coverage).toEqual({ + expectedGraphResults: 2, + requiresNuthatchForComplete: true, + minimumGraphResultsForPartial: 1, + }); + }); + + it("keeps executable gateway policy aligned with configuration", () => { + expect(M0_CORE_POLICY.gateway.rateLimit.defaultMaxRequests).toBe( + GATEWAY_DEFAULTS.rateLimitMaxRequests, + ); + expect(M0_CORE_POLICY.gateway.rateLimit.maximumMaxRequests).toBe( + GATEWAY_MAXIMUMS.rateLimitMaxRequests, + ); + expect(M0_CORE_POLICY.gateway.rateLimit.defaultWindowMs).toBe( + GATEWAY_DEFAULTS.rateLimitWindowMs, + ); + expect(M0_CORE_POLICY.gateway.rateLimit.maximumWindowMs).toBe( + GATEWAY_MAXIMUMS.rateLimitWindowMs, + ); + expect(M0_CORE_POLICY.gateway.sourceTimeoutMs).toBe(GATEWAY_DEFAULTS.sourceTimeoutMs); + expect(M0_CORE_POLICY.gateway.maximumSourceTimeoutMs).toBe(GATEWAY_MAXIMUMS.sourceTimeoutMs); + expect(M0_CORE_POLICY.gateway.rateLimit.resetPolicy).toBe("fixed_window"); + expect(M0_CORE_POLICY.gateway.endToEndTimeoutMs).toBe(15_000); + expect(M0_CORE_POLICY.gateway.maximumResponseBytes).toBe(65_536); + }); + + it("locks freshness limits", () => { + expect(M0_CORE_POLICY.freshness).toEqual({ + qualityStaleAfterSeconds: 300, + enforcementLayer: "core_quality", + preservesAdapterStatus: true, + }); + }); + + it("fits source timeouts inside the end-to-end deadline", () => { + expect(M0_CORE_POLICY.gateway.maximumSourceTimeoutMs).toBeLessThanOrEqual( + M0_CORE_POLICY.gateway.endToEndTimeoutMs, + ); + }); +}); diff --git a/tests/unit/m2-evidence.test.ts b/tests/unit/m2-evidence.test.ts new file mode 100644 index 0000000..ffea90f --- /dev/null +++ b/tests/unit/m2-evidence.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { writeEvidence, type EvidenceDocument } from "../../scripts/m2/lib/evidence.ts"; +import { metaQuery, tierBMetricsQuery } from "../../scripts/m2/lib/queries.ts"; + +const evidenceRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "../integration/__evidence__/m2", +); + +describe("writeEvidence", () => { + it("writes a well-formed credential-free evidence envelope", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "deeptrace-m2-")); + await writeEvidence( + "dummy", + "01-meta.json", + "dummy-id", + metaQuery, + {}, + { + status: 200, + body: { data: { _meta: { deployment: "QmDummy" } } }, + error: null, + latencyMs: 3, + }, + root, + ); + + const contents = await readFile(path.join(root, "dummy", "01-meta.json"), "utf8"); + const evidence = JSON.parse(contents) as EvidenceDocument; + expect(evidence.gateway_host).toBe("gateway.thegraph.com"); + expect(evidence.subgraph_id).toBe("dummy-id"); + expect(evidence.query_id).toBe("m2-meta-v1"); + expect(evidence.response).toEqual({ + data: { _meta: { deployment: "QmDummy" } }, + }); + expect(Object.hasOwn(evidence, "headers")).toBe(false); + }); + + it("keeps the historical M2 metrics query and captured evidence on v1", async () => { + expect(tierBMetricsQuery.queryId).toBe("m2-tier-b-metrics-v1"); + + for (const sourceId of ["uniswap-v3-base-native", "exchange-v3-base"]) { + const contents = await readFile( + path.join(evidenceRoot, sourceId, "07-common-metrics.json"), + "utf8", + ); + const evidence = JSON.parse(contents) as EvidenceDocument; + expect(evidence.query_id).toBe("m2-tier-b-metrics-v1"); + } + }); +}); diff --git a/tests/unit/m2-gateway.test.ts b/tests/unit/m2-gateway.test.ts new file mode 100644 index 0000000..1721b80 --- /dev/null +++ b/tests/unit/m2-gateway.test.ts @@ -0,0 +1,115 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { loadGraphApiKey, postGraphQuery } from "../../scripts/m2/lib/gateway.ts"; +import { metaQuery } from "../../scripts/m2/lib/queries.ts"; + +const ORIGINAL_GRAPH_API_KEY = process.env.GRAPH_API_KEY; + +afterEach(() => { + if (ORIGINAL_GRAPH_API_KEY === undefined) { + delete process.env.GRAPH_API_KEY; + } else { + process.env.GRAPH_API_KEY = ORIGINAL_GRAPH_API_KEY; + } +}); + +describe("loadGraphApiKey", () => { + it("prefers process environment over a .env file", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "deeptrace-m2-key-")); + const envPath = path.join(root, ".env"); + await writeFile(envPath, "GRAPH_API_KEY=from-file\n", "utf8"); + process.env.GRAPH_API_KEY = "from-env"; + + await expect(loadGraphApiKey(envPath)).resolves.toBe("from-env"); + }); + + it("reads .env when the process environment is unset", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "deeptrace-m2-key-")); + const envPath = path.join(root, ".env"); + await writeFile(envPath, "GRAPH_API_KEY=from-file\n", "utf8"); + delete process.env.GRAPH_API_KEY; + + await expect(loadGraphApiKey(envPath)).resolves.toBe("from-file"); + }); + + it("allows a missing .env when GRAPH_API_KEY is already set", async () => { + process.env.GRAPH_API_KEY = "from-env-only"; + await expect( + loadGraphApiKey(path.join(os.tmpdir(), "deeptrace-missing-env-file")), + ).resolves.toBe("from-env-only"); + }); + + it("rejects when neither process env nor .env provides a key", async () => { + delete process.env.GRAPH_API_KEY; + await expect( + loadGraphApiKey(path.join(os.tmpdir(), "deeptrace-missing-env-file")), + ).rejects.toThrow(/GRAPH_API_KEY is unset or empty/); + }); +}); + +describe("postGraphQuery", () => { + it("posts bearer-authenticated GraphQL without putting the key in the URL", async () => { + const credential = "test-credential"; + let observedUrl = ""; + let observedAuthorization = ""; + + const fetchImpl: typeof fetch = (input, init) => { + observedUrl = + input instanceof Request ? input.url : input instanceof URL ? input.href : input; + observedAuthorization = new Headers(init?.headers).get("authorization") ?? ""; + return Promise.resolve( + new Response( + JSON.stringify({ + data: { + _meta: { + deployment: "QmDummy", + block: { number: 1, timestamp: 1, hash: "0x01" }, + hasIndexingErrors: false, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }; + + const result = await postGraphQuery( + "dummy-id", + metaQuery, + {}, + { + apiKey: credential, + fetchImpl, + gatewayOrigin: "https://dummy.invalid/api", + }, + ); + + expect(observedUrl).toBe("https://dummy.invalid/api/subgraphs/id/dummy-id"); + expect(observedAuthorization).toBe(`Bearer ${credential}`); + expect(observedUrl).not.toMatch(new RegExp(credential)); + expect(result.status).toBe(200); + expect(result.error).toBeNull(); + }); + + it("redacts transport exception details", async () => { + const credential = "must-not-leak"; + const fetchImpl: typeof fetch = () => + Promise.reject(new Error(`network failed with ${credential}`)); + const result = await postGraphQuery( + "dummy-id", + metaQuery, + {}, + { + apiKey: credential, + fetchImpl, + }, + ); + + expect(result.error?.kind).toBe("transport"); + expect(result.error?.message ?? "").not.toMatch(new RegExp(credential)); + }); +}); diff --git a/tests/unit/m2-probe-pools.test.ts b/tests/unit/m2-probe-pools.test.ts new file mode 100644 index 0000000..0d8b1fa --- /dev/null +++ b/tests/unit/m2-probe-pools.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { poolProbeFailureMessage } from "../../scripts/m2/lib/pool-probe.ts"; + +describe("poolProbeFailureMessage", () => { + it("preserves explicit transport timeout messages", () => { + expect( + poolProbeFailureMessage({ + kind: "timeout", + message: "Graph gateway request exceeded the 15 second timeout.", + }), + ).toBe("Graph gateway request exceeded the 15 second timeout."); + }); + + it("preserves transport and invalid JSON messages", () => { + expect( + poolProbeFailureMessage({ + kind: "transport", + message: "Graph gateway request failed; details were redacted.", + }), + ).toBe("Graph gateway request failed; details were redacted."); + expect( + poolProbeFailureMessage({ + kind: "invalid_json", + message: "Graph gateway returned a non-JSON response.", + }), + ).toBe("Graph gateway returned a non-JSON response."); + }); + + it("labels successful empty pool responses as incompatible", () => { + expect(poolProbeFailureMessage(null)).toBe( + "The common tier query returned no pool; candidate is incompatible.", + ); + }); +}); diff --git a/tests/unit/m4-http-probe.test.mjs b/tests/unit/m4-http-probe.test.mjs new file mode 100644 index 0000000..ede85ba --- /dev/null +++ b/tests/unit/m4-http-probe.test.mjs @@ -0,0 +1,116 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { + buildAcceptance, + GUARD_QUERY, + isFreshnessViewAvailable, + isMaxRowsRejection, + isProbeAccepted, + MAX_ROWS_QUERY, + requireBaseUrl, +} from "../../scripts/m4/http-probe-lib.mjs"; + +const freshnessColumns = [ + "pool_address", + "recent_swap_count_24h", + "last_swap_block", + "last_swap_block_timestamp", + "last_swap_block_hash", + "last_swap_tx_hash", + "last_swap_log_index", +]; + +describe("M4 HTTP probe validation", () => { + it("requires an explicit HTTP(S) Nuthatch base URL", () => { + expect(() => requireBaseUrl(undefined)).toThrow(/NUTHATCH_BASE_URL is required/); + expect(() => requireBaseUrl("file:///tmp/nuthatch")).toThrow(/http: or https:/); + expect(requireBaseUrl("https://nuthatch.example")).toBe("https://nuthatch.example/"); + }); + + it("does not treat a catalog miss as max_rows rejection", () => { + expect( + isMaxRowsRejection({ + status: 400, + body: "Catalog Error: Table with name pool_swap_freshness does not exist", + }), + ).toBe(false); + }); + + it("recognizes an explicit max_rows ceiling rejection", () => { + expect( + isMaxRowsRejection({ + status: 400, + body: "max_rows must be at most 50000", + }), + ).toBe(true); + }); + + it("requires successful SQL and explain probes for the freshness view", () => { + expect( + isFreshnessViewAvailable({ + "/sql": { status: 200 }, + "/explain": { status: 200 }, + }), + ).toBe(true); + expect( + isFreshnessViewAvailable({ + "/sql": { status: 400 }, + "/explain": { status: 400 }, + }), + ).toBe(false); + }); + + it("requires POST /sql rejection for acceptance", () => { + const accepted = buildAcceptance({ + endpoints: { "/sql": { status: 200 }, "/explain": { status: 200 } }, + maxRows: { rejected: true }, + postSql: { rejected: false }, + }); + + expect(accepted.post_sql_rejected).toBe(false); + expect(isProbeAccepted(accepted)).toBe(false); + }); + + it("runs max_rows and concurrency guards against the existing raw table", () => { + expect(MAX_ROWS_QUERY).toContain("pool__swap"); + expect(GUARD_QUERY).toContain("pool__swap"); + expect(MAX_ROWS_QUERY).not.toContain("pool_swap_freshness"); + expect(GUARD_QUERY).not.toContain("pool_swap_freshness"); + }); +}); + +describe("M4 committed artifacts", () => { + it("captures the live nuthatch http surface", async () => { + const evidence = JSON.parse( + await readFile( + new URL("../integration/__evidence__/m4/p0-http-capabilities.json", import.meta.url), + "utf8", + ), + ); + + expect(evidence.endpoints["/health"].status).toBe(200); + expect(evidence.endpoints["/ready"].status).toBe(200); + expect(evidence.endpoints["/nest"].status).toBe(200); + expect(evidence.post_sql.rejected).toBe(true); + }); + + it("projects exactly the seven Nuthatch freshness fields", async () => { + const sql = await readFile( + new URL("../../nest/views/pool_swap_freshness.sql", import.meta.url), + "utf8", + ); + const executableSql = sql + .split("\n") + .filter((line) => !line.trimStart().startsWith("--")) + .join("\n"); + const projection = sql.slice(sql.indexOf("SELECT") + 6, sql.indexOf("FROM (")); + + for (const column of freshnessColumns) { + expect(projection).toMatch(new RegExp(`\\b${column}\\b`)); + } + expect(projection.match(/\bAS\s+[a-z0-9_]+/gi)).toHaveLength(freshnessColumns.length); + expect(executableSql).not.toMatch(/\b(?:CURRENT_TIMESTAMP|now)\s*\(/i); + }); +}); diff --git a/tests/unit/mcp-lifecycle.test.ts b/tests/unit/mcp-lifecycle.test.ts new file mode 100644 index 0000000..f6a723c --- /dev/null +++ b/tests/unit/mcp-lifecycle.test.ts @@ -0,0 +1,79 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; + +import { + installShutdownHandlers, + startMcpServer, + type ShutdownSignal, + type ShutdownSignalTarget, +} from "../../src/mcp/lifecycle.js"; +import { serverInfo, serverInstructions } from "../../src/mcp/server.js"; + +class TestSignalTarget implements ShutdownSignalTarget { + private readonly listeners = new Map void>(); + + once(signal: ShutdownSignal, listener: () => void): void { + this.listeners.set(signal, listener); + } + + off(signal: ShutdownSignal, listener: () => void): void { + if (this.listeners.get(signal) === listener) { + this.listeners.delete(signal); + } + } + + emit(signal: ShutdownSignal): void { + const listener = this.listeners.get(signal); + this.listeners.delete(signal); + listener?.(); + } + + listenerCount(): number { + return this.listeners.size; + } +} + +describe("MCP server lifecycle", () => { + it("completes initialization over an in-memory transport", async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const runtime = await startMcpServer(serverTransport); + const client = new Client({ + name: "deeptrace-test-client", + version: "0.1.0", + }); + + try { + await client.connect(clientTransport); + + expect(client.getServerVersion()).toEqual(serverInfo); + expect(client.getInstructions()).toBe(serverInstructions); + expect(serverInstructions.length).toBeLessThanOrEqual(512); + expect(serverInstructions).toContain("Graph subgraphs supply pool financial metrics"); + expect(serverInstructions).toContain("Nuthatch supplies independent"); + expect(serverInstructions).toContain("never present partial results as complete"); + expect(runtime.server.isConnected()).toBe(true); + } finally { + await client.close(); + await runtime.close(); + } + + expect(runtime.server.isConnected()).toBe(false); + }); + + it("closes only once when multiple shutdown signals arrive", async () => { + const signalTarget = new TestSignalTarget(); + const close = vi.fn().mockResolvedValue(undefined); + const removeHandlers = installShutdownHandlers({ close }, signalTarget); + + signalTarget.emit("SIGINT"); + signalTarget.emit("SIGTERM"); + + await vi.waitFor(() => { + expect(close).toHaveBeenCalledTimes(1); + }); + + removeHandlers(); + expect(signalTarget.listenerCount()).toBe(0); + }); +}); diff --git a/tests/unit/mcp-server.test.ts b/tests/unit/mcp-server.test.ts new file mode 100644 index 0000000..6f8eaa8 --- /dev/null +++ b/tests/unit/mcp-server.test.ts @@ -0,0 +1,146 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; + +import { startMcpServer } from "../../src/mcp/lifecycle.js"; +import { + COMPARE_LENDING_MARKETS_TOOL_NAME, + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_TOOL_NAME, + RESEARCH_WALLET_TOOL_NAME, + createMcpServer, + serverInfo, +} from "../../src/mcp/server.js"; +import { M0_COMPARE_LENDING_SCOPE } from "../../src/scope/index.js"; +import { + createFixtureCompareLendingSources, + createFixtureComparePoolsSources, +} from "../../src/tools/index.js"; +import { completeLendingScenario } from "../fixtures/lending-sources.js"; + +const lockedLendingRequest = { + chain_id: M0_COMPARE_LENDING_SCOPE.chainId, + market_token: M0_COMPARE_LENDING_SCOPE.marketToken.address, +} as const; + +async function withClient( + lendingSources = createFixtureCompareLendingSources({ + lendingResults: completeLendingScenario, + }), +) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const runtime = await startMcpServer(serverTransport, { + sources: createFixtureComparePoolsSources({ graphResults: [] }), + lendingSources, + gatewayConfig: { + rateLimitMaxRequests: 30, + rateLimitWindowMs: 60_000, + sourceTimeoutMs: 5_000, + }, + }); + const client = new Client({ name: "deeptrace-test-client", version: "0.1.0" }); + await client.connect(clientTransport); + return { client, runtime }; +} + +describe("MCP server foundation", () => { + it("creates the configured DeepTrace server", () => { + expect( + createMcpServer({ + sources: createFixtureComparePoolsSources({ graphResults: [] }), + lendingSources: createFixtureCompareLendingSources({ lendingResults: [] }), + }), + ).toBeDefined(); + expect(serverInfo).toEqual({ + name: "deeptrace", + version: "0.1.0", + }); + }); + + it("exposes all read-only research tools", async () => { + const { client, runtime } = await withClient(); + try { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name).sort()).toEqual( + [ + COMPARE_LENDING_MARKETS_TOOL_NAME, + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_TOOL_NAME, + RESEARCH_WALLET_TOOL_NAME, + ].sort(), + ); + for (const tool of listed.tools) { + expect(tool.annotations?.readOnlyHint).toBe(true); + expect(tool.annotations?.destructiveHint).toBe(false); + expect(tool.annotations?.idempotentHint).toBe(true); + expect(tool.annotations?.openWorldHint).toBe(false); + } + } finally { + await client.close(); + await runtime.close(); + } + }); +}); + +describe("compare_lending_markets MCP tool", () => { + it("returns a ranked lending envelope from the injected gateway", async () => { + const onLendingFetch = vi.fn(); + const { client, runtime } = await withClient( + createFixtureCompareLendingSources({ + lendingResults: completeLendingScenario, + onLendingFetch, + }), + ); + + try { + const result = await client.callTool({ + name: COMPARE_LENDING_MARKETS_TOOL_NAME, + arguments: lockedLendingRequest, + }); + expect(result.isError).toBeFalsy(); + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; + expect(text).toBeTypeOf("string"); + const body = JSON.parse(text!) as { + status: string; + coverage: { requested_sources: number; successful_sources: number }; + data: { rate_basis: string; markets: Array<{ protocol: string }> } | null; + }; + expect(body.status).toBe("complete"); + expect(body.coverage).toEqual({ requested_sources: 3, successful_sources: 3 }); + expect(body.data?.rate_basis).toBe("percent_apy"); + expect(body.data?.markets[0]?.protocol).toBe("aave-v3"); + expect(onLendingFetch).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + await runtime.close(); + } + }); + + it("rejects an out-of-scope market token before calling source adapters", async () => { + const onLendingFetch = vi.fn(); + const { client, runtime } = await withClient( + createFixtureCompareLendingSources({ + lendingResults: completeLendingScenario, + onLendingFetch, + }), + ); + + try { + const result = await client.callTool({ + name: COMPARE_LENDING_MARKETS_TOOL_NAME, + arguments: { + ...lockedLendingRequest, + market_token: "0x4200000000000000000000000000000000000006", + }, + }); + expect(result.isError).toBe(true); + expect((result.content as Array<{ text: string }>)[0]?.text).toMatch( + /Input validation error|Invalid arguments/i, + ); + expect(onLendingFetch).not.toHaveBeenCalled(); + } finally { + await client.close(); + await runtime.close(); + } + }); +}); diff --git a/tests/unit/mcp-smoke.test.mjs b/tests/unit/mcp-smoke.test.mjs new file mode 100644 index 0000000..407512d --- /dev/null +++ b/tests/unit/mcp-smoke.test.mjs @@ -0,0 +1,275 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; + +import { afterEach, describe, expect, it } from "vitest"; + +const TOKEN = "smoke-test-token-that-is-never-printed"; +const SESSION_ID = "smoke-test-session-that-is-never-printed"; +const PROTOCOL_VERSION = "2025-06-18"; + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve(); + else reject(error); + }); + }); +} + +function runSmoke(url) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["scripts/mcp-smoke.mjs"], { + cwd: new URL("../..", import.meta.url), + env: { + ...process.env, + DEEPTRACE_MCP_URL: url, + DEEPTRACE_HTTP_TOKEN: TOKEN, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +const servers = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map(close)); +}); + +describe("MCP smoke session lifecycle", () => { + it.each(["complete", "partial"])("accepts %s and cleans up", async (status) => { + const deletes = []; + const sessionRequests = []; + const server = createServer((request, response) => { + const authorized = request.headers.authorization === `Bearer ${TOKEN}`; + + if (request.method === "GET") { + response.writeHead(404).end(); + return; + } + + if (!authorized) { + response.writeHead(401).end(); + return; + } + + if (request.method === "DELETE") { + deletes.push({ + authorization: request.headers.authorization, + protocolVersion: request.headers["mcp-protocol-version"], + sessionId: request.headers["mcp-session-id"], + }); + response.writeHead(200).end(); + return; + } + + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + const message = JSON.parse(body); + + if (message.method === "initialize") { + const payload = { + jsonrpc: "2.0", + id: message.id, + result: { protocolVersion: PROTOCOL_VERSION }, + }; + response.writeHead(200, { + "content-type": "text/event-stream", + "mcp-session-id": SESSION_ID, + }); + response.end(`data: ${JSON.stringify(payload)}\n\n`); + return; + } + + sessionRequests.push({ + method: message.method, + protocolVersion: request.headers["mcp-protocol-version"], + }); + + if (message.method === "notifications/initialized") { + response.writeHead(500).end(); + return; + } + + if (message.method === "tools/list") { + const payload = { + jsonrpc: "2.0", + id: message.id, + result: { + tools: [ + { name: "compare_pools" }, + { name: "compare_lending_markets" }, + { name: "find_large_swaps" }, + { name: "research_wallet" }, + ], + }, + }; + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(`data: ${JSON.stringify(payload)}\n\n`); + return; + } + + const toolName = message.params?.name; + const resultText = + toolName === "find_large_swaps" + ? JSON.stringify({ + status: "complete", + coverage: { successful_sources: 1, requested_sources: 1 }, + }) + : toolName === "compare_lending_markets" + ? JSON.stringify({ + status, + coverage: { successful_sources: 3, requested_sources: 3 }, + }) + : toolName === "research_wallet" + ? JSON.stringify({ + status: "partial", + coverage: { successful_sources: 1, requested_sources: 2 }, + }) + : JSON.stringify({ + status, + coverage: { successful_deployments: 2, requested_deployments: 2 }, + }); + const payload = { + jsonrpc: "2.0", + id: message.id, + result: { content: [{ type: "text", text: resultText }] }, + }; + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(`data: ${JSON.stringify(payload)}\n\n`); + }); + }); + await listen(server); + servers.push(server); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("test server did not bind to a port"); + } + + const result = await runSmoke(`http://127.0.0.1:${address.port}/mcp`); + + expect(result.code).toBe(1); + expect(result.stdout).toContain("notifications/initialized"); + expect(result.stdout).toContain("FAIL"); + expect(result.stdout).toMatch( + new RegExp(`tools/call compare_pools\\s+PASS\\s+status=${status} 2/2`), + ); + expect(result.stdout).toMatch( + new RegExp(`tools/call compare_lending_markets\\s+PASS\\s+status=${status} 3/3`), + ); + expect(result.stdout).toMatch(/tools\/call find_large_swaps\s+PASS\s+status=complete 1\/1/); + expect(result.stdout).toMatch(/tools\/call research_wallet\s+PASS\s+status=partial 1\/2/); + expect(sessionRequests).toEqual([ + { method: "notifications/initialized", protocolVersion: PROTOCOL_VERSION }, + { method: "tools/list", protocolVersion: PROTOCOL_VERSION }, + { method: "tools/call", protocolVersion: PROTOCOL_VERSION }, + { method: "tools/call", protocolVersion: PROTOCOL_VERSION }, + { method: "tools/call", protocolVersion: PROTOCOL_VERSION }, + { method: "tools/call", protocolVersion: PROTOCOL_VERSION }, + ]); + expect(deletes).toEqual([ + { + authorization: `Bearer ${TOKEN}`, + protocolVersion: PROTOCOL_VERSION, + sessionId: SESSION_ID, + }, + ]); + expect(`${result.stdout}${result.stderr}`).not.toContain(TOKEN); + expect(`${result.stdout}${result.stderr}`).not.toContain(SESSION_ID); + }); + + it("rejects an unsupported negotiated protocol version and still cleans up", async () => { + const deletes = []; + const unexpectedMethods = []; + const server = createServer((request, response) => { + const authorized = request.headers.authorization === `Bearer ${TOKEN}`; + + if (request.method === "GET") { + response.writeHead(404).end(); + return; + } + if (!authorized) { + response.writeHead(401).end(); + return; + } + if (request.method === "DELETE") { + deletes.push({ + protocolVersion: request.headers["mcp-protocol-version"], + sessionId: request.headers["mcp-session-id"], + }); + response.writeHead(200).end(); + return; + } + + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + }); + request.on("end", () => { + const message = JSON.parse(body); + if (message.method !== "initialize") { + unexpectedMethods.push(message.method); + response.writeHead(500).end(); + return; + } + + const payload = { + jsonrpc: "2.0", + id: message.id, + result: { protocolVersion: "2099-01-01" }, + }; + response.writeHead(200, { + "content-type": "text/event-stream", + "mcp-session-id": SESSION_ID, + }); + response.end(`data: ${JSON.stringify(payload)}\n\n`); + }); + }); + await listen(server); + servers.push(server); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("test server did not bind to a port"); + } + + const result = await runSmoke(`http://127.0.0.1:${address.port}/mcp`); + + expect(result.code).toBe(1); + expect(result.stdout).toMatch(/initialize\s+FAIL\s+status=200 invalid protocolVersion/); + expect(result.stdout).toContain("initialize failed; skipping remaining checks"); + expect(unexpectedMethods).toEqual([]); + expect(deletes).toEqual([{ protocolVersion: PROTOCOL_VERSION, sessionId: SESSION_ID }]); + expect(`${result.stdout}${result.stderr}`).not.toContain(TOKEN); + expect(`${result.stdout}${result.stderr}`).not.toContain(SESSION_ID); + expect(`${result.stdout}${result.stderr}`).not.toContain("2099-01-01"); + }); +}); diff --git a/tests/unit/metrics-ranking.test.ts b/tests/unit/metrics-ranking.test.ts new file mode 100644 index 0000000..c377f92 --- /dev/null +++ b/tests/unit/metrics-ranking.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; + +import { + M0_POOL_METRICS_METHODOLOGY, + compareDecimalStrings, + compareMetricDescNullsLast, + rankCanonicalPools, +} from "../../src/metrics/index.js"; +import { + bindComparePoolsGraphResults, + type CanonicalPoolCandidate, +} from "../../src/normalization/index.js"; +import { M0_RANKING_TIE_BREAK } from "../../src/policy/index.js"; +import { graphPoolA, graphPoolB } from "../fixtures/sources/index.js"; + +const WETH = "0x4200000000000000000000000000000000000006"; +const USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; + +function candidate( + overrides: Partial & + Pick, +): CanonicalPoolCandidate { + return { + chain_id: 8453, + pair: [ + { chain_id: 8453, address: WETH, symbol: "WETH", decimals: 18 }, + { chain_id: 8453, address: USDC, symbol: "USDC", decimals: 6 }, + ], + tvl_usd: "100", + volume_usd: "50", + fees_usd: "1", + window: "24h", + ...overrides, + }; +} + +describe("M0 pool metrics methodology", () => { + it("locks source-reported USD selection and ranking tie-breaks", () => { + expect(M0_POOL_METRICS_METHODOLOGY.id).toBe("compare-pools-source-reported-usd-v1"); + expect(M0_POOL_METRICS_METHODOLOGY.unit).toBe("usd"); + expect(M0_POOL_METRICS_METHODOLOGY.window_methodology).toBe("completed-utc-days-v1"); + expect([...M0_POOL_METRICS_METHODOLOGY.ranking.tie_break]).toEqual([...M0_RANKING_TIE_BREAK]); + expect(M0_POOL_METRICS_METHODOLOGY.non_goals).toEqual([ + "apr", + "apy", + "fee_to_tvl_ratio_as_yield", + ]); + }); +}); + +describe("decimal ranking order", () => { + it("compares long decimal strings without JavaScript number coercion", () => { + expect(compareDecimalStrings("9.5", "10")).toBeLessThan(0); + expect(compareDecimalStrings("10.0", "10")).toBe(0); + expect( + compareDecimalStrings( + "6089724.592920848435197967898475142", + "1837918.971826772337839586279587621", + ), + ).toBeGreaterThan(0); + }); + + it("places null metrics after measured values when ranking descending", () => { + expect(compareMetricDescNullsLast("1", null)).toBeLessThan(0); + expect(compareMetricDescNullsLast(null, "1")).toBeGreaterThan(0); + expect(compareMetricDescNullsLast(null, null)).toBe(0); + expect(compareMetricDescNullsLast("2", "10")).toBeGreaterThan(0); + }); +}); + +describe("rankCanonicalPools", () => { + it("ranks live Graph fixtures by volume_usd descending independent of input order", () => { + const forward = rankCanonicalPools( + bindComparePoolsGraphResults([graphPoolA, graphPoolB], "24h"), + { + rankedBy: "volume_usd", + }, + ); + const reversed = rankCanonicalPools( + bindComparePoolsGraphResults([graphPoolB, graphPoolA], "24h"), + { rankedBy: "volume_usd" }, + ); + + expect(forward.map((pool) => pool.source_ids[0])).toEqual([ + graphPoolA.source_id, + graphPoolB.source_id, + ]); + expect(reversed).toEqual(forward); + expect(forward.map((pool) => pool.rank)).toEqual([1, 2]); + expect(forward[0]?.volume_usd).toBe(graphPoolA.data.volume_usd_24h); + }); + + it("applies protocol, pool address, then source_id tie-breaks", () => { + const tied = rankCanonicalPools( + [ + candidate({ + protocol: "protocol-b", + pool_address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + source_ids: ["source-b"], + volume_usd: "100", + }), + candidate({ + protocol: "protocol-a", + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_ids: ["source-z"], + volume_usd: "100", + }), + candidate({ + protocol: "protocol-a", + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_ids: ["source-a"], + volume_usd: "100", + }), + ], + { rankedBy: "volume_usd" }, + ); + + // Same pool address collapses via dedupe first (least source_id primary). + expect(tied).toHaveLength(2); + expect(tied[0]?.protocol).toBe("protocol-a"); + expect(tied[0]?.source_ids).toEqual(["source-a", "source-z"]); + expect(tied[1]?.protocol).toBe("protocol-b"); + }); + + it("keeps null requested metrics after measured values and respects Top-N", () => { + const ranked = rankCanonicalPools( + [ + candidate({ + protocol: "protocol-a", + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_ids: ["a"], + volume_usd: null, + tvl_usd: "999", + }), + candidate({ + protocol: "protocol-b", + pool_address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + source_ids: ["b"], + volume_usd: "10", + }), + candidate({ + protocol: "protocol-c", + pool_address: "0xcccccccccccccccccccccccccccccccccccccccc", + source_ids: ["c"], + volume_usd: "5", + }), + ], + { rankedBy: "volume_usd", topN: 2 }, + ); + + expect(ranked.map((pool) => pool.source_ids[0])).toEqual(["b", "c"]); + expect(ranked).toHaveLength(2); + }); + + it("ranks by tvl_usd and fees_usd when requested", () => { + const byTvl = rankCanonicalPools( + [ + candidate({ + protocol: "protocol-a", + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_ids: ["a"], + tvl_usd: "10", + volume_usd: "1000", + }), + candidate({ + protocol: "protocol-b", + pool_address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + source_ids: ["b"], + tvl_usd: "20", + volume_usd: "1", + }), + ], + { rankedBy: "tvl_usd" }, + ); + expect(byTvl[0]?.source_ids).toEqual(["b"]); + + const byFees = rankCanonicalPools( + [ + candidate({ + protocol: "protocol-a", + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_ids: ["a"], + fees_usd: "3", + }), + candidate({ + protocol: "protocol-b", + pool_address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + source_ids: ["b"], + fees_usd: "9", + }), + ], + { rankedBy: "fees_usd" }, + ); + expect(byFees[0]?.source_ids).toEqual(["b"]); + }); + + it("rejects invalid topN and mixed windows", () => { + expect(() => + rankCanonicalPools( + [ + candidate({ + protocol: "protocol-a", + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_ids: ["a"], + }), + ], + { rankedBy: "volume_usd", topN: 4 }, + ), + ).toThrow(/topN/); + + expect(() => + rankCanonicalPools( + [ + candidate({ + protocol: "protocol-a", + pool_address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + source_ids: ["a"], + window: "24h", + }), + candidate({ + protocol: "protocol-b", + pool_address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + source_ids: ["b"], + window: "7d", + }), + ], + { rankedBy: "volume_usd" }, + ), + ).toThrow(/mixed windows/); + }); +}); diff --git a/tests/unit/normalization-live-binding.test.ts b/tests/unit/normalization-live-binding.test.ts new file mode 100644 index 0000000..fd12a8a --- /dev/null +++ b/tests/unit/normalization-live-binding.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + NormalizationError, + bindComparePoolsGraphResult, + bindComparePoolsGraphResults, + convertPoolSourceResult, + toPoolComparisonRecord, +} from "../../src/normalization/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; +import { livePartialComparePoolsFixture } from "../fixtures/live-compare-pools.js"; +import { graphPoolA, graphPoolB, graphPoolCTimeout } from "../fixtures/sources/index.js"; + +describe("bindComparePoolsGraphResult", () => { + it("normalizes live Graph fixtures identically to the pure converter", () => { + expect(bindComparePoolsGraphResult(graphPoolA, "24h")).toEqual( + convertPoolSourceResult(graphPoolA, "24h"), + ); + expect(bindComparePoolsGraphResult(graphPoolB, "24h")).toEqual( + convertPoolSourceResult(graphPoolB, "24h"), + ); + expect(bindComparePoolsGraphResult(graphPoolA, "7d")).toEqual( + convertPoolSourceResult(graphPoolA, "7d"), + ); + }); + + it("returns null for in-scope non-ok Graph results", () => { + expect(bindComparePoolsGraphResult(graphPoolCTimeout, "24h")).toBeNull(); + }); + + it("binds both live Graph sources and skips timeouts", () => { + const candidates = bindComparePoolsGraphResults( + [graphPoolCTimeout, graphPoolA, graphPoolB], + "24h", + ); + expect(candidates.map((candidate) => candidate.source_ids[0])).toEqual([ + graphPoolA.source_id, + graphPoolB.source_id, + ]); + expect(candidates[0]?.pool_address).toBe(M0_COMPARE_POOLS_SCOPE.graphSources[0].pool_address); + expect(candidates[1]?.pool_address).toBe(M0_COMPARE_POOLS_SCOPE.graphSources[1].pool_address); + }); + + it("matches live compare_pools fixture pool identities after explicit ranking", () => { + const candidates = bindComparePoolsGraphResults([graphPoolA, graphPoolB], "24h"); + const ranked = [ + toPoolComparisonRecord(candidates[0]!, 1), + toPoolComparisonRecord(candidates[1]!, 2), + ]; + + expect(ranked.map((pool) => pool.pool_address)).toEqual( + livePartialComparePoolsFixture.data.pools.map((pool) => pool.pool_address), + ); + expect(ranked.map((pool) => pool.source_ids)).toEqual( + livePartialComparePoolsFixture.data.pools.map((pool) => [...pool.source_ids]), + ); + expect(ranked.map((pool) => pool.volume_usd)).toEqual( + livePartialComparePoolsFixture.data.pools.map((pool) => pool.volume_usd), + ); + }); + + it("rejects unknown Graph source IDs before conversion", () => { + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + source_id: "invented-dex", + }, + "24h", + ), + ).toThrow(/not in the locked compare_pools Graph allowlist/); + }); + + it("rejects Nuthatch results as Graph pool candidates", () => { + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + source_id: M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, + }, + "24h", + ), + ).toThrow(/Nuthatch results are not Graph pool candidates/); + }); + + it("rejects mismatched deployment, pool, protocol, and pair before metrics", () => { + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + provenance: { + ...graphPoolA.provenance, + query_id: "other-query-v1", + }, + }, + "24h", + ), + ).toThrow(/query_id does not match/); + + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + data: { + ...graphPoolA.data, + token0: { + ...graphPoolA.data.token0, + symbol: "WETHX", + }, + }, + }, + "24h", + ), + ).toThrow(/token metadata does not match/); + + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + provenance: { + ...graphPoolA.provenance, + deployment_or_view_id: "QmWrongDeploymentHashxxxxxxxxxxxxxxxxxxxxxxxxx", + }, + }, + "24h", + ), + ).toThrow(/deployment does not match/); + + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + data: { + ...graphPoolA.data, + pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + }, + }, + "24h", + ), + ).toThrow(/pool address does not match/); + + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + protocol: "sushiswap-v3", + }, + "24h", + ), + ).toThrow(/protocol does not match/); + + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + data: { + ...graphPoolA.data, + token1: { + address: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + symbol: "USDbC", + decimals: 6, + }, + }, + }, + "24h", + ), + ).toThrow(/outside the locked compare_pools pair/); + }); + + it("rejects wrong chain_id before conversion", () => { + expect(() => + bindComparePoolsGraphResult( + { + ...graphPoolA, + chain_id: 1 as never, + }, + "24h", + ), + ).toThrow(NormalizationError); + }); +}); diff --git a/tests/unit/normalization.test.ts b/tests/unit/normalization.test.ts new file mode 100644 index 0000000..ff220e6 --- /dev/null +++ b/tests/unit/normalization.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, it } from "vitest"; + +import { + DUPLICATE_POOL_COLLAPSE_POLICY, + NormalizationError, + convertPoolSourceResult, + deduplicateCanonicalPools, + normalizeAddress, + normalizeCanonicalPair, + pairIdentity, + poolIdentity, + toPoolComparisonRecord, + tokenIdentity, + type CanonicalPoolCandidate, +} from "../../src/normalization/index.js"; +import { graphPoolA, graphPoolC, graphPoolCTimeout } from "../fixtures/sources/index.js"; + +const WETH = "0x4200000000000000000000000000000000000006"; +const USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; +const MIXED_CASE_WETH = "0x4200000000000000000000000000000000000006" + .split("") + .map((char, index) => (index > 1 && index % 2 === 0 ? char.toUpperCase() : char)) + .join(""); +const MIXED_CASE_USDC = "0x833589FCd6EdB6E08F4C7C32D4F71B54BDA02913"; +const POOL_A = "0x00000000000000000000000000000000000000a1"; +const MIXED_CASE_POOL_A = "0x00000000000000000000000000000000000000A1"; +const POOL_B = "0x00000000000000000000000000000000000000b2"; + +function candidateFixture( + overrides: Partial & + Pick, +): CanonicalPoolCandidate { + return { + chain_id: 8453, + protocol: "fixture-dex-a", + pair: [ + { + chain_id: 8453, + address: WETH, + symbol: "WETH", + decimals: 18, + }, + { + chain_id: 8453, + address: USDC, + symbol: "USDC", + decimals: 6, + }, + ], + tvl_usd: "100", + volume_usd: "50", + fees_usd: "1", + window: "24h", + ...overrides, + }; +} + +describe("address normalization", () => { + it("lowercases mixed-case addresses", () => { + expect(normalizeAddress(MIXED_CASE_WETH)).toBe(WETH); + expect(normalizeAddress(MIXED_CASE_USDC)).toBe(USDC); + expect(normalizeAddress(MIXED_CASE_POOL_A)).toBe(POOL_A); + }); + + it("rejects invalid addresses", () => { + for (const address of [ + "0xnot-an-address", + "0x1234", + "4200000000000000000000000000000000000006", + "0x420000000000000000000000000000000000000g", + "", + ]) { + expect(() => normalizeAddress(address)).toThrow(NormalizationError); + } + }); +}); + +describe("chain-aware identities", () => { + it("treats token and pool identities as casing-insensitive", () => { + expect(tokenIdentity(8453, MIXED_CASE_WETH)).toBe(tokenIdentity(8453, WETH)); + expect(poolIdentity(8453, MIXED_CASE_POOL_A)).toBe(poolIdentity(8453, POOL_A)); + }); + + it("keeps identities chain-aware and rejects mixed identical pair legs", () => { + expect(tokenIdentity(8453, WETH)).not.toBe(tokenIdentity(1, WETH)); + expect(poolIdentity(8453, POOL_A)).not.toBe(poolIdentity(1, POOL_A)); + expect(() => pairIdentity(8453, WETH, MIXED_CASE_WETH)).toThrow(NormalizationError); + }); + + it("builds the same pair identity regardless of token order or casing", () => { + expect(pairIdentity(8453, MIXED_CASE_WETH, MIXED_CASE_USDC)).toBe( + pairIdentity(8453, USDC, WETH), + ); + expect(pairIdentity(8453, WETH, USDC)).toBe(`8453:${WETH}:${USDC}`); + }); +}); + +describe("canonical pair ordering", () => { + it("orders pairs by address independent of source token order and casing", () => { + const forward = normalizeCanonicalPair( + 8453, + { address: MIXED_CASE_WETH, symbol: "WETH", decimals: 18 }, + { address: MIXED_CASE_USDC, symbol: "USDC", decimals: 6 }, + ); + const reversed = normalizeCanonicalPair( + 8453, + { address: USDC, symbol: "USDC", decimals: 6 }, + { address: WETH, symbol: "WETH", decimals: 18 }, + ); + + expect(forward).toEqual(reversed); + expect(forward[0]?.address).toBe(WETH); + expect(forward[1]?.address).toBe(USDC); + expect(pairIdentity(8453, forward[0].address, forward[1].address)).toBe( + pairIdentity(8453, reversed[0].address, reversed[1].address), + ); + }); + + it("rejects token decimals outside the canonical schema boundary", () => { + expect(() => + normalizeCanonicalPair( + 8453, + { address: WETH, symbol: "WETH", decimals: 256 }, + { address: USDC, symbol: "USDC", decimals: 6 }, + ), + ).toThrow(NormalizationError); + }); +}); + +describe("pool source conversion", () => { + it("selects 24h and 7d metrics without rewriting other fields", () => { + const day = convertPoolSourceResult(graphPoolA, "24h"); + const week = convertPoolSourceResult(graphPoolA, "7d"); + + expect(day).not.toBeNull(); + expect(week).not.toBeNull(); + expect(day?.tvl_usd).toBe(graphPoolA.data.tvl_usd); + expect(day?.volume_usd).toBe(graphPoolA.data.volume_usd_24h); + expect(day?.fees_usd).toBe(graphPoolA.data.fees_usd_24h); + expect(day?.window).toBe("24h"); + expect(week?.volume_usd).toBe(graphPoolA.data.volume_usd_7d); + expect(week?.fees_usd).toBe(graphPoolA.data.fees_usd_7d); + expect(week?.window).toBe("7d"); + }); + + it("canonicalizes reversed source token order to the same pair identity", () => { + const forward = convertPoolSourceResult(graphPoolA, "24h"); + const reversed = convertPoolSourceResult( + { + ...graphPoolA, + data: { + ...graphPoolA.data, + token0: graphPoolA.data.token1, + token1: graphPoolA.data.token0, + }, + }, + "24h", + ); + + expect(forward?.pair).toEqual(reversed?.pair); + expect(pairIdentity(8453, forward!.pair[0].address, forward!.pair[1].address)).toBe( + pairIdentity(8453, reversed!.pair[0].address, reversed!.pair[1].address), + ); + }); + + it("passes high-precision decimals through unchanged and never via floating point", () => { + const precise = "123456789012345678901234567890.123456789012345678901234567890"; + const result = { + ...graphPoolA, + data: { + ...graphPoolA.data, + tvl_usd: precise, + volume_usd_24h: precise, + fees_usd_24h: "0", + }, + }; + + const converted = convertPoolSourceResult(result, "24h"); + expect(converted?.tvl_usd).toBe(precise); + expect(converted?.volume_usd).toBe(precise); + expect(converted?.fees_usd).toBe("0"); + expect(converted?.tvl_usd).not.toBe(Number(precise)); + expect(typeof converted?.tvl_usd).toBe("string"); + }); + + it("preserves measured zero as distinct from null", () => { + const withZero = { + ...graphPoolC, + data: { + ...graphPoolC.data, + tvl_usd: "0", + volume_usd_24h: "0", + fees_usd_24h: null, + volume_usd_7d: null, + fees_usd_7d: "0", + }, + }; + + const day = convertPoolSourceResult(withZero, "24h"); + const week = convertPoolSourceResult(withZero, "7d"); + + expect(day?.tvl_usd).toBe("0"); + expect(day?.volume_usd).toBe("0"); + expect(day?.fees_usd).toBeNull(); + expect(week?.volume_usd).toBeNull(); + expect(week?.fees_usd).toBe("0"); + expect(day?.fees_usd).not.toBe(day?.volume_usd); + }); + + it("does not convert failed source results", () => { + expect(convertPoolSourceResult(graphPoolCTimeout, "24h")).toBeNull(); + expect(convertPoolSourceResult(graphPoolCTimeout, "7d")).toBeNull(); + }); + + it("rejects windows outside the explicit normalization boundary", () => { + expect(() => convertPoolSourceResult(graphPoolA, "30d" as never)).toThrow(NormalizationError); + }); + + it("keeps canonical candidates rank-free and requires an explicit rank boundary", () => { + const candidate = convertPoolSourceResult(graphPoolA, "24h"); + expect(candidate).not.toBeNull(); + expect(candidate).not.toHaveProperty("rank"); + + const ranked = toPoolComparisonRecord(candidate!, 1); + expect(ranked.rank).toBe(1); + expect(ranked.source_ids).toEqual(candidate!.source_ids); + expect(() => toPoolComparisonRecord(candidate!, 0)).toThrow(NormalizationError); + }); +}); + +describe("deterministic pool deduplication", () => { + it("collapses the same chain+pool address under reversed input order", () => { + const first = candidateFixture({ + pool_address: POOL_A, + source_ids: ["fixture-graph-dex-b"], + tvl_usd: "200", + volume_usd: "80", + fees_usd: "2", + protocol: "fixture-dex-b", + }); + const second = candidateFixture({ + pool_address: MIXED_CASE_POOL_A, + source_ids: ["fixture-graph-dex-a"], + tvl_usd: "100", + volume_usd: "50", + fees_usd: "1", + protocol: "fixture-dex-a", + }); + + const forward = deduplicateCanonicalPools([first, second]); + const reversed = deduplicateCanonicalPools([second, first]); + + expect(forward).toEqual(reversed); + expect(forward).toHaveLength(1); + expect(forward[0]?.pool_address).toBe(POOL_A); + expect(forward[0]?.pair.map(({ address }) => address)).toEqual([WETH, USDC]); + expect(forward[0]?.source_ids).toEqual(["fixture-graph-dex-a", "fixture-graph-dex-b"]); + }); + + it("canonicalizes primary pair address casing during duplicate collapse", () => { + const mixedCasePair = candidateFixture({ + pool_address: POOL_A, + source_ids: ["fixture-a"], + pair: [ + { + chain_id: 8453, + address: MIXED_CASE_WETH, + symbol: "WETH", + decimals: 18, + }, + { + chain_id: 8453, + address: MIXED_CASE_USDC, + symbol: "USDC", + decimals: 6, + }, + ], + }); + + const [deduplicated] = deduplicateCanonicalPools([mixedCasePair]); + expect(deduplicated?.pair.map(({ address }) => address)).toEqual([WETH, USDC]); + }); + + it("sorts unique source IDs and applies the explicit conflict policy", () => { + expect(DUPLICATE_POOL_COLLAPSE_POLICY).toBe( + "primary_by_least_source_id_passthrough_metrics_merge_sorted_source_ids", + ); + + const laterSource = candidateFixture({ + pool_address: POOL_A, + source_ids: ["z-source", "m-source"], + tvl_usd: "999", + volume_usd: "999", + fees_usd: "999", + protocol: "should-not-win", + }); + const earlierSource = candidateFixture({ + pool_address: POOL_A, + source_ids: ["a-source", "a-source"], + tvl_usd: "10", + volume_usd: "20", + fees_usd: "0", + protocol: "primary-protocol", + }); + + const [merged] = deduplicateCanonicalPools([laterSource, earlierSource]); + + expect(merged?.protocol).toBe("primary-protocol"); + expect(merged?.tvl_usd).toBe("10"); + expect(merged?.volume_usd).toBe("20"); + expect(merged?.fees_usd).toBe("0"); + expect(merged?.source_ids).toEqual(["a-source", "m-source", "z-source"]); + expect(merged?.tvl_usd).not.toBe("504.5"); + expect(merged?.volume_usd).not.toBe("1019"); + }); + + it("uses a total deterministic tie-breaker when duplicate source IDs match", () => { + const first = candidateFixture({ + pool_address: POOL_A, + source_ids: ["same-source"], + tvl_usd: "20", + }); + const second = candidateFixture({ + pool_address: POOL_A, + source_ids: ["same-source"], + tvl_usd: "10", + }); + + const forward = deduplicateCanonicalPools([first, second]); + expect(forward).toEqual(deduplicateCanonicalPools([second, first])); + expect(forward[0]?.tvl_usd).toBe("10"); + }); + + it("does not fill a primary null financial value from a duplicate", () => { + const secondary = candidateFixture({ + pool_address: POOL_A, + source_ids: ["z-source"], + volume_usd: "500", + }); + const primary = candidateFixture({ + pool_address: POOL_A, + source_ids: ["a-source"], + volume_usd: null, + }); + + const [merged] = deduplicateCanonicalPools([secondary, primary]); + expect(merged?.volume_usd).toBeNull(); + }); + + it("rejects duplicate pool identities with incompatible pairs or windows", () => { + const base = candidateFixture({ + pool_address: POOL_A, + source_ids: ["fixture-a"], + }); + const differentPair = candidateFixture({ + pool_address: POOL_A, + source_ids: ["fixture-b"], + pair: [ + base.pair[0], + { + ...base.pair[1], + address: "0x9999999999999999999999999999999999999999", + }, + ], + }); + const differentWindow = candidateFixture({ + pool_address: POOL_A, + source_ids: ["fixture-c"], + window: "7d", + }); + + expect(() => deduplicateCanonicalPools([base, differentPair])).toThrow(NormalizationError); + expect(() => deduplicateCanonicalPools([base, differentWindow])).toThrow(NormalizationError); + }); + + it("does not collapse differing chain or pool identities", () => { + const basePool = candidateFixture({ + pool_address: POOL_A, + source_ids: ["fixture-a"], + }); + const otherPool = candidateFixture({ + pool_address: POOL_B, + source_ids: ["fixture-b"], + }); + + expect(poolIdentity(8453, POOL_A)).not.toBe(poolIdentity(1, POOL_A)); + expect(poolIdentity(8453, POOL_A)).not.toBe(poolIdentity(8453, POOL_B)); + expect(tokenIdentity(8453, WETH)).not.toBe(tokenIdentity(1, WETH)); + expect(pairIdentity(8453, WETH, USDC)).not.toBe(pairIdentity(1, WETH, USDC)); + + const deduped = deduplicateCanonicalPools([basePool, otherPool, basePool]); + expect(deduped).toHaveLength(2); + expect(deduped.map((pool) => pool.pool_address)).toEqual([POOL_A, POOL_B]); + expect(deduped[0]?.source_ids).toEqual(["fixture-a"]); + }); +}); diff --git a/tests/unit/nuthatch-watchdog-config.test.mjs b/tests/unit/nuthatch-watchdog-config.test.mjs new file mode 100644 index 0000000..2649c3c --- /dev/null +++ b/tests/unit/nuthatch-watchdog-config.test.mjs @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +describe("Nuthatch watchdog deployment configuration", () => { + it("keeps valid credential-free public RPC fallbacks in the nest", async () => { + const config = await readFile(new URL("../../nest/nuthatch.toml", import.meta.url), "utf8"); + const block = config.match(/rpc_urls\s*=\s*\[([^\]]*)\]/); + expect(block).not.toBeNull(); + + const declarations = block[1] + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + expect(declarations.length).toBeGreaterThan(1); + expect(config).not.toContain("${"); + + for (const declaration of declarations) { + expect(declaration).toMatch(/^"[^"]+"$/); + const endpoint = new URL(JSON.parse(declaration)); + expect(endpoint.protocol).toBe("https:"); + expect(endpoint.username).toBe(""); + expect(endpoint.password).toBe(""); + expect(endpoint.hostname).not.toBe("mainnet.base.org"); + } + }); +}); diff --git a/tests/unit/nuthatch-watchdog.test.mjs b/tests/unit/nuthatch-watchdog.test.mjs new file mode 100644 index 0000000..f845636 --- /dev/null +++ b/tests/unit/nuthatch-watchdog.test.mjs @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateProgress, + parseReadyPayload, + parseWatchdogState, + positiveIntegerSetting, +} from "../../scripts/m4/nuthatch-watchdog-lib.mjs"; + +const sample = (lastBlock, tip, ready = true, stalled = false) => ({ + ready, + lastBlock, + tip, + lagBlocks: tip - lastBlock, + reportedStalled: stalled, +}); + +describe("Nuthatch backfill watchdog", () => { + it("accepts both current last_block and v0.6.1 sealed_through payloads", () => { + expect( + parseReadyPayload({ + ready: true, + last_block: 49_065_709, + tip: 49_116_811, + stalled: false, + }).lastBlock, + ).toBe(49_065_709); + expect( + parseReadyPayload({ + ready: true, + sealed_through: 49_065_709, + tip: 49_116_811, + stalled: false, + }).lastBlock, + ).toBe(49_065_709); + }); + + it("alerts after elapsed no-progress time even when stalled is false", () => { + const baseline = evaluateProgress(null, sample(49_065_709, 49_116_000), 1_000, 600); + const waiting = evaluateProgress(baseline.state, sample(49_065_709, 49_116_400), 1_599, 600); + const alert = evaluateProgress(waiting.state, sample(49_065_709, 49_116_811), 1_600, 600); + + expect(waiting.report).toMatchObject({ + status: "ok", + reason: "waiting", + reported_stalled: false, + seconds_since_progress: 599, + }); + expect(alert.report).toMatchObject({ + status: "alert", + reason: "no_progress", + reported_stalled: false, + seconds_since_progress: 600, + }); + }); + + it("resets the elapsed timer only when the high-water block advances", () => { + const baseline = evaluateProgress(null, sample(100, 200), 1_000, 600); + const progress = evaluateProgress(baseline.state, sample(101, 205), 1_500, 600); + + expect(progress.report).toMatchObject({ + status: "ok", + reason: "progress", + high_water_block: 101, + seconds_since_progress: 0, + }); + expect(progress.state.last_progress_at).toBe(1_500); + }); + + it("alerts immediately on block regression and preserves the high-water mark", () => { + const baseline = evaluateProgress(null, sample(100, 200), 1_000, 600); + const regression = evaluateProgress(baseline.state, sample(99, 205), 1_001, 600); + + expect(regression.report).toMatchObject({ + status: "alert", + reason: "regressed", + last_block: 99, + high_water_block: 100, + }); + }); + + it("does not flag a caught-up indexer when the chain tip is unchanged", () => { + const baseline = evaluateProgress(null, sample(200, 200), 1_000, 600); + const caughtUp = evaluateProgress(baseline.state, sample(200, 200), 2_000, 600); + + expect(caughtUp.report).toMatchObject({ + status: "ok", + reason: "caught_up", + lag_blocks: 0, + seconds_since_progress: 0, + }); + }); + + it("treats ready:false as an alert independently of block movement", () => { + const baseline = evaluateProgress(null, sample(100, 200), 1_000, 600); + const result = evaluateProgress(baseline.state, sample(101, 200, false), 1_010, 600); + expect(result.report).toMatchObject({ status: "alert", reason: "not_ready" }); + }); + + it("rejects malformed payload, state, and settings", () => { + expect(() => parseReadyPayload({ ready: true, last_block: "1", tip: 2 })).toThrow(); + expect(() => + parseWatchdogState({ + version: 1, + high_water_block: 10, + last_block: 11, + last_observed_at: 2, + last_progress_at: 1, + }), + ).toThrow(/invariants/); + expect(() => positiveIntegerSetting("0", 600, "STALL")).toThrow(/positive integer/); + }); +}); diff --git a/tests/unit/oauth.test.ts b/tests/unit/oauth.test.ts new file mode 100644 index 0000000..807b66e --- /dev/null +++ b/tests/unit/oauth.test.ts @@ -0,0 +1,375 @@ +import { createHash, randomBytes } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { HTTP_DEFAULTS, MIN_TOKEN_LENGTH } from "../../src/http/config.js"; +import { resetOAuthState } from "../../src/http/oauth.js"; +import { createHttpServer, listen, type HttpRuntime } from "../../src/http/server.js"; +import { TokenStore } from "../../src/http/token-store.js"; + +const SHARED_TOKEN = "a".repeat(MIN_TOKEN_LENGTH); +const CALLBACK = "http://127.0.0.1:53211/callback"; + +async function startServer(): Promise<{ runtime: HttpRuntime; origin: string }> { + const config = { ...HTTP_DEFAULTS, host: "127.0.0.1", port: 0, sharedToken: SHARED_TOKEN }; + const path = join(mkdtempSync(join(tmpdir(), "deeptrace-oauth-")), "tokens.json"); + const runtime = createHttpServer(config, { tokenStore: new TokenStore(path) }); + await listen(runtime, config); + const address = runtime.server.address(); + if (address === null || typeof address === "string") { + throw new Error("test server did not bind to a port"); + } + return { runtime, origin: `http://127.0.0.1:${address.port}` }; +} + +function pkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("base64url"); + return { + verifier, + challenge: createHash("sha256").update(verifier, "utf8").digest("base64url"), + }; +} + +async function register( + origin: string, + redirectUri = CALLBACK, + sentOrigin?: string, +): Promise { + return fetch(`${origin}/oauth/register`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(sentOrigin === undefined ? {} : { origin: sentOrigin }), + }, + body: JSON.stringify({ redirect_uris: [redirectUri], client_name: "Test Client" }), + }); +} + +afterEach(() => { + resetOAuthState(); +}); + +describe("OAuth browser flow", () => { + it("advertises the flow on an unauthenticated MCP request", async () => { + const { runtime, origin } = await startServer(); + try { + const response = await fetch(origin, { method: "POST" }); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain( + 'resource_metadata="https://mcp.ikodo.dev/.well-known/oauth-protected-resource"', + ); + await response.text(); + } finally { + await runtime.close(); + } + }); + + it("publishes both discovery documents", async () => { + const { runtime, origin } = await startServer(); + try { + const resource = (await ( + await fetch(`${origin}/.well-known/oauth-protected-resource`) + ).json()) as Record; + const server = (await ( + await fetch(`${origin}/.well-known/oauth-authorization-server`) + ).json()) as Record; + + expect(resource.authorization_servers).toEqual(["https://mcp.ikodo.dev"]); + expect(server.code_challenge_methods_supported).toEqual(["S256"]); + expect(server.grant_types_supported).toEqual(["authorization_code"]); + expect(server.token_endpoint).toBe("https://mcp.ikodo.dev/oauth/token"); + } finally { + await runtime.close(); + } + }); + + it("registers a client that calls back to loopback", async () => { + const { runtime, origin } = await startServer(); + try { + const body = (await (await register(origin)).json()) as Record; + + expect(body.client_id).toEqual(expect.any(String)); + expect(body.token_endpoint_auth_method).toBe("none"); + expect(body).not.toHaveProperty("client_secret"); + } finally { + await runtime.close(); + } + }); + + it("refuses to register a callback that leaves this machine", async () => { + const { runtime, origin } = await startServer(); + try { + const response = await register(origin, "https://evil.example/callback"); + + expect(response.status).toBe(400); + expect(((await response.json()) as { error: string }).error).toBe("invalid_redirect_uri"); + } finally { + await runtime.close(); + } + }); + + it("carries a client all the way from consent to a working token", async () => { + const { runtime, origin } = await startServer(); + try { + const { client_id } = (await (await register(origin)).json()) as { client_id: string }; + const { verifier, challenge } = pkce(); + + const authorize = new URL(`${origin}/oauth/authorize`); + authorize.searchParams.set("response_type", "code"); + authorize.searchParams.set("client_id", client_id); + authorize.searchParams.set("redirect_uri", CALLBACK); + authorize.searchParams.set("code_challenge", challenge); + authorize.searchParams.set("code_challenge_method", "S256"); + authorize.searchParams.set("state", "xyz"); + + const consent = await fetch(authorize); + const html = await consent.text(); + expect(consent.status).toBe(200); + expect(html).toContain("Test Client"); + expect(html).toContain("Approve"); + expect(html).not.toMatch(/