From cd4710a0732465500af1f548607c6947a9c7d3bd Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Fri, 29 May 2026 15:13:44 +0100 Subject: [PATCH 1/3] chore: better fetching --- packages/enclave-dashboard/.env.example | 8 ++-- packages/enclave-dashboard/src/App.tsx | 22 +++++++-- packages/enclave-dashboard/src/Inspector.tsx | 11 +++-- packages/enclave-dashboard/src/PollCard.tsx | 23 ++++++---- packages/enclave-dashboard/src/lib/adapt.ts | 26 ++++++++--- packages/enclave-dashboard/src/lib/chain.ts | 8 ++-- packages/enclave-dashboard/src/lib/e3.ts | 47 +++++++++++++------- 7 files changed, 97 insertions(+), 48 deletions(-) diff --git a/packages/enclave-dashboard/.env.example b/packages/enclave-dashboard/.env.example index afcfb1537..0728a51aa 100644 --- a/packages/enclave-dashboard/.env.example +++ b/packages/enclave-dashboard/.env.example @@ -6,13 +6,13 @@ # 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 +# VITE_ENCLAVE_ADDRESS=0x670eFE043d1D340148037b4b76c4F9dfED294309 +# VITE_CIPHERNODE_REGISTRY_ADDRESS=0x4D707127F72a216EA116AF0B4262dD7382F84259 +# VITE_CRISP_PROGRAM_ADDRESS=0xbCc418F4dd1266Cc6070b1e2AC728ef56De946e7 # 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 +# VITE_DEPLOY_BLOCK=10939869 # E3 timeout windows (seconds), from the deployment's timeoutConfig. Used to # decide whether an E3 is still active vs. expired (input close + these windows). diff --git a/packages/enclave-dashboard/src/App.tsx b/packages/enclave-dashboard/src/App.tsx index bd03e5680..f40431708 100644 --- a/packages/enclave-dashboard/src/App.tsx +++ b/packages/enclave-dashboard/src/App.tsx @@ -135,7 +135,15 @@ function SiteFooter() { // Fixed presentation density (the live tweak panel was removed). const DENSITY = 'comfortable' -const pollStateForStage = (uiStageIdx: number) => (uiStageIdx >= 6 ? 'published' : uiStageIdx >= 4 ? 'computing' : 'open') +// Derive the poll-card state from the UI stage + ballot count. Specifically, +// when the input window has closed (uiStageIdx >= 4) but no ballots ever arrived, +// the committee isn't actually tallying anything — surface that as a distinct +// "idle" state instead of falsely claiming a tally is in progress. +const pollStateForStage = (uiStageIdx: number, ballotCount: number): string => { + if (uiStageIdx >= 6) return 'published' + if (uiStageIdx >= 4) return ballotCount === 0 ? 'idle' : 'computing' + return 'open' +} // Synthetic poll used only for the "Watch the lifecycle" demo when nothing is live. const DEMO_POLL: Poll = { @@ -189,8 +197,14 @@ export default function App() { // (archived). Card data comes straight from the list summary — no per-poll fetch. const crispReady = crispPolls.status === 'ready' const polls = useMemo(() => crispPolls.data ?? [], [crispPolls.data]) - const activePolls = useMemo(() => polls.filter((p) => isE3Active(p.stage, p.inputWindow[1])), [polls]) - const pastPolls = useMemo(() => polls.filter((p) => !isE3Active(p.stage, p.inputWindow[1])), [polls]) + const activePolls = useMemo( + () => polls.filter((p) => isE3Active(p.stage, p.inputWindow[1], { e3Program: p.e3Program, ballotCount: p.ballotCount })), + [polls], + ) + const pastPolls = useMemo( + () => polls.filter((p) => !isE3Active(p.stage, p.inputWindow[1], { e3Program: p.e3Program, ballotCount: p.ballotCount })), + [polls], + ) const liveHistory = useMemo(() => adaptHistoryEntries(pastPolls, detailsCache), [pastPolls, detailsCache]) // Inspector tab state. @@ -298,7 +312,7 @@ export default function App() { const AddrLink = ({ address }: { address: string }) => -function InspStatusBadge({ stageIdx }: { stageIdx: number }) { +function InspStatusBadge({ stageIdx, label }: { stageIdx: number; label?: string }) { const s = STAGES[stageIdx] const variant = stageIdx >= 6 ? 'published' : stageIdx === 3 ? 'open' : 'working' return ( - {s.label} + {label ?? s.label} ) } @@ -236,8 +236,13 @@ export default function Inspector({ // 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. + // When `noBallots` is set the chain stayed in KeyPublished (currentStage=4) but + // nothing was ever submitted, so post-input sections aren't actually "in progress". const lastStage = STAGES.length - 1 const stageStatus = (targetStage: number) => { + if (e3.noBallots && targetStage >= 4) { + return targetStage === 4 ? { kind: 'pending', label: 'No ballots' } : { kind: 'pending', label: 'Skipped' } + } 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' } @@ -300,7 +305,7 @@ export default function Inspector({
Status
- +
diff --git a/packages/enclave-dashboard/src/PollCard.tsx b/packages/enclave-dashboard/src/PollCard.tsx index 1a6aac72d..81938ff45 100644 --- a/packages/enclave-dashboard/src/PollCard.tsx +++ b/packages/enclave-dashboard/src/PollCard.tsx @@ -23,13 +23,13 @@ function formatRemaining(closesTs: number): string { return `${secs}s remaining` } -function StageBadge({ stageIdx }: { stageIdx: number }) { +function StageBadge({ stageIdx, label }: { stageIdx: number; label?: string }) { const s = STAGES[stageIdx] const variant = stageIdx >= 6 ? 'published' : stageIdx === 3 ? 'open' : 'working' return ( - {s.label} + {label ?? s.label} ) } @@ -138,8 +138,11 @@ export default function PollCard({ 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 + const isIdle = pollState === 'idle' // input window closed with no ballots + // Live countdown only meaningful while the input window is open. For idle + // (window closed with no ballots) override the canned "In progress" copy. + const timeValue = stageId === 'input' ? formatRemaining(poll.closesTs) : isIdle ? 'Closed · no ballots' : status.label + const statusSub = isIdle ? 'No encrypted ballots were submitted before close' : status.sub return (
@@ -172,7 +175,7 @@ export default function PollCard({ {liveMode ? 'Pause demo' : 'Watch the lifecycle'} )} - +

{poll.question}

@@ -182,7 +185,7 @@ export default function PollCard({
Time
{timeValue}
-
{status.sub}
+
{statusSub}
Opened
@@ -200,9 +203,11 @@ export default function PollCard({ {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.'} + : isIdle + ? 'Voting has closed without any ballots submitted.' + : isComputing + ? 'Voting has closed. The committee is now tallying the encrypted ballots.' + : 'Voting has not opened yet for this poll.'} = 4 return { id: formatE3Id(detail.id), @@ -153,8 +163,8 @@ export function adaptInspectorDetail(detail: E3FullDetails | null): InspectorDet requestedBy: detail.requester, requestedByLabel: 'Requester', requestedTx: detail.requestTxHash, - requestedAt: detail.requestedAt ? fmtUtcFromUnix(detail.requestedAt) : `block #${detail.requestBlock.toString()}`, - requestedBlock: Number(detail.requestBlock), + requestedAt: detail.requestedAt ? fmtUtcFromUnix(detail.requestedAt) : '—', + requestedBlock: detail.requestEventBlock != null ? Number(detail.requestEventBlock) : 0, currentStage: detail.uiStageIdx, summary: isCrisp ? meta.question : `Encrypted execution ${formatE3Id(detail.id)}`, @@ -188,9 +198,10 @@ export function adaptInspectorDetail(detail: E3FullDetails | null): InspectorDet }, compute: { - status: detail.uiStageIdx >= 4 ? 'active' : 'pending', - note: - detail.uiStageIdx < 4 + status: noBallots ? 'idle' : detail.uiStageIdx >= 4 ? 'active' : 'pending', + note: noBallots + ? 'The input window closed without any ballots being submitted. There is nothing to compute over.' + : detail.uiStageIdx < 4 ? 'Compute begins automatically when the input window closes.' : "The program's FHE computation runs over the encrypted inputs, without decrypting any individual input.", }, @@ -213,6 +224,7 @@ export function adaptInspectorDetail(detail: E3FullDetails | null): InspectorDet resultTx: detail.resultTxHash, }, + noBallots, events: buildEventLog(detail), } } @@ -226,7 +238,7 @@ function buildEventLog(d: E3FullDetails): InspectorEvent[] { const evs: InspectorEvent[] = [] evs.push({ t: d.requestedAt ? fmtClock(d.requestedAt) : '—', - block: Number(d.requestBlock), + block: d.requestEventBlock != null ? Number(d.requestEventBlock) : '—', name: 'E3Requested', stage: 'Requested', tx: shortHash(d.requestTxHash), diff --git a/packages/enclave-dashboard/src/lib/chain.ts b/packages/enclave-dashboard/src/lib/chain.ts index a7a3329c2..8f05fbb7e 100644 --- a/packages/enclave-dashboard/src/lib/chain.ts +++ b/packages/enclave-dashboard/src/lib/chain.ts @@ -44,13 +44,13 @@ export const publicClient = createPublicClient({ }) export const CONTRACTS = { - Enclave: envStr('VITE_ENCLAVE_ADDRESS', '0xB47B267876B60a06138Bc9dfCee7aa3E26907CCB') as Address, - CiphernodeRegistry: envStr('VITE_CIPHERNODE_REGISTRY_ADDRESS', '0x497Feea9abB72229aab1584c22b5416ff128926B') as Address, - CRISPProgram: envStr('VITE_CRISP_PROGRAM_ADDRESS', '0xba3B07aBFd0B8cad68aa1E946CC7AF5C1B1c8B5D') as Address, + Enclave: envStr('VITE_ENCLAVE_ADDRESS', '0x670eFE043d1D340148037b4b76c4F9dfED294309') as Address, + CiphernodeRegistry: envStr('VITE_CIPHERNODE_REGISTRY_ADDRESS', '0x4D707127F72a216EA116AF0B4262dD7382F84259') as Address, + CRISPProgram: envStr('VITE_CRISP_PROGRAM_ADDRESS', '0xbCc418F4dd1266Cc6070b1e2AC728ef56De946e7') as Address, } // First block to scan from — lower bound for getLogs (the Enclave deploy block). -export const DEPLOY_BLOCK = BigInt(envStr('VITE_DEPLOY_BLOCK', '10697349')) +export const DEPLOY_BLOCK = BigInt(envStr('VITE_DEPLOY_BLOCK', '10939869')) // E3 timeout windows (seconds), matching the deployment's timeoutConfig. Used to // decide whether an E3 is still genuinely active vs. expired without completing. diff --git a/packages/enclave-dashboard/src/lib/e3.ts b/packages/enclave-dashboard/src/lib/e3.ts index 850567637..00ae6764e 100644 --- a/packages/enclave-dashboard/src/lib/e3.ts +++ b/packages/enclave-dashboard/src/lib/e3.ts @@ -76,14 +76,22 @@ export function solidityStageToUiIdx(stage: number, inputWindow: [bigint, bigint } } -// Whether an E3 is genuinely still active right now. An E3 that's Complete or -// Failed isn't active; neither is one that blew past its expected deadline -// (input window close + compute + decryption windows) without completing — -// even if the chain hasn't formally marked it Failed yet. -export function isE3Active(stage: number, inputWindowClose: bigint): boolean { +// Whether an E3 is genuinely still active right now. An E3 isn't active when: +// - it's already Complete or Failed on-chain, +// - it blew past its expected deadline (input close + compute + decryption windows) +// without completing (chain hasn't formally marked it Failed yet), or +// - for input-tracked programs (CRISP), its input window has closed without +// a single ballot being submitted. The chain doesn't transition the stage +// because there's nothing to compute; functionally it's a terminal no-op. +export function isE3Active(stage: number, inputWindowClose: bigint, opts: { e3Program?: string; ballotCount?: number } = {}): boolean { if (stage === E3Stage.Complete || stage === E3Stage.Failed) return false if (inputWindowClose > 0n) { const now = BigInt(Math.floor(Date.now() / 1000)) + if (now >= inputWindowClose) { + // Input window has closed. For programs whose inputs we can observe, if + // none arrived the round is effectively done. + if (opts.e3Program && isCrispE3(opts.e3Program) && (opts.ballotCount ?? 0) === 0) return false + } const deadline = inputWindowClose + BigInt(TIMEOUTS.computeWindow + TIMEOUTS.decryptionWindow) if (now > deadline) return false } @@ -110,7 +118,10 @@ export type E3FullDetails = E3Summary & { committeePublicKey: `0x${string}` ciphertextOutput: `0x${string}` plaintextOutput: `0x${string}` - requestedAt?: number // unix seconds (block time of the request) + requestedAt?: number // unix seconds (block.timestamp of the request) + // Block number of the E3Requested log (distinct from `requestBlock` which on + // this contract version is actually a Unix timestamp, not a block number). + requestEventBlock?: bigint // From CiphernodeRegistry: committeeThreshold: [number, number] // [M, N] committeeMembers: `0x${string}`[] @@ -286,9 +297,11 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise 0n) as Promise, ]) - // Every event for this E3 happens at or after its request block, so scan from - // there instead of DEPLOY_BLOCK — bounds the work per poll tick. - const fromBlock = e3.requestBlock > DEPLOY_BLOCK ? (e3.requestBlock as bigint) : DEPLOY_BLOCK + // `e3.requestBlock` is misnamed: on this contract version it stores + // `block.timestamp` (EIP-6372 timestamp clock), not a block number. Using it + // as fromBlock would push the scan range past chain head and silently miss + // every event. Scan from the deploy block instead. + const fromBlock = DEPLOY_BLOCK // 2. Find the E3Requested tx for this id (for the inspector header). const requestLogs = await getLogsChunked( @@ -391,15 +404,14 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise 6) shownBallots.push(inputs[inputs.length - 1]) const ts = await blockTimestamps( - [ - e3.requestBlock, - finLog?.blockNumber, - pubLog?.blockNumber, - resultLog?.blockNumber, - ...shownBallots.map((l: any) => l.blockNumber), - ].filter((b): b is bigint => typeof b === 'bigint'), + [finLog?.blockNumber, pubLog?.blockNumber, resultLog?.blockNumber, ...shownBallots.map((l: any) => l.blockNumber)].filter( + (b): b is bigint => typeof b === 'bigint', + ), ) const at = (bn?: bigint) => (bn != null ? ts.get(bn.toString()) : undefined) + // `e3.requestBlock` already IS a Unix timestamp on this contract version (the + // field is misnamed — see fromBlock comment above), so we don't go to chain. + const requestedAtTs = e3.requestBlock > 0n ? Number(e3.requestBlock) : undefined return { id: e3Id, @@ -407,7 +419,8 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise Date: Fri, 29 May 2026 15:15:27 +0100 Subject: [PATCH 2/3] chore: fix footer --- packages/enclave-dashboard/src/styles.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/enclave-dashboard/src/styles.css b/packages/enclave-dashboard/src/styles.css index 0a0a23a55..642ff7c47 100644 --- a/packages/enclave-dashboard/src/styles.css +++ b/packages/enclave-dashboard/src/styles.css @@ -147,7 +147,16 @@ button { } /* ── Main shell ─────────────────────────────────────────────────────────── */ +/* Make the page a column flex container that fills at least the viewport so the + footer pins to the bottom when content is short (e.g. no live E3s). */ +.page { + display: flex; + flex-direction: column; + min-height: 100vh; +} .main { + flex: 1 0 auto; + width: 100%; max-width: var(--maxw); margin: 0 auto; padding: 56px 32px 96px; @@ -155,6 +164,9 @@ button { flex-direction: column; gap: 56px; } +.site-foot { + flex-shrink: 0; +} .page--compact .main { gap: 36px; padding-top: 36px; From 69294d8fab2e3f2f8a51df1c04f1472b0b0b820c Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Fri, 29 May 2026 15:59:11 +0100 Subject: [PATCH 3/3] chore: pr comments --- packages/enclave-dashboard/src/App.tsx | 9 ++++++--- packages/enclave-dashboard/src/Inspector.tsx | 2 +- packages/enclave-dashboard/src/lib/adapt.ts | 5 +++-- packages/enclave-dashboard/src/lib/e3.ts | 9 ++++++--- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/enclave-dashboard/src/App.tsx b/packages/enclave-dashboard/src/App.tsx index f40431708..46a492106 100644 --- a/packages/enclave-dashboard/src/App.tsx +++ b/packages/enclave-dashboard/src/App.tsx @@ -163,7 +163,7 @@ export default function App() { const [pollState, setPollState] = useState('open') const [stageIdx, setStageIdx] = useState(3) - const [, setNowTick] = useState(0) + const [nowTick, setNowTick] = useState(0) const [liveMode, setLiveMode] = useState(false) // Demo autoplay step, persisted so pausing/resuming continues where it left off. const liveStepRef = useRef(0) @@ -197,13 +197,16 @@ export default function App() { // (archived). Card data comes straight from the list summary — no per-poll fetch. const crispReady = crispPolls.status === 'ready' const polls = useMemo(() => crispPolls.data ?? [], [crispPolls.data]) + // `nowTick` is in the deps so isE3Active (which reads Date.now()) re-evaluates + // each second-tick and polls move from active → past as their windows close, + // rather than waiting for the next 15s on-chain refresh. const activePolls = useMemo( () => polls.filter((p) => isE3Active(p.stage, p.inputWindow[1], { e3Program: p.e3Program, ballotCount: p.ballotCount })), - [polls], + [polls, nowTick], ) const pastPolls = useMemo( () => polls.filter((p) => !isE3Active(p.stage, p.inputWindow[1], { e3Program: p.e3Program, ballotCount: p.ballotCount })), - [polls], + [polls, nowTick], ) const liveHistory = useMemo(() => adaptHistoryEntries(pastPolls, detailsCache), [pastPolls, detailsCache]) diff --git a/packages/enclave-dashboard/src/Inspector.tsx b/packages/enclave-dashboard/src/Inspector.tsx index 0842bc1a9..36bb5c471 100644 --- a/packages/enclave-dashboard/src/Inspector.tsx +++ b/packages/enclave-dashboard/src/Inspector.tsx @@ -334,7 +334,7 @@ export default function Inspector({ items={[ ['Requested at', {e3.requestedAt}], ['Request tx', ], - ['Block', #{e3.requestedBlock.toLocaleString()}], + ['Block', {e3.requestedBlock != null ? `#${e3.requestedBlock.toLocaleString()}` : '—'}], ['Requested by', ], ['Program', {e3.program}], ['Program address', ], diff --git a/packages/enclave-dashboard/src/lib/adapt.ts b/packages/enclave-dashboard/src/lib/adapt.ts index 23c8bfcb3..2bdecb520 100644 --- a/packages/enclave-dashboard/src/lib/adapt.ts +++ b/packages/enclave-dashboard/src/lib/adapt.ts @@ -100,7 +100,8 @@ export type InspectorDetail = { requestedByLabel: string requestedTx: string requestedAt: string - requestedBlock: number + + requestedBlock: number | null currentStage: number summary: string committee: { size: number; threshold: number; selectionSeed: string; drawnAt: string } @@ -164,7 +165,7 @@ export function adaptInspectorDetail(detail: E3FullDetails | null): InspectorDet requestedByLabel: 'Requester', requestedTx: detail.requestTxHash, requestedAt: detail.requestedAt ? fmtUtcFromUnix(detail.requestedAt) : '—', - requestedBlock: detail.requestEventBlock != null ? Number(detail.requestEventBlock) : 0, + requestedBlock: detail.requestEventBlock != null ? Number(detail.requestEventBlock) : null, currentStage: detail.uiStageIdx, summary: isCrisp ? meta.question : `Encrypted execution ${formatE3Id(detail.id)}`, diff --git a/packages/enclave-dashboard/src/lib/e3.ts b/packages/enclave-dashboard/src/lib/e3.ts index 00ae6764e..a6261d841 100644 --- a/packages/enclave-dashboard/src/lib/e3.ts +++ b/packages/enclave-dashboard/src/lib/e3.ts @@ -300,8 +300,8 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise( @@ -309,11 +309,14 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise l.args.e3Id === e3Id) const requestTxHash = (requestLog?.transactionHash ?? ('0x' as `0x${string}`)) as `0x${string}` + // Nothing for this E3 can exist on chain before its E3Requested block, so + // tighten the later scans (committee, inputs, result) to start there. + const fromBlock: bigint = requestLog?.blockNumber ?? DEPLOY_BLOCK // 3. Committee data: requested (threshold/seed) + finalized (members) from the // registry; the key-publish moment from the Enclave E3StageChanged → KeyPublished