send-form.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. "use client";
  2. import Link from "next/link";
  3. import { useRouter } from "next/navigation";
  4. import { useEffect, useState } from "react";
  5. import { UnlockPanel } from "@/components/mavi/unlock-panel";
  6. import { MaviWalletLogo } from "@/components/wallet/wallet-logos";
  7. import { Badge } from "@/components/ui/badge";
  8. import { Button, buttonVariants } from "@/components/ui/button";
  9. import {
  10. Card,
  11. CardContent,
  12. CardDescription,
  13. CardHeader,
  14. CardTitle,
  15. } from "@/components/ui/card";
  16. import { ErrorBanner } from "@/components/ui/error-banner";
  17. import { Input } from "@/components/ui/input";
  18. import {
  19. addActivity,
  20. notifyActivityChanged,
  21. } from "@/lib/mavi/activity";
  22. import {
  23. fetchEthBalance,
  24. isValidEthAddress,
  25. sendEth,
  26. } from "@/lib/mavi/chain";
  27. import { getActiveNetwork } from "@/lib/mavi/network";
  28. import {
  29. hasVault,
  30. readUnlockedSession,
  31. type UnlockedMavi,
  32. } from "@/lib/mavi/storage";
  33. import { cn } from "@/lib/utils";
  34. export function SendForm() {
  35. const router = useRouter();
  36. const [mounted, setMounted] = useState(false);
  37. const [exists, setExists] = useState(false);
  38. const [unlocked, setUnlocked] = useState<UnlockedMavi | null>(null);
  39. const [to, setTo] = useState("");
  40. const [amount, setAmount] = useState("0.01");
  41. const [balance, setBalance] = useState<string | null>(null);
  42. const [busy, setBusy] = useState(false);
  43. const [error, setError] = useState<string | null>(null);
  44. const [txHash, setTxHash] = useState<string | null>(null);
  45. const [netLabel, setNetLabel] = useState("");
  46. useEffect(() => {
  47. setMounted(true);
  48. setExists(hasVault());
  49. setUnlocked(readUnlockedSession());
  50. setNetLabel(getActiveNetwork().shortLabel);
  51. const sync = () => {
  52. setUnlocked(readUnlockedSession());
  53. setNetLabel(getActiveNetwork().shortLabel);
  54. };
  55. window.addEventListener("mavi-unlock", sync);
  56. window.addEventListener("mavi-network", sync);
  57. return () => {
  58. window.removeEventListener("mavi-unlock", sync);
  59. window.removeEventListener("mavi-network", sync);
  60. };
  61. }, []);
  62. useEffect(() => {
  63. if (!unlocked) return;
  64. let cancelled = false;
  65. void fetchEthBalance(unlocked.address).then((b) => {
  66. if (!cancelled && b.ok) setBalance(b.eth);
  67. });
  68. return () => {
  69. cancelled = true;
  70. };
  71. }, [unlocked, netLabel]);
  72. async function onSend() {
  73. if (!unlocked) return;
  74. setError(null);
  75. setTxHash(null);
  76. const dest = to.trim() as `0x${string}`;
  77. if (!isValidEthAddress(dest)) {
  78. setError("Enter a valid address starting with 0x (40 hex characters).");
  79. return;
  80. }
  81. const amt = amount.trim();
  82. if (!amt || Number.isNaN(Number(amt)) || Number(amt) <= 0) {
  83. setError("Enter an amount greater than 0.");
  84. return;
  85. }
  86. setBusy(true);
  87. try {
  88. const { hash } = await sendEth({
  89. privateKey: unlocked.privateKey,
  90. to: dest,
  91. amountEth: amt,
  92. });
  93. setTxHash(hash);
  94. addActivity({
  95. kind: "send",
  96. wallet: unlocked.address,
  97. hash,
  98. to: dest,
  99. from: unlocked.address,
  100. amountEth: amt,
  101. note: getActiveNetwork().shortLabel,
  102. });
  103. notifyActivityChanged();
  104. const b = await fetchEthBalance(unlocked.address);
  105. if (b.ok) setBalance(b.eth);
  106. } catch (e) {
  107. const msg = e instanceof Error ? e.message : "Send failed";
  108. setError(
  109. /connect|fetch|network|ECONNREFUSED/i.test(msg)
  110. ? "Local chain is not running. Start it with: bun run chains:up"
  111. : msg,
  112. );
  113. } finally {
  114. setBusy(false);
  115. }
  116. }
  117. if (!mounted) {
  118. return (
  119. <Card>
  120. <CardContent className="py-10 text-center text-sm text-muted-foreground">
  121. Loading…
  122. </CardContent>
  123. </Card>
  124. );
  125. }
  126. if (!exists) {
  127. return (
  128. <Card>
  129. <CardHeader>
  130. <CardTitle>No mavi wallet</CardTitle>
  131. <CardDescription>
  132. Create or import a wallet before sending.
  133. </CardDescription>
  134. </CardHeader>
  135. <CardContent className="flex flex-col gap-2">
  136. <Link
  137. href="/portal/mavi/create"
  138. className={cn(
  139. buttonVariants({ variant: "primary" }),
  140. "inline-flex items-center justify-center gap-2",
  141. )}
  142. >
  143. <MaviWalletLogo size={18} />
  144. Create mavi wallet
  145. </Link>
  146. <Link
  147. href="/portal/mavi/import"
  148. className={cn(buttonVariants({ variant: "outline" }), "w-full")}
  149. >
  150. Import recovery phrase
  151. </Link>
  152. </CardContent>
  153. </Card>
  154. );
  155. }
  156. if (!unlocked) {
  157. return (
  158. <Card>
  159. <CardHeader>
  160. <div className="mb-2 flex flex-wrap items-center gap-2">
  161. <MaviWalletLogo size={28} />
  162. <CardTitle>Unlock to send</CardTitle>
  163. <Badge variant="pending">Locked</Badge>
  164. </div>
  165. <CardDescription>
  166. Unlock once for this tab — then send without re-entering the
  167. password until you lock.
  168. </CardDescription>
  169. </CardHeader>
  170. <CardContent>
  171. <UnlockPanel
  172. onUnlocked={(u) => {
  173. setUnlocked(u);
  174. setError(null);
  175. }}
  176. title="Password"
  177. description="Same unlock as mavi home. Stays open in this tab."
  178. submitLabel="Unlock and continue"
  179. />
  180. <p className="mt-4 text-center text-xs text-muted-foreground">
  181. Or unlock on{" "}
  182. <Link
  183. href="/portal/mavi?next=/portal/mavi/send"
  184. className="text-accent-hover underline-offset-2 hover:underline"
  185. >
  186. mavi home
  187. </Link>{" "}
  188. first.
  189. </p>
  190. </CardContent>
  191. </Card>
  192. );
  193. }
  194. return (
  195. <Card>
  196. <CardHeader>
  197. <div className="mb-2 flex flex-wrap items-center gap-2">
  198. <MaviWalletLogo size={28} />
  199. <CardTitle>Send</CardTitle>
  200. <Badge variant="anchored">Unlocked</Badge>
  201. </div>
  202. <CardDescription>
  203. Network: <strong className="text-foreground">{netLabel}</strong>
  204. {" · "}
  205. from{" "}
  206. <span className="font-mono text-foreground">
  207. {unlocked.address.slice(0, 8)}…
  208. </span>
  209. {balance != null ? ` · balance ${trimEth(balance)} ETH` : null}
  210. . Change network on mavi home.
  211. </CardDescription>
  212. </CardHeader>
  213. <CardContent className="space-y-4">
  214. {error ? <ErrorBanner title="Send failed">{error}</ErrorBanner> : null}
  215. {txHash ? (
  216. <div className="rounded-xl border border-proof/30 bg-proof/5 px-4 py-3 text-sm">
  217. <p className="font-medium text-proof">Sent</p>
  218. <p className="mt-1 break-all font-mono text-xs text-muted-foreground">
  219. {txHash}
  220. </p>
  221. </div>
  222. ) : null}
  223. <div className="space-y-2">
  224. <label htmlFor="mavi-to" className="text-sm font-medium">
  225. To address
  226. </label>
  227. <Input
  228. id="mavi-to"
  229. value={to}
  230. onChange={(e) => setTo(e.target.value)}
  231. placeholder="0x…"
  232. spellCheck={false}
  233. autoCapitalize="none"
  234. className="font-mono"
  235. />
  236. </div>
  237. <div className="space-y-2">
  238. <label htmlFor="mavi-amount" className="text-sm font-medium">
  239. Amount (ETH)
  240. </label>
  241. <Input
  242. id="mavi-amount"
  243. value={amount}
  244. onChange={(e) => setAmount(e.target.value)}
  245. inputMode="decimal"
  246. placeholder="0.01"
  247. />
  248. </div>
  249. <div className="flex flex-col gap-2 sm:flex-row">
  250. <Button
  251. type="button"
  252. variant="outline"
  253. className="sm:flex-1"
  254. onClick={() => router.push("/portal/mavi")}
  255. >
  256. Back
  257. </Button>
  258. <Button
  259. type="button"
  260. className="sm:flex-1"
  261. disabled={busy}
  262. onClick={() => void onSend()}
  263. >
  264. {busy ? "Sending…" : "Send"}
  265. </Button>
  266. </div>
  267. <p className="text-xs text-muted-foreground leading-relaxed">
  268. If the chain is down, run{" "}
  269. <code className="text-foreground">bun run chains:up</code>. New
  270. wallets need test ETH — use Get test ETH on mavi home.
  271. </p>
  272. </CardContent>
  273. </Card>
  274. );
  275. }
  276. function trimEth(eth: string): string {
  277. const n = Number(eth);
  278. if (!Number.isFinite(n)) return eth;
  279. if (n === 0) return "0";
  280. if (n >= 1) return n.toFixed(4).replace(/\.?0+$/, "");
  281. return n.toFixed(6).replace(/\.?0+$/, "");
  282. }