From 961e016d334408b98165731678db1cff1aef4341 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 21 Jun 2026 19:23:20 +0800 Subject: [PATCH] fix: harden Hyperliquid agent renewal --- api/handler_hyperliquid_wallet.go | 102 +++ api/server.go | 1 + .../common/HyperliquidWalletConnect.tsx | 691 +++++++++++++++--- web/src/lib/api/wallet.ts | 34 +- 4 files changed, 725 insertions(+), 103 deletions(-) diff --git a/api/handler_hyperliquid_wallet.go b/api/handler_hyperliquid_wallet.go index 43ad181d..126a5620 100644 --- a/api/handler_hyperliquid_wallet.go +++ b/api/handler_hyperliquid_wallet.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strconv" "strings" "time" @@ -21,6 +22,9 @@ const ( defaultHyperliquidBuilderMaxFee = "0.05%" hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange" hyperliquidInfoURL = "https://api.hyperliquid.xyz/info" + // nofxHyperliquidAgentName must match AGENT_NAME used by the frontend + // approveAgent flow so we can locate the NOFX-managed agent on-chain. + nofxHyperliquidAgentName = "NOFX Agent" ) type hyperliquidSubmitRequest struct { @@ -50,6 +54,19 @@ type hyperliquidAccountSummary struct { UpdatedAt int64 `json:"updatedAt"` } +type hyperliquidAgentInfo struct { + Name string `json:"name"` + Address string `json:"address"` + ValidUntil int64 `json:"validUntil"` // unix milliseconds +} + +type hyperliquidAgentResponse struct { + // Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved. + Agent *hyperliquidAgentInfo `json:"agent"` + // Agents lists every approved agent for the wallet (for visibility/cleanup). + Agents []hyperliquidAgentInfo `json:"agents"` +} + type hyperliquidClearinghouseState struct { MarginSummary struct { AccountValue string `json:"accountValue"` @@ -68,6 +85,15 @@ type hyperliquidClearinghouseState struct { } `json:"assetPositions"` } +// agentValidUntilSuffix matches the " valid_until " suffix Hyperliquid uses +// to encode an agent's expiry inside the agent name. Hyperliquid normally strips +// it from the stored name, but we strip defensively before matching the slot. +var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`) + +func baseAgentName(name string) string { + return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, "")) +} + func hyperliquidBuilderAddress() string { return defaultHyperliquidBuilderAddress } @@ -159,6 +185,64 @@ func (s *Server) handleHyperliquidAccount(c *gin.Context) { }) } +// handleHyperliquidAgent reports the on-chain approved agents for a wallet, +// including the NOFX agent's validUntil so the UI can show the expiry date and +// warn before the 180-day authorization lapses. +func (s *Server) handleHyperliquidAgent(c *gin.Context) { + address := strings.ToLower(strings.TrimSpace(c.Query("address"))) + if !isEVMAddress(address) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"}) + return + } + + body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"}) + return + } + + req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"}) + return + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 20 * time.Second} + resp, err := client.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()}) + return + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode}) + return + } + + // extraAgents returns null when no agents are approved. + agents := []hyperliquidAgentInfo{} + if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" { + if err := json.Unmarshal(respBody, &agents); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"}) + return + } + } + + out := hyperliquidAgentResponse{Agents: agents} + for i := range agents { + if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) { + agent := agents[i] + out.Agent = &agent + break + } + } + + c.JSON(http.StatusOK, out) +} + func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) { var req hyperliquidSubmitRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -223,6 +307,24 @@ func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) { c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded}) return } + + // Hyperliquid returns HTTP 200 even for logical failures, signalling them via + // {"status":"err","response":""}. Without this check a rejected + // approval (e.g. valid_until past the cap, or an unchanged agent) is reported + // to the user as success while nothing changes on-chain. + var hlResp struct { + Status string `json:"status"` + Response json.RawMessage `json:"response"` + } + if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") { + msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`)) + if msg == "" { + msg = "Hyperliquid rejected the action" + } + c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded}) } diff --git a/api/server.go b/api/server.go index 098e4579..bb54906c 100644 --- a/api/server.go +++ b/api/server.go @@ -150,6 +150,7 @@ func (s *Server) setupRoutes() { api.POST("/wallet/generate", s.handleWalletGenerate) s.route(api, "GET", "/hyperliquid/connect-config", "Get NOFX Hyperliquid builder authorization config", s.handleHyperliquidConnectConfig) s.route(api, "GET", "/hyperliquid/account", "Get Hyperliquid account balance summary", s.handleHyperliquidAccount) + s.route(api, "GET", "/hyperliquid/agent", "Get Hyperliquid approved agent wallets and authorization expiry", s.handleHyperliquidAgent) s.route(api, "POST", "/hyperliquid/submit-exchange", "Submit a user-signed Hyperliquid approval action", s.handleHyperliquidSubmitExchange) // Crypto related endpoints (no authentication required, not exposed to bot). diff --git a/web/src/components/common/HyperliquidWalletConnect.tsx b/web/src/components/common/HyperliquidWalletConnect.tsx index 00104dc2..09bf87ca 100644 --- a/web/src/components/common/HyperliquidWalletConnect.tsx +++ b/web/src/components/common/HyperliquidWalletConnect.tsx @@ -1,8 +1,21 @@ -import { useEffect, useMemo, useState } from 'react' -import { Check, ChevronDown, Copy, ExternalLink, Loader2, RefreshCw, Shield, Wallet, X } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Check, + ChevronDown, + Copy, + ExternalLink, + Loader2, + RefreshCw, + Shield, + Wallet, + X, +} from 'lucide-react' import { toast } from 'sonner' import { api } from '../../lib/api' -import type { HyperliquidAccountSummary } from '../../lib/api/wallet' +import type { + HyperliquidAccountSummary, + HyperliquidAgentInfo, +} from '../../lib/api/wallet' import type { Language } from '../../i18n/translations' declare global { @@ -14,7 +27,10 @@ declare global { type WalletProvider = { request: (args: { method: string; params?: unknown[] }) => Promise on?: (event: string, handler: (...args: unknown[]) => void) => void - removeListener?: (event: string, handler: (...args: unknown[]) => void) => void + removeListener?: ( + event: string, + handler: (...args: unknown[]) => void + ) => void isMetaMask?: boolean isRabby?: boolean isOkxWallet?: boolean @@ -47,6 +63,16 @@ interface FlowState { const STORAGE_KEY = 'nofx.hyperliquid.connection.v6' const AGENT_NAME = 'NOFX Agent' +// Hyperliquid caps agent validity at 180 days and otherwise defaults to ~90 days. +// The validity is encoded in the agent name as a " valid_until " suffix +// (separator is a single space; timestamp in milliseconds). Hyperliquid strips +// this suffix from the stored/displayed name, so the named slot stays "NOFX Agent". +// A 1-minute buffer keeps clock skew from pushing valid_until past the 180d cap. +const AGENT_VALIDITY_MS = 180 * 24 * 60 * 60 * 1000 - 60 * 1000 + +function buildAgentName(nowMs: number) { + return `${AGENT_NAME} valid_until ${nowMs + AGENT_VALIDITY_MS}` +} const HYPERLIQUID_BUILDER_ADDRESS = '0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d' // 0.05% (万5). Must match the server's defaultHyperliquidBuilderMaxFee and // the BuilderInfo.Fee=50 (= 5 bps) used at order placement. The user signs @@ -69,13 +95,13 @@ function normalizeAddress(address: string) { return address.trim().toLowerCase() } - function getWalletProviders(): WalletProvider[] { const injected = window.ethereum if (!injected) return [] - const providers = Array.isArray(injected.providers) && injected.providers.length > 0 - ? injected.providers - : [injected] + const providers = + Array.isArray(injected.providers) && injected.providers.length > 0 + ? injected.providers + : [injected] const seen = new Set() return providers.filter((provider) => { if (!provider || seen.has(provider)) return false @@ -86,17 +112,19 @@ function getWalletProviders(): WalletProvider[] { function getPreferredWalletProvider(): WalletProvider | undefined { const providers = getWalletProviders() - return providers.find((provider) => provider.isRabby) - || providers.find((provider) => provider.isMetaMask) - || providers.find((provider) => provider.isCoinbaseWallet) - || providers.find((provider) => provider.isPhantom) - || providers.find((provider) => provider.isBraveWallet) - || providers.find((provider) => provider.isBackpack) - || providers.find((provider) => provider.isOkxWallet) - || providers.find((provider) => provider.isTrust) - || providers.find((provider) => provider.isExodus) - || providers.find((provider) => provider.isFrame) - || providers[0] + return ( + providers.find((provider) => provider.isRabby) || + providers.find((provider) => provider.isMetaMask) || + providers.find((provider) => provider.isCoinbaseWallet) || + providers.find((provider) => provider.isPhantom) || + providers.find((provider) => provider.isBraveWallet) || + providers.find((provider) => provider.isBackpack) || + providers.find((provider) => provider.isOkxWallet) || + providers.find((provider) => provider.isTrust) || + providers.find((provider) => provider.isExodus) || + providers.find((provider) => provider.isFrame) || + providers[0] + ) } function walletSupportLabel(language: Language) { @@ -105,6 +133,21 @@ function walletSupportLabel(language: Language) { : 'Supports MetaMask, Rabby, Coinbase Wallet, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.' } +function formatAgentExpiry(validUntil: number, language: Language) { + const dateStr = new Date(validUntil).toLocaleString( + language === 'zh' ? 'zh-CN' : 'en-US', + { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + } + ) + const daysLeft = Math.ceil((validUntil - Date.now()) / 86_400_000) + return { dateStr, daysLeft } +} + function formatUSDC(value?: number) { if (typeof value !== 'number' || Number.isNaN(value)) return '--' return new Intl.NumberFormat('en-US', { @@ -132,7 +175,11 @@ function splitSignature(signature: string) { } } -function buildTypedData(primaryType: string, fields: { name: string; type: string }[], message: Record) { +function buildTypedData( + primaryType: string, + fields: { name: string; type: string }[], + message: Record +) { return { domain: { name: 'HyperliquidSignTransaction', @@ -171,24 +218,37 @@ function saveState(state: FlowState) { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(safeState)) } -export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'dropdown' }: HyperliquidWalletConnectProps) { +export function HyperliquidWalletConnect({ + language, + isLoggedIn, + variant = 'dropdown', +}: HyperliquidWalletConnectProps) { const inline = variant === 'inline' const [open, setOpen] = useState(inline) const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [state, setState] = useState(() => getSavedState()) + const currentMainWalletRef = useRef(state.mainWallet) + currentMainWalletRef.current = state.mainWallet const [account, setAccount] = useState(null) const [balanceLoading, setBalanceLoading] = useState(false) const [balanceError, setBalanceError] = useState('') + const [agentInfo, setAgentInfo] = useState(null) + const [agentInfoLoading, setAgentInfoLoading] = useState(false) const text = useMemo( () => ({ title: language === 'zh' ? 'Hyperliquid 钱包' : 'Hyperliquid Wallet', connect: language === 'zh' ? '连接 Hyperliquid' : 'Connect Hyperliquid', connected: language === 'zh' ? '已连接' : 'Connected', mainWallet: language === 'zh' ? 'EVM 主钱包' : 'EVM main wallet', - generateAgent: language === 'zh' ? '生成 NOFX Agent 钱包' : 'Generate NOFX agent wallet', - approveAgent: language === 'zh' ? '授权 Agent 交易' : 'Authorize agent trading', - approveBuilder: language === 'zh' ? '完成交易授权' : 'Finalize trading authorization', + generateAgent: + language === 'zh' + ? '生成 NOFX Agent 钱包' + : 'Generate NOFX agent wallet', + approveAgent: + language === 'zh' ? '授权 Agent 交易' : 'Authorize agent trading', + approveBuilder: + language === 'zh' ? '完成交易授权' : 'Finalize trading authorization', save: language === 'zh' ? '保存到 NOFX' : 'Save to NOFX', done: language === 'zh' ? '流程已完成' : 'Flow complete', balance: language === 'zh' ? 'Hyperliquid 余额' : 'Hyperliquid balance', @@ -197,7 +257,25 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop marginUsed: language === 'zh' ? '已用保证金' : 'Margin used', unrealizedPnl: language === 'zh' ? '未实现盈亏' : 'Unrealized PnL', refresh: language === 'zh' ? '刷新' : 'Refresh', - noCustody: language === 'zh' ? '资金保留在你的 Hyperliquid 账户;NOFX 只保存已授权 Agent 钱包。' : 'Funds stay in your Hyperliquid account; NOFX only stores the authorized agent wallet.', + noCustody: + language === 'zh' + ? '资金保留在你的 Hyperliquid 账户;NOFX 只保存已授权 Agent 钱包。' + : 'Funds stay in your Hyperliquid account; NOFX only stores the authorized agent wallet.', + agentExpiry: + language === 'zh' ? 'Agent 授权到期' : 'Agent authorization expires', + agentExpired: language === 'zh' ? '已过期' : 'Expired', + agentNoAuth: + language === 'zh' + ? '未检测到 NOFX Agent 授权' + : 'No NOFX agent authorization found', + renewAgent: + language === 'zh' + ? '续期 Agent 授权(+180 天)' + : 'Renew agent authorization (+180d)', + renewHint: + language === 'zh' + ? 'Hyperliquid 不允许重复使用同一个 Agent,续期会生成一个新的 Agent 并以 180 天有效期授权,然后自动更新 NOFX 保存的私钥(需登录)。' + : 'Hyperliquid forbids reusing an agent, so renewal creates a new agent approved for 180 days, then updates the stored key in NOFX (sign-in required).', }), [language] ) @@ -206,21 +284,29 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop saveState(state) }, [state]) - useEffect(() => { if (!isLoggedIn || !state.mainWallet) return let cancelled = false - api.getExchangeConfigs() + api + .getExchangeConfigs() .then((configs) => { if (cancelled) return - const existing = configs.find((exchange) => - exchange.exchange_type === 'hyperliquid' && - normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(state.mainWallet!) + const existing = configs.find( + (exchange) => + exchange.exchange_type === 'hyperliquid' && + normalizeAddress(exchange.hyperliquidWalletAddr || '') === + normalizeAddress(state.mainWallet!) ) if (!existing) return setState((prev) => { - if (normalizeAddress(prev.mainWallet || '') !== normalizeAddress(state.mainWallet!)) return prev - const serverBuilderApproved = Boolean(existing.hyperliquidBuilderApproved) + if ( + normalizeAddress(prev.mainWallet || '') !== + normalizeAddress(state.mainWallet!) + ) + return prev + const serverBuilderApproved = Boolean( + existing.hyperliquidBuilderApproved + ) if ( prev.savedExchangeId === existing.id && prev.agentApproved === true && @@ -247,7 +333,10 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop useEffect(() => { const handler = (accounts: unknown) => { - const next = Array.isArray(accounts) && typeof accounts[0] === 'string' ? normalizeAddress(accounts[0]) : undefined + const next = + Array.isArray(accounts) && typeof accounts[0] === 'string' + ? normalizeAddress(accounts[0]) + : undefined if (next) { setState((prev) => ({ ...prev, mainWallet: next })) } @@ -260,9 +349,44 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop useEffect(() => { if (open && state.mainWallet) { void refreshBalance(state.mainWallet) + void refreshAgentInfo(state.mainWallet) } }, [open, state.mainWallet]) + async function refreshAgentInfo(address = state.mainWallet) { + if (!address) return + const requestedAddress = normalizeAddress(address) + setAgentInfoLoading(true) + if ( + normalizeAddress(currentMainWalletRef.current || '') === requestedAddress + ) { + setAgentInfo(null) + } + try { + const res = await api.getHyperliquidAgent(address) + if ( + normalizeAddress(currentMainWalletRef.current || '') === + requestedAddress + ) { + setAgentInfo(res.agent) + } + } catch { + if ( + normalizeAddress(currentMainWalletRef.current || '') === + requestedAddress + ) { + setAgentInfo(null) + } + } finally { + if ( + normalizeAddress(currentMainWalletRef.current || '') === + requestedAddress + ) { + setAgentInfoLoading(false) + } + } + } + async function refreshBalance(address = state.mainWallet) { if (!address) return setBalanceLoading(true) @@ -272,7 +396,11 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop setAccount(summary) } catch (err) { setAccount(null) - setBalanceError(err instanceof Error ? err.message : 'Failed to load Hyperliquid balance') + setBalanceError( + err instanceof Error + ? err.message + : 'Failed to load Hyperliquid balance' + ) } finally { setBalanceLoading(false) } @@ -282,15 +410,20 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop if (!isLoggedIn) return false try { const configs = await api.getExchangeConfigs() - const existing = configs.find((exchange) => - exchange.exchange_type === 'hyperliquid' && - normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(address) + const existing = configs.find( + (exchange) => + exchange.exchange_type === 'hyperliquid' && + normalizeAddress(exchange.hyperliquidWalletAddr || '') === + normalizeAddress(address) ) if (!existing) return false setState((prev) => ({ ...prev, mainWallet: normalizeAddress(address), - agentAddress: prev.mainWallet === normalizeAddress(address) ? prev.agentAddress : undefined, + agentAddress: + prev.mainWallet === normalizeAddress(address) + ? prev.agentAddress + : undefined, agentPrivateKey: undefined, agentApproved: true, // Existing configs default to false in the backend unless the exact @@ -310,26 +443,59 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop const agentApprovedReady = Boolean(state.agentApproved || savedReady) const builderReady = Boolean(state.builderApproved) const steps: { key: keyof FlowState; label: string; status: StepStatus }[] = [ - { key: 'mainWallet', label: text.mainWallet, status: state.mainWallet ? 'done' : 'active' }, - { key: 'agentAddress', label: text.generateAgent, status: agentReady ? 'done' : state.mainWallet ? 'active' : 'pending' }, - { key: 'agentApproved', label: text.approveAgent, status: agentApprovedReady ? 'done' : agentReady ? 'active' : 'pending' }, - { key: 'builderApproved', label: text.approveBuilder, status: builderReady ? 'done' : agentApprovedReady ? 'active' : 'pending' }, - { key: 'savedExchangeId', label: text.save, status: state.savedExchangeId ? 'done' : builderReady ? 'active' : 'pending' }, + { + key: 'mainWallet', + label: text.mainWallet, + status: state.mainWallet ? 'done' : 'active', + }, + { + key: 'agentAddress', + label: text.generateAgent, + status: agentReady ? 'done' : state.mainWallet ? 'active' : 'pending', + }, + { + key: 'agentApproved', + label: text.approveAgent, + status: agentApprovedReady ? 'done' : agentReady ? 'active' : 'pending', + }, + { + key: 'builderApproved', + label: text.approveBuilder, + status: builderReady ? 'done' : agentApprovedReady ? 'active' : 'pending', + }, + { + key: 'savedExchangeId', + label: text.save, + status: state.savedExchangeId + ? 'done' + : builderReady + ? 'active' + : 'pending', + }, ] - const complete = Boolean(state.mainWallet && state.savedExchangeId && state.builderApproved) + const complete = Boolean( + state.mainWallet && state.savedExchangeId && state.builderApproved + ) async function connectWallet() { setError('') const provider = getPreferredWalletProvider() if (!provider) { - setError(language === 'zh' ? '未检测到 EVM 钱包,请安装 MetaMask / Rabby / OKX / Coinbase Wallet。' : 'No EVM wallet detected. Install MetaMask, Rabby, OKX or Coinbase Wallet.') + setError( + language === 'zh' + ? '未检测到 EVM 钱包,请安装 MetaMask / Rabby / OKX / Coinbase Wallet。' + : 'No EVM wallet detected. Install MetaMask, Rabby, OKX or Coinbase Wallet.' + ) return } setBusy(true) try { const accounts = await provider.request({ method: 'eth_requestAccounts' }) - const first = Array.isArray(accounts) && typeof accounts[0] === 'string' ? accounts[0] : '' + const first = + Array.isArray(accounts) && typeof accounts[0] === 'string' + ? accounts[0] + : '' if (!first) throw new Error('Wallet returned no account') const normalized = normalizeAddress(first) setState((prev) => { @@ -372,21 +538,29 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop })) toast.success('NOFX agent wallet generated') } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to generate agent wallet') + setError( + err instanceof Error ? err.message : 'Failed to generate agent wallet' + ) } finally { setBusy(false) } } - async function signAndSubmit(action: Record, primaryType: string, fields: { name: string; type: string }[]) { + async function signAndSubmit( + action: Record, + primaryType: string, + fields: { name: string; type: string }[] + ) { const provider = getPreferredWalletProvider() - if (!provider || !state.mainWallet) throw new Error('Wallet is not connected') + if (!provider || !state.mainWallet) + throw new Error('Wallet is not connected') const typedData = buildTypedData(primaryType, fields, action) const raw = await provider.request({ method: 'eth_signTypedData_v4', params: [state.mainWallet, JSON.stringify(typedData)], }) - if (typeof raw !== 'string') throw new Error('Wallet returned an invalid signature') + if (typeof raw !== 'string') + throw new Error('Wallet returned an invalid signature') const signature = splitSignature(raw) await api.submitHyperliquidApproval(action, Number(action.nonce), signature) } @@ -402,7 +576,7 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop signatureChainId: '0x66eee', hyperliquidChain: 'Mainnet', agentAddress: state.agentAddress, - agentName: AGENT_NAME, + agentName: buildAgentName(nonce), nonce, } await signAndSubmit(action, 'HyperliquidTransaction:ApproveAgent', [ @@ -411,8 +585,13 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop { name: 'agentName', type: 'string' }, { name: 'nonce', type: 'uint64' }, ]) - setState((prev) => ({ ...prev, agentApproved: true, savedExchangeId: undefined })) + setState((prev) => ({ + ...prev, + agentApproved: true, + savedExchangeId: undefined, + })) toast.success('Hyperliquid agent approved') + void refreshAgentInfo() } catch (err) { setError(err instanceof Error ? err.message : 'Agent approval failed') } finally { @@ -420,6 +599,119 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop } } + async function renewAgentAuthorization() { + setError('') + // Hyperliquid rejects re-approving an already-used agent ("Extra agent + // already used"), so renewal must register a BRAND-NEW agent under the same + // name — approving the same name replaces the old slot. The old agent key is + // invalidated on-chain, so the new private key must be re-saved to NOFX; + // that requires the user to be signed in. + if (!isLoggedIn) { + setError( + language === 'zh' + ? '续期需要先登录 NOFX:Hyperliquid 不允许重复使用同一个 Agent,续期会生成新的 Agent 并更新已保存的私钥。' + : 'Renewal requires signing in: Hyperliquid forbids reusing the same agent, so renewal creates a new agent and updates the stored key.' + ) + return + } + if (!state.mainWallet) return + setBusy(true) + try { + const wallet = await api.generateWallet() + const newAgentAddress = normalizeAddress(wallet.address) + const nonce = Date.now() + const action = { + type: 'approveAgent', + signatureChainId: '0x66eee', + hyperliquidChain: 'Mainnet', + agentAddress: newAgentAddress, + agentName: buildAgentName(nonce), + nonce, + } + await signAndSubmit(action, 'HyperliquidTransaction:ApproveAgent', [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'agentAddress', type: 'address' }, + { name: 'agentName', type: 'string' }, + { name: 'nonce', type: 'uint64' }, + ]) + // Hold the new agent + key so the manual "Save to NOFX" button can recover + // if persisting the key below fails (savedExchangeId undefined keeps the + // key in localStorage and re-exposes the save step). + setState((prev) => ({ + ...prev, + agentAddress: newAgentAddress, + agentPrivateKey: wallet.private_key, + agentApproved: true, + builderApproved: false, + savedExchangeId: undefined, + reusedSavedExchange: false, + })) + const existing = (await api.getExchangeConfigs()).find( + (exchange) => + exchange.exchange_type === 'hyperliquid' && + normalizeAddress(exchange.hyperliquidWalletAddr || '') === + normalizeAddress(state.mainWallet!) + ) + if (!existing) { + setState((prev) => ({ + ...prev, + agentAddress: newAgentAddress, + agentPrivateKey: wallet.private_key, + agentApproved: true, + builderApproved: false, + savedExchangeId: undefined, + reusedSavedExchange: false, + })) + throw new Error( + language === 'zh' + ? '新 Agent 已授权,但未找到对应的 NOFX 配置,请用“保存到 NOFX”手动保存。' + : 'New agent approved, but no matching NOFX config was found. Use "Save to NOFX" to store it.' + ) + } + const existingBuilderApproved = Boolean( + existing.hyperliquidBuilderApproved + ) + await api.updateExchangeConfigsEncrypted({ + exchanges: { + [existing.id]: { + enabled: true, + api_key: wallet.private_key, + secret_key: '', + passphrase: '', + hyperliquid_wallet_addr: state.mainWallet, + hyperliquid_builder_approved: existingBuilderApproved, + testnet: false, + }, + }, + }) + setState((prev) => ({ + ...prev, + agentAddress: newAgentAddress, + agentPrivateKey: undefined, + agentApproved: true, + builderApproved: existingBuilderApproved, + savedExchangeId: existing.id, + reusedSavedExchange: true, + })) + toast.success( + language === 'zh' + ? 'Agent 已续期(新 Agent,有效期 180 天)' + : 'Agent renewed (new agent, valid 180 days)' + ) + await refreshAgentInfo() + } catch (err) { + setError( + err instanceof Error + ? err.message + : language === 'zh' + ? 'Agent 续期失败' + : 'Agent renewal failed' + ) + } finally { + setBusy(false) + } + } + async function approveBuilderFee() { setError('') setBusy(true) @@ -457,11 +749,21 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop setState((prev) => ({ ...prev, builderApproved: true, - savedExchangeId: prev.reusedSavedExchange ? prev.savedExchangeId : undefined, + savedExchangeId: prev.reusedSavedExchange + ? prev.savedExchangeId + : undefined, })) - toast.success(language === 'zh' ? '交易授权已完成' : 'Trading authorization finalized') + toast.success( + language === 'zh' ? '交易授权已完成' : 'Trading authorization finalized' + ) } catch (err) { - setError(err instanceof Error ? err.message : (language === 'zh' ? '交易授权失败' : 'Trading authorization failed')) + setError( + err instanceof Error + ? err.message + : language === 'zh' + ? '交易授权失败' + : 'Trading authorization failed' + ) } finally { setBusy(false) } @@ -470,15 +772,21 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop async function saveExchange() { setError('') if (!isLoggedIn) { - setError(language === 'zh' ? '请先登录 NOFX,再保存 Agent 钱包用于交易。' : 'Please sign in before saving the agent wallet for trading.') + setError( + language === 'zh' + ? '请先登录 NOFX,再保存 Agent 钱包用于交易。' + : 'Please sign in before saving the agent wallet for trading.' + ) return } if (!state.mainWallet || !state.builderApproved) return setBusy(true) try { - const existing = (await api.getExchangeConfigs()).find((exchange) => - exchange.exchange_type === 'hyperliquid' && - normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(state.mainWallet!) + const existing = (await api.getExchangeConfigs()).find( + (exchange) => + exchange.exchange_type === 'hyperliquid' && + normalizeAddress(exchange.hyperliquidWalletAddr || '') === + normalizeAddress(state.mainWallet!) ) if (existing) { await api.updateExchangeConfigsEncrypted({ @@ -494,12 +802,24 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop }, }, }) - setState((prev) => ({ ...prev, agentPrivateKey: undefined, savedExchangeId: existing.id, reusedSavedExchange: !state.agentPrivateKey, builderApproved: true })) - toast.success(state.agentPrivateKey ? 'Hyperliquid account updated in NOFX' : 'Existing Hyperliquid account authorization updated') + setState((prev) => ({ + ...prev, + agentPrivateKey: undefined, + savedExchangeId: existing.id, + reusedSavedExchange: !state.agentPrivateKey, + builderApproved: true, + })) + toast.success( + state.agentPrivateKey + ? 'Hyperliquid account updated in NOFX' + : 'Existing Hyperliquid account authorization updated' + ) return } if (!state.agentPrivateKey) { - throw new Error('Generate and authorize a new agent wallet before saving') + throw new Error( + 'Generate and authorize a new agent wallet before saving' + ) } const result = await api.createExchangeEncrypted({ exchange_type: 'hyperliquid', @@ -510,10 +830,19 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop hyperliquid_builder_approved: true, testnet: false, }) - setState((prev) => ({ ...prev, agentPrivateKey: undefined, savedExchangeId: result.id, reusedSavedExchange: false })) + setState((prev) => ({ + ...prev, + agentPrivateKey: undefined, + savedExchangeId: result.id, + reusedSavedExchange: false, + })) toast.success('Hyperliquid account saved to NOFX') } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save Hyperliquid account') + setError( + err instanceof Error + ? err.message + : 'Failed to save Hyperliquid account' + ) } finally { setBusy(false) } @@ -526,7 +855,8 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop ...prev, agentApproved: prev.agentApproved || Boolean(prev.savedExchangeId), builderApproved: false, - reusedSavedExchange: Boolean(prev.savedExchangeId) || prev.reusedSavedExchange, + reusedSavedExchange: + Boolean(prev.savedExchangeId) || prev.reusedSavedExchange, })) } @@ -551,21 +881,33 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop }`} > - {complete ? shortAddress(state.mainWallet) : text.connect} + + {complete ? shortAddress(state.mainWallet) : text.connect} + )} {(open || inline) && ( -
+
{text.title}
-
{text.noCustody}
-
{walletSupportLabel(language)}
+
+ {text.noCustody} +
+
+ {walletSupportLabel(language)} +
{!inline && ( - )} @@ -575,17 +917,30 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
{steps.map((step, index) => (
-
- {step.status === 'done' ? : index + 1} + {step.status === 'done' ? ( + + ) : ( + index + 1 + )}
- {step.label} + + {step.label} +
))}
@@ -600,58 +955,145 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop {state.mainWallet && (
Main -
)} {state.agentAddress && (
Agent -
)}
Network - Hyperliquid Mainnet + + Hyperliquid Mainnet +
+ {state.mainWallet && ( +
+ {text.agentExpiry} + {agentInfoLoading && !agentInfo ? ( + Loading… + ) : agentInfo ? ( + (() => { + const { dateStr, daysLeft } = formatAgentExpiry( + agentInfo.validUntil, + language + ) + const expired = daysLeft < 0 + const soon = daysLeft >= 0 && daysLeft <= 14 + const tone = expired + ? 'text-red-300' + : soon + ? 'text-amber-300' + : 'text-zinc-200' + return ( + + {dateStr} + + ({expired ? text.agentExpired : `${daysLeft}d`}) + + + ) + })() + ) : ( + + {text.agentNoAuth} + + )} +
+ )}
+ {agentInfo && ( +
+ +

+ {text.renewHint} +

+
+ )} + {state.mainWallet && (
- {text.balance} + + {text.balance} +
{balanceError ? ( -
{balanceError}
+
+ {balanceError} +
) : (
{text.withdrawable}
-
{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.withdrawable)} USDC`}
+
+ {balanceLoading && !account + ? 'Loading…' + : `${formatUSDC(account?.withdrawable)} USDC`} +
{text.equity}
-
{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.accountValue)} USDC`}
+
+ {balanceLoading && !account + ? 'Loading…' + : `${formatUSDC(account?.accountValue)} USDC`} +
{text.marginUsed}
-
{formatUSDC(account?.totalMarginUsed)} USDC
+
+ {formatUSDC(account?.totalMarginUsed)} USDC +
{text.unrealizedPnl}
-
= 0 ? 'text-emerald-300' : 'text-red-300'}`}>{formatSignedUSDC(account?.unrealizedPnl)} USDC
+
= 0 ? 'text-emerald-300' : 'text-red-300'}`} + > + {formatSignedUSDC(account?.unrealizedPnl)} USDC +
)} @@ -659,11 +1101,41 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop )}
- {!state.mainWallet && } - {state.mainWallet && !agentReady && } - {agentReady && !agentApprovedReady && } - {agentApprovedReady && !builderReady && } - {builderReady && !state.savedExchangeId && } + {!state.mainWallet && ( + + )} + {state.mainWallet && !agentReady && ( + + )} + {agentReady && !agentApprovedReady && ( + + )} + {agentApprovedReady && !builderReady && ( + + )} + {builderReady && !state.savedExchangeId && ( + + )} {complete && ( <>
@@ -674,17 +1146,28 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop onClick={resetTradingAuthorization} className="w-full flex items-center justify-center gap-2 rounded-xl border border-nofx-gold/30 bg-nofx-gold/10 px-4 py-3 text-sm font-bold text-nofx-gold transition hover:bg-nofx-gold/20" > - {language === 'zh' ? '重新授权交易' : 'Re-authorize trading'} + {language === 'zh' + ? '重新授权交易' + : 'Re-authorize trading'} )}
@@ -695,7 +1178,15 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop ) } -function ActionButton({ busy, onClick, label }: { busy: boolean; onClick: () => void; label: string }) { +function ActionButton({ + busy, + onClick, + label, +}: { + busy: boolean + onClick: () => void + label: string +}) { return (