Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions public/wasm/zk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# ZK circuit artifacts

This directory hosts the Groth16 (BLS12-381) circuit artifacts used by the
anonymous meter-reading proof module.

| File | Purpose | Size |
| -------------------- | ---------------------------------------- | ------- |
| `circuit.wasm` | Compiled witness generator (circom) | ~5 MB |
| `circuit_final.zkey` | Groth16 proving key | ~150 MB |

## Why these are not committed

`circuit_final.zkey` is ~150 MB and **must not** be committed or re-downloaded
per proof. In production it is hosted on a CDN (e.g. S3 + CloudFront) and pulled
on demand into IndexedDB by [`src/services/keyCache.ts`](../../../src/services/keyCache.ts),
which:

- resumes interrupted transfers with HTTP `Range` requests,
- verifies a SHA-256 integrity hash before use, and
- evicts least-recently-used keys to stay within a storage budget.

## Configuration

Point the prover at the hosted key via environment variables (see
[`src/services/zkProver.ts`](../../../src/services/zkProver.ts)):

```
NEXT_PUBLIC_ZK_ZKEY_URL=https://cdn.example.com/zk/circuit_final.zkey
NEXT_PUBLIC_ZK_ZKEY_SHA256=<lowercase hex sha-256 of the zkey>
```

The `.placeholder` files in this directory exist only so the path resolves in
local development; replace them (or override the URL) with the real artifacts.
2 changes: 2 additions & 0 deletions public/wasm/zk/circuit.wasm.placeholder
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PLACEHOLDER — the real compiled circuit witness generator (circuit.wasm) is
produced by `circom` and served from this path. See README.md in this directory.
2 changes: 2 additions & 0 deletions public/wasm/zk/circuit_final.zkey.placeholder
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
PLACEHOLDER — the real Groth16 proving key (~150 MB) is CDN-hosted and fetched
into IndexedDB at runtime. See README.md in this directory.
235 changes: 235 additions & 0 deletions src/components/panels/ZKSubmissionPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
"use client";

import { useMemo, useState } from "react";
import type { MeterReading, ZKSubmissionStatus } from "@/types/zk";
import { CONSUMPTION_MAX_KWH, CONSUMPTION_MIN_KWH } from "@/types/zk";
import {
useZKSubmission,
type UseZKSubmissionOptions,
} from "@/hooks/useZKSubmission";

/**
* UI panel for submitting an encrypted meter reading together with a Groth16
* zero-knowledge proof. The reading and meter identity never leave the device;
* only the ciphertext, proof and public signals are sent on-chain. A stepper
* surfaces each phase: key download → proving → submitting → verification.
*/

export interface ZKSubmissionPanelProps
extends Omit<UseZKSubmissionOptions, "buildContext"> {
buildContext: UseZKSubmissionOptions["buildContext"];
className?: string;
}

interface Step {
key: Exclude<ZKSubmissionStatus, "idle">;
label: string;
}

const STEPS: Step[] = [
{ key: "downloading-key", label: "Cache proving key" },
{ key: "proving", label: "Generate proof" },
{ key: "submitting", label: "Submit encrypted" },
{ key: "confirmed", label: "Verify on-chain" },
];

/** Index of the step a given status belongs to (rejected maps to the last). */
function statusToStepIndex(status: ZKSubmissionStatus): number {
switch (status) {
case "downloading-key":
return 0;
case "proving":
return 1;
case "submitting":
return 2;
case "confirmed":
case "rejected":
return 3;
default:
return -1;
}
}

export function ZKSubmissionPanel({
className,
buildContext,
...options
}: ZKSubmissionPanelProps) {
const { state, submit, cancel, reset } = useZKSubmission({
...options,
buildContext,
});

const [meterId, setMeterId] = useState("");
const [consumption, setConsumption] = useState("");

const activeIndex = statusToStepIndex(state.status);
const busy =
state.status === "downloading-key" ||
state.status === "proving" ||
state.status === "submitting";

const consumptionError = useMemo(() => {
if (consumption === "") return null;
const n = Number(consumption);
if (!Number.isInteger(n)) return "Enter a whole number of kWh.";
if (n < CONSUMPTION_MIN_KWH || n > CONSUMPTION_MAX_KWH) {
return `Must be between ${CONSUMPTION_MIN_KWH} and ${CONSUMPTION_MAX_KWH} kWh.`;
}
return null;
}, [consumption]);

const canSubmit =
!busy && meterId.trim() !== "" && consumption !== "" && !consumptionError;

const handleSubmit = () => {
const reading: MeterReading = {
meterId: meterId.trim(),
consumption: Number(consumption),
};
void submit(reading);
};

return (
<div
className={`rounded-xl border border-border bg-background p-6 space-y-6 ${
className ?? ""
}`}
>
<div className="space-y-1">
<h3 className="text-lg font-semibold">Anonymous Meter Submission</h3>
<p className="text-sm text-muted-foreground">
Your reading is encrypted and proven within range without revealing
the value or meter identity.
</p>
</div>

{/* Input form */}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Meter ID</span>
<input
value={meterId}
onChange={(e) => setMeterId(e.target.value)}
disabled={busy}
placeholder="0x…"
className="rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Consumption (kWh)</span>
<input
value={consumption}
onChange={(e) => setConsumption(e.target.value)}
disabled={busy}
inputMode="numeric"
placeholder="0 – 10000"
className="rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/>
{consumptionError && (
<span className="text-xs text-red-500">{consumptionError}</span>
)}
</label>
</div>

{/* Stepper */}
<ol className="space-y-3">
{STEPS.map((step, i) => {
const done = activeIndex > i || state.status === "confirmed";
const active = activeIndex === i && busy;
const failed = state.status === "rejected" && i === STEPS.length - 1;
return (
<li key={step.key} className="flex items-center gap-3">
<span
aria-hidden="true"
className={[
"flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold transition-colors",
failed
? "border-red-500 bg-red-500/10 text-red-500"
: done
? "border-green-500 bg-green-500/10 text-green-500"
: active
? "border-ring bg-accent text-foreground animate-pulse"
: "border-border text-muted-foreground",
].join(" ")}
>
{failed ? "✕" : done ? "✓" : i + 1}
</span>
<span
className={`text-sm ${
active || done ? "font-medium" : "text-muted-foreground"
}`}
>
{step.label}
</span>
</li>
);
})}
</ol>

{/* Progress bar */}
<div
className="h-2 w-full overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={state.progress}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={`h-full transition-all duration-300 ${
state.status === "rejected" ? "bg-red-500" : "bg-green-500"
}`}
style={{ width: `${state.progress}%` }}
/>
</div>

{/* Status line */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{state.status === "idle"
? "Ready"
: state.status === "confirmed"
? "Proof verified and submitted."
: state.status === "rejected"
? state.error ?? "Submission rejected."
: `${STEPS[activeIndex]?.label ?? "Working"}… ${state.progress}%`}
</span>
{state.proofHash && (
<code className="text-xs text-muted-foreground" title={state.proofHash}>
{state.proofHash.slice(0, 10)}…
</code>
)}
</div>

{/* Actions */}
<div className="flex items-center gap-3">
{busy ? (
<button
onClick={cancel}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
Cancel
</button>
) : (
<button
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
>
Generate &amp; Submit
</button>
)}
{(state.status === "confirmed" || state.status === "rejected") && (
<button
onClick={reset}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
New Submission
</button>
)}
</div>
</div>
);
}

export default ZKSubmissionPanel;
Loading
Loading