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
37 changes: 37 additions & 0 deletions public/wasm/bulletproofs.README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Bulletproofs range-proof WASM

The Proof-of-Reserve worker (`src/workers/proofWorker.worker.ts`) loads a
Bulletproofs range prover from `bulletproofs.wasm` in this directory.

## Building

Compile the dalek `bulletproofs` crate with `wasm-pack`:

```
wasm-pack build --target bundler --release
```

Constraints (see `src/types/reserve.ts`):

- Range proof over `[0, 2^64)` with a 64-bit commitment.
- Gzipped binary **must not exceed 2 MB**.
- Proof generation must complete within ~5 s on a Snapdragon 7c tablet.

## Integrity

Host the binary and pin its SHA-256 so the loader rejects tampering:

```
NEXT_PUBLIC_BULLETPROOFS_WASM_URL=https://cdn.example.com/wasm/bulletproofs.wasm
NEXT_PUBLIC_BULLETPROOFS_WASM_SHA256=<lowercase hex sha-256>
```

`src/utils/wasmLoader.ts` (`loadWasmModule`) fetches, verifies, instantiates and
caches it.

## Development fallback

Until the binary is present the worker emits a **clearly-marked deterministic
placeholder** proof so the end-to-end flow works in development. The placeholder
is **not** zero-knowledge and must never be used for real attestation — drop the
real `bulletproofs.wasm` here (or set the env URL) before production use.
162 changes: 162 additions & 0 deletions src/components/panels/ReserveAttestationPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"use client";

import { useMemo } from "react";
import {
useProofOfReserve,
type UseProofOfReserveResult,
} from "@/hooks/useProofOfReserve";
import type { ProofOfReserveConfig, ProofOfReserveDeps } from "@/services/proofOfReserve";
import type { ProofStatus } from "@/types/reserve";

/**
* Panel showing the latest Proof-of-Reserve attestation: proof hash, ledger,
* time since the last proof, and a red banner when insolvency is detected.
*/

export interface ReserveAttestationPanelProps {
config: ProofOfReserveConfig;
deps?: ProofOfReserveDeps;
/** Override the hook (testing). */
controller?: UseProofOfReserveResult;
className?: string;
}

const STATUS_LABEL: Record<ProofStatus, string> = {
idle: "No attestation yet",
fetching: "Fetching commitment & inventory…",
proving: "Generating range proof…",
submitting: "Submitting attestation…",
confirmed: "Attested",
insolvent: "Insolvency detected",
error: "Proof failed",
};

function timeSince(ms: number): string {
const secs = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
return `${hrs}h ago`;
}

function shortHash(hex: string): string {
return hex.length > 14 ? `${hex.slice(0, 10)}…${hex.slice(-4)}` : hex;
}

export function ReserveAttestationPanel({
config,
deps,
controller,
className,
}: ReserveAttestationPanelProps) {
const internal = useProofOfReserve(deps);
const { state, generate, reset } = controller ?? internal;

const busy =
state.status === "fetching" ||
state.status === "proving" ||
state.status === "submitting";

const sinceLabel = useMemo(
() => (state.result ? timeSince(state.result.provedAt) : null),
[state.result]
);

return (
<div
className={`rounded-xl border border-border bg-background p-6 space-y-5 ${
className ?? ""
}`}
>
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">Proof of Reserve</h3>
<p className="text-sm text-muted-foreground">
Cryptographic attestation that reserves back on-chain liabilities.
</p>
</div>
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
state.status === "confirmed"
? "bg-green-500/10 text-green-600"
: state.status === "insolvent" || state.status === "error"
? "bg-red-500/10 text-red-600"
: "bg-muted text-muted-foreground"
}`}
>
{STATUS_LABEL[state.status]}
</span>
</div>

{/* Insolvency banner */}
{state.insolvency && (
<div
role="alert"
className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-600"
>
<p className="font-semibold">Reserves do not cover liabilities.</p>
<p className="mt-1 font-mono text-xs">
reserves {state.insolvency.auditTotal} &lt; liability{" "}
{state.insolvency.liability} (shortfall {state.insolvency.shortfall})
</p>
</div>
)}

{/* Progress */}
{state.status !== "idle" && !state.insolvency && (
<div className="space-y-2">
<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 === "error" ? "bg-red-500" : "bg-green-500"
}`}
style={{ width: `${state.progress}%` }}
/>
</div>
{state.error && state.status === "error" && (
<p className="text-xs text-red-500">{state.error}</p>
)}
</div>
)}

{/* Attestation detail */}
{state.result && (
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Attestation hash</dt>
<dd className="text-right font-mono" title={state.result.attestationHash}>
{shortHash(state.result.attestationHash)}
</dd>
<dt className="text-muted-foreground">Ledger</dt>
<dd className="text-right font-mono">
{state.result.ledger ?? "—"}
</dd>
<dt className="text-muted-foreground">Last proof</dt>
<dd className="text-right">{sinceLabel}</dd>
</dl>
)}

<div className="flex gap-3">
<button
onClick={() => void generate(config)}
disabled={busy}
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"
>
{busy ? "Working…" : "Generate attestation"}
</button>
{(state.status === "confirmed" ||
state.status === "insolvent" ||
state.status === "error") && (
<button
onClick={reset}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
>
Reset
</button>
)}
</div>
</div>
);
}

export default ReserveAttestationPanel;
96 changes: 96 additions & 0 deletions src/hooks/useProofOfReserve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"use client";

import { useCallback, useRef, useState } from "react";
import {
InsolvencyError,
runProofOfReserve,
type ProofOfReserveConfig,
type ProofOfReserveDeps,
type ProgressPhase,
} from "@/services/proofOfReserve";
import type { ProofState, ProofStatus } from "@/types/reserve";

/**
* React hook wrapping the Proof-of-Reserve flow. Exposes status, 0–100 progress
* and the attestation result, mapping the orchestrator's progress phases
* (fetch 25 → prove 50 → submit 75 → confirm 100) and surfacing an insolvency
* report when reserves fall short.
*/

const INITIAL_STATE: ProofState = {
status: "idle",
progress: 0,
result: null,
insolvency: null,
error: null,
};

const PHASE_STATUS: Record<ProgressPhase, ProofStatus> = {
fetching: "fetching",
proving: "proving",
submitting: "submitting",
confirmed: "confirmed",
};

export interface UseProofOfReserveResult {
state: ProofState;
/** Run the proof flow for the given config. */
generate: (config: ProofOfReserveConfig) => Promise<void>;
reset: () => void;
}

export function useProofOfReserve(
deps?: ProofOfReserveDeps
): UseProofOfReserveResult {
const [state, setState] = useState<ProofState>(INITIAL_STATE);
const runningRef = useRef(false);
const depsRef = useRef(deps);
depsRef.current = deps;

const reset = useCallback(() => setState(INITIAL_STATE), []);

const generate = useCallback(async (config: ProofOfReserveConfig) => {
if (runningRef.current) return;
runningRef.current = true;
setState({ ...INITIAL_STATE, status: "fetching", progress: 5 });

try {
const outcome = await runProofOfReserve(
config,
depsRef.current ?? {},
(phase, percent) => {
setState((s) => ({ ...s, status: PHASE_STATUS[phase], progress: percent }));
}
);
setState({
status: "confirmed",
progress: 100,
result: outcome.result,
insolvency: null,
error: null,
});
} catch (err) {
if (err instanceof InsolvencyError) {
setState({
status: "insolvent",
progress: 100,
result: null,
insolvency: err.report,
error: err.message,
});
} else if ((err as Error)?.name === "AbortError") {
setState(INITIAL_STATE);
} else {
setState((s) => ({
...s,
status: "error",
error: (err as Error).message,
}));
}
} finally {
runningRef.current = false;
}
}, []);

return { state, generate, reset };
}
Loading
Loading