|
| 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 | +} |
0 commit comments