diff --git a/dashboard/app/lib/oracle-staleness.test.ts b/dashboard/app/lib/oracle-staleness.test.ts new file mode 100644 index 0000000..5696f04 --- /dev/null +++ b/dashboard/app/lib/oracle-staleness.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { calculateOracleStaleness } from './oracle-staleness.ts'; +import type { HistoryEntry } from './contract.ts'; + +describe('calculateOracleStaleness', () => { + it('returns zeros for empty history', () => { + const summary = calculateOracleStaleness([]); + assert.deepEqual(summary, { + assessedRuns: 0, + staleRuns: 0, + stalePercentage: 0, + totalRuns: 0, + }); + }); + + it('correctly calculates staleness across ok runs', () => { + const history: HistoryEntry[] = [ + { + status: 'ok', + safetyScore: 80, + methodologyVersion: 2, + computedAt: '2026-08-16T12:00:00Z', + runAt: '2026-08-16T12:00:00Z', + factors: { + oracleSafety: { + value: 100, + weight: 0.25, + detail: 'fresh', + components: [ + { id: 'priceFreshness', label: 'Freshness', value: 100, detail: 'current' }, + ], + }, + }, + }, + { + status: 'ok', + safetyScore: 40, + methodologyVersion: 2, + computedAt: '2026-08-16T11:00:00Z', + runAt: '2026-08-16T11:00:00Z', + factors: { + oracleSafety: { + value: 20, + weight: 0.25, + detail: 'stale prices', + components: [{ id: 'priceFreshness', label: 'Freshness', value: 0, detail: 'stale' }], + }, + }, + }, + { + status: 'failed', + error: 'network error', + runAt: '2026-08-16T10:00:00Z', + }, + ]; + + const summary = calculateOracleStaleness(history); + assert.equal(summary.totalRuns, 3); + assert.equal(summary.assessedRuns, 2); + assert.equal(summary.staleRuns, 1); + assert.equal(summary.stalePercentage, 50); + }); + + it('correctly tracks high-staleness scenario (e.g. K2 93% stale runs)', () => { + const history: HistoryEntry[] = []; + // 93 stale runs + for (let i = 0; i < 93; i++) { + history.push({ + status: 'ok', + safetyScore: 30, + methodologyVersion: 2, + computedAt: new Date(1760000000000 + i * 300000).toISOString(), + runAt: new Date(1760000000000 + i * 300000).toISOString(), + factors: { + oracleSafety: { + value: 0, + weight: 0.25, + detail: 'dead prices', + components: [{ id: 'priceFreshness', label: 'Freshness', value: 0, detail: 'dead' }], + }, + }, + }); + } + // 7 fresh runs + for (let i = 0; i < 7; i++) { + history.push({ + status: 'ok', + safetyScore: 90, + methodologyVersion: 2, + computedAt: new Date(1760000000000 + (93 + i) * 300000).toISOString(), + runAt: new Date(1760000000000 + (93 + i) * 300000).toISOString(), + factors: { + oracleSafety: { + value: 100, + weight: 0.25, + detail: 'fresh prices', + components: [{ id: 'priceFreshness', label: 'Freshness', value: 100, detail: 'fresh' }], + }, + }, + }); + } + + const summary = calculateOracleStaleness(history); + assert.equal(summary.assessedRuns, 100); + assert.equal(summary.staleRuns, 93); + assert.equal(summary.stalePercentage, 93); + }); +}); diff --git a/dashboard/app/lib/oracle-staleness.ts b/dashboard/app/lib/oracle-staleness.ts new file mode 100644 index 0000000..4292e00 --- /dev/null +++ b/dashboard/app/lib/oracle-staleness.ts @@ -0,0 +1,56 @@ +import type { HistoryEntry, RiskFactorComponent } from './contract'; + +export interface OracleStalenessSummary { + /** Total runs with a valid factor breakdown in the historical set */ + assessedRuns: number; + /** Number of runs where the oracle was detected as stale (score < 50 or priceFreshness < 50) */ + staleRuns: number; + /** Percentage of observed runs where the oracle was stale (0–100) */ + stalePercentage: number; + /** Total runs evaluated in history */ + totalRuns: number; +} + +/** + * Derives oracle staleness metrics across historical runs. + * + * Reuses the canonical oracleSafety factor from each historical run: + * A run's oracle is considered stale when: + * 1. An 'ok' run evaluates oracleSafety with a value < 50, OR + * 2. The priceFreshness component sub-score is < 50. + * + * Runs that don't assess oracleSafety (e.g. Dex protocols without an oracle) are safely excluded. + */ +export function calculateOracleStaleness(history: HistoryEntry[]): OracleStalenessSummary { + let assessedRuns = 0; + let staleRuns = 0; + for (const entry of history) { + if (entry.status !== 'ok') continue; + + const oracleFactor = entry.factors?.oracleSafety; + if (!oracleFactor) continue; + + assessedRuns += 1; + + // Check if the priceFreshness component sub-score or overall factor score is degraded (< 50) + const freshnessComp = oracleFactor.components?.find( + (c: RiskFactorComponent) => c.id === 'priceFreshness', + ); + const isFreshnessStale = + freshnessComp && freshnessComp.value !== null ? freshnessComp.value < 50 : false; + const isOverallFactorStale = oracleFactor.value < 50; + + if (isFreshnessStale || isOverallFactorStale) { + staleRuns += 1; + } + } + + const stalePercentage = assessedRuns > 0 ? Math.round((staleRuns / assessedRuns) * 100) : 0; + + return { + assessedRuns, + staleRuns, + stalePercentage, + totalRuns: history.length, + }; +} diff --git a/dashboard/app/protocol/[id]/page.tsx b/dashboard/app/protocol/[id]/page.tsx index 2ade520..b666317 100644 --- a/dashboard/app/protocol/[id]/page.tsx +++ b/dashboard/app/protocol/[id]/page.tsx @@ -25,6 +25,7 @@ import { FactorBreakdown } from '../../../components/factor-breakdown'; import { Reveal, RevealGroup, RevealItem } from '../../../components/reveal'; import { RunHistory } from '../../../components/run-history'; import { ScoreHistoryChart } from '../../../components/score-history-chart'; +import { OracleStalenessView } from '../../../components/oracle-staleness-view'; export const dynamic = 'force-dynamic'; @@ -96,6 +97,9 @@ export default async function ProtocolDetailPage({ params }: { params: Promise<{ )} + + + diff --git a/dashboard/components/oracle-staleness-view.tsx b/dashboard/components/oracle-staleness-view.tsx new file mode 100644 index 0000000..990ce43 --- /dev/null +++ b/dashboard/components/oracle-staleness-view.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { ActivitySquare, AlertTriangle, CheckCircle2 } from 'lucide-react'; +import type { HistoryEntry } from '../app/lib/contract'; +import { calculateOracleStaleness } from '../app/lib/oracle-staleness'; +import { cn } from '../app/lib/cn'; + +export interface OracleStalenessViewProps { + history: HistoryEntry[]; + className?: string; +} + +/** + * Historical oracle-staleness view. + * + * Displays how often a protocol's oracle feeds have been stale over the + * observed run history (percentage of runs stale, stale vs assessed counts, + * and a status tone indicating frequency of stale periods). + * + * Visually distinct from the current-state status pill / badge: + * this component represents the historical aggregate behavior over time. + */ +export function OracleStalenessView({ history, className }: OracleStalenessViewProps) { + const summary = calculateOracleStaleness(history); + + // If no runs assessed oracle safety (e.g. Dex without oracle, or zero history), omit component cleanly + if (summary.assessedRuns === 0) { + return null; + } + + const { assessedRuns, staleRuns, stalePercentage } = summary; + + // Stale frequency severity thresholds + const isHighStale = stalePercentage >= 50; + const isModerateStale = stalePercentage > 0 && stalePercentage < 50; + const isClean = stalePercentage === 0; + + return ( +
+
+
+ {isHighStale ? ( +
+ +
+ + {stalePercentage}% stale + + + ({staleRuns} of {assessedRuns} observed runs) + +
+
+ +
+ {/* Visual progress bar representing the proportion of historical runs stale */} +
+
+
+
+ +

+ {isClean + ? 'Oracle prices remained fresh across all evaluated historical runs.' + : isHighStale + ? `Oracle prices were stale in ${staleRuns} of the last ${assessedRuns} runs (${stalePercentage}% of time). Persistent staleness indicates feed refresh gaps or unaligned TTLs.` + : `Oracle prices experienced intermittent staleness in ${staleRuns} of ${assessedRuns} runs (${stalePercentage}%).`} +

+
+ ); +}