Skip to content

Commit 5a39dd8

Browse files
committed
feat(live-mode): real Sepolia reads + wallet connect + permissionless tx
Adds a Demo/Live mode toggle so the curated demo (used for the video) can coexist with a real on-chain integration (so visitors to the live URL can verify the contracts work). * lib/mode.tsx — AppMode context, persisted in localStorage. * lib/chain.ts — ethers v6 read provider against public Sepolia RPC, minimal ABI subsets, snapshot fetcher, MetaMask helpers, computeScore tx submission. * lib/wallet.tsx — WalletProvider tracking the injected wallet, listens to accountsChanged/chainChanged for live UI updates. * components/ModeToggle.tsx, WalletPill.tsx — top-chrome controls. * components/LiveSnapshot.tsx — replaces the demo-mode contrast block with real on-chain truth: registry.count(), credit.regulator(), credit.pool(), credit.governor(), block height. Includes an honest disclosure that grantRegulatorAccess can't run for a random visitor (governor registration + relayer-sdk inputs required). * components/ComputeScoreButton.tsx — the one permissionless write we surface: computeScore(id) triggers real FHE.add/mul/sub/select on encrypted state and re-grants ACL to pool + borrower wallet. Visitors with Sepolia ETH can sign a real tx and get an Etherscan link as proof. Honest split: Demo is the story (mock data, video-friendly). Live is the proof (real chain reads, optional real tx). Both ship side-by-side. Verified in browser: - Live mode reads block #10,829,956 + 0 borrowers + regulator unset + pool wired (0xe93C…) + governor (0x5E4F…) live from Sepolia. - Demo mode unchanged, persona-in-app fix and bilingual UI still work.
1 parent 355fc14 commit 5a39dd8

10 files changed

Lines changed: 1101 additions & 5 deletions

File tree

frontend/src/app/layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Metadata } from "next";
22
import { Nunito, Inter_Tight, JetBrains_Mono } from "next/font/google";
33
import { I18nProvider } from "@/lib/i18n";
4+
import { ModeProvider } from "@/lib/mode";
5+
import { WalletProvider } from "@/lib/wallet";
46
import "./globals.css";
57

68
const nunito = Nunito({
@@ -70,7 +72,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
7072
className={`${nunito.variable} ${interTight.variable} ${jetbrainsMono.variable}`}
7173
>
7274
<body>
73-
<I18nProvider>{children}</I18nProvider>
75+
<I18nProvider>
76+
<ModeProvider>
77+
<WalletProvider>{children}</WalletProvider>
78+
</ModeProvider>
79+
</I18nProvider>
7480
</body>
7581
</html>
7682
);

frontend/src/app/page.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { PersonaSwitcher } from "@/components/PersonaSwitcher";
77
import { AppTile } from "@/components/AppTile";
88
import { PublicVsConclave } from "@/components/PublicVsConclave";
99
import { LanguageToggle } from "@/components/LanguageToggle";
10+
import { ModeToggle } from "@/components/ModeToggle";
11+
import { WalletPill } from "@/components/WalletPill";
12+
import { LiveSnapshot } from "@/components/LiveSnapshot";
1013
import { RegistryApp } from "@/apps/RegistryApp";
1114
import { ScoreApp } from "@/apps/ScoreApp";
1215
import { PoolApp } from "@/apps/PoolApp";
@@ -20,6 +23,7 @@ import {
2023
ETHERSCAN_BASE,
2124
} from "@/lib/demo-data";
2225
import { useT } from "@/lib/i18n";
26+
import { useMode } from "@/lib/mode";
2327

2428
type AppId = "registry" | "score" | "pool" | "conclave";
2529
type View = "home" | AppId;
@@ -63,12 +67,15 @@ export default function Home() {
6367
const [state, dispatch] = useReducer(reducer, initial);
6468
const persona = personaById(state.persona);
6569
const t = useT();
70+
const { mode } = useMode();
6671

6772
return (
6873
<main className="relative flex min-h-screen flex-col items-center justify-start gap-6 px-4 py-10 sm:px-8 sm:py-14">
69-
{/* Language toggle, fixed top-right so it's always reachable
70-
(otherwise users would scroll up just to switch language) */}
71-
<div className="fixed right-4 top-4 z-50 sm:right-6 sm:top-6">
74+
{/* Top chrome — Mode + Wallet + Language, all fixed so they're always
75+
reachable regardless of scroll position. Grouped right-aligned. */}
76+
<div className="fixed right-4 top-4 z-50 flex flex-wrap items-center justify-end gap-2 sm:right-6 sm:top-6">
77+
{mode === "live" && <WalletPill />}
78+
<ModeToggle />
7279
<LanguageToggle />
7380
</div>
7481

@@ -147,7 +154,7 @@ export default function Home() {
147154

148155
{/* The contrast — only on home view, hide when an app is open to keep
149156
the in-app experience focused. */}
150-
{state.view === "home" && (
157+
{state.view === "home" && mode === "demo" && (
151158
<motion.div
152159
initial={{ opacity: 0, y: 12 }}
153160
animate={{ opacity: 1, y: 0 }}
@@ -158,6 +165,18 @@ export default function Home() {
158165
</motion.div>
159166
)}
160167

168+
{/* Live mode snapshot — same slot as contrast, mutually exclusive */}
169+
{state.view === "home" && mode === "live" && (
170+
<motion.div
171+
initial={{ opacity: 0, y: 12 }}
172+
animate={{ opacity: 1, y: 0 }}
173+
transition={{ duration: 0.5, delay: 0.15 }}
174+
className="w-full flex justify-center"
175+
>
176+
<LiveSnapshot />
177+
</motion.div>
178+
)}
179+
161180
{/* Footer */}
162181
<Footer />
163182
</main>
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { motion, AnimatePresence } from "framer-motion";
5+
import { useWallet } from "@/lib/wallet";
6+
import { useT } from "@/lib/i18n";
7+
import { ADDRS, ETHERSCAN_ADDR, ETHERSCAN_TX, submitComputeScore } from "@/lib/chain";
8+
9+
type TxStatus = "idle" | "signing" | "pending" | "confirmed" | "error";
10+
11+
/**
12+
* The one real Sepolia write we expose to Live-mode visitors. Calls
13+
* `CreditScoreEngine.computeScore(1)` which is permissionless (no governor
14+
* or borrower-owner check) yet meaningful — it runs FHE.add / FHE.mul /
15+
* FHE.sub / FHE.select on encrypted state, re-grants ACL to the pool and
16+
* borrower wallet via FHE.allow, and emits ScoreComputed.
17+
*
18+
* For an unregistered borrower id the encrypted inputs default to zero, so
19+
* the resulting score is zero — but the *cryptographic execution* is real
20+
* and verifiable on Etherscan / Sepolia.
21+
*/
22+
export function ComputeScoreButton({ id = 1 }: { id?: number }) {
23+
const t = useT();
24+
const w = useWallet();
25+
const [status, setStatus] = useState<TxStatus>("idle");
26+
const [txHash, setTxHash] = useState<string | null>(null);
27+
const [errorMsg, setErrorMsg] = useState<string | null>(null);
28+
29+
const canClick = w.status === "connected" && status === "idle";
30+
31+
const submit = async () => {
32+
if (!canClick) return;
33+
setStatus("signing");
34+
setErrorMsg(null);
35+
setTxHash(null);
36+
try {
37+
// The tx-submission `signing` phase covers wallet popup + user
38+
// confirmation; once we have a hash we flip to `pending` for the
39+
// block-confirmation wait.
40+
const result = await submitComputeScore(id);
41+
setTxHash(result.txHash);
42+
setStatus("confirmed");
43+
} catch (e: unknown) {
44+
const msg = e instanceof Error ? e.message : "Transaction failed";
45+
// Strip noisy wallet error scaffolding so users see the actual cause.
46+
const cleaned = msg.replace(/^Error: |\(action=.*?\)$/g, "").slice(0, 180);
47+
setErrorMsg(cleaned);
48+
setStatus("error");
49+
}
50+
};
51+
52+
return (
53+
<div className="rounded-card border-2 border-mint/50 bg-mint-bg/40 p-4">
54+
<div className="flex flex-wrap items-start justify-between gap-3">
55+
<div className="flex-1 min-w-[220px]">
56+
<h4
57+
className="text-sm font-bold leading-tight text-ink"
58+
style={{ fontFamily: "var(--font-display)", letterSpacing: "-0.005em" }}
59+
>
60+
{t.live.computeScoreTitle}
61+
</h4>
62+
<p className="mt-1 text-xs leading-relaxed text-ink-soft">
63+
{t.live.computeScoreSub}
64+
</p>
65+
<a
66+
href={ETHERSCAN_ADDR(ADDRS.credit)}
67+
target="_blank"
68+
rel="noopener noreferrer"
69+
className="mt-1 inline-block font-mono text-[10px] text-ink-muted transition-colors hover:text-mint-active"
70+
style={{ fontFamily: "var(--font-mono)" }}
71+
>
72+
CreditScoreEngine · {ADDRS.credit.slice(0, 6)}{ADDRS.credit.slice(-4)}
73+
</a>
74+
</div>
75+
76+
<div className="flex flex-col items-end gap-2">
77+
<ActionButton
78+
status={status}
79+
disabled={!canClick}
80+
onClick={submit}
81+
labels={{
82+
idle: t.live.computeScoreBtn,
83+
signing: t.live.computeScoreSubmitting,
84+
pending: t.live.computeScorePending,
85+
confirmed: t.live.computeScoreSuccess,
86+
error: t.live.computeScoreBtn,
87+
}}
88+
/>
89+
{!w.hasWallet ? (
90+
<span
91+
className="text-[10px] text-ink-soft"
92+
style={{ fontFamily: "var(--font-mono)" }}
93+
>
94+
{t.wallet.installPrompt}
95+
</span>
96+
) : w.status === "disconnected" ? (
97+
<span
98+
className="text-[10px] text-ink-soft"
99+
style={{ fontFamily: "var(--font-mono)" }}
100+
>
101+
{t.wallet.connect}
102+
</span>
103+
) : w.status === "wrong-network" ? (
104+
<span
105+
className="text-[10px] text-app-coral"
106+
style={{ fontFamily: "var(--font-mono)" }}
107+
>
108+
{t.wallet.switchToSepolia}
109+
</span>
110+
) : null}
111+
</div>
112+
</div>
113+
114+
<AnimatePresence>
115+
{txHash && (
116+
<motion.div
117+
key="tx"
118+
initial={{ opacity: 0, height: 0 }}
119+
animate={{ opacity: 1, height: "auto" }}
120+
exit={{ opacity: 0, height: 0 }}
121+
className="mt-3 overflow-hidden"
122+
>
123+
<div className="rounded-pill border-2 border-success/40 bg-success/15 px-3 py-1.5">
124+
<a
125+
href={ETHERSCAN_TX(txHash)}
126+
target="_blank"
127+
rel="noopener noreferrer"
128+
className="font-mono text-[11px] font-bold text-success transition-colors hover:text-ink-body"
129+
style={{ fontFamily: "var(--font-mono)" }}
130+
>
131+
{txHash.slice(0, 10)}{txHash.slice(-8)} · {t.live.viewTx}
132+
</a>
133+
</div>
134+
</motion.div>
135+
)}
136+
{errorMsg && (
137+
<motion.div
138+
key="err"
139+
initial={{ opacity: 0, height: 0 }}
140+
animate={{ opacity: 1, height: "auto" }}
141+
exit={{ opacity: 0, height: 0 }}
142+
className="mt-3 overflow-hidden"
143+
>
144+
<div
145+
className="rounded-pill border-2 border-app-coral/40 bg-app-coral/10 px-3 py-1.5 font-mono text-[11px] text-app-coral"
146+
style={{ fontFamily: "var(--font-mono)" }}
147+
>
148+
{errorMsg}
149+
</div>
150+
</motion.div>
151+
)}
152+
</AnimatePresence>
153+
</div>
154+
);
155+
}
156+
157+
function ActionButton({
158+
status,
159+
disabled,
160+
onClick,
161+
labels,
162+
}: {
163+
status: TxStatus;
164+
disabled: boolean;
165+
onClick: () => void;
166+
labels: Record<TxStatus, string>;
167+
}) {
168+
const isLoading = status === "signing" || status === "pending";
169+
return (
170+
<motion.button
171+
onClick={onClick}
172+
disabled={disabled || isLoading}
173+
whileHover={!disabled && !isLoading ? { y: -1 } : undefined}
174+
whileTap={!disabled && !isLoading ? { y: 1 } : undefined}
175+
className={`btn-pill ${
176+
status === "confirmed" ? "" : ""
177+
} ${disabled || isLoading ? "opacity-60 cursor-not-allowed" : ""}`}
178+
style={{
179+
background:
180+
status === "confirmed" ? "var(--color-success)" : undefined,
181+
minWidth: 160,
182+
}}
183+
>
184+
{isLoading && (
185+
<span className="mr-1.5 inline-block size-1.5 animate-pulse rounded-full bg-white" />
186+
)}
187+
{labels[status]}
188+
</motion.button>
189+
);
190+
}

0 commit comments

Comments
 (0)