diff --git a/packages/enclave-dashboard/.env.example b/packages/enclave-dashboard/.env.example new file mode 100644 index 000000000..afcfb1537 --- /dev/null +++ b/packages/enclave-dashboard/.env.example @@ -0,0 +1,20 @@ +# All values are optional — unset ones fall back to the current Sepolia +# deployment (see src/lib/chain.ts). Copy to .env(.local) to override. + +# Sepolia RPC endpoint. Defaults to a public node; use a dedicated provider +# (Alchemy/Infura) for production to avoid rate limits. +# VITE_SEPOLIA_RPC=https://eth-sepolia.g.alchemy.com/v2/ + +# Contract addresses — point the dashboard at a different deployment. +# VITE_ENCLAVE_ADDRESS=0xB47B267876B60a06138Bc9dfCee7aa3E26907CCB +# VITE_CIPHERNODE_REGISTRY_ADDRESS=0x497Feea9abB72229aab1584c22b5416ff128926B +# VITE_CRISP_PROGRAM_ADDRESS=0xba3B07aBFd0B8cad68aa1E946CC7AF5C1B1c8B5D + +# First block to scan from (the Enclave deploy block). Lower = slower initial +# load (more getLogs chunks); set to the actual deploy block of the above. +# VITE_DEPLOY_BLOCK=10697349 + +# E3 timeout windows (seconds), from the deployment's timeoutConfig. Used to +# decide whether an E3 is still active vs. expired (input close + these windows). +# VITE_COMPUTE_WINDOW=86400 +# VITE_DECRYPTION_WINDOW=3600 diff --git a/packages/enclave-dashboard/.gitignore b/packages/enclave-dashboard/.gitignore new file mode 100644 index 000000000..4dc531eaf --- /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 000000000..d8ec38f3c --- /dev/null +++ b/packages/enclave-dashboard/README.md @@ -0,0 +1,84 @@ +# @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` so they cannot drift from the deployed contracts. The +`E3Stage` enum is mirrored locally in `src/lib/chain.ts` (matching `IEnclave.E3Stage`). + +- `Enclave` proxy at `0xB47B267876B60a06138Bc9dfCee7aa3E26907CCB` — `E3Requested`, + `PlaintextOutputPublished`, `RewardsDistributed`, plus `getE3` / `getE3Stage` / `e3Payments` view + functions. +- `CiphernodeRegistryOwnable` at `0x497Feea9abB72229aab1584c22b5416ff128926B` — `CommitteeRequested` + (threshold + seed), `CommitteeFinalized` (members), `CommitteePublished` (joint PK). +- `CRISPProgram` at `0xba3B07aBFd0B8cad68aa1E946CC7AF5C1B1c8B5D` — emits `InputPublished` for every + ballot. (Enclave's own `InputPublished` is declared but never emitted; inputs live on the + program.) A re-vote reuses its Merkle-leaf `index`, so the true ballot count is the number of + **distinct** indexes. Inputs are only observable for CRISP; other programs report + `inputsTracked: false`. + +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. + +### Configuration + +All deployment-specific values are env-overridable (prefix `VITE_`) so the dashboard can point at a +different deployment without code changes. See `.env.example`; unset values fall back to the current +Sepolia deployment defined in `src/lib/chain.ts`: + +- `VITE_SEPOLIA_RPC` — RPC endpoint (defaults to a public node; use Alchemy/Infura for production). +- `VITE_ENCLAVE_ADDRESS`, `VITE_CIPHERNODE_REGISTRY_ADDRESS`, `VITE_CRISP_PROGRAM_ADDRESS` — + contracts. +- `VITE_DEPLOY_BLOCK` — first block to scan from (the Enclave deploy block). + +The fetchers chunk `getLogs` calls to 9_500 blocks per request so they work against the stricter +free-tier providers. + +### Polling + +`useCrispPolls`, `useAllE3s`, 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/ +``` + +## Deploy (Vercel) + +This is a separate Vercel **Project** from the CRISP client, both pointing at the same repo. + +1. New Project → import this repo → set **Root Directory** to `packages/enclave-dashboard`. +2. `vercel.json` (committed here) drives the rest: + - installs the whole pnpm workspace (`cd ../.. && pnpm install`), + - builds `@enclave-e3/contracts` first (typechain ABIs the dashboard imports), then the + dashboard, + - serves `dist/`, + - `ignoreCommand` skips redeploys when nothing under `packages/enclave-dashboard`, + `packages/enclave-contracts`, or `pnpm-lock.yaml` changed. +3. Optionally set `VITE_SEPOLIA_RPC` in the project's Environment Variables. + +The dashboard intentionally has **no dependency on `@enclave-e3/sdk`** (which needs a Rust/Noir +toolchain to build) — only `@enclave-e3/contracts`, which is plain `hardhat compile` + `tsc`. diff --git a/packages/enclave-dashboard/index.html b/packages/enclave-dashboard/index.html new file mode 100644 index 000000000..f6afd40a1 --- /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 000000000..1280d9eec --- /dev/null +++ b/packages/enclave-dashboard/package.json @@ -0,0 +1,27 @@ +{ + "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:*", + "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 000000000..09dbd232e --- /dev/null +++ b/packages/enclave-dashboard/src/App.tsx @@ -0,0 +1,347 @@ +// 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. +// Main app shell + tweak wiring. + +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { STAGES } from './data' +import PollCard from './PollCard' +import Timeline from './Timeline' +import History from './History' +import Pulse from './Pulse' +import Inspector from './Inspector' +import Loader from './Loader' +import { useAllE3s, useCrispPolls, useE3Details, useRecentBallots } from './lib/useE3s' +import { adaptHistoryEntries, adaptInspectorDetail, adaptInspectorE3List, adaptTodaysPoll } from './lib/adapt' +import { formatE3Id } from './lib/pollMeta' +import { LINKS, explorerAddress } from './lib/links' +import { CONTRACTS } from './lib/chain' +import { isE3Active, type E3FullDetails } from './lib/e3' + +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('inspector') + }} + aria-label='Interfold home' + > + + +
+
+ ) +} + +function Intro() { + return ( +
+
+ Live · public poll +
+

Watch an encrypted poll execute on the Interfold network.

+

+ CRISP is an example e3 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 StatusNote({ children }: { children: ReactNode }) { + return ( +
+
+
+
+ ) +} + +function SiteFooter() { + return ( + + ) +} + +// Fixed presentation density (the live tweak panel was removed). +const DENSITY = 'comfortable' + +export default function App() { + // View (tab) + demo poll state. These are the only values that change at + // runtime; everything else is fixed (accent comes from the CSS :root mint). + const [view, setView] = useState('inspector') + const [pollState, setPollState] = useState('open') + const [stageIdx, setStageIdx] = useState(3) + + const [, setNowTick] = useState(0) + const [liveMode, setLiveMode] = useState(false) + // Demo autoplay step, persisted so pausing/resuming continues where it left off. + const liveStepRef = useRef(0) + + // ─── On-chain data (Sepolia) ────────────────────────────────────────────── + // CRISP tab: only CRISP-program polls. Inspector tab: every E3 on the network. + const crispPolls = useCrispPolls() + const allE3s = useAllE3s() + const recentBallots = useRecentBallots() + + // 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 (!allE3s.data || allE3s.data.length === 0) return null + if (inspectorIdStr) { + const match = allE3s.data.find((e) => formatE3Id(e.id) === inspectorIdStr) + if (match) return match.id + } + 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 + // 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]) + + // 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 + // the timeline reflects reality, while still allowing manual overrides). + const liveStageIdx = todaysDetail.data?.uiStageIdx + useEffect(() => { + if (liveStageIdx == null) return + if (liveMode) return // the demo drives the stage while it plays + // Sync the on-chain stage (external store) into local UI state. Also runs + // when the demo ends (liveMode → false), restoring the real state. + /* eslint-disable react-hooks/set-state-in-effect */ + setStageIdx(liveStageIdx) + setPollState(liveStageIdx >= 6 ? 'published' : liveStageIdx >= 4 ? 'computing' : 'open') + /* eslint-enable react-hooks/set-state-in-effect */ + }, [liveStageIdx, liveMode]) + + const setStage = (i: number) => { + setStageIdx(i) + if (i >= 6) setPollState('published') + else if (i >= 4) setPollState('computing') + else setPollState('open') + } + + useEffect(() => { + const id = setInterval(() => setNowTick((n) => n + 1), 1000) + return () => clearInterval(id) + }, []) + + 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 cancelled = false + let timer: ReturnType | null = null + const advance = () => { + if (cancelled) return + const i = liveStepRef.current + if (i >= program.length) { + // Completed one full lifecycle — stop instead of looping. + liveStepRef.current = 0 + setLiveMode(false) + return + } + const step = program[i] + setPollState(step.state) + setStageIdx(step.stage) + liveStepRef.current = i + 1 + timer = setTimeout(advance, step.hold) + } + advance() + return () => { + cancelled = true + if (timer) clearTimeout(timer) + } + }, [liveMode]) + + const effectiveBallotCount = todaysDetail.data?.ballotCount ?? 0 + + const reconciledStage = (() => { + if (pollState === 'published') return 6 + if (pollState === 'computing') return Math.max(4, Math.min(5, stageIdx)) + if (pollState === 'none') return 6 + return Math.min(stageIdx, 3) + })() + + const noActive = pollState === 'none' + + return ( +
+
+ {view === 'inspector' ? ( +
+ {allE3s.status === 'error' ? ( +
+ Couldn't load E3s from Sepolia. Retrying automatically… +
+ ) : !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} + /> + )} +
+ ) : ( +
+ + + {crispPolls.status === 'error' ? ( + Couldn't load CRISP polls from Sepolia. Retrying automatically… + ) : !crispReady ? ( + + ) : !hasPolls ? ( + + No live CRISP polls right now. A new poll will appear here automatically when one is requested on-chain. + + ) : todaysDetail.status === 'error' ? ( + Couldn't load the latest poll details from Sepolia. Retrying automatically… + ) : !livePoll ? ( + + ) : ( + <> + setLiveMode((v) => !v)} + ballotCount={effectiveBallotCount} + onNavigate={setView} + /> + + + + {liveHistory.length > 0 && } + + )} +
+ )} + + + +
+ ) +} diff --git a/packages/enclave-dashboard/src/History.tsx b/packages/enclave-dashboard/src/History.tsx new file mode 100644 index 000000000..a236ce87a --- /dev/null +++ b/packages/enclave-dashboard/src/History.tsx @@ -0,0 +1,141 @@ +// 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 { useState } from 'react' + +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|Completed/i.test(e.result) }, + { id: 'failed', label: 'Failed', test: (e: Entry) => /Failed/i.test(e.result) }, + { id: 'expired', label: 'Expired', test: (e: Entry) => /Expired/i.test(e.result) }, +] + +function HistoryDetail({ entry, onNavigate }: { entry: Entry; onNavigate?: (view: string) => void }) { + return ( +
+
+
Result
+
{entry.result}
+
Ballots
+
{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 lower = winner.toLowerCase() + const bad = /declined|failed|expired/.test(lower) + const good = /approved|adopted|completed/.test(lower) + const tone = bad ? 'hist-row__verdict--bad' : good ? 'hist-row__verdict--good' : '' + 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 000000000..b059f0c9c --- /dev/null +++ b/packages/enclave-dashboard/src/Inspector.tsx @@ -0,0 +1,432 @@ +// 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. +// E3 Inspector — deep technical record of a single E3. + +import React, { useEffect, useRef, useState } from 'react' +import { STAGES } from './data' +import { CONTRACTS } from './lib/chain' +import { explorerAddress, explorerTx } from './lib/links' +import type { InspectorDetail } from './lib/adapt' +import Loader from './Loader' + +export type InspectorE3List = Array<{ id: string; label: string }> + +function Mono({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return {children} +} + +function short(hex: string): string { + if (!hex || hex.length < 14) return hex + return `${hex.slice(0, 10)}…${hex.slice(-6)}` +} + +// A hash/address rendered as a link out to the block explorer. +function ExplorerLink({ value, href }: { value: string; href: string }) { + return ( + + {short(value)} + + + ) +} + +const TxLink = ({ hash }: { hash: string }) => +const AddrLink = ({ address }: { address: string }) => + +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 isLast = i === stages.length - 1 + const state = i < currentStageIdx ? 'done' : i === currentStageIdx ? (isLast ? 'done' : '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 && ( + + + + )} + +
    TimeBlockEventStageTx
    + {ev.t} + + {typeof ev.block === 'number' ? `#${ev.block.toLocaleString()}` : ev.block} + + {ev.name} + + {ev.stage} + {ev.txHash ? : {ev.tx}}
    + No events match this filter. +
    +
    + Events stream live from the Enclave contract on Sepolia. + + Open in block explorer → + +
    +
    + ) +} + +export default function Inspector({ + e3List, + e3, + selectedId, + onSelect, + loading, + error, +}: { + e3List: InspectorE3List + e3: InspectorDetail | null + selectedId?: string + onSelect: (id: string) => void + loading?: boolean + error?: Error | null +}) { + const list = e3List + const setSelectedId = onSelect + + if (!e3) { + return ( +
    + {error ? ( +
    + {`Failed to load on-chain data: ${error.message}.`} +
    + ) : ( + + )} +
    + ) + } + + // Section status derived from the E3's current UI stage index (see STAGES order). + // The final stage (Published, index 6) is terminal: reaching it = complete. + const lastStage = STAGES.length - 1 + const stageStatus = (targetStage: number) => { + if (e3.currentStage > targetStage) return { kind: 'done', label: 'Done' } + if (e3.currentStage === targetStage) { + return targetStage >= lastStage ? { kind: 'done', label: 'Complete' } : { kind: 'live', label: 'In progress' } + } + return { kind: 'pending', label: 'Pending' } + } + + return ( +
    + {(loading || error) && ( +
    + {error ? `Failed to load on-chain data: ${error.message}.` : 'Refreshing from Sepolia…'} +
    + )} +
    +
    +
    +
    + Network + / + E3 inspector + / + {e3.id} +
    +

    {e3.summary}

    +
    + Program + {e3.program} + · + Requested by + +
    +
    +
    + + +
    +
    + + + +
    +
    +
    Status
    +
    + +
    +
    +
    +
    Committee
    +
    + {e3.committee.threshold} of {e3.committee.size} +
    +
    threshold · total nodes
    +
    +
    +
    Inputs
    +
    {e3.input.inputsReceived}
    +
    encrypted · published
    +
    +
    +
    Fee escrowed
    +
    {e3.fees.feeEscrowed}
    +
    held by Enclave
    +
    +
    +
    + + +
    + {e3.requestedAt}], + ['Request tx', ], + ['Block', #{e3.requestedBlock.toLocaleString()}], + ['Requested by', ], + ['Program', {e3.program}], + ['Program address', ], + ]} + /> + {e3.committee.size} nodes], + [ + 'Decryption threshold', + + {e3.committee.threshold} of {e3.committee.size} + , + ], + ['Selection seed', {e3.committee.selectionSeed}], + ['Drawn at', {e3.committee.drawnAt}], + ]} + /> +
    +
    + + +

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

    + {e3.keygen.scheme}], + ['Committee finalized', {e3.keygen.finalizedAt}], + ['Public key published', {e3.keygen.publishedAt}], + ['Publish tx', e3.keygen.publishedTx === '—' ? : ], + ['Committee public key', {e3.keygen.publicKey}], + ]} + /> +
    + + +
    + {e3.input.openedAt}], + ['Closes', {e3.input.closesAt}], + ['Inputs received', {e3.input.inputsReceived}], + ]} + /> + {e3.input.firstBallotAt}], + ['Last input', {e3.input.lastBallotAt}], + ]} + /> +
    +
    + + +

    {e3.compute.note}

    +
    + + +

    {e3.decryption.note}

    + + {e3.decryption.threshold} of {e3.decryption.committeeSize} + , + ], + ]} + /> +
    + + +

    {e3.publication.note}

    + {e3.publication.resultTx && ]]} />} +
    + + +
    + {e3.fees.feeEscrowed}], + ['Committee reward paid', {e3.fees.committeeReward}], + ['Settlement', {e3.fees.currency}], + ]} + /> +

    + Fee escrowed is the amount currently held by the Enclave contract for this E3; it is released to the committee and any refund on + settlement, so a completed E3 reads 0. Committee reward shows the total paid out once rewards are distributed. +

    +
    +
    + + + + +
    + ) +} diff --git a/packages/enclave-dashboard/src/Loader.tsx b/packages/enclave-dashboard/src/Loader.tsx new file mode 100644 index 000000000..7c69c1231 --- /dev/null +++ b/packages/enclave-dashboard/src/Loader.tsx @@ -0,0 +1,23 @@ +// 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. +// Shared on-chain loading indicator: accent spinner + shimmering skeleton rows. + +export default function Loader({ label = 'Loading on-chain data', sub = 'Reading from Sepolia…' }: { label?: string; sub?: string }) { + return ( +
    +
    + ) +} diff --git a/packages/enclave-dashboard/src/PollCard.tsx b/packages/enclave-dashboard/src/PollCard.tsx new file mode 100644 index 000000000..1a6aac72d --- /dev/null +++ b/packages/enclave-dashboard/src/PollCard.tsx @@ -0,0 +1,238 @@ +// 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. +// Today's CRISP poll card — the most prominent surface when a poll is live. + +import { useState } from 'react' +import { STAGES, STAGE_STATUS, type Poll } from './data' +import { LINKS } from './lib/links' + +// Live "time remaining" for the input window, from the on-chain close time. +function formatRemaining(closesTs: number): string { + if (!closesTs) return 'Voting open' + const secs = closesTs - Math.floor(Date.now() / 1000) + if (secs <= 0) return 'Voting closed' + const d = Math.floor(secs / 86400) + const h = Math.floor((secs % 86400) / 3600) + const m = Math.floor((secs % 3600) / 60) + if (d > 0) return `${d} day${d === 1 ? '' : 's'}, ${h} hour${h === 1 ? '' : 's'} remaining` + if (h > 0) return `${h} hour${h === 1 ? '' : 's'}, ${m} min remaining` + if (m > 0) return `${m} min remaining` + return `${secs}s remaining` +} + +function StageBadge({ stageIdx }: { stageIdx: number }) { + const s = STAGES[stageIdx] + const variant = stageIdx >= 6 ? 'published' : stageIdx === 3 ? 'open' : 'working' + return ( + + + {s.label} + + ) +} + +function EncryptedBallotGrid({ count }: { count: number }) { + return ( +
    +
    +
    + {count.toLocaleString()} +
    +
    + encrypted ballots received +
    Each is sealed on the voter's device. None have been opened.
    +
    +
    +
    + ) +} + +function PrivacyExplainer() { + const [open, setOpen] = useState(false) + return ( +
    + + {open && ( +
    +
      +
    1. + 1 +
      + Sealed on your device. Each ballot is encrypted in your browser before it leaves you. The Interfold network only ever + sees ciphertext. +
      +
    2. +
    3. + 2 +
      + No single party holds the key. A freshly-drawn committee generates a shared key collaboratively. No member can + decrypt anything alone. +
      +
    4. +
    5. + 3 +
      + The tally happens under encryption. Votes are added together while still sealed. The aggregate — and only the + aggregate — is ever decrypted. +
      +
    6. +
    7. + 4 +
      + Verifiable on-chain. Every stage transition is recorded publicly, so anyone can check that the process was followed. + Individual ballots remain sealed forever. +
      +
    8. +
    + + Read the full privacy model → + +
    + )} +
    + ) +} + +export default function PollCard({ + pollState, + currentStageIdx, + liveMode, + onToggleLive, + ballotCount, + onNavigate, + poll, +}: { + pollState: string + currentStageIdx: number + liveMode?: boolean + onToggleLive?: () => void + ballotCount?: number + onNavigate?: (view: string) => void + poll: Poll +}) { + const effective = { ...poll, ballotCount: ballotCount ?? poll.ballotCount } + const safeStageIdx = Math.min(Math.max(currentStageIdx, 0), STAGES.length - 1) + const stageId = STAGES[safeStageIdx].id + const status = STAGE_STATUS[stageId] + const isPublished = pollState === 'published' + const isOpen = pollState === 'open' + const isComputing = pollState === 'computing' + // Live countdown only meaningful while the input window is open. + const timeValue = stageId === 'input' ? formatRemaining(poll.closesTs) : status.label + + return ( +
    +
    +
    +
    + Today on CRISP + · + {poll.id} +
    + {onToggleLive && ( + + )} + +
    + +

    {poll.question}

    +

    {poll.context}

    + +
    +
    +
    Time
    +
    {timeValue}
    +
    {status.sub}
    +
    +
    +
    Opened
    +
    {poll.opened}
    +
    +
    +
    Closes
    +
    {poll.closes}
    +
    +
    + + {!isPublished && ( +
    +
    + + {isOpen + ? "Voting is open. Ballots are encrypted on the voter's device and submitted to the network." + : isComputing + ? 'Voting has closed. The committee is now tallying the encrypted ballots.' + : 'Voting has not opened yet for this poll.'} + + { + if (onNavigate) { + e.preventDefault() + onNavigate('inspector') + } + }} + > + Inspect this E3 + + +
    +
    + )} + + {isPublished && ( +
    + +
    + )} +
    + + +
    + ) +} diff --git a/packages/enclave-dashboard/src/Pulse.tsx b/packages/enclave-dashboard/src/Pulse.tsx new file mode 100644 index 000000000..230d8fa71 --- /dev/null +++ b/packages/enclave-dashboard/src/Pulse.tsx @@ -0,0 +1,37 @@ +// 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. +// Network pulse — small, low-emphasis footer strip. + +export default function Pulse({ data }: { data: { activeNow: number; ballots24h: number; pollsAllTime: number } }) { + return ( +
    +
    +
    +
    +
    +
    + {data.activeNow} + active E3{data.activeNow === 1 ? '' : 's'} right now +
    +
    + {data.ballots24h.toLocaleString()} + encrypted ballots, last 24h +
    +
    + {data.pollsAllTime.toLocaleString()} + CRISP polls, all-time +
    +
    +
    + + All systems nominal +
    +
    +
    + ) +} diff --git a/packages/enclave-dashboard/src/Timeline.tsx b/packages/enclave-dashboard/src/Timeline.tsx new file mode 100644 index 000000000..86e4d9f49 --- /dev/null +++ b/packages/enclave-dashboard/src/Timeline.tsx @@ -0,0 +1,109 @@ +// 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. +// Live E3 timeline — the hero pipeline. + +import React, { useState } from 'react' +import type { Stage } from './data' + +function StageDot({ state }: { state: 'done' | 'active' | 'todo' }) { + if (state === 'done') { + return ( + + ) + } + if (state === 'active') { + return ( + + ) + } + return