From 3c318aed5b95d22a2743bc38d56d920846b7c1fa Mon Sep 17 00:00:00 2001 From: kapustazh <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:54:23 +0100 Subject: [PATCH 01/96] setup typescript project - add package setup and scripts - add strict typescript config - add empty app entry point --- package-lock.json | 50 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 19 ++++++++++++++++++ src/index.ts | 1 + tsconfig.json | 16 +++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ed9e999 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "deeptrace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deeptrace", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=22" + } + }, + "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": { + "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/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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9a21554 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "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", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6524649 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} From 3ae65cc6909e8e9b780397a76ef890966ce222bc Mon Sep 17 00:00:00 2001 From: kapustazh <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:03:42 +0100 Subject: [PATCH 02/96] add mcp server dependencies - add the official mcp typescript sdk - add zod for tool schemas - lock dependency versions --- package-lock.json | 1167 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 + 2 files changed, 1171 insertions(+) diff --git a/package-lock.json b/package-lock.json index ed9e999..7725a91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "deeptrace", "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3" + }, "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0" @@ -15,6 +19,58 @@ "node": ">=22" } }, + "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/@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/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -25,6 +81,1060 @@ "undici-types": "~7.18.0" } }, + "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/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/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/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/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/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/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/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-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/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/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-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/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/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/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/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/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-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-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/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/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/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/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/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-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/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/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/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/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/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/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/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/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", @@ -45,6 +1155,63 @@ "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/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/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/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/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 index 9a21554..ece7fa8 100644 --- a/package.json +++ b/package.json @@ -15,5 +15,9 @@ "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "zod": "^4.4.3" } } From 105d9f7c371cb30f944e2c4f3894c57f4192af3b Mon Sep 17 00:00:00 2001 From: kapustazh <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:10:47 +0100 Subject: [PATCH 03/96] create mcp server factory - add the deeptrace server identity - keep server setup reusable for transports and tests --- src/mcp/server.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/mcp/server.ts diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..6840a86 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,10 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +export const serverInfo = { + name: "deeptrace", + version: "0.1.0", +} as const; + +export function createMcpServer(): McpServer { + return new McpServer(serverInfo); +} From 9fff00e2c58869600b43559390f4e340bebcb42d Mon Sep 17 00:00:00 2001 From: kapustazh <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:16:21 +0100 Subject: [PATCH 04/96] setup code quality checks - add type aware eslint rules - add prettier formatting - add lint and format scripts --- .prettierignore | 5 + .prettierrc.json | 6 + eslint.config.js | 30 ++ package-lock.json | 1165 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 12 +- 5 files changed, 1216 insertions(+), 2 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 eslint.config.js diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2e2d836 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +package-lock.json +*.md +LICENSE 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/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/package-lock.json b/package-lock.json index 7725a91..7e66adc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,13 +12,147 @@ "zod": "^4.4.3" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^24.0.0", - "typescript": "^5.9.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" }, "engines": { "node": ">=22" } }, + "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", @@ -31,6 +165,72 @@ "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/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -71,6 +271,27 @@ } } }, + "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", @@ -81,6 +302,236 @@ "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/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -94,6 +545,29 @@ "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", @@ -127,6 +601,16 @@ } } }, + "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", @@ -164,6 +648,19 @@ "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", @@ -290,6 +787,13 @@ } } }, + "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", @@ -364,6 +868,214 @@ "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/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", @@ -462,6 +1174,20 @@ "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", @@ -478,6 +1204,37 @@ ], "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", @@ -499,6 +1256,44 @@ "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", @@ -563,6 +1358,32 @@ "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", @@ -644,6 +1465,26 @@ "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", @@ -668,6 +1509,29 @@ "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", @@ -689,6 +1553,13 @@ "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", @@ -701,6 +1572,53 @@ "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/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/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -760,12 +1678,35 @@ "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/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", @@ -817,6 +1758,56 @@ "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", @@ -826,6 +1817,16 @@ "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", @@ -845,6 +1846,19 @@ "url": "https://opencollective.com/express" } }, + "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", @@ -854,6 +1868,32 @@ "node": ">=16.20.0" } }, + "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", @@ -867,6 +1907,16 @@ "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", @@ -942,6 +1992,19 @@ "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", @@ -1095,6 +2158,23 @@ "node": ">= 0.8" } }, + "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/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1104,6 +2184,32 @@ "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/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", @@ -1149,6 +2255,30 @@ "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", @@ -1165,6 +2295,16 @@ "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", @@ -1189,12 +2329,35 @@ "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", diff --git a/package.json b/package.json index ece7fa8..ea2b05e 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,22 @@ }, "scripts": { "build": "tsc", + "format": "prettier . --write", + "format:check": "prettier . --check", + "lint": "eslint . --max-warnings 0", + "lint:fix": "eslint . --fix", "start": "node dist/index.js", "typecheck": "tsc --noEmit" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^24.0.0", - "typescript": "^5.9.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" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", From 6d126e49ffd5eb30cde3ae6a2165f818e0dabaa0 Mon Sep 17 00:00:00 2001 From: kapustazh <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:18:36 +0100 Subject: [PATCH 05/96] code quality github-workflow - install dependencies from the lockfile - check formatting lint types and build - run on pushes and pull requests --- .github/workflows/code-quality.yml | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/code-quality.yml diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..d79a4a2 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,38 @@ +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: build project + run: npm run build From fc0bd2310edc4770a6169dadd4db876b3c66ea90 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 06:00:16 +0200 Subject: [PATCH 06/96] chore: document environment variables and Nuthatch deployment --- .env.example | 19 +++++++++++++++++++ .gitignore | 4 ++++ docs/deployment.md | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 .env.example create mode 100644 docs/deployment.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..90390b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# The Graph gateway credential. Prefer sending this as an Authorization bearer +# token instead of embedding it in a gateway URL. +GRAPH_API_KEY= + +# Base mainnet RPC endpoints used by Nuthatch for health-aware failover. +# The Graph + Nuthatch source constraint rules out keyed data providers, so +# redundancy comes from breadth. Probe each endpoint's eth_getLogs range cap +# before committing to it; the tightest cap paces the whole backfill. +BASE_RPC_URL_PRIMARY= +BASE_RPC_URL_SECONDARY= +BASE_RPC_URL_TERTIARY= + +# Tailnet-only URL of the Nuthatch HTTP API, without a trailing slash. +# Example: https://wallet-intel.example-tailnet.ts.net +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= diff --git a/.gitignore b/.gitignore index c246668..9ece15e 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/ diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..89e9451 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,41 @@ +# 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://wallet-intel.tail8ae57d.ts.net` +- 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} $NUTHATCH_EXTRA_ARGS +``` + +## 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. From 34c426c1d5201057cd73b1fa09e971e2ed7ab8e3 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 06:00:16 +0200 Subject: [PATCH 07/96] feat: define source adapter contract, registry types and fixtures --- docs/CONTRACT.md | 109 +++++++++++++++++++ src/registry/types.ts | 37 +++++++ src/schemas/source-adapter.ts | 74 +++++++++++++ tests/fixtures/sources/index.ts | 183 ++++++++++++++++++++++++++++++++ tests/tsconfig.json | 8 ++ tsconfig.fixtures.json | 16 +++ 6 files changed, 427 insertions(+) create mode 100644 docs/CONTRACT.md create mode 100644 src/registry/types.ts create mode 100644 src/schemas/source-adapter.ts create mode 100644 tests/fixtures/sources/index.ts create mode 100644 tests/tsconfig.json create mode 100644 tsconfig.fixtures.json diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md new file mode 100644 index 0000000..ba7e1e1 --- /dev/null +++ b/docs/CONTRACT.md @@ -0,0 +1,109 @@ +# 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 reasoning code. + +## 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. + +## 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-0 records are +Base DEX 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. + +## 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/src/registry/types.ts b/src/registry/types.ts new file mode 100644 index 0000000..5e2c2b0 --- /dev/null +++ b/src/registry/types.ts @@ -0,0 +1,37 @@ +import type { SourceType } from "../schemas/source-adapter.js"; + +export type SourceCategory = "dex"; + +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/source-adapter.ts b/src/schemas/source-adapter.ts new file mode 100644 index 0000000..212b007 --- /dev/null +++ b/src/schemas/source-adapter.ts @@ -0,0 +1,74 @@ +export const BASE_CHAIN_ID = 8453 as const; + +export type SourceStatus = "ok" | "timeout" | "error" | "unsupported" | "stale"; + +export type FailureSourceStatus = Exclude; + +export type SourceType = "standardized_subgraph" | "native_subgraph" | "nuthatch_view"; + +export interface SourceFreshness { + indexed_block: number; + indexed_block_timestamp: number; + indexed_block_hash?: string; + queried_at: number; + has_indexing_errors?: boolean; +} + +export interface SourceProvenance { + deployment_or_view_id: string; + schema_version: string | null; + methodology_version: string | null; + query_id: string; +} + +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; + +export interface TokenMetadata { + address: string; + symbol: string; + decimals: number; +} + +export interface PoolSourceData { + pool_address: string; + token0: TokenMetadata; + token1: TokenMetadata; + fee_tier_bps: number | null; + tvl_usd: string | null; + volume_usd_24h: string | null; + volume_usd_7d: string | null; + fees_usd_24h: string | null; + fees_usd_7d: string | null; +} + +export interface NuthatchFreshnessData { + pool_address: string; + recent_swap_count_24h: number; + last_swap_block: number; + last_swap_block_timestamp: number; + last_swap_block_hash: string; + last_swap_tx_hash: string; + last_swap_log_index: number; +} diff --git a/tests/fixtures/sources/index.ts b/tests/fixtures/sources/index.ts new file mode 100644 index 0000000..19a08cc --- /dev/null +++ b/tests/fixtures/sources/index.ts @@ -0,0 +1,183 @@ +import type { + NuthatchFreshnessData, + PoolSourceData, + SourceResult, +} from "../../../src/schemas/source-adapter.js"; + +export type PoolSourceFixture = SourceResult; +export type NuthatchSourceFixture = SourceResult; +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; + +export const graphPoolA = { + source_id: "fixture-graph-dex-a", + source_type: "standardized_subgraph", + protocol: "fixture-dex-a", + chain_id: 8453, + status: "ok", + data: { + pool_address: "0x00000000000000000000000000000000000000a1", + token0: weth, + token1: usdc, + fee_tier_bps: 5, + tvl_usd: "1250000.25", + volume_usd_24h: "250000.50", + volume_usd_7d: "1750000.75", + fees_usd_24h: "125.00", + fees_usd_7d: "875.00", + }, + freshness: { + indexed_block: 30000001, + indexed_block_timestamp: 1735689601, + queried_at: 1735689610, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "fixture-deployment-a", + schema_version: "fixture-1.0.0", + methodology_version: "fixture-1.0.0", + query_id: "fixture-pool-metrics-v1", + }, + warnings: [], + latency_ms: 101, +} satisfies SourceResult; + +export const graphPoolB = { + source_id: "fixture-graph-dex-b", + source_type: "standardized_subgraph", + protocol: "fixture-dex-b", + chain_id: 8453, + status: "ok", + data: { + pool_address: "0x00000000000000000000000000000000000000b2", + token0: usdc, + token1: weth, + fee_tier_bps: 30, + tvl_usd: "980000.00", + volume_usd_24h: "310000.10", + volume_usd_7d: "2010000.20", + fees_usd_24h: "930.00", + fees_usd_7d: "6030.00", + }, + freshness: { + indexed_block: 30000002, + indexed_block_timestamp: 1735689603, + queried_at: 1735689611, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "fixture-deployment-b", + schema_version: "fixture-1.0.0", + methodology_version: "fixture-1.0.0", + query_id: "fixture-pool-metrics-v1", + }, + warnings: [], + latency_ms: 114, +} satisfies SourceResult; + +export const graphPoolC = { + source_id: "fixture-graph-dex-c", + source_type: "standardized_subgraph", + protocol: "fixture-dex-c", + chain_id: 8453, + status: "ok", + data: { + pool_address: "0x00000000000000000000000000000000000000c3", + token0: weth, + token1: usdc, + fee_tier_bps: null, + tvl_usd: "720000.40", + volume_usd_24h: "190000.30", + volume_usd_7d: null, + fees_usd_24h: "570.00", + fees_usd_7d: null, + }, + freshness: { + indexed_block: 30000000, + indexed_block_timestamp: 1735689599, + queried_at: 1735689612, + has_indexing_errors: false, + }, + provenance: { + deployment_or_view_id: "fixture-deployment-c", + schema_version: "fixture-1.0.0", + methodology_version: null, + query_id: "fixture-pool-metrics-v1", + }, + warnings: ["Fixture source does not expose seven-day aggregates."], + latency_ms: 98, +} satisfies SourceResult; + +export const graphPoolCTimeout = { + source_id: "fixture-graph-dex-c", + source_type: "standardized_subgraph", + protocol: "fixture-dex-c", + chain_id: 8453, + status: "timeout", + data: null, + freshness: null, + provenance: { + deployment_or_view_id: "fixture-deployment-c", + schema_version: "fixture-1.0.0", + methodology_version: null, + query_id: "fixture-pool-metrics-v1", + }, + warnings: ["Fixture source timed out before returning data."], + latency_ms: 5000, +} satisfies SourceResult; + +export const nuthatchFreshness = { + source_id: "fixture-nuthatch-pool-swaps", + source_type: "nuthatch_view", + protocol: "fixture-dex-a", + chain_id: 8453, + status: "ok", + data: { + pool_address: "0x00000000000000000000000000000000000000a1", + recent_swap_count_24h: 321, + last_swap_block: 30000003, + last_swap_block_timestamp: 1735689605, + last_swap_block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + last_swap_tx_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + last_swap_log_index: 7, + }, + freshness: { + indexed_block: 30000003, + indexed_block_timestamp: 1735689605, + indexed_block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + queried_at: 1735689613, + }, + provenance: { + deployment_or_view_id: "fixture-nuthatch-registry-hash", + schema_version: "fixture-1.0.0", + methodology_version: "fixture-1.0.0", + query_id: "fixture-pool-swap-freshness-v1", + }, + warnings: [], + latency_ms: 42, +} satisfies SourceResult; + +export const completeSourceScenario = [ + graphPoolA, + graphPoolB, + graphPoolC, + nuthatchFreshness, +] satisfies readonly MvpSourceFixture[]; + +export const partialSourceScenario = [ + graphPoolA, + graphPoolB, + graphPoolCTimeout, + nuthatchFreshness, +] satisfies readonly MvpSourceFixture[]; diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..6da9093 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/tsconfig.fixtures.json b/tsconfig.fixtures.json new file mode 100644 index 0000000..b30ea0f --- /dev/null +++ b/tsconfig.fixtures.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/schemas/**/*.ts", "src/registry/**/*.ts", "tests/fixtures/sources/**/*.ts"] +} From 02b50aacdfafab369d98bdcfb6c4a8c711e56d0d Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:07:56 +0100 Subject: [PATCH 08/96] add unit test infrastructure (#5) - add Vitest scripts and MCP server factory coverage - share TypeScript test configuration with source fixtures - run unit tests in the code quality workflow --- .github/workflows/code-quality.yml | 3 + package-lock.json | 1197 +++++++++++++++++++++++++++- package.json | 5 +- tests/tsconfig.json | 3 +- tests/unit/mcp-server.test.ts | 13 + 5 files changed, 1218 insertions(+), 3 deletions(-) create mode 100644 tests/unit/mcp-server.test.ts diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index d79a4a2..da941f7 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -34,5 +34,8 @@ jobs: - name: check types run: npm run typecheck + - name: run unit tests + run: npm test + - name: build project run: npm run build diff --git a/package-lock.json b/package-lock.json index 7e66adc..e750bf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,12 +19,47 @@ "globals": "^17.7.0", "prettier": "3.9.6", "typescript": "^5.9.0", - "typescript-eslint": "^8.65.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", @@ -231,6 +266,13 @@ "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", @@ -271,6 +313,335 @@ } } }, + "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", @@ -532,6 +903,119 @@ "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", @@ -601,6 +1085,16 @@ } } }, + "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", @@ -699,6 +1193,16 @@ "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", @@ -721,6 +1225,13 @@ "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", @@ -803,6 +1314,16 @@ "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", @@ -850,6 +1371,13 @@ "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", @@ -1066,6 +1594,16 @@ "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", @@ -1106,6 +1644,16 @@ "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", @@ -1312,6 +1860,21 @@ "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", @@ -1603,6 +2166,267 @@ "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", @@ -1619,6 +2443,16 @@ "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", @@ -1700,6 +2534,25 @@ "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", @@ -1737,6 +2590,20 @@ "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", @@ -1846,6 +2713,20 @@ "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", @@ -1868,6 +2749,35 @@ "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", @@ -1970,6 +2880,40 @@ "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", @@ -2149,6 +3093,30 @@ "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", @@ -2158,6 +3126,30 @@ "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", @@ -2175,6 +3167,16 @@ "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", @@ -2197,6 +3199,14 @@ "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", @@ -2314,6 +3324,174 @@ "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", @@ -2329,6 +3507,23 @@ "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", diff --git a/package.json b/package.json index ea2b05e..87ca22b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", "typecheck": "tsc --noEmit" }, "devDependencies": { @@ -24,7 +26,8 @@ "globals": "^17.7.0", "prettier": "3.9.6", "typescript": "^5.9.0", - "typescript-eslint": "^8.65.0" + "typescript-eslint": "^8.65.0", + "vitest": "^4.1.10" }, "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 6da9093..77ebb4d 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../tsconfig.json", "compilerOptions": { "rootDir": "..", - "noEmit": true + "noEmit": true, + "types": ["vitest/globals"] }, "include": ["**/*.ts"] } diff --git a/tests/unit/mcp-server.test.ts b/tests/unit/mcp-server.test.ts new file mode 100644 index 0000000..43ffd3e --- /dev/null +++ b/tests/unit/mcp-server.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { createMcpServer, serverInfo } from "../../src/mcp/server.js"; + +describe("MCP server foundation", () => { + it("creates the configured DeepTrace server", () => { + expect(createMcpServer()).toBeDefined(); + expect(serverInfo).toEqual({ + name: "deeptrace", + version: "0.1.0", + }); + }); +}); From 03ac0b228e498f292d87840e0d3d417f66fc92f8 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:19:54 +0100 Subject: [PATCH 09/96] Add MCP stdio lifecycle (#6) * add MCP stdio lifecycle - connect the server through a reusable stdio runtime - handle clean idempotent signal shutdown - test initialization and lifecycle behavior - document local build and client setup * clarify Nuthatch deployment documentation --- README.md | 53 +++++++++++++++++++++++ src/index.ts | 15 ++++++- src/mcp/lifecycle.ts | 54 +++++++++++++++++++++++ tests/unit/mcp-lifecycle.test.ts | 74 ++++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 README.md create mode 100644 src/mcp/lifecycle.ts create mode 100644 tests/unit/mcp-lifecycle.test.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccee076 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# DeepTrace + +DeepTrace is a read-only Graph research MCP for builders and AI agents. + +The current foundation starts an MCP server over stdio. Public research tools +are added in later milestones; this branch does not register a provisional +`compare_pools` tool. + +## Requirements + +- Node.js 22 or newer +- npm + +## Install and Verify + +```sh +npm ci +npm run format:check +npm run lint +npm run typecheck +npm test +npm run build +``` + +## Start the Server + +Build before starting: + +```sh +npm run build +npm start +``` + +The server reads MCP messages from stdin and writes MCP messages to stdout. +Application diagnostics use stderr so they cannot corrupt the protocol stream. + +## MCP Client Configuration + +Use an absolute path to the built entry point: + +```json +{ + "mcpServers": { + "deeptrace": { + "command": "node", + "args": ["/absolute/path/to/deeptrace/dist/index.js"] + } + } +} +``` + +Current Nuthatch deployment notes live in `docs/deployment.md`. Live source +adapter configuration and `compare_pools` are integrated in later milestones. diff --git a/src/index.ts b/src/index.ts index cb0ff5c..86e7211 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,14 @@ -export {}; +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..09ed87c --- /dev/null +++ b/src/mcp/lifecycle.ts @@ -0,0 +1,54 @@ +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +import { createMcpServer } 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): Promise { + const server = createMcpServer(); + 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/tests/unit/mcp-lifecycle.test.ts b/tests/unit/mcp-lifecycle.test.ts new file mode 100644 index 0000000..3b05e6a --- /dev/null +++ b/tests/unit/mcp-lifecycle.test.ts @@ -0,0 +1,74 @@ +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 } 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(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); + }); +}); From 3bae14281135e7aed23e3268ae3bdad849bdef67 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 12:28:47 +0200 Subject: [PATCH 10/96] feat(m2.2): enumerate Base DEX candidates --- scripts/m2/candidates.json | 100 +++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 scripts/m2/candidates.json 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" + } +] From 65eacf40d36fe46fbb43b5008bdc1c20706ab9d0 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:38:16 +0100 Subject: [PATCH 11/96] Add source adapter runtime validation (#7) * add source adapter runtime validation - mirror the canonical source interface with strict Zod validators - enforce source-specific success and failure invariants - validate existing fixtures and malformed boundary cases * co-locate source schemas and types - make the canonical source schema the runtime and type source of truth - remove the duplicate runtime schema module - use concrete schema-derived result types for fixtures --- src/schemas/source-adapter.ts | 181 +++++++++++++++++----- tests/fixtures/sources/index.ts | 19 ++- tests/unit/source-adapter-runtime.test.ts | 153 ++++++++++++++++++ 3 files changed, 303 insertions(+), 50 deletions(-) create mode 100644 tests/unit/source-adapter-runtime.test.ts diff --git a/src/schemas/source-adapter.ts b/src/schemas/source-adapter.ts index 212b007..8bedcff 100644 --- a/src/schemas/source-adapter.ts +++ b/src/schemas/source-adapter.ts @@ -1,25 +1,102 @@ +import { z } from "zod"; + export const BASE_CHAIN_ID = 8453 as const; -export type SourceStatus = "ok" | "timeout" | "error" | "unsupported" | "stale"; +const nonEmptyStringSchema = z + .string() + .min(1) + .refine((value) => value.trim() === value, "Must not have leading or trailing whitespace"); -export type FailureSourceStatus = Exclude; +const nonNegativeIntegerSchema = z.number().int().nonnegative(); +const nonNegativeNumberSchema = z.number().nonnegative(); -export type SourceType = "standardized_subgraph" | "native_subgraph" | "nuthatch_view"; +const ethereumAddressSchema = z + .string() + .regex(/^0x[0-9a-f]{40}$/, "Expected a lowercase 20-byte hexadecimal address"); -export interface SourceFreshness { - indexed_block: number; - indexed_block_timestamp: number; - indexed_block_hash?: string; - queried_at: number; - has_indexing_errors?: boolean; -} +const blockHashSchema = z + .string() + .regex(/^0x[0-9a-f]{64}$/, "Expected a lowercase 32-byte hexadecimal hash"); -export interface SourceProvenance { - deployment_or_view_id: string; - schema_version: string | null; - methodology_version: string | null; - query_id: string; -} +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; @@ -45,30 +122,54 @@ export interface FailedSourceResult extends SourceResultBase { export type SourceResult = SuccessfulSourceResult | FailedSourceResult; -export interface TokenMetadata { - address: string; - symbol: string; - decimals: number; -} +const sourceResultBaseShape = { + source_id: nonEmptyStringSchema, + protocol: nonEmptyStringSchema, + chain_id: z.literal(BASE_CHAIN_ID), + provenance: sourceProvenanceSchema, + warnings: z.array(nonEmptyStringSchema), + latency_ms: nonNegativeNumberSchema, +}; -export interface PoolSourceData { - pool_address: string; - token0: TokenMetadata; - token1: TokenMetadata; - fee_tier_bps: number | null; - tvl_usd: string | null; - volume_usd_24h: string | null; - volume_usd_7d: string | null; - fees_usd_24h: string | null; - fees_usd_7d: string | null; -} +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(); -export interface NuthatchFreshnessData { - pool_address: string; - recent_swap_count_24h: number; - last_swap_block: number; - last_swap_block_timestamp: number; - last_swap_block_hash: string; - last_swap_tx_hash: string; - last_swap_log_index: number; + 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; diff --git a/tests/fixtures/sources/index.ts b/tests/fixtures/sources/index.ts index 19a08cc..83998b1 100644 --- a/tests/fixtures/sources/index.ts +++ b/tests/fixtures/sources/index.ts @@ -1,11 +1,10 @@ import type { - NuthatchFreshnessData, - PoolSourceData, - SourceResult, + NuthatchSourceResult, + PoolSourceResult, } from "../../../src/schemas/source-adapter.js"; -export type PoolSourceFixture = SourceResult; -export type NuthatchSourceFixture = SourceResult; +export type PoolSourceFixture = PoolSourceResult; +export type NuthatchSourceFixture = NuthatchSourceResult; export type MvpSourceFixture = PoolSourceFixture | NuthatchSourceFixture; const weth = { @@ -51,7 +50,7 @@ export const graphPoolA = { }, warnings: [], latency_ms: 101, -} satisfies SourceResult; +} satisfies PoolSourceResult; export const graphPoolB = { source_id: "fixture-graph-dex-b", @@ -84,7 +83,7 @@ export const graphPoolB = { }, warnings: [], latency_ms: 114, -} satisfies SourceResult; +} satisfies PoolSourceResult; export const graphPoolC = { source_id: "fixture-graph-dex-c", @@ -117,7 +116,7 @@ export const graphPoolC = { }, warnings: ["Fixture source does not expose seven-day aggregates."], latency_ms: 98, -} satisfies SourceResult; +} satisfies PoolSourceResult; export const graphPoolCTimeout = { source_id: "fixture-graph-dex-c", @@ -135,7 +134,7 @@ export const graphPoolCTimeout = { }, warnings: ["Fixture source timed out before returning data."], latency_ms: 5000, -} satisfies SourceResult; +} satisfies PoolSourceResult; export const nuthatchFreshness = { source_id: "fixture-nuthatch-pool-swaps", @@ -166,7 +165,7 @@ export const nuthatchFreshness = { }, warnings: [], latency_ms: 42, -} satisfies SourceResult; +} satisfies NuthatchSourceResult; export const completeSourceScenario = [ graphPoolA, diff --git a/tests/unit/source-adapter-runtime.test.ts b/tests/unit/source-adapter-runtime.test.ts new file mode 100644 index 0000000..d36cd18 --- /dev/null +++ b/tests/unit/source-adapter-runtime.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { + nuthatchFreshnessDataSchema, + nuthatchSourceResultSchema, + poolSourceDataSchema, + poolSourceResultSchema, +} from "../../src/schemas/source-adapter.js"; +import { + graphPoolA, + graphPoolB, + graphPoolC, + graphPoolCTimeout, + nuthatchFreshness, +} from "../fixtures/sources/index.js"; + +describe("source adapter runtime validation", () => { + it("accepts all canonical source fixtures unchanged", () => { + expect(poolSourceResultSchema.parse(graphPoolA)).toEqual(graphPoolA); + expect(poolSourceResultSchema.parse(graphPoolB)).toEqual(graphPoolB); + expect(poolSourceResultSchema.parse(graphPoolC)).toEqual(graphPoolC); + expect(poolSourceResultSchema.parse(graphPoolCTimeout)).toEqual(graphPoolCTimeout); + expect(nuthatchSourceResultSchema.parse(nuthatchFreshness)).toEqual(nuthatchFreshness); + }); + + it("rejects unknown fields at every object boundary", () => { + const candidates = [ + { ...graphPoolA, unexpected: true }, + { ...graphPoolA, data: { ...graphPoolA.data, unexpected: true } }, + { + ...graphPoolA, + data: { + ...graphPoolA.data, + token0: { ...graphPoolA.data.token0, unexpected: true }, + }, + }, + { ...graphPoolA, freshness: { ...graphPoolA.freshness, unexpected: true } }, + { ...graphPoolA, provenance: { ...graphPoolA.provenance, unexpected: true } }, + ]; + + for (const candidate of candidates) { + expect(poolSourceResultSchema.safeParse(candidate).success).toBe(false); + } + }); + + it.each([ + ["chain", { ...graphPoolA, chain_id: 1 }], + [ + "address", + { + ...graphPoolA, + data: { ...graphPoolA.data, pool_address: "0xnot-an-address" }, + }, + ], + [ + "decimal", + { + ...graphPoolA, + data: { ...graphPoolA.data, tvl_usd: "not-a-decimal" }, + }, + ], + [ + "timestamp", + { + ...graphPoolA, + freshness: { ...graphPoolA.freshness, indexed_block_timestamp: -1 }, + }, + ], + ["status", { ...graphPoolA, status: "pending" }], + ])("rejects an invalid %s", (_label, candidate) => { + expect(poolSourceResultSchema.safeParse(candidate).success).toBe(false); + }); + + it.each([ + [ + "hash", + { + ...nuthatchFreshness, + data: { ...nuthatchFreshness.data, last_swap_block_hash: "0xdeadbeef" }, + }, + ], + [ + "count", + { + ...nuthatchFreshness, + data: { ...nuthatchFreshness.data, recent_swap_count_24h: -1 }, + }, + ], + ])("rejects an invalid Nuthatch %s", (_label, candidate) => { + expect(nuthatchSourceResultSchema.safeParse(candidate).success).toBe(false); + }); + + it("requires successful results to contain both data and freshness", () => { + expect(poolSourceResultSchema.safeParse({ ...graphPoolA, data: null }).success).toBe(false); + expect(poolSourceResultSchema.safeParse({ ...graphPoolA, freshness: null }).success).toBe( + false, + ); + }); + + it("requires Nuthatch results with freshness to include the indexed block hash", () => { + const freshnessWithoutHash: Record = { + ...nuthatchFreshness.freshness, + }; + delete freshnessWithoutHash.indexed_block_hash; + + expect( + nuthatchSourceResultSchema.safeParse({ + ...nuthatchFreshness, + freshness: freshnessWithoutHash, + }).success, + ).toBe(false); + }); + + it("requires non-ok results to contain null data", () => { + const candidate = { + ...graphPoolCTimeout, + data: graphPoolC.data, + }; + + expect(poolSourceResultSchema.safeParse(candidate).success).toBe(false); + }); + + it('preserves measured "0" separately from unavailable null', () => { + const measuredZero = poolSourceResultSchema.parse({ + ...graphPoolA, + data: { ...graphPoolA.data, tvl_usd: "0" }, + }); + const unavailable = poolSourceResultSchema.parse({ + ...graphPoolA, + data: { ...graphPoolA.data, tvl_usd: null }, + }); + + expect(measuredZero.data?.tvl_usd).toBe("0"); + expect(unavailable.data?.tvl_usd).toBeNull(); + expect(measuredZero.data?.tvl_usd).not.toBe(unavailable.data?.tvl_usd); + }); + + it("accepts fractional non-negative source latency", () => { + const candidate = { + ...graphPoolA, + latency_ms: 101.25, + }; + + expect(poolSourceResultSchema.parse(candidate).latency_ms).toBe(101.25); + }); + + it("rejects pool and Nuthatch payloads and results at the opposite boundary", () => { + expect(poolSourceDataSchema.safeParse(nuthatchFreshness.data).success).toBe(false); + expect(nuthatchFreshnessDataSchema.safeParse(graphPoolA.data).success).toBe(false); + expect(poolSourceResultSchema.safeParse(nuthatchFreshness).success).toBe(false); + expect(nuthatchSourceResultSchema.safeParse(graphPoolA).success).toBe(false); + }); +}); From bfabc46a64327ba6cbf8af01104d58cd9aab737e Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:16:30 +0200 Subject: [PATCH 12/96] feat(m2): add redacted probe transport --- scripts/m2/lib/evidence.test.ts | 36 ++++++++++ scripts/m2/lib/evidence.ts | 78 +++++++++++++++++++++ scripts/m2/lib/gateway.test.ts | 65 +++++++++++++++++ scripts/m2/lib/gateway.ts | 120 ++++++++++++++++++++++++++++++++ scripts/m2/tsconfig.json | 9 +++ vitest.config.mjs | 12 ++++ 6 files changed, 320 insertions(+) create mode 100644 scripts/m2/lib/evidence.test.ts create mode 100644 scripts/m2/lib/evidence.ts create mode 100644 scripts/m2/lib/gateway.test.ts create mode 100644 scripts/m2/lib/gateway.ts create mode 100644 scripts/m2/tsconfig.json create mode 100644 vitest.config.mjs diff --git a/scripts/m2/lib/evidence.test.ts b/scripts/m2/lib/evidence.test.ts new file mode 100644 index 0000000..d433bc0 --- /dev/null +++ b/scripts/m2/lib/evidence.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { writeEvidence, type EvidenceDocument } from "./evidence.ts"; +import { metaQuery } from "./queries.ts"; + +void test("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; + assert.equal(evidence.gateway_host, "gateway.thegraph.com"); + assert.equal(evidence.subgraph_id, "dummy-id"); + assert.equal(evidence.query_id, "m2-meta-v1"); + assert.deepEqual(evidence.response, { + data: { _meta: { deployment: "QmDummy" } }, + }); + assert.equal(Object.hasOwn(evidence, "headers"), false); +}); 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.test.ts b/scripts/m2/lib/gateway.test.ts new file mode 100644 index 0000000..8b47f31 --- /dev/null +++ b/scripts/m2/lib/gateway.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { postGraphQuery } from "./gateway.ts"; +import { metaQuery } from "./queries.ts"; + +void test("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", + }, + ); + + assert.equal(observedUrl, "https://dummy.invalid/api/subgraphs/id/dummy-id"); + assert.equal(observedAuthorization, `Bearer ${credential}`); + assert.doesNotMatch(observedUrl, new RegExp(credential)); + assert.equal(result.status, 200); + assert.equal(result.error, null); +}); + +void test("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, + }, + ); + + assert.equal(result.error?.kind, "transport"); + assert.doesNotMatch(result.error?.message ?? "", new RegExp(credential)); +}); diff --git a/scripts/m2/lib/gateway.ts b/scripts/m2/lib/gateway.ts new file mode 100644 index 0000000..49da2f0 --- /dev/null +++ b/scripts/m2/lib/gateway.ts @@ -0,0 +1,120 @@ +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; +} + +export async function loadGraphApiKey(envPath = ".env"): Promise { + const fileValues = parseEnv(await readFile(envPath, "utf8")); + const key = process.env.GRAPH_API_KEY ?? fileValues.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/tsconfig.json b/scripts/m2/tsconfig.json new file mode 100644 index 0000000..d652c33 --- /dev/null +++ b/scripts/m2/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["./**/*.ts"] +} diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..c16975d --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: [ + "**/node_modules/**", + "**/dist/**", + "scripts/m2/**/*.test.ts", + "src/sources/graph/**/*.test.ts", + ], + }, +}); From cd475ebcb9c6dc807ec81be95485e01833f22507 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:16:38 +0200 Subject: [PATCH 13/96] feat(m2): add liveness sweep --- scripts/m2/lib/queries.ts | 160 ++++++++++++++++++++++++++++++++++++++ scripts/m2/run-sweep.ts | 154 ++++++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 scripts/m2/lib/queries.ts create mode 100644 scripts/m2/run-sweep.ts 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/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: [], +}); From 758f9f3a101ba37e4017a96f10abd2ae1a4cea34 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:17:10 +0200 Subject: [PATCH 14/96] feat(m2): add candidate data probes --- scripts/m2/probe-pools.ts | 167 +++++++++++++++++++++++++++++++++++++ scripts/m2/probe-tokens.ts | 64 ++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 scripts/m2/probe-pools.ts create mode 100644 scripts/m2/probe-tokens.ts diff --git a/scripts/m2/probe-pools.ts b/scripts/m2/probe-pools.ts new file mode 100644 index 0000000..6477626 --- /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 { + 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 = + "The common tier query returned no pool; candidate is incompatible."; + 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, +}); From 97457b2d2fc5d7a718ead1ccf0d838afe96a6a1d Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:18:27 +0200 Subject: [PATCH 15/96] feat(m2.7): assert Graph deployment identity --- .../graph/deployment-assertion.test.ts | 33 +++++++++++++++++++ src/sources/graph/deployment-assertion.ts | 27 +++++++++++++++ tsconfig.json | 1 + 3 files changed, 61 insertions(+) create mode 100644 src/sources/graph/deployment-assertion.test.ts create mode 100644 src/sources/graph/deployment-assertion.ts diff --git a/src/sources/graph/deployment-assertion.test.ts b/src/sources/graph/deployment-assertion.test.ts new file mode 100644 index 0000000..f497f10 --- /dev/null +++ b/src/sources/graph/deployment-assertion.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { assertDeployment, deploymentMismatchWarning } from "./deployment-assertion.ts"; + +void test("accepts an exact, case-sensitive deployment match", () => { + assert.deepEqual(assertDeployment("QmExpected", "QmExpected"), { ok: true }); +}); + +void test("returns both hashes on mismatch", () => { + assert.deepEqual(assertDeployment("QmExpected", "QmActual"), { + ok: false, + expected: "QmExpected", + actual: "QmActual", + }); +}); + +void test("treats an omitted deployment as an empty mismatch", () => { + assert.deepEqual(assertDeployment("QmExpected", ""), { + ok: false, + expected: "QmExpected", + actual: "", + }); +}); + +void test("warning contains both hashes and no unrelated environment value", () => { + const credential = "secret-that-must-not-appear"; + const warning = deploymentMismatchWarning("QmExpected", "QmActual"); + + assert.match(warning, /QmExpected/); + assert.match(warning, /QmActual/); + assert.doesNotMatch(warning, new RegExp(credential)); +}); 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/tsconfig.json b/tsconfig.json index 6524649..f41fc1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, "rootDir": "src", "outDir": "dist", "strict": true, From 2d7b9746ca21aa4dd6590978dfc683b8dc39a494 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:36:21 +0100 Subject: [PATCH 16/96] Add shared gateway primitives (#10) * add shared gateway primitives - add typed configuration and rate-limit errors - add validated gateway defaults and environment overrides - add injected-clock fixed-window limiting with unit coverage * address gateway review findings - prune expired rate-limit keys on cleanup boundaries - keep rate-limit error text fixed and secret-safe - cover pre-boundary rejection and expired-key eviction --- .env.example | 9 +++ src/config/defaults.ts | 15 ++++ src/config/env.ts | 71 ++++++++++++++++++ src/config/index.ts | 7 ++ src/config/positive-integer.ts | 45 ++++++++++++ src/errors/application-error.ts | 34 +++++++++ src/errors/codes.ts | 6 ++ src/errors/index.ts | 2 + src/gateway/clock.ts | 4 ++ src/gateway/index.ts | 2 + src/gateway/rate-limiter.ts | 68 ++++++++++++++++++ tests/unit/gateway-config.test.ts | 113 +++++++++++++++++++++++++++++ tests/unit/gateway-errors.test.ts | 42 +++++++++++ tests/unit/rate-limiter.test.ts | 116 ++++++++++++++++++++++++++++++ 14 files changed, 534 insertions(+) create mode 100644 src/config/defaults.ts create mode 100644 src/config/env.ts create mode 100644 src/config/index.ts create mode 100644 src/config/positive-integer.ts create mode 100644 src/errors/application-error.ts create mode 100644 src/errors/codes.ts create mode 100644 src/errors/index.ts create mode 100644 src/gateway/clock.ts create mode 100644 src/gateway/index.ts create mode 100644 src/gateway/rate-limiter.ts create mode 100644 tests/unit/gateway-config.test.ts create mode 100644 tests/unit/gateway-errors.test.ts create mode 100644 tests/unit/rate-limiter.test.ts diff --git a/.env.example b/.env.example index 90390b1..060a0f9 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,12 @@ 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). +DEEPTRACE_RATE_LIMIT_MAX_REQUESTS=30 + +# Gateway fixed-window duration in milliseconds (default: 60000). +DEEPTRACE_RATE_LIMIT_WINDOW_MS=60000 + +# Per-source adapter timeout in milliseconds (default: 5000). +DEEPTRACE_SOURCE_TIMEOUT_MS=5000 diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..8bf1b76 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,15 @@ +/** Overrideable gateway defaults for FND-03. */ +export const GATEWAY_DEFAULTS = { + rateLimitMaxRequests: 30, + rateLimitWindowMs: 60_000, + sourceTimeoutMs: 5_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..725c03b --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,71 @@ +import { ConfigurationError } from "../errors/application-error.js"; +import { GATEWAY_DEFAULTS, GATEWAY_ENV_VARS } 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, + invalidNames: string[], +): number { + const raw = env[variableName]; + if (raw === undefined || raw.trim() === "") { + return fallback; + } + + try { + return parseBoundedPositiveInteger(raw, variableName); + } 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, + invalidNames, + ); + const rateLimitWindowMs = readOptionalBoundedPositiveInteger( + env, + GATEWAY_ENV_VARS.rateLimitWindowMs, + GATEWAY_DEFAULTS.rateLimitWindowMs, + invalidNames, + ); + const sourceTimeoutMs = readOptionalBoundedPositiveInteger( + env, + GATEWAY_ENV_VARS.sourceTimeoutMs, + GATEWAY_DEFAULTS.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..6a3c848 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,7 @@ +export { GATEWAY_DEFAULTS, GATEWAY_ENV_VARS, 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..42d9591 --- /dev/null +++ b/src/config/positive-integer.ts @@ -0,0 +1,45 @@ +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, MAX_BOUNDED_POSITIVE_INTEGER]. + * Rejects fractional, NaN, and infinite inputs without inspecting string forms. + */ +export function isBoundedPositiveInteger(value: number): boolean { + return ( + Number.isFinite(value) && + Number.isInteger(value) && + value >= 1 && + value <= MAX_BOUNDED_POSITIVE_INTEGER + ); +} + +/** + * 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): number { + if (!isBoundedPositiveInteger(value)) { + 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): 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); +} 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/tests/unit/gateway-config.test.ts b/tests/unit/gateway-config.test.ts new file mode 100644 index 0000000..b36b219 --- /dev/null +++ b/tests/unit/gateway-config.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + GATEWAY_DEFAULTS, + GATEWAY_ENV_VARS, + 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("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"); + }); +}); + +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); + }, + ); +}); 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/rate-limiter.test.ts b/tests/unit/rate-limiter.test.ts new file mode 100644 index 0000000..a49f344 --- /dev/null +++ b/tests/unit/rate-limiter.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +import { GATEWAY_DEFAULTS } from "../../src/config/defaults.js"; +import { ConfigurationError, RateLimitError } from "../../src/errors/index.js"; +import { FixedWindowRateLimiter } from "../../src/gateway/rate-limiter.js"; + +function createManualClock(startMs = 0): { now: () => number; advance: (ms: number) => void } { + let current = startMs; + return { + now: () => current, + advance: (ms: number) => { + current += ms; + }, + }; +} + +describe("FixedWindowRateLimiter", () => { + it("allows requests up to the configured maximum", async () => { + const clock = createManualClock(); + const limiter = new FixedWindowRateLimiter({ + maxRequests: 3, + windowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + clock: clock.now, + }); + const callback = vi.fn(() => "ok"); + + await expect(limiter.execute("client", callback)).resolves.toBe("ok"); + await expect(limiter.execute("client", callback)).resolves.toBe("ok"); + await expect(limiter.execute("client", callback)).resolves.toBe("ok"); + expect(callback).toHaveBeenCalledTimes(3); + }); + + it("rejects over-limit execution without calling the downstream callback", async () => { + const clock = createManualClock(); + const limiter = new FixedWindowRateLimiter({ + maxRequests: 2, + windowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + clock: clock.now, + }); + const allowed = vi.fn(() => "allowed"); + const rejected = vi.fn(() => "should-not-run"); + + await limiter.execute("client", allowed); + await limiter.execute("client", allowed); + + await expect(limiter.execute("client", rejected)).rejects.toBeInstanceOf(RateLimitError); + expect(rejected).not.toHaveBeenCalled(); + expect(allowed).toHaveBeenCalledTimes(2); + }); + + it("resets the window after windowMs elapses on the injected clock", async () => { + const clock = createManualClock(); + const limiter = new FixedWindowRateLimiter({ + maxRequests: 1, + windowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + clock: clock.now, + }); + const callback = vi.fn(() => "ok"); + + await limiter.execute("client", callback); + await expect(limiter.execute("client", callback)).rejects.toBeInstanceOf(RateLimitError); + + clock.advance(GATEWAY_DEFAULTS.rateLimitWindowMs - 1); + await expect(limiter.execute("client", callback)).rejects.toBeInstanceOf(RateLimitError); + + clock.advance(1); + + await expect(limiter.execute("client", callback)).resolves.toBe("ok"); + expect(callback).toHaveBeenCalledTimes(2); + }); + + it("prunes expired keys on the next cleanup boundary", async () => { + const clock = createManualClock(); + const limiter = new FixedWindowRateLimiter({ + maxRequests: 1, + windowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + clock: clock.now, + }); + + await limiter.execute("a", () => "a"); + await limiter.execute("b", () => "b"); + await limiter.execute("c", () => "c"); + expect(limiter.trackedKeyCount).toBe(3); + + clock.advance(GATEWAY_DEFAULTS.rateLimitWindowMs); + await limiter.execute("d", () => "d"); + + expect(limiter.trackedKeyCount).toBe(1); + }); + + it("tracks keys independently", async () => { + const clock = createManualClock(); + const limiter = new FixedWindowRateLimiter({ + maxRequests: 1, + windowMs: GATEWAY_DEFAULTS.rateLimitWindowMs, + clock: clock.now, + }); + + await expect(limiter.execute("a", () => "a")).resolves.toBe("a"); + await expect(limiter.execute("b", () => "b")).resolves.toBe("b"); + await expect(limiter.execute("a", () => "again")).rejects.toBeInstanceOf(RateLimitError); + }); + + it.each([ + ["fractional maxRequests", { maxRequests: 1.5, windowMs: 1_000 }], + ["NaN maxRequests", { maxRequests: Number.NaN, windowMs: 1_000 }], + ["infinite maxRequests", { maxRequests: Number.POSITIVE_INFINITY, windowMs: 1_000 }], + ["fractional windowMs", { maxRequests: 1, windowMs: 10.5 }], + ["NaN windowMs", { maxRequests: 1, windowMs: Number.NaN }], + ["infinite windowMs", { maxRequests: 1, windowMs: Number.POSITIVE_INFINITY }], + ["zero maxRequests", { maxRequests: 0, windowMs: 1_000 }], + ["negative windowMs", { maxRequests: 1, windowMs: -1 }], + ])("rejects invalid configuration: %s", (_label, options) => { + expect(() => new FixedWindowRateLimiter(options)).toThrow(ConfigurationError); + }); +}); From d6ebb21b10e6cb14e6a50ef16742108e44ef8499 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:55:24 +0200 Subject: [PATCH 17/96] test(m2.3): capture primary liveness --- .../m2/aerodrome-base-full/01-meta.json | 28 +++++++++++++++++++ .../m2/aerodrome-slipstream-base/01-meta.json | 28 +++++++++++++++++++ .../m2/balancer-v2-base/01-meta.json | 28 +++++++++++++++++++ .../m2/baseswap-v2-base/01-meta.json | 28 +++++++++++++++++++ .../m2/exchange-v3-base/01-meta.json | 28 +++++++++++++++++++ .../m2/pancakeswap-v3-base/01-meta.json | 28 +++++++++++++++++++ .../m2/sushiswap-v3-base/01-meta.json | 28 +++++++++++++++++++ .../m2/uniswap-v3-base-messari/01-meta.json | 28 +++++++++++++++++++ .../01-meta.json | 28 +++++++++++++++++++ .../m2/uniswap-v3-base-native/01-meta.json | 28 +++++++++++++++++++ 10 files changed, 280 insertions(+) create mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/01-meta.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/01-meta.json create mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json create mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/01-meta.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/01-meta.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/01-meta.json 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-slipstream-base/01-meta.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/01-meta.json new file mode 100644 index 0000000..36079bb --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:29.441Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "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": 49095499, + "timestamp": 1784980345, + "hash": "0x4cc5dff2859b7e4f5afd9d5eb45275a5192fef1f6d2cb8270ec12daf337a437e" + }, + "hasIndexingErrors": false, + "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 92, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json b/tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json new file mode 100644 index 0000000..515e929 --- /dev/null +++ b/tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:32.684Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "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": "QmRKBwBwPKtFz4mQp5jvH44USVprM4C77Nr4m77UGCbGv9" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 98, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json b/tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json new file mode 100644 index 0000000..d2c37a4 --- /dev/null +++ b/tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:36.200Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "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": 49095503, + "timestamp": 1784980353, + "hash": "0x2e83a9aa7d1a52857ac3706897b89bcd5a3c5b36116f6cea546831989013311c" + }, + "hasIndexingErrors": false, + "deployment": "QmVL9dQdAGfqRbfbjpTXUGZNhFh2rpcPJ8XT5bYXVPvXyr" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 198, + "error": null + } +} 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/pancakeswap-v3-base/01-meta.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/01-meta.json new file mode 100644 index 0000000..9aa6151 --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-v3-base/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:34.817Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "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": 49095502, + "timestamp": 1784980351, + "hash": "0x2576866855bb05b3cd88b978f7e9b7b0c8ac566340e5b3a421341f7b4cfbfb8f" + }, + "hasIndexingErrors": false, + "deployment": "QmY5Wybn5P1BQ5gnV1QuNiszZNk2fimZatKM1XtvV49fBN" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 257, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json new file mode 100644 index 0000000..d21fdda --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:27.771Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "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": 49095499, + "timestamp": 1784980345, + "hash": "0x4cc5dff2859b7e4f5afd9d5eb45275a5192fef1f6d2cb8270ec12daf337a437e" + }, + "hasIndexingErrors": false, + "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 170, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json new file mode 100644 index 0000000..8e60243 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:26.630Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "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": 49095498, + "timestamp": 1784980343, + "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" + }, + "hasIndexingErrors": false, + "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 96, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json new file mode 100644 index 0000000..582c9ab --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:24.921Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "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": 49095498, + "timestamp": 1784980343, + "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" + }, + "hasIndexingErrors": false, + "deployment": "Qmc5N5DW7a99WEpm9aGXSSHotNCSSsTbULSB8YgPreJyE3" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 207, + "error": null + } +} 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 + } +} From a1a84e74c2729cd2e6d82340f3eca5e1e20771fa Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:55:52 +0200 Subject: [PATCH 18/96] test(m2.3): capture alternate liveness --- .../m2/base-slipstream-community/01-meta.json | 22 +++++++++++++++++++ .../pancakeswap-exchange-v3-base/01-meta.json | 22 +++++++++++++++++++ .../01-meta.json | 22 +++++++++++++++++++ .../01-meta.json | 22 +++++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json diff --git a/tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json b/tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json new file mode 100644 index 0000000..6f53ad8 --- /dev/null +++ b/tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:33.680Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", + "query_id": "m2-meta-v1", + "request": { + "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", + "variables": {} + }, + "response": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 58, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json new file mode 100644 index 0000000..effa06e --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:39.262Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", + "query_id": "m2-meta-v1", + "request": { + "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", + "variables": {} + }, + "response": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 70, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json new file mode 100644 index 0000000..164cdf2 --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:37.393Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", + "query_id": "m2-meta-v1", + "request": { + "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", + "variables": {} + }, + "response": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 63, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json new file mode 100644 index 0000000..8f979c5 --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:38.314Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", + "query_id": "m2-meta-v1", + "request": { + "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", + "variables": {} + }, + "response": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 56, + "error": null + } +} From eacf954a5dcbaf2b172f0af81ae485d16411eaa2 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:57:12 +0200 Subject: [PATCH 19/96] test(m2.4): classify primary Uniswap schemas --- .../02-tier-a-marker.json | 37 +++ .../03-tier-b-marker.json | 28 ++ .../02-tier-a-marker.json | 28 ++ .../03-tier-b-marker.json | 33 +++ .../03a-tier-b-lineage.json | 247 ++++++++++++++++++ 5 files changed, 373 insertions(+) create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/03a-tier-b-lineage.json diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json new file mode 100644 index 0000000..0f439ab --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json @@ -0,0 +1,37 @@ +{ + "captured_at": "2026-07-25T11:52:26.977Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "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": { + "data": { + "dexAmmProtocols": [ + { + "id": "0x33128a8fc17869897dce68ed026d694621f6fdfd", + "name": "Uniswap V3", + "schemaVersion": "4.0.1", + "methodologyVersion": "1.0.0", + "network": "BASE" + } + ], + "_meta": { + "block": { + "number": 49095498, + "timestamp": 1784980343, + "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" + }, + "hasIndexingErrors": false, + "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 96, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json new file mode 100644 index 0000000..8df7519 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:27.351Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "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": { + "errors": [ + { + "locations": [ + { + "column": 3, + "line": 7 + } + ], + "message": "Type `Query` has no field `factories`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 122, + "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 + } +} From 5f164141e0509cb0673a01306475bce50d50c035 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 13:59:33 +0200 Subject: [PATCH 20/96] test(m2.4): classify alternate Uniswap schema --- .../02-tier-a-marker.json | 28 +++ .../03-tier-b-marker.json | 33 +++ .../03a-tier-b-lineage.json | 235 ++++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json new file mode 100644 index 0000000..1153be0 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:25.346Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "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": 175, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json new file mode 100644 index 0000000..cb7ed3f --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json @@ -0,0 +1,33 @@ +{ + "captured_at": "2026-07-25T11:52:25.777Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "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": "Qmc5N5DW7a99WEpm9aGXSSHotNCSSsTbULSB8YgPreJyE3" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 181, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json new file mode 100644 index 0000000..2ad5884 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json @@ -0,0 +1,235 @@ +{ + "captured_at": "2026-07-25T11:52:26.283Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "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": 49095499, + "timestamp": 1784980345, + "hash": "0x4cc5dff2859b7e4f5afd9d5eb45275a5192fef1f6d2cb8270ec12daf337a437e" + }, + "hasIndexingErrors": false, + "deployment": "Qmc5N5DW7a99WEpm9aGXSSHotNCSSsTbULSB8YgPreJyE3" + }, + "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": "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": "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": 255, + "error": null + } +} From cb8269bfd4897d02dc138de3eaec85ddbbbb69d5 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 14:08:54 +0200 Subject: [PATCH 21/96] test(m2): capture blocked source evaluation --- docs/source-scope.md | 85 +++++ .../aerodrome-base-full/02-tier-a-marker.json | 28 ++ .../aerodrome-base-full/03-tier-b-marker.json | 33 ++ .../03a-tier-b-lineage.json | 247 +++++++++++++++ .../m2/aerodrome-base-full/04-tokens.json | 54 ++++ .../m2/aerodrome-base-full/05-pools.json | 22 ++ .../02-tier-a-marker.json | 28 ++ .../03-tier-b-marker.json | 33 ++ .../03a-tier-b-lineage.json | 73 +++++ .../aerodrome-slipstream-base/04-tokens.json | 54 ++++ .../m2/balancer-v2-base/02-tier-a-marker.json | 28 ++ .../m2/balancer-v2-base/03-tier-b-marker.json | 28 ++ .../m2/balancer-v2-base/04-tokens.json | 54 ++++ .../02-tier-a-marker.json | 22 ++ .../03-tier-b-marker.json | 22 ++ .../m2/baseswap-v2-base/02-tier-a-marker.json | 28 ++ .../m2/baseswap-v2-base/03-tier-b-marker.json | 28 ++ .../m2/baseswap-v2-base/04-tokens.json | 54 ++++ .../m2/exchange-v3-base/02-tier-a-marker.json | 28 ++ .../m2/exchange-v3-base/03-tier-b-marker.json | 33 ++ .../exchange-v3-base/03a-tier-b-lineage.json | 265 ++++++++++++++++ .../m2/exchange-v3-base/04-tokens.json | 54 ++++ .../m2/exchange-v3-base/05-pools.json | 93 ++++++ .../m2/exchange-v3-base/06-snapshots.json | 74 +++++ .../exchange-v3-base/07-common-metrics.json | 89 ++++++ .../integration/__evidence__/m2/manifest.json | 290 ++++++++++++++++++ .../02-tier-a-marker.json | 22 ++ .../03-tier-b-marker.json | 22 ++ .../pancakeswap-v3-base/02-tier-a-marker.json | 28 ++ .../pancakeswap-v3-base/03-tier-b-marker.json | 28 ++ .../m2/pancakeswap-v3-base/04-tokens.json | 34 ++ .../04a-token-introspection.json | 29 ++ .../02-tier-a-marker.json | 22 ++ .../03-tier-b-marker.json | 22 ++ .../02-tier-a-marker.json | 22 ++ .../03-tier-b-marker.json | 22 ++ .../sushiswap-v3-base/02-tier-a-marker.json | 28 ++ .../sushiswap-v3-base/03-tier-b-marker.json | 33 ++ .../sushiswap-v3-base/03a-tier-b-lineage.json | 70 +++++ .../m2/sushiswap-v3-base/04-tokens.json | 54 ++++ .../m2/uniswap-v3-base-messari/04-tokens.json | 54 ++++ .../04-tokens.json | 25 ++ .../m2/uniswap-v3-base-native/04-tokens.json | 54 ++++ .../m2/uniswap-v3-base-native/05-pools.json | 138 +++++++++ .../uniswap-v3-base-native/06-snapshots.json | 74 +++++ .../07-common-metrics.json | 89 ++++++ 46 files changed, 2717 insertions(+) create mode 100644 docs/source-scope.md create mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/05-pools.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json create mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/05-pools.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/06-snapshots.json create mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/07-common-metrics.json create mode 100644 tests/integration/__evidence__/m2/manifest.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json create mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/05-pools.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/06-snapshots.json create mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/07-common-metrics.json diff --git a/docs/source-scope.md b/docs/source-scope.md new file mode 100644 index 0000000..d6745f2 --- /dev/null +++ b/docs/source-scope.md @@ -0,0 +1,85 @@ +# M2 Live Source Scope — Blocked + +## Status + +M2 is blocked as of the 2026-07-25 capture. The required three compatible +deployments and pools were not demonstrated. Two Tier-B deployments passed the +native-USDC pool and common-query checks; Aerodrome pool discovery timed out. +No retry, tier mixing, silent fallback, registry finalization, fixture rewrite, +or `PLAN.md` scope lock was performed. + +## Locked inputs + +- Chain: Base (`chain_id` 8453). +- Base token: WETH `0x4200000000000000000000000000000000000006`, + symbol `WETH`, 18 decimals. +- Quote token: native USDC + `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`, symbol `USDC`, + 6 decimals. +- Schema tier under review: Tier B, Uniswap-V3 native lineage. +- Versions: `null`; Tier-B deployments do not publish Messari schema or + methodology versions. + +The token probes also found USDbC consistently, but native USDC remained the +default and was used for pool discovery. + +## Validated sources + +| Protocol | Subgraph ID | Deployment ID | Pool | Fee | Result | +| :--- | :--- | :--- | :--- | :--- | :--- | +| Uniswap V3 | `GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz` | `QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR` | `0x6c561b446416e1a00e8e93e221854d6ea4171372` | 30 bps (`feeTier` 3000) | Common query returned TVL and non-zero daily volume/fees. | +| PancakeSwap V3 | `BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3` | `QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g` | `0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38` | 1 bps (`feeTier` 100) | Common query returned TVL and non-zero daily volume/fees. | + +Both pool responses agree on the configured WETH and native-USDC addresses, +symbols, and decimals. The exact `m2-tier-b-metrics-v1` query document returned +the pool plus `poolDayDatas` for both. + +## Blocking third source + +Aerodrome Full was healthy at the metadata, marker, lineage, and token stages: + +- Subgraph ID: + `GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM` +- Deployment ID: + `QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV` + +Its native-USDC pool-discovery request exceeded the binding 15-second deadline. +The failure is preserved in `aerodrome-base-full/05-pools.json`. The scouting +policy forbids retries that mask failures, and the user chose to stop rather +than expand candidate scope or manually review Aerodrome. + +## Candidate decisions + +- Tier A had only one healthy candidate, Messari Uniswap V3, so it could not + satisfy the three-source rule. +- SushiSwap's `factories` marker was coincidental: its lineage evidence has no + compatible `Pool` or `PoolDayData`. +- Aerodrome Slipstream renames or omits required pool/day fields, breaking the + one-template rule. +- Balancer V2, BaseSwap V2, and the first Pancake candidate did not match either + supported tier. +- Four community candidates returned no usable `_meta` deployment and were + rejected. +- The alternate Uniswap deployment was not promoted because its token probe + timed out and it would not provide a distinct third protocol. + +## 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. + +## Evidence and limitations + +Evidence is stored under `tests/integration/__evidence__/m2/`. The manifest +records the reference block, per-candidate outcome, token decision, two +validated selections, and the blocker. Captures are point-in-time observations; +publishers may update the deployment behind a stable subgraph ID. + +Because the M2 exit criterion was not met: + +- `src/registry/records.json` was not finalized; +- live fixtures were not substituted; +- the `PLAN.md` scope table remains unresolved; +- M2.5 through M2.9 and M2 complete remain unticked. diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json b/tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json new file mode 100644 index 0000000..64eba35 --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:31.441Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "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": 176, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json b/tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json new file mode 100644 index 0000000..03f3ff5 --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json @@ -0,0 +1,33 @@ +{ + "captured_at": "2026-07-25T11:52:31.865Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "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": "0x5e7bb104d84c7cb9b682aac2f3d509f5f406809a" + } + ], + "_meta": { + "block": { + "number": 49095501, + "timestamp": 1784980349, + "hash": "0xc2c108bd30e1b612b1969daf6768e18d81a5c5db8e952b4b7b951d052ce404e6" + }, + "hasIndexingErrors": false, + "deployment": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 173, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json new file mode 100644 index 0000000..a4580e5 --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json @@ -0,0 +1,247 @@ +{ + "captured_at": "2026-07-25T11:52:32.335Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "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": 49095501, + "timestamp": 1784980349, + "hash": "0xc2c108bd30e1b612b1969daf6768e18d81a5c5db8e952b4b7b951d052ce404e6" + }, + "hasIndexingErrors": false, + "deployment": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV" + }, + "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": 218, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json b/tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json new file mode 100644 index 0000000..775e45c --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:45.558Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": "18" + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": "6" + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": "6" + } + ], + "_meta": { + "block": { + "number": 49095538, + "timestamp": 1784980423, + "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" + }, + "hasIndexingErrors": false, + "deployment": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 187, + "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/aerodrome-slipstream-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/02-tier-a-marker.json new file mode 100644 index 0000000..9c742da --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:29.784Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "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": [ + { + "column": 3, + "line": 7 + } + ], + "message": "Type `Query` has no field `dexAmmProtocols`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 92, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json new file mode 100644 index 0000000..fbdc3d2 --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json @@ -0,0 +1,33 @@ +{ + "captured_at": "2026-07-25T11:52:30.220Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "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": "0xaDe65c38CD4849aDBA595a4323a8C7DdfE89716a" + } + ], + "_meta": { + "block": { + "number": 49095500, + "timestamp": 1784980347, + "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" + }, + "hasIndexingErrors": false, + "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 185, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json new file mode 100644 index 0000000..9355800 --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json @@ -0,0 +1,73 @@ +{ + "captured_at": "2026-07-25T11:52:30.592Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "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": 49095500, + "timestamp": 1784980347, + "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" + }, + "hasIndexingErrors": false, + "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" + }, + "factoryType": { + "fields": [ + { + "name": "id" + }, + { + "name": "poolCount" + }, + { + "name": "owner" + } + ] + }, + "poolType": { + "fields": [ + { + "name": "id" + }, + { + "name": "createdAtTimestamp" + }, + { + "name": "createdAtBlockNumber" + }, + { + "name": "token0" + }, + { + "name": "token1" + }, + { + "name": "tickSpacing" + }, + { + "name": "liquidity" + }, + { + "name": "sqrtPrice" + }, + { + "name": "tick" + } + ] + }, + "dayType": null + } + }, + "transport": { + "http_status": 200, + "latency_ms": 120, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json new file mode 100644 index 0000000..a603aff --- /dev/null +++ b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:45.119Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": "18" + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": "6" + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": "6" + } + ], + "_meta": { + "block": { + "number": 49095538, + "timestamp": 1784980423, + "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" + }, + "hasIndexingErrors": false, + "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 103, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json new file mode 100644 index 0000000..ddd72e3 --- /dev/null +++ b/tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:33.023Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "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": 88, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json new file mode 100644 index 0000000..19ee3dd --- /dev/null +++ b/tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:33.371Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "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": { + "errors": [ + { + "locations": [ + { + "column": 3, + "line": 7 + } + ], + "message": "Type `Query` has no field `factories`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 97, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json b/tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json new file mode 100644 index 0000000..9405be5 --- /dev/null +++ b/tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:46.016Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18 + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6 + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": 6 + } + ], + "_meta": { + "block": { + "number": 49095538, + "timestamp": 1784980423, + "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" + }, + "hasIndexingErrors": false, + "deployment": "QmRKBwBwPKtFz4mQp5jvH44USVprM4C77Nr4m77UGCbGv9" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 207, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json b/tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json new file mode 100644 index 0000000..39427cc --- /dev/null +++ b/tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:34.002Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", + "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": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 71, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json b/tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json new file mode 100644 index 0000000..9193ce2 --- /dev/null +++ b/tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:34.309Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", + "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": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 55, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json new file mode 100644 index 0000000..b8b56a5 --- /dev/null +++ b/tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:36.618Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "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": [ + { + "column": 3, + "line": 7 + } + ], + "message": "Type `Query` has no field `dexAmmProtocols`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 167, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json new file mode 100644 index 0000000..b12202e --- /dev/null +++ b/tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:37.079Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "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": { + "errors": [ + { + "locations": [ + { + "line": 7, + "column": 3 + } + ], + "message": "Type `Query` has no field `factories`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 210, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json b/tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json new file mode 100644 index 0000000..9238670 --- /dev/null +++ b/tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:58.174Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": "18" + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": "6" + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": "6" + } + ], + "_meta": { + "block": { + "number": 49095539, + "timestamp": 1784980425, + "hash": "0x596ef3998589180fd72aae74be82f2f9bf4acab6e9175bde07d61b887b5276aa" + }, + "hasIndexingErrors": false, + "deployment": "QmVL9dQdAGfqRbfbjpTXUGZNhFh2rpcPJ8XT5bYXVPvXyr" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 10880, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json new file mode 100644 index 0000000..d82de6c --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:40.568Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "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": [ + { + "column": 3, + "line": 7 + } + ], + "message": "Type `Query` has no field `dexAmmProtocols`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 97, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json new file mode 100644 index 0000000..b6f359c --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json @@ -0,0 +1,33 @@ +{ + "captured_at": "2026-07-25T11:52:40.996Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "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": "0x0bfbcf9fa4f9c56b0f40a671ad40e0805a091865" + } + ], + "_meta": { + "block": { + "number": 49095505, + "timestamp": 1784980357, + "hash": "0x1cbee29e4eb7febf872604a744bb304152e364c0ad47b0cd1d159c7e3c97f821" + }, + "hasIndexingErrors": false, + "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 176, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json new file mode 100644 index 0000000..7f5760a --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json @@ -0,0 +1,265 @@ +{ + "captured_at": "2026-07-25T11:52:41.354Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "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": 49095505, + "timestamp": 1784980357, + "hash": "0x1cbee29e4eb7febf872604a744bb304152e364c0ad47b0cd1d159c7e3c97f821" + }, + "hasIndexingErrors": false, + "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" + }, + "factoryType": { + "fields": [ + { + "name": "id" + }, + { + "name": "poolCount" + }, + { + "name": "txCount" + }, + { + "name": "totalVolumeUSD" + }, + { + "name": "totalVolumeETH" + }, + { + "name": "totalFeesUSD" + }, + { + "name": "totalFeesETH" + }, + { + "name": "totalProtocolFeesUSD" + }, + { + "name": "totalProtocolFeesETH" + }, + { + "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": "feeProtocol" + }, + { + "name": "feeGrowthGlobal0X128" + }, + { + "name": "feeGrowthGlobal1X128" + }, + { + "name": "token0Price" + }, + { + "name": "token1Price" + }, + { + "name": "tick" + }, + { + "name": "observationIndex" + }, + { + "name": "volumeToken0" + }, + { + "name": "volumeToken1" + }, + { + "name": "volumeUSD" + }, + { + "name": "untrackedVolumeUSD" + }, + { + "name": "feesUSD" + }, + { + "name": "protocolFeesUSD" + }, + { + "name": "txCount" + }, + { + "name": "collectedFeesToken0" + }, + { + "name": "collectedFeesToken1" + }, + { + "name": "collectedFeesUSD" + }, + { + "name": "totalValueLockedToken0" + }, + { + "name": "totalValueLockedToken1" + }, + { + "name": "totalValueLockedETH" + }, + { + "name": "totalValueLockedUSD" + }, + { + "name": "totalValueLockedUSDUntracked" + }, + { + "name": "totalValueLockedETHUntracked" + }, + { + "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": "protocolFeesUSD" + }, + { + "name": "txCount" + }, + { + "name": "open" + }, + { + "name": "high" + }, + { + "name": "low" + }, + { + "name": "close" + } + ] + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 107, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json b/tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json new file mode 100644 index 0000000..708acdd --- /dev/null +++ b/tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:58.535Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": "18" + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": "6" + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": "6" + } + ], + "_meta": { + "block": { + "number": 49095544, + "timestamp": 1784980435, + "hash": "0xb75803c6d89a562738fe558853fd17f7aba5aed2ab714b9a905999c38066f063" + }, + "hasIndexingErrors": false, + "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 110, + "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/pancakeswap-exchange-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/02-tier-a-marker.json new file mode 100644 index 0000000..66cf440 --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/02-tier-a-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:39.565Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", + "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": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 52, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json new file mode 100644 index 0000000..b0c641d --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:39.875Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", + "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": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 59, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json new file mode 100644 index 0000000..14e3650 --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:35.287Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "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": 218, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json new file mode 100644 index 0000000..a392a63 --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:35.751Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "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": { + "errors": [ + { + "locations": [ + { + "column": 3, + "line": 7 + } + ], + "message": "Type `Query` has no field `factories`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 213, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json new file mode 100644 index 0000000..9fb83d5 --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json @@ -0,0 +1,34 @@ +{ + "captured_at": "2026-07-25T11:53:46.515Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "errors": [ + { + "locations": [ + { + "column": 3, + "line": 7 + } + ], + "message": "Type `Query` has no field `tokens`" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 248, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json new file mode 100644 index 0000000..313861d --- /dev/null +++ b/tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json @@ -0,0 +1,29 @@ +{ + "captured_at": "2026-07-25T11:53:47.043Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", + "query_id": "m2-token-introspection-v1", + "request": { + "query": "query M2TokenIntrospection {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n __type(name: \"Token\") { fields { name type { kind name ofType { kind name } } } }\n}", + "variables": {} + }, + "response": { + "data": { + "_meta": { + "block": { + "number": 49095538, + "timestamp": 1784980423, + "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" + }, + "hasIndexingErrors": false, + "deployment": "QmY5Wybn5P1BQ5gnV1QuNiszZNk2fimZatKM1XtvV49fBN" + }, + "__type": null + } + }, + "transport": { + "http_status": 200, + "latency_ms": 276, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json new file mode 100644 index 0000000..3fdad5b --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:37.699Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", + "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": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 54, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json new file mode 100644 index 0000000..6bdde39 --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:38.008Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", + "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": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 57, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json new file mode 100644 index 0000000..7a79936 --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:38.634Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", + "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": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 69, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json new file mode 100644 index 0000000..c98c72b --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json @@ -0,0 +1,22 @@ +{ + "captured_at": "2026-07-25T11:52:38.941Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", + "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": { + "errors": [ + { + "message": "subgraph not found: no allocations" + } + ] + }, + "transport": { + "http_status": 200, + "latency_ms": 57, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json new file mode 100644 index 0000000..7eac9ba --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json @@ -0,0 +1,28 @@ +{ + "captured_at": "2026-07-25T11:52:28.220Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "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": 197, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json new file mode 100644 index 0000000..8b02979 --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json @@ -0,0 +1,33 @@ +{ + "captured_at": "2026-07-25T11:52:28.644Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "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": "0x71524b4f93c58fcbf659783284e38825f0622859" + } + ], + "_meta": { + "block": { + "number": 49095500, + "timestamp": 1784980347, + "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" + }, + "hasIndexingErrors": false, + "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 173, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json new file mode 100644 index 0000000..d772af2 --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json @@ -0,0 +1,70 @@ +{ + "captured_at": "2026-07-25T11:52:29.098Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "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": 49095500, + "timestamp": 1784980347, + "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" + }, + "hasIndexingErrors": false, + "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" + }, + "factoryType": { + "fields": [ + { + "name": "id" + }, + { + "name": "type" + }, + { + "name": "volumeUSD" + }, + { + "name": "volumeNative" + }, + { + "name": "liquidityUSD" + }, + { + "name": "liquidityNative" + }, + { + "name": "feesUSD" + }, + { + "name": "feesNative" + }, + { + "name": "pairCount" + }, + { + "name": "transactionCount" + }, + { + "name": "tokenCount" + }, + { + "name": "userCount" + } + ] + }, + "poolType": null, + "dayType": null + } + }, + "transport": { + "http_status": 200, + "latency_ms": 203, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json new file mode 100644 index 0000000..765bf66 --- /dev/null +++ b/tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:44.765Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": "18" + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": "6" + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": "6" + } + ], + "_meta": { + "block": { + "number": 49095534, + "timestamp": 1784980415, + "hash": "0xc0e39d0152d7feb62e60302d450d1d8d19c4a0242ca78d1c928dcf13747a34f4" + }, + "hasIndexingErrors": false, + "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 7678, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json new file mode 100644 index 0000000..10fb3bd --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:36.837Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": 18 + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": 6 + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": 6 + } + ], + "_meta": { + "block": { + "number": 49095531, + "timestamp": 1784980409, + "hash": "0x7377a5de055bded3cb4004d411a8b2684fd1950d99186d44ca4bbb53ae850b1a" + }, + "hasIndexingErrors": false, + "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 514, + "error": null + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json new file mode 100644 index 0000000..c25b311 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json @@ -0,0 +1,25 @@ +{ + "captured_at": "2026-07-25T11:53:36.071Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": null, + "transport": { + "http_status": null, + "latency_ms": 15012, + "error": { + "kind": "timeout", + "message": "Graph gateway request exceeded the 15 second timeout." + } + } +} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json new file mode 100644 index 0000000..61b5e80 --- /dev/null +++ b/tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json @@ -0,0 +1,54 @@ +{ + "captured_at": "2026-07-25T11:53:20.807Z", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", + "query_id": "m2-tokens-v2", + "request": { + "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", + "variables": { + "ids": [ + "0x4200000000000000000000000000000000000006", + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" + ] + } + }, + "response": { + "data": { + "tokens": [ + { + "id": "0x4200000000000000000000000000000000000006", + "symbol": "WETH", + "name": "Wrapped Ether", + "decimals": "18" + }, + { + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "symbol": "USDC", + "name": "USD Coin", + "decimals": "6" + }, + { + "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + "symbol": "USDbC", + "name": "USD Base Coin", + "decimals": "6" + } + ], + "_meta": { + "block": { + "number": 49095524, + "timestamp": 1784980395, + "hash": "0x0bd5ea6d435e2c5d20d709ab96c9a67fd6b57b556ffd6500b18421a40d61b338" + }, + "hasIndexingErrors": false, + "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" + } + } + }, + "transport": { + "http_status": 200, + "latency_ms": 435, + "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 + } +} From d732b79569909f12e0020ad776a4e413ccbf9a9d Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 14:11:20 +0200 Subject: [PATCH 22/96] test(m2): keep minimal source evidence --- .../aerodrome-base-full/02-tier-a-marker.json | 28 -- .../aerodrome-base-full/03-tier-b-marker.json | 33 --- .../03a-tier-b-lineage.json | 247 ---------------- .../m2/aerodrome-base-full/04-tokens.json | 54 ---- .../m2/aerodrome-slipstream-base/01-meta.json | 28 -- .../02-tier-a-marker.json | 28 -- .../03-tier-b-marker.json | 33 --- .../03a-tier-b-lineage.json | 73 ----- .../aerodrome-slipstream-base/04-tokens.json | 54 ---- .../m2/balancer-v2-base/01-meta.json | 28 -- .../m2/balancer-v2-base/02-tier-a-marker.json | 28 -- .../m2/balancer-v2-base/03-tier-b-marker.json | 28 -- .../m2/balancer-v2-base/04-tokens.json | 54 ---- .../m2/base-slipstream-community/01-meta.json | 22 -- .../02-tier-a-marker.json | 22 -- .../03-tier-b-marker.json | 22 -- .../m2/baseswap-v2-base/01-meta.json | 28 -- .../m2/baseswap-v2-base/02-tier-a-marker.json | 28 -- .../m2/baseswap-v2-base/03-tier-b-marker.json | 28 -- .../m2/baseswap-v2-base/04-tokens.json | 54 ---- .../m2/exchange-v3-base/02-tier-a-marker.json | 28 -- .../m2/exchange-v3-base/03-tier-b-marker.json | 33 --- .../exchange-v3-base/03a-tier-b-lineage.json | 265 ------------------ .../m2/exchange-v3-base/04-tokens.json | 54 ---- .../pancakeswap-exchange-v3-base/01-meta.json | 22 -- .../02-tier-a-marker.json | 22 -- .../03-tier-b-marker.json | 22 -- .../m2/pancakeswap-v3-base/01-meta.json | 28 -- .../pancakeswap-v3-base/02-tier-a-marker.json | 28 -- .../pancakeswap-v3-base/03-tier-b-marker.json | 28 -- .../m2/pancakeswap-v3-base/04-tokens.json | 34 --- .../04a-token-introspection.json | 29 -- .../01-meta.json | 22 -- .../02-tier-a-marker.json | 22 -- .../03-tier-b-marker.json | 22 -- .../01-meta.json | 22 -- .../02-tier-a-marker.json | 22 -- .../03-tier-b-marker.json | 22 -- .../m2/sushiswap-v3-base/01-meta.json | 28 -- .../sushiswap-v3-base/02-tier-a-marker.json | 28 -- .../sushiswap-v3-base/03-tier-b-marker.json | 33 --- .../sushiswap-v3-base/03a-tier-b-lineage.json | 70 ----- .../m2/sushiswap-v3-base/04-tokens.json | 54 ---- .../m2/uniswap-v3-base-messari/01-meta.json | 28 -- .../02-tier-a-marker.json | 37 --- .../03-tier-b-marker.json | 28 -- .../m2/uniswap-v3-base-messari/04-tokens.json | 54 ---- .../01-meta.json | 28 -- .../02-tier-a-marker.json | 28 -- .../03-tier-b-marker.json | 33 --- .../03a-tier-b-lineage.json | 235 ---------------- .../04-tokens.json | 25 -- .../m2/uniswap-v3-base-native/04-tokens.json | 54 ---- 53 files changed, 2408 deletions(-) delete mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json delete mode 100644 tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json delete mode 100644 tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json delete mode 100644 tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json delete mode 100644 tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json b/tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json deleted file mode 100644 index 64eba35..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-base-full/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:31.441Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", - "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": 176, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json b/tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json deleted file mode 100644 index 03f3ff5..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-base-full/03-tier-b-marker.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:31.865Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", - "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": "0x5e7bb104d84c7cb9b682aac2f3d509f5f406809a" - } - ], - "_meta": { - "block": { - "number": 49095501, - "timestamp": 1784980349, - "hash": "0xc2c108bd30e1b612b1969daf6768e18d81a5c5db8e952b4b7b951d052ce404e6" - }, - "hasIndexingErrors": false, - "deployment": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 173, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json deleted file mode 100644 index a4580e5..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-base-full/03a-tier-b-lineage.json +++ /dev/null @@ -1,247 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:32.335Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", - "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": 49095501, - "timestamp": 1784980349, - "hash": "0xc2c108bd30e1b612b1969daf6768e18d81a5c5db8e952b4b7b951d052ce404e6" - }, - "hasIndexingErrors": false, - "deployment": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV" - }, - "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": 218, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json b/tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json deleted file mode 100644 index 775e45c..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-base-full/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:45.558Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": "18" - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": "6" - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": "6" - } - ], - "_meta": { - "block": { - "number": 49095538, - "timestamp": 1784980423, - "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" - }, - "hasIndexingErrors": false, - "deployment": "QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 187, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/01-meta.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/01-meta.json deleted file mode 100644 index 36079bb..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/01-meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:29.441Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", - "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": 49095499, - "timestamp": 1784980345, - "hash": "0x4cc5dff2859b7e4f5afd9d5eb45275a5192fef1f6d2cb8270ec12daf337a437e" - }, - "hasIndexingErrors": false, - "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 92, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/02-tier-a-marker.json deleted file mode 100644 index 9c742da..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:29.784Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", - "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": [ - { - "column": 3, - "line": 7 - } - ], - "message": "Type `Query` has no field `dexAmmProtocols`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 92, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json deleted file mode 100644 index fbdc3d2..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03-tier-b-marker.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:30.220Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", - "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": "0xaDe65c38CD4849aDBA595a4323a8C7DdfE89716a" - } - ], - "_meta": { - "block": { - "number": 49095500, - "timestamp": 1784980347, - "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" - }, - "hasIndexingErrors": false, - "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 185, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json deleted file mode 100644 index 9355800..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/03a-tier-b-lineage.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:30.592Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", - "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": 49095500, - "timestamp": 1784980347, - "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" - }, - "hasIndexingErrors": false, - "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" - }, - "factoryType": { - "fields": [ - { - "name": "id" - }, - { - "name": "poolCount" - }, - { - "name": "owner" - } - ] - }, - "poolType": { - "fields": [ - { - "name": "id" - }, - { - "name": "createdAtTimestamp" - }, - { - "name": "createdAtBlockNumber" - }, - { - "name": "token0" - }, - { - "name": "token1" - }, - { - "name": "tickSpacing" - }, - { - "name": "liquidity" - }, - { - "name": "sqrtPrice" - }, - { - "name": "tick" - } - ] - }, - "dayType": null - } - }, - "transport": { - "http_status": 200, - "latency_ms": 120, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json b/tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json deleted file mode 100644 index a603aff..0000000 --- a/tests/integration/__evidence__/m2/aerodrome-slipstream-base/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:45.119Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "EeEmhjkK76RKQpZcfT2S8ZxzcV6Saq4RUw6krq1KuDJu", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": "18" - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": "6" - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": "6" - } - ], - "_meta": { - "block": { - "number": 49095538, - "timestamp": 1784980423, - "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" - }, - "hasIndexingErrors": false, - "deployment": "QmdX5trUrA2r2PJJmZo7L6suHAMJNzoEDzDUFKjyzpAMGy" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 103, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json b/tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json deleted file mode 100644 index 515e929..0000000 --- a/tests/integration/__evidence__/m2/balancer-v2-base/01-meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:32.684Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", - "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": "QmRKBwBwPKtFz4mQp5jvH44USVprM4C77Nr4m77UGCbGv9" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 98, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json deleted file mode 100644 index ddd72e3..0000000 --- a/tests/integration/__evidence__/m2/balancer-v2-base/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:33.023Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", - "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": 88, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json deleted file mode 100644 index 19ee3dd..0000000 --- a/tests/integration/__evidence__/m2/balancer-v2-base/03-tier-b-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:33.371Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", - "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": { - "errors": [ - { - "locations": [ - { - "column": 3, - "line": 7 - } - ], - "message": "Type `Query` has no field `factories`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 97, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json b/tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json deleted file mode 100644 index 9405be5..0000000 --- a/tests/integration/__evidence__/m2/balancer-v2-base/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:46.016Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "E7XyutxXVLrp8njmjF16Hh38PCJuHm12RRyMt5ma4ctX", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": 18 - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": 6 - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": 6 - } - ], - "_meta": { - "block": { - "number": 49095538, - "timestamp": 1784980423, - "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" - }, - "hasIndexingErrors": false, - "deployment": "QmRKBwBwPKtFz4mQp5jvH44USVprM4C77Nr4m77UGCbGv9" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 207, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json b/tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json deleted file mode 100644 index 6f53ad8..0000000 --- a/tests/integration/__evidence__/m2/base-slipstream-community/01-meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:33.680Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", - "query_id": "m2-meta-v1", - "request": { - "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", - "variables": {} - }, - "response": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 58, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json b/tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json deleted file mode 100644 index 39427cc..0000000 --- a/tests/integration/__evidence__/m2/base-slipstream-community/02-tier-a-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:34.002Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", - "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": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 71, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json b/tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json deleted file mode 100644 index 9193ce2..0000000 --- a/tests/integration/__evidence__/m2/base-slipstream-community/03-tier-b-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:34.309Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "5U2aXafXWCubcFTJiJ4szrxKXJ562JdEVdbfCo6J4cms", - "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": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 55, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json b/tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json deleted file mode 100644 index d2c37a4..0000000 --- a/tests/integration/__evidence__/m2/baseswap-v2-base/01-meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:36.200Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", - "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": 49095503, - "timestamp": 1784980353, - "hash": "0x2e83a9aa7d1a52857ac3706897b89bcd5a3c5b36116f6cea546831989013311c" - }, - "hasIndexingErrors": false, - "deployment": "QmVL9dQdAGfqRbfbjpTXUGZNhFh2rpcPJ8XT5bYXVPvXyr" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 198, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json deleted file mode 100644 index b8b56a5..0000000 --- a/tests/integration/__evidence__/m2/baseswap-v2-base/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:36.618Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", - "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": [ - { - "column": 3, - "line": 7 - } - ], - "message": "Type `Query` has no field `dexAmmProtocols`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 167, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json deleted file mode 100644 index b12202e..0000000 --- a/tests/integration/__evidence__/m2/baseswap-v2-base/03-tier-b-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:37.079Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", - "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": { - "errors": [ - { - "locations": [ - { - "line": 7, - "column": 3 - } - ], - "message": "Type `Query` has no field `factories`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 210, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json b/tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json deleted file mode 100644 index 9238670..0000000 --- a/tests/integration/__evidence__/m2/baseswap-v2-base/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:58.174Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "SU9VhLEYR58QqvRmvCpDQarCkb6fb4cL9Pj3WgNcALD", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": "18" - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": "6" - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": "6" - } - ], - "_meta": { - "block": { - "number": 49095539, - "timestamp": 1784980425, - "hash": "0x596ef3998589180fd72aae74be82f2f9bf4acab6e9175bde07d61b887b5276aa" - }, - "hasIndexingErrors": false, - "deployment": "QmVL9dQdAGfqRbfbjpTXUGZNhFh2rpcPJ8XT5bYXVPvXyr" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 10880, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json deleted file mode 100644 index d82de6c..0000000 --- a/tests/integration/__evidence__/m2/exchange-v3-base/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:40.568Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", - "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": [ - { - "column": 3, - "line": 7 - } - ], - "message": "Type `Query` has no field `dexAmmProtocols`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 97, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json deleted file mode 100644 index b6f359c..0000000 --- a/tests/integration/__evidence__/m2/exchange-v3-base/03-tier-b-marker.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:40.996Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", - "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": "0x0bfbcf9fa4f9c56b0f40a671ad40e0805a091865" - } - ], - "_meta": { - "block": { - "number": 49095505, - "timestamp": 1784980357, - "hash": "0x1cbee29e4eb7febf872604a744bb304152e364c0ad47b0cd1d159c7e3c97f821" - }, - "hasIndexingErrors": false, - "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 176, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json deleted file mode 100644 index 7f5760a..0000000 --- a/tests/integration/__evidence__/m2/exchange-v3-base/03a-tier-b-lineage.json +++ /dev/null @@ -1,265 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:41.354Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", - "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": 49095505, - "timestamp": 1784980357, - "hash": "0x1cbee29e4eb7febf872604a744bb304152e364c0ad47b0cd1d159c7e3c97f821" - }, - "hasIndexingErrors": false, - "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" - }, - "factoryType": { - "fields": [ - { - "name": "id" - }, - { - "name": "poolCount" - }, - { - "name": "txCount" - }, - { - "name": "totalVolumeUSD" - }, - { - "name": "totalVolumeETH" - }, - { - "name": "totalFeesUSD" - }, - { - "name": "totalFeesETH" - }, - { - "name": "totalProtocolFeesUSD" - }, - { - "name": "totalProtocolFeesETH" - }, - { - "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": "feeProtocol" - }, - { - "name": "feeGrowthGlobal0X128" - }, - { - "name": "feeGrowthGlobal1X128" - }, - { - "name": "token0Price" - }, - { - "name": "token1Price" - }, - { - "name": "tick" - }, - { - "name": "observationIndex" - }, - { - "name": "volumeToken0" - }, - { - "name": "volumeToken1" - }, - { - "name": "volumeUSD" - }, - { - "name": "untrackedVolumeUSD" - }, - { - "name": "feesUSD" - }, - { - "name": "protocolFeesUSD" - }, - { - "name": "txCount" - }, - { - "name": "collectedFeesToken0" - }, - { - "name": "collectedFeesToken1" - }, - { - "name": "collectedFeesUSD" - }, - { - "name": "totalValueLockedToken0" - }, - { - "name": "totalValueLockedToken1" - }, - { - "name": "totalValueLockedETH" - }, - { - "name": "totalValueLockedUSD" - }, - { - "name": "totalValueLockedUSDUntracked" - }, - { - "name": "totalValueLockedETHUntracked" - }, - { - "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": "protocolFeesUSD" - }, - { - "name": "txCount" - }, - { - "name": "open" - }, - { - "name": "high" - }, - { - "name": "low" - }, - { - "name": "close" - } - ] - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 107, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json b/tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json deleted file mode 100644 index 708acdd..0000000 --- a/tests/integration/__evidence__/m2/exchange-v3-base/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:58.535Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": "18" - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": "6" - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": "6" - } - ], - "_meta": { - "block": { - "number": 49095544, - "timestamp": 1784980435, - "hash": "0xb75803c6d89a562738fe558853fd17f7aba5aed2ab714b9a905999c38066f063" - }, - "hasIndexingErrors": false, - "deployment": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 110, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json deleted file mode 100644 index effa06e..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/01-meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:39.262Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", - "query_id": "m2-meta-v1", - "request": { - "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", - "variables": {} - }, - "response": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 70, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/02-tier-a-marker.json deleted file mode 100644 index 66cf440..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/02-tier-a-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:39.565Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", - "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": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 52, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json deleted file mode 100644 index b0c641d..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-exchange-v3-base/03-tier-b-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:39.875Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "8F3ur1X2g63Lkef4JhCcfWUq8oQrghGNJFBq1vkyKDNv", - "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": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 59, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/01-meta.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/01-meta.json deleted file mode 100644 index 9aa6151..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-v3-base/01-meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:34.817Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", - "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": 49095502, - "timestamp": 1784980351, - "hash": "0x2576866855bb05b3cd88b978f7e9b7b0c8ac566340e5b3a421341f7b4cfbfb8f" - }, - "hasIndexingErrors": false, - "deployment": "QmY5Wybn5P1BQ5gnV1QuNiszZNk2fimZatKM1XtvV49fBN" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 257, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json deleted file mode 100644 index 14e3650..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-v3-base/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:35.287Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", - "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": 218, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json deleted file mode 100644 index a392a63..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-v3-base/03-tier-b-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:35.751Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", - "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": { - "errors": [ - { - "locations": [ - { - "column": 3, - "line": 7 - } - ], - "message": "Type `Query` has no field `factories`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 213, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json deleted file mode 100644 index 9fb83d5..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-v3-base/04-tokens.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:46.515Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "errors": [ - { - "locations": [ - { - "column": 3, - "line": 7 - } - ], - "message": "Type `Query` has no field `tokens`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 248, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json b/tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json deleted file mode 100644 index 313861d..0000000 --- a/tests/integration/__evidence__/m2/pancakeswap-v3-base/04a-token-introspection.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:47.043Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "84ADrft27B8Jo46mdknbJ3PHoJ5wK5YeNBrYTD19WnaH", - "query_id": "m2-token-introspection-v1", - "request": { - "query": "query M2TokenIntrospection {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n __type(name: \"Token\") { fields { name type { kind name ofType { kind name } } } }\n}", - "variables": {} - }, - "response": { - "data": { - "_meta": { - "block": { - "number": 49095538, - "timestamp": 1784980423, - "hash": "0xb3f6d754483132d9f135ccb1dabc744fadf01b3e8fac6b969432bdefd6ceb413" - }, - "hasIndexingErrors": false, - "deployment": "QmY5Wybn5P1BQ5gnV1QuNiszZNk2fimZatKM1XtvV49fBN" - }, - "__type": null - } - }, - "transport": { - "http_status": 200, - "latency_ms": 276, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json deleted file mode 100644 index 164cdf2..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/01-meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:37.393Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", - "query_id": "m2-meta-v1", - "request": { - "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", - "variables": {} - }, - "response": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 63, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json deleted file mode 100644 index 3fdad5b..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/02-tier-a-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:37.699Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", - "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": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 54, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json deleted file mode 100644 index 6bdde39..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-a/03-tier-b-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:38.008Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "8WG1adHbCgThdQHRg4FayNbxunUM1AhPJVTS5rXtEFoa", - "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": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 57, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json deleted file mode 100644 index 8f979c5..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/01-meta.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:38.314Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", - "query_id": "m2-meta-v1", - "request": { - "query": "query M2Meta {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n}", - "variables": {} - }, - "response": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 56, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json deleted file mode 100644 index 7a79936..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/02-tier-a-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:38.634Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", - "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": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 69, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json deleted file mode 100644 index c98c72b..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base-community-b/03-tier-b-marker.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:38.941Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "9KSiDKQ3KnwyxU5yA1KkGbqF4uREHVfmUcrLyjS4itSY", - "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": { - "errors": [ - { - "message": "subgraph not found: no allocations" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 57, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json deleted file mode 100644 index d21fdda..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base/01-meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:27.771Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", - "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": 49095499, - "timestamp": 1784980345, - "hash": "0x4cc5dff2859b7e4f5afd9d5eb45275a5192fef1f6d2cb8270ec12daf337a437e" - }, - "hasIndexingErrors": false, - "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 170, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json deleted file mode 100644 index 7eac9ba..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:28.220Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", - "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": 197, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json deleted file mode 100644 index 8b02979..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base/03-tier-b-marker.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:28.644Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", - "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": "0x71524b4f93c58fcbf659783284e38825f0622859" - } - ], - "_meta": { - "block": { - "number": 49095500, - "timestamp": 1784980347, - "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" - }, - "hasIndexingErrors": false, - "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 173, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json deleted file mode 100644 index d772af2..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base/03a-tier-b-lineage.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:29.098Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", - "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": 49095500, - "timestamp": 1784980347, - "hash": "0x398f0f259aadc86360441c182f808bfadeae8edc17b68995489ee6efa3f66f9f" - }, - "hasIndexingErrors": false, - "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" - }, - "factoryType": { - "fields": [ - { - "name": "id" - }, - { - "name": "type" - }, - { - "name": "volumeUSD" - }, - { - "name": "volumeNative" - }, - { - "name": "liquidityUSD" - }, - { - "name": "liquidityNative" - }, - { - "name": "feesUSD" - }, - { - "name": "feesNative" - }, - { - "name": "pairCount" - }, - { - "name": "transactionCount" - }, - { - "name": "tokenCount" - }, - { - "name": "userCount" - } - ] - }, - "poolType": null, - "dayType": null - } - }, - "transport": { - "http_status": 200, - "latency_ms": 203, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json b/tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json deleted file mode 100644 index 765bf66..0000000 --- a/tests/integration/__evidence__/m2/sushiswap-v3-base/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:44.765Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "H6SjXCnZxJhaVHw4VDuXqtzWZ2JEBDvhwA3qysnUEjSV", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": "18" - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": "6" - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": "6" - } - ], - "_meta": { - "block": { - "number": 49095534, - "timestamp": 1784980415, - "hash": "0xc0e39d0152d7feb62e60302d450d1d8d19c4a0242ca78d1c928dcf13747a34f4" - }, - "hasIndexingErrors": false, - "deployment": "QmaMCPHr8m8o2udNrTYjottDoMDM7HBgaE1z6FGu9rjKtK" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 7678, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json deleted file mode 100644 index 8e60243..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/01-meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:26.630Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", - "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": 49095498, - "timestamp": 1784980343, - "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" - }, - "hasIndexingErrors": false, - "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 96, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json deleted file mode 100644 index 0f439ab..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/02-tier-a-marker.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:26.977Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", - "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": { - "data": { - "dexAmmProtocols": [ - { - "id": "0x33128a8fc17869897dce68ed026d694621f6fdfd", - "name": "Uniswap V3", - "schemaVersion": "4.0.1", - "methodologyVersion": "1.0.0", - "network": "BASE" - } - ], - "_meta": { - "block": { - "number": 49095498, - "timestamp": 1784980343, - "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" - }, - "hasIndexingErrors": false, - "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 96, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json deleted file mode 100644 index 8df7519..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/03-tier-b-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:27.351Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", - "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": { - "errors": [ - { - "locations": [ - { - "column": 3, - "line": 7 - } - ], - "message": "Type `Query` has no field `factories`" - } - ] - }, - "transport": { - "http_status": 200, - "latency_ms": 122, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json b/tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json deleted file mode 100644 index 10fb3bd..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-messari/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:36.837Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": 18 - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": 6 - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": 6 - } - ], - "_meta": { - "block": { - "number": 49095531, - "timestamp": 1784980409, - "hash": "0x7377a5de055bded3cb4004d411a8b2684fd1950d99186d44ca4bbb53ae850b1a" - }, - "hasIndexingErrors": false, - "deployment": "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 514, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json deleted file mode 100644 index 582c9ab..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/01-meta.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:24.921Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", - "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": 49095498, - "timestamp": 1784980343, - "hash": "0x6ed6d0c1603c4dd64f6c356429651e57d3111f1be6a072197079493df9142bdb" - }, - "hasIndexingErrors": false, - "deployment": "Qmc5N5DW7a99WEpm9aGXSSHotNCSSsTbULSB8YgPreJyE3" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 207, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json deleted file mode 100644 index 1153be0..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/02-tier-a-marker.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:25.346Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", - "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": 175, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json deleted file mode 100644 index cb7ed3f..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03-tier-b-marker.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:25.777Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", - "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": "Qmc5N5DW7a99WEpm9aGXSSHotNCSSsTbULSB8YgPreJyE3" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 181, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json deleted file mode 100644 index 2ad5884..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/03a-tier-b-lineage.json +++ /dev/null @@ -1,235 +0,0 @@ -{ - "captured_at": "2026-07-25T11:52:26.283Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", - "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": 49095499, - "timestamp": 1784980345, - "hash": "0x4cc5dff2859b7e4f5afd9d5eb45275a5192fef1f6d2cb8270ec12daf337a437e" - }, - "hasIndexingErrors": false, - "deployment": "Qmc5N5DW7a99WEpm9aGXSSHotNCSSsTbULSB8YgPreJyE3" - }, - "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": "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": "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": 255, - "error": null - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json deleted file mode 100644 index c25b311..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-native-recent/04-tokens.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:36.071Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "VmwKeqb22QsuM4AcCp8qTFg2dW7EXZNg16kGgXu6bBu", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": null, - "transport": { - "http_status": null, - "latency_ms": 15012, - "error": { - "kind": "timeout", - "message": "Graph gateway request exceeded the 15 second timeout." - } - } -} diff --git a/tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json b/tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json deleted file mode 100644 index 61b5e80..0000000 --- a/tests/integration/__evidence__/m2/uniswap-v3-base-native/04-tokens.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "captured_at": "2026-07-25T11:53:20.807Z", - "gateway_host": "gateway.thegraph.com", - "subgraph_id": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz", - "query_id": "m2-tokens-v2", - "request": { - "query": "query M2Tokens($ids: [Bytes!]!) {\n _meta {\n block { number timestamp hash }\n hasIndexingErrors\n deployment\n }\n tokens(where: { id_in: $ids }) { id symbol name decimals }\n}", - "variables": { - "ids": [ - "0x4200000000000000000000000000000000000006", - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca" - ] - } - }, - "response": { - "data": { - "tokens": [ - { - "id": "0x4200000000000000000000000000000000000006", - "symbol": "WETH", - "name": "Wrapped Ether", - "decimals": "18" - }, - { - "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - "symbol": "USDC", - "name": "USD Coin", - "decimals": "6" - }, - { - "id": "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - "symbol": "USDbC", - "name": "USD Base Coin", - "decimals": "6" - } - ], - "_meta": { - "block": { - "number": 49095524, - "timestamp": 1784980395, - "hash": "0x0bd5ea6d435e2c5d20d709ab96c9a67fd6b57b556ffd6500b18421a40d61b338" - }, - "hasIndexingErrors": false, - "deployment": "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR" - } - } - }, - "transport": { - "http_status": 200, - "latency_ms": 435, - "error": null - } -} From 9d9a1ce2f51d2c618dfd033a5f4adeee308a7aeb Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:14:30 +0100 Subject: [PATCH 23/96] Define M0 core policy (#12) * define M0 core policy * document M0 core policy * clarify M0 policy ownership --- .env.example | 6 +-- PLAN.md | 67 +++++++++++++++-------- src/config/defaults.ts | 7 +++ src/config/env.ts | 8 ++- src/config/index.ts | 7 ++- src/config/positive-integer.ts | 30 ++++++----- src/policy/index.ts | 9 ++++ src/policy/m0.ts | 65 ++++++++++++++++++++++ tests/unit/gateway-config.test.ts | 32 +++++++++++ tests/unit/m0-policy.test.ts | 90 +++++++++++++++++++++++++++++++ 10 files changed, 282 insertions(+), 39 deletions(-) create mode 100644 src/policy/index.ts create mode 100644 src/policy/m0.ts create mode 100644 tests/unit/m0-policy.test.ts diff --git a/.env.example b/.env.example index 060a0f9..9ed4f18 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,11 @@ NUTHATCH_BASE_URL= # Production currently disables the admin UI with --no-admin. NUTHATCH_ADMIN_TOKEN= -# Gateway fixed-window rate limit: max requests per key (default: 30). +# 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). +# Gateway fixed-window duration in milliseconds (default: 60000, maximum: 3600000). DEEPTRACE_RATE_LIMIT_WINDOW_MS=60000 -# Per-source adapter timeout in milliseconds (default: 5000). +# Per-source adapter timeout in milliseconds (default: 5000, maximum: 8000). DEEPTRACE_SOURCE_TIMEOUT_MS=5000 diff --git a/PLAN.md b/PLAN.md index 2cebefd..e35f9da 100644 --- a/PLAN.md +++ b/PLAN.md @@ -45,17 +45,19 @@ It must: | Item | MVP-0 value | | :--- | :--- | -| Chain | `TBD` — exactly one | +| Chain | Base (`8453`) | | 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 | +| Ranking metric | `volume_usd` by default; TVL and fees are selectable | +| Top-N | Default and maximum `3` | +| USD price source | Source-reported USD values only; no repricing in MVP-0 | | Public tool implemented | `compare_pools` | -Implementation starts only after every `TBD` in this table is resolved. +Live integration starts only after every remaining `TBD` in this table is resolved. +Core policy values are executable constants in `src/policy/m0.ts`. ### MVP-0 Definition of Done @@ -247,11 +249,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", @@ -408,32 +410,46 @@ 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. +* 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 to normalized USD notional. ## Reliability and Operations -* Source queries run in parallel with bounded timeouts. +* 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. -* Responses use `complete`, `partial` or `failed` status. +* `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 @@ -473,7 +489,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", @@ -541,10 +557,19 @@ The reasoning implementation lives in: 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. +It uses one bounded reasoning operation with at most two ordered provider attempts 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`. +MVP-0 accepts an ordered primary provider and one optional fallback through injected +provider adapters. Each attempt has a 2-second deadline within a 5-second total +reasoning budget. Reasoning input is limited to 32 KiB and validated output to 8 KiB, +with at most five highlights and five caveats. Provider failure never changes the +structured response status. Vendor and model identifiers are deployment +configuration, not public request fields. + ## Suggested Project Structure ```text @@ -615,7 +640,7 @@ Internal modules may contain many functions, but only implemented high-level han ### 6. Integrate AI Reasoning * implement the reasoning module against its typed output schema; -* add one bounded model call and graceful provider fallback; +* add one bounded reasoning operation with graceful provider fallback; * validate every reasoning source reference against provenance; * test factual consistency, latency and response size. diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 8bf1b76..174b7e9 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -5,6 +5,13 @@ export const GATEWAY_DEFAULTS = { 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", diff --git a/src/config/env.ts b/src/config/env.ts index 725c03b..10eed38 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,5 +1,5 @@ import { ConfigurationError } from "../errors/application-error.js"; -import { GATEWAY_DEFAULTS, GATEWAY_ENV_VARS } from "./defaults.js"; +import { GATEWAY_DEFAULTS, GATEWAY_ENV_VARS, GATEWAY_MAXIMUMS } from "./defaults.js"; import { parseBoundedPositiveInteger } from "./positive-integer.js"; export interface GatewayConfig { @@ -14,6 +14,7 @@ function readOptionalBoundedPositiveInteger( env: EnvSource, variableName: string, fallback: number, + maximum: number, invalidNames: string[], ): number { const raw = env[variableName]; @@ -22,7 +23,7 @@ function readOptionalBoundedPositiveInteger( } try { - return parseBoundedPositiveInteger(raw, variableName); + return parseBoundedPositiveInteger(raw, variableName, maximum); } catch (error) { if (error instanceof ConfigurationError) { invalidNames.push(variableName); @@ -44,18 +45,21 @@ export function loadGatewayConfig(env: EnvSource = process.env): GatewayConfig { 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, ); diff --git a/src/config/index.ts b/src/config/index.ts index 6a3c848..f6ba4c1 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,9 @@ -export { GATEWAY_DEFAULTS, GATEWAY_ENV_VARS, MAX_BOUNDED_POSITIVE_INTEGER } from "./defaults.js"; +export { + GATEWAY_DEFAULTS, + GATEWAY_ENV_VARS, + GATEWAY_MAXIMUMS, + MAX_BOUNDED_POSITIVE_INTEGER, +} from "./defaults.js"; export { loadGatewayConfig, type GatewayConfig } from "./env.js"; export { assertBoundedPositiveInteger, diff --git a/src/config/positive-integer.ts b/src/config/positive-integer.ts index 42d9591..12495d7 100644 --- a/src/config/positive-integer.ts +++ b/src/config/positive-integer.ts @@ -2,24 +2,26 @@ 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, MAX_BOUNDED_POSITIVE_INTEGER]. + * 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): boolean { - return ( - Number.isFinite(value) && - Number.isInteger(value) && - value >= 1 && - value <= MAX_BOUNDED_POSITIVE_INTEGER - ); +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): number { - if (!isBoundedPositiveInteger(value)) { +export function assertBoundedPositiveInteger( + value: number, + variableName: string, + maximum = MAX_BOUNDED_POSITIVE_INTEGER, +): number { + if (!isBoundedPositiveInteger(value, maximum)) { throw new ConfigurationError([variableName]); } return value; @@ -29,7 +31,11 @@ export function assertBoundedPositiveInteger(value: number, variableName: string * 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): number { +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]); @@ -41,5 +47,5 @@ export function parseBoundedPositiveInteger(raw: string, variableName: string): } const value = Number(trimmed); - return assertBoundedPositiveInteger(value, variableName); + return assertBoundedPositiveInteger(value, variableName, maximum); } diff --git a/src/policy/index.ts b/src/policy/index.ts new file mode 100644 index 0000000..1a587f5 --- /dev/null +++ b/src/policy/index.ts @@ -0,0 +1,9 @@ +export { + M0_CORE_POLICY, + M0_RANKING_METRICS, + M0_RANKING_TIE_BREAK, + M0_TIME_WINDOWS, + M0_WARNING_ORDER, + 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..29b6d38 --- /dev/null +++ b/src/policy/m0.ts @@ -0,0 +1,65 @@ +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_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, deployment, and Nuthatch selections belong to M0-01B and are + * intentionally absent. + */ +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: { + expectedGraphResults: 3, + requiresNuthatchForComplete: true, + minimumGraphResultsForPartial: 1, + }, + reasoning: { + maximumProviderAttempts: 2, + providerAttemptTimeoutMs: 2_000, + totalTimeoutMs: 5_000, + maximumInputBytes: 32_768, + maximumOutputBytes: 8_192, + maximumHighlights: 5, + maximumCaveats: 5, + }, +} as const; diff --git a/tests/unit/gateway-config.test.ts b/tests/unit/gateway-config.test.ts index b36b219..44ac4c4 100644 --- a/tests/unit/gateway-config.test.ts +++ b/tests/unit/gateway-config.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { GATEWAY_DEFAULTS, GATEWAY_ENV_VARS, + GATEWAY_MAXIMUMS, loadGatewayConfig, parseBoundedPositiveInteger, } from "../../src/config/index.js"; @@ -31,6 +32,20 @@ describe("loadGatewayConfig", () => { }); }); + 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({ @@ -91,6 +106,18 @@ describe("loadGatewayConfig", () => { 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", () => { @@ -110,4 +137,9 @@ describe("parseBoundedPositiveInteger", () => { 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/m0-policy.test.ts b/tests/unit/m0-policy.test.ts new file mode 100644 index 0000000..cbe44b8 --- /dev/null +++ b/tests/unit/m0-policy.test.ts @@ -0,0 +1,90 @@ +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: 3, + 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 and reasoning limits", () => { + expect(M0_CORE_POLICY.freshness).toEqual({ + qualityStaleAfterSeconds: 300, + enforcementLayer: "core_quality", + preservesAdapterStatus: true, + }); + expect(M0_CORE_POLICY.reasoning).toEqual({ + maximumProviderAttempts: 2, + providerAttemptTimeoutMs: 2_000, + totalTimeoutMs: 5_000, + maximumInputBytes: 32_768, + maximumOutputBytes: 8_192, + maximumHighlights: 5, + maximumCaveats: 5, + }); + }); + + it("fits source and provider attempts inside their total deadlines", () => { + expect( + M0_CORE_POLICY.reasoning.maximumProviderAttempts * + M0_CORE_POLICY.reasoning.providerAttemptTimeoutMs, + ).toBeLessThanOrEqual(M0_CORE_POLICY.reasoning.totalTimeoutMs); + expect( + M0_CORE_POLICY.gateway.maximumSourceTimeoutMs + M0_CORE_POLICY.reasoning.totalTimeoutMs, + ).toBeLessThanOrEqual(M0_CORE_POLICY.gateway.endToEndTimeoutMs); + }); +}); From 08e43775d9c630fa45ba36407a848e6ab0c6f3fa Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:18:44 +0100 Subject: [PATCH 24/96] Add compare_pools response contracts (#15) * add compare pools response schemas * test compare pools response contracts * tighten response contract invariants * align source availability invariants * validate failed source coverage --- src/schemas/compare-pools.ts | 438 ++++++++++++++++++++++++ src/schemas/index.ts | 22 ++ tests/fixtures/compare-pools.ts | 239 +++++++++++++ tests/unit/compare-pools-schema.test.ts | 388 +++++++++++++++++++++ 4 files changed, 1087 insertions(+) create mode 100644 src/schemas/compare-pools.ts create mode 100644 src/schemas/index.ts create mode 100644 tests/fixtures/compare-pools.ts create mode 100644 tests/unit/compare-pools-schema.test.ts diff --git a/src/schemas/compare-pools.ts b/src/schemas/compare-pools.ts new file mode 100644 index 0000000..73b1ded --- /dev/null +++ b/src/schemas/compare-pools.ts @@ -0,0 +1,438 @@ +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 aiReasoningSchema = z + .object({ + status: z.enum(["complete", "unavailable"]), + summary: z.string(), + highlights: z.array(nonEmptyStringSchema).max(M0_CORE_POLICY.reasoning.maximumHighlights), + caveats: z.array(nonEmptyStringSchema).max(M0_CORE_POLICY.reasoning.maximumCaveats), + source_ids: z.array(nonEmptyStringSchema).refine(hasUniqueValues, "Expected unique source IDs"), + }) + .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(), + ai_reasoning: aiReasoningSchema, +}; + +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), + ...response.ai_reasoning.source_ids, + ]; + 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 three 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]), + ); + + if (response.coverage.successful_deployments !== response.data.pools.length) { + context.addIssue({ + code: "custom", + message: "Successful deployment count must equal the number of pool records", + 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 AiReasoning = 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..12a33e8 --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1,22 @@ +export { + aiReasoningSchema, + canonicalPairSchema, + canonicalTokenSchema, + comparePoolsResponseSchema, + coverageSchema, + nuthatchFreshnessFactSchema, + poolComparisonDataSchema, + poolComparisonRecordSchema, + resultFreshnessSchema, + resultProvenanceSchema, + type AiReasoning, + type CanonicalPair, + type CanonicalToken, + type ComparePoolsResponse, + type Coverage, + type NuthatchFreshnessFact, + type PoolComparisonData, + type PoolComparisonRecord, + type ResultFreshness, + type ResultProvenance, +} from "./compare-pools.js"; diff --git a/tests/fixtures/compare-pools.ts b/tests/fixtures/compare-pools.ts new file mode 100644 index 0000000..5f9a003 --- /dev/null +++ b/tests/fixtures/compare-pools.ts @@ -0,0 +1,239 @@ +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", "fixture-dex-c"] 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"), + graphProvenance(graphSourceIds[2], "protocol-c"), +] 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", + }), + poolRecord(3, graphSourceIds[2], "protocol-c", "0xcccccccccccccccccccccccccccccccccccccccc", { + tvl_usd: "750000", + volume_usd: "125000.5", + fees_usd: "375.1", + }), +] 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: 3, + successful_deployments: 3, + nuthatch_available: true, + }, + freshness: [ + observedFreshness(graphSourceIds[0], 12_345_678, 12), + observedFreshness(graphSourceIds[1], 12_345_670, 28), + observedFreshness(graphSourceIds[2], 12_345_660, 48), + { + ...observedFreshness(nuthatchSourceId, 12_345_678, 8), + indexed_block_hash: `0x${"1".repeat(64)}`, + }, + ], + provenance: allProvenance, + warnings: [], + pagination: null, + ai_reasoning: { + status: "complete", + summary: "Protocol A has the highest measured 24-hour volume.", + highlights: ["Protocol A ranks first by source-reported volume."], + caveats: [], + source_ids: [graphSourceIds[0]], + }, +} 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: 3, + successful_deployments: 2, + nuthatch_available: false, + }, + freshness: [ + observedFreshness(graphSourceIds[0], 12_345_678, 12), + observedFreshness(graphSourceIds[1], 12_345_000, 600, "stale"), + { + source_id: graphSourceIds[2], + status: "unavailable", + }, + { + source_id: nuthatchSourceId, + status: "unavailable", + }, + ], + provenance: allProvenance, + warnings: [ + "fixture-dex-c was unavailable", + "fixture-nuthatch was unavailable", + "fixture-dex-b exceeded the freshness threshold", + ], + pagination: null, + ai_reasoning: { + status: "unavailable", + summary: "", + highlights: [], + caveats: [], + source_ids: [], + }, +} as const satisfies ComparePoolsResponse; + +export const failedComparePoolsFixture = { + status: "failed", + data: null, + coverage: { + requested_deployments: 3, + 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, + ai_reasoning: { + status: "unavailable", + summary: "", + highlights: [], + caveats: [], + source_ids: [], + }, +} as const satisfies ComparePoolsResponse; + +export const comparePoolsFixtures = [ + completeComparePoolsFixture, + partialComparePoolsFixture, + failedComparePoolsFixture, +] as const; diff --git a/tests/unit/compare-pools-schema.test.ts b/tests/unit/compare-pools-schema.test.ts new file mode 100644 index 0000000..796ac86 --- /dev/null +++ b/tests/unit/compare-pools-schema.test.ts @@ -0,0 +1,388 @@ +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 === 3 ? { 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 === 3 ? { ...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 === 3 ? { ...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, + ai_reasoning: { + ...completeComparePoolsFixture.ai_reasoning, + source_ids: ["invented-source"], + }, + }).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-dex-c"], + }, + ], + }, + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...completeComparePoolsFixture, + status: "partial", + warnings: ["fixture-nuthatch was unavailable"], + freshness: completeComparePoolsFixture.freshness.map((entry, index) => + index === 3 ? { source_id: entry.source_id, status: "unavailable" } : entry, + ), + }).success, + ).toBe(false); + expect( + comparePoolsResponseSchema.safeParse({ + ...partialComparePoolsFixture, + freshness: partialComparePoolsFixture.freshness.map((entry, index) => + index === 3 + ? { + 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 === 3 + ? { + 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); + }); +}); From 9fcc5976561be42ac2bc580e47e16c5516ac9b78 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:59:00 +0100 Subject: [PATCH 25/96] add canonical pool normalization primitives - normalize chain-aware token, pair, and pool identities - preserve source-reported decimal strings, zero, and null values - collapse duplicate pools deterministically with focused tests --- src/normalization/address.ts | 14 ++ src/normalization/convert.ts | 83 +++++++ src/normalization/dedupe.ts | 120 ++++++++++ src/normalization/error.ts | 6 + src/normalization/identities.ts | 31 +++ src/normalization/index.ts | 8 + src/normalization/pair.ts | 48 ++++ src/normalization/types.ts | 32 +++ tests/unit/normalization.test.ts | 396 +++++++++++++++++++++++++++++++ 9 files changed, 738 insertions(+) create mode 100644 src/normalization/address.ts create mode 100644 src/normalization/convert.ts create mode 100644 src/normalization/dedupe.ts create mode 100644 src/normalization/error.ts create mode 100644 src/normalization/identities.ts create mode 100644 src/normalization/index.ts create mode 100644 src/normalization/pair.ts create mode 100644 src/normalization/types.ts create mode 100644 tests/unit/normalization.test.ts 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..45a41c1 --- /dev/null +++ b/src/normalization/identities.ts @@ -0,0 +1,31 @@ +import { normalizeAddress } from "./address.js"; +import { NormalizationError } from "./error.js"; + +/** + * 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}`; +} diff --git a/src/normalization/index.ts b/src/normalization/index.ts new file mode 100644 index 0000000..4ae1f47 --- /dev/null +++ b/src/normalization/index.ts @@ -0,0 +1,8 @@ +export { normalizeAddress } from "./address.js"; +export { convertPoolSourceResult, toPoolComparisonRecord } from "./convert.js"; +export { deduplicateCanonicalPools } from "./dedupe.js"; +export { NormalizationError } from "./error.js"; +export { pairIdentity, poolIdentity, tokenIdentity } from "./identities.js"; +export { normalizeCanonicalPair, normalizeCanonicalToken } from "./pair.js"; +export { DUPLICATE_POOL_COLLAPSE_POLICY, type CanonicalPoolCandidate } from "./types.js"; +export type { SourceTokenInput } from "./pair.js"; 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/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/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"]); + }); +}); From 55e129522b7ed5b8860a9166302648fa860e39d4 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 17:32:41 +0200 Subject: [PATCH 26/96] M2: close source scope to two Tier-B Graph deployments (#17) * feat(m2): finalize registry records for the two validated sources Every identity value is traced to committed M2 evidence, not to the summary prose: - deployment IDs read from each source's 01-meta.json `_meta.deployment`; - subgraph IDs from manifest.json `validated_selections`; - supported_entities is the Tier-B set from the M2 plan; - schema_version/methodology_version stay null because Tier-B deployments publish neither. source_id reuses the manifest slug so each record joins back to its evidence directory by name. Verified through the M3.1 loader: both records resolve via getSourceById with the expected protocol tags. * docs(m2): amend the source scope to two deployments Records the owner decision to ship MVP-0 with two same-tier Graph sources rather than three. The original blocked status is retained verbatim below the amendment so the reduction reads as a deliberate scope change, not a gate that was quietly satisfied. Notes that M3's exit criterion becomes two SourceResult values, and that a third source stays a configuration-only addition. PLAN.md and the M2 tracker boxes are deliberately untouched: those are M2.9 owner items behind the knowledge-check gate. * test(m2.8): replace source fixtures with live M2 values * docs(m2.9): lock MVP-0 scope to two Tier-B Graph sources * chore: add opencode executor reviewer agents * chore: register grok code reviewer agents * chore(m2.8): format source fixtures * chore: remove duplicate reviewer agent * chore: switch opencode agents to glm-5.2 * chore: switch opencode config to glm-5.2 --------- Co-authored-by: ikodo0 --- .opencode/agent/code-executor.md | 58 +++++++++ .opencode/agent/code-reviewer.md | 54 ++++++++ .opencode/agent/git-commit-reviewer.md | 60 +++++++++ .opencode/opencode.json | 31 +++++ PLAN.md | 8 +- docs/source-scope.md | 36 ++++-- src/registry/records.json | 36 ++++++ tests/fixtures/sources/index.ts | 164 +++++++++++++------------ 8 files changed, 356 insertions(+), 91 deletions(-) create mode 100644 .opencode/agent/code-executor.md create mode 100644 .opencode/agent/code-reviewer.md create mode 100644 .opencode/agent/git-commit-reviewer.md create mode 100644 .opencode/opencode.json create mode 100644 src/registry/records.json diff --git a/.opencode/agent/code-executor.md b/.opencode/agent/code-executor.md new file mode 100644 index 0000000..757510d --- /dev/null +++ b/.opencode/agent/code-executor.md @@ -0,0 +1,58 @@ +--- +description: Code executor for DeepTrace milestone implementation. Implements one sub-task with green tests and git-safe commits. +mode: subagent +model: openrouter/z-ai/glm-5.2 +color: success +permission: + edit: allow + bash: allow + external_directory: allow + webfetch: deny +--- + +You are the DeepTrace **code executor**. Model: GLM-5.2 via OpenRouter. + +## Role + +Implement exactly one assigned sub-task. Leave a clean worktree with green checks. + +## Git conventions (binding) + +- Stay on the branch named in the brief; never `git switch` to another branch +- Commit when one coherent behavior is green +- Subject: `feat(mX.Y): …` / `test(mX.Y): …` / `docs(mX): …` / `fix(mX.Y): …` +- Subject must not contain the word "and" +- Do not push, merge, rebase onto unrelated branches, or open PRs +- Do not commit planning docs or `.env` + +## Implementation rules + +1. Read only files the brief names plus immediate imports +2. Never invent pool addresses, deployment IDs, or tier choices — use `docs/source-scope.md` and `src/registry/records.json` +3. MVP-0 Graph sources are **two** (owner-amended), same Tier B, one query template +4. Decimal strings only for money; no `Number()`/`parseFloat` on financial values +5. Frozen contracts stay frozen unless the brief says both workstreams agreed +6. Never read or echo `.env` secret values + +## Before each commit + +```sh +npm test -- +npm run typecheck +npm run lint +npm run format:check +``` + +## Handoff (required) + +```text +Branch: +Worktree: +HEAD: +Commits: +Files changed: +Commands run: +Assumptions: +Gaps: +Working tree: clean +``` diff --git a/.opencode/agent/code-reviewer.md b/.opencode/agent/code-reviewer.md new file mode 100644 index 0000000..aa5029c --- /dev/null +++ b/.opencode/agent/code-reviewer.md @@ -0,0 +1,54 @@ +--- +description: Code reviewer for DeepTrace diffs against contracts and milestone plans. Read-only. Use before merge gates. +mode: subagent +model: openrouter/z-ai/glm-5.2 +color: warning +permission: + edit: deny + bash: allow + external_directory: allow + webfetch: deny +--- + +You are the DeepTrace **code reviewer**. Model: GLM-5.2 via OpenRouter. Read-only — never edit files. + +## When invoked + +Review the current branch or the paths named in the prompt against: + +- frozen contracts: `docs/CONTRACT.md`, `src/schemas/source-adapter.ts` +- milestone plan sections named in the prompt +- git workflow: one purpose per commit, no secrets, no plan docs in the tree + +## Method + +1. `git status -sb` and `git branch --show-current` +2. `git log --oneline ..HEAD` (default base: `develop` or the milestone branch) +3. `git diff ...HEAD` +4. Read changed source/tests only +5. Run focused tests if the prompt asks (`npm test -- `) — do not "fix" failures + +## Hard rules + +- Financial values must be decimal strings; reject float money paths +- Adapter boundaries must not throw past the boundary +- No credentials, keyed URLs, or `.env` contents in code/tests/evidence +- Do not invent missing M2 pool/deployment IDs +- Frozen contracts are not changed without both-workstream agreement + +## Output (exact shape) + +```text +Verdict: APPROVE | REQUEST_CHANGES | BLOCKED +Branch: +Base: +Summary: <3-6 sentences> +Must-fix: +- ... +Should-fix: +- ... +Contract risks: +- ... +Test gaps: +- ... +``` diff --git a/.opencode/agent/git-commit-reviewer.md b/.opencode/agent/git-commit-reviewer.md new file mode 100644 index 0000000..61731f5 --- /dev/null +++ b/.opencode/agent/git-commit-reviewer.md @@ -0,0 +1,60 @@ +--- +description: Git commit reviewer for DeepTrace merge gates. Checks history, secrets, branch topology. Read-only. +mode: subagent +model: openrouter/z-ai/glm-5.2 +color: info +permission: + edit: deny + bash: allow + external_directory: allow + webfetch: deny +--- + +You are the DeepTrace **git commit reviewer**. Model: GLM-5.2 via OpenRouter. Read-only — never edit, commit, merge, push, or rewrite history. + +## Binding git rules + +From the data-pipe workflow: + +1. One branch per milestone off `develop` (`feat/mN-...`) +2. Sub-branches off the milestone branch, never straight to `develop` +3. At least one commit per sub-task; subject must not need the word "and" +4. Never bundle two milestones in one commit +5. Planning docs (`DATA_PIPE_PLAN.md`, `docs/M*_PLAN.md`) must not appear in commits +6. No secrets, `.env`, or credential-bearing URLs in any commit blob +7. Typecheck/lint should be green per commit intent +8. PR base is `develop`, never `main` + +## Method + +```sh +git status --short +git branch --show-current +git log --oneline ..HEAD +git log --format='%h %s' ..HEAD +git diff --stat ...HEAD +git diff ...HEAD +# secret-ish scan of the diff only (never print .env values): +git diff ...HEAD | rg -n 'GRAPH_API_KEY|Bearer [A-Za-z0-9]|api[_-]?key=|Authorization:' || true +``` + +Default base: `origin/develop` if present, else `develop`. + +## Output (exact shape) + +```text +Verdict: READY_TO_MERGE | NEEDS_HISTORY_FIX | BLOCKED +Branch: +Base: +Commit count: +Commits: +- — ok | problem: +Diff risk summary: +Secret scan: clean | FAIL +Branch topology: ok | problem: +User knowledge-check questions: +1. What outcome does this branch deliver? +2. What is the main failure behavior if a source is bad? +3. What evidence proves it? +Merge recommendation: +``` diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..a8363ee --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "openrouter/z-ai/glm-5.2", + "small_model": "openrouter/z-ai/glm-5.2", + "default_agent": "build", + "agent": { + "code-executor": { + "model": "openrouter/z-ai/glm-5.2", + "mode": "subagent", + "description": "Implements one DeepTrace milestone sub-task with green tests and safe commits" + }, + "code-reviewer": { + "model": "openrouter/z-ai/glm-5.2", + "mode": "subagent", + "description": "Read-only code review against contracts and milestone acceptance" + }, + "git-commit-reviewer": { + "model": "openrouter/z-ai/glm-5.2", + "mode": "subagent", + "description": "Read-only git history and merge-gate review" + } + }, + "permission": { + "external_directory": { + "/home/arch/repos/deeptrace-m3.1/**": "allow", + "/home/arch/repos/deeptrace-m3.6/**": "allow", + "/home/arch/repos/deeptrace-m4/**": "allow", + "*": "ask" + } + } +} diff --git a/PLAN.md b/PLAN.md index e35f9da..56d69da 100644 --- a/PLAN.md +++ b/PLAN.md @@ -46,9 +46,9 @@ It must: | Item | MVP-0 value | | :--- | :--- | | Chain | Base (`8453`) | -| 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 | +| Token pair | WETH `0x4200000000000000000000000000000000000006` / native USDC `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` | +| Standardized DEX deployments | Two Uniswap-V3-lineage native deployments (owner-amended from three): Uniswap V3 `QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR`, PancakeSwap V3 `QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g` — see `docs/source-scope.md` | +| Nuthatch contracts/views | One of the two confirmed pools; final pick in M4.1 (prefer Uniswap V3 WETH/USDC 0.3%) | | Time windows | `24h` and `7d` | | Initial metrics | TVL, volume and fees | | Ranking metric | `volume_usd` by default; TVL and fees are selectable | @@ -56,7 +56,7 @@ It must: | USD price source | Source-reported USD values only; no repricing in MVP-0 | | Public tool implemented | `compare_pools` | -Live integration starts only after every remaining `TBD` in this table is resolved. +Base does not yield three live Messari-standardized DEX deployments; MVP-0 standardizes on the Uniswap-V3 native schema family instead (`source_type: "native_subgraph"`), with an owner-approved reduction to two Graph sources. See `docs/source-scope.md`. Core policy values are executable constants in `src/policy/m0.ts`. ### MVP-0 Definition of Done diff --git a/docs/source-scope.md b/docs/source-scope.md index d6745f2..2b48e22 100644 --- a/docs/source-scope.md +++ b/docs/source-scope.md @@ -1,12 +1,28 @@ -# M2 Live Source Scope — Blocked +# M2 Live Source Scope — Amended to two sources ## Status -M2 is blocked as of the 2026-07-25 capture. The required three compatible +**Amended 2026-07-25 by owner decision: MVP-0 ships with two same-tier Graph +sources instead of three.** The two validated Tier-B deployments below are the +final M2 selection, and `src/registry/records.json` is finalized from them. + +The original three-source criterion was not met, and this is a deliberate scope +reduction rather than a satisfied gate. Aerodrome was neither retried nor +replaced; no tier mixing or silent fallback was introduced. The third source +remains available as a later addition: adding it is a `records.json` plus +`compare-pools.json` edit, with no loader or adapter change, because the +same-tier invariant is enforced across whatever set is configured. + +The exit criterion for M3 changes accordingly — one call returns **two** valid +`SourceResult` values, not three. + +### Prior status, retained for the record + +M2 ended blocked as of the 2026-07-25 capture. The required three compatible deployments and pools were not demonstrated. Two Tier-B deployments passed the native-USDC pool and common-query checks; Aerodrome pool discovery timed out. -No retry, tier mixing, silent fallback, registry finalization, fixture rewrite, -or `PLAN.md` scope lock was performed. +No retry, tier mixing, silent fallback, fixture rewrite, or `PLAN.md` scope lock +was performed. ## Locked inputs @@ -77,9 +93,11 @@ records the reference block, per-candidate outcome, token decision, two validated selections, and the blocker. Captures are point-in-time observations; publishers may update the deployment behind a stable subgraph ID. -Because the M2 exit criterion was not met: +## Closeout (owner-amended two-source exit) -- `src/registry/records.json` was not finalized; -- live fixtures were not substituted; -- the `PLAN.md` scope table remains unresolved; -- M2.5 through M2.9 and M2 complete remain unticked. +- `src/registry/records.json` holds the two active Graph records above. +- `tests/fixtures/sources/index.ts` carries real pool/deployment values; the + timeout fixture is synthetic status over real provenance; Nuthatch remains + shape-only until M5. +- `PLAN.md` scope table is filled, including the Tier B + two-source amendment. +- M3 exit is two live Graph `SourceResult`s in one call, not three. diff --git a/src/registry/records.json b/src/registry/records.json new file mode 100644 index 0000000..115467d --- /dev/null +++ b/src/registry/records.json @@ -0,0 +1,36 @@ +[ + { + "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" + } + }, + { + "source_id": "exchange-v3-base", + "category": "dex", + "protocol": "pancakeswap-v3", + "chain_id": 8453, + "source_type": "native_subgraph", + "deployment_or_view_id": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + "schema_version": null, + "methodology_version": null, + "supported_entities": ["pools", "poolDayDatas", "tokens"], + "status": "active", + "locator": { + "kind": "graph_subgraph", + "gateway_host": "gateway.thegraph.com", + "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3" + } + } +] diff --git a/tests/fixtures/sources/index.ts b/tests/fixtures/sources/index.ts index 83998b1..43f47f0 100644 --- a/tests/fixtures/sources/index.ts +++ b/tests/fixtures/sources/index.ts @@ -19,164 +19,172 @@ const usdc = { decimals: 6, } as const; +// Point-in-time values from tests/integration/__evidence__/m2/*/07-common-metrics.json. +// 7d aggregates stay null here so Person 2 null-handling stays covered; M3.6 owns real 7d sums. + export const graphPoolA = { - source_id: "fixture-graph-dex-a", - source_type: "standardized_subgraph", - protocol: "fixture-dex-a", + source_id: "uniswap-v3-base-native", + source_type: "native_subgraph", + protocol: "uniswap-v3", chain_id: 8453, status: "ok", data: { - pool_address: "0x00000000000000000000000000000000000000a1", + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", token0: weth, token1: usdc, - fee_tier_bps: 5, - tvl_usd: "1250000.25", - volume_usd_24h: "250000.50", - volume_usd_7d: "1750000.75", - fees_usd_24h: "125.00", - fees_usd_7d: "875.00", + fee_tier_bps: 30, + tvl_usd: "150700095.7707237035076119974091172", + volume_usd_24h: "1837918.971826772337839586279587621", + volume_usd_7d: null, + fees_usd_24h: "5513.756915480317013518758838762854", + fees_usd_7d: null, }, freshness: { - indexed_block: 30000001, - indexed_block_timestamp: 1735689601, - queried_at: 1735689610, + indexed_block: 49095773, + indexed_block_timestamp: 1784980893, + indexed_block_hash: "0xfaf4cc0493056e5ccac3f68b9e148cf8e80ee0d67adf333ed27b4a62d17c185c", + queried_at: 1784980898, has_indexing_errors: false, }, provenance: { - deployment_or_view_id: "fixture-deployment-a", - schema_version: "fixture-1.0.0", - methodology_version: "fixture-1.0.0", - query_id: "fixture-pool-metrics-v1", + deployment_or_view_id: "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", + schema_version: null, + methodology_version: null, + query_id: "m2-tier-b-metrics-v1", }, - warnings: [], - latency_ms: 101, + warnings: ["Fixture retains null 7d aggregates; production 7d sums land in M3.6."], + latency_ms: 120, } satisfies PoolSourceResult; export const graphPoolB = { - source_id: "fixture-graph-dex-b", - source_type: "standardized_subgraph", - protocol: "fixture-dex-b", + source_id: "exchange-v3-base", + source_type: "native_subgraph", + protocol: "pancakeswap-v3", chain_id: 8453, status: "ok", data: { - pool_address: "0x00000000000000000000000000000000000000b2", - token0: usdc, - token1: weth, - fee_tier_bps: 30, - tvl_usd: "980000.00", - volume_usd_24h: "310000.10", - volume_usd_7d: "2010000.20", - fees_usd_24h: "930.00", - fees_usd_7d: "6030.00", + pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + token0: weth, + token1: usdc, + fee_tier_bps: 1, + tvl_usd: "6565424.026253424582404270532672039", + volume_usd_24h: "6089724.592920848435197967898475142", + volume_usd_7d: null, + fees_usd_24h: "608.9724592920848435197967898475142", + fees_usd_7d: null, }, freshness: { - indexed_block: 30000002, - indexed_block_timestamp: 1735689603, - queried_at: 1735689611, + indexed_block: 49095784, + indexed_block_timestamp: 1784980915, + indexed_block_hash: "0x3d792f0e60742149c644825adb18c76fef43f01e25bc12dfef751de1e9d1bb6d", + queried_at: 1784980920, has_indexing_errors: false, }, provenance: { - deployment_or_view_id: "fixture-deployment-b", - schema_version: "fixture-1.0.0", - methodology_version: "fixture-1.0.0", - query_id: "fixture-pool-metrics-v1", + deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + schema_version: null, + methodology_version: null, + query_id: "m2-tier-b-metrics-v1", }, - warnings: [], - latency_ms: 114, + warnings: ["Fixture retains null 7d aggregates; production 7d sums land in M3.6."], + latency_ms: 301, } satisfies PoolSourceResult; +// Synthetic null-metric variant for Person 2 null paths. Not a third live Graph source. export const graphPoolC = { - source_id: "fixture-graph-dex-c", - source_type: "standardized_subgraph", - protocol: "fixture-dex-c", + source_id: "exchange-v3-base", + source_type: "native_subgraph", + protocol: "pancakeswap-v3", chain_id: 8453, status: "ok", data: { - pool_address: "0x00000000000000000000000000000000000000c3", + pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", token0: weth, token1: usdc, - fee_tier_bps: null, - tvl_usd: "720000.40", - volume_usd_24h: "190000.30", + fee_tier_bps: 1, + tvl_usd: "6565424.026253424582404270532672039", + volume_usd_24h: "6089724.592920848435197967898475142", volume_usd_7d: null, - fees_usd_24h: "570.00", + fees_usd_24h: null, fees_usd_7d: null, }, freshness: { - indexed_block: 30000000, - indexed_block_timestamp: 1735689599, - queried_at: 1735689612, + indexed_block: 49095784, + indexed_block_timestamp: 1784980915, + indexed_block_hash: "0x3d792f0e60742149c644825adb18c76fef43f01e25bc12dfef751de1e9d1bb6d", + queried_at: 1784980920, has_indexing_errors: false, }, provenance: { - deployment_or_view_id: "fixture-deployment-c", - schema_version: "fixture-1.0.0", + deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + schema_version: null, methodology_version: null, - query_id: "fixture-pool-metrics-v1", + query_id: "m2-tier-b-metrics-v1", }, - warnings: ["Fixture source does not expose seven-day aggregates."], - latency_ms: 98, + warnings: ["Synthetic fixture: fees windows forced null for Person 2 null-handling coverage."], + latency_ms: 301, } satisfies PoolSourceResult; +// Synthetic timeout over real exchange-v3-base provenance (status flipped). export const graphPoolCTimeout = { - source_id: "fixture-graph-dex-c", - source_type: "standardized_subgraph", - protocol: "fixture-dex-c", + source_id: "exchange-v3-base", + source_type: "native_subgraph", + protocol: "pancakeswap-v3", chain_id: 8453, status: "timeout", data: null, freshness: null, provenance: { - deployment_or_view_id: "fixture-deployment-c", - schema_version: "fixture-1.0.0", + deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + schema_version: null, methodology_version: null, - query_id: "fixture-pool-metrics-v1", + query_id: "m2-tier-b-metrics-v1", }, - warnings: ["Fixture source timed out before returning data."], - latency_ms: 5000, + warnings: ["Synthetic timeout over real exchange-v3-base provenance."], + latency_ms: 15000, } satisfies PoolSourceResult; +// Shape-only until M5 delivers live Nuthatch evidence. Pool matches Uniswap selection. export const nuthatchFreshness = { - source_id: "fixture-nuthatch-pool-swaps", + source_id: "nuthatch-pool-swaps", source_type: "nuthatch_view", - protocol: "fixture-dex-a", + protocol: "uniswap-v3", chain_id: 8453, status: "ok", data: { - pool_address: "0x00000000000000000000000000000000000000a1", + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", recent_swap_count_24h: 321, - last_swap_block: 30000003, - last_swap_block_timestamp: 1735689605, + last_swap_block: 49095770, + last_swap_block_timestamp: 1784980887, last_swap_block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", last_swap_tx_hash: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", last_swap_log_index: 7, }, freshness: { - indexed_block: 30000003, - indexed_block_timestamp: 1735689605, + indexed_block: 49095770, + indexed_block_timestamp: 1784980887, indexed_block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - queried_at: 1735689613, + queried_at: 1784980925, }, provenance: { deployment_or_view_id: "fixture-nuthatch-registry-hash", - schema_version: "fixture-1.0.0", - methodology_version: "fixture-1.0.0", - query_id: "fixture-pool-swap-freshness-v1", + schema_version: null, + methodology_version: null, + query_id: "nuthatch-pool-swap-freshness-v1", }, - warnings: [], + warnings: ["Shape-only Nuthatch fixture until M5 live evidence."], latency_ms: 42, } satisfies NuthatchSourceResult; +// MVP-0 amended to two Graph sources + Nuthatch. export const completeSourceScenario = [ graphPoolA, graphPoolB, - graphPoolC, nuthatchFreshness, ] satisfies readonly MvpSourceFixture[]; export const partialSourceScenario = [ graphPoolA, - graphPoolB, graphPoolCTimeout, nuthatchFreshness, ] satisfies readonly MvpSourceFixture[]; From 90727292458e8c569fca43d8f2636c569ac49cf7 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:46:20 +0100 Subject: [PATCH 27/96] fix m2 probe harness review findings (#19) - prefer GRAPH_API_KEY from process env without requiring .env - preserve transport timeout and error messages in pool probes - move harness tests into the vitest quality gate and typecheck scripts/m2 --- package.json | 2 +- scripts/m2/lib/evidence.test.ts | 36 ------ scripts/m2/lib/gateway.test.ts | 65 ---------- scripts/m2/lib/gateway.ts | 19 ++- scripts/m2/lib/pool-probe.ts | 7 ++ scripts/m2/probe-pools.ts | 4 +- scripts/m2/tsconfig.json | 5 +- .../graph/deployment-assertion.test.ts | 33 ----- tests/tsconfig.json | 2 +- tests/unit/graph-deployment-assertion.test.ts | 39 ++++++ tests/unit/m2-evidence.test.ts | 38 ++++++ tests/unit/m2-gateway.test.ts | 115 ++++++++++++++++++ tests/unit/m2-probe-pools.test.ts | 35 ++++++ vitest.config.mjs | 7 +- 14 files changed, 259 insertions(+), 148 deletions(-) delete mode 100644 scripts/m2/lib/evidence.test.ts delete mode 100644 scripts/m2/lib/gateway.test.ts create mode 100644 scripts/m2/lib/pool-probe.ts delete mode 100644 src/sources/graph/deployment-assertion.test.ts create mode 100644 tests/unit/graph-deployment-assertion.test.ts create mode 100644 tests/unit/m2-evidence.test.ts create mode 100644 tests/unit/m2-gateway.test.ts create mode 100644 tests/unit/m2-probe-pools.test.ts diff --git a/package.json b/package.json index 87ca22b..64cf8ce 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit && tsc -p scripts/m2/tsconfig.json" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/scripts/m2/lib/evidence.test.ts b/scripts/m2/lib/evidence.test.ts deleted file mode 100644 index d433bc0..0000000 --- a/scripts/m2/lib/evidence.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, readFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; - -import { writeEvidence, type EvidenceDocument } from "./evidence.ts"; -import { metaQuery } from "./queries.ts"; - -void test("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; - assert.equal(evidence.gateway_host, "gateway.thegraph.com"); - assert.equal(evidence.subgraph_id, "dummy-id"); - assert.equal(evidence.query_id, "m2-meta-v1"); - assert.deepEqual(evidence.response, { - data: { _meta: { deployment: "QmDummy" } }, - }); - assert.equal(Object.hasOwn(evidence, "headers"), false); -}); diff --git a/scripts/m2/lib/gateway.test.ts b/scripts/m2/lib/gateway.test.ts deleted file mode 100644 index 8b47f31..0000000 --- a/scripts/m2/lib/gateway.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { postGraphQuery } from "./gateway.ts"; -import { metaQuery } from "./queries.ts"; - -void test("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", - }, - ); - - assert.equal(observedUrl, "https://dummy.invalid/api/subgraphs/id/dummy-id"); - assert.equal(observedAuthorization, `Bearer ${credential}`); - assert.doesNotMatch(observedUrl, new RegExp(credential)); - assert.equal(result.status, 200); - assert.equal(result.error, null); -}); - -void test("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, - }, - ); - - assert.equal(result.error?.kind, "transport"); - assert.doesNotMatch(result.error?.message ?? "", new RegExp(credential)); -}); diff --git a/scripts/m2/lib/gateway.ts b/scripts/m2/lib/gateway.ts index 49da2f0..e9f5f02 100644 --- a/scripts/m2/lib/gateway.ts +++ b/scripts/m2/lib/gateway.ts @@ -38,9 +38,24 @@ function parseEnv(contents: string): Map { 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 fileValues = parseEnv(await readFile(envPath, "utf8")); - const key = process.env.GRAPH_API_KEY ?? fileValues.get("GRAPH_API_KEY"); + 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."); } 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/probe-pools.ts b/scripts/m2/probe-pools.ts index 6477626..115a46a 100644 --- a/scripts/m2/probe-pools.ts +++ b/scripts/m2/probe-pools.ts @@ -1,5 +1,6 @@ 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, @@ -98,8 +99,7 @@ for (const selection of selections) { firstPoolId(pools.body, selection.tier === "A" ? "liquidityPools" : "pools") ?? undefined; } if (!pool) { - selection.pool_probe_error = - "The common tier query returned no pool; candidate is incompatible."; + selection.pool_probe_error = poolProbeFailureMessage(pools.error); await politeGap(); continue; } diff --git a/scripts/m2/tsconfig.json b/scripts/m2/tsconfig.json index d652c33..d7ee156 100644 --- a/scripts/m2/tsconfig.json +++ b/scripts/m2/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "rootDir": "../..", + "rootDir": ".", "noEmit": true, - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "types": ["node"] }, "include": ["./**/*.ts"] } diff --git a/src/sources/graph/deployment-assertion.test.ts b/src/sources/graph/deployment-assertion.test.ts deleted file mode 100644 index f497f10..0000000 --- a/src/sources/graph/deployment-assertion.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { assertDeployment, deploymentMismatchWarning } from "./deployment-assertion.ts"; - -void test("accepts an exact, case-sensitive deployment match", () => { - assert.deepEqual(assertDeployment("QmExpected", "QmExpected"), { ok: true }); -}); - -void test("returns both hashes on mismatch", () => { - assert.deepEqual(assertDeployment("QmExpected", "QmActual"), { - ok: false, - expected: "QmExpected", - actual: "QmActual", - }); -}); - -void test("treats an omitted deployment as an empty mismatch", () => { - assert.deepEqual(assertDeployment("QmExpected", ""), { - ok: false, - expected: "QmExpected", - actual: "", - }); -}); - -void test("warning contains both hashes and no unrelated environment value", () => { - const credential = "secret-that-must-not-appear"; - const warning = deploymentMismatchWarning("QmExpected", "QmActual"); - - assert.match(warning, /QmExpected/); - assert.match(warning, /QmActual/); - assert.doesNotMatch(warning, new RegExp(credential)); -}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 77ebb4d..648a9e5 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "rootDir": "..", "noEmit": true, - "types": ["vitest/globals"] + "types": ["node", "vitest/globals"] }, "include": ["**/*.ts"] } 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/m2-evidence.test.ts b/tests/unit/m2-evidence.test.ts new file mode 100644 index 0000000..c0f016a --- /dev/null +++ b/tests/unit/m2-evidence.test.ts @@ -0,0 +1,38 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { writeEvidence, type EvidenceDocument } from "../../scripts/m2/lib/evidence.ts"; +import { metaQuery } from "../../scripts/m2/lib/queries.ts"; + +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); + }); +}); 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/vitest.config.mjs b/vitest.config.mjs index c16975d..3ca675a 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -2,11 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - exclude: [ - "**/node_modules/**", - "**/dist/**", - "scripts/m2/**/*.test.ts", - "src/sources/graph/**/*.test.ts", - ], + exclude: ["**/node_modules/**", "**/dist/**"], }, }); From 3dd19ccb03387a74d307d37e7fe3cab73abfa5d8 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 18:10:08 +0200 Subject: [PATCH 28/96] =?UTF-8?q?M3:=20Graph=20adapter=20=E2=80=94=20regis?= =?UTF-8?q?try=20loader=20and=20daily=20aggregation=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(m3.1): load typed source registry records Validate records.json at load time with a strict runtime schema instead of asserting it into SourceRegistryRecord[]. Repository configuration is an untrusted deploy input, so every failure names the offending JSON path. The gateway host allowlist, Base chain id, and locator shape are enforced by the schema, and records are deep-frozen before they reach callers. * feat(m3.1): validate compare-pools profiles Add the MVP request profile as registry-owned configuration rather than extending SourceRegistryRecord, which describes source identity and location and should not absorb one milestone's pool, query, and ordering choices. getActiveComparePoolGraphSources joins each binding to an active Graph record and enforces the invariants the adapter would otherwise have to trust: exactly three bindings, unique source, pool, and priority, a shared query and response contract across the set, and entity coverage for the selected query. Results come back frozen and in ascending priority order so output order never depends on response timing. * test(m3.1): cover invalid registry records Prove that a bad records.json fails loudly with a path-specific diagnostic: missing file, malformed JSON, wrong container shape, unknown keys, a gateway host outside the allowlist, a non-Base chain, empty subgraph and deployment pins, a locator that contradicts its source type, and duplicate source ids. One error reports every issue found so a bad deploy is fixed in one pass. * test(m3.1): cover invalid compare-pools profiles Prove each load-time invariant rejects rather than degrades: non-three cardinality, duplicate source, pool, or priority, unknown, inactive, and non-Graph source references, a query or response contract that differs across the set, a mixed schema tier, checksummed and truncated pool addresses, a self-paired token, a non-Base chain, a non-positive priority, unknown keys, and a record missing an entity the selected query needs. Silently querying two sources, or two sources under different templates, would produce a comparison the contract cannot express, so these are startup failures rather than per-source warnings. * feat(m3.6): add exact decimal summation * feat(m3.1): bind two M2 pool sources * chore(m3.6): scope vitest exclude to node:test helper * chore(m3.1): format registry loader tests * feat(m3.6): aggregate completed UTC snapshots * test(m3.1): smoke test shipped registry profile * test(m3.6): cover unavailable daily history * fix(m3.1): use record chain_id in profile smoke * fix(m3.6): scope 7d consecutivity to the window * fix m2 probe harness review findings (#19) - prefer GRAPH_API_KEY from process env without requiring .env - preserve transport timeout and error messages in pool probes - move harness tests into the vitest quality gate and typecheck scripts/m2 * remove .opencode agent config - drop OpenCode executor/reviewer agents added with the M2 scope close - keep the DeepTrace workflow defined in AGENTS.md * fix graph adapter review findings - null or malformed days null the affected 7d aggregate instead of under-summing - align compare-pools query and schema ids with m2-tier-b-metrics-v1 - cover partial-null and malformed holes inside a consecutive 7d window --- .opencode/agent/code-executor.md | 58 --- .opencode/agent/code-reviewer.md | 54 --- .opencode/agent/git-commit-reviewer.md | 60 --- .opencode/opencode.json | 31 -- src/registry/compare-pools.json | 23 + src/registry/index.test.ts | 583 +++++++++++++++++++++++++ src/registry/index.ts | 425 ++++++++++++++++++ src/sources/graph/aggregation.test.ts | 283 ++++++++++++ src/sources/graph/aggregation.ts | 262 +++++++++++ src/sources/graph/decimal.test.ts | 119 +++++ src/sources/graph/decimal.ts | 117 +++++ 11 files changed, 1812 insertions(+), 203 deletions(-) delete mode 100644 .opencode/agent/code-executor.md delete mode 100644 .opencode/agent/code-reviewer.md delete mode 100644 .opencode/agent/git-commit-reviewer.md delete mode 100644 .opencode/opencode.json create mode 100644 src/registry/compare-pools.json create mode 100644 src/registry/index.test.ts create mode 100644 src/registry/index.ts create mode 100644 src/sources/graph/aggregation.test.ts create mode 100644 src/sources/graph/aggregation.ts create mode 100644 src/sources/graph/decimal.test.ts create mode 100644 src/sources/graph/decimal.ts diff --git a/.opencode/agent/code-executor.md b/.opencode/agent/code-executor.md deleted file mode 100644 index 757510d..0000000 --- a/.opencode/agent/code-executor.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -description: Code executor for DeepTrace milestone implementation. Implements one sub-task with green tests and git-safe commits. -mode: subagent -model: openrouter/z-ai/glm-5.2 -color: success -permission: - edit: allow - bash: allow - external_directory: allow - webfetch: deny ---- - -You are the DeepTrace **code executor**. Model: GLM-5.2 via OpenRouter. - -## Role - -Implement exactly one assigned sub-task. Leave a clean worktree with green checks. - -## Git conventions (binding) - -- Stay on the branch named in the brief; never `git switch` to another branch -- Commit when one coherent behavior is green -- Subject: `feat(mX.Y): …` / `test(mX.Y): …` / `docs(mX): …` / `fix(mX.Y): …` -- Subject must not contain the word "and" -- Do not push, merge, rebase onto unrelated branches, or open PRs -- Do not commit planning docs or `.env` - -## Implementation rules - -1. Read only files the brief names plus immediate imports -2. Never invent pool addresses, deployment IDs, or tier choices — use `docs/source-scope.md` and `src/registry/records.json` -3. MVP-0 Graph sources are **two** (owner-amended), same Tier B, one query template -4. Decimal strings only for money; no `Number()`/`parseFloat` on financial values -5. Frozen contracts stay frozen unless the brief says both workstreams agreed -6. Never read or echo `.env` secret values - -## Before each commit - -```sh -npm test -- -npm run typecheck -npm run lint -npm run format:check -``` - -## Handoff (required) - -```text -Branch: -Worktree: -HEAD: -Commits: -Files changed: -Commands run: -Assumptions: -Gaps: -Working tree: clean -``` diff --git a/.opencode/agent/code-reviewer.md b/.opencode/agent/code-reviewer.md deleted file mode 100644 index aa5029c..0000000 --- a/.opencode/agent/code-reviewer.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -description: Code reviewer for DeepTrace diffs against contracts and milestone plans. Read-only. Use before merge gates. -mode: subagent -model: openrouter/z-ai/glm-5.2 -color: warning -permission: - edit: deny - bash: allow - external_directory: allow - webfetch: deny ---- - -You are the DeepTrace **code reviewer**. Model: GLM-5.2 via OpenRouter. Read-only — never edit files. - -## When invoked - -Review the current branch or the paths named in the prompt against: - -- frozen contracts: `docs/CONTRACT.md`, `src/schemas/source-adapter.ts` -- milestone plan sections named in the prompt -- git workflow: one purpose per commit, no secrets, no plan docs in the tree - -## Method - -1. `git status -sb` and `git branch --show-current` -2. `git log --oneline ..HEAD` (default base: `develop` or the milestone branch) -3. `git diff ...HEAD` -4. Read changed source/tests only -5. Run focused tests if the prompt asks (`npm test -- `) — do not "fix" failures - -## Hard rules - -- Financial values must be decimal strings; reject float money paths -- Adapter boundaries must not throw past the boundary -- No credentials, keyed URLs, or `.env` contents in code/tests/evidence -- Do not invent missing M2 pool/deployment IDs -- Frozen contracts are not changed without both-workstream agreement - -## Output (exact shape) - -```text -Verdict: APPROVE | REQUEST_CHANGES | BLOCKED -Branch: -Base: -Summary: <3-6 sentences> -Must-fix: -- ... -Should-fix: -- ... -Contract risks: -- ... -Test gaps: -- ... -``` diff --git a/.opencode/agent/git-commit-reviewer.md b/.opencode/agent/git-commit-reviewer.md deleted file mode 100644 index 61731f5..0000000 --- a/.opencode/agent/git-commit-reviewer.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -description: Git commit reviewer for DeepTrace merge gates. Checks history, secrets, branch topology. Read-only. -mode: subagent -model: openrouter/z-ai/glm-5.2 -color: info -permission: - edit: deny - bash: allow - external_directory: allow - webfetch: deny ---- - -You are the DeepTrace **git commit reviewer**. Model: GLM-5.2 via OpenRouter. Read-only — never edit, commit, merge, push, or rewrite history. - -## Binding git rules - -From the data-pipe workflow: - -1. One branch per milestone off `develop` (`feat/mN-...`) -2. Sub-branches off the milestone branch, never straight to `develop` -3. At least one commit per sub-task; subject must not need the word "and" -4. Never bundle two milestones in one commit -5. Planning docs (`DATA_PIPE_PLAN.md`, `docs/M*_PLAN.md`) must not appear in commits -6. No secrets, `.env`, or credential-bearing URLs in any commit blob -7. Typecheck/lint should be green per commit intent -8. PR base is `develop`, never `main` - -## Method - -```sh -git status --short -git branch --show-current -git log --oneline ..HEAD -git log --format='%h %s' ..HEAD -git diff --stat ...HEAD -git diff ...HEAD -# secret-ish scan of the diff only (never print .env values): -git diff ...HEAD | rg -n 'GRAPH_API_KEY|Bearer [A-Za-z0-9]|api[_-]?key=|Authorization:' || true -``` - -Default base: `origin/develop` if present, else `develop`. - -## Output (exact shape) - -```text -Verdict: READY_TO_MERGE | NEEDS_HISTORY_FIX | BLOCKED -Branch: -Base: -Commit count: -Commits: -- — ok | problem: -Diff risk summary: -Secret scan: clean | FAIL -Branch topology: ok | problem: -User knowledge-check questions: -1. What outcome does this branch deliver? -2. What is the main failure behavior if a source is bad? -3. What evidence proves it? -Merge recommendation: -``` diff --git a/.opencode/opencode.json b/.opencode/opencode.json deleted file mode 100644 index a8363ee..0000000 --- a/.opencode/opencode.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "model": "openrouter/z-ai/glm-5.2", - "small_model": "openrouter/z-ai/glm-5.2", - "default_agent": "build", - "agent": { - "code-executor": { - "model": "openrouter/z-ai/glm-5.2", - "mode": "subagent", - "description": "Implements one DeepTrace milestone sub-task with green tests and safe commits" - }, - "code-reviewer": { - "model": "openrouter/z-ai/glm-5.2", - "mode": "subagent", - "description": "Read-only code review against contracts and milestone acceptance" - }, - "git-commit-reviewer": { - "model": "openrouter/z-ai/glm-5.2", - "mode": "subagent", - "description": "Read-only git history and merge-gate review" - } - }, - "permission": { - "external_directory": { - "/home/arch/repos/deeptrace-m3.1/**": "allow", - "/home/arch/repos/deeptrace-m3.6/**": "allow", - "/home/arch/repos/deeptrace-m4/**": "allow", - "*": "ask" - } - } -} diff --git a/src/registry/compare-pools.json b/src/registry/compare-pools.json new file mode 100644 index 0000000..032ad1c --- /dev/null +++ b/src/registry/compare-pools.json @@ -0,0 +1,23 @@ +{ + "profile_id": "compare-pools-base-weth-usdc-v1", + "chain_id": 8453, + "token0": "0x4200000000000000000000000000000000000006", + "token1": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "window_methodology": "completed-utc-days-v1", + "sources": [ + { + "source_id": "uniswap-v3-base-native", + "pool_address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", + "query_id": "m2-tier-b-metrics-v1", + "schema_contract_id": "m2-tier-b-metrics-v1", + "priority": 1 + }, + { + "source_id": "exchange-v3-base", + "pool_address": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + "query_id": "m2-tier-b-metrics-v1", + "schema_contract_id": "m2-tier-b-metrics-v1", + "priority": 2 + } + ] +} diff --git a/src/registry/index.test.ts b/src/registry/index.test.ts new file mode 100644 index 0000000..902714d --- /dev/null +++ b/src/registry/index.test.ts @@ -0,0 +1,583 @@ +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 M2 + // 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 M2 Graph bindings in priority order", () => { + const sources = getActiveComparePoolGraphSources(); + + expect(sources).toHaveLength(2); + expect(sources.map((source) => source.source_id)).toEqual([ + "uniswap-v3-base-native", + "exchange-v3-base", + ]); + expect(sources.map((source) => source.priority)).toEqual([1, 2]); + }); + + 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("m2-tier-b-metrics-v1"); + expect([...schemaContractIds][0]).toBe("m2-tier-b-metrics-v1"); + }); +}); diff --git a/src/registry/index.ts b/src/registry/index.ts new file mode 100644 index 0000000..ab785e2 --- /dev/null +++ b/src/registry/index.ts @@ -0,0 +1,425 @@ +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, 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 DEFAULT_RECORDS_URL = new URL("./records.json", import.meta.url); +const DEFAULT_PROFILE_URL = new URL("./compare-pools.json", import.meta.url); + +/** + * MVP-0 compares exactly two Graph deployments (owner-amended from three). + * Enforcing the count here makes the M2 selection a load-time invariant. + */ +export const COMPARE_POOLS_SOURCE_COUNT = 2; + +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.literal("dex"), + 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(); + +/** + * 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; + +/** 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; +} + +/** + * 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. M3.3 owns the query and therefore the + * real list; passing none skips the coverage check rather than guessing a + * schema tier that M2 has not fixed. + */ + 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); +} + +let cachedRecords: readonly SourceRegistryRecord[] | undefined; +let cachedProfile: ComparePoolsProfile | 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); +} + +/** + * 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; +} + +/** + * 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); +} + +function bindingPath(index: number, field: string): string { + return `${COMPARE_POOLS_LABEL}.sources[${String(index)}].${field}`; +} + +function collectDuplicateIssues( + bindings: readonly ComparePoolBinding[], + field: "source_id" | "pool_address" | "priority", +): 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( + `${bindingPath(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"; +} + +/** + * 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"), + ...collectDuplicateIssues(profile.sources, "pool_address"), + ...collectDuplicateIssues(profile.sources, "priority"), + ...collectSharedValueIssues( + profile.sources.map((binding) => binding.query_id), + (index) => bindingPath(index, "query_id"), + "query_id", + ), + ...collectSharedValueIssues( + profile.sources.map((binding) => binding.schema_contract_id), + (index) => bindingPath(index, "schema_contract_id"), + "schema_contract_id", + ), + ]; + + const joined: ComparePoolGraphSource[] = []; + + profile.sources.forEach((binding, index) => { + const path = bindingPath(index, "source_id"); + const record = records.find((candidate) => candidate.source_id === binding.source_id); + + if (record === undefined) { + issues.push(`${path}: "${binding.source_id}" is not present in ${RECORDS_LABEL}`); + return; + } + if (!isGraphRecord(record)) { + issues.push(`${path}: "${binding.source_id}" is not a Graph source`); + return; + } + if (record.status !== "active") { + issues.push(`${path}: "${binding.source_id}" is ${record.status}`); + return; + } + + const missingEntities = requiredEntities.filter( + (entity) => !record.supported_entities.includes(entity), + ); + if (missingEntities.length > 0) { + issues.push(`${path}: "${binding.source_id}" does not support ${missingEntities.join(", ")}`); + 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( + ...collectSharedValueIssues( + joined.map((source) => source.record.source_type), + (index) => bindingPath(index, "source_id"), + "source_type", + ), + ); + + if (issues.length > 0) { + throw new RegistryConfigurationError(issues); + } + + return deepFreeze(joined.sort((left, right) => left.priority - right.priority)); +} 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}`; +} From acb02fdad15999bb6d268a956e658efc52dd27fa Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 18:42:33 +0200 Subject: [PATCH 29/96] M4: Nuthatch contract probe and freshness-view draft (#21) * add nuthatch contract probe - capture the verified Nuthatch CLI and partial HTTP contract without overstating live view support - add fail-closed HTTP acceptance checks and quality-gate coverage - define the deterministic freshness-view draft with the locked 0.3% pool metadata * tighten nuthatch probe acceptance - require POST SQL rejection before the probe exits successfully - pin guard queries to the raw swap table in executable tests - keep retained evidence acceptance flags explicit * document nuthatch POST rejection gate - align the operator guide with the executable read-only acceptance checks --------- Co-authored-by: kapustazh <51422901+kapustazh@users.noreply.github.com> --- nest/semantic.toml | 33 ++ nest/views/pool_swap_freshness.sql | 26 ++ scripts/m4/README.md | 35 ++ scripts/m4/contract-probe.mjs | 370 ++++++++++++++++++ scripts/m4/fake-rpc.mjs | 68 ++++ scripts/m4/http-probe-lib.mjs | 43 ++ scripts/m4/http-probe.mjs | 193 +++++++++ scripts/m4/local-fixture/abis/probe.json | 12 + scripts/m4/local-fixture/nest.star | 15 + .../__evidence__/m4/p0-cli-help/check.txt | 13 + .../__evidence__/m4/p0-cli-help/dev.txt | 35 ++ .../__evidence__/m4/p0-cli-help/init.txt | 24 ++ .../__evidence__/m4/p0-cli-help/manifest.json | 17 + .../__evidence__/m4/p0-cli-help/mcp.txt | 11 + .../__evidence__/m4/p0-cli-help/nest.txt | 29 ++ .../__evidence__/m4/p0-cli-help/sql.txt | 15 + .../__evidence__/m4/p0-contract-delta.md | 126 ++++++ .../__evidence__/m4/p0-http-capabilities.json | 107 +++++ .../__evidence__/m4/p0-version.txt | 1 + tests/unit/m4-http-probe.test.mjs | 122 ++++++ 20 files changed, 1295 insertions(+) create mode 100644 nest/semantic.toml create mode 100644 nest/views/pool_swap_freshness.sql create mode 100644 scripts/m4/README.md create mode 100644 scripts/m4/contract-probe.mjs create mode 100644 scripts/m4/fake-rpc.mjs create mode 100644 scripts/m4/http-probe-lib.mjs create mode 100644 scripts/m4/http-probe.mjs create mode 100644 scripts/m4/local-fixture/abis/probe.json create mode 100644 scripts/m4/local-fixture/nest.star create mode 100644 tests/integration/__evidence__/m4/p0-cli-help/check.txt create mode 100644 tests/integration/__evidence__/m4/p0-cli-help/dev.txt create mode 100644 tests/integration/__evidence__/m4/p0-cli-help/init.txt create mode 100644 tests/integration/__evidence__/m4/p0-cli-help/manifest.json create mode 100644 tests/integration/__evidence__/m4/p0-cli-help/mcp.txt create mode 100644 tests/integration/__evidence__/m4/p0-cli-help/nest.txt create mode 100644 tests/integration/__evidence__/m4/p0-cli-help/sql.txt create mode 100644 tests/integration/__evidence__/m4/p0-contract-delta.md create mode 100644 tests/integration/__evidence__/m4/p0-http-capabilities.json create mode 100644 tests/integration/__evidence__/m4/p0-version.txt create mode 100644 tests/unit/m4-http-probe.test.mjs diff --git a/nest/semantic.toml b/nest/semantic.toml new file mode 100644 index 0000000..134bcf9 --- /dev/null +++ b/nest/semantic.toml @@ -0,0 +1,33 @@ +# 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.3% pool (feeTier 3000). + +[view] +name = "pool_swap_freshness" +sql = "views/pool_swap_freshness.sql" +table = "pool__swap" + +[view.pool] +chain = "base" +address = "0x6c561b446416e1a00e8e93e221854d6ea4171372" +fee_tier = "0.3%" +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." diff --git a/nest/views/pool_swap_freshness.sql b/nest/views/pool_swap_freshness.sql new file mode 100644 index 0000000..6372457 --- /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.3% pool (feeTier 3000). Anchored to the latest indexed +-- swap timestamp — no wall clock, no CURRENT_TIMESTAMP, no now(). +-- Described in semantic.toml under [view]. + +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/scripts/m4/README.md b/scripts/m4/README.md new file mode 100644 index 0000000..f8d9715 --- /dev/null +++ b/scripts/m4/README.md @@ -0,0 +1,35 @@ +# M4 contract probe + +`contract-probe.mjs` captures the installed Nuthatch CLI and read-only HTTP +surface into `tests/integration/__evidence__/m4` (or `--out-dir`). 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 writes normalized JSON evidence files to `--out-dir`. It does not +print the complete capture to stdout. + +`http-probe.mjs` probes an already-running instance and prints one JSON document +to stdout: + +```sh +NUTHATCH_BASE_URL=http://127.0.0.1:8288 node scripts/m4/http-probe.mjs +``` + +It exits non-zero unless the freshness view answers both `/sql` and `/explain`, +POST `/sql` is rejected with 405, and the `max_rows` ceiling is rejected +explicitly. Redirect stdout to a file only after the command exits +successfully. There is no offline mode; a filesystem-only sandbox cannot +produce HTTP acceptance evidence. 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..105c21b --- /dev/null +++ b/scripts/m4/http-probe.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +import { + buildAcceptance, + GUARD_QUERY, + isMaxRowsRejection, + isProbeAccepted, + MAX_ROWS_QUERY, + requireBaseUrl, +} from "./http-probe-lib.mjs"; + +// 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. + +let baseUrl; +try { + baseUrl = requireBaseUrl(process.env.NUTHATCH_BASE_URL); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +} +const BASE_URL = baseUrl; +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 === 405, + body: result.body, + }; +} + +async function probeMaxRowsRejection() { + // Use a known raw table so a missing authored view cannot masquerade as a + // max_rows guard rejection. + const url = `${join(BASE_URL, "/sql")}?q=${encodeURIComponent(MAX_ROWS_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: isMaxRowsRejection(result), + 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(GUARD_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, + verified: + guardRejected > 0 && + statuses.every((status) => status === 200 || status === 429 || status === 503), + // 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 endpoints = await probeGetEndpoints(); +const maxRows = await probeMaxRowsRejection(); +const postSql = await probePostSqlRejected(); +const acceptance = buildAcceptance({ endpoints, maxRows, postSql }); +const output = { + target_host: new URL(BASE_URL).host, + probe_version: "0.6.1", + generated_at: new Date().toISOString(), + endpoints, + post_sql: postSql, + max_rows: maxRows, + concurrency: await probeConcurrencyGuard(), + acceptance, +}; + +process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +if (!isProbeAccepted(acceptance)) { + process.exitCode = 1; +} 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/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..d9617ac --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-contract-delta.md @@ -0,0 +1,126 @@ +# P0 Contract Delta — installed Nuthatch 0.6.1 vs the M4 plan + +Task 3, partial. The CLI surface is captured in `p0-cli-help/`. The initial +HTTP capability capture is retained in `p0-http-capabilities.json`, but it is +not P5 acceptance evidence: the probed instance did not have the authored +`pool_swap_freshness` view installed. + +Binary: `/home/arch/.local/bin/nuthatch`, `nuthatch 0.6.1`, +sha256 `cac413574b1a7c5536c65403abacc0ae9ce699f2580ae01773e28bb2f0d65e89`. + +The HTTP capture confirms that the instance exposes `/health`, `/ready`, +`/nest`, `/schema`, `/tables`, `/metrics`, `/explain`, and GET-only `/sql`. +It does not prove the freshness view, `max_rows`, or concurrency guards: +`/sql` and `/explain` returned a catalog miss for `pool_swap_freshness`, and +that unrelated error invalidated the original guard classifications. + +## 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 P5 acceptance + +1. A successful `/sql` and `/explain` response for the deployed + `pool_swap_freshness` view. +2. Active timeout, row, byte, `max_rows`, and concurrency guards using queries + against tables that exist on the deployed nest. +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..17e68a6 --- /dev/null +++ b/tests/integration/__evidence__/m4/p0-http-capabilities.json @@ -0,0 +1,107 @@ +{ + "target_host": "wallet-intel.tail8ae57d.ts.net", + "probe_version": "0.6.1", + "generated_at": "2026-07-25T15:46:13.192Z", + "endpoints": { + "/health": { + "method": "GET", + "url": "https://wallet-intel.tail8ae57d.ts.net/health", + "status": 200, + "content_type": "text/plain; charset=utf-8", + "duration_ms": 250, + "error": null, + "body": "ok" + }, + "/ready": { + "method": "GET", + "url": "https://wallet-intel.tail8ae57d.ts.net/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://wallet-intel.tail8ae57d.ts.net/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://wallet-intel.tail8ae57d.ts.net/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://wallet-intel.tail8ae57d.ts.net/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://wallet-intel.tail8ae57d.ts.net/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://wallet-intel.tail8ae57d.ts.net/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://wallet-intel.tail8ae57d.ts.net/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://wallet-intel.tail8ae57d.ts.net/sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201", + "status": 405, + "rejected": true, + "body": "" + }, + "max_rows": { + "url": "https://wallet-intel.tail8ae57d.ts.net/sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=50001", + "max_rows_requested": 50001, + "status": 400, + "rejected": false, + "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://wallet-intel.tail8ae57d.ts.net/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, + "verified": false, + "observed_concurrency_lower_bound": 0 + }, + "acceptance": { + "freshness_view_available": false, + "max_rows_rejection_verified": false, + "post_sql_rejected": true + } +} 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/unit/m4-http-probe.test.mjs b/tests/unit/m4-http-probe.test.mjs new file mode 100644 index 0000000..f7d0474 --- /dev/null +++ b/tests/unit/m4-http-probe.test.mjs @@ -0,0 +1,122 @@ +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("keeps the failed pre-deployment capture explicit", async () => { + const evidence = JSON.parse( + await readFile( + new URL("../integration/__evidence__/m4/p0-http-capabilities.json", import.meta.url), + "utf8", + ), + ); + + expect(isFreshnessViewAvailable(evidence.endpoints)).toBe(false); + expect(evidence.endpoints["/sql"].body).toContain("pool_swap_freshness does not exist"); + expect(evidence.max_rows.rejected).toBe(false); + expect(evidence.concurrency.verified).toBe(false); + expect(evidence.acceptance).toEqual({ + freshness_view_available: false, + max_rows_rejection_verified: false, + post_sql_rejected: true, + }); + expect(isMaxRowsRejection(evidence.max_rows)).toBe(false); + }); + + 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); + }); +}); From a96067b41fe63846deb6fcbe3c84d3b3ffea0dcb Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:01:13 +0100 Subject: [PATCH 30/96] Graph: live PoolSourceResult adapter for locked compare-pools sources (#22) * Add Graph live adapter emitting PoolSourceResult for locked compare-pools sources. * Clamp Graph adapter timeouts and cover HTTP/transport/token failure paths. --- src/sources/graph/adapter.ts | 438 +++++++++++++++++++++++++++++++ src/sources/graph/index.ts | 22 ++ src/sources/graph/queries.ts | 24 ++ src/sources/graph/transport.ts | 110 ++++++++ tests/unit/graph-adapter.test.ts | 281 ++++++++++++++++++++ 5 files changed, 875 insertions(+) create mode 100644 src/sources/graph/adapter.ts create mode 100644 src/sources/graph/index.ts create mode 100644 src/sources/graph/queries.ts create mode 100644 src/sources/graph/transport.ts create mode 100644 tests/unit/graph-adapter.test.ts diff --git a/src/sources/graph/adapter.ts b/src/sources/graph/adapter.ts new file mode 100644 index 0000000..525674c --- /dev/null +++ b/src/sources/graph/adapter.ts @@ -0,0 +1,438 @@ +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_B_METRICS_QUERY, TIER_B_METRICS_QUERY_ID } from "./queries.js"; +import { postGraphGateway, type GraphTransportResult } from "./transport.js"; + +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 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>; +} + +function resolveApiKey(options: FetchComparePoolGraphOptions): string | null { + if (options.apiKey !== undefined && options.apiKey.trim() !== "") { + return options.apiKey; + } + const env = options.env ?? process.env; + const fromEnv = env.GRAPH_API_KEY; + if (fromEnv !== undefined && fromEnv.trim() !== "") { + return fromEnv; + } + return 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, + }; +} + +function normalizeAddress(value: string): string | null { + if (!ADDRESS_PATTERN.test(value)) { + return null; + } + return value.toLowerCase(); +} + +function parseToken(raw: unknown): TokenMetadata | null { + if (raw === null || typeof raw !== "object") { + return null; + } + const token = raw as Record; + if ( + typeof token.id !== "string" || + typeof token.symbol !== "string" || + typeof token.decimals !== "string" + ) { + return null; + } + const address = normalizeAddress(token.id); + if (address === null) { + return null; + } + const symbol = token.symbol.trim(); + if (symbol === "") { + return null; + } + if (!NON_NEGATIVE_INT_STRING.test(token.decimals)) { + return null; + } + const decimals = Number(token.decimals); + if (!Number.isSafeInteger(decimals) || decimals < 0) { + return null; + } + return { address, symbol, decimals }; +} + +function parseFeeTierBps(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; +} + +function parseFinancial(raw: unknown): string | null { + if (typeof raw !== "string" || !NON_NEGATIVE_DECIMAL.test(raw)) { + return null; + } + return raw; +} + +function parseDayDatas(raw: unknown): DailySnapshot[] | null { + if (!Array.isArray(raw)) { + return null; + } + const days: DailySnapshot[] = []; + for (const entry of raw) { + if (entry === null || typeof entry !== "object") { + return null; + } + const day = entry as Record; + if (typeof day.date !== "number" || !Number.isInteger(day.date) || day.date < 0) { + return null; + } + const volumeUSD = + day.volumeUSD === null || day.volumeUSD === undefined + ? null + : typeof day.volumeUSD === "string" + ? day.volumeUSD + : null; + const feesUSD = + day.feesUSD === null || day.feesUSD === undefined + ? null + : typeof day.feesUSD === "string" + ? day.feesUSD + : null; + if (day.volumeUSD !== null && day.volumeUSD !== undefined && volumeUSD === null) { + return null; + } + if (day.feesUSD !== null && day.feesUSD !== undefined && feesUSD === null) { + return null; + } + days.push({ date: day.date, volumeUSD, feesUSD }); + } + return days; +} + +function parseFreshness(meta: Record, queriedAt: number): SourceFreshness | null { + const block = meta.block; + if (block === null || typeof block !== "object") { + return null; + } + const blockRecord = block as Record; + if ( + typeof blockRecord.number !== "number" || + !Number.isInteger(blockRecord.number) || + blockRecord.number < 0 || + typeof blockRecord.timestamp !== "number" || + !Number.isInteger(blockRecord.timestamp) || + blockRecord.timestamp < 0 + ) { + return null; + } + + const freshness: SourceFreshness = { + indexed_block: blockRecord.number, + indexed_block_timestamp: blockRecord.timestamp, + queried_at: queriedAt, + }; + + if (typeof blockRecord.hash === "string" && BLOCK_HASH_PATTERN.test(blockRecord.hash)) { + freshness.indexed_block_hash = blockRecord.hash.toLowerCase(); + } + + if (typeof meta.hasIndexingErrors === "boolean") { + freshness.has_indexing_errors = meta.hasIndexingErrors; + } + + return freshness; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * 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); + + if (source.query_id !== TIER_B_METRICS_QUERY_ID) { + return failedResult(source, "unsupported", { + warnings: [ + `Unsupported Graph query_id "${source.query_id}"; expected "${TIER_B_METRICS_QUERY_ID}".`, + ], + 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 = resolveApiKey(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, + TIER_B_METRICS_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, + }); + } + + if (data.pool === null) { + 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(data.pool)) { + 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 poolAddress = typeof data.pool.id === "string" ? normalizeAddress(data.pool.id) : null; + const token0 = parseToken(data.pool.token0); + const token1 = parseToken(data.pool.token1); + const dayDatas = parseDayDatas(data.poolDayDatas); + + if (poolAddress === null || token0 === null || token1 === null || dayDatas === null) { + return failedResult(source, "unsupported", { + warnings: ["Graph pool metrics payload failed shape validation."], + latencyMs: transport.latencyMs, + freshness, + deploymentOrViewId: data._meta.deployment, + }); + } + + if (poolAddress !== poolVariable) { + return failedResult(source, "unsupported", { + warnings: [ + `Graph pool id "${poolAddress}" 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 || + token0.address !== expectedToken0 || + 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(dayDatas, 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: poolAddress, + token0, + token1, + fee_tier_bps: parseFeeTierBps(data.pool.feeTier), + tvl_usd: parseFinancial(data.pool.totalValueLockedUSD), + 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/index.ts b/src/sources/graph/index.ts new file mode 100644 index 0000000..7cd9415 --- /dev/null +++ b/src/sources/graph/index.ts @@ -0,0 +1,22 @@ +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 { 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"; diff --git a/src/sources/graph/queries.ts b/src/sources/graph/queries.ts new file mode 100644 index 0000000..433c600 --- /dev/null +++ b/src/sources/graph/queries.ts @@ -0,0 +1,24 @@ +/** + * Locked Tier-B metrics query for MVP-0 compare_pools Graph sources. + * Identity must stay aligned with M2 evidence and the compare-pools profile. + */ +export const TIER_B_METRICS_QUERY_ID = "m2-tier-b-metrics-v1" as const; + +export const TIER_B_METRICS_QUERY = `query M2TierBMetrics($pool: ID!) { + _meta { + block { number timestamp hash } + hasIndexingErrors + deployment + } + 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 } +}`; 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/tests/unit/graph-adapter.test.ts b/tests/unit/graph-adapter.test.ts new file mode 100644 index 0000000..5d5fd33 --- /dev/null +++ b/tests/unit/graph-adapter.test.ts @@ -0,0 +1,281 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { getActiveComparePoolGraphSources } from "../../src/registry/index.js"; +import { poolSourceResultSchema } from "../../src/schemas/source-adapter.js"; +import { + fetchComparePoolGraphSource, + 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__/m2", +); + +async function loadEvidenceData(sourceDir: string): Promise { + const raw = JSON.parse( + await readFile(path.join(evidenceRoot, sourceDir, "07-common-metrics.json"), "utf8"), + ) as { response: { data: unknown } }; + return raw.response.data; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("fetchComparePoolGraphSource", () => { + const [uniswap, pancake] = getActiveComparePoolGraphSources(); + + it("maps Uniswap live evidence into an ok PoolSourceResult with exact 7d sums", async () => { + expect(uniswap).toBeDefined(); + const data = await loadEvidenceData("uniswap-v3-base-native"); + const dayDatas = ( + data as { + poolDayDatas: Array<{ date: number; volumeUSD: string; feesUSD: string }>; + } + ).poolDayDatas; + const newest = dayDatas[0]; + expect(newest).toBeDefined(); + // Make the newest captured day a completed UTC day for aggregation. + const nowSeconds = newest!.date + 86_400 + 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({ data })); + }; + + const result = await fetchComparePoolGraphSource(uniswap!, { + apiKey: "test-credential-must-not-leak", + fetchImpl, + nowSeconds, + }); + + expect(poolSourceResultSchema.parse(result).status).toBe("ok"); + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + + expect(result.source_id).toBe("uniswap-v3-base-native"); + expect(result.source_type).toBe("native_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("150700095.7707237035076119974091172"); + expect(result.data.volume_usd_24h).toBe(dayDatas[0]!.volumeUSD); + expect(result.data.fees_usd_24h).toBe(dayDatas[0]!.feesUSD); + expect(result.data.volume_usd_7d).toBe(sumDecimals(dayDatas.map((day) => day.volumeUSD))); + expect(result.data.fees_usd_7d).toBe(sumDecimals(dayDatas.map((day) => day.feesUSD))); + expect(result.freshness.indexed_block).toBe(49095773); + expect(result.provenance.query_id).toBe(TIER_B_METRICS_QUERY_ID); + expect(result.provenance.deployment_or_view_id).toBe( + "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", + ); + expect(observedUrl).toContain("/subgraphs/id/GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz"); + 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 PancakeSwap live evidence and fee tier 100 → 1 bps", async () => { + expect(pancake).toBeDefined(); + const data = await loadEvidenceData("exchange-v3-base"); + const dayDatas = (data as { poolDayDatas: Array<{ date: number }> }).poolDayDatas; + const nowSeconds = dayDatas[0]!.date + 86_400 + 1; + + const result = await fetchComparePoolGraphSource(pancake!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + nowSeconds, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.source_id).toBe("exchange-v3-base"); + expect(result.data.fee_tier_bps).toBe(1); + expect(result.data.pool_address).toBe("0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38"); + }); + + it("excludes the current partial UTC day from 24h aggregates", async () => { + const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { + poolDayDatas: Array<{ date: number; volumeUSD: string; feesUSD: string }>; + }; + const nowSeconds = data.poolDayDatas[0]!.date + 3_600; + + const result = await fetchComparePoolGraphSource(uniswap!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + nowSeconds, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + expect(result.data.volume_usd_24h).toBe(data.poolDayDatas[1]!.volumeUSD); + expect(result.data.fees_usd_24h).toBe(data.poolDayDatas[1]!.feesUSD); + expect(result.data.volume_usd_7d).toBeNull(); + expect(result.data.fees_usd_7d).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(uniswap!, { + 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 unsupported with retained freshness on deployment mismatch", async () => { + const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { + _meta: { deployment: string }; + }; + data._meta.deployment = "QmWrongDeploymentHashxxxxxxxxxxxxxxxxxxxxxxxxx"; + + const result = await fetchComparePoolGraphSource(uniswap!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + nowSeconds: 1_785_024_001, + }); + + 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 pool entity is null", async () => { + const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { + pool: unknown; + }; + data.pool = null; + + const result = await fetchComparePoolGraphSource(uniswap!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.freshness).not.toBeNull(); + }); + + it("returns error when GRAPH_API_KEY is missing", async () => { + let called = false; + const result = await fetchComparePoolGraphSource(uniswap!, { + 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 fetchComparePoolGraphSource( + { + ...uniswap!, + 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(uniswap!, { + 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(uniswap!, { + 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(uniswap!, { + 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/); + }); + + it("returns unsupported when pool tokens disagree with the locked pair", async () => { + const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { + pool: { token1: { id: string; symbol: string; decimals: string } }; + }; + data.pool.token1 = { + id: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", + symbol: "USDbC", + decimals: "6", + }; + + const result = await fetchComparePoolGraphSource(uniswap!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + nowSeconds: 1_785_024_001, + }); + + expect(result.status).toBe("unsupported"); + expect(result.data).toBeNull(); + expect(result.warnings[0]).toMatch(/tokens do not match/i); + }); +}); From 443435f38ee6741d8e9e388f8b4555b6abac06e3 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:50:15 +0100 Subject: [PATCH 31/96] bind compare_pools request schema to locked two-source Graph scope (#23) - set expectedGraphResults to 2 and reserve unverified Nuthatch slot - add strict Base WETH/USDC request schema with policy defaults - add live-derived partial/failed/stale fixtures without inventing Nuthatch --- src/policy/m0.ts | 7 +- src/schemas/compare-pools-request.ts | 28 +++ src/schemas/compare-pools.ts | 2 +- src/schemas/index.ts | 5 + src/scope/compare-pools.ts | 44 ++++ src/scope/index.ts | 1 + tests/fixtures/compare-pools-request.ts | 16 ++ tests/fixtures/compare-pools.ts | 27 +-- tests/fixtures/live-compare-pools.ts | 267 +++++++++++++++++++++++ tests/unit/compare-pools-request.test.ts | 72 ++++++ tests/unit/compare-pools-schema.test.ts | 14 +- tests/unit/compare-pools-scope.test.ts | 36 +++ tests/unit/live-compare-pools.test.ts | 55 +++++ tests/unit/m0-policy.test.ts | 2 +- 14 files changed, 543 insertions(+), 33 deletions(-) create mode 100644 src/schemas/compare-pools-request.ts create mode 100644 src/scope/compare-pools.ts create mode 100644 src/scope/index.ts create mode 100644 tests/fixtures/compare-pools-request.ts create mode 100644 tests/fixtures/live-compare-pools.ts create mode 100644 tests/unit/compare-pools-request.test.ts create mode 100644 tests/unit/compare-pools-scope.test.ts create mode 100644 tests/unit/live-compare-pools.test.ts diff --git a/src/policy/m0.ts b/src/policy/m0.ts index 29b6d38..d5ef629 100644 --- a/src/policy/m0.ts +++ b/src/policy/m0.ts @@ -19,8 +19,8 @@ export const M0_WARNING_ORDER = ["source_order", "warning_text"] as const; /** * Product limits and deterministic behavior locked by M0-01A. * - * Live pair, deployment, and Nuthatch selections belong to M0-01B and are - * intentionally absent. + * 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, @@ -49,7 +49,8 @@ export const M0_CORE_POLICY = { preservesAdapterStatus: true, }, coverage: { - expectedGraphResults: 3, + /** Owner-amended MVP-0 Graph scope: two Tier-B deployments, not three. */ + expectedGraphResults: 2, requiresNuthatchForComplete: true, minimumGraphResultsForPartial: 1, }, 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 index 73b1ded..979f155 100644 --- a/src/schemas/compare-pools.ts +++ b/src/schemas/compare-pools.ts @@ -244,7 +244,7 @@ export const comparePoolsResponseSchema = z ) { context.addIssue({ code: "custom", - message: "Provenance must contain three Graph sources and one Nuthatch source", + message: `Provenance must contain ${String(M0_CORE_POLICY.coverage.expectedGraphResults)} Graph sources and one Nuthatch source`, path: ["provenance"], }); } diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 12a33e8..f351e4c 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -20,3 +20,8 @@ export { type ResultFreshness, type ResultProvenance, } from "./compare-pools.js"; +export { + comparePoolsRequestSchema, + type ComparePoolsRequest, + type ComparePoolsRequestInput, +} from "./compare-pools-request.js"; diff --git a/src/scope/compare-pools.ts b/src/scope/compare-pools.ts new file mode 100644 index 0000000..c5ef840 --- /dev/null +++ b/src/scope/compare-pools.ts @@ -0,0 +1,44 @@ +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +/** + * Locked MVP-0 compare_pools allowlist for Graph-side binding. + * + * Values mirror `src/registry/compare-pools.json` and `records.json`. + * Nuthatch is named for response-slot reservation only; no live fact is verified. + */ +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: "uniswap-v3-base-native", + protocol: "uniswap-v3", + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + query_id: "m2-tier-b-metrics-v1", + deployment_or_view_id: "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", + }, + { + source_id: "exchange-v3-base", + protocol: "pancakeswap-v3", + pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + query_id: "m2-tier-b-metrics-v1", + deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + }, + ], + /** + * Reserved response/provenance slot. Not present in `records.json` until + * Nuthatch P5 lands; do not treat as a verified live source. + */ + 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..b376059 --- /dev/null +++ b/src/scope/index.ts @@ -0,0 +1 @@ +export { M0_COMPARE_POOLS_SCOPE, type M0ComparePoolsScope } from "./compare-pools.js"; 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 index 5f9a003..caee0f9 100644 --- a/tests/fixtures/compare-pools.ts +++ b/tests/fixtures/compare-pools.ts @@ -21,7 +21,7 @@ export const fixturePair = [ }, ] as const satisfies CanonicalPair; -const graphSourceIds = ["fixture-dex-a", "fixture-dex-b", "fixture-dex-c"] as const; +const graphSourceIds = ["fixture-dex-a", "fixture-dex-b"] as const; const nuthatchSourceId = "fixture-nuthatch"; function graphProvenance(sourceId: string, protocol: string): ResultProvenance { @@ -40,7 +40,6 @@ function graphProvenance(sourceId: string, protocol: string): ResultProvenance { const graphProvenanceEntries = [ graphProvenance(graphSourceIds[0], "protocol-a"), graphProvenance(graphSourceIds[1], "protocol-b"), - graphProvenance(graphSourceIds[2], "protocol-c"), ] as const; const nuthatchProvenance = { @@ -84,11 +83,6 @@ const completePools = [ volume_usd: "300000", fees_usd: "0", }), - poolRecord(3, graphSourceIds[2], "protocol-c", "0xcccccccccccccccccccccccccccccccccccccccc", { - tvl_usd: "750000", - volume_usd: "125000.5", - fees_usd: "375.1", - }), ] as const; function observedFreshness( @@ -127,14 +121,13 @@ export const completeComparePoolsFixture = { }, }, coverage: { - requested_deployments: 3, - successful_deployments: 3, + 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(graphSourceIds[2], 12_345_660, 48), { ...observedFreshness(nuthatchSourceId, 12_345_678, 8), indexed_block_hash: `0x${"1".repeat(64)}`, @@ -170,28 +163,20 @@ export const partialComparePoolsFixture = { nuthatch_freshness_fact: null, }, coverage: { - requested_deployments: 3, + 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: graphSourceIds[2], - status: "unavailable", - }, { source_id: nuthatchSourceId, status: "unavailable", }, ], provenance: allProvenance, - warnings: [ - "fixture-dex-c was unavailable", - "fixture-nuthatch was unavailable", - "fixture-dex-b exceeded the freshness threshold", - ], + warnings: ["fixture-nuthatch was unavailable", "fixture-dex-b exceeded the freshness threshold"], pagination: null, ai_reasoning: { status: "unavailable", @@ -206,7 +191,7 @@ export const failedComparePoolsFixture = { status: "failed", data: null, coverage: { - requested_deployments: 3, + requested_deployments: 2, successful_deployments: 0, nuthatch_available: false, }, diff --git a/tests/fixtures/live-compare-pools.ts b/tests/fixtures/live-compare-pools.ts new file mode 100644 index 0000000..cce4250 --- /dev/null +++ b/tests/fixtures/live-compare-pools.ts @@ -0,0 +1,267 @@ +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 [uniswap, pancake] = M0_COMPARE_POOLS_SCOPE.graphSources; +const nuthatchId = M0_COMPARE_POOLS_SCOPE.nuthatchSourceId; + +const uniswapProvenance = { + source_id: uniswap.source_id, + source_type: "native_subgraph" as const, + protocol: uniswap.protocol, + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + deployment_or_view_id: uniswap.deployment_or_view_id, + schema_version: null, + methodology_version: null, + query_id: uniswap.query_id, +}; + +const pancakeProvenance = { + source_id: pancake.source_id, + source_type: "native_subgraph" as const, + protocol: pancake.protocol, + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + deployment_or_view_id: pancake.deployment_or_view_id, + schema_version: null, + methodology_version: null, + query_id: pancake.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: "unverified-nuthatch-view", + schema_version: null, + methodology_version: null, + query_id: "nuthatch-pool-swap-freshness-v1", +}; + +const liveProvenance = [uniswapProvenance, pancakeProvenance, 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: pancake.protocol, + pool_address: pancake.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: 1, + source_ids: [pancake.source_id], + }, + { + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + protocol: uniswap.protocol, + pool_address: uniswap.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: 2, + source_ids: [uniswap.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, + ai_reasoning: { + status: "unavailable", + summary: "", + highlights: [], + caveats: [], + source_ids: [], + }, +} 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: uniswap.protocol, + pool_address: uniswap.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: [uniswap.source_id], + }, + ], + nuthatch_freshness_fact: null, + }, + coverage: { + requested_deployments: 2, + successful_deployments: 1, + nuthatch_available: false, + }, + freshness: [ + graphFreshness(graphPoolA, 5), + { source_id: pancake.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, + ai_reasoning: { + status: "unavailable", + summary: "", + highlights: [], + caveats: [], + source_ids: [], + }, +} 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: uniswap.source_id, status: "unavailable" }, + { source_id: pancake.source_id, status: "unavailable" }, + { source_id: nuthatchId, status: "unavailable" }, + ], + provenance: liveProvenance, + warnings: ["No valid Graph pool record was available"], + pagination: null, + ai_reasoning: { + status: "unavailable", + summary: "", + highlights: [], + caveats: [], + source_ids: [], + }, +} 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: uniswap.protocol, + pool_address: uniswap.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: [uniswap.source_id], + }, + ], + nuthatch_freshness_fact: null, + }, + coverage: { + requested_deployments: 2, + successful_deployments: 1, + nuthatch_available: false, + }, + freshness: [ + graphFreshness(graphPoolA, 600, "stale"), + { source_id: pancake.source_id, status: "unavailable" }, + { source_id: nuthatchId, status: "unavailable" }, + ], + provenance: liveProvenance, + warnings: [ + `${uniswap.source_id} exceeded the freshness threshold`, + `${pancake.source_id} was unavailable`, + "nuthatch-pool-swaps live freshness fact is not yet verified", + ], + pagination: null, + ai_reasoning: { + status: "unavailable", + summary: "", + highlights: [], + caveats: [], + source_ids: [], + }, +} as const satisfies ComparePoolsResponse; + +export const liveComparePoolsFixtures = [ + livePartialComparePoolsFixture, + livePartialOneGraphTimeoutFixture, + liveFailedComparePoolsFixture, + liveStaleGraphComparePoolsFixture, +] as const; 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 index 796ac86..8ff2e6c 100644 --- a/tests/unit/compare-pools-schema.test.ts +++ b/tests/unit/compare-pools-schema.test.ts @@ -89,7 +89,7 @@ describe("compare_pools response schemas", () => { nuthatch_available: false, }, freshness: completeComparePoolsFixture.freshness.map((entry, index) => - index === 3 ? { source_id: entry.source_id, status: "unavailable" } : entry, + index === 2 ? { source_id: entry.source_id, status: "unavailable" } : entry, ), }).success, ).toBe(false); @@ -138,7 +138,7 @@ describe("compare_pools response schemas", () => { comparePoolsResponseSchema.safeParse({ ...completeComparePoolsFixture, provenance: completeComparePoolsFixture.provenance.map((entry, index) => - index === 3 ? { ...entry, source_type: "native_subgraph" } : entry, + index === 2 ? { ...entry, source_type: "native_subgraph" } : entry, ), }).success, ).toBe(false); @@ -149,7 +149,7 @@ describe("compare_pools response schemas", () => { comparePoolsResponseSchema.safeParse({ ...completeComparePoolsFixture, freshness: completeComparePoolsFixture.freshness.map((entry, index) => - index === 3 ? { ...entry, indexed_block_hash: null } : entry, + index === 2 ? { ...entry, indexed_block_hash: null } : entry, ), }).success, ).toBe(false); @@ -273,7 +273,7 @@ describe("compare_pools response schemas", () => { partialComparePoolsFixture.data.pools[0], { ...partialComparePoolsFixture.data.pools[1], - source_ids: ["fixture-dex-c"], + source_ids: ["fixture-unknown"], }, ], }, @@ -285,7 +285,7 @@ describe("compare_pools response schemas", () => { status: "partial", warnings: ["fixture-nuthatch was unavailable"], freshness: completeComparePoolsFixture.freshness.map((entry, index) => - index === 3 ? { source_id: entry.source_id, status: "unavailable" } : entry, + index === 2 ? { source_id: entry.source_id, status: "unavailable" } : entry, ), }).success, ).toBe(false); @@ -293,7 +293,7 @@ describe("compare_pools response schemas", () => { comparePoolsResponseSchema.safeParse({ ...partialComparePoolsFixture, freshness: partialComparePoolsFixture.freshness.map((entry, index) => - index === 3 + index === 2 ? { source_id: entry.source_id, status: "fresh", @@ -323,7 +323,7 @@ describe("compare_pools response schemas", () => { comparePoolsResponseSchema.safeParse({ ...failedComparePoolsFixture, freshness: failedComparePoolsFixture.freshness.map((entry, index) => - index === 3 + index === 2 ? { source_id: entry.source_id, status: "fresh", diff --git a/tests/unit/compare-pools-scope.test.ts b/tests/unit/compare-pools-scope.test.ts new file mode 100644 index 0000000..e487aac --- /dev/null +++ b/tests/unit/compare-pools-scope.test.ts @@ -0,0 +1,36 @@ +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("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/live-compare-pools.test.ts b/tests/unit/live-compare-pools.test.ts new file mode 100644 index 0000000..74fc2ba --- /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(["exchange-v3-base"]); + expect(pools[1]?.source_ids).toEqual(["uniswap-v3-base-native"]); + expect(pools[0]?.volume_usd).toBe(graphPoolB.data.volume_usd_24h); + expect(pools[1]?.volume_usd).toBe(graphPoolA.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/m0-policy.test.ts b/tests/unit/m0-policy.test.ts index cbe44b8..5a1d538 100644 --- a/tests/unit/m0-policy.test.ts +++ b/tests/unit/m0-policy.test.ts @@ -35,7 +35,7 @@ describe("M0 core policy", () => { it("locks complete, partial, and failed coverage boundaries", () => { expect(M0_CORE_POLICY.coverage).toEqual({ - expectedGraphResults: 3, + expectedGraphResults: 2, requiresNuthatchForComplete: true, minimumGraphResultsForPartial: 1, }); From b04a6caa75fbfb2f7d00b1734026541bd2b480e3 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:58:39 +0100 Subject: [PATCH 32/96] bind Graph pool normalization to locked compare_pools allowlist (#24) - reject out-of-scope source, deployment, pool, pair, and chain before metrics - convert only allowlisted live Graph results via the pure M0-03A path - cover timeout passthrough and live fixture identity parity --- src/normalization/index.ts | 1 + src/normalization/live-binding.ts | 130 +++++++++++++ tests/unit/normalization-live-binding.test.ts | 181 ++++++++++++++++++ 3 files changed, 312 insertions(+) create mode 100644 src/normalization/live-binding.ts create mode 100644 tests/unit/normalization-live-binding.test.ts diff --git a/src/normalization/index.ts b/src/normalization/index.ts index 4ae1f47..552ae87 100644 --- a/src/normalization/index.ts +++ b/src/normalization/index.ts @@ -3,6 +3,7 @@ export { convertPoolSourceResult, toPoolComparisonRecord } from "./convert.js"; export { deduplicateCanonicalPools } from "./dedupe.js"; export { NormalizationError } from "./error.js"; export { pairIdentity, poolIdentity, tokenIdentity } from "./identities.js"; +export { bindComparePoolsGraphResult, bindComparePoolsGraphResults } from "./live-binding.js"; export { normalizeCanonicalPair, normalizeCanonicalToken } from "./pair.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/tests/unit/normalization-live-binding.test.ts b/tests/unit/normalization-live-binding.test.ts new file mode 100644 index 0000000..6b17d15 --- /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([ + "uniswap-v3-base-native", + "exchange-v3-base", + ]); + 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[1]!, 1), + toPoolComparisonRecord(candidates[0]!, 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); + }); +}); From 555e6e5b2d15ca4af601f3da9d9a51c930ae38fa Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:23:52 +0100 Subject: [PATCH 33/96] M0-04: deterministic pool metrics ranking and Top-N (#25) * add deterministic compare_pools metric ranking and Top-N - lock source-reported USD methodology without APR/APY labeling - rank by exact decimal strings with nulls-last and policy tie-breaks - dedupe before Top-N and cover live Graph fixture order stability * reuse policy ranking tie-break constants in metrics methodology --- src/metrics/decimal-order.ts | 38 +++++ src/metrics/index.ts | 3 + src/metrics/methodology.ts | 34 +++++ src/metrics/rank.ts | 106 +++++++++++++ tests/unit/metrics-ranking.test.ts | 231 +++++++++++++++++++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 src/metrics/decimal-order.ts create mode 100644 src/metrics/index.ts create mode 100644 src/metrics/methodology.ts create mode 100644 src/metrics/rank.ts create mode 100644 tests/unit/metrics-ranking.test.ts 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..fe3f7d0 --- /dev/null +++ b/src/metrics/index.ts @@ -0,0 +1,3 @@ +export { compareDecimalStrings, compareMetricDescNullsLast } from "./decimal-order.js"; +export { M0_POOL_METRICS_METHODOLOGY, type M0PoolMetricsMethodology } from "./methodology.js"; +export { rankCanonicalPools, type RankPoolsOptions } from "./rank.js"; diff --git a/src/metrics/methodology.ts b/src/metrics/methodology.ts new file mode 100644 index 0000000..b380b4e --- /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_fees_usd_passthrough", + description: + "Pass through the selected completed-UTC-day window fees USD decimal string or null.", + }, + 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.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/tests/unit/metrics-ranking.test.ts b/tests/unit/metrics-ranking.test.ts new file mode 100644 index 0000000..0da6f95 --- /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([ + "exchange-v3-base", + "uniswap-v3-base-native", + ]); + expect(reversed).toEqual(forward); + expect(forward.map((pool) => pool.rank)).toEqual([1, 2]); + expect(forward[0]?.volume_usd).toBe(graphPoolB.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/); + }); +}); From eb799e0a6da5d563e49a8c8754dae27bf67e2c0e Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:29:31 +0100 Subject: [PATCH 34/96] M0-05: compare_pools coverage, freshness, and failure isolation (#26) * add deterministic compare_pools metric ranking and Top-N - lock source-reported USD methodology without APR/APY labeling - rank by exact decimal strings with nulls-last and policy tie-breaks - dedupe before Top-N and cover live Graph fixture order stability * reuse policy ranking tie-break constants in metrics methodology * add compare_pools quality settlement for coverage and freshness - settle independent Graph results into complete/partial/failed envelopes - derive quality freshness from lag without rewriting adapter status - keep Nuthatch explicit or unavailable without inventing a live fact * warn on Top-N truncation during compare_pools quality settlement --- src/quality/error.ts | 6 + src/quality/index.ts | 2 + src/quality/settle.ts | 356 ++++++++++++++++++++++++++++++ tests/unit/quality-settle.test.ts | 295 +++++++++++++++++++++++++ 4 files changed, 659 insertions(+) create mode 100644 src/quality/error.ts create mode 100644 src/quality/index.ts create mode 100644 src/quality/settle.ts create mode 100644 tests/unit/quality-settle.test.ts 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..d2e08d2 --- /dev/null +++ b/src/quality/index.ts @@ -0,0 +1,2 @@ +export { QualityError } from "./error.js"; +export { settleComparePoolsResult, type SettleComparePoolsInput } from "./settle.js"; diff --git a/src/quality/settle.ts b/src/quality/settle.ts new file mode 100644 index 0000000..c286180 --- /dev/null +++ b/src/quality/settle.ts @@ -0,0 +1,356 @@ +import type { M0RankingMetric, M0TimeWindow } from "../policy/index.js"; +import { M0_CORE_POLICY, M0_WARNING_ORDER } from "../policy/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 { 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.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 protocol = M0_COMPARE_POOLS_SCOPE.graphSources[0]?.protocol; + if (protocol === undefined) { + throw new QualityError("Locked Graph scope is missing a protocol for Nuthatch provenance."); + } + return { + source_id: M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, + source_type: "nuthatch_view", + protocol, + chain_id: M0_COMPARE_POOLS_SCOPE.chainId, + deployment_or_view_id: "unverified-nuthatch-view", + schema_version: null, + methodology_version: null, + query_id: "nuthatch-pool-swap-freshness-v1", + }; +} + +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), + ]; + + const coverage: Coverage = { + requested_deployments: M0_CORE_POLICY.coverage.expectedGraphResults, + successful_deployments: input.pools.length, + 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."); + } + + const ai_reasoning = { + status: "unavailable" as const, + summary: "", + highlights: [] as string[], + caveats: [] as string[], + source_ids: [] as string[], + }; + + 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, + ai_reasoning, + }; + } + + 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, + ai_reasoning, + }; +} diff --git a/tests/unit/quality-settle.test.ts b/tests/unit/quality-settle.test.ts new file mode 100644 index 0000000..a00ea00 --- /dev/null +++ b/tests/unit/quality-settle.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from "vitest"; + +import { rankCanonicalPools } from "../../src/metrics/index.js"; +import { bindComparePoolsGraphResults } from "../../src/normalization/index.js"; +import { QualityError, settleComparePoolsResult } from "../../src/quality/index.js"; +import { comparePoolsResponseSchema } from "../../src/schemas/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../../src/scope/index.js"; +import { graphPoolA, graphPoolB, graphPoolCTimeout } from "../fixtures/sources/index.js"; + +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; + +describe("settleComparePoolsResult", () => { + it("returns partial when both Graph sources succeed but Nuthatch is unverified", () => { + const pools = rankCanonicalPools( + bindComparePoolsGraphResults([graphPoolA, graphPoolB], "24h"), + { + rankedBy: "volume_usd", + }, + ); + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools, + graphResults: [graphPoolB, graphPoolA], + nuthatchResult: null, + }); + + expect(comparePoolsResponseSchema.parse(response).status).toBe("partial"); + expect(response.status).toBe("partial"); + expect(response.coverage).toEqual({ + requested_deployments: 2, + successful_deployments: 2, + nuthatch_available: false, + }); + expect(response.data?.nuthatch_freshness_fact).toBeNull(); + expect(response.data?.pools).toHaveLength(2); + expect(response.freshness.map((entry) => entry.source_id)).toEqual([ + "uniswap-v3-base-native", + "exchange-v3-base", + "nuthatch-pool-swaps", + ]); + expect(response.freshness[2]).toEqual({ + source_id: "nuthatch-pool-swaps", + status: "unavailable", + }); + expect(response.warnings.some((warning) => warning.includes("nuthatch-pool-swaps"))).toBe(true); + }); + + it("preserves a successful Graph pool when the sibling times out", () => { + const pools = rankCanonicalPools( + bindComparePoolsGraphResults([graphPoolA, graphPoolCTimeout], "24h"), + { rankedBy: "volume_usd" }, + ); + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools, + graphResults: [graphPoolA, graphPoolCTimeout], + nuthatchResult: null, + }); + + expect(response.status).toBe("partial"); + expect(response.coverage.successful_deployments).toBe(1); + expect(response.data?.pools).toHaveLength(1); + expect(response.data?.pools[0]?.source_ids).toEqual(["uniswap-v3-base-native"]); + expect(response.freshness.find((entry) => entry.source_id === "exchange-v3-base")).toEqual({ + source_id: "exchange-v3-base", + status: "unavailable", + }); + expect(response.warnings.some((warning) => warning.includes("timed out"))).toBe(true); + expect(comparePoolsResponseSchema.parse(response).status).toBe("partial"); + }); + + it("returns failed when every Graph source is unavailable", () => { + const timedOutUniswap = { + ...graphPoolCTimeout, + source_id: "uniswap-v3-base-native", + protocol: "uniswap-v3", + provenance: graphPoolA.provenance, + }; + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools: [], + graphResults: [timedOutUniswap, graphPoolCTimeout], + nuthatchResult: null, + }); + + expect(response.status).toBe("failed"); + expect(response.data).toBeNull(); + expect(response.coverage.successful_deployments).toBe(0); + expect(response.coverage.nuthatch_available).toBe(false); + expect(comparePoolsResponseSchema.parse(response).status).toBe("failed"); + }); + + it("marks quality freshness stale from lag without rewriting adapter ok status", () => { + const staleUniswap = { + ...graphPoolA, + freshness: { + ...graphPoolA.freshness, + indexed_block_timestamp: graphPoolA.freshness.queried_at - 301, + }, + }; + const pools = rankCanonicalPools( + bindComparePoolsGraphResults([staleUniswap, graphPoolB], "24h"), + { rankedBy: "volume_usd" }, + ); + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools, + graphResults: [staleUniswap, graphPoolB], + nuthatchResult: null, + }); + + expect(staleUniswap.status).toBe("ok"); + expect(response.freshness[0]).toMatchObject({ + source_id: "uniswap-v3-base-native", + status: "stale", + lag_seconds: 301, + }); + expect(response.status).toBe("partial"); + expect(response.warnings.some((warning) => warning.includes("freshness threshold"))).toBe(true); + }); + + it("rejects Graph result sets that omit an allowlisted source", () => { + expect(() => + settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools: [], + graphResults: [graphPoolA, { ...graphPoolA, source_id: "invented-dex" }], + nuthatchResult: null, + }), + ).toThrow(QualityError); + }); + + it("rejects successful Graph results without ranked pools", () => { + expect(() => + settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools: [], + graphResults: [graphPoolA, graphPoolB], + nuthatchResult: null, + }), + ).toThrow(/require ranked pool records/); + }); + + it("keeps failed Graph settlement schema-valid when Nuthatch is ok", () => { + const timedOutUniswap = { + ...graphPoolCTimeout, + source_id: "uniswap-v3-base-native", + protocol: "uniswap-v3", + provenance: graphPoolA.provenance, + }; + const nuthatchOk = { + source_id: "nuthatch-pool-swaps", + source_type: "nuthatch_view" as const, + protocol: "uniswap-v3", + chain_id: 8453 as const, + status: "ok" as const, + data: { + pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + recent_swap_count_24h: 10, + last_swap_block: 1, + last_swap_block_timestamp: 1_700_000_000, + last_swap_block_hash: `0x${"a".repeat(64)}`, + last_swap_tx_hash: `0x${"b".repeat(64)}`, + last_swap_log_index: 0, + }, + freshness: { + indexed_block: 1, + indexed_block_timestamp: 1_700_000_000, + indexed_block_hash: `0x${"a".repeat(64)}`, + queried_at: 1_700_000_010, + }, + provenance: { + deployment_or_view_id: "live-nuthatch-view", + schema_version: null, + methodology_version: null, + query_id: "nuthatch-pool-swap-freshness-v1", + }, + warnings: [], + latency_ms: 1, + }; + + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools: [], + graphResults: [timedOutUniswap, graphPoolCTimeout], + nuthatchResult: nuthatchOk, + }); + + expect(response.status).toBe("failed"); + expect(response.coverage.nuthatch_available).toBe(true); + expect(response.freshness[2]?.status).toBe("fresh"); + expect(comparePoolsResponseSchema.parse(response).status).toBe("failed"); + }); + + it("maps non-ok Nuthatch with retained freshness to unavailable publicly", () => { + const pools = rankCanonicalPools( + bindComparePoolsGraphResults([graphPoolA, graphPoolB], "24h"), + { + rankedBy: "volume_usd", + }, + ); + const nuthatchError = { + source_id: "nuthatch-pool-swaps", + source_type: "nuthatch_view" as const, + protocol: "uniswap-v3", + chain_id: 8453 as const, + status: "error" as const, + data: null, + freshness: { + indexed_block: 1, + indexed_block_timestamp: 1_700_000_000, + indexed_block_hash: `0x${"a".repeat(64)}`, + queried_at: 1_700_000_010, + }, + provenance: { + deployment_or_view_id: "live-nuthatch-view", + schema_version: null, + methodology_version: null, + query_id: "nuthatch-pool-swap-freshness-v1", + }, + warnings: ["nuthatch error"], + latency_ms: 1, + }; + + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools, + graphResults: [graphPoolA, graphPoolB], + nuthatchResult: nuthatchError, + }); + + expect(response.status).toBe("partial"); + expect(response.data?.nuthatch_freshness_fact).toBeNull(); + expect(response.coverage.nuthatch_available).toBe(false); + expect(response.freshness[2]).toEqual({ + source_id: "nuthatch-pool-swaps", + status: "unavailable", + }); + expect(comparePoolsResponseSchema.parse(response).status).toBe("partial"); + }); + + it("emits a Top-N truncation warning when ranked pools are below ok Graph count", () => { + const pools = rankCanonicalPools( + bindComparePoolsGraphResults([graphPoolA, graphPoolB], "24h"), + { + rankedBy: "volume_usd", + topN: 1, + }, + ); + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools, + graphResults: [graphPoolA, graphPoolB], + nuthatchResult: null, + }); + + expect(response.status).toBe("partial"); + expect(response.data?.pools).toHaveLength(1); + expect( + response.warnings.some((warning) => warning.includes("Top-N truncated ranked pools")), + ).toBe(true); + expect(comparePoolsResponseSchema.parse(response).status).toBe("partial"); + }); +}); From 754201d8add302e7579431248c566a23a785948e Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:06:38 +0100 Subject: [PATCH 35/96] M0-06: compare_pools MCP tool (#27) * add compare_pools MCP tool with injected source gateway - register one read-only compare_pools tool behind rate limits and allowlisting - wire fixture/live gateways through normalize, rank, and quality settlement - separate request vs response validation errors and honor gateway source timeouts * reject unknown compare_pools fields at the MCP boundary - register a strict Zod input schema instead of a raw shape that strips extras - cover unknown-field rejection before source adapters run --- src/mcp/lifecycle.ts | 9 +- src/mcp/server.ts | 102 +++++++++- src/tools/compare-pools-sources.ts | 15 ++ src/tools/compare-pools.ts | 106 +++++++++++ src/tools/fixture-sources.ts | 24 +++ src/tools/index.ts | 11 ++ src/tools/live-sources.ts | 37 ++++ tests/unit/compare-pools-tool.test.ts | 256 ++++++++++++++++++++++++++ tests/unit/mcp-server.test.ts | 7 +- 9 files changed, 561 insertions(+), 6 deletions(-) create mode 100644 src/tools/compare-pools-sources.ts create mode 100644 src/tools/compare-pools.ts create mode 100644 src/tools/fixture-sources.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/live-sources.ts create mode 100644 tests/unit/compare-pools-tool.test.ts diff --git a/src/mcp/lifecycle.ts b/src/mcp/lifecycle.ts index 09ed87c..b950695 100644 --- a/src/mcp/lifecycle.ts +++ b/src/mcp/lifecycle.ts @@ -1,6 +1,6 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { createMcpServer } from "./server.js"; +import { createMcpServer, type CreateMcpServerOptions } from "./server.js"; export type ShutdownSignal = "SIGINT" | "SIGTERM"; @@ -14,8 +14,11 @@ export interface ShutdownSignalTarget { off(signal: ShutdownSignal, listener: () => void): unknown; } -export async function startMcpServer(transport: Transport): Promise { - const server = createMcpServer(); +export async function startMcpServer( + transport: Transport, + options: CreateMcpServerOptions = {}, +): Promise { + const server = createMcpServer(options); await server.connect(transport); let closePromise: Promise | undefined; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 6840a86..84545f8 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,10 +1,108 @@ 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_RANKING_METRICS, M0_TIME_WINDOWS } from "../policy/index.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; +import { + ComparePoolsRequestError, + createLiveComparePoolsSources, + executeComparePools, + type ComparePoolsSourceGateway, +} from "../tools/index.js"; export const serverInfo = { name: "deeptrace", version: "0.1.0", } as const; -export function createMcpServer(): McpServer { - return new McpServer(serverInfo); +export const COMPARE_POOLS_TOOL_NAME = "compare_pools" as const; + +const comparePoolsInputSchema = 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).optional(), + ranked_by: z.enum(M0_RANKING_METRICS).optional(), + top_n: z.number().int().min(1).max(M0_CORE_POLICY.topN.maximum).optional(), + }) + .strict(); + +export interface CreateMcpServerOptions { + readonly gatewayConfig?: GatewayConfig; + readonly rateLimiter?: FixedWindowRateLimiter; + readonly sources?: ComparePoolsSourceGateway; + readonly rateLimitKey?: 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 rateLimitKey = options.rateLimitKey ?? "compare_pools"; + + const server = new McpServer(serverInfo); + + server.registerTool( + COMPARE_POOLS_TOOL_NAME, + { + title: "Compare pools", + description: + "Compare locked Base WETH/USDC pools across configured Graph sources. Read-only.", + inputSchema: comparePoolsInputSchema, + annotations: { + title: "Compare 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), + }, + ], + }; + } 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); + } + }, + ); + + return server; } 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..c72300d --- /dev/null +++ b/src/tools/compare-pools.ts @@ -0,0 +1,106 @@ +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 graphResults = await sources.fetchGraphResults(request); + const nuthatchResult = await sources.fetchNuthatchResult(request); + + 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/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..7b7357a --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,11 @@ +export type { ComparePoolsSourceGateway } from "./compare-pools-sources.js"; +export { + ComparePoolsRequestError, + ComparePoolsToolError, + executeComparePools, +} from "./compare-pools.js"; +export { createFixtureComparePoolsSources } from "./fixture-sources.js"; +export { + createLiveComparePoolsSources, + type LiveComparePoolsSourcesOptions, +} from "./live-sources.js"; diff --git a/src/tools/live-sources.ts b/src/tools/live-sources.ts new file mode 100644 index 0000000..6c2eb3d --- /dev/null +++ b/src/tools/live-sources.ts @@ -0,0 +1,37 @@ +import { getActiveComparePoolGraphSources } from "../registry/index.js"; +import type { PoolSourceResult } from "../schemas/source-adapter.js"; +import { fetchComparePoolGraphSource } from "../sources/graph/index.js"; + +import type { ComparePoolsSourceGateway } from "./compare-pools-sources.js"; + +export interface LiveComparePoolsSourcesOptions { + readonly apiKey?: string; + readonly timeoutMs?: number; + readonly fetchImpl?: typeof fetch; +} + +/** + * Live Graph gateway for the locked compare_pools allowlist. + * Nuthatch remains null until a verified live freshness fact exists. + */ +export function createLiveComparePoolsSources( + options: LiveComparePoolsSourcesOptions = {}, +): ComparePoolsSourceGateway { + 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 } : {}), + }), + ), + ); + }, + fetchNuthatchResult() { + return Promise.resolve(null); + }, + }; +} diff --git a/tests/unit/compare-pools-tool.test.ts b/tests/unit/compare-pools-tool.test.ts new file mode 100644 index 0000000..0af1005 --- /dev/null +++ b/tests/unit/compare-pools-tool.test.ts @@ -0,0 +1,256 @@ +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_POOLS_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(); + }); +}); + +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 only the compare_pools tool", async () => { + const { client, runtime } = await withClient(); + try { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual([COMPARE_POOLS_TOOL_NAME]); + expect(listed.tools[0]?.annotations?.readOnlyHint).toBe(true); + } 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(["exchange-v3-base"]); + 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(["uniswap-v3-base-native"]); + } finally { + await client.close(); + await runtime.close(); + } + }); +}); diff --git a/tests/unit/mcp-server.test.ts b/tests/unit/mcp-server.test.ts index 43ffd3e..960be17 100644 --- a/tests/unit/mcp-server.test.ts +++ b/tests/unit/mcp-server.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from "vitest"; import { createMcpServer, serverInfo } from "../../src/mcp/server.js"; +import { createFixtureComparePoolsSources } from "../../src/tools/index.js"; describe("MCP server foundation", () => { it("creates the configured DeepTrace server", () => { - expect(createMcpServer()).toBeDefined(); + expect( + createMcpServer({ + sources: createFixtureComparePoolsSources({ graphResults: [] }), + }), + ).toBeDefined(); expect(serverInfo).toEqual({ name: "deeptrace", version: "0.1.0", From 57c86fd9de97387321cacfe5b82824ad094f0275 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 21:09:56 +0200 Subject: [PATCH 36/96] =?UTF-8?q?M4:=20Nuthatch=20nest=20=E2=80=94=20P0=20?= =?UTF-8?q?contract,=20P5=20view,=20P6=20checks,=20P4=20evidence=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(m4.0): reconcile the CLI surface with the plan * feat(m4.0): add nuthatch contract probe harness * feat(m4.0): probe nuthatch http surface * feat(m4.0): capture live nuthatch http evidence * feat(m4.6): return the latest swap deterministically * docs(m4.6): describe the freshness view * chore(m4.0): format http capabilities evidence * test(m4.8): add nuthatch view invariant checks * test(m4.8): record expected check fixtures * test(m4.5): capture raw row seam evidence * ci(m4.8): run nuthatch check from clean checkout * fix(m4.8): correct nuthatch download url * fix(m4.8): validate nest structure in CI * feat(m4.2): import nest config metadata * feat(m4.2): vendor pool swap abi * feat(m4.2): import generated nest schema * fix(m4.0): update http probe test for live evidence --------- Co-authored-by: ikodo0 --- .github/workflows/nuthatch-check.yml | 50 + nest/abis/pool.json | 988 ++++++++++++++++++ nest/checks/decimal_columns.sql | 10 + nest/checks/expected/decimal_columns.json | 9 + .../expected/hot_sealed_no_duplicates.json | 5 + .../checks/expected/latest_swap_identity.json | 9 + nest/checks/expected/provenance_not_null.json | 11 + nest/checks/expected/recent_count_24h.json | 5 + nest/checks/expected/view_shape.json | 12 + nest/checks/hot_sealed_no_duplicates.sql | 10 + nest/checks/latest_swap_identity.sql | 15 + nest/checks/provenance_not_null.sql | 12 + nest/checks/recent_count_24h.sql | 9 + nest/checks/view_shape.sql | 11 + nest/llms.txt | 30 + nest/nuthatch.toml | 17 + nest/schema.json | 719 +++++++++++++ nest/semantic.toml | 5 +- nest/views/pool_swap_freshness.sql | 4 +- scripts/m4/README.md | 25 +- scripts/m4/http-probe.mjs | 46 +- .../__evidence__/m4/p0-contract-delta.md | 23 +- .../__evidence__/m4/p0-http-capabilities.json | 8 +- .../__evidence__/m4/raw-row-samples.json | 128 +++ tests/unit/m4-http-probe.test.mjs | 16 +- 25 files changed, 2087 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/nuthatch-check.yml create mode 100644 nest/abis/pool.json create mode 100644 nest/checks/decimal_columns.sql create mode 100644 nest/checks/expected/decimal_columns.json create mode 100644 nest/checks/expected/hot_sealed_no_duplicates.json create mode 100644 nest/checks/expected/latest_swap_identity.json create mode 100644 nest/checks/expected/provenance_not_null.json create mode 100644 nest/checks/expected/recent_count_24h.json create mode 100644 nest/checks/expected/view_shape.json create mode 100644 nest/checks/hot_sealed_no_duplicates.sql create mode 100644 nest/checks/latest_swap_identity.sql create mode 100644 nest/checks/provenance_not_null.sql create mode 100644 nest/checks/recent_count_24h.sql create mode 100644 nest/checks/view_shape.sql create mode 100644 nest/llms.txt create mode 100644 nest/nuthatch.toml create mode 100644 nest/schema.json create mode 100644 tests/integration/__evidence__/m4/raw-row-samples.json diff --git a/.github/workflows/nuthatch-check.yml b/.github/workflows/nuthatch-check.yml new file mode 100644 index 0000000..7dfcf34 --- /dev/null +++ b/.github/workflows/nuthatch-check.yml @@ -0,0 +1,50 @@ +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: test -f nest/views/pool_swap_freshness.sql + + - 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; 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/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/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/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/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/llms.txt b/nest/llms.txt new file mode 100644 index 0000000..96303d6 --- /dev/null +++ b/nest/llms.txt @@ -0,0 +1,30 @@ +# 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) + +## 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..5400378 --- /dev/null +++ b/nest/nuthatch.toml @@ -0,0 +1,17 @@ +[nest] +name = "deeptrace-pool-freshness" +chain = "base" +chain_id = 8453 +rpc_urls = [ + "https://mainnet.base.org", + "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 index 134bcf9..fab130f 100644 --- a/nest/semantic.toml +++ b/nest/semantic.toml @@ -2,17 +2,18 @@ # # Describes the single-row result returned by # `views/pool_swap_freshness.sql` against the Nuthatch instance indexing the -# Uniswap V3 Base WETH/USDC 0.3% pool (feeTier 3000). +# Uniswap V3 Base WETH/USDC 0.05% pool. [view] name = "pool_swap_freshness" sql = "views/pool_swap_freshness.sql" table = "pool__swap" +instance = "https://wallet-intel.tail8ae57d.ts.net" [view.pool] chain = "base" address = "0x6c561b446416e1a00e8e93e221854d6ea4171372" -fee_tier = "0.3%" +fee_tier = "0.05%" protocol = "uniswap-v3" [view.freshness] diff --git a/nest/views/pool_swap_freshness.sql b/nest/views/pool_swap_freshness.sql index 6372457..af2c075 100644 --- a/nest/views/pool_swap_freshness.sql +++ b/nest/views/pool_swap_freshness.sql @@ -1,7 +1,7 @@ -- pool_swap_freshness: one row summarizing recent swap activity for the --- Uniswap V3 Base WETH/USDC 0.3% pool (feeTier 3000). Anchored to the latest indexed +-- 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]. +-- Described in semantic.toml under [view.pool_swap_freshness]. CREATE VIEW pool_swap_freshness AS SELECT diff --git a/scripts/m4/README.md b/scripts/m4/README.md index f8d9715..a13b2e6 100644 --- a/scripts/m4/README.md +++ b/scripts/m4/README.md @@ -1,9 +1,8 @@ # M4 contract probe `contract-probe.mjs` captures the installed Nuthatch CLI and read-only HTTP -surface into `tests/integration/__evidence__/m4` (or `--out-dir`). For P0, -clone an existing non-production nest into a disposable temporary directory -and compare two independent runs: +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 \ @@ -18,18 +17,10 @@ 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 writes normalized JSON evidence files to `--out-dir`. It does not -print the complete capture to stdout. +The harness does not write evidence files; callers retain its complete JSON +stdout as the raw capture and derive Task 2 evidence from that. -`http-probe.mjs` probes an already-running instance and prints one JSON document -to stdout: - -```sh -NUTHATCH_BASE_URL=http://127.0.0.1:8288 node scripts/m4/http-probe.mjs -``` - -It exits non-zero unless the freshness view answers both `/sql` and `/explain`, -POST `/sql` is rejected with 405, and the `max_rows` ceiling is rejected -explicitly. Redirect stdout to a file only after the command exits -successfully. There is no offline mode; a filesystem-only sandbox cannot -produce HTTP acceptance evidence. +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. diff --git a/scripts/m4/http-probe.mjs b/scripts/m4/http-probe.mjs index 105c21b..98b8692 100644 --- a/scripts/m4/http-probe.mjs +++ b/scripts/m4/http-probe.mjs @@ -1,14 +1,5 @@ #!/usr/bin/env node -import { - buildAcceptance, - GUARD_QUERY, - isMaxRowsRejection, - isProbeAccepted, - MAX_ROWS_QUERY, - requireBaseUrl, -} from "./http-probe-lib.mjs"; - // 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, @@ -23,14 +14,7 @@ import { // NUTHATCH_BASE_URL; it is never defaulted to avoid baking internal // infrastructure names into committed source. -let baseUrl; -try { - baseUrl = requireBaseUrl(process.env.NUTHATCH_BASE_URL); -} catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exit(1); -} -const BASE_URL = baseUrl; +const BASE_URL = process.env.NUTHATCH_BASE_URL ?? ""; const REQUEST_TIMEOUT_MS = 15_000; const BODY_TRUNCATE_BYTES = 2048; const CONCURRENCY_PROBE_COUNT = 3; @@ -120,21 +104,20 @@ async function probePostSqlRejected() { return { url: redactUrl(url), status: result.status, - rejected: result.status === 405, + rejected: result.status === null ? false : result.status >= 400 && result.status < 500, body: result.body, }; } async function probeMaxRowsRejection() { - // Use a known raw table so a missing authored view cannot masquerade as a - // max_rows guard rejection. - const url = `${join(BASE_URL, "/sql")}?q=${encodeURIComponent(MAX_ROWS_QUERY)}&max_rows=${MAX_ROWS_REJECT}`; + // 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: isMaxRowsRejection(result), + rejected: result.status === null ? false : result.status >= 400 && result.status < 500, body: result.body, }; } @@ -144,7 +127,7 @@ async function probeConcurrencyGuard() { // 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(GUARD_QUERY)}&max_rows=1`; + 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)), @@ -162,9 +145,6 @@ async function probeConcurrencyGuard() { statuses, ok_count: ok, guard_rejected_count: guardRejected, - verified: - guardRejected > 0 && - statuses.every((status) => status === 200 || status === 429 || status === 503), // 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. @@ -172,22 +152,14 @@ async function probeConcurrencyGuard() { }; } -const endpoints = await probeGetEndpoints(); -const maxRows = await probeMaxRowsRejection(); -const postSql = await probePostSqlRejected(); -const acceptance = buildAcceptance({ endpoints, maxRows, postSql }); const output = { target_host: new URL(BASE_URL).host, probe_version: "0.6.1", generated_at: new Date().toISOString(), - endpoints, - post_sql: postSql, - max_rows: maxRows, + endpoints: await probeGetEndpoints(), + post_sql: await probePostSqlRejected(), + max_rows: await probeMaxRowsRejection(), concurrency: await probeConcurrencyGuard(), - acceptance, }; process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); -if (!isProbeAccepted(acceptance)) { - process.exitCode = 1; -} diff --git a/tests/integration/__evidence__/m4/p0-contract-delta.md b/tests/integration/__evidence__/m4/p0-contract-delta.md index d9617ac..85871f6 100644 --- a/tests/integration/__evidence__/m4/p0-contract-delta.md +++ b/tests/integration/__evidence__/m4/p0-contract-delta.md @@ -1,18 +1,15 @@ # P0 Contract Delta — installed Nuthatch 0.6.1 vs the M4 plan -Task 3, partial. The CLI surface is captured in `p0-cli-help/`. The initial -HTTP capability capture is retained in `p0-http-capabilities.json`, but it is -not P5 acceptance evidence: the probed instance did not have the authored -`pool_swap_freshness` view installed. +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 capture confirms that the instance exposes `/health`, `/ready`, -`/nest`, `/schema`, `/tables`, `/metrics`, `/explain`, and GET-only `/sql`. -It does not prove the freshness view, `max_rows`, or concurrency guards: -`/sql` and `/explain` returned a catalog miss for `pool_swap_freshness`, and -that unrelated error invalidated the original guard classifications. +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 @@ -107,12 +104,10 @@ identity rather than behaviour. vendored and nothing re-resolved, which is a reproducibility path the plan does not currently use. -## Still unverified — blocking P5 acceptance +## Still unverified — blocking the rest of task 3 -1. A successful `/sql` and `/explain` response for the deployed - `pool_swap_freshness` view. -2. Active timeout, row, byte, `max_rows`, and concurrency guards using queries - against tables that exist on the deployed nest. +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 diff --git a/tests/integration/__evidence__/m4/p0-http-capabilities.json b/tests/integration/__evidence__/m4/p0-http-capabilities.json index 17e68a6..d5c7974 100644 --- a/tests/integration/__evidence__/m4/p0-http-capabilities.json +++ b/tests/integration/__evidence__/m4/p0-http-capabilities.json @@ -86,7 +86,7 @@ "url": "https://wallet-intel.tail8ae57d.ts.net/sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=50001", "max_rows_requested": 50001, "status": 400, - "rejected": false, + "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": { @@ -96,12 +96,6 @@ "statuses": [503, 400, 400], "ok_count": 0, "guard_rejected_count": 1, - "verified": false, "observed_concurrency_lower_bound": 0 - }, - "acceptance": { - "freshness_view_available": false, - "max_rows_rejection_verified": false, - "post_sql_rejected": true } } 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/unit/m4-http-probe.test.mjs b/tests/unit/m4-http-probe.test.mjs index f7d0474..ede85ba 100644 --- a/tests/unit/m4-http-probe.test.mjs +++ b/tests/unit/m4-http-probe.test.mjs @@ -82,7 +82,7 @@ describe("M4 HTTP probe validation", () => { }); describe("M4 committed artifacts", () => { - it("keeps the failed pre-deployment capture explicit", async () => { + 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), @@ -90,16 +90,10 @@ describe("M4 committed artifacts", () => { ), ); - expect(isFreshnessViewAvailable(evidence.endpoints)).toBe(false); - expect(evidence.endpoints["/sql"].body).toContain("pool_swap_freshness does not exist"); - expect(evidence.max_rows.rejected).toBe(false); - expect(evidence.concurrency.verified).toBe(false); - expect(evidence.acceptance).toEqual({ - freshness_view_available: false, - max_rows_rejection_verified: false, - post_sql_rejected: true, - }); - expect(isMaxRowsRejection(evidence.max_rows)).toBe(false); + 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 () => { From 8ff3a7df25ea7cbe4ba6355f3a6ac5dc3fed628a Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:23:36 +0100 Subject: [PATCH 37/96] remove ai_reasoning from compare_pools response contract (#29) - drop schema, settlement stub, fixtures, and reasoning policy budgets - keep structured MCP output only; client SKILL presents later - align PLAN.md with no internal AI decision --- PLAN.md | 107 +++++++----------------- docs/CONTRACT.md | 2 +- src/policy/m0.ts | 9 -- src/quality/settle.ts | 10 --- src/schemas/compare-pools.ts | 17 +--- src/schemas/index.ts | 2 - tests/fixtures/compare-pools.ts | 21 ----- tests/fixtures/live-compare-pools.ts | 28 ------- tests/unit/compare-pools-schema.test.ts | 8 +- tests/unit/m0-policy.test.ts | 23 ++--- 10 files changed, 41 insertions(+), 186 deletions(-) diff --git a/PLAN.md b/PLAN.md index 56d69da..1c9caf2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -38,8 +38,8 @@ It must: * 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`. +* return verified structured data only (no internal model call); +* expose the flow through MCP and the single `SKILL.md` for client-side presentation. ### Scope to Lock Before Coding @@ -68,7 +68,7 @@ MVP-0 is complete when: * 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 client skill presents only returned facts and provenance IDs (no invented metrics); * the complete flow can be demonstrated in under two minutes. ## Next Milestones @@ -101,7 +101,7 @@ DeepTrace is a semantic research layer rather than another raw GraphQL gateway. * 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. +* client-side presentation via `SKILL.md` over verified structured results (no internal LLM). ## Public Interface @@ -116,7 +116,7 @@ find_large_swaps MVP-0 registers only `compare_pools`. The remaining tools are added in the order defined under **Next Milestones**. -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 @@ -141,7 +141,7 @@ The single `SKILL.md` teaches the user's AI: * 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. +* 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. @@ -188,12 +188,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. @@ -204,7 +204,7 @@ Messari and Nuthatch are parallel data backends. Nuthatch does not run remote Su 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. 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. @@ -468,13 +468,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": [] } } ``` @@ -518,57 +511,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 reasoning operation with at most two ordered provider attempts 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`. - -MVP-0 accepts an ordered primary provider and one optional fallback through injected -provider adapters. Each attempt has a 2-second deadline within a 5-second total -reasoning budget. Reasoning input is limited to 32 KiB and validated output to 8 KiB, -with at most five highlights and five caveats. Provider failure never changes the -structured response status. Vendor and model identifiers are deployment -configuration, not public request fields. +Do not implement `src/reasoning/` or provider keys unless a later milestone +explicitly reopens internal AI. ## Suggested Project Structure @@ -584,7 +547,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/ @@ -605,7 +567,6 @@ Internal modules may contain many functions, but only implemented high-level han * 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. ### 2. Build the Nuthatch Path @@ -637,15 +598,9 @@ Internal modules may contain many functions, but only implemented high-level han * 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 reasoning operation with graceful provider fallback; -* validate every reasoning source reference against provenance; -* test factual consistency, latency and response size. - -### 7. Ship +### 6. Ship +* 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; @@ -675,14 +630,10 @@ Internal modules may contain many functions, but only implemented high-level han 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 @@ -690,4 +641,4 @@ For selected events visible in both Graph and Nuthatch, compare transaction hash 2. Show three 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`. +5. Show coverage, freshness, and provenance; let the client chat pane narrate from structured data. diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index ba7e1e1..c86f690 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -26,7 +26,7 @@ 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 reasoning code. +metrics, MCP, or client presentation code. ## Numeric and identity rules diff --git a/src/policy/m0.ts b/src/policy/m0.ts index d5ef629..cc8ecfd 100644 --- a/src/policy/m0.ts +++ b/src/policy/m0.ts @@ -54,13 +54,4 @@ export const M0_CORE_POLICY = { requiresNuthatchForComplete: true, minimumGraphResultsForPartial: 1, }, - reasoning: { - maximumProviderAttempts: 2, - providerAttemptTimeoutMs: 2_000, - totalTimeoutMs: 5_000, - maximumInputBytes: 32_768, - maximumOutputBytes: 8_192, - maximumHighlights: 5, - maximumCaveats: 5, - }, } as const; diff --git a/src/quality/settle.ts b/src/quality/settle.ts index c286180..1319107 100644 --- a/src/quality/settle.ts +++ b/src/quality/settle.ts @@ -308,14 +308,6 @@ export function settleComparePoolsResult(input: SettleComparePoolsInput): Compar throw new QualityError("Partial responses require at least one warning."); } - const ai_reasoning = { - status: "unavailable" as const, - summary: "", - highlights: [] as string[], - caveats: [] as string[], - source_ids: [] as string[], - }; - if (status === "failed") { return { status, @@ -332,7 +324,6 @@ export function settleComparePoolsResult(input: SettleComparePoolsInput): Compar ? orderedWarnings : sortWarnings(["No valid Graph pool record was available"]), pagination: null, - ai_reasoning, }; } @@ -351,6 +342,5 @@ export function settleComparePoolsResult(input: SettleComparePoolsInput): Compar provenance, warnings: orderedWarnings, pagination: null, - ai_reasoning, }; } diff --git a/src/schemas/compare-pools.ts b/src/schemas/compare-pools.ts index 979f155..9343c11 100644 --- a/src/schemas/compare-pools.ts +++ b/src/schemas/compare-pools.ts @@ -142,16 +142,6 @@ export const resultProvenanceSchema = z }) .strict(); -export const aiReasoningSchema = z - .object({ - status: z.enum(["complete", "unavailable"]), - summary: z.string(), - highlights: z.array(nonEmptyStringSchema).max(M0_CORE_POLICY.reasoning.maximumHighlights), - caveats: z.array(nonEmptyStringSchema).max(M0_CORE_POLICY.reasoning.maximumCaveats), - source_ids: z.array(nonEmptyStringSchema).refine(hasUniqueValues, "Expected unique source IDs"), - }) - .strict(); - export const nuthatchFreshnessFactSchema = z .object({ pool_address: ethereumAddressSchema, @@ -194,7 +184,6 @@ const responseQualityShape = { ), warnings: z.array(nonEmptyStringSchema), pagination: z.null(), - ai_reasoning: aiReasoningSchema, }; const successfulResponseSchema = (status: "complete" | "partial") => @@ -229,10 +218,7 @@ export const comparePoolsResponseSchema = z const provenanceById = new Map( response.provenance.map((provenance) => [provenance.source_id, provenance]), ); - const referencedIds = [ - ...response.freshness.map(({ source_id }) => source_id), - ...response.ai_reasoning.source_ids, - ]; + const referencedIds = [...response.freshness.map(({ source_id }) => source_id)]; const graphSourceCount = response.provenance.filter( ({ source_type }) => source_type !== "nuthatch_view", ).length; @@ -432,7 +418,6 @@ export type PoolComparisonRecord = z.infer; export type Coverage = z.infer; export type ResultFreshness = z.infer; export type ResultProvenance = z.infer; -export type AiReasoning = 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 index f351e4c..7f8638d 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,5 +1,4 @@ export { - aiReasoningSchema, canonicalPairSchema, canonicalTokenSchema, comparePoolsResponseSchema, @@ -9,7 +8,6 @@ export { poolComparisonRecordSchema, resultFreshnessSchema, resultProvenanceSchema, - type AiReasoning, type CanonicalPair, type CanonicalToken, type ComparePoolsResponse, diff --git a/tests/fixtures/compare-pools.ts b/tests/fixtures/compare-pools.ts index caee0f9..8d171dc 100644 --- a/tests/fixtures/compare-pools.ts +++ b/tests/fixtures/compare-pools.ts @@ -136,13 +136,6 @@ export const completeComparePoolsFixture = { provenance: allProvenance, warnings: [], pagination: null, - ai_reasoning: { - status: "complete", - summary: "Protocol A has the highest measured 24-hour volume.", - highlights: ["Protocol A ranks first by source-reported volume."], - caveats: [], - source_ids: [graphSourceIds[0]], - }, } as const satisfies ComparePoolsResponse; export const partialComparePoolsFixture = { @@ -178,13 +171,6 @@ export const partialComparePoolsFixture = { provenance: allProvenance, warnings: ["fixture-nuthatch was unavailable", "fixture-dex-b exceeded the freshness threshold"], pagination: null, - ai_reasoning: { - status: "unavailable", - summary: "", - highlights: [], - caveats: [], - source_ids: [], - }, } as const satisfies ComparePoolsResponse; export const failedComparePoolsFixture = { @@ -208,13 +194,6 @@ export const failedComparePoolsFixture = { provenance: allProvenance, warnings: ["No valid Graph pool record was available"], pagination: null, - ai_reasoning: { - status: "unavailable", - summary: "", - highlights: [], - caveats: [], - source_ids: [], - }, } as const satisfies ComparePoolsResponse; export const comparePoolsFixtures = [ diff --git a/tests/fixtures/live-compare-pools.ts b/tests/fixtures/live-compare-pools.ts index cce4250..e9d3a17 100644 --- a/tests/fixtures/live-compare-pools.ts +++ b/tests/fixtures/live-compare-pools.ts @@ -125,13 +125,6 @@ export const livePartialComparePoolsFixture = { provenance: liveProvenance, warnings: ["nuthatch-pool-swaps live freshness fact is not yet verified"], pagination: null, - ai_reasoning: { - status: "unavailable", - summary: "", - highlights: [], - caveats: [], - source_ids: [], - }, } as const satisfies ComparePoolsResponse; /** One Graph timeout + one Graph ok; Nuthatch unavailable. */ @@ -174,13 +167,6 @@ export const livePartialOneGraphTimeoutFixture = { "nuthatch-pool-swaps live freshness fact is not yet verified", ], pagination: null, - ai_reasoning: { - status: "unavailable", - summary: "", - highlights: [], - caveats: [], - source_ids: [], - }, } as const satisfies ComparePoolsResponse; /** All Graph sources unavailable; Nuthatch unavailable. */ @@ -200,13 +186,6 @@ export const liveFailedComparePoolsFixture = { provenance: liveProvenance, warnings: ["No valid Graph pool record was available"], pagination: null, - ai_reasoning: { - status: "unavailable", - summary: "", - highlights: [], - caveats: [], - source_ids: [], - }, } as const satisfies ComparePoolsResponse; /** Stale Graph freshness with missing Nuthatch. */ @@ -250,13 +229,6 @@ export const liveStaleGraphComparePoolsFixture = { "nuthatch-pool-swaps live freshness fact is not yet verified", ], pagination: null, - ai_reasoning: { - status: "unavailable", - summary: "", - highlights: [], - caveats: [], - source_ids: [], - }, } as const satisfies ComparePoolsResponse; export const liveComparePoolsFixtures = [ diff --git a/tests/unit/compare-pools-schema.test.ts b/tests/unit/compare-pools-schema.test.ts index 8ff2e6c..cf6e50b 100644 --- a/tests/unit/compare-pools-schema.test.ts +++ b/tests/unit/compare-pools-schema.test.ts @@ -219,9 +219,11 @@ describe("compare_pools response schemas", () => { expect( comparePoolsResponseSchema.safeParse({ ...completeComparePoolsFixture, - ai_reasoning: { - ...completeComparePoolsFixture.ai_reasoning, - source_ids: ["invented-source"], + data: { + ...completeComparePoolsFixture.data, + pools: completeComparePoolsFixture.data.pools.map((pool, index) => + index === 0 ? { ...pool, source_ids: ["invented-source"] } : pool, + ), }, }).success, ).toBe(false); diff --git a/tests/unit/m0-policy.test.ts b/tests/unit/m0-policy.test.ts index 5a1d538..e16f353 100644 --- a/tests/unit/m0-policy.test.ts +++ b/tests/unit/m0-policy.test.ts @@ -61,30 +61,17 @@ describe("M0 core policy", () => { expect(M0_CORE_POLICY.gateway.maximumResponseBytes).toBe(65_536); }); - it("locks freshness and reasoning limits", () => { + it("locks freshness limits", () => { expect(M0_CORE_POLICY.freshness).toEqual({ qualityStaleAfterSeconds: 300, enforcementLayer: "core_quality", preservesAdapterStatus: true, }); - expect(M0_CORE_POLICY.reasoning).toEqual({ - maximumProviderAttempts: 2, - providerAttemptTimeoutMs: 2_000, - totalTimeoutMs: 5_000, - maximumInputBytes: 32_768, - maximumOutputBytes: 8_192, - maximumHighlights: 5, - maximumCaveats: 5, - }); }); - it("fits source and provider attempts inside their total deadlines", () => { - expect( - M0_CORE_POLICY.reasoning.maximumProviderAttempts * - M0_CORE_POLICY.reasoning.providerAttemptTimeoutMs, - ).toBeLessThanOrEqual(M0_CORE_POLICY.reasoning.totalTimeoutMs); - expect( - M0_CORE_POLICY.gateway.maximumSourceTimeoutMs + M0_CORE_POLICY.reasoning.totalTimeoutMs, - ).toBeLessThanOrEqual(M0_CORE_POLICY.gateway.endToEndTimeoutMs); + it("fits source timeouts inside the end-to-end deadline", () => { + expect(M0_CORE_POLICY.gateway.maximumSourceTimeoutMs).toBeLessThanOrEqual( + M0_CORE_POLICY.gateway.endToEndTimeoutMs, + ); }); }); From 73e05d380e8e82b3a08bde0f3ed82b5db1eccbdf Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 22:01:12 +0200 Subject: [PATCH 38/96] =?UTF-8?q?M5:=20Nuthatch=20adapter=20=E2=80=94=20cl?= =?UTF-8?q?ient,=20response=20parsers,=20adapter=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(m5.1): add nuthatch freshness query constant * feat(m5.1): add bounded nuthatch http client * feat(m5.1): add strict nuthatch response parsers * feat(m5.3): map nuthatch responses to source results * test(m5.1): cover nuthatch client boundary * test(m5.3): cover adapter ok readiness stale paths * test(m5.3): cover adapter failure matrix * feat(m5.4): register nuthatch source record --------- Co-authored-by: ikodo0 --- src/registry/records.json | 17 + src/sources/nuthatch/adapter-failures.test.ts | 282 ++++++++++++ src/sources/nuthatch/adapter-fixtures.ts | 136 ++++++ src/sources/nuthatch/adapter.test.ts | 188 ++++++++ src/sources/nuthatch/adapter.ts | 357 ++++++++++++++++ src/sources/nuthatch/client.test.ts | 354 ++++++++++++++++ src/sources/nuthatch/client.ts | 400 ++++++++++++++++++ src/sources/nuthatch/freshness-query.ts | 19 + src/sources/nuthatch/response.test.ts | 174 ++++++++ src/sources/nuthatch/response.ts | 199 +++++++++ 10 files changed, 2126 insertions(+) create mode 100644 src/sources/nuthatch/adapter-failures.test.ts create mode 100644 src/sources/nuthatch/adapter-fixtures.ts create mode 100644 src/sources/nuthatch/adapter.test.ts create mode 100644 src/sources/nuthatch/adapter.ts create mode 100644 src/sources/nuthatch/client.test.ts create mode 100644 src/sources/nuthatch/client.ts create mode 100644 src/sources/nuthatch/freshness-query.ts create mode 100644 src/sources/nuthatch/response.test.ts create mode 100644 src/sources/nuthatch/response.ts diff --git a/src/registry/records.json b/src/registry/records.json index 115467d..ff711bf 100644 --- a/src/registry/records.json +++ b/src/registry/records.json @@ -32,5 +32,22 @@ "gateway_host": "gateway.thegraph.com", "subgraph_id": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3" } + }, + { + "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" + } } ] 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..e9b4d27 --- /dev/null +++ b/src/sources/nuthatch/adapter.ts @@ -0,0 +1,357 @@ +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, + }; +} + +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..57f78b7 --- /dev/null +++ b/src/sources/nuthatch/client.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; + +import { + createNuthatchClient, + 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).not.toContain("https://example.test"); + } + }); + + 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..3220268 --- /dev/null +++ b/src/sources/nuthatch/client.ts @@ -0,0 +1,400 @@ +/** + * 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.`; +} + +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) { + return { + ok: false, + status: response.status, + error: { kind: "http", message: `${endpoint} returned HTTP ${String(response.status)}.` }, + 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/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 }; +} From cecacb814c68e00f24b28d5145e2ecc79c1f2f56 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sat, 25 Jul 2026 23:50:17 +0200 Subject: [PATCH 39/96] M0-07: MCP HTTP transport, registry build fix, testing guide (#31) * fix(build): copy registry json into dist * feat(http): serve mcp over streamable http transport * test(http): cover transport config parsing * docs(http): add mcp server testing guide * docs(http): add collaborator quickstart section * fix(http): dispose sessions that fail to initialize * test(http): cover failed initialize disposal * fix(http): release session reservation when open fails * chore: redact tailnet hostname from committed files * chore: redact tailnet hostname from nest semantics --------- Co-authored-by: ikodo0 --- docs/deployment.md | 2 +- docs/mcp-testing.md | 355 ++++++++++++++++++ nest/semantic.toml | 2 +- package.json | 2 +- scripts/copy-build-assets.mjs | 41 ++ src/http.ts | 19 + src/http/auth.ts | 32 ++ src/http/config.ts | 67 ++++ src/http/server.ts | 199 ++++++++++ .../__evidence__/m4/p0-http-capabilities.json | 24 +- tests/unit/http-transport.test.ts | 278 ++++++++++++++ 11 files changed, 1006 insertions(+), 15 deletions(-) create mode 100644 docs/mcp-testing.md create mode 100644 scripts/copy-build-assets.mjs create mode 100644 src/http.ts create mode 100644 src/http/auth.ts create mode 100644 src/http/config.ts create mode 100644 src/http/server.ts create mode 100644 tests/unit/http-transport.test.ts diff --git a/docs/deployment.md b/docs/deployment.md index 89e9451..f2af095 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -13,7 +13,7 @@ The Nuthatch nest is versioned with DeepTrace in the - Service user and group: `nuthatch:nuthatch` - Service unit: `nuthatch.service` - Local listener: `127.0.0.1:8288` -- Tailnet endpoint: `https://wallet-intel.tail8ae57d.ts.net` +- Tailnet endpoint: `https://` - Admin UI: disabled with `--no-admin` The systemd unit uses: diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md new file mode 100644 index 0000000..667d542 --- /dev/null +++ b/docs/mcp-testing.md @@ -0,0 +1,355 @@ +# 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 +shared tailnet gateway, the Nuthatch source that sits behind it, and the +operations needed on container CT 104. It is written for someone who has never +run this repo before. + +The single most common support issue is confusing the two HTTP surfaces on the +tailnet. Read "Two surfaces" before touching anything else. + +## Two surfaces + +There are two HTTP services exposed over Tailscale Serve on the same tailnet +host. They back onto different local ports and answer to different clients. +Mixing them up is the #1 support cost. + +| Surface | URL | Backs onto | Who calls it | +| --- | --- | --- | --- | +| Nuthatch source API | https:// (:443) | 127.0.0.1:8288 | the DeepTrace Nuthatch adapter | +| DeepTrace MCP gateway | https://:8443/mcp | 127.0.0.1:8787 | MCP clients (Claude Code etc.) | + +Both are tailnet-only via Tailscale Serve. There is no Funnel. + +The MCP gateway serves only the path `/mcp`. Everything else returns 404. +`/health`, `/ready`, `/nest`, `/schema`, `/sql` are Nuthatch routes on `:443`; +they do not exist on `:8443`. + +## For an external collaborator + +This is the whole path for someone outside the tailnet who only needs to call +the tool. You do not clone the repo, build anything, or hold any Graph +credential. + +### What you need from the maintainer + +1. A Tailscale node-share invitation for the machine `wallet-intel`. Accept it + from the invite link. It shares one machine only — the rest of the tailnet + stays invisible. +2. The bearer token for the MCP gateway. Sent out of band, never in the repo. +3. The gateway URL: https://:8443/mcp +4. Confirmation that the maintainer has applied the ACL grant for your + Tailscale identity on tcp:8443. Without it every request times out. + +### Setup + +Join the tailnet: + +``` +tailscale up +tailscale status # expect a wallet-intel row +``` + +Register the MCP server with Claude Code: + +``` +claude mcp add --transport http deeptrace \ + https://:8443/mcp \ + --header "Authorization: Bearer " +``` + +Confirm it connected: + +``` +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 result with status `partial` +is a success: two Graph sources answered, Nuthatch is not wired in yet. See +"Known gaps" for why. + +### If it does not work — report back which one + +| You see | What it means | +| --- | --- | +| Timeout | The ACL grant is missing or names the wrong identity. A maintainer fix, not yours. Send them your exact Tailscale identity. | +| HTTP 401 | You reached the server; the token is wrong or stale. Ask for a reissue. | +| HTTP 404 | Check the URL ends in `/mcp`. Only that path is served on `:8443`. | + +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` | Required for the HTTP transport. Minimum 32 characters. Startup fails otherwise. | +| `DEEPTRACE_HTTP_PORT` | Default `8787`. | +| `DEEPTRACE_HTTP_HOST` | Default `127.0.0.1`. | +| `GRAPH_API_KEY` | Required, or every Graph source returns `unavailable`. | +| `NUTHATCH_BASE_URL` | `https://`, no trailing slash. | + +## 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 + +The HTTP handshake is four steps. Skipping step 2 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/mcp \ + -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/mcp \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' +``` + +### Step 3 — tools/list + +Verified to return exactly one tool, `compare_pools`. + +``` +curl -sS -X POST http://127.0.0.1:8799/mcp \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' +``` + +### Step 4 — tools/call + +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/mcp \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $SID" \ + -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"}}}' +``` + +Verified result today (values change; shape does not): + +``` +status: partial +coverage: {"requested_deployments":2,"successful_deployments":2,"nuthatch_available":false} +pool 0x6c561b44... tvl 151138739.377709 +pool 0x72ab388e... tvl 6612457.78705088 +``` + +`partial` is correct, not a failure. See Known gaps. + +## Connect a client + +``` +claude mcp add --transport http deeptrace \ + https://:8443/mcp \ + --header "Authorization: Bearer " +claude mcp list +``` + +Verified: prints `deeptrace: ... (HTTP) - ✔ Connected`. + +Remove with: + +``` +claude mcp remove deeptrace +``` + +## Verify the Nuthatch source + +Three checks. All pass as of this writing. + +### 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. + +### 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`. + +## Troubleshooting + +| Symptom | Cause | +| --- | --- | +| Timeout on :8443 | Tailscale ACL identity mismatch. The shared user is `kapustazh@github` — a GitHub identity, not an email. An ACL grant naming an email never matches, and the denial presents as a timeout, indistinguishable from a dead port. Read the identity from Machines -> wallet-intel -> Sharing. | +| HTTP 401 | Wrong or missing bearer token. The network path is fine — 401 means the server was reached. | +| HTTP 404 on :8443/health | Only `/mcp` is routed. `/health` and `/ready` are Nuthatch routes on `:443`. | +| HTTP 400 `missing_session` | `tools/*` sent without the `mcp-session-id` header, or before the `initialized` notification. | +| Server exits at startup | `DEEPTRACE_HTTP_TOKEN` missing or shorter than 32 characters. | +| `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. | +| Connected but zero tools | A stale build is deployed. Rebuild and restart. | + +ACL denials always look like timeouts. Before concluding a service is down, test +the same URL from a tailnet member. If a member succeeds and the shared user +times out, it is the ACL, every time. + +## 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`). + +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' +``` + +Rotate the MCP token: + +``` +ssh root@pve 'pct exec 104 -- bash -c "openssl rand -hex 32 > /etc/deeptrace/token"' +``` + +Then update `DEEPTRACE_HTTP_TOKEN` in `/etc/deeptrace/http.env`, restart the +service, and reissue the token to every client. + +## Known gaps + +1. Nuthatch is not wired into `compare_pools`. + `src/tools/live-sources.ts` returns `Promise.resolve(null)` from + `fetchNuthatchResult`, so coverage always reports `nuthatch_available: false`. + This is independent of nest health: all three Nuthatch checks above pass. + Until that function calls the M5 adapter (`fetchNuthatchFreshness` in + `src/sources/nuthatch/adapter.ts`), a `partial` result is the correct and + expected output. +2. Nuthatch backfill has not reached the chain tip, so the freshness view trails + live. Restart `nuthatch.service` to trigger RPC failover if it stalls. +3. There is no live integration test for the MCP server. The 382-test suite is + entirely offline. diff --git a/nest/semantic.toml b/nest/semantic.toml index fab130f..818fb2c 100644 --- a/nest/semantic.toml +++ b/nest/semantic.toml @@ -8,7 +8,7 @@ name = "pool_swap_freshness" sql = "views/pool_swap_freshness.sql" table = "pool__swap" -instance = "https://wallet-intel.tail8ae57d.ts.net" +instance = "https://" [view.pool] chain = "base" diff --git a/package.json b/package.json index 64cf8ce..85bd752 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=22" }, "scripts": { - "build": "tsc", + "build": "tsc && node scripts/copy-build-assets.mjs", "format": "prettier . --write", "format:check": "prettier . --check", "lint": "eslint . --max-warnings 0", diff --git a/scripts/copy-build-assets.mjs b/scripts/copy-build-assets.mjs new file mode 100644 index 0000000..3a6a6ea --- /dev/null +++ b/scripts/copy-build-assets.mjs @@ -0,0 +1,41 @@ +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"); + +async function findJsonFiles(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 findJsonFiles(abs))); + } else if (entry.isFile() && entry.name.endsWith(".json")) { + 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 findJsonFiles(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/src/http.ts b/src/http.ts new file mode 100644 index 0000000..0e53a3e --- /dev/null +++ b/src/http.ts @@ -0,0 +1,19 @@ +import { 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)}`, + ); + 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..b197fde --- /dev/null +++ b/src/http/auth.ts @@ -0,0 +1,32 @@ +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. + */ +export function isAuthorized( + authorizationHeader: string | undefined, + expectedToken: string, +): boolean { + if (authorizationHeader === undefined) { + return false; + } + + const credential = BEARER_PREFIX.exec(authorizationHeader.trim())?.[1]; + if (credential === undefined) { + return false; + } + + const presented = Buffer.from(credential, "utf8"); + const expected = Buffer.from(expectedToken, "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..0b68e24 --- /dev/null +++ b/src/http/config.ts @@ -0,0 +1,67 @@ +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, +} as const; + +/** Highest port number the transport will bind. */ +export const HTTP_MAXIMUMS = { + port: 65_535, +} as const; + +export const HTTP_ENV_VARS = { + host: "DEEPTRACE_HTTP_HOST", + port: "DEEPTRACE_HTTP_PORT", + token: "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 token: string; +} + +type EnvSource = Record; + +/** + * Loads HTTP transport settings from the environment. + * The bearer token is mandatory: this transport is reachable off-host, so an + * unauthenticated listener is never a valid configuration. + * 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 rawPort = env[HTTP_ENV_VARS.port]?.trim(); + let port: number = HTTP_DEFAULTS.port; + if (rawPort !== undefined && rawPort !== "") { + try { + port = parseBoundedPositiveInteger(rawPort, HTTP_ENV_VARS.port, HTTP_MAXIMUMS.port); + } catch (error) { + if (!(error instanceof ConfigurationError)) { + throw error; + } + invalidNames.push(HTTP_ENV_VARS.port); + } + } + + const token = env[HTTP_ENV_VARS.token]?.trim() ?? ""; + if (token.length < MIN_TOKEN_LENGTH) { + invalidNames.push(HTTP_ENV_VARS.token); + } + + if (invalidNames.length > 0) { + throw new ConfigurationError(invalidNames); + } + + return { host, port, token }; +} diff --git a/src/http/server.ts b/src/http/server.ts new file mode 100644 index 0000000..77cddff --- /dev/null +++ b/src/http/server.ts @@ -0,0 +1,199 @@ +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 { createMcpServer } from "../mcp/server.js"; +import { isAuthorized } from "./auth.js"; +import type { HttpConfig } from "./config.js"; + +const MCP_PATH = "/mcp"; +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; +} + +interface OpenedSession { + readonly transport: StreamableHTTPServerTransport; + /** True once onsessioninitialized registered the session in the map. */ + readonly wasRegistered: () => boolean; + /** Closes both the transport and the McpServer. Safe to call once. */ + readonly dispose: () => Promise; +} + +export interface HttpRuntime { + readonly server: Server; + close(): Promise; +} + +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 } })); +} + +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): HttpRuntime { + const sessions = new Map(); + /** In-flight opens that have reserved a slot but not yet registered. */ + let pendingOpens = 0; + + const closeSession = async (sessionId: string): Promise => { + const session = sessions.get(sessionId); + if (session === undefined) { + return; + } + sessions.delete(sessionId); + await session.close(); + }; + + const openSession = async (): Promise => { + const mcpServer = createMcpServer(); + let registered = false; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + registered = true; + sessions.set(sessionId, { + transport, + close: async () => { + await transport.close(); + await mcpServer.close(); + }, + }); + }, + onsessionclosed: (sessionId) => { + void closeSession(sessionId); + }, + }); + + // 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: () => registered, + dispose: async () => { + await transport.close(); + await mcpServer.close(); + }, + }; + }; + + const server = createServer((request, response) => { + void (async () => { + try { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); + if (url.pathname !== MCP_PATH) { + respondJson(response, 404, "not_found", "Unknown endpoint"); + return; + } + + if (!isAuthorized(request.headers.authorization, config.token)) { + response.setHeader("www-authenticate", 'Bearer realm="deeptrace"'); + respondJson(response, 401, "unauthorized", "Missing or invalid bearer token"); + return; + } + + const sessionId = request.headers[SESSION_HEADER]; + const existing = typeof sessionId === "string" ? sessions.get(sessionId) : undefined; + + if (existing !== undefined) { + await existing.transport.handleRequest(request, response); + 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 { + 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, + async close() { + await Promise.all([...sessions.keys()].map((sessionId) => closeSession(sessionId))); + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }); + }); + }, + }; +} + +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/tests/integration/__evidence__/m4/p0-http-capabilities.json b/tests/integration/__evidence__/m4/p0-http-capabilities.json index d5c7974..2f2829a 100644 --- a/tests/integration/__evidence__/m4/p0-http-capabilities.json +++ b/tests/integration/__evidence__/m4/p0-http-capabilities.json @@ -1,11 +1,11 @@ { - "target_host": "wallet-intel.tail8ae57d.ts.net", + "target_host": "", "probe_version": "0.6.1", "generated_at": "2026-07-25T15:46:13.192Z", "endpoints": { "/health": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/health", + "url": "https:///health", "status": 200, "content_type": "text/plain; charset=utf-8", "duration_ms": 250, @@ -14,7 +14,7 @@ }, "/ready": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/ready", + "url": "https:///ready", "status": 200, "content_type": "application/json", "duration_ms": 56, @@ -23,7 +23,7 @@ }, "/nest": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/nest", + "url": "https:///nest", "status": 200, "content_type": "application/json", "duration_ms": 57, @@ -32,7 +32,7 @@ }, "/schema": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/schema", + "url": "https:///schema", "status": 200, "content_type": "text/plain; charset=utf-8", "duration_ms": 66, @@ -41,7 +41,7 @@ }, "/tables": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/tables", + "url": "https:///tables", "status": 200, "content_type": "application/json", "duration_ms": 70, @@ -50,7 +50,7 @@ }, "/metrics": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/metrics", + "url": "https:///metrics", "status": 200, "content_type": "text/plain; version=0.0.4", "duration_ms": 63, @@ -59,7 +59,7 @@ }, "/explain": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/explain?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201", + "url": "https:///explain?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201", "status": 400, "content_type": "application/json", "duration_ms": 121, @@ -68,7 +68,7 @@ }, "/sql": { "method": "GET", - "url": "https://wallet-intel.tail8ae57d.ts.net/sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=1", + "url": "https:///sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=1", "status": 400, "content_type": "application/json", "duration_ms": 120, @@ -77,20 +77,20 @@ } }, "post_sql": { - "url": "https://wallet-intel.tail8ae57d.ts.net/sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201", + "url": "https:///sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201", "status": 405, "rejected": true, "body": "" }, "max_rows": { - "url": "https://wallet-intel.tail8ae57d.ts.net/sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=50001", + "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://wallet-intel.tail8ae57d.ts.net/sql?q=SELECT%20*%20FROM%20pool_swap_freshness%20LIMIT%201&max_rows=1", + "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], diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts new file mode 100644 index 0000000..acdbff9 --- /dev/null +++ b/tests/unit/http-transport.test.ts @@ -0,0 +1,278 @@ +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, +} from "../../src/http/config.js"; +import { ConfigurationError } from "../../src/errors/index.js"; +import { createHttpServer, listen, type HttpRuntime } 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 token is supplied", () => { + expect(loadHttpConfig({ [HTTP_ENV_VARS.token]: VALID_TOKEN })).toEqual({ + host: HTTP_DEFAULTS.host, + port: HTTP_DEFAULTS.port, + token: VALID_TOKEN, + }); + }); + + it("applies valid overrides", () => { + expect( + loadHttpConfig({ + [HTTP_ENV_VARS.host]: "0.0.0.0", + [HTTP_ENV_VARS.port]: "9000", + [HTTP_ENV_VARS.token]: VALID_TOKEN, + }), + ).toEqual({ host: "0.0.0.0", port: 9_000, token: VALID_TOKEN }); + }); + + it("accepts the highest bindable port", () => { + expect( + loadHttpConfig({ + [HTTP_ENV_VARS.port]: String(HTTP_MAXIMUMS.port), + [HTTP_ENV_VARS.token]: 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.token]: VALID_TOKEN, + }), + ).toEqual({ host: HTTP_DEFAULTS.host, port: HTTP_DEFAULTS.port, token: VALID_TOKEN }); + }); + + it("rejects a missing token", () => { + expect(() => loadHttpConfig({})).toThrow(ConfigurationError); + }); + + it("rejects a token shorter than the minimum length", () => { + expect(() => + loadHttpConfig({ [HTTP_ENV_VARS.token]: "a".repeat(MIN_TOKEN_LENGTH - 1) }), + ).toThrow(ConfigurationError); + }); + + it("names every invalid variable", () => { + try { + loadHttpConfig({ [HTTP_ENV_VARS.port]: "0" }); + expect.unreachable("expected ConfigurationError"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).variableNames).toEqual([ + HTTP_ENV_VARS.port, + HTTP_ENV_VARS.token, + ]); + } + }); +}); + +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); + }); +}); + +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; +} + +async function startTestServer(): Promise { + const config = { host: "127.0.0.1", port: 0, token: VALID_TOKEN_32 }; + const runtime = createHttpServer(config); + 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, base: `http://127.0.0.1:${address.port}/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), + }); +} + +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("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(); + } + }); +}); From 3198640a75bf84969f79ef4323e47b5b3a675ada Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:24:08 +0200 Subject: [PATCH 40/96] fix(http): drop the browser credential challenge (#33) Co-authored-by: ikodo0 --- src/http/server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/http/server.ts b/src/http/server.ts index 77cddff..bf5fbad 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -117,7 +117,11 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { } if (!isAuthorized(request.headers.authorization, config.token)) { - response.setHeader("www-authenticate", 'Bearer realm="deeptrace"'); + // No WWW-Authenticate challenge. The endpoint is public, and a realm + // challenge makes browsers open a username/password dialog that + // cannot supply a bearer token — confusing for anyone who opens the + // URL, and useless to MCP clients, which read the token from their + // own configuration rather than negotiating. respondJson(response, 401, "unauthorized", "Missing or invalid bearer token"); return; } From 7f35d950380534134f79258a0f8254171301eb20 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:24:11 +0200 Subject: [PATCH 41/96] fix: the 7d window has never returned a value (#34) * fix(m3): fetch eight day snapshots for the 7d window * test(m3): cover 7d sums with a partial newest day --------- Co-authored-by: ikodo0 --- src/sources/graph/queries.ts | 2 +- tests/unit/graph-adapter.test.ts | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/sources/graph/queries.ts b/src/sources/graph/queries.ts index 433c600..2384323 100644 --- a/src/sources/graph/queries.ts +++ b/src/sources/graph/queries.ts @@ -16,7 +16,7 @@ export const TIER_B_METRICS_QUERY = `query M2TierBMetrics($pool: ID!) { token1 { id symbol decimals } } poolDayDatas( - first: 7 + first: 8 orderBy: date orderDirection: desc where: { pool: $pool } diff --git a/tests/unit/graph-adapter.test.ts b/tests/unit/graph-adapter.test.ts index 5d5fd33..8a969ef 100644 --- a/tests/unit/graph-adapter.test.ts +++ b/tests/unit/graph-adapter.test.ts @@ -8,6 +8,7 @@ import { getActiveComparePoolGraphSources } from "../../src/registry/index.js"; import { poolSourceResultSchema } from "../../src/schemas/source-adapter.js"; import { fetchComparePoolGraphSource, + TIER_B_METRICS_QUERY, TIER_B_METRICS_QUERY_ID, } from "../../src/sources/graph/index.js"; import { sumDecimals } from "../../src/sources/graph/decimal.js"; @@ -132,6 +133,54 @@ describe("fetchComparePoolGraphSource", () => { expect(result.data.fees_usd_7d).toBeNull(); }); + it("requests eight day snapshots so seven completed days survive the partial day", () => { + // 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).toContain("first: 8"); + }); + + it("sums 7d over completed days when the newest row is the partial day", async () => { + expect(uniswap).toBeDefined(); + const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { + poolDayDatas: Array<{ date: number; volumeUSD: string; feesUSD: string; tvlUSD?: string }>; + }; + + // Evidence holds seven rows, newest first. Production now fetches eight, so + // append one older day: the newest stays partial and seven complete days + // remain — exactly the shape the live query returns. + const oldest = data.poolDayDatas[data.poolDayDatas.length - 1]!; + data.poolDayDatas.push({ + ...oldest, + date: oldest.date - 86_400, + volumeUSD: "1000.5", + feesUSD: "3.0015", + }); + expect(data.poolDayDatas).toHaveLength(8); + + const newest = data.poolDayDatas[0]!; + // Mid-way through the newest day, so that row is not a completed day. + const nowSeconds = newest.date + 3_600; + + const result = await fetchComparePoolGraphSource(uniswap!, { + apiKey: "key", + fetchImpl: () => Promise.resolve(jsonResponse({ data })), + nowSeconds, + }); + + expect(result.status).toBe("ok"); + if (result.status !== "ok") { + return; + } + + const completed = data.poolDayDatas.slice(1); + expect(completed).toHaveLength(7); + expect(result.data.volume_usd_7d).toBe(sumDecimals(completed.map((day) => day.volumeUSD))); + expect(result.data.fees_usd_7d).toBe(sumDecimals(completed.map((day) => day.feesUSD))); + // The 24h window still tracks only the most recent completed day. + expect(result.data.volume_usd_24h).toBe(completed[0]!.volumeUSD); + }); + it("returns timeout without freshness when the gateway aborts", async () => { const fetchImpl: typeof fetch = (_input, init) => new Promise((_resolve, reject) => { From 27bdbc780229e7ab66382db1aee05949f46a72cf Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:24:16 +0200 Subject: [PATCH 42/96] M0-08: one-command MCP smoke test (#32) * feat(smoke): add one-command mcp smoke test * docs(smoke): document the mcp smoke script * chore: ignore the local handoff document * chore: ignore the local dev plan * fix(m0.8): recognize complete tool results --------- Co-authored-by: ikodo0 --- .gitignore | 2 + docs/mcp-testing.md | 32 ++++++ package.json | 1 + scripts/mcp-smoke.mjs | 221 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 scripts/mcp-smoke.mjs diff --git a/.gitignore b/.gitignore index 9ece15e..64cd025 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ coverage/ .yarn/cache/ .yarn/unplugged/ .pnp.* +/DEEPTRACE_HANDOFF.md +/DEV_PLAN.md diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index 667d542..cf672f0 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -150,6 +150,38 @@ Logs: ## 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. + +``` +DEEPTRACE_MCP_URL=https://:8443/mcp \ +DEEPTRACE_HTTP_TOKEN= \ + npm run smoke:mcp +``` + +``` +auth gate (no token) PASS status=401 (expected 401) +unknown path PASS status=404 (expected 404) +initialize PASS status=200 sid=60464046 +notifications/initialized PASS status=202 (expected 202) +tools/list PASS tools=[compare_pools] +tools/call PASS status=partial 2/2 +6 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. Point `DEEPTRACE_MCP_URL` at `http://127.0.0.1:8787/mcp` to +check a local instance instead. + +`status=partial` on the final check is a pass: the Graph sources answered and +Nuthatch is not yet wired in. See "Known gaps". + +### Manual path + The HTTP handshake is four steps. Skipping step 2 is the usual mistake. Streamable HTTP requires both content types in `Accept`: diff --git a/package.json b/package.json index 85bd752..4dfed32 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", "start": "node dist/index.js", + "smoke:mcp": "node scripts/mcp-smoke.mjs", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit && tsc -p scripts/m2/tsconfig.json" diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs new file mode 100644 index 0000000..4031e05 --- /dev/null +++ b/scripts/mcp-smoke.mjs @@ -0,0 +1,221 @@ +// 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 full 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; +} + +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 = ""; +const initOk = await runCheck("initialize", async () => { + const { status, headers } = 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 && sid !== "") { + sessionId = sid; + return { ok: true, detail: `status=200 sid=${sid.slice(0, 8)}` }; + } + if (status !== 200) { + return { ok: false, detail: `status=${status} (expected 200)` }; + } + return { ok: false, detail: `status=200 sid=(empty)` }; +}); + +if (!initOk || sessionId === "") { + console.log("initialize failed; skipping remaining checks"); + console.log(`${pass} passed, ${fail} failed`); + process.exit(1); +} + +const sessionHeaders = () => ({ + ...authHeaders(), + "mcp-session-id": sessionId, +}); + +// 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 contains compare_pools +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 = parseSse(text); + const tools = msg?.result?.tools ?? []; + const names = tools.map((t) => t?.name).filter((n) => typeof n === "string"); + const ok = status === 200 && names.includes("compare_pools"); + const detail = status !== 200 ? `status=${status} (expected 200)` : `tools=[${names.join(",")}]`; + return { ok, detail }; +}); + +// Check 6: tools/call compare_pools with locked args +await runCheck("tools/call", async () => { + const { status, text } = await postJson( + MCP_URL, + { + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "compare_pools", + arguments: { + chain_id: 8453, + token0: "0x4200000000000000000000000000000000000006", + token1: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + window: "24h", + ranked_by: "tvl_usd", + }, + }, + }, + { headers: sessionHeaders(), timeoutMs: CALL_TIMEOUT_MS }, + ); + if (status !== 200) { + return { ok: false, detail: `status=${status} (expected 200)` }; + } + const msg = parseSse(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_deployments; + const requested = coverage.requested_deployments; + const covStr = + typeof success === "number" && typeof requested === "number" + ? `${success}/${requested}` + : "?/?"; + const ok = st === "complete" || st === "partial"; + return { ok, detail: `status=${st} ${covStr}` }; +}); + +console.log(`${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1); From 694d35654372d3d3351af43da04e71990cd41493 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:32:46 +0200 Subject: [PATCH 43/96] fix(http): share one rate limiter across sessions --- src/http/server.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/http/server.ts b/src/http/server.ts index bf5fbad..c888b06 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -5,7 +5,10 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ 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 } from "../tools/index.js"; import { isAuthorized } from "./auth.js"; import type { HttpConfig } from "./config.js"; @@ -64,6 +67,20 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { /** In-flight opens that have reserved a slot but not yet registered. */ let pendingOpens = 0; + // 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 gatewayConfig = loadGatewayConfig(); + const rateLimiter = new FixedWindowRateLimiter({ + maxRequests: gatewayConfig.rateLimitMaxRequests, + windowMs: gatewayConfig.rateLimitWindowMs, + }); + const sources = createLiveComparePoolsSources({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); + const closeSession = async (sessionId: string): Promise => { const session = sessions.get(sessionId); if (session === undefined) { @@ -74,7 +91,7 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { }; const openSession = async (): Promise => { - const mcpServer = createMcpServer(); + const mcpServer = createMcpServer({ gatewayConfig, rateLimiter, sources }); let registered = false; const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), From f8847a3a4889a845501df62eb174602e4ec39e29 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:43:16 +0200 Subject: [PATCH 44/96] feat(m5.6): invoke live Nuthatch freshness --- src/sources/nuthatch/adapter.ts | 17 ++++ src/tools/compare-pools.ts | 5 +- src/tools/live-sources.ts | 88 ++++++++++++++++-- tests/unit/compare-pools-tool.test.ts | 22 +++++ tests/unit/live-sources.test.ts | 125 ++++++++++++++++++++++++++ 5 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 tests/unit/live-sources.test.ts diff --git a/src/sources/nuthatch/adapter.ts b/src/sources/nuthatch/adapter.ts index e9b4d27..b6814a5 100644 --- a/src/sources/nuthatch/adapter.ts +++ b/src/sources/nuthatch/adapter.ts @@ -76,6 +76,23 @@ function failedResult( }; } +/** + * 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"; diff --git a/src/tools/compare-pools.ts b/src/tools/compare-pools.ts index c72300d..fdf577d 100644 --- a/src/tools/compare-pools.ts +++ b/src/tools/compare-pools.ts @@ -67,8 +67,9 @@ export async function executeComparePools( throw error; } - const graphResults = await sources.fetchGraphResults(request); - const nuthatchResult = await sources.fetchNuthatchResult(request); + 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, { diff --git a/src/tools/live-sources.ts b/src/tools/live-sources.ts index 6c2eb3d..c6589d3 100644 --- a/src/tools/live-sources.ts +++ b/src/tools/live-sources.ts @@ -1,22 +1,80 @@ -import { getActiveComparePoolGraphSources } from "../registry/index.js"; +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 gateway for the locked compare_pools allowlist. - * Nuthatch remains null until a verified live freshness fact exists. + * 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(); @@ -30,8 +88,28 @@ export function createLiveComparePoolsSources( ), ); }, - fetchNuthatchResult() { - return Promise.resolve(null); + 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/tests/unit/compare-pools-tool.test.ts b/tests/unit/compare-pools-tool.test.ts index 0af1005..87142f8 100644 --- a/tests/unit/compare-pools-tool.test.ts +++ b/tests/unit/compare-pools-tool.test.ts @@ -34,6 +34,28 @@ describe("executeComparePools", () => { ).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", () => { 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); + }); +}); From 025f5aa961a4a54f4b52f7998b6e68651d04fa65 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:41:43 +0200 Subject: [PATCH 45/96] fix(m5): keep degraded provenance honest --- src/quality/settle.ts | 28 ++++++++++++--------- tests/fixtures/live-compare-pools.ts | 2 +- tests/unit/quality-settle.test.ts | 37 ++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/quality/settle.ts b/src/quality/settle.ts index 1319107..ec89f95 100644 --- a/src/quality/settle.ts +++ b/src/quality/settle.ts @@ -1,5 +1,6 @@ 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, @@ -15,6 +16,7 @@ import type { 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"; @@ -84,7 +86,7 @@ function toObservedFreshness(sourceId: string, freshness: SourceFreshness): Resu } function graphResultFreshness(result: PoolSourceResult): ResultFreshness { - if (result.freshness === null) { + if (result.status !== "ok" || result.freshness === null) { return { source_id: result.source_id, status: "unavailable" }; } return toObservedFreshness(result.source_id, result.freshness); @@ -104,19 +106,21 @@ function graphResultProvenance(result: PoolSourceResult): ResultProvenance { } function nuthatchUnavailableProvenance(): ResultProvenance { - const protocol = M0_COMPARE_POOLS_SCOPE.graphSources[0]?.protocol; - if (protocol === undefined) { - throw new QualityError("Locked Graph scope is missing a protocol for Nuthatch provenance."); + 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: M0_COMPARE_POOLS_SCOPE.nuthatchSourceId, - source_type: "nuthatch_view", - protocol, - chain_id: M0_COMPARE_POOLS_SCOPE.chainId, - deployment_or_view_id: "unverified-nuthatch-view", - schema_version: null, - methodology_version: null, - query_id: "nuthatch-pool-swap-freshness-v1", + 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, }; } diff --git a/tests/fixtures/live-compare-pools.ts b/tests/fixtures/live-compare-pools.ts index e9d3a17..391b066 100644 --- a/tests/fixtures/live-compare-pools.ts +++ b/tests/fixtures/live-compare-pools.ts @@ -52,7 +52,7 @@ const nuthatchProvenance = { source_type: "nuthatch_view" as const, protocol: "uniswap-v3", chain_id: M0_COMPARE_POOLS_SCOPE.chainId, - deployment_or_view_id: "unverified-nuthatch-view", + deployment_or_view_id: "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", schema_version: null, methodology_version: null, query_id: "nuthatch-pool-swap-freshness-v1", diff --git a/tests/unit/quality-settle.test.ts b/tests/unit/quality-settle.test.ts index a00ea00..9caacee 100644 --- a/tests/unit/quality-settle.test.ts +++ b/tests/unit/quality-settle.test.ts @@ -57,6 +57,14 @@ describe("settleComparePoolsResult", () => { source_id: "nuthatch-pool-swaps", status: "unavailable", }); + expect(response.provenance[2]).toMatchObject({ + source_id: "nuthatch-pool-swaps", + source_type: "nuthatch_view", + protocol: "uniswap-v3", + deployment_or_view_id: "0x46e57ffd7f6fb47e80c49314a5522bd588fd8f6ba2194528bd560be10d78da25", + query_id: "nuthatch-pool-swap-freshness-v1", + }); + expect(response.provenance[2]?.deployment_or_view_id).not.toBe("unverified-nuthatch-view"); expect(response.warnings.some((warning) => warning.includes("nuthatch-pool-swaps"))).toBe(true); }); @@ -86,6 +94,35 @@ describe("settleComparePoolsResult", () => { expect(comparePoolsResponseSchema.parse(response).status).toBe("partial"); }); + it("maps non-ok Graph results with retained freshness to unavailable publicly", () => { + const unsupportedPancake = { + ...graphPoolB, + status: "unsupported" as const, + data: null, + warnings: ["Graph response shape is unsupported."], + }; + const pools = rankCanonicalPools( + bindComparePoolsGraphResults([graphPoolA, unsupportedPancake], "24h"), + { rankedBy: "volume_usd" }, + ); + const response = settleComparePoolsResult({ + pair: livePair, + window: "24h", + rankedBy: "volume_usd", + pools, + graphResults: [graphPoolA, unsupportedPancake], + nuthatchResult: null, + }); + + expect(unsupportedPancake.freshness).not.toBeNull(); + expect(response.freshness.find((entry) => entry.source_id === "exchange-v3-base")).toEqual({ + source_id: "exchange-v3-base", + status: "unavailable", + }); + expect(response.status).toBe("partial"); + expect(comparePoolsResponseSchema.parse(response).status).toBe("partial"); + }); + it("returns failed when every Graph source is unavailable", () => { const timedOutUniswap = { ...graphPoolCTimeout, From bb50d17ee127565f2085b11d29aeaecc9c72829a Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:47:31 +0200 Subject: [PATCH 46/96] docs(m5): describe live Nuthatch source --- docs/mcp-testing.md | 13 ++++--------- src/scope/compare-pools.ts | 7 ++----- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index cf672f0..8544509 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -374,14 +374,9 @@ service, and reissue the token to every client. ## Known gaps -1. Nuthatch is not wired into `compare_pools`. - `src/tools/live-sources.ts` returns `Promise.resolve(null)` from - `fetchNuthatchResult`, so coverage always reports `nuthatch_available: false`. - This is independent of nest health: all three Nuthatch checks above pass. - Until that function calls the M5 adapter (`fetchNuthatchFreshness` in - `src/sources/nuthatch/adapter.ts`), a `partial` result is the correct and - expected output. -2. Nuthatch backfill has not reached the chain tip, so the freshness view trails +1. Nuthatch backfill has not reached the chain tip, so the freshness view trails live. Restart `nuthatch.service` to trigger RPC failover if it stalls. -3. There is no live integration test for the MCP server. The 382-test suite is + `compare_pools` invokes the live freshness adapter and reports that source as + stale until the backfill catches up, so a `partial` result remains expected. +2. There is no live integration test for the MCP server. The offline test suite is entirely offline. diff --git a/src/scope/compare-pools.ts b/src/scope/compare-pools.ts index c5ef840..47f78c5 100644 --- a/src/scope/compare-pools.ts +++ b/src/scope/compare-pools.ts @@ -4,7 +4,7 @@ import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; * Locked MVP-0 compare_pools allowlist for Graph-side binding. * * Values mirror `src/registry/compare-pools.json` and `records.json`. - * Nuthatch is named for response-slot reservation only; no live fact is verified. + * The Nuthatch source ID resolves the active registry-pinned freshness view. */ export const M0_COMPARE_POOLS_SCOPE = { chainId: BASE_CHAIN_ID, @@ -34,10 +34,7 @@ export const M0_COMPARE_POOLS_SCOPE = { deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", }, ], - /** - * Reserved response/provenance slot. Not present in `records.json` until - * Nuthatch P5 lands; do not treat as a verified live source. - */ + /** Active registry-pinned source for the Nuthatch freshness fact. */ nuthatchSourceId: "nuthatch-pool-swaps", } as const; From bc9b5d7d7f36f462b1f1f2db616098955872df30 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:48:39 +0200 Subject: [PATCH 47/96] fix(http): expire abandoned MCP sessions --- .env.example | 6 ++ docs/mcp-testing.md | 6 ++ src/http/config.ts | 69 +++++++++++++---- src/http/server.ts | 185 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 224 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 9ed4f18..3c0f27e 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,9 @@ DEEPTRACE_RATE_LIMIT_WINDOW_MS=60000 # Per-source adapter timeout in milliseconds (default: 5000, maximum: 8000). DEEPTRACE_SOURCE_TIMEOUT_MS=5000 + +# 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/docs/mcp-testing.md b/docs/mcp-testing.md index 8544509..1771ec2 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -96,9 +96,15 @@ shell or systemd unit that starts the server. | `DEEPTRACE_HTTP_TOKEN` | Required for the HTTP transport. Minimum 32 characters. Startup fails otherwise. | | `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` | `https://`, no trailing slash. | +Session activity refreshes after each authenticated request completes. 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 ``` diff --git a/src/http/config.ts b/src/http/config.ts index 0b68e24..3c76793 100644 --- a/src/http/config.ts +++ b/src/http/config.ts @@ -5,16 +5,23 @@ import { ConfigurationError } from "../errors/application-error.js"; export const HTTP_DEFAULTS = { host: "127.0.0.1", port: 8787, + sessionIdleTimeoutMs: 1_800_000, + sessionSweepIntervalMs: 60_000, } as const; -/** Highest port number the transport will bind. */ +/** 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", token: "DEEPTRACE_HTTP_TOKEN", } as const; @@ -24,11 +31,36 @@ export const MIN_TOKEN_LENGTH = 32; export interface HttpConfig { readonly host: string; readonly port: number; + readonly sessionIdleTimeoutMs: number; + readonly sessionSweepIntervalMs: number; readonly token: string; } 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. * The bearer token is mandatory: this transport is reachable off-host, so an @@ -41,18 +73,27 @@ export function loadHttpConfig(env: EnvSource = process.env): HttpConfig { const rawHost = env[HTTP_ENV_VARS.host]?.trim(); const host = rawHost === undefined || rawHost === "" ? HTTP_DEFAULTS.host : rawHost; - const rawPort = env[HTTP_ENV_VARS.port]?.trim(); - let port: number = HTTP_DEFAULTS.port; - if (rawPort !== undefined && rawPort !== "") { - try { - port = parseBoundedPositiveInteger(rawPort, HTTP_ENV_VARS.port, HTTP_MAXIMUMS.port); - } catch (error) { - if (!(error instanceof ConfigurationError)) { - throw error; - } - invalidNames.push(HTTP_ENV_VARS.port); - } - } + 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 token = env[HTTP_ENV_VARS.token]?.trim() ?? ""; if (token.length < MIN_TOKEN_LENGTH) { @@ -63,5 +104,5 @@ export function loadHttpConfig(env: EnvSource = process.env): HttpConfig { throw new ConfigurationError(invalidNames); } - return { host, port, token }; + return { host, port, sessionIdleTimeoutMs, sessionSweepIntervalMs, token }; } diff --git a/src/http/server.ts b/src/http/server.ts index c888b06..5794458 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -20,21 +20,52 @@ 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; +} + 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, @@ -62,10 +93,14 @@ async function readBody(request: IncomingMessage): Promise { * 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): HttpRuntime { +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 @@ -81,32 +116,58 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { timeoutMs: gatewayConfig.sourceTimeoutMs, }); - const closeSession = async (sessionId: string): Promise => { + const closeSession = (sessionId: string, expectedSession?: Session): Promise => { const session = sessions.get(sessionId); - if (session === undefined) { - return; + if (session === undefined || (expectedSession !== undefined && session !== expectedSession)) { + return Promise.resolve(); } sessions.delete(sessionId); - await session.close(); + 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 }); - let registered = false; + let registeredSession: Session | undefined; + let closePromise: Promise | undefined; const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { - registered = true; - sessions.set(sessionId, { + const session: Session = { transport, - close: async () => { - await transport.close(); - await mcpServer.close(); + 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); + void closeSession(sessionId).catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown session cleanup error"; + console.error(`[deeptrace] Session cleanup failed: ${message}`); + }); }, }); @@ -116,14 +177,43 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { await mcpServer.connect(transport as Transport); return { transport, - wasRegistered: () => registered, - dispose: async () => { - await transport.close(); - await mcpServer.close(); + 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 { @@ -143,11 +233,23 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { 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) { - await existing.transport.handleRequest(request, response); + existing.activeRequests += 1; + existing.lastActivityAt = now(); + try { + await existing.transport.handleRequest(request, response); + } finally { + existing.activeRequests -= 1; + existing.lastActivityAt = now(); + } return; } @@ -173,6 +275,7 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { try { await session.transport.handleRequest(request, response, body); } finally { + session.finishRequest(); if (!session.wasRegistered()) { await session.dispose(); } @@ -194,17 +297,43 @@ export function createHttpServer(config: HttpConfig): HttpRuntime { return { server, - async close() { - await Promise.all([...sessions.keys()].map((sessionId) => closeSession(sessionId))); - await new Promise((resolve, reject) => { - server.close((error) => { - if (error === undefined) { - resolve(); - } else { - reject(error); - } + 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; }, }; } From c6350ec9af9259d06d72dab757d6ba73820c8def Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:48:45 +0200 Subject: [PATCH 48/96] test(http): cover idle session lifecycle --- tests/unit/http-transport.test.ts | 262 +++++++++++++++++++++++++++++- 1 file changed, 256 insertions(+), 6 deletions(-) diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index acdbff9..59f0c8d 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -7,9 +7,15 @@ import { 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 } from "../../src/http/server.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 @@ -45,6 +51,8 @@ describe("loadHttpConfig", () => { expect(loadHttpConfig({ [HTTP_ENV_VARS.token]: VALID_TOKEN })).toEqual({ host: HTTP_DEFAULTS.host, port: HTTP_DEFAULTS.port, + sessionIdleTimeoutMs: HTTP_DEFAULTS.sessionIdleTimeoutMs, + sessionSweepIntervalMs: HTTP_DEFAULTS.sessionSweepIntervalMs, token: VALID_TOKEN, }); }); @@ -54,9 +62,17 @@ describe("loadHttpConfig", () => { 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.token]: VALID_TOKEN, }), - ).toEqual({ host: "0.0.0.0", port: 9_000, token: VALID_TOKEN }); + ).toEqual({ + host: "0.0.0.0", + port: 9_000, + sessionIdleTimeoutMs: 120_000, + sessionSweepIntervalMs: 30_000, + token: VALID_TOKEN, + }); }); it("accepts the highest bindable port", () => { @@ -73,9 +89,17 @@ describe("loadHttpConfig", () => { loadHttpConfig({ [HTTP_ENV_VARS.host]: " ", [HTTP_ENV_VARS.port]: " ", + [HTTP_ENV_VARS.sessionIdleTimeoutMs]: " ", + [HTTP_ENV_VARS.sessionSweepIntervalMs]: "", [HTTP_ENV_VARS.token]: VALID_TOKEN, }), - ).toEqual({ host: HTTP_DEFAULTS.host, port: HTTP_DEFAULTS.port, token: VALID_TOKEN }); + ).toEqual({ + host: HTTP_DEFAULTS.host, + port: HTTP_DEFAULTS.port, + sessionIdleTimeoutMs: HTTP_DEFAULTS.sessionIdleTimeoutMs, + sessionSweepIntervalMs: HTTP_DEFAULTS.sessionSweepIntervalMs, + token: VALID_TOKEN, + }); }); it("rejects a missing token", () => { @@ -88,6 +112,23 @@ describe("loadHttpConfig", () => { ).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.token]: 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" }); @@ -146,9 +187,20 @@ interface TestServer { readonly base: string; } -async function startTestServer(): Promise { - const config = { host: "127.0.0.1", port: 0, token: VALID_TOKEN_32 }; - const runtime = createHttpServer(config); +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, + token: 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") { @@ -172,6 +224,64 @@ async function postInitialize( }); } +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(); + }, + }; +} + describe("HTTP session lifecycle", () => { it("disposes the McpServer/transport when initialize is rejected (406)", async () => { const { runtime, base } = await startTestServer(); @@ -217,6 +327,146 @@ describe("HTTP session lifecycle", () => { } }); + 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("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(); From cabd226614086c4c946e67ef3f75eca372da2b1f Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:55:45 +0200 Subject: [PATCH 49/96] fix(http): expire abandoned SSE sessions --- docs/mcp-testing.md | 8 ++++--- src/http/server.ts | 12 +++++++++-- tests/unit/http-transport.test.ts | 36 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index 1771ec2..8206d53 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -101,9 +101,11 @@ shell or systemd unit that starts the server. | `GRAPH_API_KEY` | Required, or every Graph source returns `unavailable`. | | `NUTHATCH_BASE_URL` | `https://`, no trailing slash. | -Session activity refreshes after each authenticated request completes. 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. +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 diff --git a/src/http/server.ts b/src/http/server.ts index 5794458..0c4e098 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -242,12 +242,20 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions const existing = typeof sessionId === "string" ? sessions.get(sessionId) : undefined; if (existing !== undefined) { - existing.activeRequests += 1; + // 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 { - existing.activeRequests -= 1; + if (blocksIdleExpiration) { + existing.activeRequests -= 1; + } existing.lastActivityAt = now(); } return; diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index 59f0c8d..3ae5389 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -365,6 +365,42 @@ describe("HTTP session lifecycle", () => { 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({ From f361d16b7e5f5f0fddf6c88edaa08ef6fc632706 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:51:15 +0200 Subject: [PATCH 50/96] feat(m4.11): detect frozen Nuthatch backfills --- package.json | 1 + scripts/m4/nuthatch-watchdog-lib.mjs | 129 ++++++++++++++++++++++++++ scripts/m4/nuthatch-watchdog.mjs | 127 +++++++++++++++++++++++++ tests/unit/nuthatch-watchdog.test.mjs | 113 ++++++++++++++++++++++ 4 files changed, 370 insertions(+) create mode 100644 scripts/m4/nuthatch-watchdog-lib.mjs create mode 100644 scripts/m4/nuthatch-watchdog.mjs create mode 100644 tests/unit/nuthatch-watchdog.test.mjs diff --git a/package.json b/package.json index 4dfed32..b221f8f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "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" 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/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/); + }); +}); From a01513837ac12d4259388ef48d87bb12e4bc164b Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:51:51 +0200 Subject: [PATCH 51/96] fix(m4.11): configure RPC failover alerts --- .env.example | 8 ++-- deploy/systemd/nuthatch-watchdog.service | 22 ++++++++++ deploy/systemd/nuthatch-watchdog.timer | 13 ++++++ docs/deployment.md | 43 ++++++++++++++++++++ docs/mcp-testing.md | 7 +++- nest/nuthatch.toml | 7 ++-- scripts/m4/README.md | 4 ++ tests/unit/nuthatch-watchdog-config.test.mjs | 13 ++++++ 8 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 deploy/systemd/nuthatch-watchdog.service create mode 100644 deploy/systemd/nuthatch-watchdog.timer create mode 100644 tests/unit/nuthatch-watchdog-config.test.mjs diff --git a/.env.example b/.env.example index 3c0f27e..67d4e01 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,10 @@ # token instead of embedding it in a gateway URL. GRAPH_API_KEY= -# Base mainnet RPC endpoints used by Nuthatch for health-aware failover. -# The Graph + Nuthatch source constraint rules out keyed data providers, so -# redundancy comes from breadth. Probe each endpoint's eth_getLogs range cap -# before committing to it; the tightest cap paces the whole backfill. +# Ordered Base mainnet RPC endpoints used by Nuthatch for failover. Production +# loads these from /etc/default/nuthatch; 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. BASE_RPC_URL_PRIMARY= BASE_RPC_URL_SECONDARY= BASE_RPC_URL_TERTIARY= diff --git a/deploy/systemd/nuthatch-watchdog.service b/deploy/systemd/nuthatch-watchdog.service new file mode 100644 index 0000000..51d664d --- /dev/null +++ b/deploy/systemd/nuthatch-watchdog.service @@ -0,0 +1,22 @@ +[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..844fecf --- /dev/null +++ b/deploy/systemd/nuthatch-watchdog.timer @@ -0,0 +1,13 @@ +[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/deployment.md b/docs/deployment.md index f2af095..62eb084 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -24,6 +24,13 @@ EnvironmentFile=/etc/default/nuthatch ExecStart=/usr/local/bin/nuthatch dev --dir ${NUTHATCH_DIR} --listen ${NUTHATCH_LISTEN} $NUTHATCH_EXTRA_ARGS ``` +`nest/nuthatch.toml` reads three ordered RPC URLs from +`BASE_RPC_URL_PRIMARY`, `BASE_RPC_URL_SECONDARY`, and +`BASE_RPC_URL_TERTIARY`. Define all three in `/etc/default/nuthatch`, using +independent providers. Keyed URLs are allowed in that root-owned environment +file but must never be committed, printed by a probe, or copied into evidence. +The staging `nuthatch check` process must load the same environment file. + ## Deploy Autodeploy ships only the contents of `nest/`; the Nuthatch binary is managed @@ -39,3 +46,39 @@ separately on CT 104. The deployment must: 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 index 8206d53..cf48742 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -297,6 +297,9 @@ Verified: ``` `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 @@ -383,7 +386,9 @@ service, and reissue the token to every client. ## Known gaps 1. Nuthatch backfill has not reached the chain tip, so the freshness view trails - live. Restart `nuthatch.service` to trigger RPC failover if it stalls. + live. `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 reports that source as stale until the backfill catches up, so a `partial` result remains expected. 2. There is no live integration test for the MCP server. The offline test suite is diff --git a/nest/nuthatch.toml b/nest/nuthatch.toml index 5400378..996fc9e 100644 --- a/nest/nuthatch.toml +++ b/nest/nuthatch.toml @@ -3,10 +3,9 @@ name = "deeptrace-pool-freshness" chain = "base" chain_id = 8453 rpc_urls = [ - "https://mainnet.base.org", - "https://base-rpc.publicnode.com", - "https://base.drpc.org", - "https://base-pokt.nodies.app", + "${BASE_RPC_URL_PRIMARY}", + "${BASE_RPC_URL_SECONDARY}", + "${BASE_RPC_URL_TERTIARY}", ] schema_version = 1 diff --git a/scripts/m4/README.md b/scripts/m4/README.md index a13b2e6..b36c572 100644 --- a/scripts/m4/README.md +++ b/scripts/m4/README.md @@ -24,3 +24,7 @@ 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/tests/unit/nuthatch-watchdog-config.test.mjs b/tests/unit/nuthatch-watchdog-config.test.mjs new file mode 100644 index 0000000..3f6fd8f --- /dev/null +++ b/tests/unit/nuthatch-watchdog-config.test.mjs @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +describe("Nuthatch watchdog deployment configuration", () => { + it("keeps every production RPC endpoint in environment configuration", async () => { + const config = await readFile(new URL("../../nest/nuthatch.toml", import.meta.url), "utf8"); + expect(config).toContain('"${BASE_RPC_URL_PRIMARY}"'); + expect(config).toContain('"${BASE_RPC_URL_SECONDARY}"'); + expect(config).toContain('"${BASE_RPC_URL_TERTIARY}"'); + expect(config).not.toContain("https://"); + }); +}); From 9bb0e400eb1f6b22d991c614cce9daebb6959f56 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:05:26 +0200 Subject: [PATCH 52/96] fix(m4.11): restore valid RPC fallbacks --- .env.example | 10 ++++---- deploy/systemd/nuthatch-watchdog.service | 1 - deploy/systemd/nuthatch-watchdog.timer | 1 - docs/deployment.md | 13 ++++++----- nest/nuthatch.toml | 6 ++--- tests/unit/nuthatch-watchdog-config.test.mjs | 24 ++++++++++++++++---- 6 files changed, 35 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 67d4e01..cd0f74c 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,12 @@ # token instead of embedding it in a gateway URL. GRAPH_API_KEY= -# Ordered Base mainnet RPC endpoints used by Nuthatch for failover. Production -# loads these from /etc/default/nuthatch; 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. +# 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= diff --git a/deploy/systemd/nuthatch-watchdog.service b/deploy/systemd/nuthatch-watchdog.service index 51d664d..3481d1c 100644 --- a/deploy/systemd/nuthatch-watchdog.service +++ b/deploy/systemd/nuthatch-watchdog.service @@ -19,4 +19,3 @@ ProtectSystem=strict CapabilityBoundingSet= RestrictAddressFamilies=AF_INET AF_INET6 UMask=0077 - diff --git a/deploy/systemd/nuthatch-watchdog.timer b/deploy/systemd/nuthatch-watchdog.timer index 844fecf..b3b9b08 100644 --- a/deploy/systemd/nuthatch-watchdog.timer +++ b/deploy/systemd/nuthatch-watchdog.timer @@ -10,4 +10,3 @@ Unit=nuthatch-watchdog.service [Install] WantedBy=timers.target - diff --git a/docs/deployment.md b/docs/deployment.md index 62eb084..8c09823 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,15 +21,16 @@ 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} $NUTHATCH_EXTRA_ARGS +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 ``` -`nest/nuthatch.toml` reads three ordered RPC URLs from +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`. Define all three in `/etc/default/nuthatch`, using -independent providers. Keyed URLs are allowed in that root-owned environment -file but must never be committed, printed by a probe, or copied into evidence. -The staging `nuthatch check` process must load the same environment file. +`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 diff --git a/nest/nuthatch.toml b/nest/nuthatch.toml index 996fc9e..1addbf3 100644 --- a/nest/nuthatch.toml +++ b/nest/nuthatch.toml @@ -3,9 +3,9 @@ name = "deeptrace-pool-freshness" chain = "base" chain_id = 8453 rpc_urls = [ - "${BASE_RPC_URL_PRIMARY}", - "${BASE_RPC_URL_SECONDARY}", - "${BASE_RPC_URL_TERTIARY}", + "https://base-rpc.publicnode.com", + "https://base.drpc.org", + "https://base-pokt.nodies.app", ] schema_version = 1 diff --git a/tests/unit/nuthatch-watchdog-config.test.mjs b/tests/unit/nuthatch-watchdog-config.test.mjs index 3f6fd8f..2649c3c 100644 --- a/tests/unit/nuthatch-watchdog-config.test.mjs +++ b/tests/unit/nuthatch-watchdog-config.test.mjs @@ -3,11 +3,25 @@ import { readFile } from "node:fs/promises"; import { describe, expect, it } from "vitest"; describe("Nuthatch watchdog deployment configuration", () => { - it("keeps every production RPC endpoint in environment configuration", async () => { + 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"); - expect(config).toContain('"${BASE_RPC_URL_PRIMARY}"'); - expect(config).toContain('"${BASE_RPC_URL_SECONDARY}"'); - expect(config).toContain('"${BASE_RPC_URL_TERTIARY}"'); - expect(config).not.toContain("https://"); + 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"); + } }); }); From ea6321575973479d9bd70ee829fbe745d0c8db1e Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:27:41 +0200 Subject: [PATCH 53/96] fix(m3): version eight-day query provenance --- src/registry/compare-pools.json | 4 ++-- src/registry/index.test.ts | 2 +- src/scope/compare-pools.ts | 4 ++-- src/sources/graph/queries.ts | 4 ++-- tests/fixtures/sources/index.ts | 11 ++++++----- tests/unit/compare-pools-scope.test.ts | 14 ++++++++++++++ tests/unit/graph-adapter.test.ts | 1 + tests/unit/m2-evidence.test.ts | 21 ++++++++++++++++++++- 8 files changed, 48 insertions(+), 13 deletions(-) diff --git a/src/registry/compare-pools.json b/src/registry/compare-pools.json index 032ad1c..2eefa36 100644 --- a/src/registry/compare-pools.json +++ b/src/registry/compare-pools.json @@ -8,14 +8,14 @@ { "source_id": "uniswap-v3-base-native", "pool_address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", - "query_id": "m2-tier-b-metrics-v1", + "query_id": "m3-tier-b-metrics-v2", "schema_contract_id": "m2-tier-b-metrics-v1", "priority": 1 }, { "source_id": "exchange-v3-base", "pool_address": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", - "query_id": "m2-tier-b-metrics-v1", + "query_id": "m3-tier-b-metrics-v2", "schema_contract_id": "m2-tier-b-metrics-v1", "priority": 2 } diff --git a/src/registry/index.test.ts b/src/registry/index.test.ts index 902714d..cf155b8 100644 --- a/src/registry/index.test.ts +++ b/src/registry/index.test.ts @@ -577,7 +577,7 @@ describe("shipped registry and profile", () => { expect(queryIds.size).toBe(1); expect(schemaContractIds.size).toBe(1); - expect([...queryIds][0]).toBe("m2-tier-b-metrics-v1"); + expect([...queryIds][0]).toBe("m3-tier-b-metrics-v2"); expect([...schemaContractIds][0]).toBe("m2-tier-b-metrics-v1"); }); }); diff --git a/src/scope/compare-pools.ts b/src/scope/compare-pools.ts index 47f78c5..b93f025 100644 --- a/src/scope/compare-pools.ts +++ b/src/scope/compare-pools.ts @@ -23,14 +23,14 @@ export const M0_COMPARE_POOLS_SCOPE = { source_id: "uniswap-v3-base-native", protocol: "uniswap-v3", pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", - query_id: "m2-tier-b-metrics-v1", + query_id: "m3-tier-b-metrics-v2", deployment_or_view_id: "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", }, { source_id: "exchange-v3-base", protocol: "pancakeswap-v3", pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", - query_id: "m2-tier-b-metrics-v1", + query_id: "m3-tier-b-metrics-v2", deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", }, ], diff --git a/src/sources/graph/queries.ts b/src/sources/graph/queries.ts index 2384323..187d39c 100644 --- a/src/sources/graph/queries.ts +++ b/src/sources/graph/queries.ts @@ -1,8 +1,8 @@ /** * Locked Tier-B metrics query for MVP-0 compare_pools Graph sources. - * Identity must stay aligned with M2 evidence and the compare-pools profile. + * Identity must stay aligned with the production compare-pools profile. */ -export const TIER_B_METRICS_QUERY_ID = "m2-tier-b-metrics-v1" as const; +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 { diff --git a/tests/fixtures/sources/index.ts b/tests/fixtures/sources/index.ts index 43f47f0..4c5ade2 100644 --- a/tests/fixtures/sources/index.ts +++ b/tests/fixtures/sources/index.ts @@ -19,7 +19,8 @@ const usdc = { decimals: 6, } as const; -// Point-in-time values from tests/integration/__evidence__/m2/*/07-common-metrics.json. +// Point-in-time values from tests/integration/__evidence__/m2/*/07-common-metrics.json, +// with provenance updated to the active production query revision. // 7d aggregates stay null here so Person 2 null-handling stays covered; M3.6 owns real 7d sums. export const graphPoolA = { @@ -50,7 +51,7 @@ export const graphPoolA = { deployment_or_view_id: "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", schema_version: null, methodology_version: null, - query_id: "m2-tier-b-metrics-v1", + query_id: "m3-tier-b-metrics-v2", }, warnings: ["Fixture retains null 7d aggregates; production 7d sums land in M3.6."], latency_ms: 120, @@ -84,7 +85,7 @@ export const graphPoolB = { deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", schema_version: null, methodology_version: null, - query_id: "m2-tier-b-metrics-v1", + query_id: "m3-tier-b-metrics-v2", }, warnings: ["Fixture retains null 7d aggregates; production 7d sums land in M3.6."], latency_ms: 301, @@ -119,7 +120,7 @@ export const graphPoolC = { deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", schema_version: null, methodology_version: null, - query_id: "m2-tier-b-metrics-v1", + query_id: "m3-tier-b-metrics-v2", }, warnings: ["Synthetic fixture: fees windows forced null for Person 2 null-handling coverage."], latency_ms: 301, @@ -138,7 +139,7 @@ export const graphPoolCTimeout = { deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", schema_version: null, methodology_version: null, - query_id: "m2-tier-b-metrics-v1", + query_id: "m3-tier-b-metrics-v2", }, warnings: ["Synthetic timeout over real exchange-v3-base provenance."], latency_ms: 15000, diff --git a/tests/unit/compare-pools-scope.test.ts b/tests/unit/compare-pools-scope.test.ts index e487aac..2101488 100644 --- a/tests/unit/compare-pools-scope.test.ts +++ b/tests/unit/compare-pools-scope.test.ts @@ -25,6 +25,20 @@ describe("M0 compare_pools live scope", () => { } }); + 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(["m3-tier-b-metrics-v2"]), + ); + expect(new Set(sources.map((source) => source.schema_contract_id))).toEqual( + new Set(["m2-tier-b-metrics-v1"]), + ); + expect(new Set(M0_COMPARE_POOLS_SCOPE.graphSources.map((source) => source.query_id))).toEqual( + new Set(["m3-tier-b-metrics-v2"]), + ); + }); + it("reserves Nuthatch without claiming a verified registry record", () => { expect(M0_COMPARE_POOLS_SCOPE.nuthatchSourceId).toBe("nuthatch-pool-swaps"); expect( diff --git a/tests/unit/graph-adapter.test.ts b/tests/unit/graph-adapter.test.ts index 8a969ef..ecd9bf5 100644 --- a/tests/unit/graph-adapter.test.ts +++ b/tests/unit/graph-adapter.test.ts @@ -137,6 +137,7 @@ describe("fetchComparePoolGraphSource", () => { // 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"); }); diff --git a/tests/unit/m2-evidence.test.ts b/tests/unit/m2-evidence.test.ts index c0f016a..ffea90f 100644 --- a/tests/unit/m2-evidence.test.ts +++ b/tests/unit/m2-evidence.test.ts @@ -1,11 +1,17 @@ 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 } from "../../scripts/m2/lib/queries.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 () => { @@ -35,4 +41,17 @@ describe("writeEvidence", () => { }); 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"); + } + }); }); From c3bdd8412a8a9aaef4ef0eb96e5e9aa87a6b75f4 Mon Sep 17 00:00:00 2001 From: kapustazh <51422901+kapustazh@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:36:45 +0100 Subject: [PATCH 54/96] add large swap search schemas - freeze the Base pool request and SwapEvent contracts - add complete, empty, paginated, failed, and duplicate fixtures - cover strict validation and Nuthatch freshness invariants --- src/schemas/index.ts | 19 ++ src/schemas/large-swaps-request.ts | 37 ++++ src/schemas/large-swaps.ts | 242 +++++++++++++++++++++++++ src/scope/index.ts | 1 + src/scope/large-swaps.ts | 30 +++ tests/fixtures/large-swaps-request.ts | 18 ++ tests/fixtures/large-swaps.ts | 184 +++++++++++++++++++ tests/unit/large-swaps-request.test.ts | 77 ++++++++ tests/unit/large-swaps-schema.test.ts | 119 ++++++++++++ tsconfig.fixtures.json | 7 +- 10 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 src/schemas/large-swaps-request.ts create mode 100644 src/schemas/large-swaps.ts create mode 100644 src/scope/large-swaps.ts create mode 100644 tests/fixtures/large-swaps-request.ts create mode 100644 tests/fixtures/large-swaps.ts create mode 100644 tests/unit/large-swaps-request.test.ts create mode 100644 tests/unit/large-swaps-schema.test.ts diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 7f8638d..fc7f2b0 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -23,3 +23,22 @@ export { 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"; diff --git a/src/schemas/large-swaps-request.ts b/src/schemas/large-swaps-request.ts new file mode 100644 index 0000000..3c502a5 --- /dev/null +++ b/src/schemas/large-swaps-request.ts @@ -0,0 +1,37 @@ +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) + .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..726b0ba --- /dev/null +++ b/src/schemas/large-swaps.ts @@ -0,0 +1,242 @@ +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) + .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/scope/index.ts b/src/scope/index.ts index b376059..17580fe 100644 --- a/src/scope/index.ts +++ b/src/scope/index.ts @@ -1 +1,2 @@ export { M0_COMPARE_POOLS_SCOPE, type M0ComparePoolsScope } from "./compare-pools.js"; +export { LSS_SCOPE, type LssScope } from "./large-swaps.js"; diff --git a/src/scope/large-swaps.ts b/src/scope/large-swaps.ts new file mode 100644 index 0000000..55e9a1a --- /dev/null +++ b/src/scope/large-swaps.ts @@ -0,0 +1,30 @@ +import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; + +/** + * Locked Large Swap Search v1 allowlist. + * + * Live Nuthatch view and registry bindings are intentionally deferred to LSS-04. + */ +export const LSS_SCOPE = { + chainId: BASE_CHAIN_ID, + protocol: "uniswap-v3", + poolAddress: "0x6c561b446416e1a00e8e93e221854d6ea4171372", + tokens: { + weth: { + address: "0x4200000000000000000000000000000000000006", + symbol: "WETH", + decimals: 18, + }, + usdc: { + address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + symbol: "USDC", + decimals: 6, + }, + }, + limit: { + default: 25, + maximum: 100, + }, +} as const; + +export type LssScope = typeof LSS_SCOPE; diff --git a/tests/fixtures/large-swaps-request.ts b/tests/fixtures/large-swaps-request.ts new file mode 100644 index 0000000..88fc4c2 --- /dev/null +++ b/tests/fixtures/large-swaps-request.ts @@ -0,0 +1,18 @@ +import type { FindLargeSwapsRequestInput } from "../../src/schemas/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; + +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.000001", + limit: 10, + cursor: "lss:v1:fixture-page-2", +} as const satisfies FindLargeSwapsRequestInput; diff --git a/tests/fixtures/large-swaps.ts b/tests/fixtures/large-swaps.ts new file mode 100644 index 0000000..2b46101 --- /dev/null +++ b/tests/fixtures/large-swaps.ts @@ -0,0 +1,184 @@ +import type { + FindLargeSwapsResponse, + ResultFreshness, + ResultProvenance, + SwapEvent, +} from "../../src/schemas/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.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: "lss:v1:fixture-page-2", + }, +} 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/unit/large-swaps-request.test.ts b/tests/unit/large-swaps-request.test.ts new file mode 100644 index 0000000..5735a78 --- /dev/null +++ b/tests/unit/large-swaps-request.test.ts @@ -0,0 +1,77 @@ +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, 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/tsconfig.fixtures.json b/tsconfig.fixtures.json index b30ea0f..ff919d2 100644 --- a/tsconfig.fixtures.json +++ b/tsconfig.fixtures.json @@ -12,5 +12,10 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/schemas/**/*.ts", "src/registry/**/*.ts", "tests/fixtures/sources/**/*.ts"] + "include": [ + "src/schemas/**/*.ts", + "src/registry/**/*.ts", + "tests/fixtures/sources/**/*.ts", + "tests/fixtures/large-swaps*.ts" + ] } From f54b083f130e760cea9664a0b648f7d0a04de194 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:26:44 +0200 Subject: [PATCH 55/96] fix(m0.8): close smoke test sessions --- docs/mcp-testing.md | 7 +- scripts/mcp-smoke.mjs | 156 +++++++++++++++++++--------------- tests/unit/mcp-smoke.test.mjs | 146 +++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 71 deletions(-) create mode 100644 tests/unit/mcp-smoke.test.mjs diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index cf48742..8e72644 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -173,7 +173,7 @@ DEEPTRACE_HTTP_TOKEN= \ ``` auth gate (no token) PASS status=401 (expected 401) unknown path PASS status=404 (expected 404) -initialize PASS status=200 sid=60464046 +initialize PASS status=200 session=established notifications/initialized PASS status=202 (expected 202) tools/list PASS tools=[compare_pools] tools/call PASS status=partial 2/2 @@ -183,7 +183,10 @@ tools/call PASS status=partial 2/2 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. Point `DEEPTRACE_MCP_URL` at `http://127.0.0.1:8787/mcp` to -check a local instance instead. +check a local instance instead. 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. `status=partial` on the final check is a pass: the Graph sources answered and Nuthatch is not yet wired in. See "Known gaps". diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index 4031e05..c69606f 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -2,7 +2,7 @@ // 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 full session id. +// Never prints the token, any Authorization header, or a session id. import process from "node:process"; @@ -126,7 +126,7 @@ const initOk = await runCheck("initialize", async () => { const sid = headers.get("mcp-session-id") ?? ""; if (status === 200 && sid !== "") { sessionId = sid; - return { ok: true, detail: `status=200 sid=${sid.slice(0, 8)}` }; + return { ok: true, detail: `status=200 session=established` }; } if (status !== 200) { return { ok: false, detail: `status=${status} (expected 200)` }; @@ -145,77 +145,95 @@ const sessionHeaders = () => ({ "mcp-session-id": sessionId, }); -// 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)` }; -}); +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. + } +} -// Check 5: tools/list -> SSE payload contains compare_pools -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 = parseSse(text); - const tools = msg?.result?.tools ?? []; - const names = tools.map((t) => t?.name).filter((n) => typeof n === "string"); - const ok = status === 200 && names.includes("compare_pools"); - const detail = status !== 200 ? `status=${status} (expected 200)` : `tools=[${names.join(",")}]`; - return { ok, detail }; -}); +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 6: tools/call compare_pools with locked args -await runCheck("tools/call", async () => { - const { status, text } = await postJson( - MCP_URL, - { - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { - name: "compare_pools", - arguments: { - chain_id: 8453, - token0: "0x4200000000000000000000000000000000000006", - token1: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - window: "24h", - ranked_by: "tvl_usd", + // Check 5: tools/list -> SSE payload contains compare_pools + 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 = parseSse(text); + const tools = msg?.result?.tools ?? []; + const names = tools.map((t) => t?.name).filter((n) => typeof n === "string"); + const ok = status === 200 && names.includes("compare_pools"); + const detail = + status !== 200 ? `status=${status} (expected 200)` : `tools=[${names.join(",")}]`; + return { ok, detail }; + }); + + // Check 6: tools/call compare_pools with locked args + await runCheck("tools/call", async () => { + const { status, text } = await postJson( + MCP_URL, + { + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: { + name: "compare_pools", + arguments: { + chain_id: 8453, + token0: "0x4200000000000000000000000000000000000006", + token1: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + window: "24h", + ranked_by: "tvl_usd", + }, }, }, - }, - { headers: sessionHeaders(), timeoutMs: CALL_TIMEOUT_MS }, - ); - if (status !== 200) { - return { ok: false, detail: `status=${status} (expected 200)` }; - } - const msg = parseSse(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_deployments; - const requested = coverage.requested_deployments; - const covStr = - typeof success === "number" && typeof requested === "number" - ? `${success}/${requested}` - : "?/?"; - const ok = st === "complete" || st === "partial"; - return { ok, detail: `status=${st} ${covStr}` }; -}); + { headers: sessionHeaders(), timeoutMs: CALL_TIMEOUT_MS }, + ); + if (status !== 200) { + return { ok: false, detail: `status=${status} (expected 200)` }; + } + const msg = parseSse(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_deployments; + const requested = coverage.requested_deployments; + const covStr = + typeof success === "number" && typeof requested === "number" + ? `${success}/${requested}` + : "?/?"; + const ok = st === "complete" || st === "partial"; + return { ok, detail: `status=${st} ${covStr}` }; + }); +} finally { + await closeSession(); +} console.log(`${pass} passed, ${fail} failed`); process.exit(fail === 0 ? 0 : 1); diff --git a/tests/unit/mcp-smoke.test.mjs b/tests/unit/mcp-smoke.test.mjs new file mode 100644 index 0000000..dd10787 --- /dev/null +++ b/tests/unit/mcp-smoke.test.mjs @@ -0,0 +1,146 @@ +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"; + +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("closes an initialized session after a later check fails without printing secrets", async () => { + const deletes = []; + 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, + 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") { + response.writeHead(200, { "mcp-session-id": SESSION_ID }).end(); + return; + } + + 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" }] }, + }; + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(`data: ${JSON.stringify(payload)}\n\n`); + return; + } + + const resultText = JSON.stringify({ + status: "complete", + 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).toContain("tools/call"); + expect(deletes).toEqual([{ authorization: `Bearer ${TOKEN}`, sessionId: SESSION_ID }]); + expect(`${result.stdout}${result.stderr}`).not.toContain(TOKEN); + expect(`${result.stdout}${result.stderr}`).not.toContain(SESSION_ID); + }); +}); From bf49351ad02f118c5834851f572dd3e18bf9bfc7 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 01:34:54 +0200 Subject: [PATCH 56/96] fix(http): preserve MCP bearer challenges --- src/http/server.ts | 26 ++++++++-- tests/unit/http-transport.test.ts | 81 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 5 deletions(-) diff --git a/src/http/server.ts b/src/http/server.ts index 0c4e098..3697515 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -76,6 +76,18 @@ function respondJson( response.end(JSON.stringify({ error: { code, message } })); } +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) { @@ -223,12 +235,16 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions 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.token)) { - // No WWW-Authenticate challenge. The endpoint is public, and a realm - // challenge makes browsers open a username/password dialog that - // cannot supply a bearer token — confusing for anyone who opens the - // URL, and useless to MCP clients, which read the token from their - // own configuration rather than negotiating. + response.setHeader("www-authenticate", 'Bearer realm="deeptrace"'); respondJson(response, 401, "unauthorized", "Missing or invalid bearer token"); return; } diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index 3ae5389..0f889b1 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -1,3 +1,5 @@ +import { request as httpRequest } from "node:http"; + import { describe, expect, it, vi } from "vitest"; import { isAuthorized } from "../../src/http/auth.js"; @@ -282,6 +284,85 @@ function createFakeSessionTimer(): { }; } +async function getBrowserNavigation(base: string): Promise<{ + readonly status: number | undefined; + readonly challenge: string | undefined; + readonly body: unknown; +}> { + 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"], + body: JSON.parse(body) as unknown, + }); + }); + }, + ); + request.on("error", reject); + request.end(); + }); +} + +describe("HTTP authentication", () => { + it("returns a challenge-free 404 for top-level browser navigation", async () => { + const { runtime, base } = await startTestServer(); + try { + const response = await getBrowserNavigation(base); + + expect(response.status).toBe(404); + expect(response.challenge).toBeUndefined(); + expect(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")).toBe('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(); From 2af1b725076f23dd609b7c43ecfd257213413084 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:17:31 +0200 Subject: [PATCH 57/96] test(smoke): lock accepted success statuses --- tests/unit/mcp-smoke.test.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/mcp-smoke.test.mjs b/tests/unit/mcp-smoke.test.mjs index dd10787..e145116 100644 --- a/tests/unit/mcp-smoke.test.mjs +++ b/tests/unit/mcp-smoke.test.mjs @@ -60,7 +60,7 @@ afterEach(async () => { }); describe("MCP smoke session lifecycle", () => { - it("closes an initialized session after a later check fails without printing secrets", async () => { + it.each(["complete", "partial"])("accepts %s and cleans up", async (status) => { const deletes = []; const server = createServer((request, response) => { const authorized = request.headers.authorization === `Bearer ${TOKEN}`; @@ -114,7 +114,7 @@ describe("MCP smoke session lifecycle", () => { } const resultText = JSON.stringify({ - status: "complete", + status, coverage: { successful_deployments: 2, requested_deployments: 2 }, }); const payload = { @@ -138,7 +138,7 @@ describe("MCP smoke session lifecycle", () => { expect(result.code).toBe(1); expect(result.stdout).toContain("notifications/initialized"); expect(result.stdout).toContain("FAIL"); - expect(result.stdout).toContain("tools/call"); + expect(result.stdout).toMatch(new RegExp(`tools/call\\s+PASS\\s+status=${status} 2/2`)); expect(deletes).toEqual([{ authorization: `Bearer ${TOKEN}`, sessionId: SESSION_ID }]); expect(`${result.stdout}${result.stderr}`).not.toContain(TOKEN); expect(`${result.stdout}${result.stderr}`).not.toContain(SESSION_ID); From 38913927f4f3e30f58951b274d61c92a9620978a Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:41:30 +0200 Subject: [PATCH 58/96] fix(smoke): send negotiated protocol version --- scripts/mcp-smoke.mjs | 48 ++++++++++++----- tests/unit/mcp-smoke.test.mjs | 99 ++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 16 deletions(-) diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index c69606f..aaf176f 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -79,6 +79,16 @@ function parseSse(text) { 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", @@ -117,32 +127,35 @@ await runCheck("unknown path", async () => { // Check 3: initialize -> expect 200 and non-empty mcp-session-id let sessionId = ""; +let negotiatedProtocolVersion = ""; const initOk = await runCheck("initialize", async () => { - const { status, headers } = await postJson( + 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 && sid !== "") { - sessionId = sid; - return { ok: true, detail: `status=200 session=established` }; - } if (status !== 200) { return { ok: false, detail: `status=${status} (expected 200)` }; } - return { ok: false, detail: `status=200 sid=(empty)` }; -}); + if (sid === "") { + return { ok: false, detail: `status=200 sid=(empty)` }; + } -if (!initOk || sessionId === "") { - console.log("initialize failed; skipping remaining checks"); - console.log(`${pass} passed, ${fail} failed`); - process.exit(1); -} + 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() { @@ -158,6 +171,13 @@ async function closeSession() { } } +if (!initOk || sessionId === "") { + if (sessionId !== "") await closeSession(); + console.log("initialize failed; skipping remaining checks"); + console.log(`${pass} passed, ${fail} failed`); + process.exit(1); +} + try { // Check 4: notifications/initialized -> expect 202 await runCheck("notifications/initialized", async () => { @@ -176,7 +196,7 @@ try { { jsonrpc: "2.0", id: 3, method: "tools/list" }, { headers: sessionHeaders(), timeoutMs: SHORT_TIMEOUT_MS }, ); - const msg = parseSse(text); + const msg = parseJsonRpc(text); const tools = msg?.result?.tools ?? []; const names = tools.map((t) => t?.name).filter((n) => typeof n === "string"); const ok = status === 200 && names.includes("compare_pools"); @@ -209,7 +229,7 @@ try { if (status !== 200) { return { ok: false, detail: `status=${status} (expected 200)` }; } - const msg = parseSse(text); + 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` }; diff --git a/tests/unit/mcp-smoke.test.mjs b/tests/unit/mcp-smoke.test.mjs index e145116..a2cdb0d 100644 --- a/tests/unit/mcp-smoke.test.mjs +++ b/tests/unit/mcp-smoke.test.mjs @@ -5,6 +5,7 @@ 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) => { @@ -62,6 +63,7 @@ afterEach(async () => { 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}`; @@ -78,6 +80,7 @@ describe("MCP smoke session lifecycle", () => { 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(); @@ -93,10 +96,24 @@ describe("MCP smoke session lifecycle", () => { const message = JSON.parse(body); if (message.method === "initialize") { - response.writeHead(200, { "mcp-session-id": SESSION_ID }).end(); + 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; @@ -139,8 +156,86 @@ describe("MCP smoke session lifecycle", () => { expect(result.stdout).toContain("notifications/initialized"); expect(result.stdout).toContain("FAIL"); expect(result.stdout).toMatch(new RegExp(`tools/call\\s+PASS\\s+status=${status} 2/2`)); - expect(deletes).toEqual([{ authorization: `Bearer ${TOKEN}`, sessionId: SESSION_ID }]); + expect(sessionRequests).toEqual([ + { method: "notifications/initialized", protocolVersion: PROTOCOL_VERSION }, + { method: "tools/list", 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"); }); }); From 6a1fff0f1a9d6d4a7b7ab22a8a8258bcb437cbeb Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:07:59 +0100 Subject: [PATCH 59/96] normalize large swap events (#43) - derive direction and exact human amounts from signed pool deltas - deduplicate canonical event identities and reject conflicts - filter selected-token thresholds without floating point or USD --- src/normalization/identities.ts | 25 +++ src/normalization/index.ts | 11 +- src/normalization/swaps.ts | 290 ++++++++++++++++++++++++++ tests/unit/swap-normalization.test.ts | 189 +++++++++++++++++ 4 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 src/normalization/swaps.ts create mode 100644 tests/unit/swap-normalization.test.ts diff --git a/src/normalization/identities.ts b/src/normalization/identities.ts index 45a41c1..5cc03dc 100644 --- a/src/normalization/identities.ts +++ b/src/normalization/identities.ts @@ -1,6 +1,8 @@ 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. */ @@ -29,3 +31,26 @@ export function pairIdentity(chainId: number, addressA: string, addressB: string 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 index 552ae87..a053284 100644 --- a/src/normalization/index.ts +++ b/src/normalization/index.ts @@ -2,8 +2,17 @@ export { normalizeAddress } from "./address.js"; export { convertPoolSourceResult, toPoolComparisonRecord } from "./convert.js"; export { deduplicateCanonicalPools } from "./dedupe.js"; export { NormalizationError } from "./error.js"; -export { pairIdentity, poolIdentity, tokenIdentity } from "./identities.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/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/tests/unit/swap-normalization.test.ts b/tests/unit/swap-normalization.test.ts new file mode 100644 index 0000000..2d47221 --- /dev/null +++ b/tests/unit/swap-normalization.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import { + NormalizationError, + deduplicateSwapEvents, + filterSwapsByTokenThreshold, + normalizeDeduplicateAndFilterSwaps, + normalizeLssSwapEvent, + swapEventIdentity, + swapMeetsTokenThreshold, + type RawLssSwapEvent, +} from "../../src/normalization/index.js"; +import { LSS_SCOPE } from "../../src/scope/index.js"; +import { usdcToWethSwapFixture, wethToUsdcSwapFixture } from "../fixtures/large-swaps.js"; + +function rawSwap(overrides: Partial = {}): RawLssSwapEvent { + return { + 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, + amount0_raw: "2500000000000000000", + amount1_raw: "-6250125000", + source_id: "fixture-nuthatch-swaps", + ...overrides, + }; +} + +describe("LSS swap normalization", () => { + it("derives WETH-in/USDC-out direction and preserves signed raw deltas", () => { + expect(normalizeLssSwapEvent(rawSwap())).toEqual(wethToUsdcSwapFixture); + }); + + it("derives reversed USDC-in/WETH-out direction", () => { + expect( + normalizeLssSwapEvent( + rawSwap({ + transaction_hash: `0x${"2".repeat(64)}`, + log_index: 7, + block_number: 20_000_098, + timestamp: 1_749_999_980, + amount0_raw: "-1200000000000000000", + amount1_raw: "3000000001", + }), + ), + ).toEqual(usdcToWethSwapFixture); + }); + + it("converts base units exactly at token precision without floating point", () => { + const normalized = normalizeLssSwapEvent( + rawSwap({ + amount0_raw: "1", + amount1_raw: "-1", + }), + ); + + expect(normalized.amount_in).toBe("0.000000000000000001"); + expect(normalized.amount_out).toBe("0.000001"); + + const large = normalizeLssSwapEvent( + rawSwap({ + amount0_raw: "123456789012345678901234567890123456789", + amount1_raw: "-987654321012345", + }), + ); + expect(large.amount_in).toBe("123456789012345678901.234567890123456789"); + expect(large.amount_out).toBe("987654321.012345"); + }); + + it("normalizes mixed-case pool and transaction identities", () => { + const normalized = normalizeLssSwapEvent( + rawSwap({ + pool: LSS_SCOPE.poolAddress.toUpperCase().replace("0X", "0x"), + transaction_hash: `0x${"A".repeat(64)}`, + }), + ); + + expect(normalized.pool).toBe(LSS_SCOPE.poolAddress); + expect(normalized.transaction_hash).toBe(`0x${"a".repeat(64)}`); + }); + + it("rejects malformed, zero, same-sign, and out-of-scope rows", () => { + for (const row of [ + rawSwap({ amount0_raw: "01" }), + rawSwap({ amount0_raw: "0" }), + rawSwap({ amount1_raw: "6250125000" }), + rawSwap({ chain_id: 1 }), + rawSwap({ protocol: "pancakeswap-v3" }), + rawSwap({ pool: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }), + ]) { + expect(() => normalizeLssSwapEvent(row)).toThrow(NormalizationError); + } + }); +}); + +describe("LSS swap identity and deduplication", () => { + it("builds a chain/transaction/log identity independent of hash casing", () => { + expect(swapEventIdentity(8453, `0x${"A".repeat(64)}`, 12)).toBe( + swapEventIdentity(8453, `0x${"a".repeat(64)}`, 12), + ); + expect(swapEventIdentity(8453, `0x${"a".repeat(64)}`, 12)).not.toBe( + swapEventIdentity(8453, `0x${"a".repeat(64)}`, 13), + ); + }); + + it("collapses exact duplicate events", () => { + const event = normalizeLssSwapEvent(rawSwap()); + expect(deduplicateSwapEvents([event, { ...event }])).toEqual([event]); + }); + + it("rejects conflicting rows with the same event identity", () => { + const event = normalizeLssSwapEvent(rawSwap()); + expect(() => + deduplicateSwapEvents([ + event, + { + ...event, + amount_in: "3", + amount_in_raw: "3000000000000000000", + }, + ]), + ).toThrow(NormalizationError); + }); +}); + +describe("LSS exact token threshold", () => { + const event = normalizeLssSwapEvent(rawSwap()); + + it("includes exact equality and excludes a value one base unit below", () => { + expect(swapMeetsTokenThreshold(event, LSS_SCOPE.tokens.weth.address, "2.5")).toBe(true); + expect( + swapMeetsTokenThreshold(event, LSS_SCOPE.tokens.weth.address, "2.500000000000000001"), + ).toBe(false); + expect(swapMeetsTokenThreshold(event, LSS_SCOPE.tokens.usdc.address, "6250.125")).toBe(true); + expect(swapMeetsTokenThreshold(event, LSS_SCOPE.tokens.usdc.address, "6250.125001")).toBe( + false, + ); + }); + + it("uses the absolute signed pool delta for either selected token", () => { + expect(event.amount_out_raw?.startsWith("-")).toBe(true); + expect(filterSwapsByTokenThreshold([event], LSS_SCOPE.tokens.usdc.address, "6000")).toEqual([ + event, + ]); + }); + + it("rejects unsupported tokens, zero thresholds, missing raw values, and wrong signs", () => { + expect(() => + swapMeetsTokenThreshold(event, "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "1"), + ).toThrow(NormalizationError); + expect(() => swapMeetsTokenThreshold(event, LSS_SCOPE.tokens.weth.address, "0")).toThrow( + NormalizationError, + ); + expect(() => + swapMeetsTokenThreshold( + { ...event, amount_in_raw: undefined }, + LSS_SCOPE.tokens.weth.address, + "1", + ), + ).toThrow(NormalizationError); + expect(() => + swapMeetsTokenThreshold( + { ...event, amount_out_raw: "6250125000" }, + LSS_SCOPE.tokens.usdc.address, + "1", + ), + ).toThrow(NormalizationError); + }); + + it("normalizes, deduplicates, and removes below-threshold rows in one pipeline", () => { + const below = rawSwap({ + transaction_hash: `0x${"3".repeat(64)}`, + log_index: 3, + amount0_raw: "1499999999999999999", + amount1_raw: "-3749999999", + }); + + expect( + normalizeDeduplicateAndFilterSwaps( + [rawSwap(), { ...rawSwap() }, below], + LSS_SCOPE.tokens.weth.address, + "1.5", + ), + ).toEqual([event]); + }); +}); From 91fb0cca1fc198c4dc63d9b035141256525fc694 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:35:01 +0200 Subject: [PATCH 60/96] feat(skill): guide verified pool research --- skills/deeptrace-pool-research/SKILL.md | 87 +++++++++++++++++++ .../agents/openai.yaml | 12 +++ 2 files changed, 99 insertions(+) create mode 100644 skills/deeptrace-pool-research/SKILL.md create mode 100644 skills/deeptrace-pool-research/agents/openai.yaml diff --git a/skills/deeptrace-pool-research/SKILL.md b/skills/deeptrace-pool-research/SKILL.md new file mode 100644 index 0000000..9822d2b --- /dev/null +++ b/skills/deeptrace-pool-research/SKILL.md @@ -0,0 +1,87 @@ +--- +name: deeptrace-pool-research +description: Research and compare the locked Base WETH/USDC liquidity pools through the DeepTrace MCP server, using Graph subgraph metrics together with Nuthatch swap freshness and provenance. Use when an end user asks to compare pools, rank by TVL, volume, or fees, inspect 24h or 7d metrics, verify source freshness, explain partial results, or distinguish Graph and Nuthatch evidence. +--- + +# DeepTrace pool research + +Use the `compare_pools` MCP tool. Read `structuredContent` when available; +otherwise parse the JSON object in the text result. + +## 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. +- 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 + +Always use the locked scope: + +- `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` + +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. + +Refuse unsupported chains, pairs, windows, or metrics by stating the exact +supported scope. Never silently change the requested assets. + +## Interpret the sources + +- Treat Graph subgraphs as the source of pool TVL, volume, and fee metrics. + Preserve every financial value as the exact returned decimal string. +- Treat Nuthatch as an independent freshness fact for indexed Uniswap V3 + `Swap` events on the registered pool. Use its recent swap count, last swap + block, timestamp, and block hash only as returned. +- Treat `recent_swap_count` as the count for Nuthatch's configured view + lookback. Do not equate it with the requested `24h` or `7d` financial window + unless the response explicitly returns the same interval. +- 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. +- Join freshness and provenance to results by `source_id`. Name the source, + returned `deployment_id`, query ID, and warnings when they affect confidence. + Do not infer whether a `deployment_id` identifies a Graph deployment or a + Nuthatch view. + +## Present the answer + +1. Lead with what was compared, the window, ranking metric, and overall + `complete`, `partial`, or `failed` status. +2. List each returned pool in rank order with protocol, pool address, TVL, + volume, fees, and source IDs. Show `null` as unavailable. +3. Report the Nuthatch freshness fact separately. 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 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. + +## Preserve evidence integrity + +- Never replace nulls with estimates or derive USD values from other fields. +- Never convert financial strings through floating-point arithmetic. +- Never infer a fee tier by dividing fees by volume. +- Never suppress warnings or describe stale/unavailable data as fresh. +- Never invent methodology or schema versions when the response returns null. diff --git a/skills/deeptrace-pool-research/agents/openai.yaml b/skills/deeptrace-pool-research/agents/openai.yaml new file mode 100644 index 0000000..a6a1f39 --- /dev/null +++ b/skills/deeptrace-pool-research/agents/openai.yaml @@ -0,0 +1,12 @@ +interface: + display_name: "DeepTrace Pool Research" + short_description: "Compare Base pools with Graph and Nuthatch" + default_prompt: "Use $deeptrace-pool-research to compare Base WETH/USDC pools with verified source freshness and provenance." + +dependencies: + tools: + - type: "mcp" + value: "deeptrace" + description: "Read-only Base pool metrics and Nuthatch freshness" + transport: "streamable_http" + url: "https://mcp.ikodo.dev" From 4991bd65e790b9213aa9261e15171e9681a7bf22 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:38:57 +0200 Subject: [PATCH 61/96] feat(http): serve MCP at the root URL --- src/http/server.ts | 5 ++-- tests/unit/http-transport.test.ts | 42 ++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/http/server.ts b/src/http/server.ts index 3697515..06849a8 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -12,7 +12,8 @@ import { createLiveComparePoolsSources } from "../tools/index.js"; import { isAuthorized } from "./auth.js"; import type { HttpConfig } from "./config.js"; -const MCP_PATH = "/mcp"; +/** Root is canonical; /mcp remains an alias for existing client configs. */ +const MCP_PATHS = new Set(["/", "/mcp"]); const SESSION_HEADER = "mcp-session-id"; /** Bounds memory held by abandoned sessions that never send DELETE. */ const MAX_SESSIONS = 64; @@ -230,7 +231,7 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions void (async () => { try { const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); - if (url.pathname !== MCP_PATH) { + if (!MCP_PATHS.has(url.pathname)) { respondJson(response, 404, "not_found", "Unknown endpoint"); return; } diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index 0f889b1..c45c786 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -187,6 +187,7 @@ const INITIALIZE_BODY = { interface TestServer { readonly runtime: HttpRuntime; readonly base: string; + readonly legacy: string; } interface TestServerOptions { @@ -208,7 +209,8 @@ async function startTestServer(options: TestServerOptions = {}): Promise { + 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(); + } + }); +}); + describe("HTTP authentication", () => { it("returns a challenge-free 404 for top-level browser navigation", async () => { const { runtime, base } = await startTestServer(); From a872b3ca71cd00dbcef2ebd3e8fde5058784e375 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:39:27 +0200 Subject: [PATCH 62/96] docs: simplify public MCP connection --- .env.example | 6 ++- README.md | 33 +++++++++---- docs/connect.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 docs/connect.md diff --git a/.env.example b/.env.example index cd0f74c..e72733f 100644 --- a/.env.example +++ b/.env.example @@ -12,8 +12,10 @@ BASE_RPC_URL_PRIMARY= BASE_RPC_URL_SECONDARY= BASE_RPC_URL_TERTIARY= -# Tailnet-only URL of the Nuthatch HTTP API, without a trailing slash. -# Example: https://wallet-intel.example-tailnet.ts.net +# 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. diff --git a/README.md b/README.md index ccee076..4c5cee4 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,25 @@ # DeepTrace -DeepTrace is a read-only Graph research MCP for builders and AI agents. +DeepTrace is a read-only research MCP that compares Base liquidity pools with +Graph subgraph metrics and independent Nuthatch swap freshness. -The current foundation starts an MCP server over stdio. Public research tools -are added in later milestones; this branch does not register a provisional -`compare_pools` tool. +## Connect + +Use the public Streamable HTTP endpoint: + +```text +https://mcp.ikodo.dev +``` + +Configure the access token as an `Authorization: Bearer` header in your MCP +client. A normal user does not need Tailscale, a Graph API key, Nuthatch access, +or a local checkout. See [Connect an AI client](docs/connect.md) for the short +setup and compatibility notes. + +The installable +[DeepTrace Pool Research skill](skills/deeptrace-pool-research/SKILL.md) +teaches a client AI how to call `compare_pools`, explain the separate Graph and +Nuthatch evidence, and preserve warnings and provenance. ## Requirements @@ -22,7 +37,7 @@ npm test npm run build ``` -## Start the Server +## Run locally Build before starting: @@ -31,8 +46,8 @@ npm run build npm start ``` -The server reads MCP messages from stdin and writes MCP messages to stdout. -Application diagnostics use stderr so they cannot corrupt the protocol stream. +`npm start` runs the stdio transport. Application diagnostics use stderr so +they cannot corrupt the protocol stream. ## MCP Client Configuration @@ -49,5 +64,5 @@ Use an absolute path to the built entry point: } ``` -Current Nuthatch deployment notes live in `docs/deployment.md`. Live source -adapter configuration and `compare_pools` are integrated in later milestones. +Deployment notes live in [docs/deployment.md](docs/deployment.md), and the +operator test procedure lives in [docs/mcp-testing.md](docs/mcp-testing.md). diff --git a/docs/connect.md b/docs/connect.md new file mode 100644 index 0000000..701e242 --- /dev/null +++ b/docs/connect.md @@ -0,0 +1,120 @@ +# 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. Ask the +DeepTrace maintainer for a token through a secure channel. + +## 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. Export the +token in the shell that launches Claude Code: + +```sh +read -rsp "DeepTrace token: " DEEPTRACE_TOKEN +export DEEPTRACE_TOKEN +``` + +Then add this project-level `.mcp.json`: + +```json +{ + "mcpServers": { + "deeptrace": { + "type": "http", + "url": "https://mcp.ikodo.dev", + "headers": { + "Authorization": "Bearer ${DEEPTRACE_TOKEN}" + } + } + } +} +``` + +The file contains only the variable reference, not the secret. Confirm the +connection: + +```sh +claude mcp list +``` + +## Other AI 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. The current DeepTrace endpoint does +not advertise OAuth. In those clients, do not paste the token into chat or add +it to the URL; use a client that supports a custom authorization header. + +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. + +## Use the agent skill + +Clients that support Agent Skills can install the +[`deeptrace-pool-research`](../skills/deeptrace-pool-research/SKILL.md) folder. +The skill makes the AI preserve exact decimal strings, separate Graph metrics +from Nuthatch facts, surface partial coverage, and avoid invented fallbacks. + +## Troubleshoot + +| Symptom | Meaning | +| --- | --- | +| `401 Unauthorized` | The server is reachable, but the bearer token is missing, invalid, or expired. | +| `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 | That client cannot use the current fixed-bearer endpoint directly. | From 78df2cac0249147f3348622db1e63634b5accbb6 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:51:57 +0200 Subject: [PATCH 63/96] feat(mcp): teach clients verified pool research --- src/mcp/server.ts | 79 +++++++++++++++++++++++---- tests/unit/compare-pools-tool.test.ts | 18 +++++- tests/unit/mcp-lifecycle.test.ts | 7 ++- 3 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 84545f8..cc31b2a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,8 +5,15 @@ 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_RANKING_METRICS, M0_TIME_WINDOWS } from "../policy/index.js"; -import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; +import { + comparePoolsResponseSchema, + coverageSchema, + poolComparisonDataSchema, + resultFreshnessSchema, + resultProvenanceSchema, +} from "../schemas/index.js"; import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; +import { M0_COMPARE_POOLS_SCOPE } from "../scope/compare-pools.js"; import { ComparePoolsRequestError, createLiveComparePoolsSources, @@ -21,17 +28,63 @@ export const serverInfo = { export const COMPARE_POOLS_TOOL_NAME = "compare_pools" as const; +export const serverInstructions = + "DeepTrace is read-only and supports only the locked Base (chain 8453) native WETH/USDC pair. Use compare_pools for 24h or 7d rankings by TVL, volume, or fees. Graph subgraphs supply pool financial metrics; Nuthatch supplies independent indexed-block and recent-swap freshness facts, never financial values. Always report status and warnings, distinguish stale or unavailable sources, cite source_ids with provenance, and never present partial results as complete."; + const comparePoolsInputSchema = 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).optional(), - ranked_by: z.enum(M0_RANKING_METRICS).optional(), - top_n: z.number().int().min(1).max(M0_CORE_POLICY.topN.maximum).optional(), + 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(); +// 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, + }); + } + }); + export interface CreateMcpServerOptions { readonly gatewayConfig?: GatewayConfig; readonly rateLimiter?: FixedWindowRateLimiter; @@ -61,17 +114,20 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer }); const rateLimitKey = options.rateLimitKey ?? "compare_pools"; - const server = new McpServer(serverInfo); + const server = new McpServer(serverInfo, { + instructions: serverInstructions, + }); server.registerTool( COMPARE_POOLS_TOOL_NAME, { - title: "Compare pools", + title: "Compare Base WETH/USDC pools", description: - "Compare locked Base WETH/USDC pools across configured Graph sources. Read-only.", + "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 pools", + title: "Compare Base WETH/USDC pools", readOnlyHint: true, destructiveHint: false, idempotentHint: true, @@ -90,6 +146,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer text: JSON.stringify(response), }, ], + structuredContent: response, }; } catch (error) { if (error instanceof RateLimitError) { diff --git a/tests/unit/compare-pools-tool.test.ts b/tests/unit/compare-pools-tool.test.ts index 87142f8..3f8ebc6 100644 --- a/tests/unit/compare-pools-tool.test.ts +++ b/tests/unit/compare-pools-tool.test.ts @@ -111,7 +111,22 @@ describe("compare_pools MCP tool", () => { try { const listed = await client.listTools(); expect(listed.tools.map((tool) => tool.name)).toEqual([COMPARE_POOLS_TOOL_NAME]); - expect(listed.tools[0]?.annotations?.readOnlyHint).toBe(true); + 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(); @@ -144,6 +159,7 @@ describe("compare_pools MCP tool", () => { expect(body.coverage.nuthatch_available).toBe(false); expect(body.coverage.successful_deployments).toBe(2); expect(body.data?.pools[0]?.source_ids).toEqual(["exchange-v3-base"]); + expect(result.structuredContent).toEqual(body); expect(onGraphFetch).toHaveBeenCalledTimes(1); } finally { await client.close(); diff --git a/tests/unit/mcp-lifecycle.test.ts b/tests/unit/mcp-lifecycle.test.ts index 3b05e6a..f6a723c 100644 --- a/tests/unit/mcp-lifecycle.test.ts +++ b/tests/unit/mcp-lifecycle.test.ts @@ -8,7 +8,7 @@ import { type ShutdownSignal, type ShutdownSignalTarget, } from "../../src/mcp/lifecycle.js"; -import { serverInfo } from "../../src/mcp/server.js"; +import { serverInfo, serverInstructions } from "../../src/mcp/server.js"; class TestSignalTarget implements ShutdownSignalTarget { private readonly listeners = new Map void>(); @@ -47,6 +47,11 @@ describe("MCP server lifecycle", () => { 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(); From 6daa65a6f169d6812a2245bd8f7d89cfa3005c3c Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:57:57 +0200 Subject: [PATCH 64/96] fix(skill): align guidance with response schema --- skills/deeptrace-pool-research/SKILL.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/skills/deeptrace-pool-research/SKILL.md b/skills/deeptrace-pool-research/SKILL.md index 9822d2b..d769371 100644 --- a/skills/deeptrace-pool-research/SKILL.md +++ b/skills/deeptrace-pool-research/SKILL.md @@ -8,6 +8,10 @@ description: Research and compare the locked Base WETH/USDC liquidity pools thro Use the `compare_pools` MCP tool. Read `structuredContent` when available; otherwise parse the JSON object in the text result. +For `complete` or `partial`, read ranked records from `data.pools` and the +optional Nuthatch fact from `data.nuthatch_freshness_fact`. For `failed`, +`data` is null. + ## Connect safely - Use the canonical remote endpoint `https://mcp.ikodo.dev`. @@ -46,19 +50,19 @@ supported scope. Never silently change the requested assets. - Treat Graph subgraphs as the source of pool TVL, volume, and fee metrics. Preserve every financial value as the exact returned decimal string. - Treat Nuthatch as an independent freshness fact for indexed Uniswap V3 - `Swap` events on the registered pool. Use its recent swap count, last swap - block, timestamp, and block hash only as returned. -- Treat `recent_swap_count` as the count for Nuthatch's configured view - lookback. Do not equate it with the requested `24h` or `7d` financial window - unless the response explicitly returns the same interval. + `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. - Join freshness and provenance to results by `source_id`. Name the source, - returned `deployment_id`, query ID, and warnings when they affect confidence. - Do not infer whether a `deployment_id` identifies a Graph deployment or a - Nuthatch view. + returned `deployment_or_view_id`, query ID, and warnings when they affect + confidence. Do not infer which kind the combined identifier represents. ## Present the answer From 8486472b441790bec3b25b718613a01ae34b4650 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 02:59:25 +0200 Subject: [PATCH 65/96] docs: add three-client MCP setup --- README.md | 8 +- docs/connect.md | 79 ++++++++++++++--- docs/mcp-testing.md | 208 ++++++++++++++++++++++++++------------------ 3 files changed, 196 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 4c5cee4..fdb5e4f 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,12 @@ client. A normal user does not need Tailscale, a Graph API key, Nuthatch access, or a local checkout. See [Connect an AI client](docs/connect.md) for the short setup and compatibility notes. -The installable +The optional installable [DeepTrace Pool Research skill](skills/deeptrace-pool-research/SKILL.md) -teaches a client AI how to call `compare_pools`, explain the separate Graph and -Nuthatch evidence, and preserve warnings and provenance. +adds a richer workflow. The MCP server itself supplies essential usage +instructions, described inputs, a declared output schema, and structured +results so Claude Code, OpenCode, and Codex work without a separate skill +installation. ## Requirements diff --git a/docs/connect.md b/docs/connect.md index 701e242..e2d3d9e 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -33,15 +33,8 @@ connections should use the root URL. ## Claude Code -Claude Code supports environment-variable expansion in HTTP headers. Export the -token in the shell that launches Claude Code: - -```sh -read -rsp "DeepTrace token: " DEEPTRACE_TOKEN -export DEEPTRACE_TOKEN -``` - -Then add this project-level `.mcp.json`: +Claude Code supports environment-variable expansion in HTTP headers. Add this +project-level `.mcp.json`: ```json { @@ -51,20 +44,78 @@ Then add this project-level `.mcp.json`: "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}" } } } } ``` -The file contains only the variable reference, not the secret. Confirm the -connection: +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 ``` -## Other AI clients +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. + +## 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 @@ -102,12 +153,14 @@ parity. A `partial` result can still contain useful evidence. Read its coverage, freshness, warnings, and provenance before relying on the ranking. -## Use the agent skill +## Optional agent skill Clients that support Agent Skills can install the [`deeptrace-pool-research`](../skills/deeptrace-pool-research/SKILL.md) folder. The skill makes the AI preserve exact decimal strings, separate Graph metrics from Nuthatch facts, surface partial coverage, and avoid invented fallbacks. +It is not required for Claude Code, OpenCode, or Codex to use DeepTrace safely: +the MCP server already supplies its essential instructions. ## Troubleshoot diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index 8e72644..22db1bc 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -2,66 +2,68 @@ 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 -shared tailnet gateway, the Nuthatch source that sits behind it, and the -operations needed on container CT 104. It is written for someone who has never -run this repo before. +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 common support issue is confusing the two HTTP surfaces on the -tailnet. Read "Two surfaces" before touching anything else. +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 exposed over Tailscale Serve on the same tailnet -host. They back onto different local ports and answer to different clients. -Mixing them up is the #1 support cost. +There are two HTTP services, but only the DeepTrace MCP gateway is public: | Surface | URL | Backs onto | Who calls it | | --- | --- | --- | --- | -| Nuthatch source API | https:// (:443) | 127.0.0.1:8288 | the DeepTrace Nuthatch adapter | -| DeepTrace MCP gateway | https://:8443/mcp | 127.0.0.1:8787 | MCP clients (Claude Code etc.) | +| 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 | -Both are tailnet-only via Tailscale Serve. There is no Funnel. - -The MCP gateway serves only the path `/mcp`. Everything else returns 404. -`/health`, `/ready`, `/nest`, `/schema`, `/sql` are Nuthatch routes on `:443`; -they do not exist on `:8443`. +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 outside the tailnet who only needs to call -the tool. You do not clone the repo, build anything, or hold any Graph -credential. +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. A Tailscale node-share invitation for the machine `wallet-intel`. Accept it - from the invite link. It shares one machine only — the rest of the tailnet - stays invisible. -2. The bearer token for the MCP gateway. Sent out of band, never in the repo. -3. The gateway URL: https://:8443/mcp -4. Confirmation that the maintainer has applied the ACL grant for your - Tailscale identity on tcp:8443. Without it every request times out. - -### Setup +1. The gateway URL: `https://mcp.ikodo.dev` +2. A bearer token sent through a secure channel -Join the tailnet: +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. -``` -tailscale up -tailscale status # expect a wallet-intel row -``` +### Setup -Register the MCP server with Claude Code: +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`: -``` -claude mcp add --transport http deeptrace \ - https://:8443/mcp \ - --header "Authorization: Bearer " +```json +{ + "mcpServers": { + "deeptrace": { + "type": "http", + "url": "https://mcp.ikodo.dev", + "headers": { + "Authorization": "Bearer ${DEEPTRACE_TOKEN}" + }, + "alwaysLoad": true + } + } +} ``` -Confirm it connected: +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 ``` @@ -70,17 +72,20 @@ claude mcp list # expect: deeptrace: ... (HTTP) - ✔ Connected 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 result with status `partial` -is a success: two Graph sources answered, Nuthatch is not wired in yet. See -"Known gaps" for why. +`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. ### If it does not work — report back which one | You see | What it means | | --- | --- | -| Timeout | The ACL grant is missing or names the wrong identity. A maintainer fix, not yours. Send them your exact Tailscale identity. | +| 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 | Check the URL ends in `/mcp`. Only that path is served on `:8443`. | +| 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. @@ -99,7 +104,7 @@ shell or systemd unit that starts the server. | `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` | `https://`, no trailing slash. | +| `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 @@ -165,9 +170,9 @@ first; drop to the manual steps below only when something fails and you need to see the raw exchange. ``` -DEEPTRACE_MCP_URL=https://:8443/mcp \ -DEEPTRACE_HTTP_TOKEN= \ - npm run smoke:mcp +read -rsp "DeepTrace bearer token: " DEEPTRACE_HTTP_TOKEN && echo +export DEEPTRACE_HTTP_TOKEN +DEEPTRACE_MCP_URL=https://mcp.ikodo.dev npm run smoke:mcp ``` ``` @@ -176,24 +181,29 @@ 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] -tools/call PASS status=partial 2/2 +tools/call PASS status=complete 2/2 6 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. Point `DEEPTRACE_MCP_URL` at `http://127.0.0.1:8787/mcp` to -check a local instance instead. 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. - -`status=partial` on the final check is a pass: the Graph sources answered and -Nuthatch is not yet wired in. See "Known gaps". +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. + +Both `complete` and `partial` prove that the MCP tool call completed. Production +normally returns `complete`; `partial` means at least one upstream source was +stale or unavailable and must be explained from the returned warnings. ### Manual path -The HTTP handshake is four steps. Skipping step 2 is the usual mistake. +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`: @@ -208,7 +218,7 @@ Responses come back as SSE frames prefixed with `data: `. 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/mcp \ +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" \ @@ -221,10 +231,11 @@ SID=$(curl -sS -D - -o /dev/null -X POST http://127.0.0.1:8799/mcp \ Returns HTTP 202. Requests sent before this are rejected. ``` -curl -sS -X POST http://127.0.0.1:8799/mcp \ +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"}' ``` @@ -233,10 +244,11 @@ curl -sS -X POST http://127.0.0.1:8799/mcp \ Verified to return exactly one tool, `compare_pools`. ``` -curl -sS -X POST http://127.0.0.1:8799/mcp \ +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"}' ``` @@ -248,34 +260,46 @@ the WETH address, `token1` the native USDC address. `window` is `"24h"` or 1..3. ``` -curl -sS --max-time 90 -X POST http://127.0.0.1:8799/mcp \ +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"}}}' ``` Verified result today (values change; shape does not): ``` -status: partial -coverage: {"requested_deployments":2,"successful_deployments":2,"nuthatch_available":false} +status: complete +coverage: {"requested_deployments":2,"successful_deployments":2,"nuthatch_available":true} pool 0x6c561b44... tvl 151138739.377709 pool 0x72ab388e... tvl 6612457.78705088 ``` -`partial` is correct, not a failure. See Known gaps. +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. -## Connect a client +### Cleanup — close the session + +Close the Streamable HTTP session even when a later check fails: ``` -claude mcp add --transport http deeptrace \ - https://:8443/mcp \ - --header "Authorization: Bearer " -claude mcp list +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" ``` -Verified: prints `deeptrace: ... (HTTP) - ✔ Connected`. +## 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: @@ -285,7 +309,14 @@ claude mcp remove deeptrace ## Verify the Nuthatch source -Three checks. All pass as of this writing. +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 @@ -336,18 +367,20 @@ Row fields: `pool_address`, `recent_swap_count_24h`, `last_swap_block`, | Symptom | Cause | | --- | --- | -| Timeout on :8443 | Tailscale ACL identity mismatch. The shared user is `kapustazh@github` — a GitHub identity, not an email. An ACL grant naming an email never matches, and the denial presents as a timeout, indistinguishable from a dead port. Read the identity from Machines -> wallet-intel -> Sharing. | +| 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 404 on :8443/health | Only `/mcp` is routed. `/health` and `/ready` are Nuthatch routes on `:443`. | +| 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` missing or shorter than 32 characters. | | `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. | -ACL denials always look like timeouts. Before concluding a service is down, test -the same URL from a tailnet member. If a member succeeds and the shared user -times out, it is the ACL, every time. +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 @@ -355,6 +388,8 @@ 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`). +Production sets `NUTHATCH_BASE_URL=http://127.0.0.1:8288`; the public proxy +routes only to the DeepTrace HTTP service. Access is via the Proxmox host; there is no direct SSH into the container: @@ -377,6 +412,13 @@ 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. + Rotate the MCP token: ``` @@ -388,11 +430,11 @@ service, and reissue the token to every client. ## Known gaps -1. Nuthatch backfill has not reached the chain tip, so the freshness view trails - live. `nuthatch-watchdog.timer` detects ten minutes without indexed-block +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 reports that source as - stale until the backfill catches up, so a `partial` result remains expected. -2. There is no live integration test for the MCP server. The offline test suite is - entirely offline. + 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` 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. From ab4994a4a8c3111f15e53846c54926c2ff35bde9 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 03:01:21 +0200 Subject: [PATCH 66/96] feat(http): build public connection page --- src/http/connection-page.ts | 362 ++++++++++++++++++++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 src/http/connection-page.ts diff --git a/src/http/connection-page.ts b/src/http/connection-page.ts new file mode 100644 index 0000000..68bc7a8 --- /dev/null +++ b/src/http/connection-page.ts @@ -0,0 +1,362 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +const SECURITY_HEADERS = { + "cache-control": "no-store", + "content-security-policy": + "default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'none'; 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; + +const CONNECTION_PAGE = ` + + + + + + Connect DeepTrace to your AI + + + +
+

DeepTrace MCP

+

Connect pool research to your AI

+

One remote server gives Claude Code, OpenCode, or Codex read-only Graph metrics with separate Nuthatch freshness evidence.

+
+
+ + +
+

Before you connect

+
    +
  1. Ask the DeepTrace maintainer for a bearer token through a secure channel.
  2. +
  3. Store it in DEEPTRACE_TOKEN; never place it in a URL, prompt, repository, or support log.
  4. +
  5. Add the configuration for your client, launch it from the same shell, and verify deeptrace is connected.
  6. +
+
read -rsp "DeepTrace token: " DEEPTRACE_TOKEN
+export DEEPTRACE_TOKEN
+
+ +
+

Choose your AI client

+
+
+

Claude Code

+

Save as project-level .mcp.json, then run claude mcp list.

+
{
+  "mcpServers": {
+    "deeptrace": {
+      "type": "http",
+      "url": "https://mcp.ikodo.dev",
+      "headers": {
+        "Authorization": "Bearer \${DEEPTRACE_TOKEN}"
+      },
+      "alwaysLoad": true
+    }
+  }
+}
+
+
+

OpenCode

+

Save in ~/.config/opencode/opencode.json, then run opencode mcp list.

+
{
+  "$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}"
+      }
+    }
+  }
+}
+
+
+

Codex

+

Add to ~/.codex/config.toml, then run codex mcp list.

+
[mcp_servers.deeptrace]
+url = "https://mcp.ikodo.dev"
+bearer_token_env_var = "DEEPTRACE_TOKEN"
+
+
+
+ +
+

Try a pool comparison

+

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.
+

A partial result can still contain useful evidence. Read its coverage, freshness, warnings, and provenance before relying on the ranking.

+
+ +
+

Optional Agent Skill

+

The MCP server works without a skill: all three clients discover the compare_pools tool and its safety guidance automatically.

+
+ What does SKILL.md add? +

Install skills/deeptrace-pool-research/SKILL.md separately in clients that support Agent Skills. It teaches the AI to preserve exact decimal strings, distinguish Graph metrics from Nuthatch freshness facts, and surface partial coverage. Connecting MCP does not automatically install or load this file.

+

View the DeepTrace Pool Research skill.

+
+
+
+
+

Canonical MCP URL: https://mcp.ikodo.dev. The older /mcp path remains available only for existing client configurations. Read the full setup guide.

+
+ + +`; + +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); +} From d4dfe3be59062feeb00be34ed5000b87f91ef98f Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 03:01:28 +0200 Subject: [PATCH 67/96] feat(http): route browsers to connection page --- src/http/server.ts | 6 ++ tests/unit/http-transport.test.ts | 101 ++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/http/server.ts b/src/http/server.ts index 06849a8..afd9bb6 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -11,6 +11,7 @@ import { createMcpServer } from "../mcp/server.js"; import { createLiveComparePoolsSources } from "../tools/index.js"; import { isAuthorized } from "./auth.js"; import type { HttpConfig } from "./config.js"; +import { acceptsConnectionPage, respondConnectionPage } from "./connection-page.js"; /** Root is canonical; /mcp remains an alias for existing client configs. */ const MCP_PATHS = new Set(["/", "/mcp"]); @@ -236,6 +237,11 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions 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. diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index c45c786..4eec0e1 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -289,7 +289,8 @@ function createFakeSessionTimer(): { async function getBrowserNavigation(base: string): Promise<{ readonly status: number | undefined; readonly challenge: string | undefined; - readonly body: unknown; + readonly headers: Readonly>; + readonly body: string; }> { return new Promise((resolve, reject) => { const request = httpRequest( @@ -310,7 +311,8 @@ async function getBrowserNavigation(base: string): Promise<{ resolve({ status: response.statusCode, challenge: response.headers["www-authenticate"], - body: JSON.parse(body) as unknown, + headers: response.headers, + body, }); }); }, @@ -356,17 +358,78 @@ describe("HTTP routing", () => { 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("

Connect pool research to your AI

"); + expect(body).toContain("Claude Code"); + expect(body).toContain("OpenCode"); + expect(body).toContain("Codex"); + expect(body).toContain("No Tailscale required"); + expect(body).toContain("Connecting MCP does not automatically install or load this file"); + expect(body).not.toMatch(/ { + 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")).toBe('Bearer realm="deeptrace"'); + await response.text(); + } finally { + await runtime.close(); + } + }); }); describe("HTTP authentication", () => { - it("returns a challenge-free 404 for top-level browser navigation", async () => { - const { runtime, base } = await startTestServer(); + it("keeps /mcp browser navigation as a challenge-free JSON 404", async () => { + const { runtime, legacy } = await startTestServer(); try { - const response = await getBrowserNavigation(base); + const response = await getBrowserNavigation(legacy); expect(response.status).toBe(404); expect(response.challenge).toBeUndefined(); - expect(response.body).toEqual({ + expect(JSON.parse(response.body)).toEqual({ error: { code: "not_found", message: "This endpoint is available to MCP clients", @@ -401,6 +464,32 @@ describe("HTTP authentication", () => { 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")).toBe('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", () => { From b3e5d2e54755f10bebaafb29d22cf1afe8879e55 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 03:05:25 +0200 Subject: [PATCH 68/96] fix(http): validate MCP request origins --- src/http/server.ts | 11 +++++++ tests/unit/http-transport.test.ts | 53 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/http/server.ts b/src/http/server.ts index afd9bb6..f94bad7 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -15,6 +15,7 @@ import { acceptsConnectionPage, respondConnectionPage } from "./connection-page. /** 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; @@ -78,6 +79,11 @@ function respondJson( response.end(JSON.stringify({ error: { code, message } })); } +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; @@ -232,6 +238,11 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions void (async () => { try { const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); + if (!hasAllowedOrigin(request)) { + respondJson(response, 403, "invalid_origin", "Origin is not allowed"); + return; + } + if (!MCP_PATHS.has(url.pathname)) { respondJson(response, 404, "not_found", "Unknown endpoint"); return; diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index 4eec0e1..c31ceda 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -465,6 +465,59 @@ describe("HTTP authentication", () => { } }); + 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) => { From 0c660ac365e8780ad0f42e24b07b3c9c46d1dc96 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 03:05:39 +0200 Subject: [PATCH 69/96] docs: explain MCP origin rejection --- docs/mcp-testing.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index 22db1bc..f2c86ed 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -369,6 +369,7 @@ Row fields: `pool_address`, `recent_swap_count_24h`, `last_swap_block`, | --- | --- | | 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` missing or shorter than 32 characters. | From ea5e06333036d8f524a4419806c92fb179bc7426 Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:33:50 +0100 Subject: [PATCH 70/96] implement stable large swap pagination (#45) --- src/schemas/large-swaps-request.ts | 1 + src/schemas/large-swaps.ts | 1 + src/scope/large-swaps.ts | 4 + src/tools/index.ts | 8 + src/tools/large-swaps-query.ts | 262 ++++++++++++++++++++ tests/fixtures/large-swaps-request.ts | 9 +- tests/fixtures/large-swaps.ts | 3 +- tests/unit/large-swaps-query.test.ts | 319 +++++++++++++++++++++++++ tests/unit/large-swaps-request.test.ts | 1 + 9 files changed, 604 insertions(+), 4 deletions(-) create mode 100644 src/tools/large-swaps-query.ts create mode 100644 tests/unit/large-swaps-query.test.ts diff --git a/src/schemas/large-swaps-request.ts b/src/schemas/large-swaps-request.ts index 3c502a5..848e748 100644 --- a/src/schemas/large-swaps-request.ts +++ b/src/schemas/large-swaps-request.ts @@ -13,6 +13,7 @@ const positiveDecimalStringSchema = z const opaqueCursorSchema = z .string() .min(1) + .max(LSS_SCOPE.cursor.maximumLength) .refine((value) => value.trim() === value, "Cursor must not contain surrounding whitespace"); /** diff --git a/src/schemas/large-swaps.ts b/src/schemas/large-swaps.ts index 726b0ba..795f869 100644 --- a/src/schemas/large-swaps.ts +++ b/src/schemas/large-swaps.ts @@ -22,6 +22,7 @@ const signedIntegerStringSchema = z 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 diff --git a/src/scope/large-swaps.ts b/src/scope/large-swaps.ts index 55e9a1a..3717ff9 100644 --- a/src/scope/large-swaps.ts +++ b/src/scope/large-swaps.ts @@ -25,6 +25,10 @@ export const LSS_SCOPE = { default: 25, maximum: 100, }, + cursor: { + version: 1, + maximumLength: 2_048, + }, } as const; export type LssScope = typeof LSS_SCOPE; diff --git a/src/tools/index.ts b/src/tools/index.ts index 7b7357a..224d075 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,3 +9,11 @@ export { createLiveComparePoolsSources, type LiveComparePoolsSourcesOptions, } from "./live-sources.js"; +export { + LargeSwapCursorError, + LargeSwapQueryError, + compareSwapPageOrder, + queryLargeSwapPage, + type LargeSwapQueryInput, + type LargeSwapQueryPage, +} from "./large-swaps-query.js"; diff --git a/src/tools/large-swaps-query.ts b/src/tools/large-swaps-query.ts new file mode 100644 index 0000000..90812fb --- /dev/null +++ b/src/tools/large-swaps-query.ts @@ -0,0 +1,262 @@ +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 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."); + } +} + +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 request = parseRequest(input.request); + assertIndexedHead(input.indexedHead); + + const decodedCursor = request.cursor === null ? null : decodeCursor(request.cursor); + if (decodedCursor !== null) { + assertCursorScope(decodedCursor, request); + if (input.indexedHead < decodedCursor.snapshot_head) { + throw new LargeSwapCursorError("Large-swap source head is behind the cursor snapshot."); + } + } + const snapshotHead = decodedCursor?.snapshot_head ?? 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 (decodedCursor !== null) { + const anchorIndex = ordered.findIndex((event) => + cursorMatchesEvent(decodedCursor.last_event, 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/tests/fixtures/large-swaps-request.ts b/tests/fixtures/large-swaps-request.ts index 88fc4c2..0b63826 100644 --- a/tests/fixtures/large-swaps-request.ts +++ b/tests/fixtures/large-swaps-request.ts @@ -1,6 +1,9 @@ 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, @@ -12,7 +15,7 @@ export const lockedLargeSwapsRequestWithOptions = { chain_id: LSS_SCOPE.chainId, pool_address: LSS_SCOPE.poolAddress, threshold_token: LSS_SCOPE.tokens.usdc.address, - min_amount: "2500.000001", - limit: 10, - cursor: "lss:v1:fixture-page-2", + 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 index 2b46101..8026dc0 100644 --- a/tests/fixtures/large-swaps.ts +++ b/tests/fixtures/large-swaps.ts @@ -5,6 +5,7 @@ import type { 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"; @@ -138,7 +139,7 @@ export const paginatedLargeSwapsFixture = { limit: 1, returned: 1, has_more: true, - next_cursor: "lss:v1:fixture-page-2", + next_cursor: fixtureLargeSwapCursor, }, } satisfies FindLargeSwapsResponse; 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 index 5735a78..7be144c 100644 --- a/tests/unit/large-swaps-request.test.ts +++ b/tests/unit/large-swaps-request.test.ts @@ -69,6 +69,7 @@ describe("find_large_swaps request schema", () => { { ...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); From 4d602521b5e10a87363859ddc94ab823d5a7081d Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 04:33:09 +0200 Subject: [PATCH 71/96] docs: add one-command skill install (#46) Co-authored-by: ikodo0 Co-authored-by: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> --- README.md | 9 ++++++++ docs/connect.md | 35 +++++++++++++++++++++++++------ src/http/connection-page.ts | 9 +++++--- tests/unit/http-transport.test.ts | 6 +++++- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fdb5e4f..6fc488e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ instructions, described inputs, a declared output schema, and structured results so Claude Code, OpenCode, and Codex work without a separate skill installation. +Install the optional skill from your project with: + +```sh +npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research +``` + +The command prompts for the detected AI client. Review the skill before +approving installation; it does not configure MCP or store the bearer token. + ## Requirements - Node.js 22 or newer diff --git a/docs/connect.md b/docs/connect.md index e2d3d9e..7213f65 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -155,12 +155,35 @@ freshness, warnings, and provenance before relying on the ranking. ## Optional agent skill -Clients that support Agent Skills can install the -[`deeptrace-pool-research`](../skills/deeptrace-pool-research/SKILL.md) folder. -The skill makes the AI preserve exact decimal strings, separate Graph metrics -from Nuthatch facts, surface partial coverage, and avoid invented fallbacks. -It is not required for Claude Code, OpenCode, or Codex to use DeepTrace safely: -the MCP server already supplies its essential instructions. +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, surfaces partial 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 diff --git a/src/http/connection-page.ts b/src/http/connection-page.ts index 68bc7a8..8ecc727 100644 --- a/src/http/connection-page.ts +++ b/src/http/connection-page.ts @@ -312,10 +312,13 @@ Tell me which sources answered, whether Nuthatch is fresh, and show any warnings

Optional Agent Skill

-

The MCP server works without a skill: all three clients discover the compare_pools tool and its safety guidance automatically.

+

You do not need the skill to use DeepTrace. All three clients discover the compare_pools tool and its essential safety guidance from the MCP server.

+

For a richer pool-research workflow, run this from the project where you use your AI:

+
npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research
+

The installer detects supported agents and asks where to install. Review the source before approving it, then start a new AI session. Installing the skill does not configure MCP or store your token.

- What does SKILL.md add? -

Install skills/deeptrace-pool-research/SKILL.md separately in clients that support Agent Skills. It teaches the AI to preserve exact decimal strings, distinguish Graph metrics from Nuthatch freshness facts, and surface partial coverage. Connecting MCP does not automatically install or load this file.

+ What does the skill add? +

It teaches the AI to preserve exact decimal strings, distinguish Graph metrics from Nuthatch freshness facts, and surface partial coverage. Connecting MCP does not automatically install or load the skill.

View the DeepTrace Pool Research skill.

diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index c31ceda..b1217e2 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -375,7 +375,11 @@ describe("HTTP routing", () => { expect(body).toContain("OpenCode"); expect(body).toContain("Codex"); expect(body).toContain("No Tailscale required"); - expect(body).toContain("Connecting MCP does not automatically install or load this file"); + 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(/ Date: Sun, 26 Jul 2026 03:42:12 +0100 Subject: [PATCH 72/96] Release find_large_swaps tool (#47) * add Nuthatch large-swap source - add allowlisted swap-search view and registry capability - validate receipts and scan bounded keyset batches - retain explicitly derived parity evidence * register find_large_swaps MCP tool - settle sole-source quality and bounded response pages - wire the live adapter into MCP and HTTP sessions - cover pagination, redaction, compatibility, and tool behavior * document large-swap search release - update plans, operator guidance, and agent skill - smoke-test both released MCP tools - preserve explicit Nuthatch deployment caveats --- .github/workflows/nuthatch-check.yml | 7 +- PLAN.md | 109 +++-- README.md | 12 +- docs/CONTRACT.md | 13 + docs/connect.md | 19 +- docs/mcp-testing.md | 58 ++- nest/checks/expected/swap_search_parity.json | 5 + nest/checks/swap_search_parity.sql | 13 + nest/llms.txt | 1 + nest/semantic.toml | 7 + nest/views/pool_swap_search.sql | 15 + scripts/mcp-smoke.mjs | 53 ++- skills/deeptrace-pool-research/SKILL.md | 55 ++- .../agents/openai.yaml | 8 +- src/http/connection-page.ts | 6 +- src/http/server.ts | 12 +- src/mcp/server.ts | 114 ++++- src/registry/records.json | 17 + src/scope/large-swaps.ts | 12 +- src/sources/nuthatch/large-swaps-adapter.ts | 430 ++++++++++++++++++ src/sources/nuthatch/large-swaps-query.ts | 86 ++++ src/sources/nuthatch/large-swaps-response.ts | 117 +++++ src/tools/find-large-swaps.ts | 163 +++++++ src/tools/index.ts | 12 + src/tools/large-swaps-query.ts | 49 +- src/tools/large-swaps-source.ts | 31 ++ src/tools/live-large-swaps.ts | 94 ++++ .../lss/nuthatch-large-swaps-receipt.json | 64 +++ tests/unit/compare-pools-tool.test.ts | 9 +- tests/unit/find-large-swaps-tool.test.ts | 240 ++++++++++ tests/unit/http-transport.test.ts | 1 + tests/unit/large-swaps-adapter.test.ts | 395 ++++++++++++++++ tests/unit/live-large-swaps.test.ts | 112 +++++ tests/unit/mcp-smoke.test.mjs | 24 +- 34 files changed, 2271 insertions(+), 92 deletions(-) create mode 100644 nest/checks/expected/swap_search_parity.json create mode 100644 nest/checks/swap_search_parity.sql create mode 100644 nest/views/pool_swap_search.sql create mode 100644 src/sources/nuthatch/large-swaps-adapter.ts create mode 100644 src/sources/nuthatch/large-swaps-query.ts create mode 100644 src/sources/nuthatch/large-swaps-response.ts create mode 100644 src/tools/find-large-swaps.ts create mode 100644 src/tools/large-swaps-source.ts create mode 100644 src/tools/live-large-swaps.ts create mode 100644 tests/integration/__evidence__/lss/nuthatch-large-swaps-receipt.json create mode 100644 tests/unit/find-large-swaps-tool.test.ts create mode 100644 tests/unit/large-swaps-adapter.test.ts create mode 100644 tests/unit/live-large-swaps.test.ts diff --git a/.github/workflows/nuthatch-check.yml b/.github/workflows/nuthatch-check.yml index 7dfcf34..4508aaf 100644 --- a/.github/workflows/nuthatch-check.yml +++ b/.github/workflows/nuthatch-check.yml @@ -32,11 +32,14 @@ jobs: test -d nest/checks/expected || { echo "nest/checks/expected/ missing"; exit 1; } - name: validate view SQL exists - run: test -f nest/views/pool_swap_freshness.sql + run: | + for view in pool_swap_freshness pool_swap_search; 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; do + for check in view_shape latest_swap_identity recent_count_24h provenance_not_null decimal_columns hot_sealed_no_duplicates swap_search_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 diff --git a/PLAN.md b/PLAN.md index 1c9caf2..c36750c 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. +* The **three-tool MVP** is the current implementation commitment. +* Its tools ship sequentially as M0, LSS and WR. +* Protocol DEX Metrics, broader lending and expansion remain post-MVP. ## Global Product Goals @@ -15,11 +16,24 @@ 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 MVP public surface is implemented and accepted in this order: + +```text +compare_pools +find_large_swaps +research_wallet +``` + +`get_dex_metrics` is post-MVP. The server registers only implemented tools. + +### Tool 1 — Pool Comparison (M0) The first implementation is one complete vertical slice: @@ -27,11 +41,11 @@ The first implementation is one complete vertical slice: compare_pools ``` -MVP-0 compares one token pair across three DEX deployments on one chain. +M0 compares one token pair across two same-tier DEX deployments on one chain. It must: -* query the same compatible Standardized DEX pattern across all three deployments; +* query the same compatible DEX pattern across both 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; @@ -54,7 +68,7 @@ It must: | Ranking metric | `volume_usd` by default; TVL and fees are selectable | | Top-N | Default and maximum `3` | | USD price source | Source-reported USD values only; no repricing in MVP-0 | -| Public tool implemented | `compare_pools` | +| Public tools implemented through LSS | `compare_pools`, `find_large_swaps` | Base does not yield three live Messari-standardized DEX deployments; MVP-0 standardizes on the Uniswap-V3 native schema family instead (`source_type: "native_subgraph"`), with an owner-approved reduction to two Graph sources. See `docs/source-scope.md`. Core policy values are executable constants in `src/policy/m0.ts`. @@ -63,7 +77,7 @@ Core policy values are executable constants in `src/policy/m0.ts`. MVP-0 is complete when: -* one live request returns comparable records for three pools; +* one live request returns comparable records for the two locked Graph 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; @@ -71,13 +85,24 @@ MVP-0 is complete when: * the client skill presents only returned facts and provenance IDs (no invented metrics); * the complete flow can be demonstrated in under two minutes. -## Next Milestones +### Tool 2 — Large Swap Search (LSS) + +`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. + +### Tool 3 — Wallet Research (WR) + +`research_wallet` composes verified Graph wallet/account facts with Nuthatch +activity from explicitly indexed contracts. It remains Base-only, +source-bounded and section-aware; observed assets are not complete balances. -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. +### Post-MVP Milestones + +1. **Protocol DEX Metrics** — protocol-level aggregates. +2. **Lending and DeFi Positions** — broader lending within wallet research. +3. **Expansion** — additional chains, deployments, discovery and vaults. ## Project Context @@ -86,7 +111,7 @@ 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; +* one reusable query pattern across the two verified DEX deployments in the same schema family; * Nuthatch as a load-bearing source of a fresh pool fact; * a reusable MCP interface and one agent skill; * transparent composition of multiple sources; @@ -96,7 +121,7 @@ Wallet-specific research remains part of the global product direction, but it is 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; +* three MVP 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; @@ -105,16 +130,16 @@ DeepTrace is a semantic research layer rather than another raw GraphQL gateway. ## 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 find_large_swaps +research_wallet ``` -MVP-0 registers only `compare_pools`. The remaining tools are added in the order defined under **Next Milestones**. +The server registers only implemented tools. `compare_pools` ships first, +`find_large_swaps` second and `research_wallet` third. 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. @@ -122,14 +147,16 @@ The user's AI chooses the appropriate available tool. DeepTrace retrieves and ve | 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 | +| `compare_pools` | MVP tool 1 (M0) | Locked pair and chain, time window, ranking metric and Top-N | Canonical pool records plus Nuthatch freshness and deterministic cross-DEX ranking | +| `find_large_swaps` | MVP tool 2 (LSS) | Locked pool and chain, threshold token, human-unit minimum amount, limit and cursor | Stable pages of normalized Nuthatch swaps passing the non-USD threshold | +| `research_wallet` | MVP tool 3 (WR) | Public address, Base, requested supported sections, window, limit and cursor | Supported Graph positions plus Nuthatch activity, counterparties, protocol usage and observable flows | +| `get_dex_metrics` | Post-MVP | Protocols, chains, time window and requested metrics | Comparable protocol TVL, volume, fees, revenue and usage | 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 M0 and LSS, the chain, pair, two Graph 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 @@ -143,11 +170,13 @@ The single `SKILL.md` teaches the user's AI: * 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 now covers +`compare_pools` 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 and Large Swap +Search are implemented before Wallet Research. ```text User / User's AI @@ -285,7 +314,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 @@ -363,7 +392,8 @@ chain_id + protocol + position_id * **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. +* **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 @@ -424,7 +454,8 @@ lock a separate price source and timestamp methodology. 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 to normalized USD notional. +* Large-swap selection applies the request threshold after normalization using + exact selected-token base-unit arithmetic; no USD conversion occurs. ## Reliability and Operations @@ -555,7 +586,9 @@ 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. Through LSS the registered surface +is `compare_pools` plus `find_large_swaps`. ## MVP Build Order @@ -563,7 +596,7 @@ Internal modules may contain many functions, but only implemented high-level han * 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; +* 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; @@ -579,7 +612,7 @@ Internal modules may contain many functions, but only implemented high-level han ### 3. Build the Standardized Graph Path * implement the source registry; -* implement one version-aware pool query pattern across exactly three DEX deployments; +* 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 @@ -622,7 +655,7 @@ Internal modules may contain many functions, but only implemented high-level han ### Live Integration * Nuthatch view queries; -* exactly three Standardized DEX deployments; +* the two locked DEX deployments; * a request with one intentionally unavailable source; * block and timestamp freshness metadata. @@ -638,7 +671,9 @@ For selected events visible in both Graph and Nuthatch, compare transaction hash ## 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, and provenance; let the client chat pane narrate from structured data. +5. Run `find_large_swaps`, follow one returned cursor, and show exact amounts, + stable ordering, freshness and provenance. +6. Let the client chat pane narrate only from the structured data. diff --git a/README.md b/README.md index 6fc488e..d20d323 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # DeepTrace DeepTrace is a read-only research MCP that compares Base liquidity pools with -Graph subgraph metrics and independent Nuthatch swap freshness. +Graph subgraph metrics and independent Nuthatch swap freshness, and returns +stable pages of token-thresholded swaps from the locked Base Uniswap V3 +WETH/USDC pool. ## Connect @@ -23,6 +25,14 @@ instructions, described inputs, a declared output schema, and structured results so Claude Code, OpenCode, and Codex work without a separate skill installation. +Released tools: + +- `compare_pools` — rank the locked Base WETH/USDC pools by Graph-reported TVL, + volume, or fees, with independent Nuthatch freshness. +- `find_large_swaps` — search the locked Uniswap V3 pool using an exact WETH or + USDC human-unit threshold and opaque fixed-snapshot pagination. V1 performs + no USD conversion. + Install the optional skill from your project with: ```sh diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index c86f690..44d3c89 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -28,6 +28,11 @@ 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. + ## Numeric and identity rules All financial values are base-10 decimal strings or null. They must never pass @@ -55,6 +60,8 @@ 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 @@ -85,6 +92,12 @@ 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. + ## Fixtures and change control `tests/fixtures/sources/index.ts` exports five typed results: diff --git a/docs/connect.md b/docs/connect.md index 7213f65..2451a62 100644 --- a/docs/connect.md +++ b/docs/connect.md @@ -153,6 +153,18 @@ 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 @@ -181,9 +193,10 @@ one of these project paths: - OpenCode or Codex: `.agents/skills/deeptrace-pool-research/` The skill preserves exact decimal strings, separates Graph metrics from -Nuthatch facts, surfaces partial coverage, and avoids invented fallbacks. It -does not configure the MCP connection, receive the bearer token, or replace -the required URL-and-token setup. +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 diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index f2c86ed..fc9a1cc 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -78,6 +78,12 @@ status `complete`, successful Graph coverage, and source is temporarily stale or unavailable; inspect its warnings and provenance rather than inventing missing values. +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. + ### If it does not work — report back which one | You see | What it means | @@ -180,9 +186,10 @@ 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] -tools/call PASS status=complete 2/2 -6 passed, 0 failed +tools/list PASS tools=[compare_pools,find_large_swaps] +tools/call compare_pools PASS status=complete 2/2 +tools/call find_large_swaps PASS status=complete 1/1 +7 passed, 0 failed ``` Both variables are required; missing ones are reported by name only. The exit @@ -196,9 +203,13 @@ 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. -Both `complete` and `partial` prove that the MCP tool call completed. Production -normally returns `complete`; `partial` means at least one upstream source was -stale or unavailable and must be explained from the returned warnings. +For `compare_pools`, 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`. ### Manual path @@ -241,7 +252,8 @@ curl -sS -X POST http://127.0.0.1:8799 \ ### Step 3 — tools/list -Verified to return exactly one tool, `compare_pools`. +Verified to return exactly two tools, `compare_pools` and +`find_large_swaps`. ``` curl -sS -X POST http://127.0.0.1:8799 \ @@ -281,6 +293,22 @@ 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. +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":4,"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. + ### Cleanup — close the session Close the Streamable HTTP session even when a later check fails: @@ -363,6 +391,19 @@ 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`: + +``` +curl -sS -G --data-urlencode \ + "q=SELECT * FROM pool_swap_search ORDER BY block_number DESC, log_index DESC 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. + ## Troubleshooting | Symptom | Cause | @@ -436,6 +477,7 @@ service, and reissue the token to every client. 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` result rather than hiding the gap. + 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. 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/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/llms.txt b/nest/llms.txt index 96303d6..af273d4 100644 --- a/nest/llms.txt +++ b/nest/llms.txt @@ -15,6 +15,7 @@ A self-hosted blockchain index. Query it locally; there is no third-party API. - `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 ## Live HTTP API (run `nuthatch dev`) - `GET /` index status diff --git a/nest/semantic.toml b/nest/semantic.toml index 818fb2c..fcc0556 100644 --- a/nest/semantic.toml +++ b/nest/semantic.toml @@ -32,3 +32,10 @@ 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." 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/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index aaf176f..5ba8c08 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -189,7 +189,7 @@ try { return { ok: status === 202, detail: `status=${status} (expected 202)` }; }); - // Check 5: tools/list -> SSE payload contains compare_pools + // Check 5: tools/list -> SSE payload contains both released tools await runCheck("tools/list", async () => { const { status, text } = await postJson( MCP_URL, @@ -199,14 +199,15 @@ try { const msg = parseJsonRpc(text); const tools = msg?.result?.tools ?? []; const names = tools.map((t) => t?.name).filter((n) => typeof n === "string"); - const ok = status === 200 && names.includes("compare_pools"); + const ok = + status === 200 && names.includes("compare_pools") && names.includes("find_large_swaps"); const detail = status !== 200 ? `status=${status} (expected 200)` : `tools=[${names.join(",")}]`; return { ok, detail }; }); // Check 6: tools/call compare_pools with locked args - await runCheck("tools/call", async () => { + await runCheck("tools/call compare_pools", async () => { const { status, text } = await postJson( MCP_URL, { @@ -251,6 +252,52 @@ try { const ok = st === "complete" || st === "partial"; return { ok, detail: `status=${st} ${covStr}` }; }); + + // Check 7: tools/call find_large_swaps over the locked pool + await runCheck("tools/call find_large_swaps", async () => { + const { status, text } = await postJson( + MCP_URL, + { + 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, + }, + }, + }, + { 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}` + : "?/?"; + return { ok: st === "complete" && covStr === "1/1", detail: `status=${st} ${covStr}` }; + }); } finally { await closeSession(); } diff --git a/skills/deeptrace-pool-research/SKILL.md b/skills/deeptrace-pool-research/SKILL.md index d769371..db70d4b 100644 --- a/skills/deeptrace-pool-research/SKILL.md +++ b/skills/deeptrace-pool-research/SKILL.md @@ -1,16 +1,17 @@ --- name: deeptrace-pool-research -description: Research and compare the locked Base WETH/USDC liquidity pools through the DeepTrace MCP server, using Graph subgraph metrics together with Nuthatch swap freshness and provenance. Use when an end user asks to compare pools, rank by TVL, volume, or fees, inspect 24h or 7d metrics, verify source freshness, explain partial results, or distinguish Graph and Nuthatch evidence. +description: Research the locked Base WETH/USDC scope through DeepTrace MCP. Compare pools with Graph metrics and Nuthatch freshness, or find normalized large swaps with exact WETH/USDC thresholds and stable cursors. Use for pool rankings, 24h/7d metrics, source freshness, large swaps, whale-sized trades, pagination, provenance, and partial or failed source results. --- -# DeepTrace pool research +# DeepTrace pool and large-swap research -Use the `compare_pools` MCP tool. Read `structuredContent` when available; -otherwise parse the JSON object in the text result. +Use `compare_pools` for cross-pool financial rankings. Use +`find_large_swaps` for token-thresholded swap history from the locked Uniswap +V3 pool. Read `structuredContent` when available; otherwise parse the JSON +object in the text result. -For `complete` or `partial`, read ranked records from `data.pools` and the -optional Nuthatch fact from `data.nuthatch_freshness_fact`. For `failed`, -`data` is null. +For `compare_pools`, complete or partial records are in `data.pools`. For +`find_large_swaps`, a complete page is in `data.swaps`; failed data is null. ## Connect safely @@ -27,7 +28,7 @@ optional Nuthatch fact from `data.nuthatch_freshness_fact`. For `failed`, Streamable HTTP MCP with a Bearer header. Do not substitute direct Graph, Nuthatch, or price-API calls. -## Build the request +## Build a pool-comparison request Always use the locked scope: @@ -45,6 +46,30 @@ metric materially changes the answer and cannot be inferred. Refuse unsupported chains, pairs, windows, or metrics by stating the exact supported scope. Never silently change the requested assets. +## Build a large-swap request + +Always use the released LSS scope: + +- `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 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`. + +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. + ## Interpret the sources - Treat Graph subgraphs as the source of pool TVL, volume, and fee metrics. @@ -60,6 +85,11 @@ supported scope. Never silently change the requested assets. 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. - 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. @@ -82,6 +112,13 @@ For a `failed` result, do not rank pools 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. +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. + ## Preserve evidence integrity - Never replace nulls with estimates or derive USD values from other fields. @@ -89,3 +126,5 @@ request field, do not claim the response independently attests to that field. - Never infer a fee tier by dividing fees by volume. - 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 index a6a1f39..9700232 100644 --- a/skills/deeptrace-pool-research/agents/openai.yaml +++ b/skills/deeptrace-pool-research/agents/openai.yaml @@ -1,12 +1,12 @@ interface: - display_name: "DeepTrace Pool Research" - short_description: "Compare Base pools with Graph and Nuthatch" - default_prompt: "Use $deeptrace-pool-research to compare Base WETH/USDC pools with verified source freshness and provenance." + display_name: "DeepTrace Pool & Swap Research" + short_description: "Compare Base pools and find large swaps" + default_prompt: "Use $deeptrace-pool-research to compare Base WETH/USDC pools or find token-thresholded swaps with verified freshness and provenance." dependencies: tools: - type: "mcp" value: "deeptrace" - description: "Read-only Base pool metrics and Nuthatch freshness" + description: "Read-only Base pool metrics, Nuthatch freshness, and stable large-swap pages" transport: "streamable_http" url: "https://mcp.ikodo.dev" diff --git a/src/http/connection-page.ts b/src/http/connection-page.ts index 8ecc727..f2fc2f9 100644 --- a/src/http/connection-page.ts +++ b/src/http/connection-page.ts @@ -312,13 +312,13 @@ Tell me which sources answered, whether Nuthatch is fresh, and show any warnings

Optional Agent Skill

-

You do not need the skill to use DeepTrace. All three clients discover the compare_pools tool and its essential safety guidance from the MCP server.

-

For a richer pool-research workflow, run this from the project where you use your AI:

+

You do not need the skill to use DeepTrace. All three clients discover compare_pools and find_large_swaps with their essential safety guidance from the MCP server.

+

For a richer pool and large-swap research workflow, run this from the project where you use your AI:

npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research

The installer detects supported agents and asks where to install. Review the source before approving it, then start a new AI session. Installing the skill does not configure MCP or store your token.

What does the skill add? -

It teaches the AI to preserve exact decimal strings, distinguish Graph metrics from Nuthatch freshness facts, and surface partial coverage. Connecting MCP does not automatically install or load the skill.

+

It teaches the AI to preserve exact decimal strings, distinguish Graph metrics from Nuthatch evidence, use stable large-swap cursors, and surface partial or failed coverage. Connecting MCP does not automatically install or load the skill.

View the DeepTrace Pool Research skill.

diff --git a/src/http/server.ts b/src/http/server.ts index f94bad7..de42ec2 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -8,7 +8,7 @@ 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 } from "../tools/index.js"; +import { createLiveComparePoolsSources, createLiveLargeSwapSource } from "../tools/index.js"; import { isAuthorized } from "./auth.js"; import type { HttpConfig } from "./config.js"; import { acceptsConnectionPage, respondConnectionPage } from "./connection-page.js"; @@ -135,6 +135,9 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions const sources = createLiveComparePoolsSources({ timeoutMs: gatewayConfig.sourceTimeoutMs, }); + const largeSwapSource = createLiveLargeSwapSource({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); const closeSession = (sessionId: string, expectedSession?: Session): Promise => { const session = sessions.get(sessionId); @@ -156,7 +159,12 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions }; const openSession = async (): Promise => { - const mcpServer = createMcpServer({ gatewayConfig, rateLimiter, sources }); + const mcpServer = createMcpServer({ + gatewayConfig, + rateLimiter, + sources, + largeSwapSource, + }); let registeredSession: Session | undefined; let closePromise: Promise | undefined; const transport = new StreamableHTTPServerTransport({ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cc31b2a..194af8f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -8,17 +8,27 @@ import { M0_CORE_POLICY, M0_RANKING_METRICS, M0_TIME_WINDOWS } from "../policy/i import { comparePoolsResponseSchema, coverageSchema, + findLargeSwapsResponseSchema, + largeSwapCoverageSchema, + largeSwapPaginationSchema, + largeSwapSearchDataSchema, poolComparisonDataSchema, resultFreshnessSchema, resultProvenanceSchema, } 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 { ComparePoolsRequestError, + FindLargeSwapsToolError, + LargeSwapQueryError, createLiveComparePoolsSources, + createLiveLargeSwapSource, executeComparePools, + executeFindLargeSwaps, type ComparePoolsSourceGateway, + type LargeSwapSourceGateway, } from "../tools/index.js"; export const serverInfo = { @@ -27,9 +37,10 @@ export const serverInfo = { } as const; export const COMPARE_POOLS_TOOL_NAME = "compare_pools" as const; +export const FIND_LARGE_SWAPS_TOOL_NAME = "find_large_swaps" as const; export const serverInstructions = - "DeepTrace is read-only and supports only the locked Base (chain 8453) native WETH/USDC pair. Use compare_pools for 24h or 7d rankings by TVL, volume, or fees. Graph subgraphs supply pool financial metrics; Nuthatch supplies independent indexed-block and recent-swap freshness facts, never financial values. Always report status and warnings, distinguish stale or unavailable sources, cite source_ids with provenance, and never present partial results as complete."; + "DeepTrace is read-only for the locked Base (8453) WETH/USDC scope. Graph subgraphs supply pool financial metrics; Nuthatch supplies independent freshness and large-swap receipts. Use compare_pools for 24h/7d TVL, volume, or fee rankings. Use find_large_swaps for stable pages filtered by an exact WETH or USDC human-unit threshold, never USD. Preserve decimal strings, status, warnings, freshness, source_ids, and provenance; never present partial results as complete or failed swap results as data."; const comparePoolsInputSchema = z .object({ @@ -58,6 +69,36 @@ const comparePoolsInputSchema = z }) .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(); + // The MCP SDK advertises and validates object-root output schemas. Keep the // authoritative discriminated-union schema as the final refinement. const comparePoolsOutputSchema = z @@ -85,11 +126,34 @@ const comparePoolsOutputSchema = z } }); +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, + }); + } + }); + export interface CreateMcpServerOptions { readonly gatewayConfig?: GatewayConfig; readonly rateLimiter?: FixedWindowRateLimiter; readonly sources?: ComparePoolsSourceGateway; + readonly largeSwapSource?: LargeSwapSourceGateway; readonly rateLimitKey?: string; + readonly largeSwapRateLimitKey?: string; } function toolErrorResult(message: string) { @@ -112,7 +176,13 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer createLiveComparePoolsSources({ timeoutMs: gatewayConfig.sourceTimeoutMs, }); + const largeSwapSource = + options.largeSwapSource ?? + createLiveLargeSwapSource({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); const rateLimitKey = options.rateLimitKey ?? "compare_pools"; + const largeSwapRateLimitKey = options.largeSwapRateLimitKey ?? "find_large_swaps"; const server = new McpServer(serverInfo, { instructions: serverInstructions, @@ -161,5 +231,47 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer }, ); + 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."); + } + }, + ); + return server; } diff --git a/src/registry/records.json b/src/registry/records.json index ff711bf..359aaa2 100644 --- a/src/registry/records.json +++ b/src/registry/records.json @@ -49,5 +49,22 @@ "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" + } } ] diff --git a/src/scope/large-swaps.ts b/src/scope/large-swaps.ts index 3717ff9..c8dbd61 100644 --- a/src/scope/large-swaps.ts +++ b/src/scope/large-swaps.ts @@ -3,12 +3,18 @@ import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; /** * Locked Large Swap Search v1 allowlist. * - * Live Nuthatch view and registry bindings are intentionally deferred to LSS-04. + * 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", @@ -29,6 +35,10 @@ export const LSS_SCOPE = { version: 1, maximumLength: 2_048, }, + scan: { + batchSize: 256, + maximumRows: 50_000, + }, } as const; export type LssScope = typeof LSS_SCOPE; 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..7577795 --- /dev/null +++ b/src/sources/nuthatch/large-swaps-query.ts @@ -0,0 +1,86 @@ +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}$/; + +const SELECT_COLUMNS = `SELECT + pool_address, + block_number, + block_hash, + block_timestamp, + transaction_hash, + log_index, + amount0_raw, + amount1_raw +FROM ${LSS_SCOPE.source.viewId}`; + +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, CAST(transaction_hash AS VARCHAR) 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 CAST(transaction_hash AS VARCHAR) ${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 +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/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/index.ts b/src/tools/index.ts index 224d075..79ea9ff 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,11 +9,23 @@ 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"; diff --git a/src/tools/large-swaps-query.ts b/src/tools/large-swaps-query.ts index 90812fb..259889e 100644 --- a/src/tools/large-swaps-query.ts +++ b/src/tools/large-swaps-query.ts @@ -49,6 +49,18 @@ const cursorPayloadSchema = z 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[]; @@ -151,6 +163,25 @@ function assertCursorScope(cursor: LargeSwapCursorPayload, request: FindLargeSwa } } +/** + * 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; @@ -210,17 +241,16 @@ function nextCursor( * head or cursor anchor. */ export function queryLargeSwapPage(input: LargeSwapQueryInput): LargeSwapQueryPage { - const request = parseRequest(input.request); + const context = inspectLargeSwapQuery(input.request); + const { request } = context; assertIndexedHead(input.indexedHead); - const decodedCursor = request.cursor === null ? null : decodeCursor(request.cursor); - if (decodedCursor !== null) { - assertCursorScope(decodedCursor, request); - if (input.indexedHead < decodedCursor.snapshot_head) { + if (context.snapshotHead !== null) { + if (input.indexedHead < context.snapshotHead) { throw new LargeSwapCursorError("Large-swap source head is behind the cursor snapshot."); } } - const snapshotHead = decodedCursor?.snapshot_head ?? input.indexedHead; + const snapshotHead = context.snapshotHead ?? input.indexedHead; const snapshotEvents = deduplicateSwapEvents(input.events).filter( (event) => event.block_number <= snapshotHead, @@ -232,10 +262,9 @@ export function queryLargeSwapPage(input: LargeSwapQueryInput): LargeSwapQueryPa ).sort(compareSwapPageOrder); let remaining = ordered; - if (decodedCursor !== null) { - const anchorIndex = ordered.findIndex((event) => - cursorMatchesEvent(decodedCursor.last_event, event), - ); + 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.", 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/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/unit/compare-pools-tool.test.ts b/tests/unit/compare-pools-tool.test.ts index 3f8ebc6..ae2ea01 100644 --- a/tests/unit/compare-pools-tool.test.ts +++ b/tests/unit/compare-pools-tool.test.ts @@ -3,7 +3,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it, vi } from "vitest"; import { FixedWindowRateLimiter } from "../../src/gateway/index.js"; -import { COMPARE_POOLS_TOOL_NAME } from "../../src/mcp/server.js"; +import { COMPARE_POOLS_TOOL_NAME, FIND_LARGE_SWAPS_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 { @@ -106,11 +106,14 @@ describe("compare_pools MCP tool", () => { return { client, runtime }; } - it("lists only the compare_pools tool", async () => { + it("lists compare_pools alongside the released large-swap tool", async () => { const { client, runtime } = await withClient(); try { const listed = await client.listTools(); - expect(listed.tools.map((tool) => tool.name)).toEqual([COMPARE_POOLS_TOOL_NAME]); + expect(listed.tools.map((tool) => tool.name)).toEqual([ + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_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"); 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..63b3319 --- /dev/null +++ b/tests/unit/find-large-swaps-tool.test.ts @@ -0,0 +1,240 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; + +import { COMPARE_POOLS_TOOL_NAME, FIND_LARGE_SWAPS_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 both 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, + FIND_LARGE_SWAPS_TOOL_NAME, + ]); + const tool = listed.tools[1]; + 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/http-transport.test.ts b/tests/unit/http-transport.test.ts index b1217e2..5d1f943 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -374,6 +374,7 @@ describe("HTTP routing", () => { expect(body).toContain("Claude Code"); expect(body).toContain("OpenCode"); expect(body).toContain("Codex"); + expect(body).toContain("compare_pools and find_large_swaps"); expect(body).toContain("No Tailscale required"); expect(body).toContain( "npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research", diff --git a/tests/unit/large-swaps-adapter.test.ts b/tests/unit/large-swaps-adapter.test.ts new file mode 100644 index 0000000..bb62e8e --- /dev/null +++ b/tests/unit/large-swaps-adapter.test.ts @@ -0,0 +1,395 @@ +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 = /CAST\(transaction_hash AS VARCHAR\) (>=|>) '(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_search"); + 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("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( + `CAST(transaction_hash AS VARCHAR) > '${rows[255]!.transaction_hash}'`, + ); + expect(sqlQueries[3]).toContain( + `CAST(transaction_hash AS VARCHAR) > '${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(`CAST(transaction_hash AS VARCHAR) >= '${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/live-large-swaps.test.ts b/tests/unit/live-large-swaps.test.ts new file mode 100644 index 0000000..5af04a4 --- /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_search"))).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/mcp-smoke.test.mjs b/tests/unit/mcp-smoke.test.mjs index a2cdb0d..3f347ec 100644 --- a/tests/unit/mcp-smoke.test.mjs +++ b/tests/unit/mcp-smoke.test.mjs @@ -123,17 +123,25 @@ describe("MCP smoke session lifecycle", () => { const payload = { jsonrpc: "2.0", id: message.id, - result: { tools: [{ name: "compare_pools" }] }, + result: { + tools: [{ name: "compare_pools" }, { name: "find_large_swaps" }], + }, }; response.writeHead(200, { "content-type": "text/event-stream" }); response.end(`data: ${JSON.stringify(payload)}\n\n`); return; } - const resultText = JSON.stringify({ - status, - coverage: { successful_deployments: 2, requested_deployments: 2 }, - }); + const resultText = + message.params?.name === "find_large_swaps" + ? JSON.stringify({ + status: "complete", + coverage: { successful_sources: 1, requested_sources: 1 }, + }) + : JSON.stringify({ + status, + coverage: { successful_deployments: 2, requested_deployments: 2 }, + }); const payload = { jsonrpc: "2.0", id: message.id, @@ -155,11 +163,15 @@ describe("MCP smoke session lifecycle", () => { expect(result.code).toBe(1); expect(result.stdout).toContain("notifications/initialized"); expect(result.stdout).toContain("FAIL"); - expect(result.stdout).toMatch(new RegExp(`tools/call\\s+PASS\\s+status=${status} 2/2`)); + expect(result.stdout).toMatch( + new RegExp(`tools/call compare_pools\\s+PASS\\s+status=${status} 2/2`), + ); + expect(result.stdout).toMatch(/tools\/call find_large_swaps\s+PASS\s+status=complete 1\/1/); 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 }, ]); expect(deletes).toEqual([ { From e101ec81c4cccec6d1850e656263443fd9d3a445 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 04:49:25 +0200 Subject: [PATCH 73/96] feat(http): add the public connection page assets The page ships no script, so client configurations use native disclosure elements and code blocks select on click rather than through a copy button. Its three subset typefaces are served from this origin so the document depends on nothing external. --- src/http/public/index.html | 363 ++++++++++++++++++++++++++++++ src/http/public/mono.woff2 | Bin 0 -> 4820 bytes src/http/public/text-italic.woff2 | Bin 0 -> 22728 bytes src/http/public/text.woff2 | Bin 0 -> 20016 bytes 4 files changed, 363 insertions(+) create mode 100644 src/http/public/index.html create mode 100644 src/http/public/mono.woff2 create mode 100644 src/http/public/text-italic.woff2 create mode 100644 src/http/public/text.woff2 diff --git a/src/http/public/index.html b/src/http/public/index.html new file mode 100644 index 0000000..d234031 --- /dev/null +++ b/src/http/public/index.html @@ -0,0 +1,363 @@ + + + + + + +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
+

Request a token through a secure channel, then 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.

+
+

Wallet sign-in over OAuth will replace this step, and manual tokens will remain only for automation.

+
+ +
+

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 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 compare_pools.
+
+
+
+ +
+ 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. 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.

+
+
RecommendedClick to select
+
$ npx skills add \
+    https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research
+
+
+
Review first
Read the skill before approving installation.
+
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 only. The two never mix — that separation is what makes freshness independent evidence.
+
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 0000000000000000000000000000000000000000..8f315a3ce518b65335c10735232c69ebc2eb9802 GIT binary patch literal 4820 zcmV;_5-aU@Pew8T0RR91020&y4gdfE03|p801|)z0szDS00000000000000000000 z0000Q92+1UFa}@%fo2g93cWzDLjg7dBm;q13xQYw1Rw>1dIumIa2%tej*?nr0Dqal zjUm)OFhveDM^N#o@wV0IMS}yZu*E@LE$b6_zW&BWVLW&Frhh}+#Yado3;)$r-wBIxK{7!P7*sfg%GXYNX}^Vz+#gA8|)EIsK-nWzkrzqN}dz#T#L z02sp${5JoO0u#lKD~N@dfCcD0S#t^afJvfch%mn^fNJdfSpza2poL2H2| zmn3O{Hg9c-24Xk=>uRPh+QIGoI!f~2Q-Wd`c}0CBpa6i*Es6jS40RNWp_-f_cK?2^ zwErKF+HKhpxSJEITL~mwg{POlx%7t)IJ-xbE#-hf6%b^rvU@}+APaSM=&LI$jI64T zA-d3stAEu%iZ;fiw*6?S;ekN*BR2wcRz7PAn#wBM#RXRkm_?$w%!`@;Dht3cY7C^A zyh$q3*9$xzwKqca8FV&7G(ASID3<&H6p%exD&T7ARy%&URCiYz#hv0x0y=gQKyK;w zG-(iRhYay#m;auC72y&fLmQA)S0@1DQaB^=$s;6aC!3^zj>&g6m8+0Vg?@4Q$f`kWeI2Ex~Y+@oWPJN0*Pr>uJ|H*E6qU*AZ~RW?+T) z_|_6%>N}iDsTwu~l=KWrRFFeSSjZ?iR2oaC5W&i*45pSzF|%?mOeM%Pls+m6fIi^M zw|{{40$l^_05kiteXyBg&+dk0lnv?@@qTSe2AeeOwRw})WcE?6CjolRXm0T;l2+wY z2w17E#yBH7oSsTLdYSlK-UZBWlS7&Bc1r+6&LGCej? zI2XY?&~)Ir>x85bB?|!X2I?N|y5?rd zOzp_F6jH<(2s_rVD8fxzxjr7^gkTy}W&2HWT7Si%^t8eaE`IRN6>=GnIZLo3tYiw2 zqU_OP_9Nl~qEMt&wyU-G=0S5JFkP$RJ$6=KsPXU=rF)@cA$zy_)OhxS=>UMSVLG7!R=WvNDSq=vA` zwvkxXZdc)8!zCN!S2Yku%O0zBJMi#K@)@J{-;76!s)!RQEctvVE?;l3^KwgPM~87P zRO*p;L-b~kfww!*0iZl5Al%w)m=>EaTQ$9m6fz-4&PE!s1jJDsT+Ui+E?a2@r`W$j znn@nHB$T%ix!F6c(n$8*#< zwQAsJ=L^r^VT>$gMmpvbQmjkyEO@@)%;+O;h*HvlJwTgI9Gpem) zgfX;)Y)~ke#$r*{Uqm+v1?pjuB`qA9729vGqQNZ6ZJ~mdC<3_vAGWfGNUlc!MHB{5 zD#*&sWgM%8!-UVfLcY#Xij#J+m;u1*WQ!TU+vhB-~aU7OiTUJGx$IAN}_^dgh{9{(&*2S)A%28lrWgEIdQag@3zMkyIcm=M2P~!j)9;`niL6_ zf6ei$mh;LU8z<{b?=|bB<(36~a#Yt+IJg%;`e43pleUW*jIu`D={W`pHuZCa zJDN+}=W&c|VJm}QGAx+%U$0FSy`%~9xYO%{%Wgqp9%DXwK$*dmz26JZr~fgW{&0D{LBeJ6oq0t*N--X!??HRZW*9 z=vWCdwF=T0GuS`{7`wJPMZeso&}1Wu$@Toz9x}KJG5%Ra6(lS(>Fz<)x`AClF1;HK zZ)RmrJnZ4bn-ppoZzEt}liLt1q~aq;WDi z&=p2^gHg)j6g^82ITIpO43QAo6d|w?5Zofdp#^uGvq4c6k065ouGn5EMu+uT^{z0) zFXcEbLj8mv29NoXpYfbTJGD`eTPjyWcS&*U0xAPR&2i_7V>=od9If=F6p{5BkV`3I z#X!AK=#hKD=TvoOwq?0d!_?jrTuwJHtJ*)wN(qG3tt$JL0e7WtIQC8LTvz&<)x}Ed z)9QHG5P(ySRTw~JwIM*kE40STjuycMpLl9Pr}H)KRi-0`{Ee>T&anFXBUVsZda2dD zs0}NdK2q_fZ2=LyC2#rjCOG>#XO zY7qq*o8@Rid{E+aD7%Go$Qk8L<@MiAn#7R6?Sf3;r}Cs~LTZVGM0~+tGu#<2&HDlZ zT8=3`UlQ#Q2x1)hQX?~4Rf-ZX&K+Z=GpemgbPGMn+8xIcr{xYhD#WnR0DcY*ejA^f zhZ+>Y>0<&8EyuzXN@FVpLb^jBH8Hsyf|)OhDb9|c5K6bsm>t@@+vlvl`AX8TyHQYnfF6t3Z9 z(D4GTRFk|}&!3hWl{(1hcQ0EH*wuIU?d?gUh|XSJrosg>0mcu#lx+;F#zED^*c|K| zyqVEo7CFN?&WU+!Uyf~rs;M6_5@s-oz5jWChLHGXq4pMjifkp8{9AeEz|&mEewqc} zghP>dZOK}UJezNKBk;&dK6%Rsi@jM)Iw?pkNH*)Ml~&Q5IHWguAYo!4(@zB|$v7cm zH^8FtOiaEsrcx+~aSX^MUfv{!Kp0b5h>MAJMTC+Fg2agvLKz7sta`9W{FJM^)MS4X zfghfu>nEcY|32<_?A{d0XA35g<8}#AgkIZ^t$+-gu9Np|nbe#l+bCF2yE=W8u>@>mOl{ z#_>*JoMT9G#&TPLlA^&ad#(e?_UW-rR4GebAfEsdtfQ z5in}h?IP??)0#23k+=5^#+lEIw7hKh3U@nx1k58(Y*luB0blkXX_-o`NRCi2vFJqF z3wa)z#Y^I%oV=kg-=o~E=?UrExIf$l5pB}AkrgZ0(4=%pSVZt488X=Gy#q1ebtdiFDkq4J`o?%`G?Q5F? zY|f2}v5%w*sQ@;WP^NaNGveBtH!NM-H!?9p7RCx*B0~miy-&mt+VORbTpv@>92Tb1 zq4J-_H>_G%!}k>sPJ-jhX#s!`FXt>5uYCD%ZG@5%C9|u-*?5iKM_J^Hkq^#_$`uw$ zmVH)$r_s@Mop2J3q~eYv@2XxiKmA+@y-uP{rGUMqSExT#&%)FzWg2H=f7Y1RZmm-T zb5w3>wX^3*v>Ra23X9Y8izB`=86yT8kk=C7?6?C4s?#OBm7rJTNNuXSuBy zCU0CiwQZvU!cLm*pJ1aEJkJ6YLkqD&iZWs-xl|pI#lfShA{8l#!IJO4Veect0A4>T zOUUrLH7CHs?WgxSsp`m{7;RQo3~|{U zo0EbCpnY$j<#t%UAab=vLX~JVicEX0)ID_KI7p#oo8$Nx;t*<6r^{Qdrp1lZEgCVw zu6T^j)a9V5s;HjmqKvFq;;<=}E0t?CGOEmj4DtbL*i{?v}29zJn*P|+xegws}!iY(8dEPvE zu1F;B0T+Uh5pC*pMXNZqD%ZDtliT>b=BVk3_h3;0hzPfL|Xk;5d3;Tuka zeodT;9e>0V4PnMs{=26VjXj!^z?QP8zT;tDg0)$0rBss~G2e<+3D%xJ2K&A*H&s(k z8HK4*7RpFlcH%Nq&hp{O3SdvmfVw(USRMXnZD;g9`Q?_xd_KYCqM8ZdFhJeR7`9*kq+LnLnU)#x02dGk!r_@zKgiRw{~Ael5$2sE%Fg4<(38rDAMzTMVd?4Wa6C%F zN{f~1uxMr#<62o`hj|KQFsDORGE28oZOW^T?JhJG8YCcev{E;uD$VIY24N=QQl&$> z*&4v=*}7S(lD^aHGp2+JPmMVxgzk4KgTfifLyUrm1*6w9Nr!#oC3H+H)MDWijQ2fE znwj(8!iWOKQ1VLQHcisNYsyt5c{rXNKb(wQrMwWh5a}%wSQf=gqujV1>6z&};-bZRW8||~@+J0ZGyb8`8w2W$DbgvhTsC81 z(OxtV-CJ;8d44bnMMYyX#w~%L&;3vIL-1EqMc*^eDf&kgnbd^2Y2beJW+NHnuAUgM z8HFEr$18XI*cA}fSY8_1Sj@m`%kD34X{S%%mJ$! z{DGhYyNq4hrvn5PU-sTmxu1$uBM_AdP?>uNzR@WG1bCqj660+|9BW<1-?1GPQsWI!>}~gRk>9Q`+GB{iM$@K z`?ckzPZ(GE?C{r}9dEHQ4=<9Ddpw*nKKs9D7@@!M0h@0^ z3O(#jUBRHH^nDY0YYt72TmcYDHEspS=1qssSYsz!@u=Jgt!R=YH^ uJD*vfkR`tim*PflX@=d`mPd0)5KXe#JJg{`2-o`H6i#vqA380+A?X3`IT>OA literal 0 HcmV?d00001 diff --git a/src/http/public/text-italic.woff2 b/src/http/public/text-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..14cce97a099f9bf836081cf211e4984435736a53 GIT binary patch literal 22728 zcmV)4K+3;&Pew8T0RR9109eQX6951J0F6ul09aiB0RY7S00000000000000000000 z0000Q78@WODnCe8K~jWc24Fu^R6$gM3MUo-fod-<5eN#}c*`R&fu?Q&HUcCAgJcVV zbN~b(1%*NfAR9A-qY<(51mLZ`tt_BK5o{a?2-&LauM4;#gEvqU7St*Od;eD5-0ms} zrR!C~BPcimUDu-fE#H@Cgh#-miBU&+l+><5vnY768#}ZeWw4^)LETs&>}W~ zXI1wo<|zG#ES0##w9}5fl7IMZeSauH5saG5?Q}Ck_C@8_M%C<1C);Qg1Tp9V-d+E! z;|d9(Lfa@2StX;XtcF)F(pbj4BZ_B^TaH`9;ns58Sj+fpi~+X!|BpH=2_>aU6a~RT zu(r9VQ#&_0-HRU)aXU|>2E3d7|F9z3cH0$eeG9Z`Oi0iK2x35tXBt=fBpT z_t)<3nYk1ul3`=#yvPWZ@~Idf;qenl9U z^pV^pv;>x505g^nm(Ya;q3{7HECD6x5=p7xg_JpxgDj^HtTTrKyavo2XYKyul-LKNI7K0Cze&(#5{{dseWs$rp(E!^=6#dB$JBDk=J?~eq1nW11XXWo*r?imb{8i zB@t;&0iptDND5#m1jc0)$=XK3#DuyU8xRgR9=VQzpMYM+LP8K9Cb(d*!3hBf9vmFd z5D*{;>Wyu39(>))AkjsaiqR|o2>^l(j?8&;?j;Znj&q6 z&TtYPdN?m#p^0z~U~YI!6zqH~K9;k6c-^GLq|BrMfRF*|0onojt<*JmcnSk%PB3K0W4iXbTe}9CF+t77hz#O~VnEj-s$)0|JPG0pSB-DFKC6#Gtbi$=b~% z#RGwcL9q^(e&Of-_pyav1H_+?5uRk@ek;vNSHtS55B`uh)C201ygc?b@W9lix=7hs zfHREcI;<9{yy@9hK>ei#Y7`A!r!o)bRH%(o%TiKF?C|Ls5aSbv_3>G~awC#GeeZl+ zpf6%|5F#WvI1E7xrX$h=d?EwF0s=yUQ$r$XJhmq@C@?TBG2GvuhKLK%a03HFViV#r zJaMTh3Kl6m%JbD;uaFRbGFAhvKS4rZ zpw)!)F-u+rgaS*{;l2c@4e46%07k-)-`I>L0Gz%EhFHxA-}#kht+@5fTvjjpOd5ip zD{?PSht!^+Mwc$T^~_s0e~i4_PbPtoT107K(mXodGy&ktI^>VM*2v>EA)u$DEm;D< z*^3__d?7d?bsggPL#XqcU792fSi#YjA|PMLZ{7`SwM*TKD2*Va_M8{pFt)Oi4eGl0 za5$(1-4;cP)IeykEwY-%vA?u5EhbeXsTOjqcJ<-fKP#*hj#MKcq;vBu4ycqhxDJv? zpR0nSMHFQKobr}h5ik-sVQGV-`qMd9uMFTs2~>1bSL=ENpT7uqi&LZIgL2{8onz8% z^esbKd5vYM^wgEVI(gnv^8<~_ykw4C$qhf3*oNA&@;l3a0HSgG46lB|ojGE|!Y_Zb?+tyhl2bLcIZJ@PZ}SDz6^i zr)eY9>-JATp9+V$a421h7NH5i!&rYCwmPd{KmA8Db9O$fT3VAp8qy(=uK4*jkR2PX zt5dgo9aB=MwGiQ>mo7!&t(WQ})9?eTm2I5>kET_d^G4($&q-U^NS?e#Kl;p>Y1;}r z!PKlbh!PU0UgQ!^w%|?Gc=hlUkK;CFA53O5v%#f`x@mfVng4YYTB7asVQ#&R#$4hmhZYMcJHs&Ti?62NUfeo6|t^Q4uz}#r-L1CWS)Uw!!>!9bE&9H z)99`rC5wW_jxbL=-Ck?twp&krS#V1a)!jTphIpNY$J0)xAC|YYnig`yMY%?for6OSFK_XfSdfF z3rnunQQ|Z-Wf;!iI_cK4`ivPca<;YMV`ntTF*1RIe055<>e4-E`(mg<3f@57RJgn_ zOzf7~YQ>HfA(Ri@rm*yaaByLDF-M(bly($n*tu2KgGAS?BHMndH^74V5Cqs?Js_etM=Ft5PK}I(WTX>U1q>th^0@zFNqf z+cg=Q(((qVh5bNZmmpx5FXRVD8zJcYtubRIfu9KwlaELr-$^jt9EMlFo$cJ_VuxwQwnj7v$YK7 z#aLmrDY8kY1pEma-0SBn^#KrCTWN-oF5d>ojFDY+uw1Eu811}uYxFaCNkcYeE{hSH zH*YS%5+ke+v_UA*;zAmKe1%wkD-}H;NZxG)B|KC!T}^nNd0KZhUK4Ufq9HI44;#p5 z$ z(}NF=aI0l&(Irq*U0Rs5^6^T`m?yw6Ij8ea9k=>x^j$-@k~h$0KBV`DkuIwdSyqk$ z{dIm-$jNsYp(+qQ!S^bYHWtEQk+b4v!^ld66RAh&@N2;~2nOM>MyM5Zb%S_mo(0NZ zDX1^ebUc~rvw7(Nr6hJi*|)DNW6A2dS^o!K>2Uul)lav@&pw=;k6ShKW_J9fb~tW! z`|NaFs^ecKKoxxw4&j`eC4y#2nP&gd>UP96+Gne)Zf*O^zpyLjaEP$(mZALU+On3K z0cHfms@S1YZ`5&<+Nd36qcIkiRO zwQt&6+gB^MB-_2MLGDwc3ax%h=g^ROi33r_wwHN_hFdOWy$kQOJ-=2tRImX9{C#56 z4~CaJy61!+506z{d&_+6i*V@w*EvV;XUC7V#(w+0zVj0>915HqAB~%5VeP!Q`KbgT z@|Q1Pn_1_kdfSfKS{Dl?I>ka;|1felBCq2`LDA{ddk3JObD3}5#;R{aZ{^Cp0Jrdv z+{>!FbQp$}%IURS_2%5ooHc+OjhnwSKa~)YJdyf1z?az>gW;V?`>UN}k7oAoWGKNd zN7e&5d&i@;q4SIUwrjnnr+v5`rQC6Z&NuQ-47Xj%p@5n=95mw2%Cb~vBtMlEI8t}j zg09!UrTN(r){0$2HT%*aNeL%D@;M_g?*N^^cS00LVBhOgeq6-Qzot2IjP0|~JDZ2Q||05isa2E#`@T<3Qup2pPyx;gEvOF*AEa~`y?V*L>Z%Rv@r0m zSc2nwG3HL=?Ic1xdqbJCNq~_4`r2FPRNGRA><#M_Cm8o@-KQ$Ie@ooU%~>AI|9$Mm z^*vu|`VV~5-ThOW^{eYg(0>5hz_deYJzyTk-kxmb#zL&k=L(Daip%APx3(?|hU!re zO|Iro(NAlV*-fx3Zn`|J-&WcqWxuWe^kmk%c#nU8^%rqMRk9YNaf=#1Aqy(-VP)w; zO__uyGUh*hKypg$RB`DxyP?an_xA&k&$?u0k*l<(;^e)$O+H>fN#kNmP!gVxe@-Sp z=Ho(7gO?&w{79x+kT%h?uuxW~sE;w;%z>PwdS+>9SB7A1ak0kK%m3-euc7Q7OIz3F z&ntIZ$MA(zUoduO{@jNOB&+>!E;OK1@XC#XnuIrR=?W5S(@Dn*(L|>iRLs zQ2-$2y_NdlcL;(lJJ6u@k($2sCuD>AE^K;E4cs`XWIy8lPr#IaCY%^j>+4<{SQ3m zmhZlr?(Xw=IDd$~-1ZW9?KZDidhX+-wLkk*oEbqGSp{Rk_8()GmbLz?s z>u^d(Z7T{jcf6M;h%HM$f4#=J+aP3Efe(3)k$DkW1<9%PB5PC8ICG~=lp;=}Dr1#0 znK8Zgcb{pD$ldy|5|J1hFt@2Mw{c(-EJ`yPV6&ZfyZXpT&kk8pOtZ9pN7G2QOaifDikJ*4cz;_H(?NH{9B|Jy;zm+R*~uV2Tss8B<_`0hvruxi zAo@`?ruWb`nHtjY*~EL~;G~k0Xe?b8!>`S}6S)gP;Svw8`|5dHzsyReN0EPE;D#$p zMq^}kV%Y{vEx;x(VLrsZDMBaL7a(iBK~yYmTUhAFBj5g^i3lk?0kaIZRv__;Hm^8K!XX9~`rgHYNpie8#qpk4*?)|dT2TK)^get)} zsR6}~&1f$(p}OkI#AMt*_xS*2X$&_%HW2#BuSIzRBm_*Kk~ceMCYzNyLL94uPQlcZ z{irMVRsNy!XtT&(a`RBeu7de^4Hk|0M9QcClT~cCD&t}zBRLl3UEpkyR4xXa!o8f2 z%}Mxj9WZmqT^g^cj=np85&nr`R>^AmOs6QeV#LTuU13!!0{2Z6t5#mUUVoWbE%{>T z!!JF(J*h&xP(sPcim5WS)UZ;6M7fxTDsTRp+DRh_k>eD6ZXyYz!G7SArnhzL3$sf38$Cin4n2&64Gg!-p+adDG-6 zy=7n6s_DDg2BE^in;=aU8PiL$th?8cUJ4{fTm$`+)AGbjQ)X#x?ihWqGM_EYh>|n% z^95#B&Ehsn!SD~Qii>v^Ct1P4o83IzsUo~kLeBf|PYllT#AJN+HodWq*V4YlB}u8# zkCs}KuImnWcJGvFGW!@nVN(DlL_>b1l;W_;VeHI5@vNEqGeB4J|J0Nhd1baX?QY8F zaU=}lj~N7?$XmCY0*I@4A6V}9 z&F-`ni)1`0cHP9^d?Zlm)a0qj(E@paG^yzL@0{~ka;S{{)Fx_jrA<47OQ{K)zBu6M z$H&ev;Z}(E-nYy@=^eTLAN&P9-@t#K;D6z>od@iH^A|e~{HkI<`5$;IMC( z&T*ZNl;}5~13UK#?~jfy{t*z@o2+-I-tIlczeU@ceUW%aiO2X3Cz)KOs@vqM7XYoE zP59z9))6`_Ybo~kMLz|UMVlw{Wha|W@J|9DZmAcArvZ@`l`=g=)W+7ax>LrvQxOm+ z6pEfAjYo8c)nT;b@dq=RLE#b9XZT6=*+X2G*drQET~pAIgg?||1<>DN?h zVq@&Py|3R2wVL_yHO2h&R0-za`8{NHBtdBi2VyV*rBRiVI6gV@ruhkihB$LI~x6&jl2`svBm!}X!Fps9Z9kF>wM%CEfq!i5L^aqoTK z^>P+-^Zf5#%XG{6!^Z8+-{E@b*0VP!H-oVZ9<Mq2*wr>@eGAVv zAFf3ioQnS}sz?NWeI#lB{LP7sbJt!>egExR?eDc@FK_R3_XI%B?Nc-fw(OL=>e8p2 zg*U!X?>-b298Z`&n7qUG*od_0O|*%thRrD2qP3`v0es zmYZ)=Cno+V%d-4*VDPTpwx%ps7U?IuxWAeg%oO$No5u#&FW{nNB4pyDT0^} zEG>N$ZiCF!CfyXfDI?|xnFytn85P~3`6{=t!ikMnLL?X)nbim6%C}m#+Y1MvHQS(T zyyt|6>brd;{5_;Kp$gNCMx9X6}vhkSsgvJh&2g!sd*g-Snc82H z52Ur^<+s`t*~3oT;k4?eJxpajlYX2;Y4ZG8L&-1g%aXRJW%*iX#+SD3EX?mJw-(H- zci!YTTSjFu zDc{)?JDwlW+B|hYZZh@A_%4~bboij5k@S7N+vbO*tzd=X>_q>gyjIf$NtMHl`YW1r zt?)Z>B#=tIpduu?e^eWK0dLsmzW3g_EyZDufA`7Ge?Nac0U#_cwBOhhRy+_nr>?GK zx&$uh>h8f85?9yhd{GMjMmeE-M;wt>IFwp$=WKw|Vk|bzS;+`4fa&;$R(=eaE!X5d z&q&zybfNWO@?!0s&byU8&AcM}o)5bE&=3I=TtXs2Iol`3T^_DEXtoqug){qvpC`)+h; zs>HXO?~lsi6BqOtQ6JSk6yO+)QsoJT666cdoI)f?-k(>qF=nzDa_Z5 zfy%uNEwHmUr81~4Zsu?iyF*+NTN=Qq9GB$}l~v>(>1v-5>f6s)jhA}8FXkKII90Bs zIb|$j9Y-)hc=ZkZ`9@lCVuHO8sQ8p;^Au2K_vS_ zAhh7Y{C@PK#1n?vvvU#_-!4GfD=b!7SgNNav1QSp2&3bZ1(^-n?Yw{l@I9Z!OiTFJ zpyq28&>vUMufakr{xn9GvyUNE0I6&5!6BNz3Y7r&^t({5bW$@JEo<4Ql8+n3v0dfO zXDf%8N7f{)tIz2WXUOt%)ipwPZB{Cw;pNr$j&dEVFD!W#Z4T7Y?UwCFz-p)|jbq6v zE*@Thx8|(bz~0uAwm#Q3)YY$9$bi(;4fcZHA>6iN*!+veqd}kvZL{%H&f{u@DjTOIw zQer~YA0h$3kFX!nmk1s_M;VuS-XFk?*h_?j=|;Ff32mRZl^@;CX24oNrA8itk|__1 z0R?dGbak;h>fz4r|Dg9+!Gm1~ERX-NpHPVhIBV0(9Lx9!3 z>r0#5hO28db9XK60Pw_z^K`)(w!S=y_xxOuwJM^~O7WNez7jf4kjr`D? z;yv2~-@E=F2gATugHPT~hyDP;A-`KTJh=Jc&LalbKr<)vf*-okZWZv9@uzareeQ z^j{cA4dATK4%qFd*FL@P>GMzjXz-?Ie>rsQ`8V)C4&U_R8$`PJz2bkPprlI2Up@Tl zd!^HIplr*hUZZ*8jp^f>ga+6k0U-gn>E%j&272uz`Lm)Rw)(-MWNR(Tow zB@&6;2{}g~d&C5VA6-VhUr{_fcdGy3E4um%M87dWCJs#t>>@CilFRpQ7GG53>oJRo zUYPsz(QI-f-OUG_E`n+bX1zg=NYA$KdjCo|x_Gt0A35;1Rr9b9g;ZJjz$>pWPZEKC zDH`-oD8wvKcQU*ifa0gIa>WqEi zteM;5`4i<=zM7lO$)enbR|zDUXPBobJls7n2Hnw-SWe^YIeh!gb^uUWL|4v}@v_MI zJlTj6_`(L!iCj?EZCzv0WCj!<-@5A==16XBejz@Ehf+WkBacqH#;uGdCvA2?0N*-x z{Ehj9d^6c(qISodPtH(xdEd*FA3Iij+-`q3Rs2@G5X>z~=&%&#R%{1ZVdbtK{S;h^ zRSk=j2udm$=B@s`uq^zQ&t0FE4=DcY)D~R6fIdi+3FUCm=}!=hOqwdQ+ohwGKYysM zjE;ZJp+q@U4RI7yfQD9N)98}U1-LgJdUMO7Py$4lhLF=py$-5?%XMP4w_X|a98=UkpckB zD>qG>C9zW|Y^ygw(TA%YN`<2;vZoy&mq7U_rEYe|H+funFT4A*-8IiA<^9ZBI11#N zcJ@xhaIZ+=)<}qExhg?ilqwN9sf znnvl;v%S1<110zqiSf3UttV$FA4D63vjmt~F)=51d={;)Z$kWf<@&)S3&8B6FPuzW zPzrS=e}cD2{)f~TPpjD0DwG~KQ*t@VSxRR@`$E~9N|CLx_&*=n+zlCg;Z5-ld4gRb(s6mb0_o+jkTa-0%5sEQiOJN{m86G3Dvn0?aL zC{WSF7SW%fFzJb&71^v^-~QzX1;<@9QtG*iy4V^j@s|^}K7RYlw{P@4oaK{uvz*)B zSX+z#F=MQ*Vy=;cuZ1Ku=hEVl|2+kX;|d9nYCV@0PAv_bgXwPoty;BMc4FX0mxTy{ zoaWGzV(`E)Q%Qi1ah3{dw|)x$N+m}0`>&xoTfgKcg1(tg#%l1rCE^#dANFcP@IdG>OoLLLhLWvzjN)K2JLX)MC5v?5`%HNLZ24 z0w;Q*l_b%9X#KwyV^F)+3*2dUK#I@0r0H1Uqp$b0!0xlcubwJl{$*%SlwikXf*_oP z%|j^o`pUF&Df|R{p7v>L_I`yzF4B1XPqic4Hd8L^_QBm4P7=sm5k_M3Z1ffZS1-xd z-Tg)7R?myK!8@Y?tEF>?-3~pHnj4EHGqd`^As{wsHkoBB98`uP6)8DgDoj{Xr=lM9@W zjwt?Zp(EvR%U*b*khm}XUkXY;tb1}b*rMUEIq=n%PUn0PZoLFxz=}KDfPz*zt!6S?C&BOtrZe#I;3Dzci)&@qFZ~f3c18Qmp+9d zjxKA%@MI$4f}k)AJo6Zk%T2?2?3?8n=b7`sI0T$eMN}By$9sEOV#wLnNVP{xy5%}b zWr(L0a*K@BD>KCCz(YOz5G8a|sg8!uw+P*>z=&SnX&dIlwhmW&I3W*?rHpbvPx~l$ zK)U)Fg6M-(8|$x>o&1wUQO*J-2j8C8Hb{@(LG|2ty(WWJFkGzEXLr=%K5;ciN62!vrZ5qrWa5qfUaYf-40*^QfnWqy z5GBuleOM7iIPhN^BYJwf0XMW$d$1KSdn~HP%e`p^{3a8&%@hM(&T)>Ny2gc_*P53f z#K`^D;_adG1&ctM%SrLL?2Q&qi4aumqWW6cev^JyS`k=MzGyH*oI$$gZ3d&cgtS*U zV*jrl)cEw+5rGp$x->Y6Xi}-=6TLY}X3jmK@75zViuUy#4u^^omr{u>nc}hkZ)d-c zFYJ6f%9!+g-S7P`R8>fu^!UJ1o+pT*gS*@0TV)ngaq*&#bLLlhe=YH7Z|SLzD?K-A|GlyN># zGtnNHMQMm=1+l(i?E|p80>19<*Jf@^(~?A|(|trB|F-up$v)a;p6V}` zBoOp=K{v@r>T)y$AdWHwAn>%di{TqmhN!-}($(y<<8hy<*vNzE_DyGx!U;JWwKZFb z_5cdUsm+U%NyBF?7l>4%_Hdd4z?e6R`vPxHN3_b>;b05J87XQI;c%DPaa%oU3FeFo zXN#?ohbz=#3a9#9%~FIra#cD_wgbnDJGvRi(w*c__7AIhZRI0Mpm$B2&? z|6-omNQFlAK!L`4w@p|VG$`&~_^9F;zE3^X?0ai^bK>X!&8JA;BmDycmo~wBiEQj` z3piQqp-G1ZB-vcI*FLTAu!GT^(ygDYlHForDG(~^{AsfPv|m)nhqMvCzp2(nSeGHa zqHw&(bylJ-qvGOh(rd++7|&C;{L~cs2#dQ$Q!AP!eEM@e;I{1$J>MlPY=z2Y{{l!> z5khthqt^Zuh~_?pm38(`dsM?VcZ&}?Y>|K{qBq+UCP@WR}+VVTz0v^b&$Qu`R z{4hSZJU2(+4;VpSQEq;Ygjl0-kmJY#rI}|XICc@<|4@iUzN_>rrdXumerr_oj1I&7 zT`&wb+Q>o59Tiq_5elPf(rL^|Xq<;T($vf@efJuXumHf)qRzU>3G4)Kk#h9_RqCn3 znj?(K+!OXuBJ(2AEBtA!{mTFJa*{j(|MOw}| z)lRf_T;z=dN~p43hM_T;1+Y_#b}vN&VwOp@tpTI@CXaEA^(D z?_MRKf>JU5BYhacwZ;AO27Va<)QUR2{5y%nA#zvD7})hFQs$`3Jef>tLyRETXbW1N z9mt%k0^69q&?e}lnjh6Z6p>6eB_3>sX4_R7_YywKI|yID2%==0_EEJgSlAby8-!ri zcB-;dy*gB&5Rt_!wTTyf_@xE7aZbhDDffWw$;b-zX_J9nN|VjdNA}i33o9pA-}ADRFy_alJGs}FbaPHo zK6v1ob{^;50YaXh|wVUEdcT?I>bedL3!oT_^jdR2rtD*9 zdAkiB5P}o<9eD7zmBy}rBH;r9l0(?vT&!PI@iH$fS!t_yv~n(7p*Y*P`@Y;P*=pG# zW8&uy1i2%tw-)%6u3wAA{>z8Evt>Yo|*WN~N-}z(sg6{N5M@j+6fK zw?vf6nQh5dYLXQT_mxd2lCe{FJxGbD(hwzFrRB&hf{&*zbJ;F!<8Iv5+IqcpL;q8=j!zG1Q)4VM* z?x4`IH5e8Q2;i2WN!FgRubZckt7S`QSqS2vs<@-(wl;E5sxUytLLeE-sz&jJR6IL2 z5>u_1_+f;57rh&NTQX;&xj&~-qTiai6~*a6sSO56V8x-E$OU0D?*UX?pztVkor?T_ zxe2hKnQU!skf-rJ$u7~&B8<63g zDdTjwLo*&Z>K6JFtVleTvxyshgONpEcvB|SfZCzNTDa6&yL@0kVu1`d@AJ%@ZUZQgsmtTm65w@ zQQJvzy-(|+a4xjVIfS}x24>G)PR!*94ox#b^g`A=;sFBhMi*$;jx?XpQLly#^ zbu2N!6JK^MS7zOqV6XLN>=K}z61f@+@E#>(M11L3y1ihLP3AH^-jA)FEI3@;ahL}S zg{;V@GL{@lZTxbty(N>O0|g2geo0-bF%PDGB|-lA^6pD0l113<$_jo)#LFHKY`eBt z>;nuko%b`Vt3`uF|9OwW_|CwJ>G5d>PoOQ^4Ro zJ$R$;_%x}T=7R~hc7%fxltA-W$5c|}6u+hF#ycmgJsI}ZXravQnnT?>!`G%98cO!X z2heph6}3|cU1XY?TD7G!9Ab=vJ`UDjdrD zO3ZnSd2*>LN!NBw{p?~{7*~?DsjF6b%A-OC_-dLgY)nIYCALf;dGT*P(AJ(5dC(+G zH3jKk>rWUc81G~mlof7a2Yo*hDYB9x7CW!k6W26gNhoGM1y25pwPnH{B*K^Raak&X zI#!gE2fxJZ<&)5!p3tCjDx7<3Y;LY? zlUpSxy~y?B?Ro*QuER3jSzcA!T@Q50Dfp3!hNh{UL#8<{)+8hd>G94B_aOac2By0W z>S-SBq$PjR5j?#AVhNmHJAZ!uv7!Tz!Ak4I`D@d2g914nxy1Hfv3pF5RBg;Jg47Z0)L zLeFy&M<|^7MA11mffLPR)#h+ny&ZfxsXR`Vnew1m_%nanzGL53 zdk4L5pA6@DB=X6qusySIJ#--qpc3iCR;Z3ciolPICqA1R$E-pn*5vxJX4}erlALN?f5R_QP^rO!8#AjD=$5>}w42*3 zL@$QT|9~W4SZ7gkr3lO~WT79Kg@i42JfP`|3kw_Pr_$3B8uj~bC9uSU$1Xc-0bn3I zHm#wXx0n?eV3$NZzq}FCW-a+|E+M6e$S)s!_Cy;j9-yRp@K7=DC=J_wkEL^c4q4(N z(F*?*zf*0}l1BoFohf4s`lO><72PH#6Q?>jI;u{h`UD%satK7Y`XUvGG$+%4T!C7-)L_I5NBX`1lSw?%FrJW^-eOoC*lZq;i< z@2oAoLayILE!3lQsz3}GyVch0qs*q#J^go=u}QuyS=)8{_diQ&@hf`?Dcl$VmJ}O3 zr>fDvXS=-^6ss?ru*4r|yTAU?&UFk5Bp?EJpH*3F7k0XRHPd0xV1{eD2_y8N9Z&I~ za*G3(3kOQrB@!9J*Bw`{O{UTGkcQV6e(3bpx@B@*@(d;eO}Q%5W#CcHny8;W_-L}DL8g+@&^xR&@bDxi0(9FUWT`rJ3c4GYMZf*=&No|0D;k>F+3>v{#SPn2 z>d(cC}^*gfdyQPN`%Kt1|(0WSsw_aRmLPDW>OL!|#z9 zQ@C)EWL!hd*e|xbaxUTJlw4oEpCW)H6}BgO9+0fuyI+?fKRxfM=OCU;D2nUwGUcGa zCArI>B?*;$z~bkp4$OQ$r&L`#l>08(?2Y`fYHPuGs+Vi=9~YU4?*c{O8I^Zt&xko4 z-pWyJ{^IWOw!{ugp5`2Moc?A!(MoJ@(9ZZ&XXfUM+be~nB)j%mYCBz$V)U`UDpRz^ zZPF;jiD#*er2<%%X%|_r#g#KhzJNh1afn2UV+|y~r83aiMWLtjmQ6lt`PC6tB z&nOhc67ZkH`fb@UGkR`xNBS-vkkST&A-Loq#%iGPCRu~@?z7Ngwo2TDg}ysGa9qU?*4?XD z585OaE-@%q@szC%^~gw>ch-n~Vkwp?V!2qZmRITfY@tFR?fhXn8sT@57<(Tc6S0t# z+pkIYi;Ye$1^?m@>6TuuY|ei!=MK&>sJ35Kh{qKcJxA!_*!eYyMg3${aaIUkhbE*W zbBNfUFng_1ez7wPV8k-Z$tyef=|f~G5e2g3k10>Qc3&rHidfHPUI8eSA#M4_6?K|L zg-r)}Pl=uj)?F->1~EDIS!DesnXJDzvbXncTymiLpCokfZ3Uk;Z2K6k)E{<=5y@et zXThTfnGLsmlP`RRN9T>E^@~3ABaRUPnL-nt%Ir#wUP>fj@zV&xZ4~9=mx)I9n3I_z zk5t2!0_%)n^mMKOYsPgSa&;K9k`i#T?X@8VMV$t{@kCX(H)B`;RmJ+?BnDQNHEco# zw`qggA>IX|Y1g=yHbONIU~p}3L4Z03&9L^(^nycmhc%Hfy2q?Xqa4bN8WIKj6Q8_i zeZlgGL^84mOqlajmz0t-Je7Z7IT`|<-pVIiesanxU>O7xt;2GoS!HGDje6iq4Z49> z5IoBZJVRuE&CIMO&58$5sIQv;P<#2k?9NOd#ngHuF`O$;HI(AfGP4sY5dXE{J=B=0*w%+OpN?b*ZHe_&h1SbVC zv+k4Y>e)}tABA^tELV!tE0_7Mlbsd`8fFgMFQw?61(BLOZcrRWJ!GAhKT~Fd7LrK~ z;)$dfaVqkrFY7&m#(;M>2XC>(8^$RQN`z5p0ttiUn&u^}^`xcq=8(gdi9|mB?&IZ? z-h+AWhkaEwL1V`)o9TjYzNurRf3{6Jl`tBl}88&Cpi>O zo)rSr8d{|Qw<1>F%4<}NTY11xN%>R+t}{j{bF~{-O`pJ_Rqo{xBtQ&U=m3@{;*w9i@5S>R5M4 zpT>3VmMYHn$|?7OCtIUt?Sz?y^-&@-EK_zLO}M&?rEoF`O@l0cc1gr4oZt>OZ+WsFImvF%F*X50Z?X zFjSqkrbHy-T>&FSHg#3{4i(t6+Y3UK;!wE40{d(!NndBwqA`_Nhb7Bbr8By;NtGJV zt>^_+i7NLkJx5gNl9JrSAzB>#aZe5Ow79vK+}rbqOT(BQ=M1w}=QPsfEVQzsd>pHP z^l1$39yCNR7R~dP$#dM$6t}s3rXX>6j+6|g9^~t@q=lE~slj_ohntFenVm_4X-Gk+ z^(+x~8KjCZsltJMkS49uxc4Tl`Ol4K8u629GYT>bDjeM<#RVE+4UUGCSzyIq(erN; zrHq`3Nh}NVYS$Uf*$Dogta!8@G@sCSMCFMP1=wSzd|})HWw+fsCjKajaos zK>>dL1{154XP0fqNww6Bm)UwYUw8X^Y4~OWJ-P@Rc;VnTAqzgUqY+2dcneO;Whzpm zvrf^W#w3k;rYU9uz&P!Znrs~VG)YQX+L91iyG_`l@xTB%?;{t5DffKz%qgak3u(qA0h1VP=MuHb?@uoAe~p)FWqA|D?%|Ksu}GX6eN8BD?5mq5U9~=E z>PdI6+8VthQA?N;bc)w(gqE<}>9CUil7|Y1v1S%+g9e?!sQ6k!qj8QUj@!g#dBIi_ zWTI;_=yXf$-pr~}wGd9efmN!-h()A21x9Vmeeq(Y-s4Vg=XAm{4aEbpv!OIboO{?a zI3`J{b<3*z5u}u*eO0}_B}Zc6WYW)Nur7S?_>qvg7PcOR{j*_!sf>6Tzq$Vq|@S0ErtgL3%SMO&o?zB zK4GlSj{RK7abUlPs2+B4JeQD6(6GjmcODzyhlA$mGo9YNY`zVT{qo2IKjN!oiL_~Z z4OvU9MDYp}=AE5^__(P>kUNOa$Vus(<3q`tt9II6QHniiupuIyC5JU=(oKH$S%bY| zSfI5!8#ZdKt{r3Va%Y6W81C}Q$s~)MTYtR*GkwD*X&@Y%VXUb3rY+*y&^+mXpC_2o zw%MM95`|Wpdh3i?%H6L~SS7c3vbBG$1`LLTX*?2$n44yv*9n*vb7+DYu+js?5g+^b zsuBz6FEw&1lucBc6r7o$VRbn718(bp10#JpB zGF`|q9v=wi!Zh(bLy?uNJ*8-To=s$QrIOpv&51RYuaej*z!5#L2SRyR)>kSOGrkH2 z!Ku=0fcku}DSRx+Qhas&879k&-UOkdiCe2>F{|3F_ND6Y;!^~gZKKc{($l@zAQ{8( zFqLv>4E}xt=gRi3CrNRVYkFeU-dstfflG<-iTa)^$>eUd1 zKSqJVAnjhKVyl`aNRjEkHk%d=?m#!#)*8Mj=7pdOSi<1C)pZgTkQ=K}+NYSZ)LKPU z>_a<4(bXjeXR|dxdXas~l28WGxjw)dNU%uZ2js#R+rh2xKKPQPWMU7Sfcd=|0W{Ez zd~{8#C={xyTi5k~0f+M26C=^l`TT~z<);gSl20C+;aHD{rQ20=dhL>ez>2Bly|CC`4F+8ks-i=Na$X8ifrx@< z%7;~%zOC={zw!I&?=51xd-$m6S$tab%_&GM{`_9!>xzdys9ea}3r*@3;H{N+M_x%y zGr+vR&{CALiM0j(^&G4eIOi!e$Y|+zS*-iE(4%o2wZISWj8V?IuWx5u>fPlyihwst z=a34IrR`dzFNJ&UBkrOOYDnmV6Zj1H8z9ID{NSlEWm%ugq{~n(mcvQ%q=H>a={Aj2 zyA|#Y0Ff3(&iLk%%vxnML$k`LWa=VAN^YQQ2Z{7CVYWLL$=EAZD8+Kx0e_K5X1~?a zaFP%`?iq1!;GG&^R1~walG&~qrkfDdfcQEfXMCp(#a=hDqBsa>I?5bPgQZT>imWDH zNb>4N!NCUGL~5=#)Hi4`rX^yftXypj101a&*PHv)ke{(yeb*pTZ~5NPoaW+t35KCT zq);T2Q=$|#W6U9uENsNFV(m+*$%_@gkI@Pet_EcMpwy1spytGmP~|oQ{=42Z3W1EBO@a z6Fe-yiPqrQS~n3!^(?^AOBw~1yK0q6uzl-Tq7o4-1_7FF*=*9yuV)~Pzm+U!GC%?u z8(x9Kdw{XMn|mq&{}&9W2WA$Hx0L1)YbDVDD55xuw5IATS8yFFWk7Y2k3_OLU6g{v zNur+1MkZ-vk_d`ni2p5(kg51W4Jcr#oVM*+x2BT7>B0f0N*{Ao0Ku7_gb-{h(KT-- z1H(wo33&H;@pXk_QfmJo2SB9YtuYH!%8;2#h)qg-qIw6J`iub5rYWYD_NI`Q0WmMDBx9&y>C@jlS#|cvn7q_Glb;I3VdU{`%M070-@XM zlE95Ap`(kOfLfRYWAwAMm8^gYR_z58lOzS+*gH%*HjH$ z__#gxbh^OxY7>jM3CIk@bN*E1N+z1-Z@(6jAI2RQVF|>8%17j0MFlv~oOip-gJnw4 zBvGw5%#k8!v4|E71pLhFsJo|O*VU}+WXrzU`z)in3^dw867S~RIclVJjuW-4N6D7s zqDNi9%BIz>8*PZt0@kDOG{w+vKb%TDCXh;n=dcRPsl40F8`FiFW*SmiR!vQqS=H#P zT1yt6Qp;0QvrDUSO}zdWvb<{KYGpyWtM7T?&#A1yQXMW>p=i$9bOw=ZT#}`Fy)vp5 zBZ{=a)g{9ndJI?rmx_so0$>&;dw5FpZG%EvPD-(`PoTLYpWE|vMnu6fiW4NMPAgfj zs}om$I3n$xOz%c!;Of>Z&BY1}*qtI>=QtXU_mByM=_BziZ{Wyob^5Inic986f)yn} zRMk?-eZCU(`&rKG=ea8dcz^RxX5tgyem6O{_lIvMW`yj-``=9b5TAKU<*L<6AzPOg zkV%e?*#{NQm8mQld-uSM{DI&DcfYF^86u{GzXX0FDOtIN7r=hkXg7pvwLfR2WF;Mo zOOnsf-JPLBax`fn8i_?HD9a!nq1{7J>@Wr;SirDYV&AOqI~yk?_GsqYn56&3y7A9d zQFUKtE=%?0&Aq$Vp;o5ALuD67f#-R?oh}aABt$d^e1av$h#~~K-l~lBT?NvR2tu+> z0m+*PvM(FvDCu<}i^O4LVO{L)GrKgLB3Ss%m9UD1l6~e=T}G;fv)ZbcGen~`x4>a% zgjH%}Yp(kBpkLd%p3xT(2S78{L`wQ-tWEc~IXq zul3DM`cUPSCJLLPVJn$sr>YN!aHGw0IeG{QHIQI=f4G#EFrwQT8g($=3_+ndqSxhY zbL3pp0P}YnDI(gJu9s!Yk@3KG(0F#wp0uiU5S)gS2QcbHf7V~7==MyYk$8=S_n1mj zq|?QW3%n@dRd*`78$2oTgl08sX*P0Er#hw0nmV$0ygL$Dm#c_<=xKDw{z&i!DC_pI zGi&SpvYhR=9U*sh1SD1`gq{IlusH3l|2cg6I0Qmx3Y;|oJG3mQ(de!z(Ojc{(oicM z^ZreR&^y4%}UVQYH8oiz9S($$6@)TMnq#l_D!FwfX<*B}B>AlYXS?7`3Gf8C6H268sOaWU)U zUE7DZOOnl90C%1K<8k#hlzFeZkCD45_>8rU&&E&li zNa#%Na{Zm3ymjE{vpuIY^Rt*UtwQ417BdCSN`nVpaU$Bc7sn^qVJDBi zP@$1lU{ZlE`M**W>((gL7QoX6SO}Gh#V#jC>_72ssdQK1Y5t67IDyaC5P7Sk@Bx;Q z^V`!=g#YZf1>QE+%HZu!0g0SOqraM-n|tRLo4LxXg=NNiqV4}bcFMb4L!b$~qm;!P z6a-|{=hJkjzdJ%L^fh-=yQnQ188MM>^vC3)FFjgjwxEH|8?tV z7*3~7W=P8Au&iKfuE-HC19ZbEMO&c~tJV!gkiWv9vt0VT7h=2Xh8TOxK+1lAdF+)7 z2a&@!TRut=E2U72MKL>F5-jLCD~S(=rAHnXBsQ3!;WXw};>6!mz$U36DF<2he78c!RNRb&JKOLOTnmQWTI zE>tZfH>pDdl%qtsXI$*Tf=77%IH*c|fIQ#aS1DFV$-7g!!e1G5F;%SR9+WYVXo8zV|Y=zIe&>iF$V@E>|LO zP*mS#z%}ApX5=q_=(pEYFU;doT7VZSl+oQX;0ti>KvY@FT~Ar6(^=X@|NnUN)^Ixg zEeE&?g3WM+W6?n@57_|KkhcND2J)mbXVYoXe91+$cRLI?4ks*Tn&yrpF>WGQs8fc4 zL#B*Po+I&{?gS11sh1SDYoJ%9%zCA99%~i|vILP2w}-0|r{J>MEYUP;G+fG(D3`k0 zD~(Eol0MpojXM6f^|DDxV%mQ8oOsQKas;15k^zEmdLzgH|HNvy>LmKfm;cG{dNzrr z_*b9bnO}pu7s~e{QWk}YPxLv58-B$i7}7Qg7@YBVtXA;L470~bERy6>r;Tbu9uui?19x@Bw_~&nC;ob%Cnx=cVTCYEEDI~hd9^_w(4vf(Di;-@A0JW|g{VE6a z#jdg@t)Qg>;h;X)JT>|=*<)eE%d4s3AkPBeFV@uoBJ(^~ErUs%tKexg5{k>xR;xlF zTuM6}XGB|L?B^U`RFD<+;GVA>2dR8&jrsaaUDOszyA*8W);rvDVX!UU7 z&}kGCs(G&BK~ra~D!6^Q*{I1DOY^6V_z`~8!EPbe58Z)JxE1T2dC0kpuLM2>ywXx&1Qw3{+s4BKrN5XtQKauQM%Z(crD7RWX zieK%j!sd1J!M$bIRBI{?rWB0^l4hNXEt?6$;&4x~h^*RzR4U2>o@%sDHqy3MrJT*3q=zsui z*p2mOu`xI(R7{ALdV8h8W;oSnK8r@XuWa*d7SfWv8oj3o48aLPD5{e-Bw;mGE7_r) zIAP!sbsULk05nQKyo<*yB5A&~4}s~1(;t(2qzE5L9!#6jub!bfOJ_;R#;I+py?#w( zurf-q&N^}A9#aN*$AQKPt=(75M%FSC`F@lSJvq-zA#AZNtA zb0UTln$HJvO^4IZ^zcfBRqjoHyf|TU)o`%bNin-fp=!19BMA{IQ-Y!mdRn$o9ax-< zfd-c9uvXKBkb4@4Y6L|_J*tGE7S)!z(CzlUfDYl0>CU?8q#J>zN#?^=+FZ`#CL}P4 zd)ZEZ;jmw%|4I{6;NRo1%Z5kq(rJgHWe~+IYoF^D1h}(ncb6$hUc)lOy?Fw6tl8)X zQ;V4zXa6g;{ie0ajr#V87zoFSsuVJKGqbp7Dy^tm6H><@hM+j!RB?J2T(WqP@E5Yb zd3;X73B9VhgOkC@g4^@@O;MJ0vjsVvi@Xvtwjvgb*zl~=@yEnaGDD3s3MdSG@Wh?y39X|+B;$S^sNpBfs5z#PF1CkJ0fSz zi1){0txj4UW~d^<=yp%iGj#^Zk|Hr|dSmh(jgW;ETXM>P4w%#+-q$aqwR}(F#A(n- zhnUybCnF)v-Ev2aBq(yGQBDFlz_Eh;96z%4(tOlroPK*>oN{) zSkv?cprhLgy0;X7IE1sRc9j4vXLABH@Mhl_EC`NmZZUC#b!ElgE45_7s&A+8ECK2$ zaA8q_Gya5cgdxyEYZ_7rLSR*LA44Nw_K$(*B{9f!egp12#fD_bej%AIWHz~?6LuHG z56d{WJc^v=8`0`RPDCO+Tkt=MF~=^iNf>S0eKi6C;l zcEtCor}jV-!WR;()yIs1c{0?Q?Y&|mtb3{B3az>m#mH5v-6a8HO~Pz9^2w}a%rhVTm-(L;@g$ltdT=T5BbILhZ~EZ}37-r;FYuDbzVl>8{uPivK5>Fj z&oTdp;726zwo&;^S=;RraHo@Fnv`X$GnX#p(^kE(7rDWnqN+1_QR6Yk8Aj9;fJfx2 z33D?jRLAb-%?#$7mkI0b7y^QMyWjm4w;px8bw^w z8~5)6@7C%__|yjQ_~oWp zs`fASEC;Liw{%9b>$Lb_T9RbFH&l!HE(ng=R@mnvWp5#^?o!O&NRV)FYAl5l6o89# zJ2*m?tmOk&kkExgTNgrST3WyVB~?+HO);MVnM!Pj$h;|nw#`xrE+5D7|DVjikwS?Q z5_Qs&JtFeIiZf^IYp|IYe1Y`XV9nlM^RSyS5L5)|B2n9{vlFLWbbzR>=IYh)V(~e= zu@~)DO8L{-*r-Ax?A^hp0d_pm9F$@%Z&k39DXa}%&*e&F`jp4{K`?5Dg}WC40f^h* zU$5j+*LlBDk-!%Lz&pRa_-la;KL30-wtry?6d~X*U=YCofB^p^yE*V77S6B!!C6o% ztHewF3vkUH0JnSKW}0WlRr7&Jp>i59U+{}0XeeR}fob}t3GFfpqnVc{vn@eA2FWs? zPAz7JxDRzB>WcS{5biug@nBAmNAP(%Kyw1%xA`Ixax=`2#{z7PpEEzOU7Xd!acI}L zyEkxl`vF?b=>L4&F7ub($njPp8kw?AKHbmp`Wl3$M(*lM;?8kEzhActx*ehR6Es^A zhgjoK`h76&&FGD9pxblJO{~YbcC@KrUmza>kOR44yPCU6&MfR6FM%ih-i584Z<`k) z-G;K+vyOn00BE`#Yb3T@Sj+ZHv`=!Zz5KDKB2XE@(|jW8hyRwk`^1wqZ9lR6v! zR&M^GRmP^7F={6BQ94lcKq9`Q019A*7}VncEP;niIB(*U8Q^^1S&U>n`+p!Zn9LUT z3h<3%Fx!utZ#bFQzLCF21XcruRBL03n*SvnEK2$>NCE`F0YSJUh$PsjCRJR)QjX$} z$a$OuveN9$U?gKtt2w~{9%l2ZAo~qfVX*P43I{S-g-15zDgwJ>^HL(xaeoy7!gNoS z5K4vSq*n`7!4UOeRR|q)kEh;1!lFlrwoQ0{Br7 zn8Gt$g;shA;J^8i0=ZG(x-n8%`YTZvh{96;cslXr#f1`iiWI5P;ovZPCUoMe?jSih zK3oQqJ?(giKLq~(GT#%57f#G81fod-<5eN#lL|B}Fsq1YR0^&lK2-7<4%i=$i?5W*c( zKnhaicyow2&{`>v;892^I%{?B^`kD74 zwO&XErt2fn9PKZ0*$mHbvxkbqC_WL~-TRc^a< zYtMn-`TsrGUq+X5wc3KW)oL)$xfFiD)9)22zRY|8Cm=n6UudUWZ_MUGT(ZDCJa1S3 zzxF{@ch^8q@Vq+@pFr&oWwT>CEK8Hv;QwvOk=A#2}wwThD?tbFoO*;hz=?V z5J(PJpXk@G*w_0r(>>xl{JZ(}C#AG#Nl1t}QGp;{r1dL0;!1b@cc18=tBrW?&H1KD z^7~zlsfa9T9QzZyz=r8U-rfqwCAjSsn4Dw@5-?350RG<8wB1KuSW*s4)1z5uaAc1E zIj(a{DVG{oa_!dBFZVC8i8z8FbR>4k(=v?~1n;y6x?2FeIZ_d#(}|T*2pdg z<&m;*I)@Otte7ju+@+#uR60!WqAcyskl+7&)#_5x$mqE7To}6Zm1E2-2TxPl^37|e zdBhNyRYYv4$hv%KN9|fVYO3Gie!tbZZ#MeAbfdrmVW72F0R%t+$R}WY<$%4gL`gg> z*j>CgjA!`)0Jh>F7wtAfDxWLz@(Xf<7T#r0m@W&z4d6?j;#Bi(kjS+4;PC*)=krhi zU`7BPi_Eq|&3?Z5r`rGU_?^2hj{5jMOp0*}PhFn;==n!4T%7s$UFfk(d*;<(_LB12 z-@%*Tg15f|AKwU;|L+Ms53D!^Z1@@TbLu|Wcsba8KcNN2&IS8U*MHX^B=Jb;Q^CpW z!Rc$kxu>b!2Ch6CD=!5%pMgy;!`?^Yz!44};^0GY^r1NMAe_1zryq?oPsha<;mSjB z^$tut4mWScyCdAY0Ur->|FwAV96Wrei5qGXW}3t?CV8?+nQzjknw%#l@AIa}-&4gu zW{N*Hr95OxebSWnhADe#%DxM)@ZkdhT0ru^hj9u9<0||RSTGVVz}i>?Gh+>`2?=WG zFhw3jv_M50ni#+lKO^KRot|vd)2h~L=PTh+C-t3C4=nh;KPCsf?nmc8oBM$oFQ4@q zwIh#kB>vcfx6FEF{6~hrW95n^b6){~U7F1+0s4p4OI`fu$}jL51>ca{D=Z^Rr`qa= zZG5}aGjc{Wl1#*c7(PdYNuI2qsFrvNnFpKa0kAb&g?t(k9~UC~r9PyN4^vq*OgdA` z-~Hcz0f@(MxLVF4=p)WH0{~+Af`LKsy#Tl;lh2Dg{fP+#%y@QXU^4IzI(3?qVQe5Q zGLfDi$>S3I@z0>ukkODJTswnYnWbR@3FN{01_X;R_^pBR=}_Bm2yc(++iK-h#uxH! z=a!#`=(7{~0gxlFEq} z8$+8}<^lE9Lwh~V(aG=hI_Pb-jyih`0D})0G?+jwVAX;XDn|hWnS37Z02tU>QF9Z( zEI{Bd0Gn9(Yjw_NYd2wzD+hz{wS4%g5Nlp~s$}?^oF7j*U{L7CQ0QA~A_oak@Y}dl zMk+=rzixe$-#(O4yK zTA5NwuAtB@Ka!8*1;pB@R$hlUG0e1CZ6l_ikoxal?fOf8gr{1#!jVDnL{$qLD91D_ z&B)-~anuF7ngp>z+M5hl6Kc-J1A{jO8g0PIcp0bd5e9d{x1&e%LCyC+-f`&tgw^%o z4jaC0Pn_VWzz-#D8=QNQUj_F+Y`SB=v*<6Cq*+s_dwqDs1&=U|HUy}3JZ!oivDLJX z*cM(X=Zsjx7s6}RN*(t6doYu$xlEM}I70Q-!q1l3c~Tf7F6{@bYY*CaI?j)v_P^*c zw|yBc7qpU9e!{qkn-Va7Kh5=hp~c0>aIaULtZ--fU|V9Ui&GEf}3l@hR7{Ur~TWFzu`N16V-LuC2&lLRkH|T6p7ww z$b=!v$Y2H@OToqnwHf2ME^aYJwDKUr)$l5_XlKW@%Zy3IvO51cd@Dvx5mFOajWDO$anv$N)ryl$9 z+hLDo#tw^t9npiQ%xyvQ$@FR=9$7YRLz=GSA~CH7A)PQQ)>t9LG5&5B1NkLtEaDnmVDAF zoN5jWCJ%;y{9%}|lf%b~*4D)sRT~w*)q9D`M(SO@)dsrol`f+%Xin48Y-UVJuvb+D zaeIs26Mh@PCw72jsEFFjZV;}`>MuMxAOWLrz@YG+r1v!xCy^&`0~r_I6okuno_PI2 ziF6nh--}GM_TJb~d{dy!g<)#0g^dhrk75ZYgAWtxl0QOG@UY?=WsT!woumcIaxvzp z@sGzVwSf)PeKCI}|AH=aO6rT};+s(|jeg5xYS&`zn~onPm|?3yZQg8twJ#L~(XDR@ zGI~4EaW;u>x^?ICHEGOT6sN&agXmtkYDZ@-lnwN%%(SGTO6V$`-5i#kS@765f6>JrtrYaHAb_4Zv-CG|8g!e=UY*leycDry!+)JbY3cLITT*ra5H z+7szY=Qv@&asaUEn6=gTTOlPy(cyj@e3J)&LVCV8our(|5jLx*?Ej5QHtym~Q~T#i zU+U6dPZ#RoPP3j$GOWi*r0Jc(;&M)_U|TaQDaFr0Rn2dHoKTY(sRWb|Pz1=BdNRlM zZk8+>Bm8Kb7#qiyGA!U;zI$_{<=1T~jJSW-w(YlRu5HEcW|bXbXBEPk4GvqKKuBd7 zOq#PF7oK<2fb=q`56m`faiKwz2$e4*G}YWgzp&K|Q9y#;gY$HgxNfE7uu(h7tkQHK z=$_Nn^@o2MyRL8Fh(Sv16}z9PUdlr|REdcp6N49mxvh7W)>gRqWn7a!dudOx+<@P- zCJ#k4RWF@{elObwCf{)^cv_7N^IsaRyJq&H)h%4`Bx71N4GSN*shcrF)tGSOCvqAa z-szeiWJ3f`YO6511xOIo*kZz2lRh|*$t`$Gb#OY_NcE8yucb(!B$k@kytV%`-+_+q zs@U$Ob9=_kgMnAY&NPBtWY z3ezI$WTL1l{ZFhg1+8E%tuV1u@4Zm!DfHttcO97?72EU`@ttyqKhWAXV_KF{k%Pj8 z$P+FlXRddgyKy>FLgH)L=HU)|KhZgxai|X}5G9EVA}&**y}Ry+7dAk&%}2wZeXdbNSc&w`7=yKRbTLJQVr-wmr`^>KOD8!x5OYqx2E+Sa=h| z>)1zcaMak(wjJ>=t_P3z=$<*~zj^0W@EKrj`%imyRqH-stNNL@s8f+ZA?2+7PBNNX&?i+H)Vd3 zxU6H@5a8FqGiW^ps4o_;$%UpttEhqLmuz=m_+eRM62Lvk#I!M- zj^_*GuWHt0c`e@;P(0Nd+jG_C7+I% z|8*_f?(%8X*8g(_97C*pGSd40{-%MbAH3dY)r|wuKY9TRb=jusJIY;$3%1uCdFphH zR#sOYdRBd)4qva=Jj7A=Wj%D-thCSHA79#OTzoSBt3K8pOPl`fsNCnwX}D!$*ov7y z3sTD|wm7%H$TvAghbjrqBS{0q@wU7T9y$Ipy2pFA&mPdpn#b~&z4N)vvg3}`zS?e9 z3@x|ThEL=A>eE@)IHeO2%{#L&oo?~3_2nd`iHhv-I5nY=hhvoa*heaGevjlFom?`b zQyYS=0c^QypcDT$c^SkH!)0VVuQNAV$*r^Ov`Erq%5EM(IbqGH_QsOA?GGM zhPELW=uKhIi`c5jnfiDg2Qu#O?;x;#8$a#pdih89t^@z|^?f*Ht#_MumRc_ya}3Qs zYPF961p3>)gjrr6klAqWud9df4q5X#ODNSINU?*oio&b&(@D6u9fb`v@w7M z8zyXxU6G^DOXNTTjhI1D60((9Zi&=r#43@ztc;y6_WnIZ3arTKEP;#FjpvwasKg5S zt?ALY*eLFz>;={|h$u);(d1fgh>WE~x@*u=MbES~YHlmQ{j6%e}-3?e(G~gM<5p?sRjQ zGZ7@GxOGn4Xl#7d%D9BbF4yCP+zJkFPDPt~Y^l90*PRvEHCd+d%-}Zct#_X9D9+#A zHh4e={ym~^t!uLMnAw}_#n6=th&+l|oL-hAkLLv9diSFLb2!%4EIT^0Zx_!&w1t%< zcI)Y%&R>JLgPf*7A-33uza%mL!T!lXhpYk#+uS7!jfU+3Z7vuO7R68V7pK?CSxixt zg5j<4n6|7lyJtvHVkmwjp;FU4gWqzrq4G*^U-4&$yTM!LuH8}7Pp}B(5w^*#e-rQz z@T&;8#gs_(ZWsYQdwVCv4PeoC@NaCQEla!oV^Y0wL?~8Nl#;2 z?eN{--*Ez}fGJQOl$;`j@W_<-OfKFP2Q0X?geN&!DoZ+7J(scCBXOv#gklV>vt{kB z&({#}H#)E3R}t#GuCBc61>r|G(0BqexeC!TR|*xrrEx7(+T4?ty)XCvHa$iG!URPQ zZ2pt1BI%caNJnVm`3ymsS(g9fut1+I2ohw1i1FXrc6VpvNAWFbRQqhrp8x&VxC~E5 zKkmbR-V-G`F(5Jgux$s6mT)*jR{lLTdn@MOat!Siez6K4!Taid&B+$)QgQ@+ly(8y zEH!2pqRHA4VMc9K>mO^*YtFsH+-#BAUb&WMn;<1QIj9uA?Y0I!u9CM(_^aCoc>CKe z+NZyM-hmE_RMB^_zUJz{?1p=nTYLI1pk1yEE)`I@J4V+Y>}9x7HmFbBl}GdCo?ny?ui zCCl6M{Bh?>%Fi0WC-q1#)^ z)~0jOhy!N8%`*Pab|C62H{FI=ANnGr6toB;|RmT zu!djG{F^Z6U8ho;v^=u7UM^3sG_GDVzK{tG zZB&Kv51%=sU3tK4y;T**R|bKd#;+gRyn~Vfq3q0Ax4+gNG-EGc|NfeJV#de@ipt#V zhgaa-d5-a;&6FPv!oO4qz9M|%=ra2!<&2+f0?PYuZa4ihyX(!LOmbfv{an(|>qo9q zpK1ESialF>Xr=eG`d^cpInAP8!@uu4_?Y*riQ?{r->{qU%ofs%U917+;f;_lO?~6P z`PVmptK#3IA6y}RK0LoY^u1o!1I~%!?A9OpZr}Ob|5&ehaekX3#ZO^xEuQ@cmcPtq z2LsHT3z5kz*58(Uo*?c9I#~+=i*x2 zQ(-6fU}$3@22(f)cNuH{d5k!wv2M%hua>N@aHyAFRPBWqG?+6akJ4>S1edgYHKKQW`ck+2X!CuG%;r%$W4K>8lNNJ$wgKpI1>S;eO^lvsQi)Y zq(bg<_JLKS5)X%6$LAx?g_3W6!KCWXzlSQ{(4BNpD-+hflZoFg_}5W!SJd;SvG_gk*aU+w=at;&z2|6K;qL|7(xqGXMyC!ny;(!7?)l5w6mzI$J+XD|sn`Gfe0S}(LffWVR(z9WaMqUc8f$Hw z&IS@^+LvW*FXy-NP44!x8gD0dfeGDSKDypmQ98_*bZSMsoGjhAL8y7RVF~_w{2sE=nmYZd_T$O1 z)Ghx`PyKH#AW{1AN=*SZp3 zy&UlPC zAuTu!nV=VQI|{4Fr5H-t290fFYj@Sv3r&NIZY*=ZS@Cf8efO!ctsmw?fZv|At{+o^ z>cZQQKccXhv_r{;tG7V%sHSea+P0vmdHFSS=|Oh7J-?c~CklfnE4`|Ai^k9RS#gZxEG*R{9GAl7=UD>S?6SujOVJI3iDXN;YCA#alS*r>7 z{rFJ=ZJp-@!=T_<7FDgzGIZ>)nKqPZ1>SYTGudwkcUCSlnF=e5^wm~#8b(M_j<5ZA ziGj~{%6RqfwkUG<$!+HA{tq{mKco1a@}q&UCftxx(*ghw5R?^;Kuo zS3kJEW|OTwJCUNkoaA0#_di!%&HngoX-*>mP9kNNeH@2RC@#Y5(7s=)!U4Eh|4$v{ zn}M=5p?wZV9IO3Ly@JRvp!PoX1|q_UrPliF6AMB=&`{48gO+Fx4L_9u1OR4an}O=} z`rHd-3T|2#G(RI6EL!I^XPfpC+O=pCm+@yvDV9+DSEL3ofc^tH!6dZr&*G!kU7CB| zR?9Vwd{5`WIZ`dBmY;8;``|R8(|3^#IQ-L~!zyti^rj=_G8u5{D-A6-==Aw^iK#eq zsQm{bJ$r1}+J{}fqUZV z*K%XrIqv@WZ^!>V!4zHR75={G8!_YFm)atJy@Zsei+?l4y7V3Lr74f5yq$VIp{*P& ze~ofp`EeQ|@fT4vyj&0r0R{oULVYucgnS5ZW=FaJBtS~S$0t}AirYn`#Ouc#D41Cw zvZ}2wwGT@qvLth)1wUQ&#iBGAOhsNe1n6K6ywW-p)~@TCWvO_+-gI(-kU&;M zti)9M-Y^Ziq5%Ka9lo{vEhJ>&$Jhu;(G_^h$4(N3d-#8jdyt?9FjO@6zr(_QVA}Y^ z_g|@`^@Fg43^-{*w{&Q6&2Mi-u~W9Se%9_oAyKX$mo1s6?&L5%B0&4iXZDl)Lq%Qx zmL8z4rtI71At?fpHQfCPSh|f_)^DLOis41YxJrP-Vx&???Q~oj*s7%!$s3A=6qe!v ziliV2!PvRp`gv-dHl>NQraoDgtzmXGa)v(A#s@eRvjA;l=%~(nSU8OZG6p2^| zcV#*(@c1t9v^{kHt?nk=$iP&?SJWV{97O85pxTslvbu@+1QFc{> zKUMzg6U_Dj$_F=?&0;oMu^Y8)wq`Kc>Tzp?<(+?*3vmnV#W*MFDGi;LLh}M?xd(ZC zjHv558Xyl(;~ts4nar7j;s2A^q932qFQ*sMZ$B%`Z2!Voqmb{3b*<*!1zbN#8L_i$ zck}WH%DK}kTiwwbo?J-WmpvyH@n$lAGdw2G1{eMKR8h4dxYk)}c1>@l?d6b;d*Fem zZSYwfA5+*Bonun_?F0JOf$%U7-jL<8(>ZJ&yeYFgA_>_wn=^7t`dsSMq@Fx@y;d0b zptF7F!3X1I#fqY(fOBzhC>6zAulFupNj=VFBo75>sQJPFq_Ku^oJqheXmv`7akakH z;Jq`G;ol@sisAKiQWBsiF&b~Zk9=kBL7_iWPH2%>$jhmjSfqFui@i3ch=~zr)2W27 z4}p~xvS3}<`ccK>@z~>T)9a8>2)F3O5jt6>#D1s$|D!IqS1voHF}@hgyjPUlylz{q z+4i*=0Vr=gwAuPHx%^CV0ZJZr96i;&h1)OvlGXWtpPG}d0AVsefBYwg?+z~zXnxn% zU+*zod`EZhpKvV=9VHt8T&y81+g-_if&ezUFiO4S=a zN^hYWVtaDgjO%A#Yu0?1?1b*rC;_2UZU<+vK`RM1 z2t>OrB1-IA+6C4HIN&{LqGH^$8C)>l4tYl04#!mp*Y6_EK{GAqq>p+^pTE%QJYhRO zKMnUIPdw0<%lUrszo^m5VRYg@(%8&-RQ@?jia$OppHTWp1E`Aaq{ z&>)SsV(c8t#X4{|-;r2%54U?4uVI<F<>nHq9EY zpW>RPVUR)I0_ZJ0S)O2r_v_(dILzR1V``DO`yvC36*gko7MUMO~RVosWJ2Mw?1BP4XyV|1cI~M zt^NQuFqFVY)OcvHcuHWPr_^Zs8bbwTn}aL)LK3Of!UFH}w?70N!4zbfbGS`*hqX?C z8#jnve=d7G`>xX1vPqk;db;cBm;=dPZ$2a1iqqA2IBy;J>rqXuNov(kf&yeI=z3VN zdMdhYZJl!|UV9bN|InTpl(Fj3&?Ou9o@_?`)s7n~Kx&lsi)@RCWYkjPdm?4Iy72?0N(GBOhkY6cTNRh>KyU=6~L@yYy*su3Pg7yR&cl zsi7^(IFWvNqHFtI5x-X?ZZqr@T5mGKG4#W{QTd}^?ip6Y$GKc%l}{_d|9$;%eeJ?O zc-;1m=EhukKO-0LGk8mSs&E*+Z>}E(c4=!3@7~bk@MGcqK};ky<}5h z%Jsag20gELm$ri8lEFJLRoOdn-CuLfG)E+Oj&7DtrvxacLZiTeBz zP%0D$Il)id+H{NPnT|Wf!6oF7oC1dm7IvM_5Ls)7)z_K|aYa)ETcE3wDl?oloRMue z@Y`^!{&|@HPqNMo?EA$e7op?&0t-hc+Cr$?|5NT`^>+n81TG1+{vjq94!VuRd7I+( ztY9Q%P)f3^_B>Oa&_E%4jx|4_iI`ud>{IuI6}@+Jf97(aNRm1~rj*n)ZFNBdJD_L& zHdbr!OI(7xu9dfA;r@pT?=>ei`zIoBYmpmvqXf}52MkeeNoHxeP7<~}(TKj^g^!g{R`b~_e&E(qVG=D%lSHfmfnV2T^3 z?W{W(OIkt&aaDmD*cJ>rTD_?|>q}lC{jMqneQi%}x5qF>Y;%G2el^ZY879nZdXQBR zT(h}@@2V&T<4 z@%TUKo;UgD({zy^kkQ50iu+PWYv)pgI5PJL0M zf$k{^_F>EtUa)o${^qj8egtpSc?+F}4{`xYkXv#5U=^RL!*j9q<;npV@MmmdCg4Q; zx_Sdc^v-9nXp11<{GW7scA7YE590A~GImEmm8uvs6slz1#yV;cY?d%bb9Kb!mbu8B{|p@=?A$iB9W zcTDlFMf49!B`V0|^~XaY--r1^f=Tf1pG&sPK-mulACN$tf_Nn1#V=>M3L zj^FZz|6|HmaDxbFBjkccW76e|mr-g=L5hO|9(#)m`jl0gsck1Wb%3tsjzkCcPnep~ z%rm2(jfKPy2x8W2%wNs&c~5Lhyb{Sk^1x15babzWW;5nLX{>yb-)jRQ2Xa5&+7puE z1CMwr|8Dg?JiK#j3~ysTKk^1}h$+Z_jJk&>(SOTehQUQe$&wq^!5_hu93OpKd7#U4 zu}|bF6#V0@e-VJ)13e3R{*QveCA5zv%zI0G)skiE+KyaGb`PGJO0Z8VaF?{&S%SNJjv_2=faH z>Gaz$6PEE6-%nx7j7F=uv(?()6yJ|+MEfGnH z9iMp4T-=qlxyhONSwvvP=cCNtleQR)uM?fpy{;(Ro6_h+iLDFcrtx{vHzJK zrj=OH?;*tXF%b_azc`Y08w(g0r;v40-M!T_8uAFeD@r>^Qcnt2n3x_6ebAiWo@%8s zIrc}2u**toca}qNoN1oqskxy5(#qu!sm^zF=%H$5C>30$0MBN~!6Pvq6>?&GD=NVI zQ?)))3NSlii|(6$mKROsLhhXmwEPnHBplAgqFJip`D*3-AqCCGI4={kX33T8rU>x9 z@^CW9c-$d&ws}Kufu`&vzzVWD85RL2uPF(s+vu?nR;@jX$Q^Lz+D65y=Q3{{(lGFe zm=uppa(5c(!JQL zECIXGQNd^mfZv>(%}863{L@HxbWiVmZF3g|eM#oBaa46F@1 z`b5b(Pb;*w{yvsvxwx9mk*At*>Zq%nx2<&^^}nuK^>*vDEP~{eCl(fP^2$A-vo|Kb zE8?Gpq7kn%NYhyZf)=PwhM7kwx;Xkvg_Ll}s9Ekj5i&Ny9(&~UOoU?985PM3_dOIj z=C@e#T6Y2rF?j4FNzjRz|6N)ZA71aYLHf<4J7{gKlbC;2kWBSl+T|I6s}bkFcN!}- z8;%subE1_Ql1lu zN@yT6$~98BegmxBD)M1aVmP+}}x#?$0IM`zSbuV@jS@v8r5$ zz;lB5NF))&IvP8j(IT~UNgi?Izj12|JMlT-WCaVKjI88vBY9#x$G?fc7`0~;Bks1V z+f6P#Om0*r5H;PgQCJtmV?y?gFR?mrj8aB=MT;iVhc5R-*i;hY8Z9p1x0@~jAwV8E zTD=OROEVE}YZb64@I;+m^nI%mrhU{O3-#!Z2aex7M~eP21CL|t;~JHU9zQ}@qLMQK zz<=v8iF~qh;fp#N;Dh!Vbtg3s;c|sCteYV{mP_y94NSl%8tEhA^5xQ1 zc_a+X7w#WG#z#z*ia^F;=?YDEFsX6KsX#?mP9&bfEXWSzrcw(ixX1vXDG^U0B}Q4f zzzbG}w3Vn1c92NSVbIb6e=v{B6)P2dE>|E|sSE~HM|Mvv3nlg^5{lIn_?F0S#3ynlu1vq;R|!MUt0OuT;tT=< zbr*VEB8iI7RLXaElc{BCqU6l`lGFfIiJ!y|T4&8SN_4h@b!s$JNDF|F{g3o3%UiBn zqFzxJiBaD?vQjJvh(T^gJiyTm-#twCh3QeJ)9cnvKY{-5foD`@RVce@dPl35dRc}eO#v#)cN?1NB+7P7tuaBK z^nXZINNh)lSD)v}3CimN9N@6y@3Sj~Z>{upK(}QE!5t17-jdF}#{O@xxwF0;z+p&3 zMvSY5Z>y8vIO|;00Y`sPcj|( zB!_6FYTzdi#eyFvI$4M&p(zF5E!2QMyONdHt=K3givmutMm)~L$c9o~+&#UZkE|OL zPU&FiA@FeY);VW*=q2Dh5R)mn?y1w+u-|n_N}t}PpZMb!dOd6V;FrQ;zTws9*Qe9! zV&k%P7@)J0f7~xtQ)l^-#GxW5QX##hmx7hi6zo+4(f~Rf8PAL{5yhl8L{1f#oLgAn zA>8(;FK$n#A2_`*w6&|XHonyDs)LiYE9Y-k#m!4G4Vt#{?JSy$f+7auUrRPQtfoB?XFx@Gcv3tkO&u)5OdzL@@^~YrPu%nA=IfM)@j% z>oXeJia*jrs5HMg4MkN|m4@ezP&%<;h~=c=#Q?Ei%IYF&8ix_JbQF!H!I2YO;Mf!< z-wR1zohC={TMaK0GcwV}&$xS=0DQ~ZB^A>hew)o8!EH(M1tY0^A#-Q-h$>5SdS`U! zF}t8nG1aDbz3O3b9(7OCDI;l3*i=+Rv4;u%Nyf14ncAdV$L%R~86Y^BzX@ptO%AMr zGqKG-TVNqzJ)CZ?5_~x=!vYkXUx0QlR8EN(vrP+Qj4^zx_5VWCZ zpye_6?5eqzO;bXqgREXTZUZBWX-VC4P4&6i`LJ9Js_Q`_ zG~D<#nQ&DV>j^{96{2nzJ`Ja>snPeRL@-Oz;@b`bF!D&Rv`T@r($csF-_|B)u2SkW zE&&WZI9d^2Mb z=yhgHYs+OXvfb&F%YlHLO`OwhnFcNCM7@SJJMb?SPCt}kNyqRKG7?=ak`vaVK5i_4 zHmFOlq>}jPPS+e#=cCck!s&cWtFYEXm+0$OaNM$TWdoP`0vB3qpF+{A4J7X3W1;t}!n3!}&J5Y2F`(a%bt*sXfB$N*YaGWi&Aqg%z*N$VUaNt4E zT9H6Q0aKr3Ct8BP{e~M>Y4(*?vmt|$wJLltB_hf2t-tZTWuu9V#ybseSedK7BC##H6|8AK&l%Z#iO@0@EnjlL|%WF*c3(rlRE zQ^D5BiR`xvWp=LOSNX-C{c+>0qtVXk4t|D)`0vi&`>3N!2D$QaD$P`m(hU0*_|{ zD#4tGubYSVoTby0bto=a%uZ8`+NMpWbw$+{c@37BP&w^XQ)X zz1rXswKz0a>k{(7<}uQ8Qf^nmEv9biLcjNz0!wdzcBo1v&n|a4Abvq?DTt0b$ycWo z;4x}Rc2S&KR&ZNI4@%=5#Xd!aF^7n_@RXfX7CBcd0(RRbinOVkqq<~EV7N`b;=Zm~ zO#CiVN`I#7>8=#cALiIMl4jST6!|nT3tDK}X85vFS)h1oLJTY5KvG_03N_3}=LCik zOmWt`iz+9V=~a$T-mZmrnTn9h+{K1;h7ZjcJclKT z2aDH?J{|v&^OotEzlgnF z$3qeC*I4`Ca^Jfo4t8wSv0Gg1`o|;qZ|9sIzp$SiVWQ;+)&F|-Shus4JWgE)4M8`! zB|lQiYh?$=CtbkVJc^j{_-#~~y2-v^(+5e_4sRGQ$I-KK<92{geiLAc5x_`Y=RO(S zK+MWbNTk_4oH)S)f{_i~4o4s@YH8_xI(yNb6UUYGr1t;C;zELm7pwE5XURgcq>m0x zs|O)pJsu~3KtqKS94cr?G?yF9vqgxc#UcS%4;NAD>$GDp7V6-TN*IP{6BNMLF$m^M zkWv~R-yeGfU$RVaL^#wQ7Hhnx;JWaPh7z3N+e5fxFrW~KZglED*66i$ewfZkFo*=x3RSJ0Xn# z2e`iY3I4^vOMnJUC1&Gw9521H2@EdAw5#Tv`BoRK{PoJQrxrXCnG=wJWG_4jFNRZe zCogz&sSLZzY(Yu7!ijCwrJguY1yW^qs$iS-CGES|Hi5Hwh6iNQ{K%Xwf-(2~s`9d~ z!E+6Y)D;RcMB*;NNpPF%qYehjIVCiQb!;fylH#{ug zCmEi1DMhPZOz1CUyo~D_BB4KxK_%XG;XHd)B7dD`;K`B}t z0-Lvd^|v8(sQC^o_)u6Nk;R^AtG66w!uy;CaeM=`;ggU&@1igha$ZT=tmekd3&K|e zK7i6AjH=9O50sw*4m4@4M$@0}nX~lskOusa8d;7;y+Tk%N_uv^Q7;#B+1z5N97Es) z!;sn}SuZhKS-i$<2s;)44lJR?F<50uOXbu%nVHbP+SMu=(#mDsAdDa}mB8Sx?q$?! zhfO^HcV0hL3U<_0*F5^)UKEUS_^}Rw->UD;`0oWey>~Y}^0xA`IbZt>)92>aPtCvf zT;bV|@elO~ykkzE{qIe2^PV`om^uxj9@SiX7CB)rhPlmr+!B22965f52fKlGTE(a z0uYs1Rz1Mef;GxI6NZVW%B@zf+Y*;ms4Gpgf)LPE00bb}?k8z^xQrg<6_-r<5IdJJ zFP%BwbidyUTvtSu5EQFD3*xfxz(@&BR#aXTY{8e2Vn9G1f_B?;3hy4weG;q7ko|(1 z%9sxjfDmz?@LoMQ!<#oc7gIhorwkg?nGp%HYKKWOQItlzTNZ~4>iI<0!cl@e%V{nj z`F~01n|m|-c`Q?@=HjWmE+ZL9M$<{NFEsFsrCd262=nCAp{d_^UcuVRIYiV%WS=cxjbMIK61iu$_7dPn zhr_B}kT6j3l4_M03hSRcm)?9USjP*}*rYQZ$LX>n1yN+4 zrdjcY2v}K5r%Ogu5>!$IDQc9`Z%Mr*BB2!N9YOy4E)R=T+0QVeI;@Mo^)ui=-9|d0eJ|!4<)44+pW+q-kfO zdIZ*9?NEX6O2U)U$`-PbvEOGwljsceqA#y*IHKe9^&-iuxc^PQI1uD^B44PZ@*PgB z92t?SDsOW_hSHRjl2y9@n73`lfam)pw59&xgw%o;{uo`BAHJ+A4E~ap+b#rLUu$Gp ztCcXc@OIP#^1t=kDg~Z!jUf5g#Ij|xrX~=y_M_d5QD=$)aYbDfnR?vFbQ{(K5!ST3 zEcrCammn`;PZX5^M+sh8l0^A_0~a{inLHHqv7o%_E@Uwf^gk0to}nc}AtlihciKzQ=aXf* zQLmMBjo&xa!0WaIqR9MDlEl#47y>s4E%KcHF?JxSsdJuJJL>ns4i5##qtd1`%p#@} zxH2AVb|y;BhE90@=(uUaOVR#OJe~ZxDA9ys$ShY8;)PI9^zcsoR3fkUwr{eg@B)F8 z`=aFWDk8-NR7oN(%dg=cTQF>n5rARA@k}+(6jl0FqkD$8Iqf#LtHGg`yX42_fj}-! zn2|j%hz*LUBaQqr{C_XSAtk@q?)jl+j)h2~+-NktUXQBEqTLH)MW`e|K>Ab|*XP5H zEk#c!%Pl=s4*)NriJmWCYy9w`7OVBBTm>+v*YXh1Zu^R)3|CF$J~r4u+>A_>QVO4uUm=~(cvhye-? z_3?nwJ?ORjo|z9Kl7X7MIm<|of#+an_qGdxZDF=a#nkpp_J#5>FJ;$Bf_4ePJuoW0 zBMu0m;aK|FWXqOQO8eBi{p&^O5M&R$# z{Sna<>Z^z@ZT&2q_7A5JmQsvnEw3QRnjlmrbtv7a<;6uFxn096d&fp_J~Tm6@dk<_ zJLock0wjZV_S;9iq%VBp11tH{bj9uRUe zP*eh@8MJ6^UGy*{&g#B=wR#$ZJs&Nw5_6*|nOBInKm^nVQ`cF_J*RD-mY zOuyyvO-nVZ;q{6t3-#Mk+TWFgD>JJh;w1(d%BLE-wyG*C&8DhchpJg_7zeIKAqWCe z@#lbAlT+W7IQ0N-Pph!S*6tNBv&=Ic=QD@XQP9bxQw%G?0o5OG2E18LLF)0s7D?)r z=q_c-#~vjXET6U^z&S6@XX&!tUCMG$aSK~k|4i&>&nH{&?K(smLJ3yu&j2EW^jFeI zv&eVdARQ+Zhc6dB_s*rinA?A1{wR5^zE-iC;~18Xk;y(RSbF~cz!{&qxmhJdm0@~= zz?%Q$|@|93>)ac}DXM`Et4t;HYr0=D?`dm$IL z`2Aj}ZPE6Et3}@n*y101L1^*cy-?kv?%oC#FzF>$`bW*4O6S7^f*XZIK?s;Ag~ZE% z6KXI~)8_10V0plCEazP+H)Z`! zGTHGs0p*U_mJK$+qIC8YW+@m(sL03}iLxTCk-Tk^iiXB2o6kVMrI>qojyJm}H=sdK zGbY{f&W$fip=ux$Ggh7MFj+Q^*b*Z-XC2|+oru1>^I+G$r{#VPJ)CK~l%(ZJG*J_j zD9s)2D-ezBry?f^{IsU2C|-N)4h?d?Y95Z` zMJJ1Qbp;Cbijf|v9MFK0xu;4xnJHw`QGVM(JR(pHT3=;&Q*2>%gmpCg_jd+8FNy-P zm^Rw|$~}EH6+v-|l2S=|4Rw(yjl+KIGpSwbiNDc;jcbl6PFq(>LFiv`cA;EKbh)~o z9A;0aTl>Vs_=UD4uq(VE^oN+L){|XNq1cALnD zMkkOqyjWq;ez=((HsSLDYA3a2lJ9yV97>n1yCB;Z#R;e;7pJ9uyw#d}g=`jL-I?0N zn)wPLA*~bCRUa)YoFYt3<5@A<{Z`alct9BUU=h+CP4Tj>4f1R+4I`zpXb;ATZuoMD z$Xpp+dX{wuqd(H8m+TgV<6?c%xfK1iS#AlPB5tduO7Q<5P#06ZJ8`ce_k;pE7*EF( zxrdM8@f*Q^Y*e-RmtpIoSeWd5*l2Ae)5Z+4NM7a&NnH-Q?kxlQo# zhNWsqQ6k0!Gj8YBZbLanlvpmYAWoK6G?%p=+Aoa+aI9Q0JuJ#Hn`#+M2uW%!%H|W2 z)!tsUQRMy(yTiL>?{i`Gs6aW@jTD$|9Oo?$=WIwN0P9pcN$Hezt881ZLEs5(TcG}; zPuf!TZf25IC&*7@O8J+7|#3j!SN+Or5P;=QP=I6+#hkoMYzZEO6xkKEe#tKs#YhsX3=oRA$5H%vW0&BtZK zwG}qH{SPX-4yq%`QUfI6cBp*Fk1|B1pfnSlgNV7Y`EM>62`doihrVTmte|L8GVDel z%=hDF)f(R`)C-rf>Qg|3l+w`nPQb|emVeYeKlATSyrBb}e>Uw+!@}$jY%k1YS(+oW z@J8yjNck-ov7_FIEA~f1=yNP|UHn>lW~}S+?x2e|Y=@D2n0Yr6HfyHqe+gfdJ4e<- zWf+cf$wZ7n(7a{j8Gj(^QqFd~s`|Yu%FS9MokP63F+8Xvxezf+gkNz5(54(GlNnot zBC}Q1aAaglRceapkXs9dw`o=DjF2dqmxR(U`e>)OAp2ugaAc#dj%`%&eR(z8Ukdsd z?xh=zdW3eYbUmSoE|&&T;zhhRK!%BJeJ3O^a#3$=l~F`(cNekTUmFo_gR4@?xP1tk zvZXEN<9Nc-&(+~7(ik5d4;uL(x0eQvW!67d3PN|Yl0e~6D|O`JAE`tkVd}61?ts=}bmAQFuo|=<`nst;ouUQ6vrHE}O_XKI(P(n{HbgN6IFv z`|Un7W}e3R{aqq2E4%213;vRv+^`wZGCLN|2`Ibr1c5_6@f+b-?Z#VuQ>XCRetm%h7gw zPZL?%1;&iRsli@pGL_SgI5hR%0PppOLvT0J!+sA(n=^z$=mzxrs+{~HU(;1^`rl=@ ze*cx!LtpgiT5ufgBT#}3&cvL{a40v?Kc+bUesJvK6#6v}@21Jx&tyE0R>V{srU84) z%8e$Z-@C#b1BgtvVaecSj5%064YHq(VKlgw5LX2Mb}I!_S$%u%TTkTC@pQtkaaU=) zzaKMp&f}-*Lh`GfM;iU)6d)=2$4|{(z1DrKV}~#%co0Znc@yvd+bjCFepJOMiUTF^ z3Ce#26>3Do84p$R&=3iz)!5J#Nl+I zGVzj|klm;|_a!SwcM+BWw3?=z*HyvhP+LI>oUr`8NHUz_bdlXeLZw~YeyUy=4iE$^ zB$Q;&eVF~fsdWmLkWjdJM>os%}LJ8 z(VJZgqPdaNt=ruUF*HbGe6Nv}e>_ff0s!}FHaTy89Sjz!EM&saFp}v7#iar?#DdfQ zcx7QuHEMF~h*7#*b%`)LTmYkE0GMND zn9UG^r;zHj>a`L}qU!qpD*xu>@>0wH$aFsI&hVz=r;KswA!j+HEFG870@TnG%k_(y zxe;D*s!2TBSAyF&;HuVLmia{o#ZrZTlI@OK?a8bfjn-a<5>XQ^@IFF|L$1I>#O_S&4Z)t2N-EdpI!J)3vcs!_RURe`BKYux{{p9k_r)RD= zd)tuX-v9vk_;-g#006#w?-=m^eZMa1b!Tk=;P?OlfWY7K7Uo}d!hW*{-%U8=&Z%4)=f0 z*tQF2+6Vhu#`+&gC&=g@!HVASyr5UVXOTznT+cbDw0{m;>p=8s-g8DX0P>a$6mYP<`ECO~XKNEjLda;v~k^-tK-8iV-e28!}GEGds*Pg&%bYQxz2 zwelhIWuB@}U_|{6j5QB%>JMN6gube}2dfJYCGibB(>{RP;<{1gjD>}T;kpf&@d%E^ zGq|SufU!mZOU%KDa?*%ufZS>TdVB+w?F_hUApq=yFuvx3&GF~(c!>ta5)D{oHp*p+ zPs(V#S4QKD+6VCPmvE@HMeEiU6Af)31jAqvyaW?r3BWMvD>u4zLx;wKxH$o@ega!s zd%RrVgn3N|M?LGORsbAL0A6_p+c^HeclK}CTz`NMehyUvybmA3yu_+L9`iJQ!)6)d zNgPk<-=KNZaQ(|BO#}c}0T<0MSjU@vgc@)5#dECLPj)0W!^KADSNhA0v9E&wl-S;k z5FLjNhMx@0BKXJcW(g4S5ai`AH7gJtZ#AnB7@zH&0kY!<*y5OWXWXlnD}#ltjh|x+ z;T0H~GQ|)3y)l+6($rh<$J(`pQ7985!=_c%TJN5<@S2xcO&y2XDp!jt4fE@TcGKM9 z&{!NcRog9Xym#V;S?GK721|T%xASjWa7Zo>8@VCAxxP{NuEpsOs;slLl1{P)M@Gvo+A!3@ShcX*)K~~<6XQ}!Y0J8mnbLp-vl>D5o_MY$Rw+2_ LyROrRccnxYYDD^# literal 0 HcmV?d00001 From 92badfeac607bc70c57dd758a5c1a2f8a8fd0e54 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 04:49:32 +0200 Subject: [PATCH 74/96] feat(http): serve the connection page from a file The inline HTML constant becomes a file read, so the page is editable as a document instead of a template literal. font-src moves from 'none' to 'self' to admit the three same-origin typefaces. script-src stays 'none' and style-src still omits 'self', so styles remain inline. The typeface table is exact-match: request paths are only ever compared against its keys, so no caller-supplied string reaches the filesystem and traversal is not expressible. --- src/http/connection-page.ts | 366 +++++------------------------------- 1 file changed, 52 insertions(+), 314 deletions(-) diff --git a/src/http/connection-page.ts b/src/http/connection-page.ts index f2fc2f9..53d8633 100644 --- a/src/http/connection-page.ts +++ b/src/http/connection-page.ts @@ -1,9 +1,16 @@ +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 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'none'; script-src 'none'; style-src 'unsafe-inline'", + "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": @@ -14,321 +21,22 @@ const SECURITY_HEADERS = { "x-frame-options": "DENY", } as const; -const CONNECTION_PAGE = ` - - - - - - Connect DeepTrace to your AI - - - -
-

DeepTrace MCP

-

Connect pool research to your AI

-

One remote server gives Claude Code, OpenCode, or Codex read-only Graph metrics with separate Nuthatch freshness evidence.

-
-
- - -
-

Before you connect

-
    -
  1. Ask the DeepTrace maintainer for a bearer token through a secure channel.
  2. -
  3. Store it in DEEPTRACE_TOKEN; never place it in a URL, prompt, repository, or support log.
  4. -
  5. Add the configuration for your client, launch it from the same shell, and verify deeptrace is connected.
  6. -
-
read -rsp "DeepTrace token: " DEEPTRACE_TOKEN
-export DEEPTRACE_TOKEN
-
- -
-

Choose your AI client

-
-
-

Claude Code

-

Save as project-level .mcp.json, then run claude mcp list.

-
{
-  "mcpServers": {
-    "deeptrace": {
-      "type": "http",
-      "url": "https://mcp.ikodo.dev",
-      "headers": {
-        "Authorization": "Bearer \${DEEPTRACE_TOKEN}"
-      },
-      "alwaysLoad": true
-    }
-  }
-}
-
-
-

OpenCode

-

Save in ~/.config/opencode/opencode.json, then run opencode mcp list.

-
{
-  "$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}"
-      }
-    }
-  }
-}
-
-
-

Codex

-

Add to ~/.codex/config.toml, then run codex mcp list.

-
[mcp_servers.deeptrace]
-url = "https://mcp.ikodo.dev"
-bearer_token_env_var = "DEEPTRACE_TOKEN"
-
-
-
+function readPublicAsset(name: string): Buffer { + return readFileSync(new URL(`./public/${name}`, import.meta.url)); +} -
-

Try a pool comparison

-

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.
-

A partial result can still contain useful evidence. Read its coverage, freshness, warnings, and provenance before relying on the ranking.

-
+const CONNECTION_PAGE = readPublicAsset("index.html"); -
-

Optional Agent Skill

-

You do not need the skill to use DeepTrace. All three clients discover compare_pools and find_large_swaps with their essential safety guidance from the MCP server.

-

For a richer pool and large-swap research workflow, run this from the project where you use your AI:

-
npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research
-

The installer detects supported agents and asks where to install. Review the source before approving it, then start a new AI session. Installing the skill does not configure MCP or store your token.

-
- What does the skill add? -

It teaches the AI to preserve exact decimal strings, distinguish Graph metrics from Nuthatch evidence, use stable large-swap cursors, and surface partial or failed coverage. Connecting MCP does not automatically install or load the skill.

-

View the DeepTrace Pool Research skill.

-
-
-
-
-

Canonical MCP URL: https://mcp.ikodo.dev. The older /mcp path remains available only for existing client configurations. Read the full setup guide.

-
- - -`; +/** + * 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 ( @@ -363,3 +71,33 @@ export function respondConnectionPage(response: ServerResponse): void { }); 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); +} From 3570535d04486e78bd26924de2bd728f0e210896 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 04:49:50 +0200 Subject: [PATCH 75/96] feat(http): route typeface requests before the MCP paths The typefaces are the only non-MCP paths this origin answers, so they resolve ahead of the MCP path check and before authentication. The build now copies .html and .woff2 beside the emitted JavaScript, since tsc emits only JS and the runtime reads these at startup. Prettier skips the page because its
 whitespace is rendered output, and
reformatting the inline stylesheet tripled the file.
---
 .prettierignore               |  1 +
 scripts/copy-build-assets.mjs | 11 +++++++----
 src/http/server.ts            | 14 +++++++++++++-
 3 files changed, 21 insertions(+), 5 deletions(-)

diff --git a/.prettierignore b/.prettierignore
index 2e2d836..b71d25a 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -3,3 +3,4 @@ node_modules
 package-lock.json
 *.md
 LICENSE
+src/http/public/index.html
diff --git a/scripts/copy-build-assets.mjs b/scripts/copy-build-assets.mjs
index 3a6a6ea..6936f1a 100644
--- a/scripts/copy-build-assets.mjs
+++ b/scripts/copy-build-assets.mjs
@@ -6,14 +6,17 @@ const root = fileURLToPath(new URL("..", import.meta.url));
 const srcDir = join(root, "src");
 const distDir = join(root, "dist");
 
-async function findJsonFiles(dir) {
+// 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 findJsonFiles(abs)));
-    } else if (entry.isFile() && entry.name.endsWith(".json")) {
+      files.push(...(await findAssetFiles(abs)));
+    } else if (entry.isFile() && ASSET_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
       files.push(abs);
     }
   }
@@ -27,7 +30,7 @@ try {
   process.exit(1);
 }
 
-const files = await findJsonFiles(srcDir);
+const files = await findAssetFiles(srcDir);
 for (const src of files) {
   const rel = relative(srcDir, src);
   const dest = join(distDir, rel);
diff --git a/src/http/server.ts b/src/http/server.ts
index de42ec2..b50bb9a 100644
--- a/src/http/server.ts
+++ b/src/http/server.ts
@@ -11,7 +11,12 @@ 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 { acceptsConnectionPage, respondConnectionPage } from "./connection-page.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"]);
@@ -251,6 +256,13 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions
           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;
+        }
+
         if (!MCP_PATHS.has(url.pathname)) {
           respondJson(response, 404, "not_found", "Unknown endpoint");
           return;

From 509a49f0aa7bc58ecfc097017ae81663351c2676 Mon Sep 17 00:00:00 2001
From: ikodo0 
Date: Sun, 26 Jul 2026 04:50:03 +0200
Subject: [PATCH 76/96] test(http): cover the connection page typeface route

Asserts the typefaces are served unauthenticated with immutable caching,
that unknown and traversal-shaped asset paths are refused, and that the
page links to nothing beyond its own fonts.
---
 src/http/public/index.html        |  8 +++---
 tests/unit/http-transport.test.ts | 45 ++++++++++++++++++++++++++++---
 2 files changed, 45 insertions(+), 8 deletions(-)

diff --git a/src/http/public/index.html b/src/http/public/index.html
index d234031..50c53ab 100644
--- a/src/http/public/index.html
+++ b/src/http/public/index.html
@@ -306,14 +306,13 @@ 

Warnings

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.

+

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
+
$ npx skills add https://github.com/ikodo0/deeptrace/tree/develop/skills/deeptrace-pool-research
-
Review first
Read the skill before approving installation.
+
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.
@@ -329,6 +328,7 @@

Warnings

Rank by
TVL, volume, or fees — up to three pools
The Graph
Supplies every financial metric
Nuthatch
Supplies swap-index freshness only. The two never mix — that separation is what makes freshness independent evidence.
+
Tools
compare_pools and find_large_swaps are live. get_dex_metrics and research_wallet are queued.
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.
diff --git a/tests/unit/http-transport.test.ts b/tests/unit/http-transport.test.ts index 5d1f943..f3f11b4 100644 --- a/tests/unit/http-transport.test.ts +++ b/tests/unit/http-transport.test.ts @@ -370,18 +370,55 @@ describe("HTTP routing", () => { 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("

Connect pool research to your AI

"); + 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 and find_large_swaps"); - expect(body).toContain("No Tailscale required"); + expect(body).toContain("compare_pools"); + expect(body).toContain("find_large_swaps"); + 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(); } From 3c763130d601c7a1bc1f38b4540f2132593585ce Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:17:20 +0100 Subject: [PATCH 77/96] feat: compare Base markets through Messari standardized subgraphs (#48) * feat(registry): pin Messari standardized deployments on Base Replace the native Uniswap V3 and PancakeSwap records with one Messari dex-amm deployment bound twice, once per WETH/USDC fee tier, and add the three Messari lending deployments behind a new "lending" category. The compare-lending profile mirrors the compare-pools join, so both profiles share the loader's duplicate, category, and same-tier checks rather than growing a second set of validation rules. * feat(graph): read pool metrics from the Messari dex-amm standard Dispatch the pool adapter on query_id so the Tier-A Messari path and the native Tier-B path can coexist, and extract the parsing helpers both share into response.ts. Tier-A differs from the native schema in three ways that are easy to miss: inputTokens replaces token0/token1 and reports decimals as an Int, the fee tier lives in a fees array as a percentage rather than a bps integer, and snapshots are keyed by day-since-epoch rather than a midnight timestamp. The adapter converts the last one before handing snapshots to the shared completed-UTC-day aggregator, so window semantics stay identical across both tiers. The standard publishes no per-day fee field, so fees_usd maps to dailyTotalRevenueUSD. That is supply-side plus protocol-side revenue; on both compared pools the protocol-side share is zero today, but that is a property of the pools rather than a conversion performed here. * feat(lending): define the compare_lending_markets contract Lock the USDC market, the five ranking metrics, and the three-source coverage expectation in policy, then add the request and response schemas and the scope binding they validate against. The market token is policy rather than a request field: the tool compares one asset across every selected protocol, so letting callers vary it would change what the comparison means. Rates are carried as decimal strings with an explicit percent_apy basis so no layer is tempted to convert between APR and APY. * feat(lending): query the three Base lending deployments One query document serves Aave v3, Seamless, and Moonwell unchanged, which is the whole reason for standardizing: the sources differ by deployment, not by adapter. Two decisions worth naming. The adapter reads lendingProtocols.network back and rejects anything that does not self-report BASE, because a deployment published for one network can index another -- the Compound v3 "base" deployment reports MAINNET, which is why it is out of scope. Second, an absent rate pair stays null and silent because Moonwell genuinely publishes no stable borrow rate, while a duplicated or malformed pair yields null plus a warning, since both defaulting to zero and picking arbitrarily would invent a number no source reported. * feat(lending): rank and settle lending market results Rank by the requested metric with nulls last and deterministic protocol/market/source tie-breaks, then settle into the same status/coverage/freshness/provenance envelope compare_pools returns. Each protocol contributes at most one market for the locked asset, so unlike compare_pools there is nothing to deduplicate across sources. A market reporting isActive false is still ranked and returned with the flag and a warning, because its balances and rates remain real. * feat(mcp): register compare_lending_markets Wire the tool behind an injected source gateway and advertise it beside compare_pools with the same read-only annotations. The live lending gateway defers registry loading into fetchLendingResults rather than resolving it at construction, so creating the server cannot throw on a registry problem. Tests inject both gateways to stay offline. * test(smoke): exercise both tools in the MCP smoke check Require every public tool in tools/list and call each one, so a tool that is built but never registered cannot pass unnoticed. * docs: describe the Messari scope and the lending tool Record the pinned deployments, the field mapping, and the two caveats a reader will otherwise trip over: fees_usd is revenue rather than a fee field, and Compound v3 is excluded for self-reporting MAINNET. The superseded native Tier-B selection is kept at the end of source-scope so the earlier sweep's findings are not lost. Extend the existing skill to cover both tools rather than adding a second one, since an agent choosing between them needs a single set of rules. * fix(quality): count sources that answered, not records returned Both tools derived successful coverage from the post-Top-N record count, so asking for top_n 1 while all sources were healthy and fresh reported one successful source and settled partial. Top-N is the caller's choice, not a coverage gap, and the truncation already has its own warning. Coverage now counts the sources that returned ok. The response schemas relax from requiring equality to requiring that records never outnumber the sources that answered, which still catches the inconsistency the equality check was there to prevent. --- PLAN.md | 374 ++++++------ README.md | 30 +- docs/mcp-testing.md | 147 +++-- docs/source-scope.md | 199 ++++--- scripts/m3/capture-tier-a-evidence.mjs | 71 +++ scripts/mcp-smoke.mjs | 182 +++--- skills/deeptrace-pool-research/SKILL.md | 114 +++- .../agents/openai.yaml | 8 +- src/mcp/server.ts | 115 +++- src/metrics/index.ts | 1 + src/metrics/methodology.ts | 4 +- src/metrics/rank-lending.ts | 125 ++++ src/policy/index.ts | 2 + src/policy/m0.ts | 27 +- src/quality/index.ts | 1 + src/quality/settle-lending.ts | 245 ++++++++ src/quality/settle.ts | 4 +- src/registry/compare-lending.json | 25 + src/registry/compare-pools.json | 16 +- src/registry/index.test.ts | 23 +- src/registry/index.ts | 279 +++++++-- src/registry/records.json | 81 ++- src/registry/types.ts | 2 +- src/schemas/compare-lending-request.ts | 28 + src/schemas/compare-lending.ts | 224 +++++++ src/schemas/compare-pools.ts | 5 +- src/schemas/index.ts | 16 + src/schemas/source-adapter.ts | 33 ++ src/scope/compare-lending.ts | 39 ++ src/scope/compare-pools.ts | 24 +- src/scope/index.ts | 1 + src/sources/graph/adapter.ts | 297 ++++++---- src/sources/graph/index.ts | 15 +- src/sources/graph/lending-adapter.ts | 459 +++++++++++++++ src/sources/graph/lending-queries.ts | 44 ++ src/sources/graph/queries.ts | 51 +- src/sources/graph/response.ts | 120 ++++ src/tools/compare-lending-sources.ts | 12 + src/tools/compare-lending.ts | 97 ++++ src/tools/fixture-lending-sources.ts | 19 + src/tools/index.ts | 11 + src/tools/live-lending-sources.ts | 33 ++ tests/fixtures/lending-sources.ts | 170 ++++++ tests/fixtures/live-compare-pools.ts | 84 +-- tests/fixtures/sources/index.ts | 138 ++--- .../01-pool-metrics.json | 102 ++++ .../01-pool-metrics.json | 102 ++++ tests/unit/compare-lending-registry.test.ts | 91 +++ tests/unit/compare-lending-tool.test.ts | 177 ++++++ tests/unit/compare-pools-scope.test.ts | 6 +- tests/unit/compare-pools-tool.test.ts | 13 +- tests/unit/find-large-swaps-tool.test.ts | 9 +- tests/unit/graph-adapter.test.ts | 419 +++++++++----- tests/unit/lending-adapter.test.ts | 546 ++++++++++++++++++ tests/unit/live-compare-pools.test.ts | 8 +- tests/unit/mcp-server.test.ts | 132 ++++- tests/unit/mcp-smoke.test.mjs | 26 +- tests/unit/metrics-ranking.test.ts | 6 +- tests/unit/normalization-live-binding.test.ts | 8 +- tests/unit/quality-settle.test.ts | 47 +- 60 files changed, 4692 insertions(+), 995 deletions(-) create mode 100644 scripts/m3/capture-tier-a-evidence.mjs create mode 100644 src/metrics/rank-lending.ts create mode 100644 src/quality/settle-lending.ts create mode 100644 src/registry/compare-lending.json create mode 100644 src/schemas/compare-lending-request.ts create mode 100644 src/schemas/compare-lending.ts create mode 100644 src/scope/compare-lending.ts create mode 100644 src/sources/graph/lending-adapter.ts create mode 100644 src/sources/graph/lending-queries.ts create mode 100644 src/sources/graph/response.ts create mode 100644 src/tools/compare-lending-sources.ts create mode 100644 src/tools/compare-lending.ts create mode 100644 src/tools/fixture-lending-sources.ts create mode 100644 src/tools/live-lending-sources.ts create mode 100644 tests/fixtures/lending-sources.ts create mode 100644 tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee005/01-pool-metrics.json create mode 100644 tests/integration/__evidence__/m3/messari-uniswap-v3-base-fee030/01-pool-metrics.json create mode 100644 tests/unit/compare-lending-registry.test.ts create mode 100644 tests/unit/compare-lending-tool.test.ts create mode 100644 tests/unit/lending-adapter.test.ts diff --git a/PLAN.md b/PLAN.md index c36750c..d5a4698 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,10 +4,10 @@ DeepTrace is a read-only Graph Research MCP for builders and AI agents. It combi This file is the shared product and implementation reference: -* **Global Product Goals** describe the intended DeepTrace product. -* The **three-tool MVP** is the current implementation commitment. -* Its tools ship sequentially as M0, LSS and WR. -* Protocol DEX Metrics, broader lending and expansion remain post-MVP. +- **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 @@ -23,86 +23,93 @@ The first users are builders integrating on-chain research into applications and ## Current Build Target — Three-Tool MVP -The MVP public surface is implemented and accepted in this order: +The MVP public surface is implemented: ```text compare_pools +compare_lending_markets find_large_swaps -research_wallet ``` -`get_dex_metrics` is post-MVP. The server registers only implemented tools. +`get_dex_metrics` and `research_wallet` are post-MVP. The server registers only +implemented tools. -### Tool 1 — Pool Comparison (M0) +### Tools 1–2 — Pool Comparison and Lending Markets (Messari standardized) -The first implementation is one complete vertical slice: +The Graph-backed vertical slice: ```text compare_pools +compare_lending_markets ``` -M0 compares one token pair across two same-tier DEX deployments on one chain. +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. It must: -* query the same compatible DEX pattern across both 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; -* return verified structured data only (no internal model call); -* expose the flow through MCP and the single `SKILL.md` for client-side presentation. +- 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. ### Scope to Lock Before Coding -| Item | MVP-0 value | -| :--- | :--- | -| Chain | Base (`8453`) | -| Token pair | WETH `0x4200000000000000000000000000000000000006` / native USDC `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` | -| Standardized DEX deployments | Two Uniswap-V3-lineage native deployments (owner-amended from three): Uniswap V3 `QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR`, PancakeSwap V3 `QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g` — see `docs/source-scope.md` | -| Nuthatch contracts/views | One of the two confirmed pools; final pick in M4.1 (prefer Uniswap V3 WETH/USDC 0.3%) | -| Time windows | `24h` and `7d` | -| Initial metrics | TVL, volume and fees | -| Ranking metric | `volume_usd` by default; TVL and fees are selectable | -| Top-N | Default and maximum `3` | -| USD price source | Source-reported USD values only; no repricing in MVP-0 | -| Public tools implemented through LSS | `compare_pools`, `find_large_swaps` | - -Base does not yield three live Messari-standardized DEX deployments; MVP-0 standardizes on the Uniswap-V3 native schema family instead (`source_type: "native_subgraph"`), with an owner-approved reduction to two Graph sources. See `docs/source-scope.md`. +| 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-0 Definition of Done +### MVP Definition of Done -MVP-0 is complete when: +The three-tool MVP is complete when: -* one live request returns comparable records for the two locked Graph 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; -* the client skill presents only returned facts and provenance IDs (no invented metrics); -* the complete flow can be demonstrated in under two minutes. +- 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 2 — Large Swap Search (LSS) +### 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. -### Tool 3 — Wallet Research (WR) - -`research_wallet` composes verified Graph wallet/account facts with Nuthatch -activity from explicitly indexed contracts. It remains Base-only, -source-bounded and section-aware; observed assets are not complete balances. - ### Post-MVP Milestones -1. **Protocol DEX Metrics** — protocol-level aggregates. -2. **Lending and DeFi Positions** — broader lending within wallet research. -3. **Expansion** — additional chains, deployments, discovery and vaults. +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 @@ -110,23 +117,23 @@ 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 the two verified DEX deployments in the same schema family; -* 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: -* three MVP 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). +- 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 @@ -134,49 +141,51 @@ The MVP interface contains one `SKILL.md` and three high-level MCP tools: ```text compare_pools +compare_lending_markets find_large_swaps -research_wallet ``` -The server registers only implemented tools. `compare_pools` ships first, -`find_large_swaps` second and `research_wallet` third. +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 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 tool 1 (M0) | Locked pair and chain, time window, ranking metric and Top-N | Canonical pool records plus Nuthatch freshness and deterministic cross-DEX ranking | -| `find_large_swaps` | MVP tool 2 (LSS) | Locked pool and chain, threshold token, human-unit minimum amount, limit and cursor | Stable pages of normalized Nuthatch swaps passing the non-USD threshold | -| `research_wallet` | MVP tool 3 (WR) | Public address, Base, requested supported sections, window, limit and cursor | Supported Graph positions plus Nuthatch activity, counterparties, protocol usage and observable flows | -| `get_dex_metrics` | Post-MVP | Protocols, chains, time window and requested metrics | Comparable protocol TVL, volume, fees, revenue and usage | +| 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 M0 and LSS, the chain, pair, two Graph deployments, locked Uniswap pool and -Nuthatch capabilities are configuration-backed allowlisted values. Inputs -outside that scope return a clear unsupported-scope error. +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 structured results without inventing metrics or replacing 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. -The skill documents only tools present in `tools/list`; it now covers -`compare_pools` and `find_large_swaps`. +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. Pool Comparison and Large Swap -Search are implemented before Wallet Research. +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 @@ -189,9 +198,10 @@ 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 + ├── DEX Metrics later + ├── Pool Comparison implemented + ├── Lending Markets implemented + └── Large Swap Search implemented │ ▼ Source + Coverage Registry @@ -231,7 +241,7 @@ 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. **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. @@ -243,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. @@ -256,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. @@ -265,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. @@ -336,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. @@ -387,12 +397,12 @@ 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 selected WETH or USDC absolute pool +- **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 @@ -449,36 +459,36 @@ lock a separate price source and timestamp methodology. ### Rankings and Thresholds -* Rankings use the requested metric in descending order with unavailable values last. -* Ties use normalized protocol ascending, pool address ascending and source ID +- 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 +- 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 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 +- 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, +- 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 +- `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 +- `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 +- 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. @@ -556,10 +566,10 @@ envelope only. There is no `ai_reasoning` field. Presentation is the job of the **user's AI** guided by `SKILL.md`. That skill must: -* 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. +- 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. Do not implement `src/reasoning/` or provider keys unless a later milestone explicitly reopens internal AI. @@ -587,77 +597,77 @@ tests/ ``` Internal modules may contain many functions, but only implemented high-level -handlers are registered as public MCP tools. Through LSS the registered surface -is `compare_pools` plus `find_large_swaps`. +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 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. +- 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 the two locked 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. +- implement `compare_pools`; +- write one `SKILL.md` for the available tool; +- add rate limits and read-only policy; +- add setup and client configuration. ### 6. Ship -* 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. +- 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; -* the two locked 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 @@ -665,15 +675,17 @@ For selected events visible in both Graph and Nuthatch, compare transaction hash ### Client Presentation (skill) -* skill instructs the client AI to cite only returned `source_ids` and surface warnings/freshness; -* no DeepTrace provider/model tests (no internal AI). +- 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 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. Run `find_large_swaps`, follow one returned cursor, and show exact amounts, +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. -6. Let the client chat pane narrate only from the structured data. +7. Let the client chat pane narrate only from the structured data. diff --git a/README.md b/README.md index d20d323..0023042 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # DeepTrace -DeepTrace is a read-only research MCP that compares Base liquidity pools with -Graph subgraph metrics and independent Nuthatch swap freshness, and returns -stable pages of token-thresholded swaps from the locked Base Uniswap V3 -WETH/USDC pool. +DeepTrace is a read-only research MCP that compares Base liquidity pools and +USDC lending markets with Graph subgraph metrics and independent Nuthatch swap +freshness, and returns stable pages of token-thresholded swaps from the locked +Base Uniswap V3 WETH/USDC pool. ## Connect @@ -13,10 +13,12 @@ Use the public Streamable HTTP endpoint: https://mcp.ikodo.dev ``` -Configure the access token as an `Authorization: Bearer` header in your MCP -client. A normal user does not need Tailscale, a Graph API key, Nuthatch access, -or a local checkout. See [Connect an AI client](docs/connect.md) for the short -setup and compatibility notes. +Browsers that request HTML receive a short public connection page at that same +root URL. Configure the access token as an `Authorization: Bearer` header in +your MCP client. A normal user does not need Tailscale, a Graph API key, +Nuthatch access, or a local checkout. See +[Connect an AI client](docs/connect.md) for the short setup and compatibility +notes. The optional installable [DeepTrace Pool Research skill](skills/deeptrace-pool-research/SKILL.md) @@ -27,8 +29,10 @@ installation. Released tools: -- `compare_pools` — rank the locked Base WETH/USDC pools by Graph-reported TVL, - volume, or fees, with independent Nuthatch freshness. +- `compare_pools` — rank the locked Base WETH/USDC Uniswap V3 fee tiers by + Graph-reported TVL, volume, or fees, with independent Nuthatch freshness. +- `compare_lending_markets` — compare the native-USDC market across Aave v3, + Seamless, and Moonwell through one shared Messari lending query template. - `find_large_swaps` — search the locked Uniswap V3 pool using an exact WETH or USDC human-unit threshold and opaque fixed-snapshot pagination. V1 performs no USD conversion. @@ -85,5 +89,7 @@ Use an absolute path to the built entry point: } ``` -Deployment notes live in [docs/deployment.md](docs/deployment.md), and the -operator test procedure lives in [docs/mcp-testing.md](docs/mcp-testing.md). +For the public HTTP endpoint, see [docs/connect.md](docs/connect.md). Never put +the bearer token in the URL. Deployment notes live in +[docs/deployment.md](docs/deployment.md), and the operator test procedure lives +in [docs/mcp-testing.md](docs/mcp-testing.md). diff --git a/docs/mcp-testing.md b/docs/mcp-testing.md index fc9a1cc..ffe87e6 100644 --- a/docs/mcp-testing.md +++ b/docs/mcp-testing.md @@ -13,10 +13,10 @@ server; it is never exposed to an end user's machine. 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 | +| 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`, @@ -78,6 +78,12 @@ status `complete`, successful Graph coverage, and 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 @@ -86,12 +92,12 @@ request scope. This tool does not calculate USD notional. ### 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. | +| 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. @@ -102,15 +108,15 @@ 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` | Required for the HTTP transport. Minimum 32 characters. Startup fails otherwise. | -| `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. | +| Variable | Purpose | +| ------------------------------------------ | -------------------------------------------------------------------------------- | +| `DEEPTRACE_HTTP_TOKEN` | Required for the HTTP transport. Minimum 32 characters. Startup fails otherwise. | +| `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 @@ -181,15 +187,19 @@ 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 three 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,find_large_swaps] +tools/list PASS tools=[compare_pools,compare_lending_markets,find_large_swaps] 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 -7 passed, 0 failed ``` Both variables are required; missing ones are reported by name only. The exit @@ -203,13 +213,13 @@ 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`, 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`. +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`. ### Manual path @@ -252,8 +262,8 @@ curl -sS -X POST http://127.0.0.1:8799 \ ### Step 3 — tools/list -Verified to return exactly two tools, `compare_pools` and -`find_large_swaps`. +Verified to return three read-only tools: `compare_pools`, +`compare_lending_markets`, and `find_large_swaps`. ``` curl -sS -X POST http://127.0.0.1:8799 \ @@ -264,7 +274,7 @@ curl -sS -X POST http://127.0.0.1:8799 \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' ``` -### Step 4 — tools/call +### 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 @@ -280,19 +290,53 @@ curl -sS --max-time 90 -X POST http://127.0.0.1:8799 \ -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"}}}' ``` -Verified result today (values change; shape does not): +Point-in-time sample from the Messari standardized fee-tier scope (values +change; shape does not): ``` -status: complete -coverage: {"requested_deployments":2,"successful_deployments":2,"nuthatch_available":true} -pool 0x6c561b44... tvl 151138739.377709 -pool 0x72ab388e... tvl 6612457.78705088 +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: ``` @@ -301,7 +345,7 @@ curl -sS --max-time 90 -X POST http://127.0.0.1:8799 \ -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":"find_large_swaps","arguments":{"chain_id":8453,"pool_address":"0x6c561b446416e1a00e8e93e221854d6ea4171372","threshold_token":"0x4200000000000000000000000000000000000006","min_amount":"1","limit":1}}}' + -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 @@ -406,19 +450,19 @@ 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` missing or shorter than 32 characters. | -| `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. | +| 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` missing or shorter than 32 characters. | +| `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. | +| 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 @@ -481,3 +525,8 @@ service, and reissue the token to every client. `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 index 2b48e22..948d27e 100644 --- a/docs/source-scope.md +++ b/docs/source-scope.md @@ -1,103 +1,132 @@ -# M2 Live Source Scope — Amended to two sources +# Live Source Scope — Messari standardized subgraphs on Base ## Status -**Amended 2026-07-25 by owner decision: MVP-0 ships with two same-tier Graph -sources instead of three.** The two validated Tier-B deployments below are the -final M2 selection, and `src/registry/records.json` is finalized from them. +**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. -The original three-source criterion was not met, and this is a deliberate scope -reduction rather than a satisfied gate. Aerodrome was neither retried nor -replaced; no tier mixing or silent fallback was introduced. The third source -remains available as a later addition: adding it is a `records.json` plus -`compare-pools.json` edit, with no loader or adapter change, because the -same-tier invariant is enforced across whatever set is configured. +- `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. -The exit criterion for M3 changes accordingly — one call returns **two** valid -`SourceResult` values, not three. - -### Prior status, retained for the record - -M2 ended blocked as of the 2026-07-25 capture. The required three compatible -deployments and pools were not demonstrated. Two Tier-B deployments passed the -native-USDC pool and common-query checks; Aerodrome pool discovery timed out. -No retry, tier mixing, silent fallback, fixture rewrite, or `PLAN.md` scope lock -was performed. +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). -- Base token: WETH `0x4200000000000000000000000000000000000006`, - symbol `WETH`, 18 decimals. -- Quote token: native USDC - `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913`, symbol `USDC`, - 6 decimals. -- Schema tier under review: Tier B, Uniswap-V3 native lineage. -- Versions: `null`; Tier-B deployments do not publish Messari schema or - methodology versions. - -The token probes also found USDbC consistently, but native USDC remained the -default and was used for pool discovery. - -## Validated sources - -| Protocol | Subgraph ID | Deployment ID | Pool | Fee | Result | -| :--- | :--- | :--- | :--- | :--- | :--- | -| Uniswap V3 | `GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz` | `QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR` | `0x6c561b446416e1a00e8e93e221854d6ea4171372` | 30 bps (`feeTier` 3000) | Common query returned TVL and non-zero daily volume/fees. | -| PancakeSwap V3 | `BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3` | `QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g` | `0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38` | 1 bps (`feeTier` 100) | Common query returned TVL and non-zero daily volume/fees. | - -Both pool responses agree on the configured WETH and native-USDC addresses, -symbols, and decimals. The exact `m2-tier-b-metrics-v1` query document returned -the pool plus `poolDayDatas` for both. - -## Blocking third source - -Aerodrome Full was healthy at the metadata, marker, lineage, and token stages: - -- Subgraph ID: - `GENunSHWLBXm59mBSgPzQ8metBEp9YDfdqwFr91Av1UM` -- Deployment ID: - `QmasYjypV6nTLp4iNH4Vjf7fksRNxAkAskqDdKf2DCsQkV` - -Its native-USDC pool-discovery request exceeded the binding 15-second deadline. -The failure is preserved in `aerodrome-base-full/05-pools.json`. The scouting -policy forbids retries that mask failures, and the user chose to stop rather -than expand candidate scope or manually review Aerodrome. - -## Candidate decisions - -- Tier A had only one healthy candidate, Messari Uniswap V3, so it could not - satisfy the three-source rule. -- SushiSwap's `factories` marker was coincidental: its lineage evidence has no - compatible `Pool` or `PoolDayData`. -- Aerodrome Slipstream renames or omits required pool/day fields, breaking the - one-template rule. -- Balancer V2, BaseSwap V2, and the first Pancake candidate did not match either - supported tier. -- Four community candidates returned no usable `_meta` deployment and were - rejected. -- The alternate Uniswap deployment was not promoted because its token probe - timed out and it would not provide a distinct third protocol. +- 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. +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 -Evidence is stored under `tests/integration/__evidence__/m2/`. The manifest -records the reference block, per-candidate outcome, token decision, two -validated selections, and the blocker. Captures are point-in-time observations; -publishers may update the deployment behind a stable subgraph ID. - -## Closeout (owner-amended two-source exit) - -- `src/registry/records.json` holds the two active Graph records above. -- `tests/fixtures/sources/index.ts` carries real pool/deployment values; the - timeout fixture is synthetic status over real provenance; Nuthatch remains - shape-only until M5. -- `PLAN.md` scope table is filled, including the Tier B + two-source amendment. -- M3 exit is two live Graph `SourceResult`s in one call, not three. +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/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/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index 5ba8c08..19df4e4 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -178,6 +178,45 @@ if (!initOk || sessionId === "") { process.exit(1); } +const EXPECTED_TOOLS = ["compare_pools", "compare_lending_markets", "find_large_swaps"]; + +// Checks 6+: tools/call each public tool with its locked allowlisted args. +// Pool/lending calls pass on complete or partial. Large-swap calls require a +// complete 1/1 coverage settlement (develop's stricter LSS smoke gate). +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 () => { @@ -189,7 +228,7 @@ try { return { ok: status === 202, detail: `status=${status} (expected 202)` }; }); - // Check 5: tools/list -> SSE payload contains both released tools + // Check 5: tools/list -> SSE payload advertises all three public tools await runCheck("tools/list", async () => { const { status, text } = await postJson( MCP_URL, @@ -199,105 +238,60 @@ try { const msg = parseJsonRpc(text); const tools = msg?.result?.tools ?? []; const names = tools.map((t) => t?.name).filter((n) => typeof n === "string"); - const ok = - status === 200 && names.includes("compare_pools") && names.includes("find_large_swaps"); - const detail = - status !== 200 ? `status=${status} (expected 200)` : `tools=[${names.join(",")}]`; + 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 }; }); - // Check 6: tools/call compare_pools with locked args - await runCheck("tools/call compare_pools", async () => { - const { status, text } = await postJson( - MCP_URL, - { - jsonrpc: "2.0", - id: 4, - method: "tools/call", - params: { - name: "compare_pools", - arguments: { - chain_id: 8453, - token0: "0x4200000000000000000000000000000000000006", - token1: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - window: "24h", - ranked_by: "tvl_usd", - }, - }, + 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", }, - { 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_deployments; - const requested = coverage.requested_deployments; - const covStr = - typeof success === "number" && typeof requested === "number" - ? `${success}/${requested}` - : "?/?"; - const ok = st === "complete" || st === "partial"; - return { ok, detail: `status=${st} ${covStr}` }; - }); + requestedKey: "requested_deployments", + successKey: "successful_deployments", + }), + ); - // Check 7: tools/call find_large_swaps over the locked pool - await runCheck("tools/call find_large_swaps", async () => { - const { status, text } = await postJson( - MCP_URL, - { - 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, - }, - }, + 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", }, - { 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}` - : "?/?"; - return { ok: st === "complete" && covStr === "1/1", detail: `status=${st} ${covStr}` }; - }); + 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, + }), + ); } finally { await closeSession(); } diff --git a/skills/deeptrace-pool-research/SKILL.md b/skills/deeptrace-pool-research/SKILL.md index db70d4b..92b1d98 100644 --- a/skills/deeptrace-pool-research/SKILL.md +++ b/skills/deeptrace-pool-research/SKILL.md @@ -1,17 +1,27 @@ --- name: deeptrace-pool-research -description: Research the locked Base WETH/USDC scope through DeepTrace MCP. Compare pools with Graph metrics and Nuthatch freshness, or find normalized large swaps with exact WETH/USDC thresholds and stable cursors. Use for pool rankings, 24h/7d metrics, source freshness, large swaps, whale-sized trades, pagination, provenance, and partial or failed source results. +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, or find large swaps with exact WETH/USDC thresholds and stable cursors. 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, inspect 24h or 7d metrics, verify source freshness, explain partial results, or distinguish Graph and Nuthatch evidence. --- -# DeepTrace pool and large-swap research +# DeepTrace research -Use `compare_pools` for cross-pool financial rankings. Use -`find_large_swaps` for token-thresholded swap history from the locked Uniswap -V3 pool. Read `structuredContent` when available; otherwise parse the JSON -object in the text result. +DeepTrace exposes three read-only MCP tools. Read `structuredContent` when +available; otherwise parse the JSON object in the text result. -For `compare_pools`, complete or partial records are in `data.pools`. For -`find_large_swaps`, a complete page is in `data.swaps`; failed data is null. +| 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` | + +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 any `failed` result, `data` is null. + +Anything else — other chains, other pairs, other assets, wallet positions, 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 @@ -28,9 +38,11 @@ For `compare_pools`, complete or partial records are in `data.pools`. For Streamable HTTP MCP with a Bearer header. Do not substitute direct Graph, Nuthatch, or price-API calls. -## Build a pool-comparison request +## Build the request -Always use the locked scope: +Every tool has a fully locked scope; every argument is an allowlisted literal. + +`compare_pools`: - `chain_id`: `8453` - `token0`: `0x4200000000000000000000000000000000000006` (WETH) @@ -39,16 +51,22 @@ Always use the locked scope: - `ranked_by`: `tvl_usd`, `volume_usd`, or `fees_usd`; default to `volume_usd` - `top_n`: integer from `1` to `3`; default to `3` -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. +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. -Refuse unsupported chains, pairs, windows, or metrics by stating the exact -supported scope. Never silently change the requested assets. +`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` -## Build a large-swap request +The compared protocols are Aave v3, Seamless, and Moonwell, all on the +native-USDC market, through one shared query template. -Always use the released LSS scope: +`find_large_swaps`: - `chain_id`: `8453` - `pool_address`: `0x6c561b446416e1a00e8e93e221854d6ea4171372` @@ -59,21 +77,41 @@ Always use the released LSS scope: - `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`. +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. +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. ## Interpret the sources -- Treat Graph subgraphs as the source of pool TVL, volume, and fee metrics. - Preserve every financial value as the exact returned decimal string. +- 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 @@ -94,24 +132,36 @@ and a warning when the 64 KiB response budget requires a shorter page. returned `deployment_or_view_id`, query ID, and warnings when they affect confidence. Do not infer which kind the combined identifier represents. +`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, ranking metric, and overall +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 in rank order with protocol, pool address, TVL, - volume, fees, and source IDs. Show `null` as unavailable. -3. Report the Nuthatch freshness fact separately. If it is unavailable or - stale, say so before explaining the usable Graph results. +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 invent a fallback. +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 @@ -123,7 +173,11 @@ the next tool call. - Never replace nulls with estimates or derive USD values from other fields. - Never convert financial strings through floating-point arithmetic. -- Never infer a fee tier by dividing fees by volume. +- 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 diff --git a/skills/deeptrace-pool-research/agents/openai.yaml b/skills/deeptrace-pool-research/agents/openai.yaml index 9700232..af73b63 100644 --- a/skills/deeptrace-pool-research/agents/openai.yaml +++ b/skills/deeptrace-pool-research/agents/openai.yaml @@ -1,12 +1,12 @@ interface: - display_name: "DeepTrace Pool & Swap Research" - short_description: "Compare Base pools and find large swaps" - default_prompt: "Use $deeptrace-pool-research to compare Base WETH/USDC pools or find token-thresholded swaps with verified freshness and provenance." + display_name: "DeepTrace Pool, Lending & Swap Research" + short_description: "Compare Base pools and lending markets, or find large swaps" + default_prompt: "Use $deeptrace-pool-research to compare Base WETH/USDC pools, Base USDC lending markets, or find token-thresholded swaps with verified freshness and provenance." dependencies: tools: - type: "mcp" value: "deeptrace" - description: "Read-only Base pool metrics, Nuthatch freshness, and stable large-swap pages" + description: "Read-only Base pool metrics, USDC lending markets, Nuthatch freshness, and stable large-swap pages" transport: "streamable_http" url: "https://mcp.ikodo.dev" diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 194af8f..de2ee0d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -4,14 +4,22 @@ 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_RANKING_METRICS, M0_TIME_WINDOWS } from "../policy/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, resultFreshnessSchema, resultProvenanceSchema, @@ -20,13 +28,17 @@ 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 { + CompareLendingRequestError, ComparePoolsRequestError, FindLargeSwapsToolError, LargeSwapQueryError, + createLiveCompareLendingSources, createLiveComparePoolsSources, createLiveLargeSwapSource, + executeCompareLending, executeComparePools, executeFindLargeSwaps, + type CompareLendingSourceGateway, type ComparePoolsSourceGateway, type LargeSwapSourceGateway, } from "../tools/index.js"; @@ -37,10 +49,11 @@ export const serverInfo = { } 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 serverInstructions = - "DeepTrace is read-only for the locked Base (8453) WETH/USDC scope. Graph subgraphs supply pool financial metrics; Nuthatch supplies independent freshness and large-swap receipts. Use compare_pools for 24h/7d TVL, volume, or fee rankings. Use find_large_swaps for stable pages filtered by an exact WETH or USDC human-unit threshold, never USD. Preserve decimal strings, status, warnings, freshness, source_ids, and provenance; never present partial results as complete or failed swap results as data."; + "DeepTrace is read-only for locked Base (8453) scopes. Graph subgraphs supply pool financial metrics and lending rates; Nuthatch supplies independent freshness and large-swap receipts. Use compare_pools (WETH/USDC TVL/volume/fees), compare_lending_markets (USDC Aave v3/Seamless/Moonwell), and find_large_swaps (exact WETH/USDC threshold, never USD). Preserve decimal strings, status, warnings, freshness, source_ids, and provenance; never present partial results as complete or failed swap results as data."; const comparePoolsInputSchema = z .object({ @@ -69,6 +82,28 @@ const comparePoolsInputSchema = z }) .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."), @@ -126,6 +161,31 @@ const comparePoolsOutputSchema = z } }); +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"]), @@ -151,8 +211,10 @@ export interface CreateMcpServerOptions { readonly gatewayConfig?: GatewayConfig; readonly rateLimiter?: FixedWindowRateLimiter; readonly sources?: ComparePoolsSourceGateway; + readonly lendingSources?: CompareLendingSourceGateway; readonly largeSwapSource?: LargeSwapSourceGateway; readonly rateLimitKey?: string; + readonly lendingRateLimitKey?: string; readonly largeSwapRateLimitKey?: string; } @@ -176,12 +238,18 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer createLiveComparePoolsSources({ timeoutMs: gatewayConfig.sourceTimeoutMs, }); + const lendingSources = + options.lendingSources ?? + createLiveCompareLendingSources({ + timeoutMs: gatewayConfig.sourceTimeoutMs, + }); const largeSwapSource = options.largeSwapSource ?? createLiveLargeSwapSource({ 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 server = new McpServer(serverInfo, { @@ -231,6 +299,49 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): McpServer }, ); + 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, { diff --git a/src/metrics/index.ts b/src/metrics/index.ts index fe3f7d0..0cafa7e 100644 --- a/src/metrics/index.ts +++ b/src/metrics/index.ts @@ -1,3 +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 index b380b4e..936eea5 100644 --- a/src/metrics/methodology.ts +++ b/src/metrics/methodology.ts @@ -20,9 +20,9 @@ export const M0_POOL_METRICS_METHODOLOGY = { "Pass through the selected completed-UTC-day window volume USD decimal string or null.", }, fees_usd: { - selection: "source_reported_window_fees_usd_passthrough", + selection: "source_reported_window_total_revenue_usd_passthrough", description: - "Pass through the selected completed-UTC-day window fees USD decimal string or null.", + "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, 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/policy/index.ts b/src/policy/index.ts index 1a587f5..0519b88 100644 --- a/src/policy/index.ts +++ b/src/policy/index.ts @@ -1,9 +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 index cc8ecfd..a1c60aa 100644 --- a/src/policy/m0.ts +++ b/src/policy/m0.ts @@ -7,6 +7,15 @@ 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", @@ -49,9 +58,25 @@ export const M0_CORE_POLICY = { preservesAdapterStatus: true, }, coverage: { - /** Owner-amended MVP-0 Graph scope: two Tier-B deployments, not three. */ + /** 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/index.ts b/src/quality/index.ts index d2e08d2..5df5644 100644 --- a/src/quality/index.ts +++ b/src/quality/index.ts @@ -1,2 +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 index ec89f95..b5c7cbc 100644 --- a/src/quality/settle.ts +++ b/src/quality/settle.ts @@ -288,9 +288,11 @@ export function settleComparePoolsResult(input: SettleComparePoolsInput): Compar : 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: input.pools.length, + successful_deployments: okGraphCount, nuthatch_available: nuthatchAvailable, }; 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 index 2eefa36..14d2f64 100644 --- a/src/registry/compare-pools.json +++ b/src/registry/compare-pools.json @@ -1,22 +1,22 @@ { - "profile_id": "compare-pools-base-weth-usdc-v1", + "profile_id": "compare-pools-base-weth-usdc-v2", "chain_id": 8453, "token0": "0x4200000000000000000000000000000000000006", "token1": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "window_methodology": "completed-utc-days-v1", "sources": [ { - "source_id": "uniswap-v3-base-native", + "source_id": "messari-uniswap-v3-base-fee030", "pool_address": "0x6c561b446416e1a00e8e93e221854d6ea4171372", - "query_id": "m3-tier-b-metrics-v2", - "schema_contract_id": "m2-tier-b-metrics-v1", + "query_id": "tier-a-dex-pool-metrics-v1", + "schema_contract_id": "tier-a-dex-pool-metrics-v1", "priority": 1 }, { - "source_id": "exchange-v3-base", - "pool_address": "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", - "query_id": "m3-tier-b-metrics-v2", - "schema_contract_id": "m2-tier-b-metrics-v1", + "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 index cf155b8..4075416 100644 --- a/src/registry/index.test.ts +++ b/src/registry/index.test.ts @@ -538,20 +538,31 @@ describe("compare-pools profile validation", () => { }); describe("shipped registry and profile", () => { - // Exercises the default-path load (no injection) against the committed M2 + // 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 M2 Graph bindings in priority order", () => { + 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([ - "uniswap-v3-base-native", - "exchange-v3-base", + "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(); @@ -577,7 +588,7 @@ describe("shipped registry and profile", () => { expect(queryIds.size).toBe(1); expect(schemaContractIds.size).toBe(1); - expect([...queryIds][0]).toBe("m3-tier-b-metrics-v2"); - expect([...schemaContractIds][0]).toBe("m2-tier-b-metrics-v1"); + 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 index ab785e2..6359b7c 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -5,7 +5,7 @@ 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, SourceRegistryRecord } from "./types.js"; +import type { GraphSourceRegistryRecord, SourceCategory, SourceRegistryRecord } from "./types.js"; /** * Repository configuration is an untrusted deploy input: it is validated at @@ -31,16 +31,24 @@ 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); /** - * MVP-0 compares exactly two Graph deployments (owner-amended from three). - * Enforcing the count here makes the M2 selection a load-time invariant. + * `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) @@ -52,7 +60,7 @@ const lowercaseAddressSchema = z const registryRecordBaseShape = { source_id: nonEmptyStringSchema, - category: z.literal("dex"), + category: z.enum(["dex", "lending"]), protocol: nonEmptyStringSchema, chain_id: z.literal(BASE_CHAIN_ID), deployment_or_view_id: nonEmptyStringSchema, @@ -120,6 +128,25 @@ const comparePoolsProfileSchema = z }) .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 @@ -129,6 +156,10 @@ 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; @@ -143,6 +174,17 @@ export interface ComparePoolGraphSource { 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. @@ -151,9 +193,8 @@ export interface RegistryLoadOptions { readonly records?: unknown; readonly profile?: unknown; /** - * Entities the selected query needs. M3.3 owns the query and therefore the - * real list; passing none skips the coverage check rather than guessing a - * schema tier that M2 has not fixed. + * 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[]; } @@ -236,8 +277,18 @@ function parseProfile(source: unknown): ComparePoolsProfile { 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) { @@ -265,6 +316,21 @@ function loadProfile(source: unknown): ComparePoolsProfile { 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 @@ -273,6 +339,7 @@ function loadProfile(source: unknown): ComparePoolsProfile { export function resetRegistryCache(): void { cachedRecords = undefined; cachedProfile = undefined; + cachedLendingProfile = undefined; } /** @@ -286,13 +353,19 @@ export function getSourceById( return loadRecords(options.records).find((record) => record.source_id === sourceId); } -function bindingPath(index: number, field: string): string { - return `${COMPARE_POOLS_LABEL}.sources[${String(index)}].${field}`; +type BindingPath = (index: number, field: string) => string; + +function bindingPathFor(label: string): BindingPath { + return (index, field) => `${label}.sources[${String(index)}].${field}`; } -function collectDuplicateIssues( - bindings: readonly ComparePoolBinding[], - field: "source_id" | "pool_address" | "priority", +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[] = []; @@ -304,9 +377,7 @@ function collectDuplicateIssues( firstIndexByValue.set(value, index); return; } - issues.push( - `${bindingPath(index, field)}: "${value}" duplicates sources[${String(firstIndex)}]`, - ); + issues.push(`${path(index, field)}: "${value}" duplicates sources[${String(firstIndex)}]`); }); return issues; @@ -337,6 +408,80 @@ function isGraphRecord(record: SourceRegistryRecord): record is GraphSourceRegis 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 @@ -353,45 +498,21 @@ export function getActiveComparePoolGraphSources( ...(profile.token0 === profile.token1 ? [`${COMPARE_POOLS_LABEL}.token1: must differ from token0 "${profile.token0}"`] : []), - ...collectDuplicateIssues(profile.sources, "source_id"), - ...collectDuplicateIssues(profile.sources, "pool_address"), - ...collectDuplicateIssues(profile.sources, "priority"), - ...collectSharedValueIssues( - profile.sources.map((binding) => binding.query_id), - (index) => bindingPath(index, "query_id"), - "query_id", - ), - ...collectSharedValueIssues( - profile.sources.map((binding) => binding.schema_contract_id), - (index) => bindingPath(index, "schema_contract_id"), - "schema_contract_id", - ), + ...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 path = bindingPath(index, "source_id"); - const record = records.find((candidate) => candidate.source_id === binding.source_id); - + const record = resolveGraphRecord(binding.source_id, bindingPath(index, "source_id"), { + records, + category: "dex", + requiredEntities, + issues, + }); if (record === undefined) { - issues.push(`${path}: "${binding.source_id}" is not present in ${RECORDS_LABEL}`); - return; - } - if (!isGraphRecord(record)) { - issues.push(`${path}: "${binding.source_id}" is not a Graph source`); - return; - } - if (record.status !== "active") { - issues.push(`${path}: "${binding.source_id}" is ${record.status}`); - return; - } - - const missingEntities = requiredEntities.filter( - (entity) => !record.supported_entities.includes(entity), - ); - if (missingEntities.length > 0) { - issues.push(`${path}: "${binding.source_id}" does not support ${missingEntities.join(", ")}`); return; } @@ -410,10 +531,66 @@ export function getActiveComparePoolGraphSources( }); issues.push( - ...collectSharedValueIssues( + ...collectSharedContractIssues( + profile.sources, joined.map((source) => source.record.source_type), - (index) => bindingPath(index, "source_id"), - "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, ), ); diff --git a/src/registry/records.json b/src/registry/records.json index 359aaa2..b683d02 100644 --- a/src/registry/records.json +++ b/src/registry/records.json @@ -1,36 +1,36 @@ [ { - "source_id": "uniswap-v3-base-native", + "source_id": "messari-uniswap-v3-base-fee030", "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"], + "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": "GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz" + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS" } }, { - "source_id": "exchange-v3-base", + "source_id": "messari-uniswap-v3-base-fee005", "category": "dex", - "protocol": "pancakeswap-v3", + "protocol": "uniswap-v3", "chain_id": 8453, - "source_type": "native_subgraph", - "deployment_or_view_id": "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", - "schema_version": null, - "methodology_version": null, - "supported_entities": ["pools", "poolDayDatas", "tokens"], + "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": "BHWNsedAHtmTCzXxCCDfhPmm6iN9rxUhoRHdHKyujic3" + "subgraph_id": "FUbEPQw1oMghy39fwWBFY5fE6MXPXZQtjncQy2cXdrNS" } }, { @@ -66,5 +66,56 @@ "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" + } } ] diff --git a/src/registry/types.ts b/src/registry/types.ts index 5e2c2b0..1117d40 100644 --- a/src/registry/types.ts +++ b/src/registry/types.ts @@ -1,6 +1,6 @@ import type { SourceType } from "../schemas/source-adapter.js"; -export type SourceCategory = "dex"; +export type SourceCategory = "dex" | "lending"; export type RegistrySourceStatus = "active" | "inactive"; 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.ts b/src/schemas/compare-pools.ts index 9343c11..eca8039 100644 --- a/src/schemas/compare-pools.ts +++ b/src/schemas/compare-pools.ts @@ -269,10 +269,11 @@ export const comparePoolsResponseSchema = z : [response.data.nuthatch_freshness_fact.source_id]), ); - if (response.coverage.successful_deployments !== response.data.pools.length) { + // Top-N may return fewer records than answered, never more. + if (response.coverage.successful_deployments < response.data.pools.length) { context.addIssue({ code: "custom", - message: "Successful deployment count must equal the number of pool records", + message: "Pool records cannot outnumber the successful deployments", path: ["coverage", "successful_deployments"], }); } diff --git a/src/schemas/index.ts b/src/schemas/index.ts index fc7f2b0..bbbb42d 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,3 +1,19 @@ +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, diff --git a/src/schemas/source-adapter.ts b/src/schemas/source-adapter.ts index 8bedcff..a4b104a 100644 --- a/src/schemas/source-adapter.ts +++ b/src/schemas/source-adapter.ts @@ -173,3 +173,36 @@ export const nuthatchSourceResultSchema = createSourceResultSchema( 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/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 index b93f025..95991b8 100644 --- a/src/scope/compare-pools.ts +++ b/src/scope/compare-pools.ts @@ -1,10 +1,12 @@ import { BASE_CHAIN_ID } from "../schemas/source-adapter.js"; /** - * Locked MVP-0 compare_pools allowlist for Graph-side binding. + * Locked compare_pools allowlist for Graph-side binding. * - * Values mirror `src/registry/compare-pools.json` and `records.json`. - * The Nuthatch source ID resolves the active registry-pinned freshness view. + * 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, @@ -20,18 +22,18 @@ export const M0_COMPARE_POOLS_SCOPE = { }, graphSources: [ { - source_id: "uniswap-v3-base-native", + source_id: "messari-uniswap-v3-base-fee030", protocol: "uniswap-v3", pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", - query_id: "m3-tier-b-metrics-v2", - deployment_or_view_id: "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", + query_id: "tier-a-dex-pool-metrics-v1", + deployment_or_view_id: "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", }, { - source_id: "exchange-v3-base", - protocol: "pancakeswap-v3", - pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", - query_id: "m3-tier-b-metrics-v2", - deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", + 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. */ diff --git a/src/scope/index.ts b/src/scope/index.ts index 17580fe..7c39024 100644 --- a/src/scope/index.ts +++ b/src/scope/index.ts @@ -1,2 +1,3 @@ +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"; diff --git a/src/sources/graph/adapter.ts b/src/sources/graph/adapter.ts index 525674c..d0c7485 100644 --- a/src/sources/graph/adapter.ts +++ b/src/sources/graph/adapter.ts @@ -11,13 +11,25 @@ import { import { aggregateDailySnapshots, type DailySnapshot } from "./aggregation.js"; import { assertDeployment, deploymentMismatchWarning } from "./deployment-assertion.js"; -import { TIER_B_METRICS_QUERY, TIER_B_METRICS_QUERY_ID } from "./queries.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 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*)$/; +const SECONDS_PER_DAY = 86_400; export interface FetchComparePoolGraphOptions { /** Bearer token for the Graph gateway. Prefer injection in tests. */ @@ -30,16 +42,28 @@ export interface FetchComparePoolGraphOptions { readonly env?: Readonly>; } -function resolveApiKey(options: FetchComparePoolGraphOptions): string | null { - if (options.apiKey !== undefined && options.apiKey.trim() !== "") { - return options.apiKey; - } - const env = options.env ?? process.env; - const fromEnv = env.GRAPH_API_KEY; - if (fromEnv !== undefined && fromEnv.trim() !== "") { - return fromEnv; - } - return null; +/** 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( @@ -81,134 +105,175 @@ function failedResult( }; } -function normalizeAddress(value: string): string | null { - if (!ADDRESS_PATTERN.test(value)) { - return null; - } - return value.toLowerCase(); -} - -function parseToken(raw: unknown): TokenMetadata | null { - if (raw === null || typeof raw !== "object") { +/** 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 token = raw as Record; - if ( - typeof token.id !== "string" || - typeof token.symbol !== "string" || - typeof token.decimals !== "string" - ) { + const feeTier = Number(raw); + if (!Number.isSafeInteger(feeTier) || feeTier < 0 || feeTier % 100 !== 0) { return null; } - const address = normalizeAddress(token.id); - if (address === 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 symbol = token.symbol.trim(); - if (symbol === "") { + const [whole = "0", fraction = ""] = raw.split("."); + if (fraction.length > 2) { return null; } - if (!NON_NEGATIVE_INT_STRING.test(token.decimals)) { + 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 decimals = Number(token.decimals); - if (!Number.isSafeInteger(decimals) || decimals < 0) { + const trading = raw.filter( + (entry) => isRecord(entry) && entry.feeType === "FIXED_TRADING_FEE", + ) as Record[]; + if (trading.length !== 1) { return null; } - return { address, symbol, decimals }; + return parsePercentageFeeBps(trading[0]!.feePercentage); } -function parseFeeTierBps(raw: unknown): number | null { - if (typeof raw !== "string" || !NON_NEGATIVE_INT_STRING.test(raw)) { - return null; +/** 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 }; } - const feeTier = Number(raw); - if (!Number.isSafeInteger(feeTier) || feeTier < 0 || feeTier % 100 !== 0) { - return null; + if (typeof raw !== "string") { + return { value: null, valid: false }; } - return feeTier / 100; + return { value: raw, valid: true }; } -function parseFinancial(raw: unknown): string | null { - if (typeof raw !== "string" || !NON_NEGATIVE_DECIMAL.test(raw)) { +function parseTierBSnapshots(raw: unknown): DailySnapshot[] | null { + if (!Array.isArray(raw)) { return null; } - return raw; + 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; } -function parseDayDatas(raw: unknown): DailySnapshot[] | null { +/** + * 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 (entry === null || typeof entry !== "object") { + if (!isRecord(entry)) { return null; } - const day = entry as Record; - if (typeof day.date !== "number" || !Number.isInteger(day.date) || day.date < 0) { + if (typeof entry.day !== "number" || !Number.isInteger(entry.day) || entry.day < 0) { return null; } - const volumeUSD = - day.volumeUSD === null || day.volumeUSD === undefined - ? null - : typeof day.volumeUSD === "string" - ? day.volumeUSD - : null; - const feesUSD = - day.feesUSD === null || day.feesUSD === undefined - ? null - : typeof day.feesUSD === "string" - ? day.feesUSD - : null; - if (day.volumeUSD !== null && day.volumeUSD !== undefined && volumeUSD === null) { + const volume = parseOptionalMetric(entry.dailyVolumeUSD); + const revenue = parseOptionalMetric(entry.dailyTotalRevenueUSD); + if (!volume.valid || !revenue.valid) { return null; } - if (day.feesUSD !== null && day.feesUSD !== undefined && feesUSD === null) { - return null; - } - days.push({ date: day.date, volumeUSD, feesUSD }); + days.push({ + date: entry.day * SECONDS_PER_DAY, + volumeUSD: volume.value, + feesUSD: revenue.value, + }); } return days; } -function parseFreshness(meta: Record, queriedAt: number): SourceFreshness | null { - const block = meta.block; - if (block === null || typeof block !== "object") { +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; } - const blockRecord = block as Record; - if ( - typeof blockRecord.number !== "number" || - !Number.isInteger(blockRecord.number) || - blockRecord.number < 0 || - typeof blockRecord.timestamp !== "number" || - !Number.isInteger(blockRecord.timestamp) || - blockRecord.timestamp < 0 - ) { + + // 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; } - const freshness: SourceFreshness = { - indexed_block: blockRecord.number, - indexed_block_timestamp: blockRecord.timestamp, - queried_at: queriedAt, + return { + pool_address: poolAddress, + token0, + token1, + fee_tier_bps: parseTradingFeeBps(pool.fees), + tvl_usd: parseFinancial(pool.totalValueLockedUSD), + snapshots, }; +} - if (typeof blockRecord.hash === "string" && BLOCK_HASH_PATTERN.test(blockRecord.hash)) { - freshness.indexed_block_hash = blockRecord.hash.toLowerCase(); - } - - if (typeof meta.hasIndexingErrors === "boolean") { - freshness.has_indexing_errors = meta.hasIndexingErrors; +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 freshness; + return { + pool_address: poolAddress, + token0, + token1, + fee_tier_bps: parseNativeFeeTierBps(pool.feeTier), + tvl_usd: parseFinancial(pool.totalValueLockedUSD), + snapshots, + }; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} +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`. @@ -222,10 +287,11 @@ export async function fetchComparePoolGraphSource( const requestedTimeoutMs = options.timeoutMs ?? GATEWAY_DEFAULTS.sourceTimeoutMs; const timeoutMs = Math.min(Math.max(1, requestedTimeoutMs), GATEWAY_MAXIMUMS.sourceTimeoutMs); - if (source.query_id !== TIER_B_METRICS_QUERY_ID) { + const selected = POOL_METRICS_QUERIES[source.query_id]; + if (selected === undefined) { return failedResult(source, "unsupported", { warnings: [ - `Unsupported Graph query_id "${source.query_id}"; expected "${TIER_B_METRICS_QUERY_ID}".`, + `Unsupported Graph query_id "${source.query_id}"; expected one of ${Object.keys(POOL_METRICS_QUERIES).join(", ")}.`, ], latencyMs: 0, freshness: null, @@ -240,7 +306,7 @@ export async function fetchComparePoolGraphSource( }); } - const apiKey = resolveApiKey(options); + const apiKey = resolveGraphApiKey(options); if (apiKey === null) { return failedResult(source, "error", { warnings: ["GRAPH_API_KEY is unset or empty."], @@ -263,7 +329,7 @@ export async function fetchComparePoolGraphSource( const transport: GraphTransportResult = await postGraphGateway( source.record.locator.subgraph_id, - TIER_B_METRICS_QUERY, + selected.query, { pool: poolVariable }, { apiKey, @@ -337,7 +403,8 @@ export async function fetchComparePoolGraphSource( }); } - if (data.pool === null) { + 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, @@ -346,7 +413,7 @@ export async function fetchComparePoolGraphSource( }); } - if (!isRecord(data.pool)) { + if (!isRecord(poolEntity)) { return failedResult(source, "unsupported", { warnings: ["Graph pool payload has an unexpected shape."], latencyMs: transport.latencyMs, @@ -364,12 +431,8 @@ export async function fetchComparePoolGraphSource( }); } - const poolAddress = typeof data.pool.id === "string" ? normalizeAddress(data.pool.id) : null; - const token0 = parseToken(data.pool.token0); - const token1 = parseToken(data.pool.token1); - const dayDatas = parseDayDatas(data.poolDayDatas); - - if (poolAddress === null || token0 === null || token1 === null || dayDatas === null) { + const parsed = selected.parse(data, poolEntity); + if (parsed === null) { return failedResult(source, "unsupported", { warnings: ["Graph pool metrics payload failed shape validation."], latencyMs: transport.latencyMs, @@ -378,10 +441,10 @@ export async function fetchComparePoolGraphSource( }); } - if (poolAddress !== poolVariable) { + if (parsed.pool_address !== poolVariable) { return failedResult(source, "unsupported", { warnings: [ - `Graph pool id "${poolAddress}" does not match the registry pool "${poolVariable}".`, + `Graph pool id "${parsed.pool_address}" does not match the registry pool "${poolVariable}".`, ], latencyMs: transport.latencyMs, freshness, @@ -394,8 +457,8 @@ export async function fetchComparePoolGraphSource( if ( expectedToken0 === null || expectedToken1 === null || - token0.address !== expectedToken0 || - token1.address !== expectedToken1 + parsed.token0.address !== expectedToken0 || + parsed.token1.address !== expectedToken1 ) { return failedResult(source, "unsupported", { warnings: ["Graph pool tokens do not match the locked compare-pools pair."], @@ -405,18 +468,18 @@ export async function fetchComparePoolGraphSource( }); } - const aggregation = aggregateDailySnapshots(dayDatas, nowSeconds); + 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: poolAddress, - token0, - token1, - fee_tier_bps: parseFeeTierBps(data.pool.feeTier), - tvl_usd: parseFinancial(data.pool.totalValueLockedUSD), + 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, diff --git a/src/sources/graph/index.ts b/src/sources/graph/index.ts index 7cd9415..ae03fdf 100644 --- a/src/sources/graph/index.ts +++ b/src/sources/graph/index.ts @@ -12,7 +12,20 @@ export { deploymentMismatchWarning, type DeploymentAssertion, } from "./deployment-assertion.js"; -export { TIER_B_METRICS_QUERY, TIER_B_METRICS_QUERY_ID } from "./queries.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, 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 index 187d39c..a8b21c9 100644 --- a/src/sources/graph/queries.ts +++ b/src/sources/graph/queries.ts @@ -1,15 +1,56 @@ /** - * Locked Tier-B metrics query for MVP-0 compare_pools Graph sources. - * Identity must stay aligned with the production compare-pools profile. + * 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. */ -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 { +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 } 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/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/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/index.ts b/src/tools/index.ts index 79ea9ff..5845a99 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,10 +1,21 @@ +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, 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/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 index 391b066..28da08d 100644 --- a/tests/fixtures/live-compare-pools.ts +++ b/tests/fixtures/live-compare-pools.ts @@ -22,29 +22,29 @@ const livePair = [ }, ] as const satisfies CanonicalPair; -const [uniswap, pancake] = M0_COMPARE_POOLS_SCOPE.graphSources; +const [feeHigh, feeLow] = M0_COMPARE_POOLS_SCOPE.graphSources; const nuthatchId = M0_COMPARE_POOLS_SCOPE.nuthatchSourceId; -const uniswapProvenance = { - source_id: uniswap.source_id, - source_type: "native_subgraph" as const, - protocol: uniswap.protocol, +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: uniswap.deployment_or_view_id, - schema_version: null, - methodology_version: null, - query_id: uniswap.query_id, + 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 pancakeProvenance = { - source_id: pancake.source_id, - source_type: "native_subgraph" as const, - protocol: pancake.protocol, +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: pancake.deployment_or_view_id, - schema_version: null, - methodology_version: null, - query_id: pancake.query_id, + 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 = { @@ -58,7 +58,7 @@ const nuthatchProvenance = { query_id: "nuthatch-pool-swap-freshness-v1", }; -const liveProvenance = [uniswapProvenance, pancakeProvenance, nuthatchProvenance] as const; +const liveProvenance = [feeHighProvenance, feeLowProvenance, nuthatchProvenance] as const; function graphFreshness( result: typeof graphPoolA, @@ -87,27 +87,27 @@ export const livePartialComparePoolsFixture = { pools: [ { chain_id: M0_COMPARE_POOLS_SCOPE.chainId, - protocol: pancake.protocol, - pool_address: pancake.pool_address, + protocol: feeHigh.protocol, + pool_address: feeHigh.pool_address, pair: livePair, - tvl_usd: graphPoolB.data.tvl_usd, - volume_usd: graphPoolB.data.volume_usd_24h, - fees_usd: graphPoolB.data.fees_usd_24h, + 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: [pancake.source_id], + source_ids: [feeHigh.source_id], }, { chain_id: M0_COMPARE_POOLS_SCOPE.chainId, - protocol: uniswap.protocol, - pool_address: uniswap.pool_address, + protocol: feeLow.protocol, + pool_address: feeLow.pool_address, pair: livePair, - tvl_usd: graphPoolA.data.tvl_usd, - volume_usd: graphPoolA.data.volume_usd_24h, - fees_usd: graphPoolA.data.fees_usd_24h, + 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: [uniswap.source_id], + source_ids: [feeLow.source_id], }, ], nuthatch_freshness_fact: null, @@ -138,15 +138,15 @@ export const livePartialOneGraphTimeoutFixture = { pools: [ { chain_id: M0_COMPARE_POOLS_SCOPE.chainId, - protocol: uniswap.protocol, - pool_address: uniswap.pool_address, + 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: [uniswap.source_id], + source_ids: [feeHigh.source_id], }, ], nuthatch_freshness_fact: null, @@ -158,7 +158,7 @@ export const livePartialOneGraphTimeoutFixture = { }, freshness: [ graphFreshness(graphPoolA, 5), - { source_id: pancake.source_id, status: "unavailable" }, + { source_id: feeLow.source_id, status: "unavailable" }, { source_id: nuthatchId, status: "unavailable" }, ], provenance: liveProvenance, @@ -179,8 +179,8 @@ export const liveFailedComparePoolsFixture = { nuthatch_available: false, }, freshness: [ - { source_id: uniswap.source_id, status: "unavailable" }, - { source_id: pancake.source_id, status: "unavailable" }, + { source_id: feeHigh.source_id, status: "unavailable" }, + { source_id: feeLow.source_id, status: "unavailable" }, { source_id: nuthatchId, status: "unavailable" }, ], provenance: liveProvenance, @@ -199,15 +199,15 @@ export const liveStaleGraphComparePoolsFixture = { pools: [ { chain_id: M0_COMPARE_POOLS_SCOPE.chainId, - protocol: uniswap.protocol, - pool_address: uniswap.pool_address, + 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: [uniswap.source_id], + source_ids: [feeHigh.source_id], }, ], nuthatch_freshness_fact: null, @@ -219,13 +219,13 @@ export const liveStaleGraphComparePoolsFixture = { }, freshness: [ graphFreshness(graphPoolA, 600, "stale"), - { source_id: pancake.source_id, status: "unavailable" }, + { source_id: feeLow.source_id, status: "unavailable" }, { source_id: nuthatchId, status: "unavailable" }, ], provenance: liveProvenance, warnings: [ - `${uniswap.source_id} exceeded the freshness threshold`, - `${pancake.source_id} was unavailable`, + `${feeHigh.source_id} exceeded the freshness threshold`, + `${feeLow.source_id} was unavailable`, "nuthatch-pool-swaps live freshness fact is not yet verified", ], pagination: null, diff --git a/tests/fixtures/sources/index.ts b/tests/fixtures/sources/index.ts index 4c5ade2..5f49c0e 100644 --- a/tests/fixtures/sources/index.ts +++ b/tests/fixtures/sources/index.ts @@ -19,13 +19,14 @@ const usdc = { decimals: 6, } as const; -// Point-in-time values from tests/integration/__evidence__/m2/*/07-common-metrics.json, -// with provenance updated to the active production query revision. -// 7d aggregates stay null here so Person 2 null-handling stays covered; M3.6 owns real 7d sums. +// 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: "uniswap-v3-base-native", - source_type: "native_subgraph", + source_id: "messari-uniswap-v3-base-fee030", + source_type: "standardized_subgraph", protocol: "uniswap-v3", chain_id: 8453, status: "ok", @@ -34,118 +35,89 @@ export const graphPoolA = { token0: weth, token1: usdc, fee_tier_bps: 30, - tvl_usd: "150700095.7707237035076119974091172", - volume_usd_24h: "1837918.971826772337839586279587621", + tvl_usd: "114861166.2464289945430831254042441", + volume_usd_24h: "7784090.37607948122807978670587", volume_usd_7d: null, - fees_usd_24h: "5513.756915480317013518758838762854", + fees_usd_24h: "23352.27112823844368423936011761", fees_usd_7d: null, }, freshness: { - indexed_block: 49095773, - indexed_block_timestamp: 1784980893, - indexed_block_hash: "0xfaf4cc0493056e5ccac3f68b9e148cf8e80ee0d67adf333ed27b4a62d17c185c", - queried_at: 1784980898, + indexed_block: 49121447, + indexed_block_timestamp: 1785032241, + indexed_block_hash: "0x4c8d9676350cd7754f7eb2a1f2eb8cab1660889c2a141be4a4f6905cc23dd54e", + queried_at: 1785032246, has_indexing_errors: false, }, provenance: { - deployment_or_view_id: "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", - schema_version: null, - methodology_version: null, - query_id: "m3-tier-b-metrics-v2", + 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; production 7d sums land in M3.6."], + 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: "exchange-v3-base", - source_type: "native_subgraph", - protocol: "pancakeswap-v3", + source_id: "messari-uniswap-v3-base-fee005", + source_type: "standardized_subgraph", + protocol: "uniswap-v3", chain_id: 8453, status: "ok", data: { - pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", + pool_address: "0xd0b53d9277642d899df5c87a3966a349a798f224", token0: weth, token1: usdc, - fee_tier_bps: 1, - tvl_usd: "6565424.026253424582404270532672039", - volume_usd_24h: "6089724.592920848435197967898475142", + fee_tier_bps: 5, + tvl_usd: "10637748.34455860477744937575922415", + volume_usd_24h: "2957180.60438499663534225066392", volume_usd_7d: null, - fees_usd_24h: "608.9724592920848435197967898475142", + fees_usd_24h: "1478.59030219249831767112534419", fees_usd_7d: null, }, freshness: { - indexed_block: 49095784, - indexed_block_timestamp: 1784980915, - indexed_block_hash: "0x3d792f0e60742149c644825adb18c76fef43f01e25bc12dfef751de1e9d1bb6d", - queried_at: 1784980920, + indexed_block: 49121448, + indexed_block_timestamp: 1785032243, + indexed_block_hash: "0xe60c2ae734e634f8260a56b337c9a8c233b36c9a361fb53fcf727e680a2d1855", + queried_at: 1785032248, has_indexing_errors: false, }, provenance: { - deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", - schema_version: null, - methodology_version: null, - query_id: "m3-tier-b-metrics-v2", + 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; production 7d sums land in M3.6."], + warnings: ["Fixture retains null 7d aggregates; live 7d sums come from the adapter."], latency_ms: 301, } satisfies PoolSourceResult; -// Synthetic null-metric variant for Person 2 null paths. Not a third live Graph source. +// Synthetic null-metric variant over the 0.05% tier's real identity. export const graphPoolC = { - source_id: "exchange-v3-base", - source_type: "native_subgraph", - protocol: "pancakeswap-v3", - chain_id: 8453, - status: "ok", + ...graphPoolB, data: { - pool_address: "0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38", - token0: weth, - token1: usdc, - fee_tier_bps: 1, - tvl_usd: "6565424.026253424582404270532672039", - volume_usd_24h: "6089724.592920848435197967898475142", - volume_usd_7d: null, + ...graphPoolB.data, fees_usd_24h: null, - fees_usd_7d: null, - }, - freshness: { - indexed_block: 49095784, - indexed_block_timestamp: 1784980915, - indexed_block_hash: "0x3d792f0e60742149c644825adb18c76fef43f01e25bc12dfef751de1e9d1bb6d", - queried_at: 1784980920, - has_indexing_errors: false, - }, - provenance: { - deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", - schema_version: null, - methodology_version: null, - query_id: "m3-tier-b-metrics-v2", }, - warnings: ["Synthetic fixture: fees windows forced null for Person 2 null-handling coverage."], - latency_ms: 301, + warnings: ["Synthetic fixture: fees windows forced null for null-handling coverage."], } satisfies PoolSourceResult; -// Synthetic timeout over real exchange-v3-base provenance (status flipped). +// Synthetic timeout over the 0.05% tier's real provenance (status flipped). export const graphPoolCTimeout = { - source_id: "exchange-v3-base", - source_type: "native_subgraph", - protocol: "pancakeswap-v3", - chain_id: 8453, + 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: { - deployment_or_view_id: "QmQ1fMMrEjnmeDXn7BZMhWtFZYUQQuiDJrJP3c9oghRC9g", - schema_version: null, - methodology_version: null, - query_id: "m3-tier-b-metrics-v2", - }, - warnings: ["Synthetic timeout over real exchange-v3-base provenance."], + provenance: graphPoolB.provenance, + warnings: [`Synthetic timeout over real ${graphPoolB.source_id} provenance.`], latency_ms: 15000, } satisfies PoolSourceResult; -// Shape-only until M5 delivers live Nuthatch evidence. Pool matches Uniswap selection. +// 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", @@ -155,17 +127,17 @@ export const nuthatchFreshness = { data: { pool_address: "0x6c561b446416e1a00e8e93e221854d6ea4171372", recent_swap_count_24h: 321, - last_swap_block: 49095770, - last_swap_block_timestamp: 1784980887, + 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: 49095770, - indexed_block_timestamp: 1784980887, + indexed_block: 49121440, + indexed_block_timestamp: 1785032227, indexed_block_hash: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - queried_at: 1784980925, + queried_at: 1785032250, }, provenance: { deployment_or_view_id: "fixture-nuthatch-registry-hash", @@ -173,11 +145,11 @@ export const nuthatchFreshness = { methodology_version: null, query_id: "nuthatch-pool-swap-freshness-v1", }, - warnings: ["Shape-only Nuthatch fixture until M5 live evidence."], + warnings: ["Shape-only Nuthatch fixture until live evidence."], latency_ms: 42, } satisfies NuthatchSourceResult; -// MVP-0 amended to two Graph sources + Nuthatch. +// Two Messari Graph fee tiers + Nuthatch. export const completeSourceScenario = [ graphPoolA, graphPoolB, 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/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-scope.test.ts b/tests/unit/compare-pools-scope.test.ts index 2101488..c1a205b 100644 --- a/tests/unit/compare-pools-scope.test.ts +++ b/tests/unit/compare-pools-scope.test.ts @@ -29,13 +29,13 @@ describe("M0 compare_pools live scope", () => { const sources = getActiveComparePoolGraphSources(); expect(new Set(sources.map((source) => source.query_id))).toEqual( - new Set(["m3-tier-b-metrics-v2"]), + new Set(["tier-a-dex-pool-metrics-v1"]), ); expect(new Set(sources.map((source) => source.schema_contract_id))).toEqual( - new Set(["m2-tier-b-metrics-v1"]), + new Set(["tier-a-dex-pool-metrics-v1"]), ); expect(new Set(M0_COMPARE_POOLS_SCOPE.graphSources.map((source) => source.query_id))).toEqual( - new Set(["m3-tier-b-metrics-v2"]), + new Set(["tier-a-dex-pool-metrics-v1"]), ); }); diff --git a/tests/unit/compare-pools-tool.test.ts b/tests/unit/compare-pools-tool.test.ts index ae2ea01..7ca849d 100644 --- a/tests/unit/compare-pools-tool.test.ts +++ b/tests/unit/compare-pools-tool.test.ts @@ -3,7 +3,11 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it, vi } from "vitest"; import { FixedWindowRateLimiter } from "../../src/gateway/index.js"; -import { COMPARE_POOLS_TOOL_NAME, FIND_LARGE_SWAPS_TOOL_NAME } from "../../src/mcp/server.js"; +import { + COMPARE_LENDING_MARKETS_TOOL_NAME, + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_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 { @@ -106,12 +110,13 @@ describe("compare_pools MCP tool", () => { return { client, runtime }; } - it("lists compare_pools alongside the released large-swap tool", async () => { + it("lists compare_pools alongside lending and large-swap 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, ]); const tool = listed.tools[0]; @@ -161,7 +166,7 @@ describe("compare_pools MCP tool", () => { 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(["exchange-v3-base"]); + expect(body.data?.pools[0]?.source_ids).toEqual([graphPoolA.source_id]); expect(result.structuredContent).toEqual(body); expect(onGraphFetch).toHaveBeenCalledTimes(1); } finally { @@ -288,7 +293,7 @@ describe("compare_pools MCP tool", () => { }; expect(body.status).toBe("partial"); expect(body.coverage.successful_deployments).toBe(1); - expect(body.data?.pools[0]?.source_ids).toEqual(["uniswap-v3-base-native"]); + 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 index 63b3319..168ac29 100644 --- a/tests/unit/find-large-swaps-tool.test.ts +++ b/tests/unit/find-large-swaps-tool.test.ts @@ -2,7 +2,11 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it, vi } from "vitest"; -import { COMPARE_POOLS_TOOL_NAME, FIND_LARGE_SWAPS_TOOL_NAME } from "../../src/mcp/server.js"; +import { + COMPARE_LENDING_MARKETS_TOOL_NAME, + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_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"; @@ -194,9 +198,10 @@ describe("find_large_swaps MCP tool", () => { 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, ]); - const tool = listed.tools[1]; + 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); diff --git a/tests/unit/graph-adapter.test.ts b/tests/unit/graph-adapter.test.ts index ecd9bf5..38b0f33 100644 --- a/tests/unit/graph-adapter.test.ts +++ b/tests/unit/graph-adapter.test.ts @@ -4,10 +4,15 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { getActiveComparePoolGraphSources } from "../../src/registry/index.js"; +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"; @@ -15,13 +20,36 @@ import { sumDecimals } from "../../src/sources/graph/decimal.js"; const evidenceRoot = path.join( path.dirname(fileURLToPath(import.meta.url)), - "../integration/__evidence__/m2", + "../integration/__evidence__", ); -async function loadEvidenceData(sourceDir: string): Promise { +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, sourceDir, "07-common-metrics.json"), "utf8"), - ) as { response: { data: unknown } }; + await readFile(path.join(evidenceRoot, "m2", sourceDir, "07-common-metrics.json"), "utf8"), + ) as { response: { data: Record } }; return raw.response.data; } @@ -32,21 +60,62 @@ function jsonResponse(body: unknown, status = 200): Response { }); } -describe("fetchComparePoolGraphSource", () => { - const [uniswap, pancake] = getActiveComparePoolGraphSources(); +/** + * 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("maps Uniswap live evidence into an ok PoolSourceResult with exact 7d sums", async () => { - expect(uniswap).toBeDefined(); - const data = await loadEvidenceData("uniswap-v3-base-native"); - const dayDatas = ( - data as { - poolDayDatas: Array<{ date: number; volumeUSD: string; feesUSD: string }>; - } - ).poolDayDatas; - const newest = dayDatas[0]; - expect(newest).toBeDefined(); - // Make the newest captured day a completed UTC day for aggregation. - const nowSeconds = newest!.date + 86_400 + 1; + 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 = ""; @@ -54,72 +123,81 @@ describe("fetchComparePoolGraphSource", () => { observedUrl = input instanceof Request ? input.url : input instanceof URL ? input.href : String(input); observedAuthorization = new Headers(init?.headers).get("authorization") ?? ""; - return Promise.resolve(jsonResponse({ data })); + return Promise.resolve(jsonResponse(response)); }; - const result = await fetchComparePoolGraphSource(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "test-credential-must-not-leak", fetchImpl, nowSeconds, }); expect(poolSourceResultSchema.parse(result).status).toBe("ok"); - expect(result.status).toBe("ok"); if (result.status !== "ok") { return; } - expect(result.source_id).toBe("uniswap-v3-base-native"); - expect(result.source_type).toBe("native_subgraph"); + 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("150700095.7707237035076119974091172"); - expect(result.data.volume_usd_24h).toBe(dayDatas[0]!.volumeUSD); - expect(result.data.fees_usd_24h).toBe(dayDatas[0]!.feesUSD); - expect(result.data.volume_usd_7d).toBe(sumDecimals(dayDatas.map((day) => day.volumeUSD))); - expect(result.data.fees_usd_7d).toBe(sumDecimals(dayDatas.map((day) => day.feesUSD))); - expect(result.freshness.indexed_block).toBe(49095773); - expect(result.provenance.query_id).toBe(TIER_B_METRICS_QUERY_ID); + 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( - "QmVeyHjXivX8mY7bzWdbHDyA5z9ojgJdTu6uwFJsJvUzYR", + "QmawEzRNeDyaTgjPKb1eRrbyzxczgSHUYzvTMaMnN8jyuh", ); - expect(observedUrl).toContain("/subgraphs/id/GqzP4Xaehti8KSfQmv3ZctFSjnSUYZ4En5NRsiTbvZpz"); + 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 PancakeSwap live evidence and fee tier 100 → 1 bps", async () => { - expect(pancake).toBeDefined(); - const data = await loadEvidenceData("exchange-v3-base"); - const dayDatas = (data as { poolDayDatas: Array<{ date: number }> }).poolDayDatas; - const nowSeconds = dayDatas[0]!.date + 86_400 + 1; + 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(pancake!, { + const result = await fetchComparePoolGraphSource(feeLow!, { apiKey: "key", - fetchImpl: () => Promise.resolve(jsonResponse({ data })), - nowSeconds, + 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("exchange-v3-base"); - expect(result.data.fee_tier_bps).toBe(1); - expect(result.data.pool_address).toBe("0x72ab388e2e2f6facef59e3c3fa2c4e29011c2d38"); + 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("excludes the current partial UTC day from 24h aggregates", async () => { - const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { - poolDayDatas: Array<{ date: number; volumeUSD: string; feesUSD: string }>; - }; - const nowSeconds = data.poolDayDatas[0]!.date + 3_600; + 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(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", - fetchImpl: () => Promise.resolve(jsonResponse({ data })), + fetchImpl: () => Promise.resolve(jsonResponse(response)), nowSeconds, }); @@ -127,111 +205,114 @@ describe("fetchComparePoolGraphSource", () => { if (result.status !== "ok") { return; } - expect(result.data.volume_usd_24h).toBe(data.poolDayDatas[1]!.volumeUSD); - expect(result.data.fees_usd_24h).toBe(data.poolDayDatas[1]!.feesUSD); - expect(result.data.volume_usd_7d).toBeNull(); - expect(result.data.fees_usd_7d).toBeNull(); + expect(result.data.volume_usd_24h).toBe(snapshots[1]!.dailyVolumeUSD); + expect(result.data.volume_usd_24h).not.toBe(snapshots[0]!.dailyVolumeUSD); }); - it("requests eight day snapshots so seven completed days survive the partial day", () => { - // 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("sums 7d over completed days when the newest row is the partial day", async () => { - expect(uniswap).toBeDefined(); - const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { - poolDayDatas: Array<{ date: number; volumeUSD: string; feesUSD: string; tvlUSD?: string }>; + 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"; + } + } - // Evidence holds seven rows, newest first. Production now fetches eight, so - // append one older day: the newest stays partial and seven complete days - // remain — exactly the shape the live query returns. - const oldest = data.poolDayDatas[data.poolDayDatas.length - 1]!; - data.poolDayDatas.push({ - ...oldest, - date: oldest.date - 86_400, - volumeUSD: "1000.5", - feesUSD: "3.0015", - }); - expect(data.poolDayDatas).toHaveLength(8); - - const newest = data.poolDayDatas[0]!; - // Mid-way through the newest day, so that row is not a completed day. - const nowSeconds = newest.date + 3_600; - - const result = await fetchComparePoolGraphSource(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", - fetchImpl: () => Promise.resolve(jsonResponse({ data })), - nowSeconds, + 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(); + }); - const completed = data.poolDayDatas.slice(1); - expect(completed).toHaveLength(7); - expect(result.data.volume_usd_7d).toBe(sumDecimals(completed.map((day) => day.volumeUSD))); - expect(result.data.fees_usd_7d).toBe(sumDecimals(completed.map((day) => day.feesUSD))); - // The 24h window still tracks only the most recent completed day. - expect(result.data.volume_usd_24h).toBe(completed[0]!.volumeUSD); + 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("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); - }); - }); + 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(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", - fetchImpl, - timeoutMs: 20, + fetchImpl: () => Promise.resolve(jsonResponse(response)), }); - expect(result.status).toBe("timeout"); + expect(result.status).toBe("unsupported"); expect(result.data).toBeNull(); - expect(result.freshness).toBeNull(); - expect(result.warnings[0]).toMatch(/timeout/i); + expect(result.warnings[0]).toMatch(/tokens do not match/i); }); it("returns unsupported with retained freshness on deployment mismatch", async () => { - const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { - _meta: { deployment: string }; + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const response = structuredClone(capture.response) as unknown as { + data: { _meta: { deployment: string } }; }; - data._meta.deployment = "QmWrongDeploymentHashxxxxxxxxxxxxxxxxxxxxxxxxx"; + response.data._meta.deployment = "QmWrongDeploymentHashxxxxxxxxxxxxxxxxxxxxxxxxx"; - const result = await fetchComparePoolGraphSource(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", - fetchImpl: () => Promise.resolve(jsonResponse({ data })), - nowSeconds: 1_785_024_001, + 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(data._meta.deployment); + 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 data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { - pool: unknown; + const capture = await loadTierACapture("messari-uniswap-v3-base-fee030"); + const response = structuredClone(capture.response) as unknown as { + data: { liquidityPool: unknown }; }; - data.pool = null; + response.data.liquidityPool = null; - const result = await fetchComparePoolGraphSource(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", - fetchImpl: () => Promise.resolve(jsonResponse({ data })), + fetchImpl: () => Promise.resolve(jsonResponse(response)), }); expect(result.status).toBe("unsupported"); @@ -239,9 +320,31 @@ describe("fetchComparePoolGraphSource", () => { 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(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { env: {}, fetchImpl: () => { called = true; @@ -254,13 +357,10 @@ describe("fetchComparePoolGraphSource", () => { expect(result.warnings[0]).toMatch(/GRAPH_API_KEY/); }); - it("returns unsupported for a non-locked query_id without calling the network", async () => { + it("returns unsupported for an unimplemented query_id without calling the network", async () => { let called = false; const result = await fetchComparePoolGraphSource( - { - ...uniswap!, - query_id: "other-query-v1", - }, + { ...feeHigh!, query_id: "other-query-v1" }, { apiKey: "key", fetchImpl: () => { @@ -275,7 +375,7 @@ describe("fetchComparePoolGraphSource", () => { }); it("returns error on GraphQL errors without inventing pool data", async () => { - const result = await fetchComparePoolGraphSource(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", fetchImpl: () => Promise.resolve(jsonResponse({ errors: [{ message: "boom" }], data: null })), }); @@ -286,7 +386,7 @@ describe("fetchComparePoolGraphSource", () => { }); it("returns error on HTTP non-2xx gateway responses", async () => { - const result = await fetchComparePoolGraphSource(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", fetchImpl: () => Promise.resolve(jsonResponse({ message: "nope" }, 503)), }); @@ -297,7 +397,7 @@ describe("fetchComparePoolGraphSource", () => { }); it("returns error on transport failures", async () => { - const result = await fetchComparePoolGraphSource(uniswap!, { + const result = await fetchComparePoolGraphSource(feeHigh!, { apiKey: "key", fetchImpl: () => Promise.reject(new TypeError("network down")), }); @@ -307,25 +407,62 @@ describe("fetchComparePoolGraphSource", () => { expect(result.warnings[0]).toMatch(/redacted/i); expect(JSON.stringify(result)).not.toMatch(/network down/); }); +}); - it("returns unsupported when pool tokens disagree with the locked pair", async () => { - const data = structuredClone(await loadEvidenceData("uniswap-v3-base-native")) as { - pool: { token1: { id: string; symbol: string; decimals: string } }; - }; - data.pool.token1 = { - id: "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca", - symbol: "USDbC", - decimals: "6", +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(uniswap!, { + const result = await fetchComparePoolGraphSource(pancakeSource, { apiKey: "key", fetchImpl: () => Promise.resolve(jsonResponse({ data })), - nowSeconds: 1_785_024_001, + nowSeconds: dayDatas[0]!.date + 86_400 + 1, }); - expect(result.status).toBe("unsupported"); - expect(result.data).toBeNull(); - expect(result.warnings[0]).toMatch(/tokens do not match/i); + 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/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 index 74fc2ba..35d097e 100644 --- a/tests/unit/live-compare-pools.test.ts +++ b/tests/unit/live-compare-pools.test.ts @@ -32,10 +32,10 @@ describe("live-derived compare_pools fixtures", () => { const pools = livePartialComparePoolsFixture.data.pools; expect(pools[0]?.rank).toBe(1); expect(pools[1]?.rank).toBe(2); - expect(pools[0]?.source_ids).toEqual(["exchange-v3-base"]); - expect(pools[1]?.source_ids).toEqual(["uniswap-v3-base-native"]); - expect(pools[0]?.volume_usd).toBe(graphPoolB.data.volume_usd_24h); - expect(pools[1]?.volume_usd).toBe(graphPoolA.data.volume_usd_24h); + 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", () => { diff --git a/tests/unit/mcp-server.test.ts b/tests/unit/mcp-server.test.ts index 960be17..a4a431a 100644 --- a/tests/unit/mcp-server.test.ts +++ b/tests/unit/mcp-server.test.ts @@ -1,13 +1,53 @@ -import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it, vi } from "vitest"; -import { createMcpServer, serverInfo } from "../../src/mcp/server.js"; -import { createFixtureComparePoolsSources } from "../../src/tools/index.js"; +import { startMcpServer } from "../../src/mcp/lifecycle.js"; +import { + COMPARE_LENDING_MARKETS_TOOL_NAME, + COMPARE_POOLS_TOOL_NAME, + FIND_LARGE_SWAPS_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({ @@ -15,4 +55,90 @@ describe("MCP server foundation", () => { version: "0.1.0", }); }); + + it("exposes both read-only comparison 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, + ].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 index 3f347ec..40c4cc0 100644 --- a/tests/unit/mcp-smoke.test.mjs +++ b/tests/unit/mcp-smoke.test.mjs @@ -124,7 +124,11 @@ describe("MCP smoke session lifecycle", () => { jsonrpc: "2.0", id: message.id, result: { - tools: [{ name: "compare_pools" }, { name: "find_large_swaps" }], + tools: [ + { name: "compare_pools" }, + { name: "compare_lending_markets" }, + { name: "find_large_swaps" }, + ], }, }; response.writeHead(200, { "content-type": "text/event-stream" }); @@ -132,16 +136,22 @@ describe("MCP smoke session lifecycle", () => { return; } + const toolName = message.params?.name; const resultText = - message.params?.name === "find_large_swaps" + toolName === "find_large_swaps" ? JSON.stringify({ status: "complete", coverage: { successful_sources: 1, requested_sources: 1 }, }) - : JSON.stringify({ - status, - coverage: { successful_deployments: 2, requested_deployments: 2 }, - }); + : toolName === "compare_lending_markets" + ? JSON.stringify({ + status, + coverage: { successful_sources: 3, requested_sources: 3 }, + }) + : JSON.stringify({ + status, + coverage: { successful_deployments: 2, requested_deployments: 2 }, + }); const payload = { jsonrpc: "2.0", id: message.id, @@ -166,12 +176,16 @@ describe("MCP smoke session lifecycle", () => { 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(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 }, ]); expect(deletes).toEqual([ { diff --git a/tests/unit/metrics-ranking.test.ts b/tests/unit/metrics-ranking.test.ts index 0da6f95..c377f92 100644 --- a/tests/unit/metrics-ranking.test.ts +++ b/tests/unit/metrics-ranking.test.ts @@ -82,12 +82,12 @@ describe("rankCanonicalPools", () => { ); expect(forward.map((pool) => pool.source_ids[0])).toEqual([ - "exchange-v3-base", - "uniswap-v3-base-native", + 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(graphPoolB.data.volume_usd_24h); + expect(forward[0]?.volume_usd).toBe(graphPoolA.data.volume_usd_24h); }); it("applies protocol, pool address, then source_id tie-breaks", () => { diff --git a/tests/unit/normalization-live-binding.test.ts b/tests/unit/normalization-live-binding.test.ts index 6b17d15..fd12a8a 100644 --- a/tests/unit/normalization-live-binding.test.ts +++ b/tests/unit/normalization-live-binding.test.ts @@ -34,8 +34,8 @@ describe("bindComparePoolsGraphResult", () => { "24h", ); expect(candidates.map((candidate) => candidate.source_ids[0])).toEqual([ - "uniswap-v3-base-native", - "exchange-v3-base", + 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); @@ -44,8 +44,8 @@ describe("bindComparePoolsGraphResult", () => { it("matches live compare_pools fixture pool identities after explicit ranking", () => { const candidates = bindComparePoolsGraphResults([graphPoolA, graphPoolB], "24h"); const ranked = [ - toPoolComparisonRecord(candidates[1]!, 1), - toPoolComparisonRecord(candidates[0]!, 2), + toPoolComparisonRecord(candidates[0]!, 1), + toPoolComparisonRecord(candidates[1]!, 2), ]; expect(ranked.map((pool) => pool.pool_address)).toEqual( diff --git a/tests/unit/quality-settle.test.ts b/tests/unit/quality-settle.test.ts index 9caacee..6f6f25f 100644 --- a/tests/unit/quality-settle.test.ts +++ b/tests/unit/quality-settle.test.ts @@ -49,8 +49,8 @@ describe("settleComparePoolsResult", () => { expect(response.data?.nuthatch_freshness_fact).toBeNull(); expect(response.data?.pools).toHaveLength(2); expect(response.freshness.map((entry) => entry.source_id)).toEqual([ - "uniswap-v3-base-native", - "exchange-v3-base", + graphPoolA.source_id, + graphPoolB.source_id, "nuthatch-pool-swaps", ]); expect(response.freshness[2]).toEqual({ @@ -85,9 +85,9 @@ describe("settleComparePoolsResult", () => { expect(response.status).toBe("partial"); expect(response.coverage.successful_deployments).toBe(1); expect(response.data?.pools).toHaveLength(1); - expect(response.data?.pools[0]?.source_ids).toEqual(["uniswap-v3-base-native"]); - expect(response.freshness.find((entry) => entry.source_id === "exchange-v3-base")).toEqual({ - source_id: "exchange-v3-base", + expect(response.data?.pools[0]?.source_ids).toEqual([graphPoolA.source_id]); + expect(response.freshness.find((entry) => entry.source_id === graphPoolB.source_id)).toEqual({ + source_id: graphPoolB.source_id, status: "unavailable", }); expect(response.warnings.some((warning) => warning.includes("timed out"))).toBe(true); @@ -95,14 +95,14 @@ describe("settleComparePoolsResult", () => { }); it("maps non-ok Graph results with retained freshness to unavailable publicly", () => { - const unsupportedPancake = { + const unsupportedFeeLow = { ...graphPoolB, status: "unsupported" as const, data: null, warnings: ["Graph response shape is unsupported."], }; const pools = rankCanonicalPools( - bindComparePoolsGraphResults([graphPoolA, unsupportedPancake], "24h"), + bindComparePoolsGraphResults([graphPoolA, unsupportedFeeLow], "24h"), { rankedBy: "volume_usd" }, ); const response = settleComparePoolsResult({ @@ -110,13 +110,13 @@ describe("settleComparePoolsResult", () => { window: "24h", rankedBy: "volume_usd", pools, - graphResults: [graphPoolA, unsupportedPancake], + graphResults: [graphPoolA, unsupportedFeeLow], nuthatchResult: null, }); - expect(unsupportedPancake.freshness).not.toBeNull(); - expect(response.freshness.find((entry) => entry.source_id === "exchange-v3-base")).toEqual({ - source_id: "exchange-v3-base", + expect(unsupportedFeeLow.freshness).not.toBeNull(); + expect(response.freshness.find((entry) => entry.source_id === graphPoolB.source_id)).toEqual({ + source_id: graphPoolB.source_id, status: "unavailable", }); expect(response.status).toBe("partial"); @@ -124,9 +124,9 @@ describe("settleComparePoolsResult", () => { }); it("returns failed when every Graph source is unavailable", () => { - const timedOutUniswap = { + const timedOutFeeHigh = { ...graphPoolCTimeout, - source_id: "uniswap-v3-base-native", + source_id: graphPoolA.source_id, protocol: "uniswap-v3", provenance: graphPoolA.provenance, }; @@ -135,7 +135,7 @@ describe("settleComparePoolsResult", () => { window: "24h", rankedBy: "volume_usd", pools: [], - graphResults: [timedOutUniswap, graphPoolCTimeout], + graphResults: [timedOutFeeHigh, graphPoolCTimeout], nuthatchResult: null, }); @@ -147,7 +147,7 @@ describe("settleComparePoolsResult", () => { }); it("marks quality freshness stale from lag without rewriting adapter ok status", () => { - const staleUniswap = { + const staleFeeHigh = { ...graphPoolA, freshness: { ...graphPoolA.freshness, @@ -155,7 +155,7 @@ describe("settleComparePoolsResult", () => { }, }; const pools = rankCanonicalPools( - bindComparePoolsGraphResults([staleUniswap, graphPoolB], "24h"), + bindComparePoolsGraphResults([staleFeeHigh, graphPoolB], "24h"), { rankedBy: "volume_usd" }, ); const response = settleComparePoolsResult({ @@ -163,13 +163,13 @@ describe("settleComparePoolsResult", () => { window: "24h", rankedBy: "volume_usd", pools, - graphResults: [staleUniswap, graphPoolB], + graphResults: [staleFeeHigh, graphPoolB], nuthatchResult: null, }); - expect(staleUniswap.status).toBe("ok"); + expect(staleFeeHigh.status).toBe("ok"); expect(response.freshness[0]).toMatchObject({ - source_id: "uniswap-v3-base-native", + source_id: graphPoolA.source_id, status: "stale", lag_seconds: 301, }); @@ -204,9 +204,9 @@ describe("settleComparePoolsResult", () => { }); it("keeps failed Graph settlement schema-valid when Nuthatch is ok", () => { - const timedOutUniswap = { + const timedOutFeeHigh = { ...graphPoolCTimeout, - source_id: "uniswap-v3-base-native", + source_id: graphPoolA.source_id, protocol: "uniswap-v3", provenance: graphPoolA.provenance, }; @@ -246,7 +246,7 @@ describe("settleComparePoolsResult", () => { window: "24h", rankedBy: "volume_usd", pools: [], - graphResults: [timedOutUniswap, graphPoolCTimeout], + graphResults: [timedOutFeeHigh, graphPoolCTimeout], nuthatchResult: nuthatchOk, }); @@ -322,8 +322,11 @@ describe("settleComparePoolsResult", () => { nuthatchResult: null, }); + // Partial here is owed to the absent Nuthatch fact, not to the truncation: + // both deployments answered, so coverage still reports two. expect(response.status).toBe("partial"); expect(response.data?.pools).toHaveLength(1); + expect(response.coverage.successful_deployments).toBe(2); expect( response.warnings.some((warning) => warning.includes("Top-N truncated ranked pools")), ).toBe(true); From 9ae01b1e678f80db2810a499d1bc9be53d323904 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 05:22:16 +0200 Subject: [PATCH 78/96] feat(http): issue per-client bearer tokens One shared deployment token means a single leak compromises every client with no way to revoke selectively. The store issues per-client tokens and keeps only their SHA-256 digests, so the file is enough to revoke a token but never to use one. Verification is a digest lookup rather than a comparison, so its cost does not grow with the number of issued tokens and no timing-safe compare is needed: a digest cannot be walked back to the secret. isAuthorized accepts issued tokens and the shared token together, so existing clients keep working while the shared one is retired. --- src/http/auth.ts | 7 ++ src/http/token-store.ts | 114 +++++++++++++++++++++++++++ tests/unit/token-store.test.ts | 136 +++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 src/http/token-store.ts create mode 100644 tests/unit/token-store.test.ts diff --git a/src/http/auth.ts b/src/http/auth.ts index b197fde..c3bce81 100644 --- a/src/http/auth.ts +++ b/src/http/auth.ts @@ -9,6 +9,7 @@ const BEARER_PREFIX = /^Bearer (.+)$/; export function isAuthorized( authorizationHeader: string | undefined, expectedToken: string, + issuedTokens?: { verify(token: string): boolean }, ): boolean { if (authorizationHeader === undefined) { return false; @@ -19,6 +20,12 @@ export function isAuthorized( 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; + } + const presented = Buffer.from(credential, "utf8"); const expected = Buffer.from(expectedToken, "utf8"); 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/tests/unit/token-store.test.ts b/tests/unit/token-store.test.ts new file mode 100644 index 0000000..bbe75b9 --- /dev/null +++ b/tests/unit/token-store.test.ts @@ -0,0 +1,136 @@ +import { createHash, randomUUID } from "node:crypto"; +import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { isAuthorized } from "../../src/http/auth.js"; +import { TokenStore } from "../../src/http/token-store.js"; + +function storePath(): string { + return join(mkdtempSync(join(tmpdir(), "deeptrace-tokens-")), "tokens.json"); +} + +describe("TokenStore", () => { + it("mints a prefixed token that verifies", () => { + const store = new TokenStore(storePath()); + const token = store.mint(); + + expect(token.startsWith("dt_")).toBe(true); + expect(store.verify(token)).toBe(true); + expect(store.size()).toBe(1); + }); + + it("mints a distinct token every time", () => { + const store = new TokenStore(storePath()); + const tokens = new Set([store.mint(), store.mint(), store.mint()]); + + expect(tokens.size).toBe(3); + expect(store.size()).toBe(3); + }); + + it("rejects a token it never issued", () => { + const store = new TokenStore(storePath()); + store.mint(); + + expect(store.verify("dt_not-a-real-token")).toBe(false); + expect(store.verify("")).toBe(false); + }); + + it("never writes the plaintext token to disk", () => { + const path = storePath(); + const store = new TokenStore(path); + const token = store.mint(); + const onDisk = readFileSync(path, "utf8"); + + expect(onDisk).not.toContain(token); + expect(onDisk).toContain(createHash("sha256").update(token, "utf8").digest("hex")); + }); + + it("keeps the store file owner-only", () => { + const path = storePath(); + new TokenStore(path).mint(); + + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("restores issued tokens from disk", () => { + const path = storePath(); + const token = new TokenStore(path).mint(); + + expect(new TokenStore(path).verify(token)).toBe(true); + }); + + it("tightens permissions on a store file that was left readable", () => { + const path = storePath(); + writeFileSync(path, "{}", "utf8"); + chmodSync(path, 0o644); + new TokenStore(path).mint(); + + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it("revokes one token without affecting the others", () => { + const path = storePath(); + const store = new TokenStore(path); + const kept = store.mint(); + const revoked = store.mint(); + + expect(store.revoke(createHash("sha256").update(revoked, "utf8").digest("hex"))).toBe(true); + expect(store.verify(revoked)).toBe(false); + expect(store.verify(kept)).toBe(true); + expect(new TokenStore(path).verify(revoked)).toBe(false); + }); + + it("records last use, leaving it null until first verified", () => { + const path = storePath(); + const store = new TokenStore(path, () => 1_700_000_000_000); + const token = store.mint(); + + const persisted = JSON.parse(readFileSync(path, "utf8")) as Record; + expect(Object.values(persisted)).toEqual([{ createdAt: 1_700_000_000_000, lastUsedAt: null }]); + store.verify(token); + expect(store.verify(token)).toBe(true); + }); + + it("treats an unreadable or malformed store as empty", () => { + const path = storePath(); + writeFileSync(path, "not json at all", "utf8"); + + expect(() => new TokenStore(path)).not.toThrow(); + expect(new TokenStore(join(storePath(), "missing", "tokens.json")).size()).toBe(0); + }); +}); + +describe("isAuthorized with issued tokens", () => { + const shared = "s".repeat(40); + + it("accepts a minted token alongside the shared one", () => { + const store = new TokenStore(storePath()); + const token = store.mint(); + + expect(isAuthorized(`Bearer ${token}`, shared, store)).toBe(true); + expect(isAuthorized(`Bearer ${shared}`, shared, store)).toBe(true); + }); + + it("rejects an unknown token even when a store is present", () => { + const store = new TokenStore(storePath()); + store.mint(); + + expect(isAuthorized(`Bearer dt_${randomUUID()}`, shared, store)).toBe(false); + }); + + it("still rejects a minted token presented without the bearer scheme", () => { + const store = new TokenStore(storePath()); + const token = store.mint(); + + expect(isAuthorized(token, shared, store)).toBe(false); + expect(isAuthorized(`Basic ${token}`, shared, store)).toBe(false); + }); + + it("behaves exactly as before when no store is supplied", () => { + expect(isAuthorized(`Bearer ${shared}`, shared)).toBe(true); + expect(isAuthorized("Bearer nope", shared)).toBe(false); + }); +}); From 6e2e6dedc35a03c754ccc52eb1ca700cc04864e5 Mon Sep 17 00:00:00 2001 From: ikodo0 Date: Sun, 26 Jul 2026 05:28:47 +0200 Subject: [PATCH 79/96] feat(http): let callers take their own token Access is open, so the endpoint is unauthenticated: a caller cannot present a token before something has given them one. The page carries its own policy because the connection page forbids forms outright, and this one needs exactly a single form. The per-address limit is cost control rather than access control. Anyone may hold a token; nobody may mint an unbounded number of them, since each one spends metered Graph quota. --- src/http/server.ts | 21 ++++- src/http/token-issue.ts | 150 +++++++++++++++++++++++++++++++++ tests/unit/token-issue.test.ts | 143 +++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/http/token-issue.ts create mode 100644 tests/unit/token-issue.test.ts diff --git a/src/http/server.ts b/src/http/server.ts index b50bb9a..0334a9e 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -11,6 +11,10 @@ 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 { isTokenIssueRequest, respondTokenIssue } from "./token-issue.js"; +import { TokenStore } from "./token-store.js"; import { acceptsConnectionPage, isFontAssetRequest, @@ -45,6 +49,8 @@ interface OpenedSession { 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 { @@ -132,6 +138,12 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions // 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, @@ -263,6 +275,13 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions 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, now); + return; + } + if (!MCP_PATHS.has(url.pathname)) { respondJson(response, 404, "not_found", "Unknown endpoint"); return; @@ -281,7 +300,7 @@ export function createHttpServer(config: HttpConfig, options: HttpServerOptions return; } - if (!isAuthorized(request.headers.authorization, config.token)) { + if (!isAuthorized(request.headers.authorization, config.token, tokenStore)) { response.setHeader("www-authenticate", 'Bearer realm="deeptrace"'); respondJson(response, 401, "unauthorized", "Missing or invalid bearer token"); return; diff --git a/src/http/token-issue.ts b/src/http/token-issue.ts new file mode 100644 index 0000000..87ac716 --- /dev/null +++ b/src/http/token-issue.ts @@ -0,0 +1,150 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +import type { TokenStore } from "./token-store.js"; + +export const ISSUE_PATH = "/auth"; + +/** + * Access is open: anyone may take a token. The limit is therefore not an + * access control but a cost control — it stops one caller minting an unbounded + * number of credentials, each of which would spend metered Graph quota. + */ +const MINTS_PER_WINDOW = 3; +const WINDOW_MS = 60 * 60 * 1000; + +/** + * 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. + */ +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": "no-referrer", + "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.

+
`, +); + +const LIMIT_PAGE = page( + "Too many tokens", + `

Slow down

+

That is more tokens than this address needs in an hour. Reuse the one you have, or try +again later.

+

Back to the setup guide

`, +); + +function issuedPage(token: string): string { + return page( + "Your DeepTrace token", + `

Your token

+
Shown onceCopy it now. It is stored only as a hash, so it cannot be +shown again. Losing it costs nothing — come back and take another.
+
${token}
+

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.

`, + ); +} + +/** Fixed window keyed by caller address. Cleared lazily as windows lapse. */ +const mints = new Map(); + +function withinLimit(address: string, now: number): boolean { + const entry = mints.get(address); + if (entry === undefined || now - entry.windowStart >= WINDOW_MS) { + mints.set(address, { count: 1, windowStart: now }); + return true; + } + if (entry.count >= MINTS_PER_WINDOW) { + return false; + } + entry.count += 1; + return true; +} + +/** Cloudflare terminates TLS, so the tunnel reports the real caller here. */ +function callerAddress(request: IncomingMessage): string { + const forwarded = request.headers["cf-connecting-ip"] ?? request.headers["x-forwarded-for"]; + const value = Array.isArray(forwarded) ? forwarded[0] : forwarded; + return value?.split(",")[0]?.trim() ?? request.socket.remoteAddress ?? "unknown"; +} + +export function isTokenIssueRequest(request: IncomingMessage, pathname: string): boolean { + return (request.method === "GET" || request.method === "POST") && pathname === ISSUE_PATH; +} + +export function respondTokenIssue( + request: IncomingMessage, + response: ServerResponse, + store: TokenStore, + now: () => number = Date.now, +): void { + if (request.method === "GET") { + response.writeHead(200, { ...PAGE_HEADERS, "content-type": "text/html; charset=utf-8" }); + response.end(FORM_PAGE); + return; + } + + if (!withinLimit(callerAddress(request), now())) { + response.writeHead(429, { ...PAGE_HEADERS, "content-type": "text/html; charset=utf-8" }); + response.end(LIMIT_PAGE); + return; + } + + response.writeHead(201, { ...PAGE_HEADERS, "content-type": "text/html; charset=utf-8" }); + response.end(issuedPage(store.mint())); +} + +/** Test seam: the mint window is process-global. */ +export function resetIssueLimits(): void { + mints.clear(); +} diff --git a/tests/unit/token-issue.test.ts b/tests/unit/token-issue.test.ts new file mode 100644 index 0000000..a7831c8 --- /dev/null +++ b/tests/unit/token-issue.test.ts @@ -0,0 +1,143 @@ +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 { createHttpServer, listen, type HttpRuntime } from "../../src/http/server.js"; +import { resetIssueLimits } from "../../src/http/token-issue.js"; +import { TokenStore } from "../../src/http/token-store.js"; + +const SHARED_TOKEN = "a".repeat(MIN_TOKEN_LENGTH); + +async function startServer(): Promise<{ runtime: HttpRuntime; origin: string }> { + const path = join(mkdtempSync(join(tmpdir(), "deeptrace-issue-")), "tokens.json"); + const runtime = createHttpServer( + { ...HTTP_DEFAULTS, host: "127.0.0.1", port: 0, token: SHARED_TOKEN }, + { tokenStore: new TokenStore(path) }, + ); + await listen(runtime, { ...HTTP_DEFAULTS, host: "127.0.0.1", port: 0, token: SHARED_TOKEN }); + 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 extractToken(body: string): string { + return /dt_[A-Za-z0-9_-]+/u.exec(body)?.[0] ?? ""; +} + +afterEach(() => { + resetIssueLimits(); +}); + +describe("self-serve token issuance", () => { + it("offers a form without authentication", async () => { + const { runtime, origin } = await startServer(); + try { + const response = await fetch(`${origin}/auth`); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); + expect(body).toContain('
'); + expect(body).not.toMatch(/