@hfunlabs/hypurr-connect 0.1.23 → 0.1.25

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hfunlabs/hypurr-connect",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@10.10.0",
6
6
  "main": "./dist/index.js",
@@ -55,13 +55,14 @@ import type {
55
55
  /** @internal context value — extends the public type with fields used only by library internals */
56
56
  interface InternalConnectState extends HypurrConnectState {
57
57
  loginTelegram: () => void;
58
+ /** Connected EOA owner address (EOA auth only); null otherwise. */
59
+ eoaAddress: `0x${string}` | null;
58
60
  }
59
61
 
60
62
  const TELEGRAM_STORAGE_KEY = "hypurr-connect-tg-jwt";
61
63
  const LEGACY_TELEGRAM_STORAGE_KEY = "hypurr-connect-tg-user";
62
64
  const TELEGRAM_AUTH_STATE_KEY = "hypurr-connect-auth-state";
63
- const TELEGRAM_AUTH_CODE_VERIFIER_PREFIX =
64
- "hypurr-connect-auth-code-verifier:";
65
+ const TELEGRAM_AUTH_CODE_VERIFIER_PREFIX = "hypurr-connect-auth-code-verifier:";
65
66
  const TELEGRAM_AUTH_RETURN_TO_PREFIX = "hypurr-connect-auth-return-to:";
66
67
  const TELEGRAM_AUTH_MESSAGE = "hypurr-connect:telegram-auth";
67
68
  const DEFAULT_AUTH_HUB_URL = "https://auth.hypurr.fun/login";
@@ -350,12 +351,10 @@ function clearTelegramAuthSession(state: string): void {
350
351
  sessionStorage.removeItem(returnToStorageKey(state));
351
352
  }
352
353
 
353
- function takeTelegramAuthSession(state: string):
354
- | {
355
- codeVerifier: string | null;
356
- returnTo: string | null;
357
- }
358
- | null {
354
+ function takeTelegramAuthSession(state: string): {
355
+ codeVerifier: string | null;
356
+ returnTo: string | null;
357
+ } | null {
359
358
  const expectedState = sessionStorage.getItem(TELEGRAM_AUTH_STATE_KEY);
360
359
  sessionStorage.removeItem(TELEGRAM_AUTH_STATE_KEY);
361
360
  if (!expectedState || state !== expectedState) {
@@ -375,19 +374,63 @@ function takeTelegramAuthSession(state: string):
375
374
  return { codeVerifier, returnTo };
376
375
  }
377
376
 
378
- function resolveAuthTokenUrl(authHubUrl?: string, tokenUrl?: string): string {
379
- const configuredTokenUrl = tokenUrl?.trim();
380
- if (configuredTokenUrl) return configuredTokenUrl;
381
-
377
+ function fallbackAuthTokenUrl(authHubUrl?: string): string {
382
378
  const url = new URL(authHubUrl || DEFAULT_AUTH_HUB_URL);
383
- const pathWithoutTrailingSlash = url.pathname.replace(/\/+$/, "");
384
- const basePath = pathWithoutTrailingSlash.replace(/\/[^/]*$/, "");
385
- url.pathname = `${basePath}/token`;
379
+ url.pathname = "/oauth/token";
386
380
  url.search = "";
387
381
  url.hash = "";
388
382
  return url.toString();
389
383
  }
390
384
 
385
+ function authMetadataUrls(authHubUrl?: string): string[] {
386
+ const authUrl = new URL(authHubUrl || DEFAULT_AUTH_HUB_URL);
387
+ const urls = [
388
+ new URL("/.well-known/oauth-authorization-server", authUrl).toString(),
389
+ new URL("/.well-known/openid-configuration", authUrl).toString(),
390
+ ];
391
+
392
+ const authPath = authUrl.pathname.replace(/\/+$/, "");
393
+ const basePath = authPath.replace(/\/[^/]*$/, "");
394
+ if (basePath) {
395
+ urls.push(
396
+ new URL(
397
+ `/.well-known/oauth-authorization-server${basePath}`,
398
+ authUrl,
399
+ ).toString(),
400
+ );
401
+ }
402
+
403
+ return Array.from(new Set(urls));
404
+ }
405
+
406
+ async function tokenUrlFromMetadata(
407
+ authHubUrl?: string,
408
+ ): Promise<string | undefined> {
409
+ for (const metadataUrl of authMetadataUrls(authHubUrl)) {
410
+ try {
411
+ const response = await fetch(metadataUrl, {
412
+ headers: { accept: "application/json" },
413
+ });
414
+ if (!response.ok) continue;
415
+ const metadata = (await response.json()) as { token_endpoint?: unknown };
416
+ const tokenEndpoint = metadata.token_endpoint;
417
+ if (typeof tokenEndpoint === "string" && tokenEndpoint.trim()) {
418
+ return tokenEndpoint.trim();
419
+ }
420
+ } catch {
421
+ // Metadata discovery is best effort; fall back to the conventional route.
422
+ }
423
+ }
424
+
425
+ return undefined;
426
+ }
427
+
428
+ async function resolveAuthTokenUrl(authHubUrl?: string): Promise<string> {
429
+ return (
430
+ (await tokenUrlFromMetadata(authHubUrl)) || fallbackAuthTokenUrl(authHubUrl)
431
+ );
432
+ }
433
+
391
434
  function getTokenFromExchangeResponse(data: unknown): string | null {
392
435
  if (typeof data === "string") {
393
436
  const token = data.trim();
@@ -412,14 +455,12 @@ async function exchangeTelegramAuthCode({
412
455
  code,
413
456
  codeVerifier,
414
457
  returnTo,
415
- tokenUrl,
416
458
  }: {
417
459
  authHubUrl?: string;
418
460
  clientId: string;
419
461
  code: string;
420
462
  codeVerifier: string;
421
463
  returnTo: string;
422
- tokenUrl?: string;
423
464
  }): Promise<string> {
424
465
  const body = new URLSearchParams({
425
466
  client_id: clientId,
@@ -429,7 +470,7 @@ async function exchangeTelegramAuthCode({
429
470
  return_to: returnTo,
430
471
  });
431
472
 
432
- const response = await fetch(resolveAuthTokenUrl(authHubUrl, tokenUrl), {
473
+ const response = await fetch(await resolveAuthTokenUrl(authHubUrl), {
433
474
  method: "POST",
434
475
  headers: {
435
476
  accept: "application/json",
@@ -599,7 +640,6 @@ export function HypurrConnectProvider({
599
640
  code: callback.code,
600
641
  codeVerifier: authSession.codeVerifier,
601
642
  returnTo: authSession.returnTo || currentReturnTo(),
602
- tokenUrl: config.telegram?.tokenUrl,
603
643
  })
604
644
  .then(acceptTelegramToken)
605
645
  .catch((err) =>
@@ -620,7 +660,6 @@ export function HypurrConnectProvider({
620
660
  acceptTelegramToken,
621
661
  config.clientId,
622
662
  config.telegram?.authHubUrl,
623
- config.telegram?.tokenUrl,
624
663
  ],
625
664
  );
626
665
 
@@ -1858,6 +1897,7 @@ export function HypurrConnectProvider({
1858
1897
  agent,
1859
1898
  agentReady,
1860
1899
  clearAgent: handleClearAgent,
1900
+ eoaAddress,
1861
1901
 
1862
1902
  authDataMap,
1863
1903
  authToken: tgAuthToken,
@@ -1902,6 +1942,7 @@ export function HypurrConnectProvider({
1902
1942
  agent,
1903
1943
  agentReady,
1904
1944
  handleClearAgent,
1945
+ eoaAddress,
1905
1946
  authDataMap,
1906
1947
  tgAuthToken,
1907
1948
  telegramRpcOptions,
@@ -2,6 +2,8 @@ import { AnimatePresence, motion } from "framer-motion";
2
2
  import type { HyperliquidWallet } from "hypurr-grpc/ts/hypurr/wallet";
3
3
  import {
4
4
  useCallback,
5
+ useEffect,
6
+ useMemo,
5
7
  useState,
6
8
  type CSSProperties,
7
9
  type ReactNode,
@@ -13,7 +15,12 @@ import {
13
15
  import { DeleteWalletModal } from "./DeleteWalletModal";
14
16
  import { useHypurrConnectInternal } from "./HypurrConnectProvider";
15
17
  import { RenameWalletModal } from "./RenameWalletModal";
16
- import { getAgentExpiryTitle } from "./agentWallet";
18
+ import {
19
+ clearAgent as clearStoredAgent,
20
+ loadAllAgents,
21
+ type LocalApprovedAgent,
22
+ } from "./agent";
23
+ import { formatAgentExpiry, getAgentExpiryTitle } from "./agentWallet";
17
24
  import {
18
25
  Bot,
19
26
  Copy,
@@ -314,6 +321,9 @@ export function UserProfileModal({
314
321
  deleteWallet,
315
322
  renameWallet,
316
323
  authMethod,
324
+ agent,
325
+ eoaAddress,
326
+ clearAgent: clearContextAgent,
317
327
  } = useHypurrConnectInternal();
318
328
 
319
329
  const [settingsTab, setSettingsTab] = useState<SettingsTab>("ui");
@@ -321,6 +331,41 @@ export function UserProfileModal({
321
331
  useState<HyperliquidWallet | null>(null);
322
332
  const [walletToRename, setWalletToRename] =
323
333
  useState<HyperliquidWallet | null>(null);
334
+ const [storedAgents, setStoredAgents] = useState<LocalApprovedAgent[]>([]);
335
+
336
+ // Local approved agents live in localStorage; refresh whenever the modal opens.
337
+ useEffect(() => {
338
+ if (isOpen) setStoredAgents(loadAllAgents());
339
+ }, [isOpen]);
340
+
341
+ // Merge the localStorage scan with the reactive context agent (the currently
342
+ // connected EOA's approved agent), deduped by owner address.
343
+ const localAgents = useMemo(() => {
344
+ const byOwner = new Map<string, LocalApprovedAgent>();
345
+ for (const a of storedAgents) byOwner.set(a.masterAddress.toLowerCase(), a);
346
+ if (agent && eoaAddress && !byOwner.has(eoaAddress.toLowerCase())) {
347
+ byOwner.set(eoaAddress.toLowerCase(), {
348
+ ...agent,
349
+ masterAddress: eoaAddress,
350
+ });
351
+ }
352
+ return [...byOwner.values()].sort((a, b) => b.approvedAt - a.approvedAt);
353
+ }, [storedAgents, agent, eoaAddress]);
354
+
355
+ const handleRemoveLocalAgent = useCallback(
356
+ (masterAddress: string) => {
357
+ if (
358
+ eoaAddress &&
359
+ masterAddress.toLowerCase() === eoaAddress.toLowerCase()
360
+ ) {
361
+ clearContextAgent();
362
+ } else {
363
+ clearStoredAgent(masterAddress);
364
+ }
365
+ setStoredAgents(loadAllAgents());
366
+ },
367
+ [eoaAddress, clearContextAgent],
368
+ );
324
369
 
325
370
  const profilePic = user?.photoUrl || "";
326
371
  const displayName = user?.displayName || "";
@@ -347,10 +392,12 @@ export function UserProfileModal({
347
392
  );
348
393
 
349
394
  const handleCopyAddress = useCallback(() => {
350
- if (!displayName) return;
351
- navigator.clipboard.writeText(displayName);
395
+ // Copy the full address, not the truncated display name.
396
+ const address = user?.address || eoaAddress || displayName;
397
+ if (!address) return;
398
+ navigator.clipboard.writeText(address);
352
399
  onNotify?.({ type: "success", message: "Address copied" });
353
- }, [displayName, onNotify]);
400
+ }, [user?.address, eoaAddress, displayName, onNotify]);
354
401
 
355
402
  return (
356
403
  <div className="hypurr-connect" style={{ display: "contents" }}>
@@ -798,6 +845,48 @@ export function UserProfileModal({
798
845
  {wallets.length} wallet
799
846
  {wallets.length !== 1 ? "s" : ""} linked
800
847
  </p>
848
+
849
+ {localAgents.length > 0 && (
850
+ <div style={{ marginTop: 8 }}>
851
+ <p
852
+ style={{
853
+ margin: "0 0 8px",
854
+ color: profileColors.muted,
855
+ ...upperLabelStyle,
856
+ }}
857
+ >
858
+ Agent wallets
859
+ </p>
860
+ <div
861
+ style={{
862
+ display: "flex",
863
+ flexDirection: "column",
864
+ gap: 6,
865
+ }}
866
+ >
867
+ {localAgents.map((agent) => (
868
+ <LocalAgentRow
869
+ key={agent.masterAddress}
870
+ agent={agent}
871
+ onRemove={() =>
872
+ handleRemoveLocalAgent(agent.masterAddress)
873
+ }
874
+ />
875
+ ))}
876
+ </div>
877
+ <p
878
+ style={{
879
+ margin: "8px 0 0",
880
+ fontSize: 12.5,
881
+ lineHeight: "1rem",
882
+ color: profileColors.subdued,
883
+ textAlign: "center",
884
+ }}
885
+ >
886
+ Approved on this device
887
+ </p>
888
+ </div>
889
+ )}
801
890
  </div>
802
891
  )}
803
892
  </div>
@@ -1039,6 +1128,118 @@ function WalletRow({
1039
1128
  );
1040
1129
  }
1041
1130
 
1131
+ function shortAddress(address: string): string {
1132
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
1133
+ }
1134
+
1135
+ // Older builds stored validUntil scaled ×1000 (µs). Coerce those back to ms.
1136
+ function normalizeExpiryMs(validUntil: number): number {
1137
+ return validUntil > 1e14 ? Math.round(validUntil / 1000) : validUntil;
1138
+ }
1139
+
1140
+ function LocalAgentRow({
1141
+ agent,
1142
+ onRemove,
1143
+ }: {
1144
+ agent: LocalApprovedAgent;
1145
+ onRemove: () => void;
1146
+ }) {
1147
+ const [hovered, setHovered] = useState(false);
1148
+ const expiresAtMs = normalizeExpiryMs(agent.validUntil);
1149
+ const isExpired = expiresAtMs <= Date.now();
1150
+ const agentTypeColor = "rgb(var(--blue-500))";
1151
+ return (
1152
+ <div
1153
+ style={{
1154
+ ...walletRowStyle,
1155
+ background: hovered
1156
+ ? profileColors.surfaceBtnHover
1157
+ : profileColors.surfaceBtn,
1158
+ borderColor: hovered
1159
+ ? profileColors.surfaceBdHover
1160
+ : profileColors.surfaceBd,
1161
+ }}
1162
+ onMouseEnter={() => setHovered(true)}
1163
+ onMouseLeave={() => setHovered(false)}
1164
+ >
1165
+ <div
1166
+ style={{
1167
+ width: 32,
1168
+ height: 32,
1169
+ borderRadius: 6,
1170
+ background: "rgb(var(--blue-500) / 0.16)",
1171
+ border: "1px solid rgb(var(--blue-500) / 0.34)",
1172
+ display: "flex",
1173
+ alignItems: "center",
1174
+ justifyContent: "center",
1175
+ flexShrink: 0,
1176
+ color: agentTypeColor,
1177
+ }}
1178
+ >
1179
+ <Bot size={15} />
1180
+ </div>
1181
+ <div style={{ flex: 1, minWidth: 0 }}>
1182
+ <p
1183
+ style={{
1184
+ margin: 0,
1185
+ fontSize: 12.5,
1186
+ lineHeight: "1rem",
1187
+ fontWeight: 500,
1188
+ color: profileColors.text,
1189
+ fontFamily: fontFamily.mono,
1190
+ overflow: "hidden",
1191
+ textOverflow: "ellipsis",
1192
+ whiteSpace: "nowrap",
1193
+ }}
1194
+ >
1195
+ {shortAddress(agent.address)}
1196
+ </p>
1197
+ <p
1198
+ style={{
1199
+ margin: "2px 0 0",
1200
+ fontSize: 12.5,
1201
+ lineHeight: "1rem",
1202
+ color: isExpired ? EXPIRED_AGENT_COLOR : profileColors.muted,
1203
+ }}
1204
+ >
1205
+ {isExpired ? "Expired" : "Valid until"}{" "}
1206
+ {formatAgentExpiry(expiresAtMs)}
1207
+ </p>
1208
+ <p
1209
+ style={{
1210
+ margin: "2px 0 0",
1211
+ fontSize: 12.5,
1212
+ lineHeight: "1rem",
1213
+ color: profileColors.subdued,
1214
+ fontFamily: fontFamily.mono,
1215
+ }}
1216
+ >
1217
+ Owner {shortAddress(agent.masterAddress)}
1218
+ </p>
1219
+ </div>
1220
+ <div
1221
+ style={{
1222
+ display: "flex",
1223
+ alignItems: "center",
1224
+ flexShrink: 0,
1225
+ opacity: hovered ? 1 : 0,
1226
+ transition: "opacity 120ms",
1227
+ }}
1228
+ >
1229
+ <IconBtn
1230
+ color="#ef4444"
1231
+ hoverBackgroundColor="rgba(239,68,68,0.1)"
1232
+ title="Remove local agent"
1233
+ ariaLabel={`Remove local agent for ${shortAddress(agent.masterAddress)}`}
1234
+ onClick={onRemove}
1235
+ >
1236
+ <Trash2 size={13} />
1237
+ </IconBtn>
1238
+ </div>
1239
+ </div>
1240
+ );
1241
+ }
1242
+
1042
1243
  function IconBtn({
1043
1244
  children,
1044
1245
  color,
package/src/agent.ts CHANGED
@@ -26,6 +26,39 @@ export function clearAgent(masterAddress: string): void {
26
26
  localStorage.removeItem(storageKey(masterAddress));
27
27
  }
28
28
 
29
+ /** A locally-stored agent alongside the master address it was approved for. */
30
+ export interface LocalApprovedAgent extends StoredAgent {
31
+ masterAddress: string;
32
+ }
33
+
34
+ /**
35
+ * Enumerate every agent approved and stored locally on this device by scanning
36
+ * localStorage for the agent storage prefix. Newest approvals first.
37
+ */
38
+ export function loadAllAgents(): LocalApprovedAgent[] {
39
+ const agents: LocalApprovedAgent[] = [];
40
+ try {
41
+ for (let i = 0; i < localStorage.length; i++) {
42
+ const key = localStorage.key(i);
43
+ if (!key || !key.startsWith(`${AGENT_STORAGE_PREFIX}:`)) continue;
44
+ const raw = localStorage.getItem(key);
45
+ if (!raw) continue;
46
+ try {
47
+ const agent = JSON.parse(raw) as StoredAgent;
48
+ agents.push({
49
+ ...agent,
50
+ masterAddress: key.slice(key.indexOf(":") + 1),
51
+ });
52
+ } catch {
53
+ // Skip malformed entries.
54
+ }
55
+ }
56
+ } catch {
57
+ return [];
58
+ }
59
+ return agents.sort((a, b) => b.approvedAt - a.approvedAt);
60
+ }
61
+
29
62
  /**
30
63
  * Generate a random 32-byte private key and derive its address using the
31
64
  * local PrivateKeySigner compatibility wrapper.
@@ -73,10 +106,8 @@ async function fetchExtraAgents(
73
106
  const agents: unknown = await res.json();
74
107
  if (!Array.isArray(agents)) return [];
75
108
 
76
- return (agents as ExtraAgent[]).map((agent) => ({
77
- ...agent,
78
- validUntil: agent.validUntil * 1000,
79
- }));
109
+ // The extraAgents API already returns validUntil in epoch ms.
110
+ return agents as ExtraAgent[];
80
111
  }
81
112
 
82
113
  /**
package/src/types.ts CHANGED
@@ -33,8 +33,6 @@ export interface HypurrConnectConfig {
33
33
  telegram?: {
34
34
  /** Auth hub login URL. Defaults to https://auth.hypurr.fun/login. */
35
35
  authHubUrl?: string;
36
- /** Auth hub token exchange URL. Defaults to the auth hub login URL with `/login` replaced by `/token`. */
37
- tokenUrl?: string;
38
36
  /** Optional callback URL. Defaults to the current page without auth query params. */
39
37
  returnTo?: string | (() => string);
40
38
  /** Requested hub scopes. Defaults to the scopes required by this SDK. */