From e49938bd0a0ea4a8d65b77c291d3da819ebe64e0 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 25 Feb 2026 22:29:57 +0000 Subject: [PATCH] fix: Ponder: add test infrastructure + coverage for helpers (target 95%) (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vitest ^2 + @vitest/coverage-v8 ^2 as devDependencies - Add `test` and `test:coverage` scripts to package.json - Create vitest.config.ts with resolve.alias to mock ponder virtual modules (ponder:schema, ponder:registry) and point kraiken-lib/version to source - Add coverage/ to .gitignore - Add vitest.config.ts to tsconfig.json include so eslint project-aware rules apply - Create src/tests/__mocks__/ponder-schema.ts and ponder-registry.ts stubs - Create src/tests/stats.test.ts — 48 tests covering ring buffer logic, segment updates, hourly advancement, projections, ETH reserve snapshots, all exported async helpers with mock Ponder contexts - Create src/tests/version.test.ts — 14 tests covering isCompatibleVersion, getVersionMismatchError, and validateContractVersion (compatible / mismatch / error paths, existing-meta upsert path) - Create src/tests/abi.test.ts — 6 tests covering validateAbi and validateContractAbi Result: 68 tests pass, 100% line/statement/function coverage on all helpers (stats.ts, version.ts, abi.ts, logger.ts) — exceeds 95% target. Co-Authored-By: Claude Sonnet 4.6 --- services/ponder/.gitignore | 1 + services/ponder/package-lock.json | 711 +++++++++++++++- services/ponder/package.json | 6 +- .../src/tests/__mocks__/ponder-registry.ts | 5 + .../src/tests/__mocks__/ponder-schema.ts | 11 + services/ponder/src/tests/abi.test.ts | 92 +++ services/ponder/src/tests/stats.test.ts | 772 ++++++++++++++++++ services/ponder/src/tests/version.test.ts | 201 +++++ services/ponder/tsconfig.json | 2 +- services/ponder/vitest.config.ts | 27 + 10 files changed, 1808 insertions(+), 20 deletions(-) create mode 100644 services/ponder/src/tests/__mocks__/ponder-registry.ts create mode 100644 services/ponder/src/tests/__mocks__/ponder-schema.ts create mode 100644 services/ponder/src/tests/abi.test.ts create mode 100644 services/ponder/src/tests/stats.test.ts create mode 100644 services/ponder/src/tests/version.test.ts create mode 100644 services/ponder/vitest.config.ts diff --git a/services/ponder/.gitignore b/services/ponder/.gitignore index 1e7fca4..ce92682 100644 --- a/services/ponder/.gitignore +++ b/services/ponder/.gitignore @@ -3,5 +3,6 @@ *.log dist build +coverage .DS_Store pnpm-lock.yaml diff --git a/services/ponder/package-lock.json b/services/ponder/package-lock.json index 21614c3..62fdf02 100644 --- a/services/ponder/package-lock.json +++ b/services/ponder/package-lock.json @@ -17,13 +17,15 @@ "@types/node": "^20.11.30", "@typescript-eslint/eslint-plugin": "^8.45.0", "@typescript-eslint/parser": "^8.45.0", + "@vitest/coverage-v8": "^2.0.0", "esbuild": "^0.25.10", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", "lint-staged": "^16.2.3", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^2.0.0" }, "engines": { "node": ">=18.0.0" @@ -34,7 +36,8 @@ "dependencies": { "@apollo/client": "^3.9.10", "graphql": "^16.8.1", - "graphql-tag": "^2.12.6" + "graphql-tag": "^2.12.6", + "viem": "^2.22.13" }, "devDependencies": { "@graphql-codegen/cli": "^5.0.2", @@ -61,6 +64,20 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -75,15 +92,62 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-identifier": { + "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@commander-js/extra-typings": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-12.1.0.tgz", @@ -1033,6 +1097,55 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -1733,6 +1846,152 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@whatwg-node/disposablestack": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", @@ -1950,6 +2209,16 @@ "dev": true, "license": "Python-2.0" }, + "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/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -2065,6 +2334,33 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2209,6 +2505,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2433,6 +2739,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2712,19 +3025,6 @@ "node": "*" } }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -2792,6 +3092,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", @@ -2849,6 +3159,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3177,6 +3497,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-terminator": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/http-terminator/-/http-terminator-3.2.0.tgz", @@ -3373,6 +3700,60 @@ "ws": "*" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -3805,12 +4186,57 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "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/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4182,6 +4608,16 @@ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -4793,6 +5229,13 @@ "node": ">=8" } }, + "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/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -4826,6 +5269,13 @@ "node": ">= 10.x" } }, + "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/stacktrace-parser": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", @@ -4847,6 +5297,13 @@ "node": ">=8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5001,6 +5458,19 @@ "node": ">=16" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tdigest": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", @@ -5022,6 +5492,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/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/test-exclude/node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thread-stream": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", @@ -5031,6 +5555,50 @@ "real-require": "^0.2.0" } }, + "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": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5310,6 +5878,96 @@ } } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/when-exit": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.4.tgz", @@ -5331,6 +5989,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/services/ponder/package.json b/services/ponder/package.json index 512f9c8..97d3844 100644 --- a/services/ponder/package.json +++ b/services/ponder/package.json @@ -8,6 +8,8 @@ "start": "node ./node_modules/ponder/dist/esm/bin/ponder.js start", "codegen": "node ./node_modules/ponder/dist/esm/bin/ponder.js codegen", "build": "node ./node_modules/ponder/dist/esm/bin/ponder.js codegen", + "test": "vitest run", + "test:coverage": "vitest run --coverage", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.ts\" \"ponder.config.ts\" \"ponder.schema.ts\"", @@ -24,13 +26,15 @@ "@types/node": "^20.11.30", "@typescript-eslint/eslint-plugin": "^8.45.0", "@typescript-eslint/parser": "^8.45.0", + "@vitest/coverage-v8": "^2.0.0", "esbuild": "^0.25.10", "eslint": "^9.36.0", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", "lint-staged": "^16.2.3", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^2.0.0" }, "lint-staged": { "src/**/*.ts": [ diff --git a/services/ponder/src/tests/__mocks__/ponder-registry.ts b/services/ponder/src/tests/__mocks__/ponder-registry.ts new file mode 100644 index 0000000..e9aed39 --- /dev/null +++ b/services/ponder/src/tests/__mocks__/ponder-registry.ts @@ -0,0 +1,5 @@ +/** + * Mock for ponder:registry virtual module used in unit tests. + * Only the ponder.on shape is needed for type inference in stats.ts. + */ +export const ponder = { on: () => {} }; diff --git a/services/ponder/src/tests/__mocks__/ponder-schema.ts b/services/ponder/src/tests/__mocks__/ponder-schema.ts new file mode 100644 index 0000000..4fb3273 --- /dev/null +++ b/services/ponder/src/tests/__mocks__/ponder-schema.ts @@ -0,0 +1,11 @@ +/** + * Mock for ponder:schema virtual module used in unit tests. + * Exports the constants and table stubs needed by src/helpers/*.ts. + */ +export const HOURS_IN_RING_BUFFER = 168; +export const SECONDS_IN_HOUR = 3600; +export const STATS_ID = '0x01'; + +// Table stubs — only the identifier is needed; DB operations are mocked at context level. +export const stats = {}; +export const stackMeta = {}; diff --git a/services/ponder/src/tests/abi.test.ts b/services/ponder/src/tests/abi.test.ts new file mode 100644 index 0000000..67375db --- /dev/null +++ b/services/ponder/src/tests/abi.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { validateAbi, validateContractAbi } from '../helpers/abi.js'; +import type { Abi } from 'viem'; + +// --------------------------------------------------------------------------- +// validateAbi +// --------------------------------------------------------------------------- + +describe('validateAbi', () => { + it('returns the exact same ABI reference it receives', () => { + const abi: Abi = [ + { + name: 'transfer', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + ]; + const result = validateAbi(abi); + expect(result).toBe(abi); + }); + + it('returns an empty ABI unchanged', () => { + const abi: Abi = []; + expect(validateAbi(abi)).toBe(abi); + }); + + it('preserves all entries in a multi-entry ABI', () => { + const abi: Abi = [ + { + name: 'mint', + type: 'function', + stateMutability: 'nonpayable', + inputs: [], + outputs: [], + }, + { + name: 'Transfer', + type: 'event', + inputs: [ + { name: 'from', type: 'address', indexed: true }, + { name: 'to', type: 'address', indexed: true }, + { name: 'value', type: 'uint256', indexed: false }, + ], + anonymous: false, + }, + ]; + const result = validateAbi(abi); + expect(result).toHaveLength(2); + expect(result).toBe(abi); + }); +}); + +// --------------------------------------------------------------------------- +// validateContractAbi +// --------------------------------------------------------------------------- + +describe('validateContractAbi', () => { + it('returns the abi property from the contract object', () => { + const abi: Abi = [ + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, + ]; + const contract = { abi, address: '0xdeadbeef' }; + const result = validateContractAbi(contract); + expect(result).toBe(abi); + }); + + it('works with an empty abi property', () => { + const contract = { abi: [] as Abi }; + const result = validateContractAbi(contract); + expect(result).toEqual([]); + }); + + it('does not return any other property of the contract object', () => { + const abi: Abi = []; + const contract = { abi, address: '0x1234', name: 'MyContract' }; + const result = validateContractAbi(contract); + expect(result).toBe(abi); + // result is just the ABI array, not the full contract object + expect(Array.isArray(result)).toBe(true); + }); +}); diff --git a/services/ponder/src/tests/stats.test.ts b/services/ponder/src/tests/stats.test.ts new file mode 100644 index 0000000..c9541e4 --- /dev/null +++ b/services/ponder/src/tests/stats.test.ts @@ -0,0 +1,772 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + makeEmptyRingBuffer, + parseRingBuffer, + serializeRingBuffer, + checkBlockHistorySufficient, + updateHourlyData, + ensureStatsExists, + recordEthReserveSnapshot, + updateEthReserve, + markPositionsUpdated, + refreshOutstandingStake, + refreshMinStake, + RING_BUFFER_SEGMENTS, + MINIMUM_BLOCKS_FOR_RINGBUFFER, + type StatsContext, +} from '../helpers/stats.js'; + +// Constants duplicated from the mock so tests don't re-import ponder:schema +const HOURS = 168; +const SECS_PER_HOUR = 3600; + +// --------------------------------------------------------------------------- +// Helper factories +// --------------------------------------------------------------------------- + +interface MockStatsRow { + ringBuffer: string[]; + ringBufferPointer: number; + lastHourlyUpdateTimestamp: bigint; + holderCount: number; + minStake?: bigint; + stakeTotalSupply?: bigint; + outstandingStake?: bigint; + kraikenTotalSupply?: bigint; +} + +function emptyStatsRow(overrides: Partial = {}): MockStatsRow { + return { + ringBuffer: makeEmptyRingBuffer().map(String), + ringBufferPointer: 0, + lastHourlyUpdateTimestamp: 0n, + holderCount: 0, + minStake: 0n, + stakeTotalSupply: 0n, + outstandingStake: 0n, + kraikenTotalSupply: 0n, + ...overrides, + }; +} + +/** Build a ring buffer string[] with a known value at a specific slot. */ +function ringBufferWith( + pointer: number, + slotOffset: number, + value: bigint, + hoursBack = 0, +): string[] { + const buf = makeEmptyRingBuffer(); + const idx = ((pointer - hoursBack + HOURS) % HOURS) * RING_BUFFER_SEGMENTS + slotOffset; + buf[idx] = value; + return buf.map(String); +} + +function createSetMock() { + return vi.fn().mockResolvedValue(undefined); +} + +function createMockContext(statsRow: MockStatsRow | null = null): StatsContext { + const setFn = createSetMock(); + const ctx = { + db: { + find: vi.fn().mockResolvedValue(statsRow), + update: vi.fn().mockReturnValue({ set: setFn }), + insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }), + }, + client: { + readContract: vi.fn().mockResolvedValue(0n), + }, + contracts: { + Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' as `0x${string}` }, + Stake: { abi: [], address: '0x0000000000000000000000000000000000000002' as `0x${string}` }, + }, + network: { + contracts: { + Kraiken: { abi: [], address: '0x0000000000000000000000000000000000000001' }, + }, + }, + logger: { + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, + }; + // Cast: vitest uses esbuild (no type-check), so the slim mock satisfies the runtime contract. + return ctx as unknown as StatsContext; +} + +// --------------------------------------------------------------------------- +// makeEmptyRingBuffer +// --------------------------------------------------------------------------- + +describe('makeEmptyRingBuffer', () => { + it('returns an array of the correct length', () => { + const buf = makeEmptyRingBuffer(); + expect(buf).toHaveLength(HOURS * RING_BUFFER_SEGMENTS); + }); + + it('fills every element with 0n', () => { + const buf = makeEmptyRingBuffer(); + expect(buf.every(v => v === 0n)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseRingBuffer +// --------------------------------------------------------------------------- + +describe('parseRingBuffer', () => { + it('converts a string array to bigint array', () => { + const raw = ['1', '2', '3']; + expect(parseRingBuffer(raw)).toEqual([1n, 2n, 3n]); + }); + + it('returns empty ring buffer for null input', () => { + expect(parseRingBuffer(null)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS); + expect(parseRingBuffer(null).every(v => v === 0n)).toBe(true); + }); + + it('returns empty ring buffer for undefined input', () => { + expect(parseRingBuffer(undefined)).toHaveLength(HOURS * RING_BUFFER_SEGMENTS); + }); + + it('returns empty ring buffer for empty array input', () => { + expect(parseRingBuffer([])).toHaveLength(HOURS * RING_BUFFER_SEGMENTS); + }); +}); + +// --------------------------------------------------------------------------- +// serializeRingBuffer +// --------------------------------------------------------------------------- + +describe('serializeRingBuffer', () => { + it('converts bigint array to string array', () => { + expect(serializeRingBuffer([0n, 1n, 1000n])).toEqual(['0', '1', '1000']); + }); + + it('round-trips through parseRingBuffer', () => { + const original = makeEmptyRingBuffer(); + original[0] = 42n; + original[RING_BUFFER_SEGMENTS] = 99n; + const serialized = serializeRingBuffer(original); + expect(parseRingBuffer(serialized)).toEqual(original); + }); +}); + +// --------------------------------------------------------------------------- +// checkBlockHistorySufficient +// --------------------------------------------------------------------------- + +describe('checkBlockHistorySufficient', () => { + it('returns false when context has no network contracts', () => { + const ctx = { network: {} } as unknown as StatsContext; + const event = { block: { number: 200n } } as unknown as Parameters[1]; + expect(checkBlockHistorySufficient(ctx, event)).toBe(false); + }); + + it('returns false when context is missing network entirely', () => { + const ctx = {} as unknown as StatsContext; + const event = { block: { number: 200n } } as unknown as Parameters[1]; + expect(checkBlockHistorySufficient(ctx, event)).toBe(false); + }); + + it('returns false when not enough blocks have passed', () => { + const ctx = createMockContext(); + const event = { + block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) }, + } as unknown as Parameters[1]; + expect(checkBlockHistorySufficient(ctx, event)).toBe(false); + }); + + it('uses console fallback when context has no logger', () => { + // Context with network.contracts.Kraiken but no logger — exercises logger.ts fallback branch + const ctx = { + network: { contracts: { Kraiken: {} } }, + } as unknown as StatsContext; + const event = { + block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER - 1) }, + } as unknown as Parameters[1]; + // Should not throw; console.warn is used as fallback + expect(checkBlockHistorySufficient(ctx, event)).toBe(false); + }); + + it('returns true when sufficient blocks have passed', () => { + const ctx = createMockContext(); + const event = { + block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER + 1) }, + } as unknown as Parameters[1]; + expect(checkBlockHistorySufficient(ctx, event)).toBe(true); + }); + + it('returns true at exact threshold', () => { + const ctx = createMockContext(); + const event = { + block: { number: BigInt(MINIMUM_BLOCKS_FOR_RINGBUFFER) }, + } as unknown as Parameters[1]; + expect(checkBlockHistorySufficient(ctx, event)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// updateHourlyData +// --------------------------------------------------------------------------- + +describe('updateHourlyData', () => { + it('returns early when no stats row exists', async () => { + const ctx = createMockContext(null); + await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 10)); + expect((ctx as unknown as { db: { update: ReturnType } }).db.update).not.toHaveBeenCalled(); + }); + + it('initialises lastHourlyUpdateTimestamp when it is 0n', async () => { + const row = emptyStatsRow({ lastHourlyUpdateTimestamp: 0n }); + const ctx = createMockContext(row); + const timestamp = BigInt(SECS_PER_HOUR * 5); + await updateHourlyData(ctx, timestamp); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + // Should store the current hour + expect(setArg.lastHourlyUpdateTimestamp).toBe((timestamp / BigInt(SECS_PER_HOUR)) * BigInt(SECS_PER_HOUR)); + }); + + it('advances the pointer when a new hour has started', async () => { + const baseTimestamp = BigInt(SECS_PER_HOUR * 100); + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: 0, + }); + const ctx = createMockContext(row); + // Move 3 hours forward + const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 3 + 60); + await updateHourlyData(ctx, newTimestamp); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.ringBufferPointer).toBe(3); + }); + + it('clamps hours elapsed to HOURS_IN_RING_BUFFER to prevent full-buffer clear loops', async () => { + const baseTimestamp = BigInt(SECS_PER_HOUR * 100); + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: 0, + }); + const ctx = createMockContext(row); + // Jump 500 hours — exceeds ring buffer + const newTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR * 500); + await updateHourlyData(ctx, newTimestamp); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + // pointer should wrap around within HOURS range + expect(setArg.ringBufferPointer).toBeGreaterThanOrEqual(0); + expect(setArg.ringBufferPointer).toBeLessThan(HOURS); + }); + + it('computes metrics when hour has advanced (mintedDay, burnedDay, etc.)', async () => { + const pointer = 5; + // Slot 1 (minted) at index i=0 (current hour) through i=23 — 10n in each of last 48 hours + const buf = makeEmptyRingBuffer(); + for (let h = 0; h < 48; h++) { + const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS; + buf[idx + 1] = 10n; // minted + buf[idx + 2] = 5n; // burned + } + const baseTimestamp = BigInt(SECS_PER_HOUR * 200); + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: pointer, + ringBuffer: buf.map(String), + }); + const ctx = createMockContext(row); + + await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2)); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + // mintedLastWeek and mintedLastDay should be positive + expect(setArg.mintedLastWeek).toBeGreaterThan(0n); + expect(setArg.mintedLastDay).toBeGreaterThan(0n); + expect(setArg.burnedLastWeek).toBeGreaterThan(0n); + expect(setArg.burnedLastDay).toBeGreaterThan(0n); + }); + + it('carries forward holderCount into new ring buffer slots', async () => { + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 10), + ringBufferPointer: 0, + holderCount: 42, + }); + const ctx = createMockContext(row); + await updateHourlyData(ctx, BigInt(SECS_PER_HOUR * 12)); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArgs = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + const updatedBuf = parseRingBuffer(setArgs.ringBuffer as string[]); + // pointer advanced to 2; slot 3 of new pointer should be 42n + const newPointer = 2; + expect(updatedBuf[newPointer * RING_BUFFER_SEGMENTS + 3]).toBe(42n); + }); + + it('computes projections when same hour (no advancement)', async () => { + const pointer = 3; + const baseTimestamp = BigInt(SECS_PER_HOUR * 100); + // Add some minted data in the current and previous slot + const buf = makeEmptyRingBuffer(); + buf[pointer * RING_BUFFER_SEGMENTS + 1] = 100n; // current hour minted + const prevPointer = ((pointer - 1 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS; + buf[prevPointer + 1] = 80n; // previous hour minted + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: pointer, + ringBuffer: buf.map(String), + }); + const ctx = createMockContext(row); + + // Same hour but 30 minutes in + const sameHourTimestamp = baseTimestamp + BigInt(SECS_PER_HOUR / 2); + await updateHourlyData(ctx, sameHourTimestamp); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + // Projections should be computed + expect(setArg.mintNextHourProjected).toBeDefined(); + expect(setArg.burnNextHourProjected).toBeDefined(); + }); + + it('handles zero elapsedSeconds in projection (exact hour boundary)', async () => { + const pointer = 0; + const exactHour = BigInt(SECS_PER_HOUR * 50); + const buf = makeEmptyRingBuffer(); + buf[pointer * RING_BUFFER_SEGMENTS + 1] = 50n; // some minted + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: exactHour, + ringBufferPointer: pointer, + ringBuffer: buf.map(String), + }); + const ctx = createMockContext(row); + + // Pass exactly the same timestamp (elapsedSeconds = 0) + await updateHourlyData(ctx, exactHour); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + }); + + it('computes ETH reserve change metrics when reserves are populated', async () => { + const pointer = 25; + const buf = makeEmptyRingBuffer(); + // Set current hour ETH reserve (i=0) + buf[pointer * RING_BUFFER_SEGMENTS + 0] = 2000n; + // Set 24h ago ETH reserve (i=23) + const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS; + buf[idx24h + 0] = 1000n; + const baseTimestamp = BigInt(SECS_PER_HOUR * 200); + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: pointer, + ringBuffer: buf.map(String), + }); + const ctx = createMockContext(row); + await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2)); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.ethReserveLastDay).toBeGreaterThanOrEqual(0n); + }); +}); + +// --------------------------------------------------------------------------- +// recordEthReserveSnapshot +// --------------------------------------------------------------------------- + +describe('recordEthReserveSnapshot', () => { + it('returns early when no stats row exists', async () => { + const ctx = createMockContext(null); + await recordEthReserveSnapshot(ctx, 1000n, 500n); + expect((ctx as unknown as { db: { update: ReturnType } }).db.update).not.toHaveBeenCalled(); + }); + + it('writes the ETH balance into slot 0 of the current pointer', async () => { + const pointer = 2; + const row = emptyStatsRow({ ringBufferPointer: pointer }); + const ctx = createMockContext(row); + const ethBalance = 12345678n; + + await recordEthReserveSnapshot(ctx, 1000n, ethBalance); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + const updatedBuf = parseRingBuffer(setArg.ringBuffer as string[]); + expect(updatedBuf[pointer * RING_BUFFER_SEGMENTS + 0]).toBe(ethBalance); + }); +}); + +// --------------------------------------------------------------------------- +// updateEthReserve +// --------------------------------------------------------------------------- + +describe('updateEthReserve', () => { + it('skips update when weth balance is 0', async () => { + const ctx = createMockContext(emptyStatsRow()); + // readContract returns 0n by default + await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`); + expect((ctx as unknown as { db: { update: ReturnType } }).db.update).not.toHaveBeenCalled(); + }); + + it('updates lastEthReserve when weth balance is non-zero', async () => { + const ctx = createMockContext(emptyStatsRow()); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockResolvedValue(9999n); + + await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.lastEthReserve).toBe(9999n); + }); + + it('logs warning and returns when readContract throws', async () => { + const ctx = createMockContext(emptyStatsRow()); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockRejectedValue(new Error('rpc error')); + + await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// ensureStatsExists +// --------------------------------------------------------------------------- + +describe('ensureStatsExists', () => { + it('returns existing stats without re-creating when row exists', async () => { + const row = emptyStatsRow(); + const ctx = createMockContext(row); + + const result = await ensureStatsExists(ctx, 1000n); + + const dbMock = ctx as unknown as { db: { insert: ReturnType } }; + expect(dbMock.db.insert).not.toHaveBeenCalled(); + expect(result).toBe(row); + }); + + it('creates a new stats row when none exists', async () => { + // First call returns null, second call (post-insert) returns the row + const row = emptyStatsRow(); + const dbFindMock = vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(row); + const ctx = createMockContext(null); + (ctx as unknown as { db: { find: ReturnType } }).db.find = dbFindMock; + + const result = await ensureStatsExists(ctx, 1000n); + + const dbMock = ctx as unknown as { db: { insert: ReturnType } }; + expect(dbMock.db.insert).toHaveBeenCalled(); + expect(result).toBe(row); + }); + + it('uses fallback values when readContract throws during creation', async () => { + const row = emptyStatsRow(); + const dbFindMock = vi.fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(row); + const ctx = createMockContext(null); + (ctx as unknown as { db: { find: ReturnType } }).db.find = dbFindMock; + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockRejectedValue(new Error('rpc error')); + + const result = await ensureStatsExists(ctx, 1000n); + + expect(result).toBe(row); + // insert should still have been called with fallback 0n values + const dbMock = ctx as unknown as { db: { insert: ReturnType } }; + expect(dbMock.db.insert).toHaveBeenCalled(); + }); + + it('works without timestamp argument', async () => { + const row = emptyStatsRow(); + const ctx = createMockContext(row); + const result = await ensureStatsExists(ctx); + expect(result).toBe(row); + }); +}); + +// --------------------------------------------------------------------------- +// markPositionsUpdated +// --------------------------------------------------------------------------- + +describe('markPositionsUpdated', () => { + it('updates positionsUpdatedAt in the stats row', async () => { + const row = emptyStatsRow(); + const ctx = createMockContext(row); + const ts = 7777n; + + await markPositionsUpdated(ctx, ts); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.positionsUpdatedAt).toBe(ts); + }); +}); + +// --------------------------------------------------------------------------- +// getStakeTotalSupply +// --------------------------------------------------------------------------- + +describe('getStakeTotalSupply', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('reads totalSupply from contract when cache is cold', async () => { + // Fresh import to reset module-level cache + const { getStakeTotalSupply: freshGet } = await import('../helpers/stats.js'); + const row = emptyStatsRow({ stakeTotalSupply: 0n }); + const ctx = createMockContext(row); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockResolvedValue(555n); + + const result = await freshGet(ctx); + + expect(result).toBe(555n); + }); + + it('returns cached value without calling readContract on second call', async () => { + // Fresh import to get a clean module with null cache + const { getStakeTotalSupply: freshGet } = await import('../helpers/stats.js'); + const row = emptyStatsRow({ stakeTotalSupply: 0n }); + const readContractMock = vi.fn().mockResolvedValue(777n); + const ctx = createMockContext(row); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = readContractMock; + + // First call — populates cache via readContract + const first = await freshGet(ctx); + expect(first).toBe(777n); + + // Second call — should return cached value, readContract not called again + const second = await freshGet(ctx); + expect(second).toBe(777n); + expect(readContractMock).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// refreshOutstandingStake +// --------------------------------------------------------------------------- + +describe('refreshOutstandingStake', () => { + it('reads outstandingStake from contract and persists it', async () => { + const row = emptyStatsRow(); + const ctx = createMockContext(row); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockResolvedValue(1234n); + + await refreshOutstandingStake(ctx); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.outstandingStake).toBe(1234n); + }); +}); + +// --------------------------------------------------------------------------- +// refreshMinStake +// --------------------------------------------------------------------------- + +describe('refreshMinStake', () => { + it('skips update when minStake is unchanged', async () => { + const row = emptyStatsRow({ minStake: 500n }); + const ctx = createMockContext(row); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockResolvedValue(500n); + + await refreshMinStake(ctx, row as unknown as Awaited>); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).not.toHaveBeenCalled(); + }); + + it('updates minStake when the on-chain value has changed', async () => { + const row = emptyStatsRow({ minStake: 100n }); + const ctx = createMockContext(row); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockResolvedValue(200n); + + await refreshMinStake(ctx, row as unknown as Awaited>); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.minStake).toBe(200n); + }); + + it('fetches stats when no statsData arg is provided and row exists', async () => { + const row = emptyStatsRow({ minStake: 0n }); + const ctx = createMockContext(row); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockResolvedValue(300n); + + await refreshMinStake(ctx); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + }); + + it('creates stats row when find returns null', async () => { + const row = emptyStatsRow({ minStake: 0n }); + const dbFindMock = vi.fn() + .mockResolvedValueOnce(null) // first find (refresh check) + .mockResolvedValueOnce(null) // ensureStatsExists find + .mockResolvedValueOnce(row); // ensureStatsExists post-insert + const ctx = createMockContext(null); + (ctx as unknown as { db: { find: ReturnType } }).db.find = dbFindMock; + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockResolvedValue(300n); + + await refreshMinStake(ctx); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + }); + + it('logs warning and returns when readContract throws', async () => { + const row = emptyStatsRow({ minStake: 100n }); + const ctx = createMockContext(row); + (ctx as unknown as { client: { readContract: ReturnType } }).client.readContract + = vi.fn().mockRejectedValue(new Error('rpc error')); + + await refreshMinStake(ctx, row as unknown as Awaited>); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).not.toHaveBeenCalled(); + }); + + it('uses ringBufferWith helper to build non-trivial buffers', () => { + const pointer = 10; + const buf = parseRingBuffer(ringBufferWith(pointer, 1, 999n)); + expect(buf[pointer * RING_BUFFER_SEGMENTS + 1]).toBe(999n); + }); +}); + +// --------------------------------------------------------------------------- +// RING_BUFFER_SEGMENTS and MINIMUM_BLOCKS_FOR_RINGBUFFER exports +// --------------------------------------------------------------------------- + +describe('constants', () => { + it('RING_BUFFER_SEGMENTS is 4', () => { + expect(RING_BUFFER_SEGMENTS).toBe(4); + }); + + it('MINIMUM_BLOCKS_FOR_RINGBUFFER is 100', () => { + expect(MINIMUM_BLOCKS_FOR_RINGBUFFER).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// computeMetrics coverage: holderCount and ethReserve at 24h/7d boundaries +// --------------------------------------------------------------------------- + +describe('computeMetrics boundary coverage (via updateHourlyData)', () => { + it('captures ethReserve7dAgo from the oldest non-zero slot', async () => { + const pointer = 2; + const buf = makeEmptyRingBuffer(); + // Fill a slot > 24 hours back with a non-zero ETH reserve + const oldSlot = ((pointer - 50 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS; + buf[oldSlot + 0] = 500n; + // Current hour also has a value + buf[pointer * RING_BUFFER_SEGMENTS + 0] = 1000n; + + const baseTimestamp = BigInt(SECS_PER_HOUR * 200); + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: pointer, + ringBuffer: buf.map(String), + }); + const ctx = createMockContext(row); + await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2)); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.ethReserveLastWeek).toBeGreaterThanOrEqual(0n); + }); + + it('records holderCount at 24h and 7d ago markers', async () => { + const pointer = 30; + const buf = makeEmptyRingBuffer(); + // Set holderCount at current pointer (i=0) + buf[pointer * RING_BUFFER_SEGMENTS + 3] = 100n; + // Set holderCount at 24h ago (i=23) + const idx24h = ((pointer - 23 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS; + buf[idx24h + 3] = 80n; + // Set holderCount far back (i=150, older than 7d) + const idxOld = ((pointer - 150 + HOURS) % HOURS) * RING_BUFFER_SEGMENTS; + buf[idxOld + 3] = 50n; + + const baseTimestamp = BigInt(SECS_PER_HOUR * 500); + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: pointer, + ringBuffer: buf.map(String), + }); + const ctx = createMockContext(row); + await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR * 2)); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + expect(dbMock.db.update).toHaveBeenCalled(); + }); + + it('handles projection fallback when weekly totals are also zero', async () => { + // All zeros in ring buffer — medium will be 0, fallback is weekly/7 + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: BigInt(SECS_PER_HOUR * 100), + ringBufferPointer: 0, + }); + const ctx = createMockContext(row); + const sameHourTimestamp = BigInt(SECS_PER_HOUR * 100) + BigInt(SECS_PER_HOUR / 4); + await updateHourlyData(ctx, sameHourTimestamp); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + // Projection should be 0 (weekly/7 = 0/7 = 0) + expect(setArg.mintNextHourProjected).toBe(0n); + expect(setArg.burnNextHourProjected).toBe(0n); + }); + + it('computes netSupplyChange from minted minus burned', async () => { + const pointer = 1; + const buf = makeEmptyRingBuffer(); + for (let h = 0; h < 24; h++) { + const idx = ((pointer - h + HOURS) % HOURS) * RING_BUFFER_SEGMENTS; + buf[idx + 1] = 20n; // minted + buf[idx + 2] = 8n; // burned + } + const baseTimestamp = BigInt(SECS_PER_HOUR * 300); + const row = emptyStatsRow({ + lastHourlyUpdateTimestamp: baseTimestamp, + ringBufferPointer: pointer, + ringBuffer: buf.map(String), + }); + const ctx = createMockContext(row); + await updateHourlyData(ctx, baseTimestamp + BigInt(SECS_PER_HOUR)); + + const dbMock = ctx as unknown as { db: { update: ReturnType } }; + const setArg = (dbMock.db.update as ReturnType).mock.results[0].value.set.mock.calls[0][0]; + expect(setArg.netSupplyChangeDay).toBeGreaterThan(0n); + expect(setArg.netSupplyChangeWeek).toBeGreaterThan(0n); + }); +}); + diff --git a/services/ponder/src/tests/version.test.ts b/services/ponder/src/tests/version.test.ts new file mode 100644 index 0000000..97af459 --- /dev/null +++ b/services/ponder/src/tests/version.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + isCompatibleVersion, + getVersionMismatchError, + KRAIKEN_LIB_VERSION, + COMPATIBLE_CONTRACT_VERSIONS, +} from 'kraiken-lib/version'; + +// --------------------------------------------------------------------------- +// isCompatibleVersion (from kraiken-lib/version, aliased to source) +// --------------------------------------------------------------------------- + +describe('isCompatibleVersion', () => { + it('returns true for each version in COMPATIBLE_CONTRACT_VERSIONS', () => { + for (const v of COMPATIBLE_CONTRACT_VERSIONS) { + expect(isCompatibleVersion(v)).toBe(true); + } + }); + + it('returns false for version 0', () => { + expect(isCompatibleVersion(0)).toBe(false); + }); + + it('returns false for a future version not yet listed', () => { + expect(isCompatibleVersion(9999)).toBe(false); + }); + + it('returns false for negative version', () => { + expect(isCompatibleVersion(-1)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getVersionMismatchError (from kraiken-lib/version) +// --------------------------------------------------------------------------- + +describe('getVersionMismatchError', () => { + it('includes the bad contract version in the output (ponder context)', () => { + const msg = getVersionMismatchError(99, 'ponder'); + expect(msg).toContain('99'); + }); + + it('includes the library version in the output', () => { + const msg = getVersionMismatchError(99, 'ponder'); + expect(msg).toContain(String(KRAIKEN_LIB_VERSION)); + }); + + it('includes ponder-specific remediation steps', () => { + const msg = getVersionMismatchError(99, 'ponder'); + expect(msg).toContain('COMPATIBLE_CONTRACT_VERSIONS'); + }); + + it('includes frontend-specific remediation steps', () => { + const msg = getVersionMismatchError(99, 'frontend'); + expect(msg).toContain('administrator'); + }); + + it('formats compatible versions list', () => { + const msg = getVersionMismatchError(99, 'ponder'); + for (const v of COMPATIBLE_CONTRACT_VERSIONS) { + expect(msg).toContain(String(v)); + } + }); + + it('returns a multi-line string (box drawing)', () => { + const msg = getVersionMismatchError(99, 'ponder'); + expect(msg.split('\n').length).toBeGreaterThan(3); + }); +}); + +// --------------------------------------------------------------------------- +// validateContractVersion (helpers/version.ts) — mock the Ponder context +// --------------------------------------------------------------------------- + +describe('validateContractVersion', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('logs success and upserts metadata on compatible version', async () => { + const { validateContractVersion } = await import('../helpers/version.js'); + + const setFn = vi.fn().mockResolvedValue(undefined); + const ctx = { + client: { + readContract: vi.fn().mockResolvedValue(KRAIKEN_LIB_VERSION), + }, + contracts: { + Kraiken: { abi: [], address: '0x0001' as `0x${string}` }, + }, + db: { + find: vi.fn().mockResolvedValue(null), + insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }), + update: vi.fn().mockReturnValue({ set: setFn }), + }, + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + }; + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + + await validateContractVersion(ctx as unknown as import('ponder:registry').Context); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(ctx.logger.info).toHaveBeenCalled(); + }); + + it('calls process.exit on incompatible contract version', async () => { + const { validateContractVersion } = await import('../helpers/version.js'); + + const ctx = { + client: { + readContract: vi.fn().mockResolvedValue(9999n), + }, + contracts: { + Kraiken: { abi: [], address: '0x0001' as `0x${string}` }, + }, + db: { + find: vi.fn().mockResolvedValue(null), + insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }), + update: vi.fn().mockReturnValue({ set: vi.fn().mockResolvedValue(undefined) }), + }, + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + }; + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + + await validateContractVersion(ctx as unknown as import('ponder:registry').Context); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('calls process.exit when readContract throws', async () => { + const { validateContractVersion } = await import('../helpers/version.js'); + + const ctx = { + client: { + readContract: vi.fn().mockRejectedValue(new Error('network error')), + }, + contracts: { + Kraiken: { abi: [], address: '0x0001' as `0x${string}` }, + }, + db: { + find: vi.fn().mockResolvedValue(null), + insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }), + update: vi.fn().mockReturnValue({ set: vi.fn().mockResolvedValue(undefined) }), + }, + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + }; + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + + await validateContractVersion(ctx as unknown as import('ponder:registry').Context); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('updates existing metadata when a row already exists', async () => { + const { validateContractVersion } = await import('../helpers/version.js'); + + const setFn = vi.fn().mockResolvedValue(undefined); + const existingMeta = { id: 'stack-meta', contractVersion: 1 }; + const ctx = { + client: { + readContract: vi.fn().mockResolvedValue(KRAIKEN_LIB_VERSION), + }, + contracts: { + Kraiken: { abi: [], address: '0x0001' as `0x${string}` }, + }, + db: { + find: vi.fn().mockResolvedValue(existingMeta), + insert: vi.fn().mockReturnValue({ values: vi.fn().mockResolvedValue(undefined) }), + update: vi.fn().mockReturnValue({ set: setFn }), + }, + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + }; + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as () => never); + + await validateContractVersion(ctx as unknown as import('ponder:registry').Context); + + expect(exitSpy).not.toHaveBeenCalled(); + expect(ctx.db.update).toHaveBeenCalled(); + expect(ctx.db.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/services/ponder/tsconfig.json b/services/ponder/tsconfig.json index 27332f9..0f8824a 100644 --- a/services/ponder/tsconfig.json +++ b/services/ponder/tsconfig.json @@ -20,6 +20,6 @@ "ponder/virtual": ["./node_modules/ponder/src/types.d.ts"] } }, - "include": ["src/**/*", "ponder.config.ts", "ponder.schema.ts", "ponder-env.d.ts"], + "include": ["src/**/*", "ponder.config.ts", "ponder.schema.ts", "ponder-env.d.ts", "vitest.config.ts"], "exclude": ["node_modules", ".ponder"] } diff --git a/services/ponder/vitest.config.ts b/services/ponder/vitest.config.ts new file mode 100644 index 0000000..503b932 --- /dev/null +++ b/services/ponder/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'url'; +import { dirname, resolve } from 'path'; + +const rootDir = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + coverage: { + provider: 'v8', + include: ['src/helpers/**/*.ts'], + reporter: ['text', 'lcov'], + thresholds: { + lines: 95, + }, + }, + }, + resolve: { + alias: { + 'ponder:schema': resolve(rootDir, 'src/tests/__mocks__/ponder-schema.ts'), + 'ponder:registry': resolve(rootDir, 'src/tests/__mocks__/ponder-registry.ts'), + 'kraiken-lib/version': resolve(rootDir, '../../kraiken-lib/src/version.ts'), + }, + }, +});