@hfunlabs/hypurr-connect 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,744 @@
1
+ import { AnimatePresence, motion } from "framer-motion";
2
+ import type { HyperliquidWallet } from "hypurr-grpc/ts/hypurr/wallet";
3
+ import { useCallback, useState, type ReactNode } from "react";
4
+ import { useHypurrConnectInternal } from "./HypurrConnectProvider";
5
+ import {
6
+ AGENT_APPROVAL_DURATION_OPTIONS,
7
+ DEFAULT_AGENT_APPROVAL_DURATION_MS,
8
+ createTelegramAgentApprovalName,
9
+ type AgentApprovalDurationOption,
10
+ } from "./agentWallet";
11
+ import {
12
+ AlertTriangle,
13
+ Check,
14
+ Eye,
15
+ EyeOff,
16
+ KeyRound,
17
+ Loader2,
18
+ Plus,
19
+ SpinKeyframes,
20
+ Wallet,
21
+ X,
22
+ } from "./icons/lucide";
23
+ import type { Hex, SignTypedDataFn } from "./types";
24
+
25
+ export interface AddWalletModalProps {
26
+ isOpen: boolean;
27
+ onClose: () => void;
28
+ ownerAddress?: string | null;
29
+ isWalletConnected?: boolean;
30
+ chainId?: number;
31
+ signTypedDataAsync?: SignTypedDataFn;
32
+ onConnectWallet?: () => void;
33
+ onDisconnectWallet?: () => void;
34
+ onWalletAdded?: (wallet?: HyperliquidWallet) => void | Promise<void>;
35
+ onAddReadOnlyWallet?: (address: string) => void | Promise<void>;
36
+ onNotify?: (n: { type: "success" | "error"; message: string }) => void;
37
+ hyperliquidChain?: "Mainnet" | "Testnet" | string;
38
+ /** Expiration choices for owner-approved agent wallets. Defaults to 1/7/30/90 days. */
39
+ agentApprovalDurationOptions?: AgentApprovalDurationOption[];
40
+ /** Initial owner approval duration. Defaults to 1 day. */
41
+ defaultAgentApprovalDurationMs?: number;
42
+ }
43
+
44
+ type TabKey = "generate" | "import" | "api" | "readonly";
45
+
46
+ const WALLET_NAME_REGEX = /^[a-zA-Z0-9/]*$/;
47
+ const PRIVATE_KEY_REGEX = /^(0x)?[a-fA-F0-9]{64}$/;
48
+ const BASE_TAB_KEYS: TabKey[] = ["generate", "import", "api"];
49
+
50
+ function createAuthSignatureRequest(
51
+ chainId: number,
52
+ hyperliquidChain: string,
53
+ agentAddress: string,
54
+ approvalDurationMs: number,
55
+ ) {
56
+ const nonce = Date.now();
57
+
58
+ return {
59
+ domain: {
60
+ name: "HyperliquidSignTransaction",
61
+ version: "1",
62
+ chainId,
63
+ verifyingContract: "0x0000000000000000000000000000000000000000" as const,
64
+ },
65
+ types: {
66
+ "HyperliquidTransaction:ApproveAgent": [
67
+ { name: "hyperliquidChain", type: "string" },
68
+ { name: "agentAddress", type: "address" },
69
+ { name: "agentName", type: "string" },
70
+ { name: "nonce", type: "uint64" },
71
+ ],
72
+ },
73
+ primaryType: "HyperliquidTransaction:ApproveAgent",
74
+ message: {
75
+ hyperliquidChain,
76
+ agentAddress: agentAddress.toLowerCase() as Hex,
77
+ agentName: createTelegramAgentApprovalName(approvalDurationMs),
78
+ nonce,
79
+ },
80
+ };
81
+ }
82
+
83
+ const tabClass = (selected: boolean) =>
84
+ `flex-1 py-1.5 rounded text-base font-medium flex items-center justify-center gap-1.5 ${
85
+ selected ? "btn-raised-active" : "btn-raised"
86
+ }`;
87
+
88
+ const inputClass = (hasError?: boolean) =>
89
+ `w-full bg-surface-btn/90 border ${
90
+ hasError ? "border-trade-down/50" : "border-surface-bd"
91
+ } rounded-lg px-3 py-2.5 text-white placeholder-gray-600 focus:outline-none font-mono text-base`;
92
+
93
+ const formatAddress = (address: string) =>
94
+ `${address.slice(0, 6)}...${address.slice(-4)}`;
95
+
96
+ export function AddWalletModal({
97
+ isOpen,
98
+ onClose,
99
+ ownerAddress,
100
+ isWalletConnected,
101
+ chainId,
102
+ signTypedDataAsync,
103
+ onConnectWallet,
104
+ onDisconnectWallet,
105
+ onWalletAdded,
106
+ onAddReadOnlyWallet,
107
+ onNotify,
108
+ hyperliquidChain = "Mainnet",
109
+ agentApprovalDurationOptions,
110
+ defaultAgentApprovalDurationMs = DEFAULT_AGENT_APPROVAL_DURATION_MS,
111
+ }: AddWalletModalProps): ReactNode {
112
+ const tabKeys = onAddReadOnlyWallet
113
+ ? [...BASE_TAB_KEYS, "readonly" as const]
114
+ : BASE_TAB_KEYS;
115
+
116
+ const [activeTab, setActiveTab] = useState<TabKey>("generate");
117
+ const [isLoading, setIsLoading] = useState(false);
118
+ const [walletName, setWalletName] = useState("");
119
+ const [newWalletName, setNewWalletName] = useState("");
120
+ const [importWalletName, setImportWalletName] = useState("");
121
+ const [importPrivateKey, setImportPrivateKey] = useState("");
122
+ const [showImportPrivateKey, setShowImportPrivateKey] = useState(false);
123
+ const [readOnlyAddress, setReadOnlyAddress] = useState("");
124
+ const [agentApprovalDurationMs, setAgentApprovalDurationMs] = useState(
125
+ defaultAgentApprovalDurationMs,
126
+ );
127
+ const [error, setError] = useState<string | null>(null);
128
+
129
+ const {
130
+ authMethod,
131
+ createWallet,
132
+ isLoading: userLoading,
133
+ refreshWallets,
134
+ telegramClient,
135
+ telegramRpcOptions,
136
+ } = useHypurrConnectInternal();
137
+
138
+ const isValidWalletName =
139
+ walletName.length > 0 && WALLET_NAME_REGEX.test(walletName);
140
+ const isValidNewWalletName =
141
+ newWalletName.length > 0 && WALLET_NAME_REGEX.test(newWalletName);
142
+ const isValidImportWalletName =
143
+ importWalletName.length > 0 && WALLET_NAME_REGEX.test(importWalletName);
144
+ const trimmedImportPrivateKey = importPrivateKey.trim();
145
+ const isValidImportPrivateKey = PRIVATE_KEY_REGEX.test(
146
+ trimmedImportPrivateKey,
147
+ );
148
+ const isProcessing = isLoading || userLoading;
149
+ const durationOptions =
150
+ agentApprovalDurationOptions && agentApprovalDurationOptions.length > 0
151
+ ? agentApprovalDurationOptions
152
+ : AGENT_APPROVAL_DURATION_OPTIONS;
153
+
154
+ const handleClose = useCallback(() => {
155
+ if (isProcessing) return;
156
+ setError(null);
157
+ setImportPrivateKey("");
158
+ setShowImportPrivateKey(false);
159
+ onClose();
160
+ }, [isProcessing, onClose]);
161
+
162
+ const assertTelegramReady = useCallback(() => {
163
+ if (authMethod !== "telegram" || !telegramRpcOptions) {
164
+ throw new Error("Telegram login is required to add wallets");
165
+ }
166
+ }, [authMethod, telegramRpcOptions]);
167
+
168
+ const completeWalletAdded = useCallback(
169
+ async (wallet?: HyperliquidWallet) => {
170
+ refreshWallets();
171
+ await onWalletAdded?.(wallet);
172
+ },
173
+ [onWalletAdded, refreshWallets],
174
+ );
175
+
176
+ const handleCreateWallet = useCallback(async () => {
177
+ if (!isValidNewWalletName) {
178
+ setError("Wallet name may only contain letters, numbers, and /");
179
+ return;
180
+ }
181
+ setError(null);
182
+ setIsLoading(true);
183
+ try {
184
+ assertTelegramReady();
185
+ const wallet = await createWallet(newWalletName);
186
+ await completeWalletAdded(wallet);
187
+ onNotify?.({ type: "success", message: "Wallet created successfully" });
188
+ setNewWalletName("");
189
+ handleClose();
190
+ } catch (e: unknown) {
191
+ const message =
192
+ e instanceof Error ? e.message : "Failed to create wallet";
193
+ setError(message);
194
+ onNotify?.({ type: "error", message });
195
+ } finally {
196
+ setIsLoading(false);
197
+ }
198
+ }, [
199
+ assertTelegramReady,
200
+ completeWalletAdded,
201
+ createWallet,
202
+ handleClose,
203
+ isValidNewWalletName,
204
+ newWalletName,
205
+ onNotify,
206
+ ]);
207
+
208
+ const handleImportWallet = useCallback(async () => {
209
+ if (!isValidImportWalletName) {
210
+ setError("Wallet name may only contain letters, numbers, and /");
211
+ return;
212
+ }
213
+ if (!isValidImportPrivateKey) {
214
+ setError("Please enter a valid 64-character hex private key");
215
+ return;
216
+ }
217
+ setError(null);
218
+ setIsLoading(true);
219
+ try {
220
+ assertTelegramReady();
221
+ const response = await telegramClient.hyperliquidWalletImport(
222
+ {
223
+ authData: {},
224
+ name: importWalletName,
225
+ privateKey: trimmedImportPrivateKey,
226
+ },
227
+ telegramRpcOptions,
228
+ );
229
+ await completeWalletAdded(response.response.wallet);
230
+ onNotify?.({ type: "success", message: "Wallet imported successfully" });
231
+ setImportWalletName("");
232
+ setImportPrivateKey("");
233
+ handleClose();
234
+ } catch (e: unknown) {
235
+ const message =
236
+ e instanceof Error ? e.message : "Failed to import wallet";
237
+ setError(message);
238
+ onNotify?.({ type: "error", message });
239
+ } finally {
240
+ setIsLoading(false);
241
+ }
242
+ }, [
243
+ assertTelegramReady,
244
+ completeWalletAdded,
245
+ handleClose,
246
+ importWalletName,
247
+ isValidImportPrivateKey,
248
+ isValidImportWalletName,
249
+ onNotify,
250
+ telegramClient,
251
+ telegramRpcOptions,
252
+ trimmedImportPrivateKey,
253
+ ]);
254
+
255
+ const handleSignAndAdd = useCallback(async () => {
256
+ if (!ownerAddress) {
257
+ onConnectWallet?.();
258
+ return;
259
+ }
260
+ if (!signTypedDataAsync || !chainId) {
261
+ setError("Wallet signing is not ready yet");
262
+ return;
263
+ }
264
+ if (!isValidWalletName) {
265
+ setError("Wallet name may only contain letters, numbers, and /");
266
+ return;
267
+ }
268
+ setError(null);
269
+ setIsLoading(true);
270
+ try {
271
+ assertTelegramReady();
272
+ const res = await telegramClient.hyperliquidAgentSignatureCreate(
273
+ { authData: {}, address: ownerAddress },
274
+ telegramRpcOptions,
275
+ );
276
+ const agentAddress = res.response.agent;
277
+ const request = createAuthSignatureRequest(
278
+ chainId,
279
+ hyperliquidChain,
280
+ agentAddress,
281
+ agentApprovalDurationMs,
282
+ );
283
+ const signature = await signTypedDataAsync(request);
284
+
285
+ await telegramClient.hyperliquidAgentWalletCreate(
286
+ {
287
+ authData: {},
288
+ name: walletName,
289
+ signature: {
290
+ agentAddress: request.message.agentAddress,
291
+ agentName: request.message.agentName,
292
+ nonce: request.message.nonce,
293
+ chainId,
294
+ signature,
295
+ },
296
+ },
297
+ telegramRpcOptions,
298
+ );
299
+
300
+ await completeWalletAdded();
301
+ onNotify?.({ type: "success", message: "Wallet added successfully" });
302
+ onDisconnectWallet?.();
303
+ setWalletName("");
304
+ handleClose();
305
+ } catch (e: unknown) {
306
+ const message = e instanceof Error ? e.message : "Failed to add wallet";
307
+ setError(message);
308
+ onNotify?.({ type: "error", message });
309
+ } finally {
310
+ setIsLoading(false);
311
+ }
312
+ }, [
313
+ assertTelegramReady,
314
+ agentApprovalDurationMs,
315
+ chainId,
316
+ completeWalletAdded,
317
+ handleClose,
318
+ hyperliquidChain,
319
+ isValidWalletName,
320
+ onConnectWallet,
321
+ onDisconnectWallet,
322
+ onNotify,
323
+ ownerAddress,
324
+ signTypedDataAsync,
325
+ telegramClient,
326
+ telegramRpcOptions,
327
+ walletName,
328
+ ]);
329
+
330
+ const handleAddReadOnly = useCallback(async () => {
331
+ const trimmed = readOnlyAddress.trim();
332
+ if (!trimmed) {
333
+ setError("Please enter a wallet address");
334
+ return;
335
+ }
336
+ if (!/^0x[a-fA-F0-9]{40}$/.test(trimmed)) {
337
+ setError("Invalid Ethereum address format");
338
+ return;
339
+ }
340
+ setError(null);
341
+ setIsLoading(true);
342
+ try {
343
+ await onAddReadOnlyWallet?.(trimmed);
344
+ onNotify?.({ type: "success", message: "Read-only wallet added" });
345
+ setReadOnlyAddress("");
346
+ handleClose();
347
+ } catch (e: unknown) {
348
+ const message = e instanceof Error ? e.message : "Failed to add wallet";
349
+ setError(message);
350
+ onNotify?.({ type: "error", message });
351
+ } finally {
352
+ setIsLoading(false);
353
+ }
354
+ }, [handleClose, onAddReadOnlyWallet, onNotify, readOnlyAddress]);
355
+
356
+ return (
357
+ <AnimatePresence>
358
+ {isOpen && (
359
+ <motion.div className="hypurr-connect" style={{ display: "contents" }}>
360
+ <SpinKeyframes />
361
+ <motion.div
362
+ className="fixed inset-0 z-[100] bg-black/70 backdrop-blur-sm"
363
+ initial={{ opacity: 0 }}
364
+ animate={{ opacity: 1 }}
365
+ exit={{ opacity: 0 }}
366
+ transition={{ duration: 0.15 }}
367
+ onClick={handleClose}
368
+ />
369
+ <div className="fixed inset-0 z-[101] flex items-center justify-center p-4">
370
+ <motion.div
371
+ className="relative w-full max-w-md overflow-hidden rounded-lg border border-surface-bd bg-surface-modal font-sans shadow-modal"
372
+ initial={{ opacity: 0, y: 8 }}
373
+ animate={{ opacity: 1, y: 0 }}
374
+ exit={{ opacity: 0, y: 8 }}
375
+ transition={{ duration: 0.18, ease: "easeOut" }}
376
+ onClick={(event) => event.stopPropagation()}
377
+ >
378
+ <div className="relative flex items-center justify-center border-b border-white/[0.06] px-6 pb-5 pt-6">
379
+ <h3 className="text-lg font-semibold text-white">Add Wallet</h3>
380
+ <button
381
+ onClick={handleClose}
382
+ disabled={isProcessing}
383
+ className="absolute right-6 text-gray-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
384
+ aria-label="Close"
385
+ >
386
+ <X size={16} />
387
+ </button>
388
+ </div>
389
+
390
+ <div className="flex gap-1.5 px-6 pt-5">
391
+ {tabKeys.map((tab) => (
392
+ <button
393
+ key={tab}
394
+ type="button"
395
+ onClick={() => {
396
+ setError(null);
397
+ setActiveTab(tab);
398
+ }}
399
+ className={tabClass(activeTab === tab)}
400
+ >
401
+ {tab === "generate" ? (
402
+ <>
403
+ <Plus size={13} /> New
404
+ </>
405
+ ) : tab === "import" ? (
406
+ <>
407
+ <KeyRound size={13} /> Import
408
+ </>
409
+ ) : tab === "api" ? (
410
+ <>
411
+ <Wallet size={13} /> Link
412
+ </>
413
+ ) : (
414
+ <>
415
+ <Eye size={13} /> Watch
416
+ </>
417
+ )}
418
+ </button>
419
+ ))}
420
+ </div>
421
+
422
+ <div className="px-6 pb-6 pt-5">
423
+ {activeTab === "generate" && (
424
+ <section className="space-y-4">
425
+ <p className="text-base text-gray-400">
426
+ Create a new Hyperliquid wallet.
427
+ </p>
428
+ <WalletNameInput
429
+ value={newWalletName}
430
+ setValue={setNewWalletName}
431
+ isValid={isValidNewWalletName}
432
+ clearError={() => setError(null)}
433
+ placeholder="MyNewWallet"
434
+ />
435
+ <button
436
+ onClick={handleCreateWallet}
437
+ disabled={isProcessing || !isValidNewWalletName}
438
+ className={`flex w-full items-center justify-center gap-2 rounded-lg py-2 text-base font-medium ${
439
+ !isProcessing && isValidNewWalletName
440
+ ? "btn-raised"
441
+ : "btn-raised-disabled"
442
+ }`}
443
+ >
444
+ {isProcessing ? (
445
+ <>
446
+ <Loader2 size={14} /> Creating...
447
+ </>
448
+ ) : (
449
+ <>
450
+ <Plus size={14} /> Create Wallet
451
+ </>
452
+ )}
453
+ </button>
454
+ {error && <ErrorBox message={error} />}
455
+ </section>
456
+ )}
457
+
458
+ {activeTab === "import" && (
459
+ <section className="space-y-4">
460
+ <p className="text-base text-gray-400">
461
+ Import a Hyperliquid wallet with its private key.
462
+ </p>
463
+ <WalletNameInput
464
+ value={importWalletName}
465
+ setValue={setImportWalletName}
466
+ isValid={isValidImportWalletName}
467
+ clearError={() => setError(null)}
468
+ placeholder="MyImportedWallet"
469
+ />
470
+ <div>
471
+ <label className="mb-2 block text-base uppercase tracking-[0.1em] text-gray-400">
472
+ Private Key
473
+ </label>
474
+ <div className="relative">
475
+ <input
476
+ type={showImportPrivateKey ? "text" : "password"}
477
+ value={importPrivateKey}
478
+ onChange={(event) => {
479
+ setImportPrivateKey(event.target.value);
480
+ setError(null);
481
+ }}
482
+ placeholder="0x..."
483
+ spellCheck={false}
484
+ autoComplete="off"
485
+ className={`${inputClass(
486
+ !!importPrivateKey && !isValidImportPrivateKey,
487
+ )} pr-11`}
488
+ />
489
+ <button
490
+ type="button"
491
+ onClick={() =>
492
+ setShowImportPrivateKey((visible) => !visible)
493
+ }
494
+ className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-gray-500 transition-colors hover:text-gray-300"
495
+ aria-label={
496
+ showImportPrivateKey
497
+ ? "Hide private key"
498
+ : "Show private key"
499
+ }
500
+ >
501
+ {showImportPrivateKey ? (
502
+ <EyeOff size={15} />
503
+ ) : (
504
+ <Eye size={15} />
505
+ )}
506
+ </button>
507
+ </div>
508
+ </div>
509
+ <button
510
+ onClick={handleImportWallet}
511
+ disabled={
512
+ isProcessing ||
513
+ !isValidImportWalletName ||
514
+ !isValidImportPrivateKey
515
+ }
516
+ className={`flex w-full items-center justify-center gap-2 rounded-lg border bg-transparent py-2 text-base font-medium transition-colors ${
517
+ !isProcessing &&
518
+ isValidImportWalletName &&
519
+ isValidImportPrivateKey
520
+ ? "border-purple-500 text-purple-400 hover:bg-purple-500/10"
521
+ : "cursor-not-allowed border-gray-700 text-gray-600"
522
+ }`}
523
+ >
524
+ {isProcessing ? (
525
+ <>
526
+ <Loader2 size={14} /> Importing...
527
+ </>
528
+ ) : (
529
+ <>
530
+ <KeyRound size={14} /> Import Wallet
531
+ </>
532
+ )}
533
+ </button>
534
+ {error && <ErrorBox message={error} />}
535
+ </section>
536
+ )}
537
+
538
+ {activeTab === "api" && (
539
+ <section className="space-y-4">
540
+ <p className="text-base text-gray-400">
541
+ Connect your wallet and sign to link it for trading.
542
+ </p>
543
+ {!isWalletConnected || !ownerAddress ? (
544
+ <button
545
+ onClick={onConnectWallet}
546
+ className="btn-raised flex w-full items-center justify-center gap-2 rounded-lg py-2 text-base font-medium"
547
+ >
548
+ <Wallet size={14} /> Connect Wallet
549
+ </button>
550
+ ) : (
551
+ <>
552
+ <div className="flex items-center gap-3 rounded-lg border border-white/[0.06] bg-white/[0.03] px-3 py-2.5">
553
+ <div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-white/[0.08] bg-white/[0.06]">
554
+ <Wallet size={14} className="text-gray-400" />
555
+ </div>
556
+ <div className="min-w-0 flex-1">
557
+ <p className="text-base text-gray-400">Connected</p>
558
+ <p className="font-mono text-base text-white">
559
+ {formatAddress(ownerAddress)}
560
+ </p>
561
+ </div>
562
+ <Check
563
+ size={16}
564
+ className="flex-shrink-0 text-trade-up"
565
+ />
566
+ </div>
567
+ <WalletNameInput
568
+ value={walletName}
569
+ setValue={setWalletName}
570
+ isValid={isValidWalletName}
571
+ clearError={() => setError(null)}
572
+ placeholder="MyWallet"
573
+ />
574
+ <ApprovalDurationPicker
575
+ value={agentApprovalDurationMs}
576
+ onChange={setAgentApprovalDurationMs}
577
+ options={durationOptions}
578
+ disabled={isProcessing}
579
+ />
580
+ <button
581
+ onClick={handleSignAndAdd}
582
+ disabled={isProcessing || !isValidWalletName}
583
+ className={`flex w-full items-center justify-center gap-2 rounded-lg py-2 text-base font-medium ${
584
+ !isProcessing && isValidWalletName
585
+ ? "btn-raised"
586
+ : "btn-raised-disabled"
587
+ }`}
588
+ >
589
+ {isProcessing ? (
590
+ <>
591
+ <Loader2 size={14} /> Signing...
592
+ </>
593
+ ) : (
594
+ "Sign & Add Wallet"
595
+ )}
596
+ </button>
597
+ {onDisconnectWallet && (
598
+ <button
599
+ onClick={onDisconnectWallet}
600
+ className="w-full py-1.5 text-base text-gray-400 transition-colors hover:text-gray-300"
601
+ >
602
+ Disconnect & use different wallet
603
+ </button>
604
+ )}
605
+ </>
606
+ )}
607
+ {error && <ErrorBox message={error} />}
608
+ </section>
609
+ )}
610
+
611
+ {activeTab === "readonly" && onAddReadOnlyWallet && (
612
+ <section className="space-y-4">
613
+ <p className="text-base text-gray-400">
614
+ View positions and balances without trading access.
615
+ </p>
616
+ <div>
617
+ <label className="mb-2 block text-base uppercase tracking-[0.1em] text-gray-400">
618
+ Wallet Address
619
+ </label>
620
+ <input
621
+ type="text"
622
+ value={readOnlyAddress}
623
+ onChange={(event) =>
624
+ setReadOnlyAddress(event.target.value)
625
+ }
626
+ placeholder="0x..."
627
+ className={inputClass()}
628
+ />
629
+ </div>
630
+ <button
631
+ onClick={handleAddReadOnly}
632
+ disabled={isProcessing || !readOnlyAddress.trim()}
633
+ className={`flex w-full items-center justify-center gap-2 rounded-lg py-2 text-base font-medium ${
634
+ !isProcessing && readOnlyAddress.trim()
635
+ ? "btn-raised"
636
+ : "btn-raised-disabled"
637
+ }`}
638
+ >
639
+ {isProcessing ? (
640
+ <>
641
+ <Loader2 size={14} /> Adding...
642
+ </>
643
+ ) : (
644
+ <>
645
+ <Eye size={14} /> Add Watch Wallet
646
+ </>
647
+ )}
648
+ </button>
649
+ {error && <ErrorBox message={error} />}
650
+ </section>
651
+ )}
652
+ </div>
653
+ </motion.div>
654
+ </div>
655
+ </motion.div>
656
+ )}
657
+ </AnimatePresence>
658
+ );
659
+ }
660
+
661
+ function WalletNameInput({
662
+ value,
663
+ setValue,
664
+ isValid,
665
+ clearError,
666
+ placeholder,
667
+ }: {
668
+ value: string;
669
+ setValue: (value: string) => void;
670
+ isValid: boolean;
671
+ clearError: () => void;
672
+ placeholder: string;
673
+ }) {
674
+ return (
675
+ <div>
676
+ <label className="mb-2 block text-base uppercase tracking-[0.1em] text-gray-400">
677
+ Wallet Name
678
+ </label>
679
+ <input
680
+ type="text"
681
+ value={value}
682
+ onChange={(event) => {
683
+ const nextValue = event.target.value;
684
+ if (WALLET_NAME_REGEX.test(nextValue) || nextValue === "") {
685
+ setValue(nextValue);
686
+ clearError();
687
+ }
688
+ }}
689
+ placeholder={placeholder}
690
+ className={inputClass(!!value && !isValid)}
691
+ />
692
+ <p className="mt-1.5 text-base text-gray-600">
693
+ Letters, numbers, and / only
694
+ </p>
695
+ </div>
696
+ );
697
+ }
698
+
699
+ function ApprovalDurationPicker({
700
+ value,
701
+ onChange,
702
+ options,
703
+ disabled,
704
+ }: {
705
+ value: number;
706
+ onChange: (value: number) => void;
707
+ options: AgentApprovalDurationOption[];
708
+ disabled?: boolean;
709
+ }) {
710
+ return (
711
+ <div>
712
+ <label className="mb-2 block text-base uppercase tracking-[0.1em] text-gray-400">
713
+ Approval Duration
714
+ </label>
715
+ <div className="grid grid-cols-4 gap-1.5">
716
+ {options.map((option) => (
717
+ <button
718
+ key={`${option.label}-${option.durationMs}`}
719
+ type="button"
720
+ onClick={() => onChange(option.durationMs)}
721
+ disabled={disabled}
722
+ className={`rounded py-1.5 text-base font-medium disabled:cursor-not-allowed disabled:opacity-50 ${
723
+ value === option.durationMs ? "btn-raised-active" : "btn-raised"
724
+ }`}
725
+ >
726
+ {option.label}
727
+ </button>
728
+ ))}
729
+ </div>
730
+ </div>
731
+ );
732
+ }
733
+
734
+ function ErrorBox({ message }: { message: string }) {
735
+ return (
736
+ <div className="flex items-start gap-2 rounded-lg border border-trade-down/20 bg-trade-down/[0.08] p-3">
737
+ <AlertTriangle
738
+ size={14}
739
+ className="mt-0.5 flex-shrink-0 text-trade-down"
740
+ />
741
+ <p className="text-base text-trade-down">{message}</p>
742
+ </div>
743
+ );
744
+ }