Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/mcp-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 16 additions & 15 deletions docs/wallet-source-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 15 additions & 4 deletions skills/deeptrace-pool-research/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions src/sources/nuthatch/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" } }),
Expand Down
35 changes: 34 additions & 1 deletion src/sources/nuthatch/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NuthatchHttpResult>;
readonly ready: () => Promise<NuthatchHttpResult>;
Expand Down Expand Up @@ -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),
};
}
Expand Down
23 changes: 15 additions & 8 deletions src/sources/nuthatch/large-swaps-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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`;
}

Expand Down Expand Up @@ -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)}`;
}

Expand Down
10 changes: 8 additions & 2 deletions src/sources/nuthatch/wallet-activity-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand Down
24 changes: 16 additions & 8 deletions src/sources/nuthatch/wallet-activity-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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`;
}

Expand All @@ -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)}`;
}

Expand Down
21 changes: 7 additions & 14 deletions tests/unit/large-swaps-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))));
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/live-large-swaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading