diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index 7638e67..ea5e40e 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -459,18 +459,21 @@ 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 deployment additionally requires `pool_swap_search`: +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 * FROM pool_swap_search ORDER BY block_number DESC, log_index DESC LIMIT 1" \ + "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` nest check before accepting a new nest -bundle. +Run the committed `swap_search_parity` and `wallet_activity_parity` nest checks +before accepting a new nest bundle. ## Troubleshooting diff --git a/docs/wallet-source-scope.md b/docs/wallet-source-scope.md index 9cb48ad..f402db6 100644 --- a/docs/wallet-source-scope.md +++ b/docs/wallet-source-scope.md @@ -27,24 +27,25 @@ price or revalue a position. ## Nuthatch coverage -The dedicated `wallet_swap_activity` view exposes sender/recipient-aware Swap -events from the one allowlisted Uniswap v3 WETH/USDC pool: +The dedicated `wallet_swap_activity` view documents the sender/recipient-aware +Swap projection from the one allowlisted Uniswap v3 WETH/USDC pool: `0x6c561b446416e1a00e8e93e221854d6ea4171372` -The adapter 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. 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. - -The local environment did not provide `NUTHATCH_BASE_URL` during WR -implementation. Unit and contract tests therefore verify the adapter boundary, -but a deployed-view receipt and transaction-receipt parity packet remain a -release gate. Until that gate passes, live responses preserve Graph positions -as `partial` and report Nuthatch coverage as unavailable. +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 diff --git a/skills/deeptrace-pool-research/SKILL.md b/skills/deeptrace-pool-research/SKILL.md index 9c1453d..e6f29d7 100644 --- a/skills/deeptrace-pool-research/SKILL.md +++ b/skills/deeptrace-pool-research/SKILL.md @@ -34,8 +34,9 @@ plainly instead of substituting a tool that answers a different question. 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. -- Do not connect to Nuthatch from the user's device. DeepTrace queries the - private Nuthatch service on the server. +- 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. @@ -157,7 +158,13 @@ coverage. 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. + 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 @@ -199,7 +206,11 @@ 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. +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 diff --git a/src/sources/nuthatch/client.test.ts b/src/sources/nuthatch/client.test.ts index 57f78b7..c511604 100644 --- a/src/sources/nuthatch/client.test.ts +++ b/src/sources/nuthatch/client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createNuthatchClient, + httpErrorDetail, NUTHATCH_DEFAULT_TIMEOUT_MS, NUTHATCH_MAX_ROWS_CEILING, NUTHATCH_MAX_ROWS_FLOOR, @@ -161,10 +162,24 @@ describe("nuthatch client result mapping", () => { 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" } }), diff --git a/src/sources/nuthatch/client.ts b/src/sources/nuthatch/client.ts index 3220268..ee4d8d2 100644 --- a/src/sources/nuthatch/client.ts +++ b/src/sources/nuthatch/client.ts @@ -224,6 +224,32 @@ function safeErrorMessage(error: unknown, endpoint: string, timedOut: boolean): 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; @@ -307,10 +333,17 @@ export function createNuthatchClient(options: NuthatchClientOptions): NuthatchCl } if (response.status < 200 || response.status >= 300) { + const detail = httpErrorDetail(body); return { ok: false, status: response.status, - error: { kind: "http", message: `${endpoint} returned HTTP ${String(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), }; } diff --git a/src/sources/nuthatch/large-swaps-query.ts b/src/sources/nuthatch/large-swaps-query.ts index 7577795..0492f0b 100644 --- a/src/sources/nuthatch/large-swaps-query.ts +++ b/src/sources/nuthatch/large-swaps-query.ts @@ -3,16 +3,23 @@ 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 - pool_address, + address AS pool_address, block_number, block_hash, block_timestamp, - transaction_hash, + tx_hash AS transaction_hash, log_index, - amount0_raw, - amount1_raw -FROM ${LSS_SCOPE.source.viewId}`; + amount0 AS amount0_raw, + amount1 AS amount1_raw +FROM pool__swap`; function safeInteger(value: number, name: string): string { if (!Number.isSafeInteger(value) || value < 0) { @@ -33,7 +40,7 @@ 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, CAST(transaction_hash AS VARCHAR) ASC +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC LIMIT 1`; } @@ -71,14 +78,14 @@ export function buildLargeSwapScanQuery(options: LargeSwapScanQueryOptions): str OR ( block_number = ${block} AND log_index = ${log} - AND CAST(transaction_hash AS VARCHAR) ${hashOperator} '${options.after.transaction_hash}' + AND tx_hash ${hashOperator} '${options.after.transaction_hash}' ) )`; } return `${SELECT_COLUMNS} WHERE block_number <= ${snapshotHead}${keyset} -ORDER BY block_number DESC, log_index DESC, CAST(transaction_hash AS VARCHAR) ASC +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC LIMIT ${String(options.limit)}`; } diff --git a/src/sources/nuthatch/wallet-activity-adapter.ts b/src/sources/nuthatch/wallet-activity-adapter.ts index 3e0317f..e13f419 100644 --- a/src/sources/nuthatch/wallet-activity-adapter.ts +++ b/src/sources/nuthatch/wallet-activity-adapter.ts @@ -218,7 +218,14 @@ export async function fetchNuthatchWalletActivity( return failed(record, "stale", "Nuthatch wallet activity readiness is invalid."); } - const [nestResult, schemaResult] = await Promise.all([client.nest(), client.schema()]); + // 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); } @@ -235,7 +242,6 @@ export async function fetchNuthatchWalletActivity( ); } - const headResult = await client.sql(buildWalletActivityHeadQuery(context.snapshotHead), 1); if (!headResult.ok) { return failed(record, httpStatus(headResult), headResult.error.message); } diff --git a/src/sources/nuthatch/wallet-activity-query.ts b/src/sources/nuthatch/wallet-activity-query.ts index a2c1117..689b51a 100644 --- a/src/sources/nuthatch/wallet-activity-query.ts +++ b/src/sources/nuthatch/wallet-activity-query.ts @@ -3,18 +3,26 @@ 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 - pool_address, + address AS pool_address, block_number, block_hash, block_timestamp, - transaction_hash, + tx_hash AS transaction_hash, log_index, sender, recipient, - amount0_raw, - amount1_raw -FROM ${WALLET_RESEARCH_SCOPE.nuthatch.viewId}`; + amount0 AS amount0_raw, + amount1 AS amount1_raw +FROM pool__swap`; function safeInteger(value: number, field: string): string { if (!Number.isSafeInteger(value) || value < 0) { @@ -29,7 +37,7 @@ export function buildWalletActivityHeadQuery(snapshotHead: number | null): strin ? "" : `\nWHERE block_number <= ${safeInteger(snapshotHead, "snapshotHead")}`; return `${SELECT_COLUMNS}${bound} -ORDER BY block_number DESC, log_index DESC, CAST(transaction_hash AS VARCHAR) ASC +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC LIMIT 1`; } @@ -48,8 +56,8 @@ export function buildWalletActivityQuery( return `${SELECT_COLUMNS} WHERE block_number <= ${head} AND block_timestamp >= ${start} - AND (CAST(sender AS VARCHAR) = '${wallet}' OR CAST(recipient AS VARCHAR) = '${wallet}') -ORDER BY block_number DESC, log_index DESC, CAST(transaction_hash AS VARCHAR) ASC + AND (sender = '${wallet}' OR recipient = '${wallet}') +ORDER BY block_number DESC, log_index DESC, transaction_hash ASC LIMIT ${String(limit)}`; } diff --git a/tests/unit/large-swaps-adapter.test.ts b/tests/unit/large-swaps-adapter.test.ts index bb62e8e..932c85b 100644 --- a/tests/unit/large-swaps-adapter.test.ts +++ b/tests/unit/large-swaps-adapter.test.ts @@ -92,9 +92,7 @@ function clientFor(rows: readonly NuthatchLargeSwapRow[]) { 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 = /CAST\(transaction_hash AS VARCHAR\) (>=|>) '(0x[0-9a-f]{64})'/.exec( - query, - ); + const anchorMatch = /tx_hash (>=|>) '(0x[0-9a-f]{64})'/.exec(query); if (anchorMatch === null) { return Promise.resolve(ok(receipt(candidates.slice(0, maxRows)))); } @@ -123,10 +121,11 @@ describe("Nuthatch large-swap query boundary", () => { limit: 25, }); - expect(query).toContain("FROM pool_swap_search"); + expect(query).toContain("FROM pool__swap"); expect(query).toContain("block_number <= 100"); expect(query).toContain("block_number < 99"); - expect(query).toContain(`CAST(transaction_hash AS VARCHAR) > '0x${"a".repeat(64)}'`); + 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); @@ -285,12 +284,8 @@ describe("Nuthatch large-swap adapter", () => { [...matchIndexes].map((index) => rows[index]!.transaction_hash), ); expect(sqlQueries).toHaveLength(4); - expect(sqlQueries[2]).toContain( - `CAST(transaction_hash AS VARCHAR) > '${rows[255]!.transaction_hash}'`, - ); - expect(sqlQueries[3]).toContain( - `CAST(transaction_hash AS VARCHAR) > '${rows[511]!.transaction_hash}'`, - ); + 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 () => { @@ -346,9 +341,7 @@ describe("Nuthatch large-swap adapter", () => { expect(continuationAnchors.length).toBeGreaterThan(1); expect( continuationAnchors.every((anchor) => - sqlQueries.some((query) => - query.includes(`CAST(transaction_hash AS VARCHAR) >= '${anchor}'`), - ), + sqlQueries.some((query) => query.includes(`tx_hash >= '${anchor}'`)), ), ).toBe(true); }); diff --git a/tests/unit/live-large-swaps.test.ts b/tests/unit/live-large-swaps.test.ts index 5af04a4..4441950 100644 --- a/tests/unit/live-large-swaps.test.ts +++ b/tests/unit/live-large-swaps.test.ts @@ -88,7 +88,7 @@ describe("live find_large_swaps source", () => { .filter(({ pathname }) => pathname === "/sql") .map(({ searchParams }) => searchParams.get("q") ?? ""); expect(sqlQueries).toHaveLength(2); - expect(sqlQueries.every((query) => query.includes("pool_swap_search"))).toBe(true); + expect(sqlQueries.every((query) => query.includes("pool__swap"))).toBe(true); expect(sqlQueries.every((query) => !query.includes(LSS_SCOPE.tokens.weth.address))).toBe(true); }); diff --git a/tests/unit/wallet-activity-adapter.test.ts b/tests/unit/wallet-activity-adapter.test.ts index 78d2f9e..209cd97 100644 --- a/tests/unit/wallet-activity-adapter.test.ts +++ b/tests/unit/wallet-activity-adapter.test.ts @@ -90,6 +90,71 @@ function client(activityRecipient = WALLET): NuthatchClient { } describe("wallet Nuthatch adapter", () => { + it("starts nest, schema, and head /sql without waiting for either metadata call", async () => { + let nestStarted = false; + let schemaStarted = false; + let sqlStarted = false; + let nestSawPeers = false; + let schemaSawPeers = false; + let sqlSawPeers = false; + + const release = { + nest: () => {}, + schema: () => {}, + sql: () => {}, + }; + const nestGate = new Promise((resolve) => { + release.nest = resolve; + }); + const schemaGate = new Promise((resolve) => { + release.schema = resolve; + }); + const sqlGate = new Promise((resolve) => { + release.sql = resolve; + }); + + const base = client(); + const gated: NuthatchClient = { + ...base, + nest: vi.fn(async () => { + nestStarted = true; + nestSawPeers = schemaStarted && sqlStarted; + await nestGate; + return base.nest(); + }), + schema: vi.fn(async () => { + schemaStarted = true; + schemaSawPeers = nestStarted && sqlStarted; + await schemaGate; + return base.schema(); + }), + sql: vi.fn(async (query: string, maxRows: number) => { + sqlStarted = true; + sqlSawPeers = nestStarted && schemaStarted; + await sqlGate; + return base.sql(query, maxRows); + }), + }; + + const pending = fetchNuthatchWalletActivity( + { client: gated, clock: () => 1_000, record: record() }, + inspectWalletResearchQuery({ + chain_id: 8453, + address: WALLET, + limit: 5, + }), + ); + + await vi.waitFor(() => { + expect(nestStarted && schemaStarted && sqlStarted).toBe(true); + }); + expect(nestSawPeers || schemaSawPeers || sqlSawPeers).toBe(true); + release.nest(); + release.schema(); + release.sql(); + await expect(pending).resolves.toMatchObject({ status: "ok" }); + }); + it("normalizes sender/recipient activity with exact token amounts", async () => { const result = await fetchNuthatchWalletActivity( { client: client(), clock: () => 1_000, record: record() }, diff --git a/tests/unit/wallet-activity-query.test.ts b/tests/unit/wallet-activity-query.test.ts index 60cdede..d344917 100644 --- a/tests/unit/wallet-activity-query.test.ts +++ b/tests/unit/wallet-activity-query.test.ts @@ -21,9 +21,10 @@ describe("wallet activity SQL", () => { }); const query = buildWalletActivityQuery(context, 100, 50); - expect(query).toContain("FROM wallet_swap_activity"); - expect(query).toContain(`CAST(sender AS VARCHAR) = '${WALLET}'`); - expect(query).toContain(`CAST(recipient AS VARCHAR) = '${WALLET}'`); + expect(query).toContain("FROM pool__swap"); + expect(query).toContain(`sender = '${WALLET}'`); + expect(query).toContain(`recipient = '${WALLET}'`); + expect(query).not.toContain("CAST("); expect(query).toContain("block_number <= 100"); expect(query).toContain("block_timestamp >= 50"); expect(query).toContain("LIMIT 5001");