@hfunlabs/hypurr-connect 0.1.2 → 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.
@@ -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";
@@ -147,6 +149,7 @@ export function HypurrConnectProvider({
147
149
  const [agent, setAgent] = useState<StoredAgent | null>(null);
148
150
  const [eoaLoading, setEoaLoading] = useState(false);
149
151
  const [eoaError, setEoaError] = useState<string | null>(null);
152
+ const eoaSignerRef = useRef<EoaSigner | null>(null);
150
153
 
151
154
  // ── Derived auth ─────────────────────────────────────────────
152
155
  const authMethod: AuthMethod = tgLoginData
@@ -223,7 +226,12 @@ export function HypurrConnectProvider({
223
226
 
224
227
  // ── Exchange client ──────────────────────────────────────────
225
228
  // Telegram: GrpcExchangeTransport → HyperliquidCoreAction (server signs)
226
- // 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).
227
235
 
228
236
  const onDeadAgentRef = useRef<((address: `0x${string}`) => void) | null>(
229
237
  null,
@@ -234,6 +242,20 @@ export function HypurrConnectProvider({
234
242
  setEoaError("Agent expired or was deregistered. Please reconnect.");
235
243
  };
236
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
+
237
259
  const agentReady =
238
260
  authMethod === "telegram" || (authMethod === "eoa" && !!agent);
239
261
 
@@ -254,14 +276,16 @@ export function HypurrConnectProvider({
254
276
  }
255
277
 
256
278
  if (authMethod === "eoa" && eoaAddress) {
257
- if (!agent) {
279
+ const hasSigner = !!eoaSignerRef.current;
280
+
281
+ if (!agent && !hasSigner) {
258
282
  const noAgentTransport: IRequestTransport = {
259
283
  isTestnet: config.isTestnet ?? false,
260
284
  request(): Promise<never> {
261
285
  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.",
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 }).",
265
289
  );
266
290
  },
267
291
  };
@@ -272,9 +296,8 @@ export function HypurrConnectProvider({
272
296
  });
273
297
  }
274
298
 
275
- const inner = new HttpTransport({
276
- isTestnet: config.isTestnet ?? false,
277
- });
299
+ const isTestnet = config.isTestnet ?? false;
300
+ const inner = new HttpTransport({ isTestnet });
278
301
  const deadAgentAddr = eoaAddress;
279
302
  const guardedTransport: IRequestTransport = {
280
303
  isTestnet: inner.isTestnet,
@@ -293,10 +316,160 @@ export function HypurrConnectProvider({
293
316
  }
294
317
  },
295
318
  };
296
- const wallet = new PrivateKeySigner(agent.privateKey);
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;
429
+ };
430
+
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);
463
+ },
464
+ };
465
+
297
466
  return new ExchangeClient({
298
467
  transport: guardedTransport,
299
- wallet,
468
+ wallet: dualWallet,
469
+ signatureChainId: () => {
470
+ const id = signerRef.current?.chainId ?? 42161;
471
+ return `0x${id.toString(16)}` as `0x${string}`;
472
+ },
300
473
  });
301
474
  }
302
475
 
@@ -415,22 +588,26 @@ export function HypurrConnectProvider({
415
588
  setEoaError(null);
416
589
  }, []);
417
590
 
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);
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);
425
600
 
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
- }, []);
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
+ );
434
611
 
435
612
  const approveAgentFn = useCallback(
436
613
  async (signTypedDataAsync: SignTypedDataFn, chainId: number) => {
@@ -440,6 +617,8 @@ export function HypurrConnectProvider({
440
617
  );
441
618
  }
442
619
 
620
+ eoaSignerRef.current = { signTypedData: signTypedDataAsync, chainId };
621
+
443
622
  setEoaLoading(true);
444
623
  setEoaError(null);
445
624
  try {
@@ -457,20 +636,59 @@ export function HypurrConnectProvider({
457
636
  const { privateKey, address: agentAddress } = await generateAgentKey();
458
637
  const isTestnet = config.isTestnet ?? false;
459
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
+
460
661
  const wallet = {
461
- signTypedData: signTypedDataAsync,
662
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
663
+ signTypedData(params: any) {
664
+ return signTypedDataAsync(params);
665
+ },
462
666
  getAddresses: async () => [eoaAddress] as `0x${string}`[],
463
667
  getChainId: async () => chainId,
464
668
  };
465
- const transport = new HttpTransport({ isTestnet });
466
669
 
467
- await sdkApproveAgent(
468
- { transport, wallet },
469
- {
470
- agentAddress: agentAddress.toLowerCase() as `0x${string}`,
471
- agentName: AGENT_NAME,
472
- },
473
- );
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
+ }
474
692
 
475
693
  const remote = await fetchActiveAgent(eoaAddress, isTestnet);
476
694
  const validUntil =
@@ -502,6 +720,7 @@ export function HypurrConnectProvider({
502
720
  setEoaAddress(null);
503
721
  setAgent(null);
504
722
  setEoaError(null);
723
+ eoaSignerRef.current = null;
505
724
  localStorage.removeItem(TELEGRAM_STORAGE_KEY);
506
725
  }, []);
507
726
 
@@ -543,6 +762,8 @@ export function HypurrConnectProvider({
543
762
  clearAgent: handleClearAgent,
544
763
 
545
764
  botId: config.telegram?.botId ?? "",
765
+ botUsername: config.telegram?.botUsername ?? "",
766
+ useWidget: config.telegram?.useWidget ?? false,
546
767
 
547
768
  authDataMap,
548
769
  telegramClient: tgClient,
@@ -578,6 +799,8 @@ export function HypurrConnectProvider({
578
799
  agentReady,
579
800
  handleClearAgent,
580
801
  config.telegram?.botId,
802
+ config.telegram?.botUsername,
803
+ config.telegram?.useWidget,
581
804
  authDataMap,
582
805
  tgClient,
583
806
  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,
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,