fix: Ponder: add test infrastructure + coverage for helpers (target 95%) (#287)

- 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 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-25 22:29:57 +00:00
parent 24b3cf2836
commit e49938bd0a
10 changed files with 1808 additions and 20 deletions

View file

@ -3,5 +3,6 @@
*.log
dist
build
coverage
.DS_Store
pnpm-lock.yaml

View file

@ -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",

View file

@ -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": [

View file

@ -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: () => {} };

View file

@ -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 = {};

View file

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

View file

@ -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> = {}): 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<typeof checkBlockHistorySufficient>[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<typeof checkBlockHistorySufficient>[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<typeof checkBlockHistorySufficient>[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<typeof checkBlockHistorySufficient>[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<typeof checkBlockHistorySufficient>[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<typeof checkBlockHistorySufficient>[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<typeof vi.fn> } }).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<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
const setArgs = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
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<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } }).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<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } }).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<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(9999n);
await updateEthReserve(ctx, '0xpool0000000000000000000000000000000000001' as `0x${string}`);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } }).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<typeof vi.fn> } };
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<typeof vi.fn> } };
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<typeof vi.fn> } }).db.find = dbFindMock;
const result = await ensureStatsExists(ctx, 1000n);
const dbMock = ctx as unknown as { db: { insert: ReturnType<typeof vi.fn> } };
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<typeof vi.fn> } }).db.find = dbFindMock;
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).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<typeof vi.fn> } };
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<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } }).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<typeof vi.fn> } }).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<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(1234n);
await refreshOutstandingStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(500n);
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
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<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(200n);
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(300n);
await refreshMinStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
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<typeof vi.fn> } }).db.find = dbFindMock;
(ctx as unknown as { client: { readContract: ReturnType<typeof vi.fn> } }).client.readContract
= vi.fn().mockResolvedValue(300n);
await refreshMinStake(ctx);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
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<typeof vi.fn> } }).client.readContract
= vi.fn().mockRejectedValue(new Error('rpc error'));
await refreshMinStake(ctx, row as unknown as Awaited<ReturnType<typeof ensureStatsExists>>);
const dbMock = ctx as unknown as { db: { update: ReturnType<typeof vi.fn> } };
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<typeof vi.fn> } };
expect(dbMock.db.update).toHaveBeenCalled();
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
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<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).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<typeof vi.fn> } };
const setArg = (dbMock.db.update as ReturnType<typeof vi.fn>).mock.results[0].value.set.mock.calls[0][0];
expect(setArg.netSupplyChangeDay).toBeGreaterThan(0n);
expect(setArg.netSupplyChangeWeek).toBeGreaterThan(0n);
});
});

View file

@ -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();
});
});

View file

@ -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"]
}

View file

@ -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'),
},
},
});