feat(ponder): Add strict ESLint + Prettier with pre-commit hooks (#52)

- Install eslint, @typescript-eslint plugins, prettier, husky, lint-staged
- Configure ESLint flat config with TypeScript parser
- Enforce: no-explicit-any (with exceptions), no-unused-vars, naming-convention, prefer-const, no-console
- Set style: 2-space indent, 140 char max-len, disable complexity rules
- Configure Prettier: single quotes, 140 width, trailing commas
- Setup husky pre-commit hook to auto-fix and format on commit
- Replace console.log/warn with context.logger.info/warn in handlers
- Remove console.log from ponder.config.ts (replaced with comment)
- Add npm scripts: lint, lint:fix, format, format:check
- All lint rules pass without warnings

resolvel #45

Co-authored-by: johba <johba@harb.eth>
Reviewed-on: https://codeberg.org/johba/harb/pulls/52
This commit is contained in:
johba 2025-10-04 15:37:26 +02:00
parent c150b683c8
commit dc61771dfc
18 changed files with 2229 additions and 194 deletions

View file

@ -1,4 +1,5 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
set -e set -e
if [ -d "onchain" ]; then if [ -d "onchain" ]; then
@ -12,3 +13,7 @@ fi
if [ -d "web-app" ]; then if [ -d "web-app" ]; then
(cd web-app && npx lint-staged) (cd web-app && npx lint-staged)
fi fi
if [ -d "services/ponder" ]; then
(cd services/ponder && npx lint-staged)
fi

View file

@ -219,7 +219,6 @@
"integrity": "sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==", "integrity": "sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.2", "@babel/code-frame": "^7.24.2",
@ -3107,7 +3106,6 @@
"integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.13.0" "undici-types": "~7.13.0"
} }
@ -3943,7 +3941,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001587", "caniuse-lite": "^1.0.30001587",
"electron-to-chromium": "^1.4.668", "electron-to-chromium": "^1.4.668",
@ -5521,7 +5518,6 @@
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==", "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
} }
@ -5606,7 +5602,6 @@
"integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==", "integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"workspaces": [ "workspaces": [
"website" "website"
], ],
@ -6266,7 +6261,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@ -9249,7 +9243,6 @@
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -9572,7 +9565,6 @@
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
}, },

View file

@ -1,4 +1,4 @@
import type { Positions } from './__generated__/graphql.js'; import type { Positions as Position } from './__generated__/graphql.js';
import { toBigIntId } from './ids.js'; import { toBigIntId } from './ids.js';
export interface SnatchablePosition { export interface SnatchablePosition {
@ -110,7 +110,7 @@ export function selectSnatchPositions(candidates: SnatchablePosition[], options:
} }
export function getSnatchList( export function getSnatchList(
positions: Positions[], positions: Position[],
needed: bigint, needed: bigint,
taxRate: number, taxRate: number,
stakeTotalSupply: bigint stakeTotalSupply: bigint

View file

@ -2803,11 +2803,6 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@^2.3.2:
version "2.3.3"
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2: function-bind@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"

View file

@ -0,0 +1 @@
npm test

View file

@ -0,0 +1,14 @@
{
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
],
"ponder.config.ts": [
"eslint --fix",
"prettier --write"
],
"ponder.schema.ts": [
"eslint --fix",
"prettier --write"
]
}

View file

@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 140,
"tabWidth": 2,
"trailingComma": "es5",
"arrowParens": "avoid"
}

View file

@ -0,0 +1,76 @@
import eslint from '@eslint/js';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
eslint.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
globals: {
process: 'readonly',
console: 'readonly',
Context: 'readonly',
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
// TypeScript
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'function',
format: ['camelCase'],
},
{
selector: 'variable',
format: ['camelCase', 'UPPER_CASE'],
},
{
selector: 'typeLike',
format: ['PascalCase'],
},
],
// Style
'prefer-const': 'error',
indent: ['error', 2, { SwitchCase: 1 }],
'max-len': [
'error',
{
code: 140,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreUrls: true,
},
],
// Console
'no-console': 'error',
// Complexity (off)
complexity: 'off',
'max-depth': 'off',
'max-nested-callbacks': 'off',
'max-params': 'off',
'max-statements': 'off',
},
},
eslintConfigPrettier,
];

File diff suppressed because it is too large Load diff

View file

@ -4,10 +4,15 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "ponder dev", "dev": "node ./node_modules/ponder/dist/esm/bin/ponder.js dev",
"start": "ponder start", "start": "node ./node_modules/ponder/dist/esm/bin/ponder.js start",
"codegen": "ponder codegen", "codegen": "node ./node_modules/ponder/dist/esm/bin/ponder.js codegen",
"build": "ponder codegen" "build": "node ./node_modules/ponder/dist/esm/bin/ponder.js codegen",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.ts\" \"ponder.config.ts\" \"ponder.schema.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"ponder.config.ts\" \"ponder.schema.ts\"",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"hono": "^4.5.0", "hono": "^4.5.0",
@ -17,9 +22,30 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.30", "@types/node": "^20.11.30",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"esbuild": "^0.25.10", "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"
}, },
"lint-staged": {
"src/**/*.ts": [
"eslint --fix",
"prettier --write"
],
"ponder.config.ts": [
"eslint --fix",
"prettier --write"
],
"ponder.schema.ts": [
"eslint --fix",
"prettier --write"
]
},
"overrides": { "overrides": {
"esbuild": "^0.25.10", "esbuild": "^0.25.10",
"vite": "^5.4.11" "vite": "^5.4.11"

View file

@ -1,6 +1,6 @@
import { createConfig } from "ponder"; import { createConfig } from 'ponder';
import type { Abi } from "viem"; import type { Abi } from 'viem';
import { KraikenAbi, StakeAbi } from "kraiken-lib"; import { KraikenAbi, StakeAbi } from 'kraiken-lib';
// Network configurations keyed by canonical environment name // Network configurations keyed by canonical environment name
type NetworkConfig = { type NetworkConfig = {
@ -17,53 +17,54 @@ type NetworkConfig = {
const networks: Record<string, NetworkConfig> = { const networks: Record<string, NetworkConfig> = {
BASE_SEPOLIA_LOCAL_FORK: { BASE_SEPOLIA_LOCAL_FORK: {
chainId: 31337, chainId: 31337,
rpc: process.env.PONDER_RPC_URL_BASE_SEPOLIA_LOCAL_FORK || "http://127.0.0.1:8545", rpc: process.env.PONDER_RPC_URL_BASE_SEPOLIA_LOCAL_FORK || 'http://127.0.0.1:8545',
disableCache: true, disableCache: true,
contracts: { contracts: {
kraiken: process.env.KRAIKEN_ADDRESS || "0x56186c1E64cA8043dEF78d06AfF222212eA5df71", kraiken: process.env.KRAIKEN_ADDRESS || '0x56186c1E64cA8043dEF78d06AfF222212eA5df71',
stake: process.env.STAKE_ADDRESS || "0x056E4a859558A3975761ABd7385506BC4D8A8E60", stake: process.env.STAKE_ADDRESS || '0x056E4a859558A3975761ABd7385506BC4D8A8E60',
startBlock: parseInt(process.env.START_BLOCK || "31425917"), startBlock: parseInt(process.env.START_BLOCK || '31425917'),
}, },
}, },
BASE_SEPOLIA: { BASE_SEPOLIA: {
chainId: 84532, chainId: 84532,
rpc: process.env.PONDER_RPC_URL_BASE_SEPOLIA || "https://sepolia.base.org", rpc: process.env.PONDER_RPC_URL_BASE_SEPOLIA || 'https://sepolia.base.org',
contracts: { contracts: {
kraiken: "0x22c264Ecf8D4E49D1E3CabD8DD39b7C4Ab51C1B8", kraiken: '0x22c264Ecf8D4E49D1E3CabD8DD39b7C4Ab51C1B8',
stake: "0xe28020BCdEeAf2779dd47c670A8eFC2973316EE2", stake: '0xe28020BCdEeAf2779dd47c670A8eFC2973316EE2',
startBlock: 20940337, startBlock: 20940337,
}, },
}, },
BASE: { BASE: {
chainId: 8453, chainId: 8453,
rpc: process.env.PONDER_RPC_URL_BASE || "https://base.llamarpc.com", rpc: process.env.PONDER_RPC_URL_BASE || 'https://base.llamarpc.com',
contracts: { contracts: {
kraiken: "0x45caa5929f6ee038039984205bdecf968b954820", kraiken: '0x45caa5929f6ee038039984205bdecf968b954820',
stake: "0xed70707fab05d973ad41eae8d17e2bcd36192cfc", stake: '0xed70707fab05d973ad41eae8d17e2bcd36192cfc',
startBlock: 26038614, startBlock: 26038614,
}, },
}, },
}; };
// Select network based on environment variable // Select network based on environment variable
const NETWORK = (process.env.PONDER_NETWORK as keyof typeof networks) || "BASE_SEPOLIA_LOCAL_FORK"; const NETWORK = (process.env.PONDER_NETWORK as keyof typeof networks) || 'BASE_SEPOLIA_LOCAL_FORK';
const selectedNetwork = networks[NETWORK as keyof typeof networks]; const selectedNetwork = networks[NETWORK as keyof typeof networks];
if (!selectedNetwork) { if (!selectedNetwork) {
throw new Error(`Invalid network: ${NETWORK}. Valid options: ${Object.keys(networks).join(", ")}`); throw new Error(`Invalid network: ${NETWORK}. Valid options: ${Object.keys(networks).join(', ')}`);
} }
console.log( // Network configuration logged during Ponder startup
`[ponder.config] Network=${NETWORK}, chainId=${selectedNetwork.chainId}, startBlock=${selectedNetwork.contracts.startBlock}`, // Network=${NETWORK}, chainId=${selectedNetwork.chainId}, startBlock=${selectedNetwork.contracts.startBlock}
);
export default createConfig({ export default createConfig({
// Use PostgreSQL if DATABASE_URL is set, otherwise use PGlite // Use PostgreSQL if DATABASE_URL is set, otherwise use PGlite
database: process.env.DATABASE_URL ? { database: process.env.DATABASE_URL
kind: "postgres" as const, ? {
connectionString: process.env.DATABASE_URL, kind: 'postgres' as const,
schema: process.env.DATABASE_SCHEMA || "public", connectionString: process.env.DATABASE_URL,
} : undefined, schema: process.env.DATABASE_SCHEMA || 'public',
}
: undefined,
chains: { chains: {
[NETWORK]: { [NETWORK]: {
id: selectedNetwork.chainId, id: selectedNetwork.chainId,

View file

@ -1,73 +1,145 @@
import { onchainTable, primaryKey, index } from "ponder"; import { onchainTable, index } from 'ponder';
export const HOURS_IN_RING_BUFFER = 168; // 7 days * 24 hours export const HOURS_IN_RING_BUFFER = 168; // 7 days * 24 hours
const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax
// Global protocol stats - singleton with id "0x01" // Global protocol stats - singleton with id "0x01"
export const stats = onchainTable( export const stats = onchainTable('stats', t => ({
"stats", id: t.text().primaryKey(), // Always "0x01"
(t) => ({ kraikenTotalSupply: t
id: t.text().primaryKey(), // Always "0x01" .bigint()
kraikenTotalSupply: t.bigint().notNull().$default(() => 0n), .notNull()
stakeTotalSupply: t.bigint().notNull().$default(() => 0n), .$default(() => 0n),
outstandingStake: t.bigint().notNull().$default(() => 0n), stakeTotalSupply: t
.bigint()
.notNull()
.$default(() => 0n),
outstandingStake: t
.bigint()
.notNull()
.$default(() => 0n),
// Totals // Totals
totalMinted: t.bigint().notNull().$default(() => 0n), totalMinted: t
totalBurned: t.bigint().notNull().$default(() => 0n), .bigint()
totalTaxPaid: t.bigint().notNull().$default(() => 0n), .notNull()
totalUbiClaimed: t.bigint().notNull().$default(() => 0n), .$default(() => 0n),
totalBurned: t
.bigint()
.notNull()
.$default(() => 0n),
totalTaxPaid: t
.bigint()
.notNull()
.$default(() => 0n),
totalUbiClaimed: t
.bigint()
.notNull()
.$default(() => 0n),
// Rolling windows - calculated from ring buffer // Rolling windows - calculated from ring buffer
mintedLastWeek: t.bigint().notNull().$default(() => 0n), mintedLastWeek: t
mintedLastDay: t.bigint().notNull().$default(() => 0n), .bigint()
mintNextHourProjected: t.bigint().notNull().$default(() => 0n), .notNull()
.$default(() => 0n),
mintedLastDay: t
.bigint()
.notNull()
.$default(() => 0n),
mintNextHourProjected: t
.bigint()
.notNull()
.$default(() => 0n),
burnedLastWeek: t.bigint().notNull().$default(() => 0n), burnedLastWeek: t
burnedLastDay: t.bigint().notNull().$default(() => 0n), .bigint()
burnNextHourProjected: t.bigint().notNull().$default(() => 0n), .notNull()
.$default(() => 0n),
burnedLastDay: t
.bigint()
.notNull()
.$default(() => 0n),
burnNextHourProjected: t
.bigint()
.notNull()
.$default(() => 0n),
taxPaidLastWeek: t.bigint().notNull().$default(() => 0n), taxPaidLastWeek: t
taxPaidLastDay: t.bigint().notNull().$default(() => 0n), .bigint()
taxPaidNextHourProjected: t.bigint().notNull().$default(() => 0n), .notNull()
.$default(() => 0n),
taxPaidLastDay: t
.bigint()
.notNull()
.$default(() => 0n),
taxPaidNextHourProjected: t
.bigint()
.notNull()
.$default(() => 0n),
ubiClaimedLastWeek: t.bigint().notNull().$default(() => 0n), ubiClaimedLastWeek: t
ubiClaimedLastDay: t.bigint().notNull().$default(() => 0n), .bigint()
ubiClaimedNextHourProjected: t.bigint().notNull().$default(() => 0n), .notNull()
.$default(() => 0n),
ubiClaimedLastDay: t
.bigint()
.notNull()
.$default(() => 0n),
ubiClaimedNextHourProjected: t
.bigint()
.notNull()
.$default(() => 0n),
// Ring buffer state (flattened array of length HOURS_IN_RING_BUFFER * 4) // Ring buffer state (flattened array of length HOURS_IN_RING_BUFFER * 4)
ringBufferPointer: t.integer().notNull().$default(() => 0), ringBufferPointer: t
lastHourlyUpdateTimestamp: t.bigint().notNull().$default(() => 0n), .integer()
ringBuffer: t .notNull()
.jsonb() .$default(() => 0),
.$type<string[]>() lastHourlyUpdateTimestamp: t
.notNull() .bigint()
.$default(() => Array(HOURS_IN_RING_BUFFER * RING_BUFFER_SEGMENTS).fill("0")), .notNull()
}) .$default(() => 0n),
); ringBuffer: t
.jsonb()
.$type<string[]>()
.notNull()
.$default(() => Array(HOURS_IN_RING_BUFFER * RING_BUFFER_SEGMENTS).fill('0')),
}));
// Individual staking positions // Individual staking positions
export const positions = onchainTable( export const positions = onchainTable(
"positions", 'positions',
(t) => ({ t => ({
id: t.text().primaryKey(), // Position ID from contract id: t.text().primaryKey(), // Position ID from contract
owner: t.hex().notNull(), owner: t.hex().notNull(),
share: t.real().notNull(), // Share as decimal (0-1) share: t.real().notNull(), // Share as decimal (0-1)
taxRate: t.real().notNull(), // Tax rate as decimal (e.g., 0.01 for 1%) taxRate: t.real().notNull(), // Tax rate as decimal (e.g., 0.01 for 1%)
kraikenDeposit: t.bigint().notNull(), kraikenDeposit: t.bigint().notNull(),
stakeDeposit: t.bigint().notNull(), stakeDeposit: t.bigint().notNull(),
taxPaid: t.bigint().notNull().$default(() => 0n), taxPaid: t
snatched: t.integer().notNull().$default(() => 0), .bigint()
.notNull()
.$default(() => 0n),
snatched: t
.integer()
.notNull()
.$default(() => 0),
creationTime: t.bigint().notNull(), creationTime: t.bigint().notNull(),
lastTaxTime: t.bigint().notNull(), lastTaxTime: t.bigint().notNull(),
status: t.text().notNull().$default(() => "Active"), // "Active" or "Closed" status: t
.text()
.notNull()
.$default(() => 'Active'), // "Active" or "Closed"
createdAt: t.bigint().notNull(), createdAt: t.bigint().notNull(),
closedAt: t.bigint(), closedAt: t.bigint(),
totalSupplyInit: t.bigint().notNull(), totalSupplyInit: t.bigint().notNull(),
totalSupplyEnd: t.bigint(), totalSupplyEnd: t.bigint(),
payout: t.bigint().notNull().$default(() => 0n), payout: t
.bigint()
.notNull()
.$default(() => 0n),
}), }),
(table) => ({ table => ({
ownerIdx: index().on(table.owner), ownerIdx: index().on(table.owner),
statusIdx: index().on(table.status), statusIdx: index().on(table.status),
}) })
@ -75,11 +147,10 @@ export const positions = onchainTable(
// Constants for tax rates (matches subgraph) // Constants for tax rates (matches subgraph)
export const TAX_RATES = [ export const TAX_RATES = [
0.01, 0.03, 0.05, 0.07, 0.09, 0.11, 0.13, 0.15, 0.17, 0.19, 0.01, 0.03, 0.05, 0.07, 0.09, 0.11, 0.13, 0.15, 0.17, 0.19, 0.21, 0.25, 0.29, 0.33, 0.37, 0.41, 0.45, 0.49, 0.53, 0.57, 0.61, 0.65, 0.69,
0.21, 0.25, 0.29, 0.33, 0.37, 0.41, 0.45, 0.49, 0.53, 0.57, 0.73, 0.77, 0.81, 0.85, 0.89, 0.93, 0.97,
0.61, 0.65, 0.69, 0.73, 0.77, 0.81, 0.85, 0.89, 0.93, 0.97
]; ];
// Helper constants // Helper constants
export const STATS_ID = "0x01"; export const STATS_ID = '0x01';
export const SECONDS_IN_HOUR = 3600; export const SECONDS_IN_HOUR = 3600;

View file

@ -1,24 +1,29 @@
import { Hono } from "hono"; import { Hono } from 'hono';
import { cors } from "hono/cors"; import { cors } from 'hono/cors';
import { client, graphql } from "ponder"; import { client, graphql } from 'ponder';
import { db } from "ponder:api"; import { db } from 'ponder:api';
import schema from "ponder:schema"; import schema from 'ponder:schema';
const app = new Hono(); const app = new Hono();
const allowedOrigins = process.env.PONDER_CORS_ORIGINS?.split(",").map((origin) => origin.trim()).filter(Boolean); const allowedOrigins = process.env.PONDER_CORS_ORIGINS?.split(',')
.map(origin => origin.trim())
.filter(Boolean);
app.use("/*", cors({ app.use(
origin: allowedOrigins?.length ? allowedOrigins : "*", '/*',
allowMethods: ["GET", "POST", "OPTIONS"], cors({
allowHeaders: ["Content-Type", "Apollo-Require-Preflight"], origin: allowedOrigins?.length ? allowedOrigins : '*',
})); allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Apollo-Require-Preflight'],
})
);
// SQL endpoint // SQL endpoint
app.use("/sql/*", client({ db, schema })); app.use('/sql/*', client({ db, schema }));
// GraphQL endpoints // GraphQL endpoints
app.use("/graphql", graphql({ db, schema })); app.use('/graphql', graphql({ db, schema }));
app.use("/", graphql({ db, schema })); app.use('/', graphql({ db, schema }));
export default app; export default app;

View file

@ -1,4 +1,4 @@
import type { Abi } from 'viem' import type { Abi } from 'viem';
/** /**
* Helper function to ensure an imported ABI matches the viem Abi type at compile time * Helper function to ensure an imported ABI matches the viem Abi type at compile time
@ -6,7 +6,7 @@ import type { Abi } from 'viem'
* @returns The validated ABI * @returns The validated ABI
*/ */
export function validateAbi<T extends Abi>(abi: T): T { export function validateAbi<T extends Abi>(abi: T): T {
return abi return abi;
} }
/** /**
@ -15,5 +15,5 @@ export function validateAbi<T extends Abi>(abi: T): T {
* @returns The validated contract ABI * @returns The validated contract ABI
*/ */
export function validateContractAbi<T extends { abi: Abi }>(contract: T): T['abi'] { export function validateContractAbi<T extends { abi: Abi }>(contract: T): T['abi'] {
return contract.abi return contract.abi;
} }

View file

@ -1,4 +1,9 @@
import { stats, STATS_ID, HOURS_IN_RING_BUFFER, SECONDS_IN_HOUR } from "ponder:schema"; import { stats, STATS_ID, HOURS_IN_RING_BUFFER, SECONDS_IN_HOUR } from 'ponder:schema';
type Handler = Parameters<(typeof import('ponder:registry'))['ponder']['on']>[1];
type HandlerArgs = Handler extends (...args: infer Args) => unknown ? Args[0] : never;
export type StatsContext = HandlerArgs extends { context: infer C } ? C : never;
type StatsEvent = HandlerArgs extends { event: infer E } ? E : never;
export const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax export const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax
export const MINIMUM_BLOCKS_FOR_RINGBUFFER = 100; export const MINIMUM_BLOCKS_FOR_RINGBUFFER = 100;
@ -13,11 +18,11 @@ export function parseRingBuffer(raw?: string[] | null): bigint[] {
if (!raw || raw.length === 0) { if (!raw || raw.length === 0) {
return makeEmptyRingBuffer(); return makeEmptyRingBuffer();
} }
return raw.map((value) => BigInt(value)); return raw.map(value => BigInt(value));
} }
export function serializeRingBuffer(values: bigint[]): string[] { export function serializeRingBuffer(values: bigint[]): string[] {
return values.map((value) => value.toString()); return values.map(value => value.toString());
} }
function computeMetrics(ringBuffer: bigint[], pointer: number) { function computeMetrics(ringBuffer: bigint[], pointer: number) {
@ -62,12 +67,7 @@ function computeMetrics(ringBuffer: bigint[], pointer: number) {
}; };
} }
function computeProjections( function computeProjections(ringBuffer: bigint[], pointer: number, timestamp: bigint, metrics: ReturnType<typeof computeMetrics>) {
ringBuffer: bigint[],
pointer: number,
timestamp: bigint,
metrics: ReturnType<typeof computeMetrics>
) {
const startOfHour = (timestamp / BigInt(SECONDS_IN_HOUR)) * BigInt(SECONDS_IN_HOUR); const startOfHour = (timestamp / BigInt(SECONDS_IN_HOUR)) * BigInt(SECONDS_IN_HOUR);
const elapsedSeconds = timestamp - startOfHour; const elapsedSeconds = timestamp - startOfHour;
@ -96,13 +96,13 @@ function computeProjections(
}; };
} }
export function checkBlockHistorySufficient(context: any, event: any): boolean { export function checkBlockHistorySufficient(context: StatsContext, event: StatsEvent): boolean {
const currentBlock = event.block.number; const currentBlock = event.block.number;
const deployBlock = BigInt(context.network.contracts.Kraiken.startBlock); const deployBlock = BigInt(context.network.contracts.Kraiken.startBlock);
const blocksSinceDeployment = Number(currentBlock - deployBlock); const blocksSinceDeployment = Number(currentBlock - deployBlock);
if (blocksSinceDeployment < MINIMUM_BLOCKS_FOR_RINGBUFFER) { if (blocksSinceDeployment < MINIMUM_BLOCKS_FOR_RINGBUFFER) {
context.log.warn( context.logger.warn(
`Insufficient block history (only ${blocksSinceDeployment} blocks available, need ${MINIMUM_BLOCKS_FOR_RINGBUFFER})` `Insufficient block history (only ${blocksSinceDeployment} blocks available, need ${MINIMUM_BLOCKS_FOR_RINGBUFFER})`
); );
return false; return false;
@ -110,7 +110,7 @@ export function checkBlockHistorySufficient(context: any, event: any): boolean {
return true; return true;
} }
export async function ensureStatsExists(context: any, timestamp?: bigint) { export async function ensureStatsExists(context: StatsContext, timestamp?: bigint) {
let statsData = await context.db.find(stats, { id: STATS_ID }); let statsData = await context.db.find(stats, { id: STATS_ID });
if (!statsData) { if (!statsData) {
const { client, contracts } = context; const { client, contracts } = context;
@ -118,7 +118,7 @@ export async function ensureStatsExists(context: any, timestamp?: bigint) {
try { try {
return await fn(); return await fn();
} catch (error) { } catch (error) {
console.warn(`[stats.ensureStatsExists] Falling back for ${label}`, error); context.logger.warn(`[stats.ensureStatsExists] Falling back for ${label}`, error);
return fallback; return fallback;
} }
}; };
@ -129,38 +129,36 @@ export async function ensureStatsExists(context: any, timestamp?: bigint) {
client.readContract({ client.readContract({
abi: contracts.Kraiken.abi, abi: contracts.Kraiken.abi,
address: contracts.Kraiken.address, address: contracts.Kraiken.address,
functionName: "totalSupply", functionName: 'totalSupply',
}), }),
0n, 0n,
"Kraiken.totalSupply", 'Kraiken.totalSupply'
), ),
readWithFallback( readWithFallback(
() => () =>
client.readContract({ client.readContract({
abi: contracts.Stake.abi, abi: contracts.Stake.abi,
address: contracts.Stake.address, address: contracts.Stake.address,
functionName: "totalSupply", functionName: 'totalSupply',
}), }),
0n, 0n,
"Stake.totalSupply", 'Stake.totalSupply'
), ),
readWithFallback( readWithFallback(
() => () =>
client.readContract({ client.readContract({
abi: contracts.Stake.abi, abi: contracts.Stake.abi,
address: contracts.Stake.address, address: contracts.Stake.address,
functionName: "outstandingStake", functionName: 'outstandingStake',
}), }),
0n, 0n,
"Stake.outstandingStake", 'Stake.outstandingStake'
), ),
]); ]);
cachedStakeTotalSupply = stakeTotalSupply; cachedStakeTotalSupply = stakeTotalSupply;
const currentHour = timestamp const currentHour = timestamp ? (timestamp / BigInt(SECONDS_IN_HOUR)) * BigInt(SECONDS_IN_HOUR) : 0n;
? (timestamp / BigInt(SECONDS_IN_HOUR)) * BigInt(SECONDS_IN_HOUR)
: 0n;
await context.db.insert(stats).values({ await context.db.insert(stats).values({
id: STATS_ID, id: STATS_ID,
@ -178,7 +176,7 @@ export async function ensureStatsExists(context: any, timestamp?: bigint) {
return statsData; return statsData;
} }
export async function updateHourlyData(context: any, timestamp: bigint) { export async function updateHourlyData(context: StatsContext, timestamp: bigint) {
const statsData = await context.db.find(stats, { id: STATS_ID }); const statsData = await context.db.find(stats, { id: STATS_ID });
if (!statsData) return; if (!statsData) return;
@ -197,9 +195,7 @@ export async function updateHourlyData(context: any, timestamp: bigint) {
if (currentHour > lastUpdate) { if (currentHour > lastUpdate) {
const hoursElapsedBig = (currentHour - lastUpdate) / BigInt(SECONDS_IN_HOUR); const hoursElapsedBig = (currentHour - lastUpdate) / BigInt(SECONDS_IN_HOUR);
const hoursElapsed = Number(hoursElapsedBig > BigInt(HOURS_IN_RING_BUFFER) const hoursElapsed = Number(hoursElapsedBig > BigInt(HOURS_IN_RING_BUFFER) ? BigInt(HOURS_IN_RING_BUFFER) : hoursElapsedBig);
? BigInt(HOURS_IN_RING_BUFFER)
: hoursElapsedBig);
for (let h = 0; h < hoursElapsed; h++) { for (let h = 0; h < hoursElapsed; h++) {
pointer = (pointer + 1) % HOURS_IN_RING_BUFFER; pointer = (pointer + 1) % HOURS_IN_RING_BUFFER;
@ -251,7 +247,7 @@ export async function updateHourlyData(context: any, timestamp: bigint) {
} }
} }
export async function getStakeTotalSupply(context: any): Promise<bigint> { export async function getStakeTotalSupply(context: StatsContext): Promise<bigint> {
await ensureStatsExists(context); await ensureStatsExists(context);
if (cachedStakeTotalSupply !== null) { if (cachedStakeTotalSupply !== null) {
@ -261,7 +257,7 @@ export async function getStakeTotalSupply(context: any): Promise<bigint> {
const totalSupply = await context.client.readContract({ const totalSupply = await context.client.readContract({
abi: context.contracts.Stake.abi, abi: context.contracts.Stake.abi,
address: context.contracts.Stake.address, address: context.contracts.Stake.address,
functionName: "totalSupply", functionName: 'totalSupply',
}); });
cachedStakeTotalSupply = totalSupply; cachedStakeTotalSupply = totalSupply;
await context.db.update(stats, { id: STATS_ID }).set({ await context.db.update(stats, { id: STATS_ID }).set({
@ -270,12 +266,12 @@ export async function getStakeTotalSupply(context: any): Promise<bigint> {
return totalSupply; return totalSupply;
} }
export async function refreshOutstandingStake(context: any) { export async function refreshOutstandingStake(context: StatsContext) {
await ensureStatsExists(context); await ensureStatsExists(context);
const outstandingStake = await context.client.readContract({ const outstandingStake = await context.client.readContract({
abi: context.contracts.Stake.abi, abi: context.contracts.Stake.abi,
address: context.contracts.Stake.address, address: context.contracts.Stake.address,
functionName: "outstandingStake", functionName: 'outstandingStake',
}); });
await context.db.update(stats, { id: STATS_ID }).set({ await context.db.update(stats, { id: STATS_ID }).set({

View file

@ -1,6 +1,6 @@
// Import all event handlers // Import all event handlers
import "./kraiken"; import './kraiken';
import "./stake"; import './stake';
// This file serves as the entry point for all indexing functions // This file serves as the entry point for all indexing functions
// Ponder will automatically register all event handlers from imported files // Ponder will automatically register all event handlers from imported files

View file

@ -1,5 +1,5 @@
import { ponder } from "ponder:registry"; import { ponder } from 'ponder:registry';
import { stats, STATS_ID } from "ponder:schema"; import { stats, STATS_ID } from 'ponder:schema';
import { import {
ensureStatsExists, ensureStatsExists,
parseRingBuffer, parseRingBuffer,
@ -7,11 +7,11 @@ import {
updateHourlyData, updateHourlyData,
checkBlockHistorySufficient, checkBlockHistorySufficient,
RING_BUFFER_SEGMENTS, RING_BUFFER_SEGMENTS,
} from "./helpers/stats"; } from './helpers/stats';
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as const;
ponder.on("Kraiken:Transfer", async ({ event, context }) => { ponder.on('Kraiken:Transfer', async ({ event, context }) => {
const { from, to, value } = event.args; const { from, to, value } = event.args;
await ensureStatsExists(context, event.block.timestamp); await ensureStatsExists(context, event.block.timestamp);
@ -69,7 +69,7 @@ ponder.on("Kraiken:Transfer", async ({ event, context }) => {
await updateHourlyData(context, event.block.timestamp); await updateHourlyData(context, event.block.timestamp);
}); });
ponder.on("StatsBlock:block", async ({ event, context }) => { ponder.on('StatsBlock:block', async ({ event, context }) => {
await ensureStatsExists(context, event.block.timestamp); await ensureStatsExists(context, event.block.timestamp);
// Only update hourly data if we have sufficient block history // Only update hourly data if we have sufficient block history

View file

@ -1,5 +1,5 @@
import { ponder } from "ponder:registry"; import { ponder } from 'ponder:registry';
import { positions, stats, STATS_ID, TAX_RATES } from "ponder:schema"; import { positions, stats, STATS_ID, TAX_RATES } from 'ponder:schema';
import { import {
ensureStatsExists, ensureStatsExists,
getStakeTotalSupply, getStakeTotalSupply,
@ -9,15 +9,16 @@ import {
updateHourlyData, updateHourlyData,
checkBlockHistorySufficient, checkBlockHistorySufficient,
RING_BUFFER_SEGMENTS, RING_BUFFER_SEGMENTS,
} from "./helpers/stats"; } from './helpers/stats';
import type { StatsContext } from './helpers/stats';
const ZERO = 0n; const ZERO = 0n;
async function getKraikenTotalSupply(context: Context) { async function getKraikenTotalSupply(context: StatsContext) {
return context.client.readContract({ return context.client.readContract({
abi: context.contracts.Kraiken.abi, abi: context.contracts.Kraiken.abi,
address: context.contracts.Kraiken.address, address: context.contracts.Kraiken.address,
functionName: "totalSupply", functionName: 'totalSupply',
}); });
} }
@ -26,7 +27,7 @@ function toShareRatio(share: bigint, stakeTotalSupply: bigint): number {
return Number(share) / Number(stakeTotalSupply); return Number(share) / Number(stakeTotalSupply);
} }
ponder.on("Stake:PositionCreated", async ({ event, context }) => { ponder.on('Stake:PositionCreated', async ({ event, context }) => {
await ensureStatsExists(context, event.block.timestamp); await ensureStatsExists(context, event.block.timestamp);
const stakeTotalSupply = await getStakeTotalSupply(context); const stakeTotalSupply = await getStakeTotalSupply(context);
@ -44,7 +45,7 @@ ponder.on("Stake:PositionCreated", async ({ event, context }) => {
snatched: 0, snatched: 0,
creationTime: event.block.timestamp, creationTime: event.block.timestamp,
lastTaxTime: event.block.timestamp, lastTaxTime: event.block.timestamp,
status: "Active", status: 'Active',
createdAt: event.block.timestamp, createdAt: event.block.timestamp,
totalSupplyInit, totalSupplyInit,
totalSupplyEnd: null, totalSupplyEnd: null,
@ -54,7 +55,7 @@ ponder.on("Stake:PositionCreated", async ({ event, context }) => {
await refreshOutstandingStake(context); await refreshOutstandingStake(context);
}); });
ponder.on("Stake:PositionRemoved", async ({ event, context }) => { ponder.on('Stake:PositionRemoved', async ({ event, context }) => {
await ensureStatsExists(context, event.block.timestamp); await ensureStatsExists(context, event.block.timestamp);
const positionId = event.args.positionId.toString(); const positionId = event.args.positionId.toString();
@ -64,7 +65,7 @@ ponder.on("Stake:PositionRemoved", async ({ event, context }) => {
const totalSupplyEnd = await getKraikenTotalSupply(context); const totalSupplyEnd = await getKraikenTotalSupply(context);
await context.db.update(positions, { id: positionId }).set({ await context.db.update(positions, { id: positionId }).set({
status: "Closed", status: 'Closed',
closedAt: event.block.timestamp, closedAt: event.block.timestamp,
totalSupplyEnd, totalSupplyEnd,
payout: (position.payout ?? ZERO) + event.args.kraikenPayout, payout: (position.payout ?? ZERO) + event.args.kraikenPayout,
@ -79,7 +80,7 @@ ponder.on("Stake:PositionRemoved", async ({ event, context }) => {
} }
}); });
ponder.on("Stake:PositionShrunk", async ({ event, context }) => { ponder.on('Stake:PositionShrunk', async ({ event, context }) => {
await ensureStatsExists(context, event.block.timestamp); await ensureStatsExists(context, event.block.timestamp);
const positionId = event.args.positionId.toString(); const positionId = event.args.positionId.toString();
@ -104,7 +105,7 @@ ponder.on("Stake:PositionShrunk", async ({ event, context }) => {
} }
}); });
ponder.on("Stake:PositionTaxPaid", async ({ event, context }) => { ponder.on('Stake:PositionTaxPaid', async ({ event, context }) => {
await ensureStatsExists(context, event.block.timestamp); await ensureStatsExists(context, event.block.timestamp);
const positionId = event.args.positionId.toString(); const positionId = event.args.positionId.toString();
@ -151,7 +152,7 @@ ponder.on("Stake:PositionTaxPaid", async ({ event, context }) => {
await refreshOutstandingStake(context); await refreshOutstandingStake(context);
}); });
ponder.on("Stake:PositionRateHiked", async ({ event, context }) => { ponder.on('Stake:PositionRateHiked', async ({ event, context }) => {
const positionId = event.args.positionId.toString(); const positionId = event.args.positionId.toString();
await context.db.update(positions, { id: positionId }).set({ await context.db.update(positions, { id: positionId }).set({
taxRate: TAX_RATES[Number(event.args.newTaxRate)] || 0, taxRate: TAX_RATES[Number(event.args.newTaxRate)] || 0,