From dbd94439d7fc9cdc9fd0c1c0997344500d13d8b3 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 20 May 2026 09:19:03 +0100 Subject: [PATCH 1/5] feat: general dashboard --- packages/enclave-dashboard/.env.example | 2 + packages/enclave-dashboard/.gitignore | 1 + packages/enclave-dashboard/README.md | 60 + packages/enclave-dashboard/index.html | 19 + packages/enclave-dashboard/package.json | 28 + packages/enclave-dashboard/src/App.tsx | 383 +++ packages/enclave-dashboard/src/History.tsx | 210 ++ packages/enclave-dashboard/src/Inspector.tsx | 492 ++++ packages/enclave-dashboard/src/PollCard.tsx | 298 ++ packages/enclave-dashboard/src/Pulse.tsx | 33 + packages/enclave-dashboard/src/Timeline.tsx | 101 + packages/enclave-dashboard/src/data.ts | 247 ++ packages/enclave-dashboard/src/lib/adapt.ts | 271 ++ packages/enclave-dashboard/src/lib/chain.ts | 37 + packages/enclave-dashboard/src/lib/e3.ts | 312 +++ packages/enclave-dashboard/src/lib/links.ts | 21 + .../enclave-dashboard/src/lib/pollMeta.ts | 55 + packages/enclave-dashboard/src/lib/useE3s.ts | 90 + packages/enclave-dashboard/src/main.tsx | 10 + packages/enclave-dashboard/src/styles.css | 2392 +++++++++++++++++ .../enclave-dashboard/src/tweaks-panel.tsx | 333 +++ packages/enclave-dashboard/src/useTweaks.ts | 20 + packages/enclave-dashboard/tsconfig.json | 20 + packages/enclave-dashboard/vite.config.ts | 9 + pnpm-lock.yaml | 235 +- pnpm-workspace.yaml | 1 + 26 files changed, 5554 insertions(+), 126 deletions(-) create mode 100644 packages/enclave-dashboard/.env.example create mode 100644 packages/enclave-dashboard/.gitignore create mode 100644 packages/enclave-dashboard/README.md create mode 100644 packages/enclave-dashboard/index.html create mode 100644 packages/enclave-dashboard/package.json create mode 100644 packages/enclave-dashboard/src/App.tsx create mode 100644 packages/enclave-dashboard/src/History.tsx create mode 100644 packages/enclave-dashboard/src/Inspector.tsx create mode 100644 packages/enclave-dashboard/src/PollCard.tsx create mode 100644 packages/enclave-dashboard/src/Pulse.tsx create mode 100644 packages/enclave-dashboard/src/Timeline.tsx create mode 100644 packages/enclave-dashboard/src/data.ts create mode 100644 packages/enclave-dashboard/src/lib/adapt.ts create mode 100644 packages/enclave-dashboard/src/lib/chain.ts create mode 100644 packages/enclave-dashboard/src/lib/e3.ts create mode 100644 packages/enclave-dashboard/src/lib/links.ts create mode 100644 packages/enclave-dashboard/src/lib/pollMeta.ts create mode 100644 packages/enclave-dashboard/src/lib/useE3s.ts create mode 100644 packages/enclave-dashboard/src/main.tsx create mode 100644 packages/enclave-dashboard/src/styles.css create mode 100644 packages/enclave-dashboard/src/tweaks-panel.tsx create mode 100644 packages/enclave-dashboard/src/useTweaks.ts create mode 100644 packages/enclave-dashboard/tsconfig.json create mode 100644 packages/enclave-dashboard/vite.config.ts diff --git a/packages/enclave-dashboard/.env.example b/packages/enclave-dashboard/.env.example new file mode 100644 index 0000000000..18b4f0df23 --- /dev/null +++ b/packages/enclave-dashboard/.env.example @@ -0,0 +1,2 @@ +# Optional: override the Sepolia RPC. Defaults to publicnode if unset. +# VITE_SEPOLIA_RPC=https://eth-sepolia.g.alchemy.com/v2/ diff --git a/packages/enclave-dashboard/.gitignore b/packages/enclave-dashboard/.gitignore new file mode 100644 index 0000000000..4dc531eaf6 --- /dev/null +++ b/packages/enclave-dashboard/.gitignore @@ -0,0 +1 @@ +.env diff --git a/packages/enclave-dashboard/README.md b/packages/enclave-dashboard/README.md new file mode 100644 index 0000000000..306871ab8a --- /dev/null +++ b/packages/enclave-dashboard/README.md @@ -0,0 +1,60 @@ +# @enclave-e3/dashboard + +Interfold / CRISP public observation dashboard. Two tabs: + +- **CRISP** — hero poll card, live 7-stage timeline, expandable history, network pulse footer. + Observational only (no vote CTA). +- **E3 inspector** — deep technical record of one E3: request, committee, keygen rounds, input + window, compute, decryption, publication, fees, on-chain event log. + +## Run + +```bash +pnpm install +pnpm --filter @enclave-e3/dashboard dev +``` + +Opens at `http://localhost:5173`. + +## On-chain backend (Sepolia) + +The dashboard reads live data from the Sepolia deployment of Enclave +(`packages/enclave-contracts/deployed_contracts.json`). ABIs come from the canonical typechain +factories in `@enclave-e3/contracts/types` and the `E3Stage`/`FailureReason` enums from +`@enclave-e3/sdk/contracts` so the dashboard cannot drift from the deployed contracts. + +- `Enclave` proxy at `0xB47B267876B60a06138Bc9dfCee7aa3E26907CCB` — `E3Requested`, `InputPublished`, + `PlaintextOutputPublished`, plus `getE3` / `getE3Stage` view functions. +- `CiphernodeRegistryOwnable` at `0x497Feea9abB72229aab1584c22b5416ff128926B` — `CommitteeRequested` + (threshold), `CommitteeFinalized` (members), `CommitteePublished` (joint PK). +- `CRISPProgram` at `0xba3B07aBFd0B8cad68aa1E946CC7AF5C1B1c8B5D` — the program address shown in the + inspector. + +CRISP question text + option labels are off-chain (the program doesn't store them); the mapping +lives in `src/lib/pollMeta.ts`. Unknown E3 ids get a generic "Encrypted poll #N" header with numeric +option labels. + +### RPC + +Defaults to `https://ethereum-sepolia.publicnode.com`. Override via: + +``` +VITE_SEPOLIA_RPC=https://eth-sepolia.g.alchemy.com/v2/ +``` + +The fetchers chunk `getLogs` calls to 9_500 blocks per request so they work against the stricter +free-tier providers. + +### Polling + +`useE3List` and `useE3Details` poll every 15 seconds while mounted. When the chain-derived stage +advances, the CRISP tab's stage + pollState reconcile automatically; manual overrides via the Tweaks +panel still work (they're clobbered on the next poll tick). + +## Build + +```bash +pnpm --filter @enclave-e3/dashboard build # vite build → dist/ +pnpm --filter @enclave-e3/dashboard typecheck # tsc --noEmit +pnpm --filter @enclave-e3/dashboard preview # serve dist/ +``` diff --git a/packages/enclave-dashboard/index.html b/packages/enclave-dashboard/index.html new file mode 100644 index 0000000000..f6afd40a1e --- /dev/null +++ b/packages/enclave-dashboard/index.html @@ -0,0 +1,19 @@ + + + + + + CRISP · Interfold + + + + + + +
+ + + diff --git a/packages/enclave-dashboard/package.json b/packages/enclave-dashboard/package.json new file mode 100644 index 0000000000..527d7f6e2a --- /dev/null +++ b/packages/enclave-dashboard/package.json @@ -0,0 +1,28 @@ +{ + "name": "@enclave-e3/dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Interfold / CRISP public observation dashboard", + "license": "LGPL-3.0-only", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@enclave-e3/contracts": "workspace:*", + "@enclave-e3/sdk": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "viem": "^2.21.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.3", + "vite": "^5.4.0" + } +} diff --git a/packages/enclave-dashboard/src/App.tsx b/packages/enclave-dashboard/src/App.tsx new file mode 100644 index 0000000000..9b5e451c37 --- /dev/null +++ b/packages/enclave-dashboard/src/App.tsx @@ -0,0 +1,383 @@ +// Main app shell + tweak wiring. + +import { useEffect, useMemo, useState } from 'react' +import { STAGES, TODAYS_POLL, HISTORY, PULSE } from './data' +import PollCard from './PollCard' +import Timeline from './Timeline' +import History from './History' +import Pulse from './Pulse' +import Inspector from './Inspector' +import { useE3Details, useE3List } from './lib/useE3s' +import { adaptHistoryEntries, adaptInspectorDetail, adaptInspectorE3List, adaptTodaysPoll } from './lib/adapt' +import { formatE3Id } from './lib/pollMeta' +import { LINKS } from './lib/links' +import type { E3FullDetails } from './lib/e3' +import { useTweaks } from './useTweaks' +import { TweaksPanel, TweakSection, TweakSelect, TweakRadio, TweakToggle } from './tweaks-panel' + +function Header({ density, view, onNav }: { density: string; view: string; onNav: (id: string) => void }) { + const link = (id: string, label: string) => ( + { + e.preventDefault() + onNav(id) + }} + > + {label} + + ) + return ( +
+
+ { + e.preventDefault() + onNav('crisp') + }} + aria-label='Interfold home' + > + + Interfold + + +
+
+ ) +} + +function Intro() { + return ( +
+
+ Live · public poll +
+

Watch an encrypted poll execute on the Interfold network.

+

+ CRISP is an example poll running live. Ballots are encrypted on each voter's device, tallied without ever being decrypted, and only + the final result is revealed. This page shows the lifecycle as it happens — and the archive of every poll that came before. +

+
+ ) +} + +function NoActivePollHero() { + return ( +
+
+
+
+ ) +} + +function SiteFooter() { + return ( + + ) +} + +const TWEAK_DEFAULTS = { + view: 'crisp', + stageIdx: 3, + pollState: 'open', + density: 'comfortable', + resultVariant: 'all', + showPulse: true, + accent: 'mint', +} + +const ACCENT_PRESETS: Record = { + mint: { bg: '#e8faf0', deep: '#1f6b4a', soft: '#cdeede', ink: '#163d2c' }, + dusk: { bg: '#e6e8fa', deep: '#3a3f8a', soft: '#cdd2ee', ink: '#1f2347' }, + paper: { bg: '#f1ece2', deep: '#5a4a2a', soft: '#e3d9c2', ink: '#3a2f17' }, +} + +export default function App() { + const [t, setTweak] = useTweaks(TWEAK_DEFAULTS) + const setView = (v: string) => setTweak('view', v) + + const [ballotDelta, setBallotDelta] = useState(0) + const [, setNowTick] = useState(0) + const [liveMode, setLiveMode] = useState(false) + + // ─── On-chain data (Sepolia) ────────────────────────────────────────────── + const e3List = useE3List() + // The newest E3 (first entry; useE3List returns newest first) is "today's poll". + const todaysId = e3List.data && e3List.data.length > 0 ? e3List.data[0].id : null + const todaysDetail = useE3Details(todaysId) + + // Inspector keeps its own selection — track which id is currently selected. + const [inspectorIdStr, setInspectorIdStr] = useState(null) + const selectedInspectorId = useMemo(() => { + if (!e3List.data || e3List.data.length === 0) return null + if (inspectorIdStr) { + const match = e3List.data.find((e) => formatE3Id(e.id) === inspectorIdStr) + if (match) return match.id + } + return e3List.data[0].id + }, [e3List.data, inspectorIdStr]) + const inspectorDetail = useE3Details(selectedInspectorId) + + // Latest detail per E3 id, for history rows. Derived from the two detail + // sources we actively poll (today's poll + the inspector selection) — no + // effect needed, so no cascading renders. + const detailsCache = useMemo(() => { + const m = new Map() + if (todaysDetail.data) m.set(todaysDetail.data.id.toString(), todaysDetail.data) + if (inspectorDetail.data) m.set(inspectorDetail.data.id.toString(), inspectorDetail.data) + return m + }, [todaysDetail.data, inspectorDetail.data]) + + const livePoll = useMemo(() => adaptTodaysPoll(todaysDetail.data), [todaysDetail.data]) + const liveHistory = useMemo(() => { + if (!e3List.data || e3List.data.length <= 1) return HISTORY + return adaptHistoryEntries(e3List.data.slice(1), detailsCache) + }, [e3List.data, detailsCache]) + const inspectorList = useMemo(() => (e3List.data ? adaptInspectorE3List(e3List.data) : undefined), [e3List.data]) + const inspectorE3 = useMemo(() => adaptInspectorDetail(inspectorDetail.data), [inspectorDetail.data]) + + // Sync poll stage to the live chain-derived stage whenever it changes (so + // the timeline reflects reality, while still allowing manual overrides). + const liveStageIdx = todaysDetail.data?.uiStageIdx + useEffect(() => { + if (liveStageIdx == null) return + setTweak('stageIdx', liveStageIdx) + setTweak('pollState', liveStageIdx >= 6 ? 'published' : liveStageIdx >= 4 ? 'computing' : 'open') + }, [liveStageIdx, setTweak]) + + const setStage = (i: number) => { + setTweak('stageIdx', i) + if (i >= 6) setTweak('pollState', 'published') + else if (i >= 4) setTweak('pollState', 'computing') + else setTweak('pollState', 'open') + } + + useEffect(() => { + const id = setInterval(() => setNowTick((n) => n + 1), 1000) + return () => clearInterval(id) + }, []) + + useEffect(() => { + if (t.pollState !== 'open') return undefined + const id = setInterval(() => { + setBallotDelta((d) => d + 1 + Math.floor(Math.random() * 3)) + }, 2400) + return () => clearInterval(id) + }, [t.pollState]) + + useEffect(() => { + if (!liveMode) return undefined + const program = [ + { state: 'open', stage: 0, hold: 2200 }, + { state: 'open', stage: 1, hold: 2200 }, + { state: 'open', stage: 2, hold: 2400 }, + { state: 'open', stage: 3, hold: 4600 }, + { state: 'computing', stage: 4, hold: 2800 }, + { state: 'computing', stage: 5, hold: 2400 }, + { state: 'published', stage: 6, hold: 4000 }, + ] + let i = 0 + let cancelled = false + let timer: ReturnType | null = null + const advance = () => { + if (cancelled) return + const step = program[i] + setTweak('pollState', step.state) + setTweak('stageIdx', step.stage) + if (step.stage === 0) setBallotDelta(0) + i = (i + 1) % program.length + timer = setTimeout(advance, step.hold) + } + advance() + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [liveMode, setTweak]) + + const effectiveBallotCount = (todaysDetail.data ? todaysDetail.data.ballotCount : TODAYS_POLL.ballotCount) + ballotDelta + + const reconciledStage = (() => { + if (t.pollState === 'published') return 6 + if (t.pollState === 'computing') return Math.max(4, Math.min(5, t.stageIdx)) + if (t.pollState === 'none') return 6 + return Math.min(t.stageIdx, 3) + })() + + useEffect(() => { + const a = ACCENT_PRESETS[t.accent] ?? ACCENT_PRESETS.mint + const root = document.documentElement + root.style.setProperty('--accent-bg', a.bg) + root.style.setProperty('--accent-deep', a.deep) + root.style.setProperty('--accent-soft', a.soft) + root.style.setProperty('--accent-ink', a.ink) + }, [t.accent]) + + const noActive = t.pollState === 'none' + + return ( +
+
+ {t.view === 'inspector' ? ( +
+ setInspectorIdStr(id)} + loading={e3List.status === 'loading' || (selectedInspectorId != null && inspectorDetail.status === 'loading')} + error={inspectorDetail.status === 'error' ? inspectorDetail.error : null} + /> +
+ ) : ( +
+ + + {noActive && } + + setLiveMode((v) => !v)} + ballotCount={effectiveBallotCount} + onNavigate={setView} + /> + + + + +
+ )} + + + + + + + setTweak('view', v)} + /> + + + setTweak('pollState', v)} + /> + ({ + value: String(i), + label: `${i + 1}. ${s.label}`, + }))} + onChange={(v) => setTweak('stageIdx', Number(v))} + /> + + + setTweak('density', v)} /> + setTweak('resultVariant', v)} + /> + setTweak('accent', v)} /> + setTweak('showPulse', v)} /> + +
+ ) +} diff --git a/packages/enclave-dashboard/src/History.tsx b/packages/enclave-dashboard/src/History.tsx new file mode 100644 index 0000000000..4f32dfcf00 --- /dev/null +++ b/packages/enclave-dashboard/src/History.tsx @@ -0,0 +1,210 @@ +// Poll history archive — past completed polls. Rows expand to show detail. + +import React, { useState } from 'react' +import { STAGES } from './data' + +type Entry = { + id: string + question: string + closed: string + duration: string + ballotCount: number + result: string +} + +const HIST_FILTERS = [ + { id: 'all', label: 'All', test: () => true }, + { id: 'appr', label: 'Approved', test: (e: Entry) => /Approved|Adopted/i.test(e.result) }, + { id: 'decl', label: 'Declined', test: (e: Entry) => /Declined/i.test(e.result) }, + { id: '2026', label: '2026', test: (e: Entry) => /2026/.test(e.closed) }, +] + +function pctFromResultStr(s: string) { + const m = s.match(/(\d+)%/) + return m ? Number(m[1]) : 50 +} + +function MiniTimeline({ stages }: { stages: typeof STAGES }) { + return ( + + ) +} + +function HistoryDetail({ entry, onNavigate }: { entry: Entry; onNavigate?: (view: string) => void }) { + const winnerPct = pctFromResultStr(entry.result) + const isApproved = !/Declined/i.test(entry.result) + const otherPct = 100 - winnerPct + const absPct = Math.min(8, Math.max(2, Math.round(otherPct * 0.15))) + const losePct = otherPct - absPct + const winnerLabel = isApproved ? 'Yes / Approve' : 'No / Decline' + const loseLabel = isApproved ? 'No / Decline' : 'Yes / Approve' + + return ( +
+
+
+
Final tally
+
    +
  • +
    + {winnerLabel} + + {winnerPct}% + +
    +
    +
    +
    +
  • +
  • +
    + {loseLabel} + + {losePct}% + +
    +
    +
    +
    +
  • +
  • +
    + Abstain + + {absPct}% + +
    +
    +
    +
    +
  • +
+
+ +
+
Lifecycle
+ +
+
Ballots tallied
+
{entry.ballotCount.toLocaleString()}
+
Open for
+
{entry.duration}
+
Closed
+
{entry.closed}
+
Privacy
+
No individual ballot was ever decrypted.
+
+
+
+ + +
+ ) +} + +function HistoryRow({ + entry, + expanded, + onToggle, + onNavigate, +}: { + entry: Entry + expanded: boolean + onToggle: () => void + onNavigate?: (view: string) => void +}) { + const [winner, pct] = entry.result.split(' · ') + const declined = winner.toLowerCase().includes('declined') + return ( +
  • + + {expanded && } +
  • + ) +} + +export default function History({ entries, onNavigate }: { entries: Entry[]; onNavigate?: (view: string) => void }) { + const [filterId, setFilterId] = useState('all') + const [expandedId, setExpandedId] = useState(null) + const filter = HIST_FILTERS.find((f) => f.id === filterId) || HIST_FILTERS[0] + const filtered = entries.filter(filter.test as any) + + return ( +
    +
    +
    +
    Archive
    +

    Past polls

    +
    +
    + {HIST_FILTERS.map((f) => ( + + ))} +
    +
    +
      + {filtered.map((e) => ( + setExpandedId((cur) => (cur === e.id ? null : e.id))} + onNavigate={onNavigate} + /> + ))} + {filtered.length === 0 &&
    • No polls match this filter.
    • } +
    +
    + +
    +
    + ) +} diff --git a/packages/enclave-dashboard/src/Inspector.tsx b/packages/enclave-dashboard/src/Inspector.tsx new file mode 100644 index 0000000000..af922a2e62 --- /dev/null +++ b/packages/enclave-dashboard/src/Inspector.tsx @@ -0,0 +1,492 @@ +// E3 Inspector — deep technical record of a single E3. + +import React, { useEffect, useRef, useState } from 'react' +import { STAGES, E3_DETAILS, E3_LIST } from './data' +import { CONTRACTS } from './lib/chain' +import { explorerAddress } from './lib/links' + +export type InspectorE3List = Array<{ id: string; label: string }> + +function Mono({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return {children} +} + +function CopyableHash({ value, full }: { value: string; full?: string }) { + const [copied, setCopied] = useState(false) + const onClick = () => { + if (navigator.clipboard) navigator.clipboard.writeText(full || value) + setCopied(true) + setTimeout(() => setCopied(false), 1100) + } + return ( + + ) +} + +function InspStatusBadge({ stageIdx }: { stageIdx: number }) { + const s = STAGES[stageIdx] + const variant = stageIdx >= 6 ? 'published' : stageIdx === 3 ? 'open' : 'working' + return ( + + + {s.label} + + ) +} + +function DefList({ items }: { items: Array<[React.ReactNode, React.ReactNode]> }) { + return ( +
    + {items.map(([k, v], i) => ( + +
    {k}
    +
    {v}
    +
    + ))} +
    + ) +} + +function SectionCard({ + eyebrow, + title, + status, + children, + dense, +}: { + eyebrow: string + title: string + status?: { kind: string; label: string } + children: React.ReactNode + dense?: boolean +}) { + return ( +
    +
    +
    +
    {eyebrow}
    +

    {title}

    +
    + {status && {status.label}} +
    +
    {children}
    +
    + ) +} + +function InspectorStageStrip({ stages, currentStageIdx }: { stages: typeof STAGES; currentStageIdx: number }) { + const wrapRef = useRef(null) + const [overflow, setOverflow] = useState(false) + useEffect(() => { + const el = wrapRef.current + if (!el) return + const check = () => { + const inner = el.querySelector('.istrip') as HTMLElement | null + if (!inner) return + setOverflow(inner.scrollWidth - inner.clientWidth > 4 && inner.scrollLeft + inner.clientWidth < inner.scrollWidth - 4) + } + check() + const ro = new ResizeObserver(check) + ro.observe(el) + const inner = el.querySelector('.istrip') + inner?.addEventListener('scroll', check) + return () => { + ro.disconnect() + inner?.removeEventListener('scroll', check) + } + }, []) + return ( +
    +
    + {stages.map((s, i) => { + const state = i < currentStageIdx ? 'done' : i === currentStageIdx ? 'active' : 'todo' + return ( + +
    + + {s.label} +
    + {i < stages.length - 1 && } +
    + ) + })} +
    +
    + ) +} + +function EventLog({ events }: { events: any[] }) { + const [filter, setFilter] = useState('all') + const stages = ['all', ...Array.from(new Set(events.map((e) => e.stage)))] + const filtered = filter === 'all' ? events : events.filter((e) => e.stage === filter) + return ( +
    +
    + {stages.map((s) => ( + + ))} + + {filtered.length} / {events.length} + +
    + + + + + + + + + + + + + {filtered.map((ev, i) => ( + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
    TimeBlockEventStageTxGas
    + {ev.t} + + {typeof ev.block === 'number' ? `#${ev.block.toLocaleString()}` : ev.block} + + {ev.name} + + {ev.stage} + {ev.tx === '—' ? : }{ev.gas}
    + No events match this filter. +
    +
    + Events stream live from the Enclave contract on Sepolia. + + Open in block explorer → + +
    +
    + ) +} + +export default function Inspector({ + e3List: e3ListProp, + e3Override, + selectedId: selectedIdProp, + onSelect, + loading, + error, +}: { + e3List?: InspectorE3List + e3Override?: any + selectedId?: string + onSelect?: (id: string) => void + loading?: boolean + error?: Error | null +} = {}) { + const list = e3ListProp && e3ListProp.length > 0 ? e3ListProp : E3_LIST + const fallbackId = list[0]?.id ?? 'E3-0481' + const [localId, setLocalId] = useState(fallbackId) + const selectedId = selectedIdProp ?? localId + const setSelectedId = onSelect ?? setLocalId + const e3 = e3Override ?? E3_DETAILS[selectedId] ?? E3_DETAILS['E3-0481'] + + return ( +
    + {(loading || error) && ( +
    + {error ? `Failed to load on-chain data: ${error.message}. Showing cached preview.` : 'Loading on-chain data from Sepolia…'} +
    + )} +
    +
    +
    +
    + Network + / + E3 inspector + / + {e3.id} +
    +

    {e3.summary}

    +
    + Program + {e3.program} + · + Requested by + + {e3.requestedByLabel} · {e3.requestedBy} + +
    +
    +
    + + +
    +
    + + + +
    +
    +
    Status
    +
    + +
    +
    +
    +
    Committee
    +
    + {e3.committee.threshold} of {e3.committee.size} +
    +
    threshold · total nodes
    +
    +
    +
    Ballots
    +
    {e3.input.ballotsReceived.toLocaleString()}
    +
    encrypted · in-flight
    +
    +
    +
    Compute fee
    +
    {e3.fees.computeFee}
    +
    of {e3.fees.requesterDeposit} deposit
    +
    +
    +
    + + +
    + {e3.requestedAt}], + ['Request tx', ], + ['Block', #{e3.requestedBlock.toLocaleString()}], + ['Requested by', {e3.requestedBy}], + ['Program', {e3.program}], + ['Program address', {e3.programAddr}], + ]} + /> + {e3.committee.size} nodes], + [ + 'Decryption threshold', + + {e3.committee.threshold} of {e3.committee.size} + , + ], + ['Selection seed', {e3.committee.selectionSeed}], + ['Selection tx', ], + ['Drawn at', {e3.committee.drawnAt}], + ['Identities', {e3.committee.note}], + ]} + /> +
    +
    + + +

    + The committee jointly produced an encryption key in three rounds. The matching decryption key is held in shares — never + assembled, never written down. +

    +
    + Protocol + {e3.keygen.protocol} +
    +
      + {e3.keygen.rounds.map((r: any, i: number) => ( +
    1. +
      {String(i + 1).padStart(2, '0')}
      +
      +
      +
      {r.name}
      +
      + + Participants + {r.participants} + + + Started + {r.startedAt} + + + Duration + {r.duration} + + + + +
      +
      +
      {r.note}
      +
      +
    2. + ))} +
    +
    + Joint public key + {e3.keygen.publicKey} +
    +
    + + +
    + {e3.input.openedAt}], + ['Closes', {e3.input.closesAt}], + ['Ballots received', {e3.input.ballotsReceived.toLocaleString()}], + ]} + /> + {e3.input.firstBallotAt}], + ['Last ballot', {e3.input.lastBallotAt}], + ['Avg ballot size', {e3.input.avgBallotSize}], + ['Ballot circuit', {e3.input.ballotCircuit}], + ]} + /> +
    +
    + + +

    {e3.compute.note}

    + {e3.compute.circuit}], + ['Estimated duration', {e3.compute.estDuration}], + ['Estimated gas', {e3.compute.estGas}], + ]} + /> +
    + + +

    {e3.decryption.note}

    +
    +
    + Partial decryptions received + + {e3.decryption.sharesReceived} / {e3.decryption.sharesRequired} + +
    +
    +
    +
    +
    + {e3.decryption.sharesRequired} of {e3.committee.size} committee members must each publish a partial share. +
    +
    + + + +

    {e3.publication.note}

    +
    + + +
    +
    - © 2026 Interfold Foundation · Built in the open - commit a1f9c4d · indexer green + © 2026 Interfold · Built in the open + + Enclave on Sepolia ↗ +
    ) } const TWEAK_DEFAULTS = { - view: 'crisp', + view: 'inspector', stageIdx: 3, pollState: 'open', density: 'comfortable', @@ -159,26 +168,28 @@ export default function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS) const setView = (v: string) => setTweak('view', v) - const [ballotDelta, setBallotDelta] = useState(0) const [, setNowTick] = useState(0) const [liveMode, setLiveMode] = useState(false) // ─── On-chain data (Sepolia) ────────────────────────────────────────────── - const e3List = useE3List() - // The newest E3 (first entry; useE3List returns newest first) is "today's poll". - const todaysId = e3List.data && e3List.data.length > 0 ? e3List.data[0].id : null + // CRISP tab: only CRISP-program polls. Inspector tab: every E3 on the network. + const crispPolls = useCrispPolls() + const allE3s = useAllE3s() + + // The newest CRISP poll (first entry; lists are newest-first) is "today's poll". + const todaysId = crispPolls.data && crispPolls.data.length > 0 ? crispPolls.data[0].id : null const todaysDetail = useE3Details(todaysId) // Inspector keeps its own selection — track which id is currently selected. const [inspectorIdStr, setInspectorIdStr] = useState(null) const selectedInspectorId = useMemo(() => { - if (!e3List.data || e3List.data.length === 0) return null + if (!allE3s.data || allE3s.data.length === 0) return null if (inspectorIdStr) { - const match = e3List.data.find((e) => formatE3Id(e.id) === inspectorIdStr) + const match = allE3s.data.find((e) => formatE3Id(e.id) === inspectorIdStr) if (match) return match.id } - return e3List.data[0].id - }, [e3List.data, inspectorIdStr]) + return allE3s.data[0].id + }, [allE3s.data, inspectorIdStr]) const inspectorDetail = useE3Details(selectedInspectorId) // Latest detail per E3 id, for history rows. Derived from the two detail @@ -191,12 +202,17 @@ export default function App() { return m }, [todaysDetail.data, inspectorDetail.data]) - const livePoll = useMemo(() => adaptTodaysPoll(todaysDetail.data), [todaysDetail.data]) - const liveHistory = useMemo(() => { - if (!e3List.data || e3List.data.length <= 1) return HISTORY - return adaptHistoryEntries(e3List.data.slice(1), detailsCache) - }, [e3List.data, detailsCache]) - const inspectorList = useMemo(() => (e3List.data ? adaptInspectorE3List(e3List.data) : undefined), [e3List.data]) + // CRISP tab state. + const crispReady = crispPolls.status === 'ready' + const polls = useMemo(() => crispPolls.data ?? [], [crispPolls.data]) + const hasPolls = polls.length > 0 + const livePoll = useMemo(() => (todaysDetail.data ? adaptTodaysPoll(todaysDetail.data) : null), [todaysDetail.data]) + const liveHistory = useMemo(() => (polls.length > 1 ? adaptHistoryEntries(polls.slice(1), detailsCache) : []), [polls, detailsCache]) + + // Inspector tab state. + const inspectorReady = allE3s.status === 'ready' + const hasE3s = (allE3s.data?.length ?? 0) > 0 + const inspectorList = useMemo(() => adaptInspectorE3List(allE3s.data ?? []), [allE3s.data]) const inspectorE3 = useMemo(() => adaptInspectorDetail(inspectorDetail.data), [inspectorDetail.data]) // Sync poll stage to the live chain-derived stage whenever it changes (so @@ -220,14 +236,6 @@ export default function App() { return () => clearInterval(id) }, []) - useEffect(() => { - if (t.pollState !== 'open') return undefined - const id = setInterval(() => { - setBallotDelta((d) => d + 1 + Math.floor(Math.random() * 3)) - }, 2400) - return () => clearInterval(id) - }, [t.pollState]) - useEffect(() => { if (!liveMode) return undefined const program = [ @@ -247,7 +255,6 @@ export default function App() { const step = program[i] setTweak('pollState', step.state) setTweak('stageIdx', step.stage) - if (step.stage === 0) setBallotDelta(0) i = (i + 1) % program.length timer = setTimeout(advance, step.hold) } @@ -258,7 +265,7 @@ export default function App() { } }, [liveMode, setTweak]) - const effectiveBallotCount = (todaysDetail.data ? todaysDetail.data.ballotCount : TODAYS_POLL.ballotCount) + ballotDelta + const effectiveBallotCount = todaysDetail.data?.ballotCount ?? 0 const reconciledStage = (() => { if (t.pollState === 'published') return 6 @@ -283,48 +290,70 @@ export default function App() {
    {t.view === 'inspector' ? (
    - setInspectorIdStr(id)} - loading={e3List.status === 'loading' || (selectedInspectorId != null && inspectorDetail.status === 'loading')} - error={inspectorDetail.status === 'error' ? inspectorDetail.error : null} - /> + {!inspectorReady ? ( +
    + +
    + ) : !hasE3s ? ( +
    + No E3s on the network yet. They will appear here once one is requested on-chain. +
    + ) : ( + setInspectorIdStr(id)} + loading={inspectorDetail.status === 'loading'} + error={inspectorDetail.status === 'error' ? inspectorDetail.error : null} + /> + )}
    ) : (
    - {noActive && } - - setLiveMode((v) => !v)} - ballotCount={effectiveBallotCount} - onNavigate={setView} - /> + {!crispReady ? ( + + ) : !hasPolls ? ( + + No live CRISP polls right now. A new poll will appear here automatically when one is requested on-chain. + + ) : !livePoll ? ( + + ) : ( + <> + setLiveMode((v) => !v)} + ballotCount={effectiveBallotCount} + onNavigate={setView} + /> - + - + {liveHistory.length > 0 && } + + )}
    )} @@ -335,8 +364,8 @@ export default function App() { label='Tab' value={t.view} options={[ - { value: 'crisp', label: 'CRISP' }, { value: 'inspector', label: 'Inspector' }, + { value: 'crisp', label: 'CRISP' }, ]} onChange={(v) => setTweak('view', v)} /> diff --git a/packages/enclave-dashboard/src/History.tsx b/packages/enclave-dashboard/src/History.tsx index 4f32dfcf00..d61b52ed9b 100644 --- a/packages/enclave-dashboard/src/History.tsx +++ b/packages/enclave-dashboard/src/History.tsx @@ -1,7 +1,11 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. // Poll history archive — past completed polls. Rows expand to show detail. -import React, { useState } from 'react' -import { STAGES } from './data' +import { useState } from 'react' type Entry = { id: string @@ -19,91 +23,21 @@ const HIST_FILTERS = [ { id: '2026', label: '2026', test: (e: Entry) => /2026/.test(e.closed) }, ] -function pctFromResultStr(s: string) { - const m = s.match(/(\d+)%/) - return m ? Number(m[1]) : 50 -} - -function MiniTimeline({ stages }: { stages: typeof STAGES }) { - return ( - - ) -} - function HistoryDetail({ entry, onNavigate }: { entry: Entry; onNavigate?: (view: string) => void }) { - const winnerPct = pctFromResultStr(entry.result) - const isApproved = !/Declined/i.test(entry.result) - const otherPct = 100 - winnerPct - const absPct = Math.min(8, Math.max(2, Math.round(otherPct * 0.15))) - const losePct = otherPct - absPct - const winnerLabel = isApproved ? 'Yes / Approve' : 'No / Decline' - const loseLabel = isApproved ? 'No / Decline' : 'Yes / Approve' - return (
    -
    -
    -
    Final tally
    -
      -
    • -
      - {winnerLabel} - - {winnerPct}% - -
      -
      -
      -
      -
    • -
    • -
      - {loseLabel} - - {losePct}% - -
      -
      -
      -
      -
    • -
    • -
      - Abstain - - {absPct}% - -
      -
      -
      -
      -
    • -
    -
    - -
    -
    Lifecycle
    - -
    -
    Ballots tallied
    -
    {entry.ballotCount.toLocaleString()}
    -
    Open for
    -
    {entry.duration}
    -
    Closed
    -
    {entry.closed}
    -
    Privacy
    -
    No individual ballot was ever decrypted.
    -
    -
    -
    +
    +
    Result
    +
    {entry.result}
    +
    Ballots
    +
    {entry.ballotCount.toLocaleString()}
    +
    Open for
    +
    {entry.duration}
    +
    Closed
    +
    {entry.closed}
    +
    Privacy
    +
    No individual ballot was ever decrypted.
    +
    @@ -195,26 +202,44 @@ function EventLog({ events }: { events: any[] }) { } export default function Inspector({ - e3List: e3ListProp, - e3Override, - selectedId: selectedIdProp, + e3List, + e3, + selectedId, onSelect, loading, error, }: { - e3List?: InspectorE3List - e3Override?: any + e3List: InspectorE3List + e3: InspectorDetail | null selectedId?: string - onSelect?: (id: string) => void + onSelect: (id: string) => void loading?: boolean error?: Error | null -} = {}) { - const list = e3ListProp && e3ListProp.length > 0 ? e3ListProp : E3_LIST - const fallbackId = list[0]?.id ?? 'E3-0481' - const [localId, setLocalId] = useState(fallbackId) - const selectedId = selectedIdProp ?? localId - const setSelectedId = onSelect ?? setLocalId - const e3 = e3Override ?? E3_DETAILS[selectedId] ?? E3_DETAILS['E3-0481'] +}) { + const list = e3List + const setSelectedId = onSelect + + if (!e3) { + return ( +
    + {error ? ( +
    + {`Failed to load on-chain data: ${error.message}.`} +
    + ) : ( + + )} +
    + ) + } return (
    @@ -229,7 +254,7 @@ export default function Inspector({ color: error ? '#8a1f1f' : '#3a3f4a', }} > - {error ? `Failed to load on-chain data: ${error.message}. Showing cached preview.` : 'Loading on-chain data from Sepolia…'} + {error ? `Failed to load on-chain data: ${error.message}.` : 'Refreshing from Sepolia…'}
    )}
    @@ -284,14 +309,14 @@ export default function Inspector({
    threshold · total nodes
    -
    Ballots
    -
    {e3.input.ballotsReceived.toLocaleString()}
    -
    encrypted · in-flight
    +
    Inputs
    +
    {e3.input.inputsReceived}
    +
    encrypted · published
    -
    Compute fee
    -
    {e3.fees.computeFee}
    -
    of {e3.fees.requesterDeposit} deposit
    +
    Fee escrowed
    +
    {e3.fees.feeEscrowed}
    +
    held by Enclave
    @@ -318,7 +343,6 @@ export default function Inspector({ , ], ['Selection seed', {e3.committee.selectionSeed}], - ['Selection tx', ], ['Drawn at', {e3.committee.drawnAt}], ['Identities', {e3.committee.note}], ]} @@ -326,161 +350,85 @@ export default function Inspector({
    - +

    - The committee jointly produced an encryption key in three rounds. The matching decryption key is held in shares — never - assembled, never written down. + The committee jointly generated an encryption key. The matching decryption key is held in shares — never assembled, never + written down.

    -
    - Protocol - {e3.keygen.protocol} -
    -
      - {e3.keygen.rounds.map((r: any, i: number) => ( -
    1. -
      {String(i + 1).padStart(2, '0')}
      -
      -
      -
      {r.name}
      -
      - - Participants - {r.participants} - - - Started - {r.startedAt} - - - Duration - {r.duration} - - - - -
      -
      -
      {r.note}
      -
      -
    2. - ))} -
    -
    - Joint public key - {e3.keygen.publicKey} -
    + {e3.keygen.scheme}], + ['Committee finalized', {e3.keygen.finalizedAt}], + ['Public key published', {e3.keygen.publishedAt}], + [ + 'Publish tx', + e3.keygen.publishedTx === '—' ? ( + + ) : ( + + ), + ], + ['Joint public key', {e3.keygen.publicKey}], + ]} + />
    - +
    {e3.input.openedAt}], ['Closes', {e3.input.closesAt}], - ['Ballots received', {e3.input.ballotsReceived.toLocaleString()}], + ['Inputs received', {e3.input.inputsReceived}], ]} /> {e3.input.firstBallotAt}], - ['Last ballot', {e3.input.lastBallotAt}], - ['Avg ballot size', {e3.input.avgBallotSize}], - ['Ballot circuit', {e3.input.ballotCircuit}], + ['First input', {e3.input.firstBallotAt}], + ['Last input', {e3.input.lastBallotAt}], ]} />
    - +

    {e3.compute.note}

    - {e3.compute.circuit}], - ['Estimated duration', {e3.compute.estDuration}], - ['Estimated gas', {e3.compute.estGas}], - ]} - />

    {e3.decryption.note}

    -
    -
    - Partial decryptions received - - {e3.decryption.sharesReceived} / {e3.decryption.sharesRequired} - -
    -
    -
    -
    -
    - {e3.decryption.sharesRequired} of {e3.committee.size} committee members must each publish a partial share. -
    -
    + + {e3.decryption.threshold} of {e3.decryption.committeeSize} + , + ], + ]} + />

    {e3.publication.note}

    - +
    -