feat: surface stack versions in app footer

This commit is contained in:
johba 2025-10-11 17:20:30 +00:00
parent beea5f67f9
commit d8119da65b
13 changed files with 480 additions and 49 deletions

View file

@ -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';

View file

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

View file

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

View file

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

View file

@ -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<void> {
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);

View file

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

2
web-app/env.d.ts vendored
View file

@ -8,4 +8,6 @@ declare global {
}
}
declare const __APP_VERSION__: string;
export {};

View file

@ -0,0 +1,124 @@
<template>
<footer class="stack-version-footer" data-testid="stack-version-footer">
<div class="stack-version-footer__items">
<div class="stack-version-footer__item" v-for="item in footerItems" :key="item.key">
<span class="stack-version-footer__label">{{ item.label }}</span>
<span class="stack-version-footer__value" :class="valueClass(item.key)" :data-testid="`stack-version-${item.key}`">
{{ item.value }}
</span>
</div>
</div>
<p v-if="footerMessage" class="stack-version-footer__warning" data-testid="stack-version-warning">
{{ footerMessage }}
</p>
</footer>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { DEFAULT_CHAIN_ID } from '@/config';
import { resolveGraphqlEndpoint } from '@/utils/graphqlRetry';
import { useVersionCheckOnMount } from '@/composables/useVersionCheck';
import logger from '@/utils/logger';
const endpointHint = ref<string | null>(null);
let endpoint: string | undefined;
try {
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
logger.info('Stack version footer resolved GraphQL endpoint', endpoint);
} catch (error) {
endpoint = undefined;
endpointHint.value = 'GraphQL endpoint unavailable ensure the stack is running.';
logger.error('Failed to resolve GraphQL endpoint for stack version footer', error);
}
const { versionStatus, stackVersions, isChecking, hasChecked } = useVersionCheckOnMount(endpoint);
const footerItems = computed(() => {
const loading = isChecking.value && !hasChecked.value;
const loadingText = loading ? 'Loading…' : '—';
return [
{
key: 'contracts',
label: 'Contracts',
value: stackVersions.value.contractVersion !== null ? `v${stackVersions.value.contractVersion}` : loadingText,
},
{
key: 'ponder',
label: 'Ponder',
value: stackVersions.value.ponderVersion ? `v${stackVersions.value.ponderVersion}` : loadingText,
},
{
key: 'kraiken-lib',
label: 'kraiken-lib',
value: `v${stackVersions.value.kraikenLibVersion}`,
},
{
key: 'web-app',
label: 'Web App',
value: `v${stackVersions.value.webAppVersion}`,
},
] as const;
});
const footerMessage = computed(() => {
if (!versionStatus.value.isValid) {
return versionStatus.value.error ?? 'Stack version mismatch detected.';
}
return endpointHint.value;
});
function valueClass(key: (typeof footerItems.value)[number]['key']) {
if (key === 'kraiken-lib') {
return versionStatus.value.isValid ? null : 'stack-version-footer__value--warning';
}
return null;
}
</script>
<style lang="sass" scoped>
.stack-version-footer
padding: 12px 24px
background: rgba(7, 17, 27, 0.92)
color: #cbd5f5
border-top: 1px solid rgba(255, 255, 255, 0.08)
font-size: 12px
letter-spacing: 0.04em
display: flex
flex-direction: column
gap: 8px
align-items: center
width: 100%
.stack-version-footer__items
display: flex
flex-wrap: wrap
justify-content: center
gap: 16px
.stack-version-footer__item
display: flex
gap: 6px
align-items: baseline
.stack-version-footer__label
text-transform: uppercase
font-weight: 600
font-size: 11px
color: #94a3b8
.stack-version-footer__value
font-family: 'Fira Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace
font-weight: 600
color: #e2e8f0
.stack-version-footer__value--warning
color: #f97316
.stack-version-footer__warning
margin: 0
color: #f97316
font-size: 11px
text-align: center
</style>

View file

@ -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<T> = {
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<VersionStatus>({
isValid: true,
libVersion: KRAIKEN_LIB_VERSION,
webAppVersion: WEB_APP_VERSION,
});
const stackVersions = ref<StackVersions>({
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<StackMetaResult>;
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,
};
}

View file

@ -1,5 +1,22 @@
<template>
<main>
<slot></slot>
</main>
<div class="default-layout">
<main>
<slot></slot>
</main>
<StackVersionFooter />
</div>
</template>
<script setup lang="ts">
import StackVersionFooter from '@/components/layouts/StackVersionFooter.vue';
</script>
<style lang="sass" scoped>
.default-layout
min-height: 100vh
display: flex
flex-direction: column
.default-layout > main
flex: 1 1 auto
</style>

View file

@ -9,10 +9,8 @@
<main>
<slot></slot>
</main>
<StackVersionFooter class="navbar-layout__footer" />
</div>
<!-- <footer>
<f-footer :dark="darkTheme"></f-footer>
</footer> -->
<div class="mobile-navigation-bar" v-if="isMobile">
<div class="mobile-navigation-tab" @click="router.push('/')">
<div class="mobile-navigation-tab__icon">
@ -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() {
<style lang="sass">
.navbar-layout
padding-top: 100px
padding-bottom: 120px
min-height: 100vh
display: flex
flex-direction: column
.navbar-layout > main
flex: 1 1 auto
.navbar-layout__footer
margin-top: auto
.mobile-navigation-bar
height: 72px

View file

@ -1,20 +1,59 @@
export function info(text: string, data: unknown = null) {
if (data) {
// console.log(`%c ${text}`, 'color: #17a2b8', data);
} else {
// console.log(`%c ${text}`, 'color: #17a2b8');
type LogLevel = 'info' | 'error' | 'contract';
interface LogEntry {
level: LogLevel;
message: string;
data?: unknown;
timestamp: string;
}
const isBrowser = typeof window !== 'undefined';
const MAX_BUFFER = 50;
const buffer: LogEntry[] = [];
function emit(entry: LogEntry) {
buffer.push(entry);
if (buffer.length > MAX_BUFFER) {
buffer.shift();
}
if (isBrowser && import.meta.env.DEV) {
window.dispatchEvent(
new CustomEvent('kraiken:log', {
detail: entry,
})
);
}
}
export function contract(text: string, data: unknown = null) {
if (data) {
// console.log(`%c ${text}`, 'color: #8732a8', data);
} else {
// console.log(`%c ${text}`, 'color: #8732a8');
}
function buildEntry(level: LogLevel, message: string, data?: unknown): LogEntry {
return {
level,
message,
data,
timestamp: new Date().toISOString(),
};
}
export function info(message: string, data?: unknown) {
emit(buildEntry('info', message, data));
}
export function contract(message: string, data?: unknown) {
emit(buildEntry('contract', message, data));
}
export function error(message: string, data?: unknown) {
emit(buildEntry('error', message, data));
}
export function getBufferedLogs(): LogEntry[] {
return [...buffer];
}
export default {
info,
contract,
error,
getBufferedLogs,
};

View file

@ -3,12 +3,14 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import packageJson from './package.json' assert { type: 'json' }
// https://vite.dev/config/
export default defineConfig(() => {
const localRpcProxyTarget = process.env.VITE_LOCAL_RPC_PROXY_TARGET
const localGraphqlProxyTarget = process.env.VITE_LOCAL_GRAPHQL_PROXY_TARGET ?? 'http://127.0.0.1:42069'
const localTxnProxyTarget = process.env.VITE_LOCAL_TXN_PROXY_TARGET ?? 'http://127.0.0.1:43069'
const appVersion = (packageJson as { version?: string }).version ?? 'dev'
return {
// base: "/HarbergPublic/",
@ -16,6 +18,9 @@ export default defineConfig(() => {
vue(),
vueDevTools(),
],
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),