@hfunlabs/hypurr-connect 0.1.1 → 0.1.3

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.
@@ -1,23 +1,32 @@
1
1
  import {
2
2
  ExchangeClient,
3
3
  HttpTransport,
4
- InfoClient,
4
+ type IRequestTransport,
5
5
  } from "@hfunlabs/hyperliquid";
6
- import { PrivateKeySigner } from "@hfunlabs/hyperliquid/signing";
6
+ import { PrivateKeySigner, signUserSignedAction } from "@hfunlabs/hyperliquid/signing";
7
7
  import type { TelegramUserResponse } from "hypurr-grpc/ts/hypurr/telegram/telegram_service";
8
- import type { TelegramUser as HypurrTelegramUser } from "hypurr-grpc/ts/hypurr/user";
8
+ import type {
9
+ TelegramUser as HypurrTelegramUser,
10
+ TelegramChatWalletPack,
11
+ } from "hypurr-grpc/ts/hypurr/user";
12
+ import type { HyperliquidWallet } from "hypurr-grpc/ts/hypurr/wallet";
9
13
  import {
10
14
  createContext,
11
15
  useCallback,
12
16
  useContext,
13
17
  useEffect,
14
18
  useMemo,
19
+ useRef,
15
20
  useState,
16
21
  type ReactNode,
17
22
  } from "react";
18
23
  import {
24
+ AGENT_NAME,
19
25
  clearAgent as clearStoredAgent,
26
+ fetchActiveAgent,
20
27
  generateAgentKey,
28
+ isAgentValid,
29
+ isDeadAgentError,
21
30
  loadAgent,
22
31
  saveAgent,
23
32
  } from "./agent";
@@ -25,6 +34,7 @@ import { createStaticClient, createTelegramClient } from "./grpc";
25
34
  import { GrpcExchangeTransport } from "./GrpcExchangeTransport";
26
35
  import type {
27
36
  AuthMethod,
37
+ EoaSigner,
28
38
  HypurrConnectConfig,
29
39
  HypurrConnectState,
30
40
  HypurrUser,
@@ -33,6 +43,13 @@ import type {
33
43
  TelegramLoginData,
34
44
  } from "./types";
35
45
 
46
+ /** @internal context value — extends the public type with fields used only by library internals */
47
+ interface InternalConnectState extends HypurrConnectState {
48
+ loginTelegram: (data: TelegramLoginData) => void;
49
+ botUsername: string;
50
+ useWidget: boolean;
51
+ }
52
+
36
53
  const TELEGRAM_STORAGE_KEY = "hypurr-connect-tg-user";
37
54
 
38
55
  function toAuthDataMap(data: TelegramLoginData): Record<string, string> {
@@ -48,7 +65,7 @@ function toAuthDataMap(data: TelegramLoginData): Record<string, string> {
48
65
  return map;
49
66
  }
50
67
 
51
- const HypurrConnectContext = createContext<HypurrConnectState | null>(null);
68
+ const HypurrConnectContext = createContext<InternalConnectState | null>(null);
52
69
 
53
70
  export function useHypurrConnect(): HypurrConnectState {
54
71
  const ctx = useContext(HypurrConnectContext);
@@ -59,6 +76,16 @@ export function useHypurrConnect(): HypurrConnectState {
59
76
  return ctx;
60
77
  }
61
78
 
79
+ /** @internal — gives library components access to fields not on the public API */
80
+ export function useHypurrConnectInternal(): InternalConnectState {
81
+ const ctx = useContext(HypurrConnectContext);
82
+ if (!ctx)
83
+ throw new Error(
84
+ "useHypurrConnectInternal must be used within <HypurrConnectProvider>",
85
+ );
86
+ return ctx;
87
+ }
88
+
62
89
  export function HypurrConnectProvider({
63
90
  config,
64
91
  children,
@@ -89,6 +116,8 @@ export function HypurrConnectProvider({
89
116
  [tgLoginData],
90
117
  );
91
118
 
119
+ const [tgUserTick, setTgUserTick] = useState(0);
120
+
92
121
  useEffect(() => {
93
122
  if (!tgLoginData) return;
94
123
  let cancelled = false;
@@ -98,9 +127,7 @@ export function HypurrConnectProvider({
98
127
  (async () => {
99
128
  try {
100
129
  const authData = toAuthDataMap(tgLoginData);
101
- console.log(authData);
102
130
  const { response } = await tgClient.telegramUser({ authData });
103
- console.log(response);
104
131
  if (cancelled) return;
105
132
  setTgUser((response as TelegramUserResponse).user ?? null);
106
133
  } catch (err) {
@@ -115,19 +142,14 @@ export function HypurrConnectProvider({
115
142
  return () => {
116
143
  cancelled = true;
117
144
  };
118
- }, [tgLoginData, tgClient]);
145
+ }, [tgLoginData, tgClient, tgUserTick]);
119
146
 
120
147
  // ── EOA auth state ───────────────────────────────────────────
121
148
  const [eoaAddress, setEoaAddress] = useState<`0x${string}` | null>(null);
122
149
  const [agent, setAgent] = useState<StoredAgent | null>(null);
123
-
124
- useEffect(() => {
125
- if (eoaAddress) {
126
- setAgent(loadAgent(eoaAddress));
127
- } else {
128
- setAgent(null);
129
- }
130
- }, [eoaAddress]);
150
+ const [eoaLoading, setEoaLoading] = useState(false);
151
+ const [eoaError, setEoaError] = useState<string | null>(null);
152
+ const eoaSignerRef = useRef<EoaSigner | null>(null);
131
153
 
132
154
  // ── Derived auth ─────────────────────────────────────────────
133
155
  const authMethod: AuthMethod = tgLoginData
@@ -136,19 +158,59 @@ export function HypurrConnectProvider({
136
158
  ? "eoa"
137
159
  : null;
138
160
 
139
- const tgWallet = tgUser?.wallet ?? (tgUser?.wallets ?? [])[0] ?? null;
161
+ // ── Multi-wallet state (Telegram) ─────────────────────────────
162
+ const [wallets, setWallets] = useState<HyperliquidWallet[]>([]);
163
+ const [selectedWalletId, setSelectedWalletId] = useState<number>(0);
164
+ const [packs, setPacks] = useState<TelegramChatWalletPack[]>([]);
165
+
166
+ const refreshWallets = useCallback(() => setTgUserTick((t) => t + 1), []);
167
+
168
+ useEffect(() => {
169
+ if (authMethod !== "telegram" || !tgUser) {
170
+ setWallets([]);
171
+ setSelectedWalletId(0);
172
+ setPacks([]);
173
+ return;
174
+ }
175
+
176
+ const userWallets = tgUser.wallets ?? [];
177
+ setWallets(userWallets);
178
+ setPacks(tgUser.packs ?? []);
179
+
180
+ const defaultId = tgUser.walletId || userWallets[0]?.id || 0;
181
+ setSelectedWalletId((prev) => {
182
+ if (prev && userWallets.some((w) => w.id === prev)) return prev;
183
+ return defaultId;
184
+ });
185
+ }, [authMethod, tgUser]);
186
+
187
+ const selectedWallet = useMemo(
188
+ () => wallets.find((w) => w.id === selectedWalletId) ?? wallets[0] ?? null,
189
+ [wallets, selectedWalletId],
190
+ );
191
+
192
+ const selectWallet = useCallback(
193
+ (walletId: number) => {
194
+ if (wallets.some((w) => w.id === walletId)) {
195
+ setSelectedWalletId(walletId);
196
+ }
197
+ },
198
+ [wallets],
199
+ );
140
200
 
141
201
  const user = useMemo<HypurrUser | null>(() => {
142
- if (tgLoginData && authMethod === "telegram") {
202
+ if (tgLoginData && authMethod === "telegram" && selectedWallet) {
143
203
  return {
144
- address: tgWallet?.ethereumAddress ?? "",
145
- walletId: tgUser?.walletId ?? tgWallet?.id ?? 0,
204
+ address: selectedWallet.ethereumAddress,
205
+ walletId: selectedWallet.id,
146
206
  displayName: tgLoginData.username
147
207
  ? `@${tgLoginData.username}`
148
208
  : tgLoginData.first_name,
149
209
  photoUrl: tgLoginData.photo_url,
150
210
  authMethod: "telegram",
151
211
  telegramId: String(tgLoginData.id),
212
+ hfunScore: tgUser?.reputation?.hfunScore,
213
+ reputationScore: tgUser?.reputation?.reputationScore,
152
214
  };
153
215
  }
154
216
  if (eoaAddress && authMethod === "eoa") {
@@ -160,11 +222,43 @@ export function HypurrConnectProvider({
160
222
  };
161
223
  }
162
224
  return null;
163
- }, [tgLoginData, tgUser, tgWallet, eoaAddress, authMethod]);
225
+ }, [tgLoginData, selectedWallet, eoaAddress, authMethod, tgUser]);
164
226
 
165
227
  // ── Exchange client ──────────────────────────────────────────
166
228
  // Telegram: GrpcExchangeTransport → HyperliquidCoreAction (server signs)
167
- // EOA: HttpTransport + agent wallet (SDK signs locally)
229
+ // EOA: dual wallet agent key for L1 actions, master signer for user-signed
230
+ // actions (transfers, withdrawals, etc.). The dual wallet inspects the
231
+ // EIP-712 domain name to decide which key signs each request.
232
+ // When a signer is available but no agent exists yet, the dual wallet
233
+ // auto-provisions an agent on the first L1 action (triggers one extra
234
+ // wallet popup for the approveAgent user-signed action).
235
+
236
+ const onDeadAgentRef = useRef<((address: `0x${string}`) => void) | null>(
237
+ null,
238
+ );
239
+ onDeadAgentRef.current = (addr: `0x${string}`) => {
240
+ clearStoredAgent(addr);
241
+ setAgent(null);
242
+ setEoaError("Agent expired or was deregistered. Please reconnect.");
243
+ };
244
+
245
+ // Mutable slot for the agent signer — the dual wallet reads this so it can
246
+ // pick up a newly provisioned agent without waiting for a React re-render.
247
+ const agentSignerRef = useRef<PrivateKeySigner | null>(
248
+ agent ? new PrivateKeySigner(agent.privateKey) : null,
249
+ );
250
+ useEffect(() => {
251
+ agentSignerRef.current = agent
252
+ ? new PrivateKeySigner(agent.privateKey)
253
+ : null;
254
+ }, [agent]);
255
+
256
+ // Lock to prevent concurrent auto-provisioning attempts
257
+ const provisioningRef = useRef<Promise<PrivateKeySigner> | null>(null);
258
+
259
+ const agentReady =
260
+ authMethod === "telegram" || (authMethod === "eoa" && !!agent);
261
+
168
262
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
169
263
  const exchange = useMemo<ExchangeClient<any> | null>(() => {
170
264
  if (authMethod === "telegram" && user?.address) {
@@ -181,150 +275,304 @@ export function HypurrConnectProvider({
181
275
  });
182
276
  }
183
277
 
184
- if (authMethod === "eoa" && agent) {
185
- const wallet = new PrivateKeySigner(agent.privateKey);
186
- return new ExchangeClient({
187
- transport: new HttpTransport({
188
- isTestnet: config.isTestnet ?? false,
189
- }),
190
- wallet,
191
- });
192
- }
193
-
194
- return null;
195
- }, [authMethod, user, agent, config.isTestnet, tgClient, authDataMap]);
278
+ if (authMethod === "eoa" && eoaAddress) {
279
+ const hasSigner = !!eoaSignerRef.current;
196
280
 
197
- // ── USDC balance from Hyperliquid ──────────────────────────────
198
- const infoClient = useMemo(
199
- () =>
200
- new InfoClient({
201
- transport: new HttpTransport({
281
+ if (!agent && !hasSigner) {
282
+ const noAgentTransport: IRequestTransport = {
202
283
  isTestnet: config.isTestnet ?? false,
203
- }),
204
- }),
205
- [config.isTestnet],
206
- );
207
-
208
- const [usdcBalance, setUsdcBalance] = useState<string | null>(null);
209
- const [usdcBalanceLoading, setUsdcBalanceLoading] = useState(false);
210
- const [balanceTick, setBalanceTick] = useState(0);
211
-
212
- const refreshBalance = useCallback(() => setBalanceTick((t) => t + 1), []);
213
-
214
- useEffect(() => {
215
- const addr = user?.address;
216
- if (!addr) {
217
- setUsdcBalance(null);
218
- return;
219
- }
220
-
221
- let cancelled = false;
222
- setUsdcBalanceLoading(true);
223
-
224
- (async () => {
225
- try {
226
- const state = await infoClient.clearinghouseState({
227
- user: addr as `0x${string}`,
284
+ request(): Promise<never> {
285
+ throw new Error(
286
+ "[HypurrConnect] No agent key approved and no wallet signer available. " +
287
+ "Either call approveAgent(signTypedDataAsync) or pass a signer to " +
288
+ "connectEoa(address, { signTypedData, chainId }).",
289
+ );
290
+ },
291
+ };
292
+ return new ExchangeClient({
293
+ transport: noAgentTransport,
294
+ externalSigning: true,
295
+ userAddress: eoaAddress,
228
296
  });
229
- if (!cancelled) {
230
- setUsdcBalance(state.withdrawable);
231
- }
232
- } catch (err) {
233
- console.error("[HypurrConnect] Failed to fetch USDC balance:", err);
234
- if (!cancelled) setUsdcBalance(null);
235
- } finally {
236
- if (!cancelled) setUsdcBalanceLoading(false);
237
297
  }
238
- })();
239
-
240
- return () => {
241
- cancelled = true;
242
- };
243
- }, [user?.address, infoClient, balanceTick]);
244
-
245
- // ── Agent approval (EOA flow) ────────────────────────────────
246
- const approveAgent = useCallback(
247
- async (signTypedDataAsync: SignTypedDataFn) => {
248
- if (!eoaAddress) throw new Error("No EOA address connected");
249
-
250
- const { privateKey, address: agentAddress } = await generateAgentKey();
251
298
 
252
299
  const isTestnet = config.isTestnet ?? false;
253
- const nonce = Date.now();
254
- const action = {
255
- type: "approveAgent",
256
- signatureChainId: isTestnet ? "0x66eee" : "0xa4b1",
257
- hyperliquidChain: isTestnet ? "Testnet" : "Mainnet",
258
- agentAddress: agentAddress.toLowerCase() as `0x${string}`,
259
- agentName: null as string | null,
260
- nonce,
300
+ const inner = new HttpTransport({ isTestnet });
301
+ const deadAgentAddr = eoaAddress;
302
+ const guardedTransport: IRequestTransport = {
303
+ isTestnet: inner.isTestnet,
304
+ async request<T>(
305
+ endpoint: "info" | "exchange" | "explorer",
306
+ payload: unknown,
307
+ signal?: AbortSignal,
308
+ ): Promise<T> {
309
+ try {
310
+ return await inner.request<T>(endpoint, payload, signal);
311
+ } catch (err) {
312
+ if (endpoint === "exchange" && isDeadAgentError(err)) {
313
+ onDeadAgentRef.current?.(deadAgentAddr);
314
+ }
315
+ throw err;
316
+ }
317
+ },
261
318
  };
262
- const types = {
263
- "HyperliquidTransaction:ApproveAgent": [
264
- { name: "hyperliquidChain", type: "string" },
265
- { name: "agentAddress", type: "address" },
266
- { name: "agentName", type: "string" },
267
- { name: "nonce", type: "uint64" },
268
- ],
319
+
320
+ const signerRef = eoaSignerRef;
321
+ const agentRef = agentSignerRef;
322
+ const provRef = provisioningRef;
323
+ const ownerAddress = eoaAddress;
324
+
325
+ /**
326
+ * Auto-provision an agent key when one doesn't exist yet.
327
+ *
328
+ * Bypasses the SDK's `executeUserSignedAction` (and its per-address
329
+ * semaphore) to avoid deadlocking when called from inside
330
+ * `dualWallet.signTypedData`, which is already inside the SDK's
331
+ * `executeL1Action` lock for the same address.
332
+ */
333
+ const ensureAgent = async (): Promise<PrivateKeySigner> => {
334
+ const existing = agentRef.current;
335
+ if (existing) return existing;
336
+
337
+ if (provRef.current) return provRef.current;
338
+
339
+ const signer = signerRef.current;
340
+ if (!signer) {
341
+ throw new Error(
342
+ "[HypurrConnect] No wallet signer available to auto-provision agent. " +
343
+ "Pass a signer to connectEoa(address, { signTypedData, chainId }).",
344
+ );
345
+ }
346
+
347
+ provRef.current = (async () => {
348
+ try {
349
+ const { privateKey, address: agentAddress } =
350
+ await generateAgentKey();
351
+
352
+ const chainIdHex = `0x${signer.chainId.toString(16)}` as `0x${string}`;
353
+ const nonce = Date.now();
354
+ const action = {
355
+ type: "approveAgent" as const,
356
+ signatureChainId: chainIdHex,
357
+ hyperliquidChain: (isTestnet ? "Testnet" : "Mainnet") as
358
+ | "Testnet"
359
+ | "Mainnet",
360
+ agentAddress: agentAddress.toLowerCase() as `0x${string}`,
361
+ agentName: AGENT_NAME,
362
+ nonce,
363
+ };
364
+
365
+ const approveAgentTypes = {
366
+ "HyperliquidTransaction:ApproveAgent": [
367
+ { name: "hyperliquidChain", type: "string" },
368
+ { name: "agentAddress", type: "address" },
369
+ { name: "agentName", type: "string" },
370
+ { name: "nonce", type: "uint64" },
371
+ ],
372
+ };
373
+
374
+ const wallet = {
375
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
376
+ signTypedData(params: any) {
377
+ return signer.signTypedData(params);
378
+ },
379
+ getAddresses: async () => [ownerAddress] as `0x${string}`[],
380
+ getChainId: async () => signer.chainId,
381
+ };
382
+
383
+ const signature = await signUserSignedAction({
384
+ wallet,
385
+ action,
386
+ types: approveAgentTypes,
387
+ });
388
+
389
+ const apiUrl = isTestnet
390
+ ? "https://api.hyperliquid-testnet.xyz/exchange"
391
+ : "https://api.hyperliquid.xyz/exchange";
392
+
393
+ const res = await fetch(apiUrl, {
394
+ method: "POST",
395
+ headers: { "Content-Type": "application/json" },
396
+ body: JSON.stringify({ action, signature, nonce }),
397
+ });
398
+
399
+ const body = await res.json();
400
+ if (body?.status === "err") {
401
+ throw new Error(
402
+ `approveAgent API error: ${body.response ?? JSON.stringify(body)}`,
403
+ );
404
+ }
405
+
406
+ const remote = await fetchActiveAgent(ownerAddress, isTestnet);
407
+ const validUntil =
408
+ remote?.validUntil ?? Date.now() + 7 * 24 * 60 * 60 * 1000;
409
+
410
+ const stored: StoredAgent = {
411
+ privateKey,
412
+ address: agentAddress,
413
+ approvedAt: Date.now(),
414
+ validUntil,
415
+ };
416
+ saveAgent(ownerAddress, stored);
417
+
418
+ const newSigner = new PrivateKeySigner(privateKey);
419
+ agentRef.current = newSigner;
420
+ setAgent(stored);
421
+
422
+ return newSigner;
423
+ } finally {
424
+ provRef.current = null;
425
+ }
426
+ })();
427
+
428
+ return provRef.current;
269
429
  };
270
430
 
271
- const signature = await signTypedDataAsync({
272
- domain: {
273
- name: "HyperliquidSignTransaction",
274
- version: "1",
275
- chainId: isTestnet ? 421614 : 42161,
276
- verifyingContract: "0x0000000000000000000000000000000000000000",
431
+ // Dual wallet: routes signing based on the EIP-712 domain.
432
+ // "Exchange" domain → L1 action → agent key signs (auto-provisions if needed).
433
+ // "HyperliquidSignTransaction" domain → user-signed → master wallet (popup).
434
+ const dualWallet = {
435
+ address: ownerAddress,
436
+ async signTypedData(params: {
437
+ domain: {
438
+ name?: string;
439
+ version?: string;
440
+ chainId?: number;
441
+ verifyingContract?: `0x${string}`;
442
+ salt?: `0x${string}`;
443
+ };
444
+ types: Record<string, { name: string; type: string }[]>;
445
+ primaryType: string;
446
+ message: Record<string, unknown>;
447
+ }): Promise<`0x${string}`> {
448
+ if (params.domain.name === "HyperliquidSignTransaction") {
449
+ const signer = signerRef.current;
450
+ if (!signer) {
451
+ throw new Error(
452
+ "[HypurrConnect] No wallet signer available for user-signed actions. " +
453
+ "Pass a signer to connectEoa(address, { signTypedData, chainId }).",
454
+ );
455
+ }
456
+ return signer.signTypedData(
457
+ params as Parameters<typeof signer.signTypedData>[0],
458
+ );
459
+ }
460
+
461
+ const agentSigner = await ensureAgent();
462
+ return agentSigner.signTypedData(params);
277
463
  },
278
- types,
279
- primaryType: "HyperliquidTransaction:ApproveAgent",
280
- message: {
281
- hyperliquidChain: action.hyperliquidChain,
282
- agentAddress: action.agentAddress,
283
- agentName: "",
284
- nonce: BigInt(nonce),
464
+ };
465
+
466
+ return new ExchangeClient({
467
+ transport: guardedTransport,
468
+ wallet: dualWallet,
469
+ signatureChainId: () => {
470
+ const id = signerRef.current?.chainId ?? 42161;
471
+ return `0x${id.toString(16)}` as `0x${string}`;
285
472
  },
286
473
  });
474
+ }
287
475
 
288
- const r = `0x${signature.slice(2, 66)}`;
289
- const s = `0x${signature.slice(66, 130)}`;
290
- const v = parseInt(signature.slice(130, 132), 16);
476
+ return null;
477
+ }, [
478
+ authMethod,
479
+ user,
480
+ agent,
481
+ eoaAddress,
482
+ config.isTestnet,
483
+ tgClient,
484
+ authDataMap,
485
+ ]);
291
486
 
292
- const url = isTestnet
293
- ? "https://api.hyperliquid-testnet.xyz/exchange"
294
- : "https://api.hyperliquid.xyz/exchange";
487
+ const handleClearAgent = useCallback(() => {
488
+ if (eoaAddress) {
489
+ clearStoredAgent(eoaAddress);
490
+ setAgent(null);
491
+ }
492
+ }, [eoaAddress]);
295
493
 
296
- const res = await fetch(url, {
297
- method: "POST",
298
- headers: { "Content-Type": "application/json" },
299
- body: JSON.stringify({
300
- action,
301
- nonce,
302
- signature: { r, s, v },
303
- }),
494
+ // ── Wallet management (Telegram only) ───────────────────────
495
+ const createWallet = useCallback(
496
+ async (name: string): Promise<HyperliquidWallet> => {
497
+ const { response } = await tgClient.hyperliquidWalletCreate({
498
+ authData: authDataMap,
499
+ name,
304
500
  });
501
+ refreshWallets();
502
+ if (!response.wallet)
503
+ throw new Error("Wallet creation returned no wallet");
504
+ return response.wallet;
505
+ },
506
+ [tgClient, authDataMap, refreshWallets],
507
+ );
305
508
 
306
- const body = await res.json();
307
- if (body?.status !== "ok") {
308
- throw new Error(`approveAgent failed: ${JSON.stringify(body)}`);
509
+ const deleteWallet = useCallback(
510
+ async (walletId: number): Promise<void> => {
511
+ await tgClient.hyperliquidWalletDelete({
512
+ authData: authDataMap,
513
+ walletId,
514
+ });
515
+ if (walletId === selectedWalletId) {
516
+ const remaining = wallets.filter((w) => w.id !== walletId);
517
+ setSelectedWalletId(remaining[0]?.id ?? 0);
309
518
  }
519
+ refreshWallets();
520
+ },
521
+ [tgClient, authDataMap, selectedWalletId, wallets, refreshWallets],
522
+ );
310
523
 
311
- const stored: StoredAgent = {
312
- privateKey,
313
- address: agentAddress,
314
- approvedAt: Date.now(),
315
- };
316
- saveAgent(eoaAddress, stored);
317
- setAgent(stored);
524
+ const createWalletPack = useCallback(
525
+ async (name: string): Promise<number> => {
526
+ const { response } = await tgClient.telegramChatWalletPackCreate({
527
+ authData: authDataMap,
528
+ name,
529
+ });
530
+ refreshWallets();
531
+ return response.packId;
318
532
  },
319
- [eoaAddress, config.isTestnet],
533
+ [tgClient, authDataMap, refreshWallets],
320
534
  );
321
535
 
322
- const handleClearAgent = useCallback(() => {
323
- if (eoaAddress) {
324
- clearStoredAgent(eoaAddress);
325
- setAgent(null);
326
- }
327
- }, [eoaAddress]);
536
+ const addPackLabel = useCallback(
537
+ async (params: {
538
+ walletAddress: string;
539
+ walletLabel: string;
540
+ packId: number;
541
+ }): Promise<void> => {
542
+ await tgClient.telegramChatWalletPackLabelAdd({
543
+ authData: authDataMap,
544
+ ...params,
545
+ });
546
+ refreshWallets();
547
+ },
548
+ [tgClient, authDataMap, refreshWallets],
549
+ );
550
+
551
+ const modifyPackLabel = useCallback(
552
+ async (params: {
553
+ walletLabelOld: string;
554
+ walletLabelNew: string;
555
+ packId: number;
556
+ }): Promise<void> => {
557
+ await tgClient.telegramChatWalletPackLabelModify({
558
+ authData: authDataMap,
559
+ ...params,
560
+ });
561
+ refreshWallets();
562
+ },
563
+ [tgClient, authDataMap, refreshWallets],
564
+ );
565
+
566
+ const removePackLabel = useCallback(
567
+ async (params: { walletLabel: string; packId: number }): Promise<void> => {
568
+ await tgClient.telegramChatWalletPackLabelRemove({
569
+ authData: authDataMap,
570
+ ...params,
571
+ });
572
+ refreshWallets();
573
+ },
574
+ [tgClient, authDataMap, refreshWallets],
575
+ );
328
576
 
329
577
  // ── Login modal state ────────────────────────────────────────
330
578
  const [loginModalOpen, setLoginModalOpen] = useState(false);
@@ -337,15 +585,133 @@ export function HypurrConnectProvider({
337
585
  localStorage.setItem(TELEGRAM_STORAGE_KEY, JSON.stringify(data));
338
586
  setEoaAddress(null);
339
587
  setAgent(null);
588
+ setEoaError(null);
340
589
  }, []);
341
590
 
342
- const loginEoa = useCallback((address: `0x${string}`) => {
343
- setEoaAddress(address);
344
- setTgLoginData(null);
345
- setTgUser(null);
346
- setTgError(null);
347
- localStorage.removeItem(TELEGRAM_STORAGE_KEY);
348
- }, []);
591
+ const connectEoa = useCallback(
592
+ (address: `0x${string}`, signer?: EoaSigner) => {
593
+ eoaSignerRef.current = signer ?? null;
594
+ setEoaAddress(address);
595
+ setTgLoginData(null);
596
+ setTgUser(null);
597
+ setTgError(null);
598
+ setEoaError(null);
599
+ localStorage.removeItem(TELEGRAM_STORAGE_KEY);
600
+
601
+ const existing = loadAgent(address);
602
+ if (existing && existing.validUntil > Date.now()) {
603
+ setAgent(existing);
604
+ } else {
605
+ if (existing) clearStoredAgent(address);
606
+ setAgent(null);
607
+ }
608
+ },
609
+ [],
610
+ );
611
+
612
+ const approveAgentFn = useCallback(
613
+ async (signTypedDataAsync: SignTypedDataFn, chainId: number) => {
614
+ if (!eoaAddress) {
615
+ throw new Error(
616
+ "[HypurrConnect] Cannot approve agent: no EOA wallet connected. Call connectEoa(address) first.",
617
+ );
618
+ }
619
+
620
+ eoaSignerRef.current = { signTypedData: signTypedDataAsync, chainId };
621
+
622
+ setEoaLoading(true);
623
+ setEoaError(null);
624
+ try {
625
+ const existing = loadAgent(eoaAddress);
626
+ if (existing) {
627
+ const isTestnet = config.isTestnet ?? false;
628
+ const valid = await isAgentValid(existing, eoaAddress, isTestnet);
629
+ if (valid) {
630
+ setAgent(existing);
631
+ return;
632
+ }
633
+ clearStoredAgent(eoaAddress);
634
+ }
635
+
636
+ const { privateKey, address: agentAddress } = await generateAgentKey();
637
+ const isTestnet = config.isTestnet ?? false;
638
+
639
+ const chainIdHex = `0x${chainId.toString(16)}` as `0x${string}`;
640
+ const nonce = Date.now();
641
+ const action = {
642
+ type: "approveAgent" as const,
643
+ signatureChainId: chainIdHex,
644
+ hyperliquidChain: (isTestnet ? "Testnet" : "Mainnet") as
645
+ | "Testnet"
646
+ | "Mainnet",
647
+ agentAddress: agentAddress.toLowerCase() as `0x${string}`,
648
+ agentName: AGENT_NAME,
649
+ nonce,
650
+ };
651
+
652
+ const approveAgentTypes = {
653
+ "HyperliquidTransaction:ApproveAgent": [
654
+ { name: "hyperliquidChain", type: "string" },
655
+ { name: "agentAddress", type: "address" },
656
+ { name: "agentName", type: "string" },
657
+ { name: "nonce", type: "uint64" },
658
+ ],
659
+ };
660
+
661
+ const wallet = {
662
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
663
+ signTypedData(params: any) {
664
+ return signTypedDataAsync(params);
665
+ },
666
+ getAddresses: async () => [eoaAddress] as `0x${string}`[],
667
+ getChainId: async () => chainId,
668
+ };
669
+
670
+ const signature = await signUserSignedAction({
671
+ wallet,
672
+ action,
673
+ types: approveAgentTypes,
674
+ });
675
+
676
+ const apiUrl = isTestnet
677
+ ? "https://api.hyperliquid-testnet.xyz/exchange"
678
+ : "https://api.hyperliquid.xyz/exchange";
679
+
680
+ const res = await fetch(apiUrl, {
681
+ method: "POST",
682
+ headers: { "Content-Type": "application/json" },
683
+ body: JSON.stringify({ action, signature, nonce }),
684
+ });
685
+
686
+ const body = await res.json();
687
+ if (body?.status === "err") {
688
+ throw new Error(
689
+ `approveAgent API error: ${body.response ?? JSON.stringify(body)}`,
690
+ );
691
+ }
692
+
693
+ const remote = await fetchActiveAgent(eoaAddress, isTestnet);
694
+ const validUntil =
695
+ remote?.validUntil ?? Date.now() + 7 * 24 * 60 * 60 * 1000;
696
+
697
+ const stored: StoredAgent = {
698
+ privateKey,
699
+ address: agentAddress,
700
+ approvedAt: Date.now(),
701
+ validUntil,
702
+ };
703
+ saveAgent(eoaAddress, stored);
704
+ setAgent(stored);
705
+ } catch (err) {
706
+ console.error("[HypurrConnect] EOA agent approval failed:", err);
707
+ setEoaError(err instanceof Error ? err.message : String(err));
708
+ setAgent(null);
709
+ } finally {
710
+ setEoaLoading(false);
711
+ }
712
+ },
713
+ [eoaAddress, config.isTestnet],
714
+ );
349
715
 
350
716
  const logout = useCallback(() => {
351
717
  setTgLoginData(null);
@@ -353,37 +719,51 @@ export function HypurrConnectProvider({
353
719
  setTgError(null);
354
720
  setEoaAddress(null);
355
721
  setAgent(null);
722
+ setEoaError(null);
723
+ eoaSignerRef.current = null;
356
724
  localStorage.removeItem(TELEGRAM_STORAGE_KEY);
357
725
  }, []);
358
726
 
359
727
  // ── Context value ────────────────────────────────────────────
360
- const value = useMemo<HypurrConnectState>(
728
+ const value = useMemo<InternalConnectState>(
361
729
  () => ({
362
730
  user,
363
731
  isLoggedIn: !!user,
364
- isLoading: tgLoading,
365
- error: tgError,
732
+ isLoading: tgLoading || eoaLoading,
733
+ error: tgError ?? eoaError,
366
734
  authMethod,
367
735
  exchange,
368
736
 
369
- usdcBalance,
370
- usdcBalanceLoading,
371
- refreshBalance,
737
+ wallets,
738
+ selectedWalletId,
739
+ selectWallet,
740
+
741
+ createWallet,
742
+ deleteWallet,
743
+ refreshWallets,
744
+
745
+ packs,
746
+ createWalletPack,
747
+ addPackLabel,
748
+ modifyPackLabel,
749
+ removePackLabel,
372
750
 
373
751
  loginModalOpen,
374
752
  openLoginModal,
375
753
  closeLoginModal,
376
754
 
377
755
  loginTelegram,
378
- loginEoa,
756
+ connectEoa,
757
+ approveAgent: approveAgentFn,
379
758
  logout,
380
759
 
381
760
  agent,
382
- agentReady: authMethod === "telegram" || !!agent,
383
- approveAgent,
761
+ agentReady,
384
762
  clearAgent: handleClearAgent,
385
763
 
386
764
  botId: config.telegram?.botId ?? "",
765
+ botUsername: config.telegram?.botUsername ?? "",
766
+ useWidget: config.telegram?.useWidget ?? false,
387
767
 
388
768
  authDataMap,
389
769
  telegramClient: tgClient,
@@ -392,22 +772,35 @@ export function HypurrConnectProvider({
392
772
  [
393
773
  user,
394
774
  tgLoading,
775
+ eoaLoading,
395
776
  tgError,
777
+ eoaError,
396
778
  authMethod,
397
779
  exchange,
398
- usdcBalance,
399
- usdcBalanceLoading,
400
- refreshBalance,
780
+ wallets,
781
+ selectedWalletId,
782
+ selectWallet,
783
+ createWallet,
784
+ deleteWallet,
785
+ refreshWallets,
786
+ packs,
787
+ createWalletPack,
788
+ addPackLabel,
789
+ modifyPackLabel,
790
+ removePackLabel,
401
791
  loginModalOpen,
402
792
  openLoginModal,
403
793
  closeLoginModal,
404
794
  loginTelegram,
405
- loginEoa,
795
+ connectEoa,
796
+ approveAgentFn,
406
797
  logout,
407
798
  agent,
408
- approveAgent,
799
+ agentReady,
409
800
  handleClearAgent,
410
801
  config.telegram?.botId,
802
+ config.telegram?.botUsername,
803
+ config.telegram?.useWidget,
411
804
  authDataMap,
412
805
  tgClient,
413
806
  staticClient,