@hfunlabs/hypurr-connect 0.1.2 → 0.1.4

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.
@@ -3,8 +3,7 @@ import {
3
3
  HttpTransport,
4
4
  type IRequestTransport,
5
5
  } from "@hfunlabs/hyperliquid";
6
- import { approveAgent as sdkApproveAgent } from "@hfunlabs/hyperliquid/api/exchange";
7
- import { PrivateKeySigner } from "@hfunlabs/hyperliquid/signing";
6
+ import { PrivateKeySigner, signUserSignedAction } from "@hfunlabs/hyperliquid/signing";
8
7
  import type { TelegramUserResponse } from "hypurr-grpc/ts/hypurr/telegram/telegram_service";
9
8
  import type {
10
9
  TelegramUser as HypurrTelegramUser,
@@ -35,6 +34,7 @@ import { createStaticClient, createTelegramClient } from "./grpc";
35
34
  import { GrpcExchangeTransport } from "./GrpcExchangeTransport";
36
35
  import type {
37
36
  AuthMethod,
37
+ EoaSigner,
38
38
  HypurrConnectConfig,
39
39
  HypurrConnectState,
40
40
  HypurrUser,
@@ -46,6 +46,8 @@ import type {
46
46
  /** @internal context value — extends the public type with fields used only by library internals */
47
47
  interface InternalConnectState extends HypurrConnectState {
48
48
  loginTelegram: (data: TelegramLoginData) => void;
49
+ botUsername: string;
50
+ useWidget: boolean;
49
51
  }
50
52
 
51
53
  const TELEGRAM_STORAGE_KEY = "hypurr-connect-tg-user";
@@ -125,9 +127,19 @@ export function HypurrConnectProvider({
125
127
  (async () => {
126
128
  try {
127
129
  const authData = toAuthDataMap(tgLoginData);
128
- const { response } = await tgClient.telegramUser({ authData });
130
+ const [{ response: userResp }, { response: walletsResp }] =
131
+ await Promise.all([
132
+ tgClient.telegramUser({ authData }),
133
+ tgClient.telegramUserWallets({ authData }),
134
+ ]);
129
135
  if (cancelled) return;
130
- setTgUser((response as TelegramUserResponse).user ?? null);
136
+ const user = (userResp as TelegramUserResponse).user ?? null;
137
+ if (user) {
138
+ // TelegramUser.wallets lacks twap/scale sessions; replace with
139
+ // the full wallets from TelegramUserWallets which populates them.
140
+ user.wallets = walletsResp.wallets;
141
+ }
142
+ setTgUser(user);
131
143
  } catch (err) {
132
144
  if (cancelled) return;
133
145
  console.error("[HypurrConnect] gRPC TelegramUser failed:", err);
@@ -147,6 +159,7 @@ export function HypurrConnectProvider({
147
159
  const [agent, setAgent] = useState<StoredAgent | null>(null);
148
160
  const [eoaLoading, setEoaLoading] = useState(false);
149
161
  const [eoaError, setEoaError] = useState<string | null>(null);
162
+ const eoaSignerRef = useRef<EoaSigner | null>(null);
150
163
 
151
164
  // ── Derived auth ─────────────────────────────────────────────
152
165
  const authMethod: AuthMethod = tgLoginData
@@ -223,7 +236,12 @@ export function HypurrConnectProvider({
223
236
 
224
237
  // ── Exchange client ──────────────────────────────────────────
225
238
  // Telegram: GrpcExchangeTransport → HyperliquidCoreAction (server signs)
226
- // EOA: HttpTransport + agent wallet (SDK signs locally)
239
+ // EOA: dual wallet agent key for L1 actions, master signer for user-signed
240
+ // actions (transfers, withdrawals, etc.). The dual wallet inspects the
241
+ // EIP-712 domain name to decide which key signs each request.
242
+ // When a signer is available but no agent exists yet, the dual wallet
243
+ // auto-provisions an agent on the first L1 action (triggers one extra
244
+ // wallet popup for the approveAgent user-signed action).
227
245
 
228
246
  const onDeadAgentRef = useRef<((address: `0x${string}`) => void) | null>(
229
247
  null,
@@ -234,6 +252,20 @@ export function HypurrConnectProvider({
234
252
  setEoaError("Agent expired or was deregistered. Please reconnect.");
235
253
  };
236
254
 
255
+ // Mutable slot for the agent signer — the dual wallet reads this so it can
256
+ // pick up a newly provisioned agent without waiting for a React re-render.
257
+ const agentSignerRef = useRef<PrivateKeySigner | null>(
258
+ agent ? new PrivateKeySigner(agent.privateKey) : null,
259
+ );
260
+ useEffect(() => {
261
+ agentSignerRef.current = agent
262
+ ? new PrivateKeySigner(agent.privateKey)
263
+ : null;
264
+ }, [agent]);
265
+
266
+ // Lock to prevent concurrent auto-provisioning attempts
267
+ const provisioningRef = useRef<Promise<PrivateKeySigner> | null>(null);
268
+
237
269
  const agentReady =
238
270
  authMethod === "telegram" || (authMethod === "eoa" && !!agent);
239
271
 
@@ -254,14 +286,16 @@ export function HypurrConnectProvider({
254
286
  }
255
287
 
256
288
  if (authMethod === "eoa" && eoaAddress) {
257
- if (!agent) {
289
+ const hasSigner = !!eoaSignerRef.current;
290
+
291
+ if (!agent && !hasSigner) {
258
292
  const noAgentTransport: IRequestTransport = {
259
293
  isTestnet: config.isTestnet ?? false,
260
294
  request(): Promise<never> {
261
295
  throw new Error(
262
- "[HypurrConnect] No agent key approved. " +
263
- "Call approveAgent(signTypedDataAsync) before using the exchange client. " +
264
- "This is required for EOA wallets to sign transactions on Hyperliquid.",
296
+ "[HypurrConnect] No agent key approved and no wallet signer available. " +
297
+ "Either call approveAgent(signTypedDataAsync) or pass a signer to " +
298
+ "connectEoa(address, { signTypedData, chainId }).",
265
299
  );
266
300
  },
267
301
  };
@@ -272,9 +306,8 @@ export function HypurrConnectProvider({
272
306
  });
273
307
  }
274
308
 
275
- const inner = new HttpTransport({
276
- isTestnet: config.isTestnet ?? false,
277
- });
309
+ const isTestnet = config.isTestnet ?? false;
310
+ const inner = new HttpTransport({ isTestnet });
278
311
  const deadAgentAddr = eoaAddress;
279
312
  const guardedTransport: IRequestTransport = {
280
313
  isTestnet: inner.isTestnet,
@@ -293,10 +326,160 @@ export function HypurrConnectProvider({
293
326
  }
294
327
  },
295
328
  };
296
- const wallet = new PrivateKeySigner(agent.privateKey);
329
+
330
+ const signerRef = eoaSignerRef;
331
+ const agentRef = agentSignerRef;
332
+ const provRef = provisioningRef;
333
+ const ownerAddress = eoaAddress;
334
+
335
+ /**
336
+ * Auto-provision an agent key when one doesn't exist yet.
337
+ *
338
+ * Bypasses the SDK's `executeUserSignedAction` (and its per-address
339
+ * semaphore) to avoid deadlocking when called from inside
340
+ * `dualWallet.signTypedData`, which is already inside the SDK's
341
+ * `executeL1Action` lock for the same address.
342
+ */
343
+ const ensureAgent = async (): Promise<PrivateKeySigner> => {
344
+ const existing = agentRef.current;
345
+ if (existing) return existing;
346
+
347
+ if (provRef.current) return provRef.current;
348
+
349
+ const signer = signerRef.current;
350
+ if (!signer) {
351
+ throw new Error(
352
+ "[HypurrConnect] No wallet signer available to auto-provision agent. " +
353
+ "Pass a signer to connectEoa(address, { signTypedData, chainId }).",
354
+ );
355
+ }
356
+
357
+ provRef.current = (async () => {
358
+ try {
359
+ const { privateKey, address: agentAddress } =
360
+ await generateAgentKey();
361
+
362
+ const chainIdHex = `0x${signer.chainId.toString(16)}` as `0x${string}`;
363
+ const nonce = Date.now();
364
+ const action = {
365
+ type: "approveAgent" as const,
366
+ signatureChainId: chainIdHex,
367
+ hyperliquidChain: (isTestnet ? "Testnet" : "Mainnet") as
368
+ | "Testnet"
369
+ | "Mainnet",
370
+ agentAddress: agentAddress.toLowerCase() as `0x${string}`,
371
+ agentName: AGENT_NAME,
372
+ nonce,
373
+ };
374
+
375
+ const approveAgentTypes = {
376
+ "HyperliquidTransaction:ApproveAgent": [
377
+ { name: "hyperliquidChain", type: "string" },
378
+ { name: "agentAddress", type: "address" },
379
+ { name: "agentName", type: "string" },
380
+ { name: "nonce", type: "uint64" },
381
+ ],
382
+ };
383
+
384
+ const wallet = {
385
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
386
+ signTypedData(params: any) {
387
+ return signer.signTypedData(params);
388
+ },
389
+ getAddresses: async () => [ownerAddress] as `0x${string}`[],
390
+ getChainId: async () => signer.chainId,
391
+ };
392
+
393
+ const signature = await signUserSignedAction({
394
+ wallet,
395
+ action,
396
+ types: approveAgentTypes,
397
+ });
398
+
399
+ const apiUrl = isTestnet
400
+ ? "https://api.hyperliquid-testnet.xyz/exchange"
401
+ : "https://api.hyperliquid.xyz/exchange";
402
+
403
+ const res = await fetch(apiUrl, {
404
+ method: "POST",
405
+ headers: { "Content-Type": "application/json" },
406
+ body: JSON.stringify({ action, signature, nonce }),
407
+ });
408
+
409
+ const body = await res.json();
410
+ if (body?.status === "err") {
411
+ throw new Error(
412
+ `approveAgent API error: ${body.response ?? JSON.stringify(body)}`,
413
+ );
414
+ }
415
+
416
+ const remote = await fetchActiveAgent(ownerAddress, isTestnet);
417
+ const validUntil =
418
+ remote?.validUntil ?? Date.now() + 7 * 24 * 60 * 60 * 1000;
419
+
420
+ const stored: StoredAgent = {
421
+ privateKey,
422
+ address: agentAddress,
423
+ approvedAt: Date.now(),
424
+ validUntil,
425
+ };
426
+ saveAgent(ownerAddress, stored);
427
+
428
+ const newSigner = new PrivateKeySigner(privateKey);
429
+ agentRef.current = newSigner;
430
+ setAgent(stored);
431
+
432
+ return newSigner;
433
+ } finally {
434
+ provRef.current = null;
435
+ }
436
+ })();
437
+
438
+ return provRef.current;
439
+ };
440
+
441
+ // Dual wallet: routes signing based on the EIP-712 domain.
442
+ // "Exchange" domain → L1 action → agent key signs (auto-provisions if needed).
443
+ // "HyperliquidSignTransaction" domain → user-signed → master wallet (popup).
444
+ const dualWallet = {
445
+ address: ownerAddress,
446
+ async signTypedData(params: {
447
+ domain: {
448
+ name?: string;
449
+ version?: string;
450
+ chainId?: number;
451
+ verifyingContract?: `0x${string}`;
452
+ salt?: `0x${string}`;
453
+ };
454
+ types: Record<string, { name: string; type: string }[]>;
455
+ primaryType: string;
456
+ message: Record<string, unknown>;
457
+ }): Promise<`0x${string}`> {
458
+ if (params.domain.name === "HyperliquidSignTransaction") {
459
+ const signer = signerRef.current;
460
+ if (!signer) {
461
+ throw new Error(
462
+ "[HypurrConnect] No wallet signer available for user-signed actions. " +
463
+ "Pass a signer to connectEoa(address, { signTypedData, chainId }).",
464
+ );
465
+ }
466
+ return signer.signTypedData(
467
+ params as Parameters<typeof signer.signTypedData>[0],
468
+ );
469
+ }
470
+
471
+ const agentSigner = await ensureAgent();
472
+ return agentSigner.signTypedData(params);
473
+ },
474
+ };
475
+
297
476
  return new ExchangeClient({
298
477
  transport: guardedTransport,
299
- wallet,
478
+ wallet: dualWallet,
479
+ signatureChainId: () => {
480
+ const id = signerRef.current?.chainId ?? 42161;
481
+ return `0x${id.toString(16)}` as `0x${string}`;
482
+ },
300
483
  });
301
484
  }
302
485
 
@@ -415,22 +598,26 @@ export function HypurrConnectProvider({
415
598
  setEoaError(null);
416
599
  }, []);
417
600
 
418
- const connectEoa = useCallback((address: `0x${string}`) => {
419
- setEoaAddress(address);
420
- setTgLoginData(null);
421
- setTgUser(null);
422
- setTgError(null);
423
- setEoaError(null);
424
- localStorage.removeItem(TELEGRAM_STORAGE_KEY);
601
+ const connectEoa = useCallback(
602
+ (address: `0x${string}`, signer?: EoaSigner) => {
603
+ eoaSignerRef.current = signer ?? null;
604
+ setEoaAddress(address);
605
+ setTgLoginData(null);
606
+ setTgUser(null);
607
+ setTgError(null);
608
+ setEoaError(null);
609
+ localStorage.removeItem(TELEGRAM_STORAGE_KEY);
425
610
 
426
- const existing = loadAgent(address);
427
- if (existing && existing.validUntil > Date.now()) {
428
- setAgent(existing);
429
- } else {
430
- if (existing) clearStoredAgent(address);
431
- setAgent(null);
432
- }
433
- }, []);
611
+ const existing = loadAgent(address);
612
+ if (existing && existing.validUntil > Date.now()) {
613
+ setAgent(existing);
614
+ } else {
615
+ if (existing) clearStoredAgent(address);
616
+ setAgent(null);
617
+ }
618
+ },
619
+ [],
620
+ );
434
621
 
435
622
  const approveAgentFn = useCallback(
436
623
  async (signTypedDataAsync: SignTypedDataFn, chainId: number) => {
@@ -440,6 +627,8 @@ export function HypurrConnectProvider({
440
627
  );
441
628
  }
442
629
 
630
+ eoaSignerRef.current = { signTypedData: signTypedDataAsync, chainId };
631
+
443
632
  setEoaLoading(true);
444
633
  setEoaError(null);
445
634
  try {
@@ -457,20 +646,59 @@ export function HypurrConnectProvider({
457
646
  const { privateKey, address: agentAddress } = await generateAgentKey();
458
647
  const isTestnet = config.isTestnet ?? false;
459
648
 
649
+ const chainIdHex = `0x${chainId.toString(16)}` as `0x${string}`;
650
+ const nonce = Date.now();
651
+ const action = {
652
+ type: "approveAgent" as const,
653
+ signatureChainId: chainIdHex,
654
+ hyperliquidChain: (isTestnet ? "Testnet" : "Mainnet") as
655
+ | "Testnet"
656
+ | "Mainnet",
657
+ agentAddress: agentAddress.toLowerCase() as `0x${string}`,
658
+ agentName: AGENT_NAME,
659
+ nonce,
660
+ };
661
+
662
+ const approveAgentTypes = {
663
+ "HyperliquidTransaction:ApproveAgent": [
664
+ { name: "hyperliquidChain", type: "string" },
665
+ { name: "agentAddress", type: "address" },
666
+ { name: "agentName", type: "string" },
667
+ { name: "nonce", type: "uint64" },
668
+ ],
669
+ };
670
+
460
671
  const wallet = {
461
- signTypedData: signTypedDataAsync,
672
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
673
+ signTypedData(params: any) {
674
+ return signTypedDataAsync(params);
675
+ },
462
676
  getAddresses: async () => [eoaAddress] as `0x${string}`[],
463
677
  getChainId: async () => chainId,
464
678
  };
465
- const transport = new HttpTransport({ isTestnet });
466
679
 
467
- await sdkApproveAgent(
468
- { transport, wallet },
469
- {
470
- agentAddress: agentAddress.toLowerCase() as `0x${string}`,
471
- agentName: AGENT_NAME,
472
- },
473
- );
680
+ const signature = await signUserSignedAction({
681
+ wallet,
682
+ action,
683
+ types: approveAgentTypes,
684
+ });
685
+
686
+ const apiUrl = isTestnet
687
+ ? "https://api.hyperliquid-testnet.xyz/exchange"
688
+ : "https://api.hyperliquid.xyz/exchange";
689
+
690
+ const res = await fetch(apiUrl, {
691
+ method: "POST",
692
+ headers: { "Content-Type": "application/json" },
693
+ body: JSON.stringify({ action, signature, nonce }),
694
+ });
695
+
696
+ const body = await res.json();
697
+ if (body?.status === "err") {
698
+ throw new Error(
699
+ `approveAgent API error: ${body.response ?? JSON.stringify(body)}`,
700
+ );
701
+ }
474
702
 
475
703
  const remote = await fetchActiveAgent(eoaAddress, isTestnet);
476
704
  const validUntil =
@@ -502,6 +730,7 @@ export function HypurrConnectProvider({
502
730
  setEoaAddress(null);
503
731
  setAgent(null);
504
732
  setEoaError(null);
733
+ eoaSignerRef.current = null;
505
734
  localStorage.removeItem(TELEGRAM_STORAGE_KEY);
506
735
  }, []);
507
736
 
@@ -543,6 +772,8 @@ export function HypurrConnectProvider({
543
772
  clearAgent: handleClearAgent,
544
773
 
545
774
  botId: config.telegram?.botId ?? "",
775
+ botUsername: config.telegram?.botUsername ?? "",
776
+ useWidget: config.telegram?.useWidget ?? false,
546
777
 
547
778
  authDataMap,
548
779
  telegramClient: tgClient,
@@ -578,6 +809,8 @@ export function HypurrConnectProvider({
578
809
  agentReady,
579
810
  handleClearAgent,
580
811
  config.telegram?.botId,
812
+ config.telegram?.botUsername,
813
+ config.telegram?.useWidget,
581
814
  authDataMap,
582
815
  tgClient,
583
816
  staticClient,
@@ -14,6 +14,7 @@ import {
14
14
  import { useHypurrConnectInternal } from "./HypurrConnectProvider";
15
15
  import { MetaMaskColorIcon } from "./icons/MetaMaskColorIcon";
16
16
  import { TelegramColorIcon } from "./icons/TelegramColorIcon";
17
+ import { TelegramLoginWidget } from "./TelegramLoginWidget";
17
18
  import type { TelegramLoginData } from "./types";
18
19
 
19
20
  export interface LoginModalProps {
@@ -130,8 +131,14 @@ function HoverButton({
130
131
  }
131
132
 
132
133
  export function LoginModal({ onConnectWallet, walletIcon }: LoginModalProps) {
133
- const { loginTelegram, loginModalOpen, closeLoginModal, botId } =
134
- useHypurrConnectInternal();
134
+ const {
135
+ loginTelegram,
136
+ loginModalOpen,
137
+ closeLoginModal,
138
+ botId,
139
+ botUsername,
140
+ useWidget,
141
+ } = useHypurrConnectInternal();
135
142
 
136
143
  const handleTelegramAuth = useCallback(
137
144
  (user: TelegramLoginData) => {
@@ -195,10 +202,17 @@ export function LoginModal({ onConnectWallet, walletIcon }: LoginModalProps) {
195
202
  overflow: "hidden",
196
203
  }}
197
204
  >
198
- <HoverButton onClick={openTelegramOAuth}>
199
- <TelegramColorIcon style={iconSize} />
200
- Telegram
201
- </HoverButton>
205
+ {useWidget && botUsername ? (
206
+ <TelegramLoginWidget
207
+ botUsername={botUsername}
208
+ onAuth={handleTelegramAuth}
209
+ />
210
+ ) : (
211
+ <HoverButton onClick={openTelegramOAuth}>
212
+ <TelegramColorIcon style={iconSize} />
213
+ Telegram
214
+ </HoverButton>
215
+ )}
202
216
  </div>
203
217
 
204
218
  <div style={dividerStyle} />
@@ -0,0 +1,62 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { TelegramLoginData } from "./types";
3
+
4
+ const WIDGET_SCRIPT_URL = "https://telegram.org/js/telegram-widget.js?22";
5
+ const CALLBACK_NAME = "__hypurrConnectTelegramAuth";
6
+
7
+ export interface TelegramLoginWidgetProps {
8
+ botUsername: string;
9
+ onAuth: (data: TelegramLoginData) => void;
10
+ buttonSize?: "large" | "medium" | "small";
11
+ cornerRadius?: number;
12
+ showUserPhoto?: boolean;
13
+ requestAccess?: boolean;
14
+ }
15
+
16
+ export function TelegramLoginWidget({
17
+ botUsername,
18
+ onAuth,
19
+ buttonSize = "large",
20
+ cornerRadius,
21
+ showUserPhoto = true,
22
+ requestAccess = true,
23
+ }: TelegramLoginWidgetProps) {
24
+ const containerRef = useRef<HTMLDivElement>(null);
25
+ const onAuthRef = useRef(onAuth);
26
+ onAuthRef.current = onAuth;
27
+
28
+ useEffect(() => {
29
+ const container = containerRef.current;
30
+ if (!container) return;
31
+
32
+ (window as unknown as Record<string, unknown>)[CALLBACK_NAME] = (
33
+ user: TelegramLoginData,
34
+ ) => {
35
+ onAuthRef.current(user);
36
+ };
37
+
38
+ const script = document.createElement("script");
39
+ script.src = WIDGET_SCRIPT_URL;
40
+ script.async = true;
41
+ script.setAttribute("data-telegram-login", botUsername);
42
+ script.setAttribute("data-size", buttonSize);
43
+ script.setAttribute("data-onauth", `${CALLBACK_NAME}(user)`);
44
+ script.setAttribute("data-userpic", String(showUserPhoto));
45
+ if (requestAccess) {
46
+ script.setAttribute("data-request-access", "write");
47
+ }
48
+ if (cornerRadius !== undefined) {
49
+ script.setAttribute("data-radius", String(cornerRadius));
50
+ }
51
+
52
+ container.innerHTML = "";
53
+ container.appendChild(script);
54
+
55
+ return () => {
56
+ container.innerHTML = "";
57
+ delete (window as unknown as Record<string, unknown>)[CALLBACK_NAME];
58
+ };
59
+ }, [botUsername, buttonSize, cornerRadius, showUserPhoto, requestAccess]);
60
+
61
+ return <div ref={containerRef} />;
62
+ }
package/src/index.ts CHANGED
@@ -4,11 +4,15 @@ export {
4
4
  } from "./HypurrConnectProvider";
5
5
  export { LoginModal } from "./LoginModal";
6
6
  export type { LoginModalProps } from "./LoginModal";
7
+ export { TelegramLoginWidget } from "./TelegramLoginWidget";
8
+ export type { TelegramLoginWidgetProps } from "./TelegramLoginWidget";
7
9
  export { GrpcExchangeTransport } from "./GrpcExchangeTransport";
8
10
  export type { GrpcExchangeTransportConfig } from "./GrpcExchangeTransport";
9
11
  export { createTelegramClient, createStaticClient } from "./grpc";
12
+ export { createEoaSigner } from "./types";
10
13
  export type {
11
14
  AuthMethod,
15
+ EoaSigner,
12
16
  HypurrConnectConfig,
13
17
  HypurrConnectState,
14
18
  HypurrUser,
@@ -20,3 +24,7 @@ export type {
20
24
  // Re-export wallet types from hypurr-grpc so consumers don't need the dependency
21
25
  export type { HyperliquidWallet } from "hypurr-grpc/ts/hypurr/wallet";
22
26
  export type { TelegramChatWalletPack } from "hypurr-grpc/ts/hypurr/user";
27
+ export type {
28
+ HyperliquidWalletScaleSession,
29
+ HyperliquidWalletTwapSession,
30
+ } from "hypurr-grpc/ts/hypurr/tools";
package/src/types.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import type { ExchangeClient } from "@hfunlabs/hyperliquid";
2
2
  import type { StaticClient } from "hypurr-grpc/ts/hypurr/static/static_service.client";
3
3
  import type { TelegramClient } from "hypurr-grpc/ts/hypurr/telegram/telegram_service.client";
4
- import type { HyperliquidWallet } from "hypurr-grpc/ts/hypurr/wallet";
5
4
  import type { TelegramChatWalletPack } from "hypurr-grpc/ts/hypurr/user";
5
+ import type { HyperliquidWallet } from "hypurr-grpc/ts/hypurr/wallet";
6
6
 
7
7
  // ─── Config ──────────────────────────────────────────────────────
8
8
 
9
9
  export interface HypurrConnectConfig {
10
10
  grpcTimeout?: number;
11
11
  isTestnet?: boolean;
12
- telegram?: {
12
+ telegram: {
13
13
  botUsername: string;
14
- botId: string;
14
+ botId?: string;
15
+ useWidget: boolean;
15
16
  };
16
17
  }
17
18
 
@@ -59,6 +60,51 @@ export type SignTypedDataFn = (params: {
59
60
  message: Record<string, unknown>;
60
61
  }) => Promise<`0x${string}`>;
61
62
 
63
+ /** Wallet signer provided at EOA connect time for user-signed actions. */
64
+ export interface EoaSigner {
65
+ signTypedData: SignTypedDataFn;
66
+ chainId: number;
67
+ }
68
+
69
+ /**
70
+ * Create an {@link EoaSigner} from any EIP-712 signing function.
71
+ *
72
+ * Accepts either a direct function or a `{ current }` ref object so the
73
+ * signer always calls through to the latest function (avoids stale closures
74
+ * with React hooks like wagmi's `useSignTypedData`).
75
+ *
76
+ * @example wagmi v2 — ref pattern (recommended)
77
+ * ```ts
78
+ * const { signTypedDataAsync } = useSignTypedData();
79
+ * const chainId = useChainId();
80
+ * const signerRef = useRef(signTypedDataAsync);
81
+ * signerRef.current = signTypedDataAsync; // stays fresh every render
82
+ *
83
+ * // call once — the ref keeps it up to date
84
+ * connectEoa(address, createEoaSigner(signerRef, chainId));
85
+ * ```
86
+ *
87
+ * @example direct function (e.g. from viem WalletClient)
88
+ * ```ts
89
+ * connectEoa(address, createEoaSigner(client.signTypedData, chainId));
90
+ * ```
91
+ */
92
+ export function createEoaSigner(
93
+ signTypedDataAsync:
94
+ | ((args: Record<string, unknown>) => Promise<`0x${string}`>)
95
+ | { current: (args: Record<string, unknown>) => Promise<`0x${string}`> },
96
+ chainId: number,
97
+ ): EoaSigner {
98
+ const resolve =
99
+ typeof signTypedDataAsync === "function"
100
+ ? signTypedDataAsync
101
+ : (args: Record<string, unknown>) => signTypedDataAsync.current(args);
102
+ return {
103
+ signTypedData: (params) => resolve(params),
104
+ chainId,
105
+ };
106
+ }
107
+
62
108
  // ─── Context state ───────────────────────────────────────────────
63
109
 
64
110
  export interface HypurrConnectState {
@@ -69,9 +115,10 @@ export interface HypurrConnectState {
69
115
  error: string | null;
70
116
  authMethod: AuthMethod;
71
117
 
72
- // SDK ExchangeClient — works for all L1 actions
118
+ // SDK ExchangeClient — handles both L1 (agent-signed) and user-signed actions.
73
119
  // Telegram: backed by GrpcExchangeTransport (HyperliquidCoreAction)
74
- // EOA: backed by HttpTransport + agent wallet
120
+ // EOA: uses a dual wallet agent key for L1 actions, master wallet for
121
+ // user-signed actions (transfers, withdrawals, etc.) when a signer is provided.
75
122
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
123
  exchange: ExchangeClient<any> | null;
77
124
 
@@ -109,7 +156,7 @@ export interface HypurrConnectState {
109
156
  closeLoginModal: () => void;
110
157
 
111
158
  // Auth actions
112
- connectEoa: (address: `0x${string}`) => void;
159
+ connectEoa: (address: `0x${string}`, signer?: EoaSigner) => void;
113
160
  approveAgent: (
114
161
  signTypedDataAsync: SignTypedDataFn,
115
162
  chainId: number,