From d8119da65be9086c4384529724774be1c7b4e755 Mon Sep 17 00:00:00 2001 From: johba Date: Sat, 11 Oct 2025 17:20:30 +0000 Subject: [PATCH] feat: surface stack versions in app footer --- kraiken-lib/src/index.ts | 8 +- kraiken-lib/src/version.ts | 5 + services/ponder/generated/schema.graphql | 67 ++++++- services/ponder/ponder.schema.ts | 20 +++ services/ponder/src/helpers/version.ts | 25 ++- tests/e2e/01-acquire-and-stake.spec.ts | 11 ++ web-app/env.d.ts | 2 + .../components/layouts/StackVersionFooter.vue | 124 +++++++++++++ web-app/src/composables/useVersionCheck.ts | 163 ++++++++++++++---- web-app/src/layouts/DefaultLayout.vue | 23 ++- web-app/src/layouts/NavbarLayout.vue | 15 +- web-app/src/utils/logger.ts | 61 +++++-- web-app/vite.config.ts | 5 + 13 files changed, 480 insertions(+), 49 deletions(-) create mode 100644 web-app/src/components/layouts/StackVersionFooter.vue diff --git a/kraiken-lib/src/index.ts b/kraiken-lib/src/index.ts index 9f32e74..0e486fb 100644 --- a/kraiken-lib/src/index.ts +++ b/kraiken-lib/src/index.ts @@ -23,4 +23,10 @@ export { KRAIKEN_ABI, STAKE_ABI, ABIS } from './abis.js'; // Backward compatible aliases export { KRAIKEN_ABI as KraikenAbi, STAKE_ABI as StakeAbi } from './abis.js'; -export { KRAIKEN_LIB_VERSION, COMPATIBLE_CONTRACT_VERSIONS, isCompatibleVersion, getVersionMismatchError } from './version.js'; +export { + KRAIKEN_LIB_VERSION, + COMPATIBLE_CONTRACT_VERSIONS, + STACK_META_ID, + isCompatibleVersion, + getVersionMismatchError, +} from './version.js'; diff --git a/kraiken-lib/src/version.ts b/kraiken-lib/src/version.ts index 7d4b843..9d88a5f 100644 --- a/kraiken-lib/src/version.ts +++ b/kraiken-lib/src/version.ts @@ -11,6 +11,11 @@ */ export const KRAIKEN_LIB_VERSION = 1; +/** + * Singleton ID used for stack metadata rows across services. + */ +export const STACK_META_ID = 'stack-meta'; + /** * List of Kraiken contract versions this library is compatible with. * diff --git a/services/ponder/generated/schema.graphql b/services/ponder/generated/schema.graphql index 78678b8..2760729 100644 --- a/services/ponder/generated/schema.graphql +++ b/services/ponder/generated/schema.graphql @@ -17,6 +17,8 @@ type Meta { } type Query { + stackMeta(id: String!): stackMeta + stackMetas(where: stackMetaFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): stackMetaPage! stats(id: String!): stats statss(where: statsFilter, orderBy: String, orderDirection: String, before: String, after: String, limit: Int): statsPage! positions(id: String!): positions @@ -24,6 +26,69 @@ type Query { _meta: Meta } +type stackMeta { + id: String! + contractVersion: Int! + ponderVersion: String! + kraikenLibVersion: Int! + updatedAt: BigInt! +} + +type stackMetaPage { + items: [stackMeta!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input stackMetaFilter { + AND: [stackMetaFilter] + OR: [stackMetaFilter] + id: String + id_not: String + id_in: [String] + id_not_in: [String] + id_contains: String + id_not_contains: String + id_starts_with: String + id_ends_with: String + id_not_starts_with: String + id_not_ends_with: String + contractVersion: Int + contractVersion_not: Int + contractVersion_in: [Int] + contractVersion_not_in: [Int] + contractVersion_gt: Int + contractVersion_lt: Int + contractVersion_gte: Int + contractVersion_lte: Int + ponderVersion: String + ponderVersion_not: String + ponderVersion_in: [String] + ponderVersion_not_in: [String] + ponderVersion_contains: String + ponderVersion_not_contains: String + ponderVersion_starts_with: String + ponderVersion_ends_with: String + ponderVersion_not_starts_with: String + ponderVersion_not_ends_with: String + kraikenLibVersion: Int + kraikenLibVersion_not: Int + kraikenLibVersion_in: [Int] + kraikenLibVersion_not_in: [Int] + kraikenLibVersion_gt: Int + kraikenLibVersion_lt: Int + kraikenLibVersion_gte: Int + kraikenLibVersion_lte: Int + updatedAt: BigInt + updatedAt_not: BigInt + updatedAt_in: [BigInt] + updatedAt_not_in: [BigInt] + updatedAt_gt: BigInt + updatedAt_lt: BigInt + updatedAt_gte: BigInt + updatedAt_lte: BigInt +} + type stats { id: String! kraikenTotalSupply: BigInt! @@ -428,4 +493,4 @@ input positionsFilter { payout_lt: BigInt payout_gte: BigInt payout_lte: BigInt -} +} \ No newline at end of file diff --git a/services/ponder/ponder.schema.ts b/services/ponder/ponder.schema.ts index 8f59755..380ed1f 100644 --- a/services/ponder/ponder.schema.ts +++ b/services/ponder/ponder.schema.ts @@ -4,6 +4,26 @@ import { TAX_RATE_OPTIONS } from 'kraiken-lib/taxRates'; export const HOURS_IN_RING_BUFFER = 168; // 7 days * 24 hours const RING_BUFFER_SEGMENTS = 4; // ubi, minted, burned, tax +export const stackMeta = onchainTable('stackMeta', t => ({ + id: t.text().primaryKey(), + contractVersion: t + .integer() + .notNull() + .$default(() => 0), + ponderVersion: t + .text() + .notNull() + .$default(() => 'unknown'), + kraikenLibVersion: t + .integer() + .notNull() + .$default(() => 0), + updatedAt: t + .bigint() + .notNull() + .$default(() => 0n), +})); + // Global protocol stats - singleton with id "0x01" export const stats = onchainTable('stats', t => ({ id: t.text().primaryKey(), // Always "0x01" diff --git a/services/ponder/src/helpers/version.ts b/services/ponder/src/helpers/version.ts index f4a4516..b2dc991 100644 --- a/services/ponder/src/helpers/version.ts +++ b/services/ponder/src/helpers/version.ts @@ -1,6 +1,11 @@ -import { isCompatibleVersion, getVersionMismatchError, KRAIKEN_LIB_VERSION } from 'kraiken-lib/version'; +import { createRequire } from 'node:module'; +import { isCompatibleVersion, getVersionMismatchError, KRAIKEN_LIB_VERSION, STACK_META_ID } from 'kraiken-lib/version'; +import { stackMeta } from 'ponder:schema'; import type { Context } from 'ponder:registry'; +const require = createRequire(import.meta.url); +const { version: PONDER_APP_VERSION } = require('../../package.json') as { version: string }; + /** * Validates that the deployed Kraiken contract version is compatible * with this indexer's kraiken-lib version. @@ -25,6 +30,24 @@ export async function validateContractVersion(context: Context): Promise { process.exit(1); } + const timestamp = BigInt(Math.floor(Date.now() / 1000)); + const metaPayload = { + contractVersion: versionNumber, + ponderVersion: PONDER_APP_VERSION ?? 'dev', + kraikenLibVersion: KRAIKEN_LIB_VERSION, + updatedAt: timestamp, + }; + + const existingMeta = await context.db.find(stackMeta, { id: STACK_META_ID }); + if (existingMeta) { + await context.db.update(stackMeta, { id: STACK_META_ID }).set(metaPayload); + } else { + await context.db.insert(stackMeta).values({ + id: STACK_META_ID, + ...metaPayload, + }); + } + logger.info(`✓ Contract version validated: v${versionNumber} (kraiken-lib v${KRAIKEN_LIB_VERSION})`); } catch (error) { logger.error('Failed to read contract VERSION:', error); diff --git a/tests/e2e/01-acquire-and-stake.spec.ts b/tests/e2e/01-acquire-and-stake.spec.ts index 61e8f4b..8d63db4 100644 --- a/tests/e2e/01-acquire-and-stake.spec.ts +++ b/tests/e2e/01-acquire-and-stake.spec.ts @@ -78,6 +78,17 @@ test.describe('Acquire & Stake', () => { await expect(walletDisplay).toBeVisible({ timeout: 15_000 }); console.log('[TEST] Wallet connected successfully!'); + console.log('[TEST] Verifying stack version footer...'); + const versionFooter = page.getByTestId('stack-version-footer'); + await expect(versionFooter).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('stack-version-contracts')).not.toHaveText(/Loading|—/i); + await expect(page.getByTestId('stack-version-ponder')).not.toHaveText(/Loading|—/i); + await expect(page.getByTestId('stack-version-kraiken-lib')).toHaveText(/^v\d+/i); + await expect(page.getByTestId('stack-version-web-app')).toHaveText(/^v/i); + const warningBanner = page.getByTestId('stack-version-warning'); + await expect(warningBanner).toHaveCount(0); + console.log('[TEST] Stack version footer verified.'); + console.log('[TEST] Navigating to cheats page...'); await page.goto(`${STACK_WEBAPP_URL}/app/#/cheats`); await expect(page.getByRole('heading', { name: 'Cheat Console' })).toBeVisible(); diff --git a/web-app/env.d.ts b/web-app/env.d.ts index 5a8efba..1a00442 100644 --- a/web-app/env.d.ts +++ b/web-app/env.d.ts @@ -8,4 +8,6 @@ declare global { } } +declare const __APP_VERSION__: string; + export {}; diff --git a/web-app/src/components/layouts/StackVersionFooter.vue b/web-app/src/components/layouts/StackVersionFooter.vue new file mode 100644 index 0000000..f6842ee --- /dev/null +++ b/web-app/src/components/layouts/StackVersionFooter.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/web-app/src/composables/useVersionCheck.ts b/web-app/src/composables/useVersionCheck.ts index 1ee56f3..0810e20 100644 --- a/web-app/src/composables/useVersionCheck.ts +++ b/web-app/src/composables/useVersionCheck.ts @@ -1,57 +1,162 @@ import { ref, onMounted } from 'vue'; -import { KRAIKEN_LIB_VERSION } from 'kraiken-lib/version'; +import { DEFAULT_CHAIN_ID } from '@/config'; +import { resolveGraphqlEndpoint } from '@/utils/graphqlRetry'; +import { KRAIKEN_LIB_VERSION, STACK_META_ID } from 'kraiken-lib/version'; + +const WEB_APP_VERSION = __APP_VERSION__; + +type GraphqlError = { message?: string | null }; +type GraphqlResponse = { + data?: T; + errors?: GraphqlError[]; +}; + +interface StackMetaRecord { + contractVersion?: number | null; + ponderVersion?: string | null; + kraikenLibVersion?: number | null; + updatedAt?: string | null; +} + +interface StackMetaResult { + stackMeta?: StackMetaRecord | null; +} + +export interface StackVersions { + contractVersion: number | null; + ponderVersion: string | null; + indexerLibVersion: number | null; + kraikenLibVersion: number; + webAppVersion: string; + updatedAt: bigint | null; +} export interface VersionStatus { isValid: boolean; error?: string; contractVersion?: number; - indexerVersion?: number; + indexerLibVersion?: number; + ponderVersion?: string | null; libVersion: number; + webAppVersion: string; } +const STACK_META_QUERY = ` + query StackMeta($id: String!) { + stackMeta(id: $id) { + contractVersion + ponderVersion + kraikenLibVersion + updatedAt + } + } +`; + const versionStatus = ref({ isValid: true, libVersion: KRAIKEN_LIB_VERSION, + webAppVersion: WEB_APP_VERSION, +}); + +const stackVersions = ref({ + contractVersion: null, + ponderVersion: null, + indexerLibVersion: null, + kraikenLibVersion: KRAIKEN_LIB_VERSION, + webAppVersion: WEB_APP_VERSION, + updatedAt: null, }); const isChecking = ref(false); const hasChecked = ref(false); -/** - * Validates version compatibility between contract, indexer (Ponder), and frontend (kraiken-lib). - * - * Queries Ponder GraphQL for the contract version it indexed, then compares: - * 1. Frontend lib version vs Ponder's lib version (should match exactly) - * 2. Contract version vs compatible versions list (should be in COMPATIBLE_CONTRACT_VERSIONS) - */ +function formatError(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === 'string' && error.length > 0) { + return error; + } + return 'Failed to load stack version metadata'; +} + +function sanitizeNumber(value: unknown): number | null { + const parsed = typeof value === 'number' ? value : Number(value ?? Number.NaN); + return Number.isFinite(parsed) ? parsed : null; +} + export function useVersionCheck() { - async function checkVersions(_graphqlUrl: string) { - if (isChecking.value || hasChecked.value) return versionStatus.value; + async function checkVersions(graphqlEndpointOverride?: string) { + if (isChecking.value) return versionStatus.value; isChecking.value = true; try { - // For now, we don't have contract version in Ponder stats yet - // This is a placeholder for when we add it to the schema - // Just validate that kraiken-lib is loaded correctly + const endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID, graphqlEndpointOverride); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + query: STACK_META_QUERY, + variables: { id: STACK_META_ID }, + }), + }); - versionStatus.value = { - isValid: true, - libVersion: KRAIKEN_LIB_VERSION, + if (!response.ok) { + throw new Error(`GraphQL stackMeta request failed (HTTP ${response.status})`); + } + + const payload = (await response.json()) as GraphqlResponse; + + const apiErrors = payload.errors?.filter(err => err?.message); + if (apiErrors && apiErrors.length > 0) { + throw new Error(apiErrors.map(err => err.message).join(', ')); + } + + const meta = payload.data?.stackMeta; + if (!meta) { + throw new Error('Stack metadata unavailable (stackMeta query returned null)'); + } + + const contractVersion = sanitizeNumber(meta.contractVersion); + const ponderVersion = meta.ponderVersion ?? null; + const indexerLibVersion = sanitizeNumber(meta.kraikenLibVersion); + const updatedAt = meta.updatedAt ? BigInt(meta.updatedAt) : null; + + const libMismatch = indexerLibVersion !== null && indexerLibVersion !== KRAIKEN_LIB_VERSION; + + stackVersions.value = { + contractVersion, + ponderVersion, + indexerLibVersion, + kraikenLibVersion: KRAIKEN_LIB_VERSION, + webAppVersion: WEB_APP_VERSION, + updatedAt, }; - // TODO: Implement actual version check against Ponder GraphQL - // console.log(`✓ Frontend version check passed: v${KRAIKEN_LIB_VERSION}`); - hasChecked.value = true; + versionStatus.value = { + isValid: !libMismatch, + error: libMismatch + ? `Stack mismatch: Ponder reports kraiken-lib v${indexerLibVersion} (frontend expects v${KRAIKEN_LIB_VERSION}). Restart the stack to rebuild containers.` + : undefined, + contractVersion: contractVersion ?? undefined, + indexerLibVersion: indexerLibVersion ?? undefined, + ponderVersion, + libVersion: KRAIKEN_LIB_VERSION, + webAppVersion: WEB_APP_VERSION, + }; } catch (error) { - // TODO: Add proper error logging - // console.error('Version check failed:', error); versionStatus.value = { isValid: false, - error: error instanceof Error ? error.message : 'Failed to check version compatibility', + error: formatError(error), + contractVersion: stackVersions.value.contractVersion ?? undefined, + indexerLibVersion: stackVersions.value.indexerLibVersion ?? undefined, + ponderVersion: stackVersions.value.ponderVersion, libVersion: KRAIKEN_LIB_VERSION, + webAppVersion: WEB_APP_VERSION, }; } finally { + hasChecked.value = true; isChecking.value = false; } @@ -60,18 +165,15 @@ export function useVersionCheck() { return { versionStatus, + stackVersions, checkVersions, isChecking, hasChecked, }; } -/** - * Vue composable that automatically checks versions on mount. - * Shows a warning banner if versions are incompatible. - */ -export function useVersionCheckOnMount(graphqlUrl: string) { - const { versionStatus, checkVersions, isChecking } = useVersionCheck(); +export function useVersionCheckOnMount(graphqlUrl?: string) { + const { versionStatus, stackVersions, checkVersions, isChecking, hasChecked } = useVersionCheck(); onMounted(async () => { await checkVersions(graphqlUrl); @@ -79,6 +181,9 @@ export function useVersionCheckOnMount(graphqlUrl: string) { return { versionStatus, + stackVersions, isChecking, + hasChecked, + checkVersions, }; } diff --git a/web-app/src/layouts/DefaultLayout.vue b/web-app/src/layouts/DefaultLayout.vue index cf44a22..ec22f55 100644 --- a/web-app/src/layouts/DefaultLayout.vue +++ b/web-app/src/layouts/DefaultLayout.vue @@ -1,5 +1,22 @@ + + + + diff --git a/web-app/src/layouts/NavbarLayout.vue b/web-app/src/layouts/NavbarLayout.vue index d71ad52..8395fc8 100644 --- a/web-app/src/layouts/NavbarLayout.vue +++ b/web-app/src/layouts/NavbarLayout.vue @@ -9,10 +9,8 @@
+ -
@@ -33,6 +31,7 @@ import NavbarHeader from '@/components/layouts/NavbarHeader.vue'; import SlideoutPanel from '@/components/layouts/SlideoutPanel.vue'; import ConnectWallet from '@/components/layouts/ConnectWallet.vue'; +import StackVersionFooter from '@/components/layouts/StackVersionFooter.vue'; import { useMobile } from '@/composables/useMobile'; import IconHome from '@/components/icons/IconHome.vue'; import IconDocs from '@/components/icons/IconDocs.vue'; @@ -54,6 +53,16 @@ function openDocs() {