Skip to content

Commit 5d3bac1

Browse files
committed
feat(frontend): pre-flight check panel on /deposit
Four-row checklist that runs before the stepper: wallet connected on Arbitrum Sepolia, ERC-3643 verified identity, ≥0.001 native ETH for gas headroom (~7 tx worst case), Nox handle SDK initialised. Each failed row shows an actionable hint (faucet links, /verify path, network switch) so the user can self-resolve before hitting the chain. Auto-collapses once all four are green so the deposit form keeps primary focus.
1 parent 803cc5e commit 5d3bac1

2 files changed

Lines changed: 225 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { useState } from "react";
2+
import { useBalance } from "wagmi";
3+
import {
4+
AlertCircle,
5+
CheckCircle2,
6+
ChevronDown,
7+
ChevronUp,
8+
Loader2,
9+
ShieldCheck,
10+
XCircle,
11+
} from "lucide-react";
12+
13+
import { useWallet } from "@/hooks/useWallet";
14+
import { useIdentityStatus } from "@/hooks/useIdentityStatus";
15+
import { useHandleClient } from "@/hooks/useHandleClient";
16+
import { ARB_SEPOLIA_ID } from "@/lib/wagmi";
17+
18+
// Minimum native gas balance considered "enough for a deposit run". Wrap
19+
// is 3 txs (mint + approve + wrap), submitDeposit is 2 (transfer +
20+
// recordDeposit) plus an optional processDeposit, claim is 1 — call it
21+
// ~7 tx worst case. At Arbitrum Sepolia gas prices ~0.001 ETH covers all
22+
// of them with headroom; below that the user will hit "insufficient
23+
// funds" mid-flow which is a far worse failure mode than blocking
24+
// upfront.
25+
const MIN_NATIVE_GAS_WEI = 1_000_000_000_000_000n; // 0.001 ETH
26+
27+
type CheckState = "ok" | "fail" | "loading" | "warn";
28+
29+
interface CheckRow {
30+
label: string;
31+
state: CheckState;
32+
detail: string;
33+
hint?: string;
34+
}
35+
36+
export function PreflightPanel() {
37+
const { address, isConnected, isOnArbSepolia } = useWallet();
38+
const { status: idStatus, isLoading: idLoading } = useIdentityStatus(address);
39+
const { sdk, sdkError } = useHandleClient();
40+
const { data: balance, isLoading: balanceLoading } = useBalance({
41+
address,
42+
chainId: ARB_SEPOLIA_ID,
43+
});
44+
45+
const checks: CheckRow[] = [
46+
walletCheck(isConnected, isOnArbSepolia),
47+
identityCheck(idStatus, idLoading, isConnected),
48+
gasCheck(balance?.value, balanceLoading, isConnected),
49+
sdkCheck(sdk, sdkError, isConnected),
50+
];
51+
52+
const allOk = checks.every((c) => c.state === "ok");
53+
const anyFail = checks.some((c) => c.state === "fail");
54+
const [open, setOpen] = useState(true);
55+
56+
// Auto-collapse default once everything is green; a user toggle wins
57+
// once they interact, but on first reach-all-green this folds the panel
58+
// out of the way so the deposit form gets primary focus.
59+
const effectiveOpen = open && !allOk ? true : open && allOk ? false : open;
60+
61+
return (
62+
<div
63+
className={`rounded-lg border ${
64+
anyFail ? "border-warning/40 bg-warning/5" : allOk ? "border-sage/40 bg-sage/5" : "border-border bg-card"
65+
}`}
66+
>
67+
<button
68+
type="button"
69+
onClick={() => setOpen((s) => !s)}
70+
className="w-full flex items-center justify-between px-5 py-3 text-left"
71+
>
72+
<div className="flex items-center gap-2.5">
73+
<ShieldCheck
74+
className={`h-4 w-4 ${allOk ? "text-sage" : anyFail ? "text-warning" : "text-muted-foreground"}`}
75+
/>
76+
<span className="font-display text-sm text-forest">
77+
{allOk ? "Pre-flight ready" : anyFail ? "Pre-flight blockers" : "Running pre-flight checks…"}
78+
</span>
79+
<span className="text-[11px] text-muted-foreground">
80+
{checks.filter((c) => c.state === "ok").length}/{checks.length} ready
81+
</span>
82+
</div>
83+
{effectiveOpen ? (
84+
<ChevronUp className="h-4 w-4 text-muted-foreground" />
85+
) : (
86+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
87+
)}
88+
</button>
89+
90+
{effectiveOpen && (
91+
<ul className="border-t border-border/60 px-5 py-3 space-y-2.5">
92+
{checks.map((c) => (
93+
<li key={c.label} className="flex items-start gap-2.5 text-sm">
94+
<StateIcon state={c.state} />
95+
<div className="flex-1 min-w-0">
96+
<div className="flex items-baseline gap-2">
97+
<span className="font-medium text-foreground">{c.label}</span>
98+
<span className="text-[11px] text-muted-foreground">{c.detail}</span>
99+
</div>
100+
{c.hint && (
101+
<p className="text-[11px] text-muted-foreground mt-0.5">{c.hint}</p>
102+
)}
103+
</div>
104+
</li>
105+
))}
106+
</ul>
107+
)}
108+
</div>
109+
);
110+
}
111+
112+
function StateIcon({ state }: { state: CheckState }) {
113+
if (state === "ok") return <CheckCircle2 className="h-4 w-4 text-sage mt-0.5 shrink-0" />;
114+
if (state === "fail") return <XCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />;
115+
if (state === "warn") return <AlertCircle className="h-4 w-4 text-warning mt-0.5 shrink-0" />;
116+
return <Loader2 className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0 animate-spin" />;
117+
}
118+
119+
function walletCheck(connected: boolean, onArbSepolia: boolean): CheckRow {
120+
if (!connected) {
121+
return {
122+
label: "Wallet connected",
123+
state: "fail",
124+
detail: "Disconnected",
125+
hint: "Click Connect Wallet in the top-right to bind a signer to GroundVault.",
126+
};
127+
}
128+
if (!onArbSepolia) {
129+
return {
130+
label: "Wallet connected",
131+
state: "fail",
132+
detail: "Wrong network",
133+
hint: "Switch your wallet to Arbitrum Sepolia (chain 421614). Mainnet has no GroundVault deployment.",
134+
};
135+
}
136+
return { label: "Wallet connected", state: "ok", detail: "Arbitrum Sepolia" };
137+
}
138+
139+
function identityCheck(
140+
status: ReturnType<typeof useIdentityStatus>["status"],
141+
loading: boolean,
142+
connected: boolean,
143+
): CheckRow {
144+
if (!connected) {
145+
return { label: "ERC-3643 verified identity", state: "warn", detail: "—", hint: "Connect a wallet first." };
146+
}
147+
if (loading) return { label: "ERC-3643 verified identity", state: "loading", detail: "Reading IdentityRegistry…" };
148+
if (status === "verified") {
149+
return { label: "ERC-3643 verified identity", state: "ok", detail: "isVerified() returned true" };
150+
}
151+
if (status === "pending") {
152+
return {
153+
label: "ERC-3643 verified identity",
154+
state: "fail",
155+
detail: "Identity registered but missing claim",
156+
hint: "Visit /verify to publish the KYC claim. recordDeposit reverts on un-claimed identities.",
157+
};
158+
}
159+
if (status === "unknown") {
160+
return {
161+
label: "ERC-3643 verified identity",
162+
state: "warn",
163+
detail: "Identity read failed (RPC error)",
164+
hint: "Reload the page or retry — a transient RPC failure can mask a verified identity.",
165+
};
166+
}
167+
return {
168+
label: "ERC-3643 verified identity",
169+
state: "fail",
170+
detail: "Unverified",
171+
hint: "Visit /verify to deploy your Identity contract and register with IdentityRegistry.",
172+
};
173+
}
174+
175+
function gasCheck(
176+
weiValue: bigint | undefined,
177+
loading: boolean,
178+
connected: boolean,
179+
): CheckRow {
180+
if (!connected) {
181+
return { label: "Sepolia ETH for gas", state: "warn", detail: "—", hint: "Connect a wallet first." };
182+
}
183+
if (loading || weiValue === undefined) {
184+
return { label: "Sepolia ETH for gas", state: "loading", detail: "Reading native balance…" };
185+
}
186+
const eth = Number(weiValue) / 1e18;
187+
if (weiValue >= MIN_NATIVE_GAS_WEI) {
188+
return {
189+
label: "Sepolia ETH for gas",
190+
state: "ok",
191+
detail: `${eth.toFixed(4)} ETH`,
192+
};
193+
}
194+
return {
195+
label: "Sepolia ETH for gas",
196+
state: "fail",
197+
detail: `${eth.toFixed(4)} ETH (<0.001)`,
198+
hint: "Top up at faucet.quicknode.com/arbitrum/sepolia or sepoliafaucet.com — wrap + submit + claim needs ~7 transactions of headroom.",
199+
};
200+
}
201+
202+
function sdkCheck(
203+
sdk: ReturnType<typeof useHandleClient>["sdk"],
204+
sdkError: string | null,
205+
connected: boolean,
206+
): CheckRow {
207+
if (!connected) {
208+
return { label: "Nox handle SDK", state: "warn", detail: "—", hint: "SDK builds once a wallet is connected." };
209+
}
210+
if (sdkError) {
211+
return {
212+
label: "Nox handle SDK",
213+
state: "fail",
214+
detail: "Initialisation failed",
215+
hint: sdkError.length > 140 ? `${sdkError.slice(0, 140)}…` : sdkError,
216+
};
217+
}
218+
if (!sdk) {
219+
return { label: "Nox handle SDK", state: "loading", detail: "Building handle client…" };
220+
}
221+
return { label: "Nox handle SDK", state: "ok", detail: "ACL handshake ready" };
222+
}

frontend/src/routes/Deposit.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { StepPending } from "@/components/deposit/StepPending";
1313
import { StepClaim } from "@/components/deposit/StepClaim";
1414
import { PrivacyProofDrawer } from "@/components/deposit/PrivacyProofDrawer";
1515
import { ActivityLog } from "@/components/deposit/ActivityLog";
16+
import { PreflightPanel } from "@/components/deposit/PreflightPanel";
1617
import { EncryptedValue } from "@/components/shared/EncryptedValue";
1718

1819
const SIX_DECIMALS = 1_000_000n;
@@ -104,6 +105,8 @@ export default function Deposit() {
104105
</div>
105106
)}
106107

108+
<PreflightPanel />
109+
107110
<Stepper order={flow.order} currentIndex={flow.stepIndex} />
108111

109112
<div className="grid grid-cols-1 lg:grid-cols-[1.6fr_1fr] gap-6">

0 commit comments

Comments
 (0)