fix: harden Hyperliquid agent renewal

This commit is contained in:
tinkle-community
2026-06-21 19:23:20 +08:00
parent b95da3ed42
commit 961e016d33
4 changed files with 725 additions and 103 deletions

View File

@@ -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 <ms>" 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":"<message>"}. 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})
}

View File

@@ -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).

View File

@@ -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<unknown>
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 <ms>" 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<WalletProvider>()
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<string, unknown>) {
function buildTypedData(
primaryType: string,
fields: { name: string; type: string }[],
message: Record<string, unknown>
) {
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<FlowState>(() => getSavedState())
const currentMainWalletRef = useRef(state.mainWallet)
currentMainWalletRef.current = state.mainWallet
const [account, setAccount] = useState<HyperliquidAccountSummary | null>(null)
const [balanceLoading, setBalanceLoading] = useState(false)
const [balanceError, setBalanceError] = useState('')
const [agentInfo, setAgentInfo] = useState<HyperliquidAgentInfo | null>(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<string, unknown>, primaryType: string, fields: { name: string; type: string }[]) {
async function signAndSubmit(
action: Record<string, unknown>,
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'
? '续期需要先登录 NOFXHyperliquid 不允许重复使用同一个 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
}`}
>
<Wallet className="w-4 h-4" />
<span>{complete ? shortAddress(state.mainWallet) : text.connect}</span>
<span>
{complete ? shortAddress(state.mainWallet) : text.connect}
</span>
<ChevronDown className="w-4 h-4" />
</button>
)}
{(open || inline) && (
<div className={`${inline ? 'relative w-full' : 'absolute right-0 top-full mt-2 w-[420px] shadow-2xl shadow-black/50'} rounded-2xl border border-nofx-gold/20 bg-[#11151B] z-[80] overflow-hidden`}>
<div
className={`${inline ? 'relative w-full' : 'absolute right-0 top-full mt-2 w-[420px] shadow-2xl shadow-black/50'} rounded-2xl border border-nofx-gold/20 bg-[#11151B] z-[80] overflow-hidden`}
>
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div>
<div className="font-bold text-white">{text.title}</div>
<div className="text-xs text-nofx-text-muted mt-1">{text.noCustody}</div>
<div className="text-[11px] text-nofx-gold/80 mt-1">{walletSupportLabel(language)}</div>
<div className="text-xs text-nofx-text-muted mt-1">
{text.noCustody}
</div>
<div className="text-[11px] text-nofx-gold/80 mt-1">
{walletSupportLabel(language)}
</div>
</div>
{!inline && (
<button type="button" onClick={() => setOpen(false)} className="p-1 rounded hover:bg-white/10 text-zinc-500">
<button
type="button"
onClick={() => setOpen(false)}
className="p-1 rounded hover:bg-white/10 text-zinc-500"
>
<X className="w-4 h-4" />
</button>
)}
@@ -575,17 +917,30 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
<div className="space-y-2">
{steps.map((step, index) => (
<div key={step.key} className="flex items-center gap-3 text-sm">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
step.status === 'done'
? 'bg-emerald-400 text-black'
: step.status === 'active'
? 'bg-nofx-gold text-black'
: 'bg-zinc-800 text-zinc-500'
}`}
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
step.status === 'done'
? 'bg-emerald-400 text-black'
: step.status === 'active'
? 'bg-nofx-gold text-black'
: 'bg-zinc-800 text-zinc-500'
}`}
>
{step.status === 'done' ? <Check className="w-3.5 h-3.5" /> : index + 1}
{step.status === 'done' ? (
<Check className="w-3.5 h-3.5" />
) : (
index + 1
)}
</div>
<span className={step.status === 'pending' ? 'text-zinc-500' : 'text-zinc-200'}>{step.label}</span>
<span
className={
step.status === 'pending'
? 'text-zinc-500'
: 'text-zinc-200'
}
>
{step.label}
</span>
</div>
))}
</div>
@@ -600,58 +955,145 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
{state.mainWallet && (
<div className="flex items-center justify-between gap-3">
<span className="text-zinc-500">Main</span>
<button type="button" onClick={() => copy(state.mainWallet!, 'Main wallet')} className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1">
{shortAddress(state.mainWallet)} <Copy className="w-3 h-3" />
<button
type="button"
onClick={() => copy(state.mainWallet!, 'Main wallet')}
className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1"
>
{shortAddress(state.mainWallet)}{' '}
<Copy className="w-3 h-3" />
</button>
</div>
)}
{state.agentAddress && (
<div className="flex items-center justify-between gap-3">
<span className="text-zinc-500">Agent</span>
<button type="button" onClick={() => copy(state.agentAddress!, 'Agent wallet')} className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1">
{shortAddress(state.agentAddress)} <Copy className="w-3 h-3" />
<button
type="button"
onClick={() => copy(state.agentAddress!, 'Agent wallet')}
className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1"
>
{shortAddress(state.agentAddress)}{' '}
<Copy className="w-3 h-3" />
</button>
</div>
)}
<div className="flex items-center justify-between gap-3">
<span className="text-zinc-500">Network</span>
<span className="font-mono text-zinc-300">Hyperliquid Mainnet</span>
<span className="font-mono text-zinc-300">
Hyperliquid Mainnet
</span>
</div>
{state.mainWallet && (
<div className="flex items-center justify-between gap-3 border-t border-white/10 pt-2">
<span className="text-zinc-500">{text.agentExpiry}</span>
{agentInfoLoading && !agentInfo ? (
<span className="font-mono text-zinc-400">Loading</span>
) : 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 (
<span className={`font-mono text-right ${tone}`}>
{dateStr}
<span className="ml-1 opacity-80">
({expired ? text.agentExpired : `${daysLeft}d`})
</span>
</span>
)
})()
) : (
<span className="font-mono text-zinc-500">
{text.agentNoAuth}
</span>
)}
</div>
)}
</div>
{agentInfo && (
<div className="rounded-xl border border-nofx-gold/20 bg-nofx-gold/5 p-3 space-y-2 text-xs">
<button
type="button"
disabled={busy}
onClick={renewAgentAuthorization}
className="w-full flex items-center justify-center gap-2 rounded-xl border border-nofx-gold/30 bg-nofx-gold/10 px-4 py-2.5 text-sm font-bold text-nofx-gold transition hover:bg-nofx-gold/20 disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{text.renewAgent}
</button>
<p className="text-[11px] leading-relaxed text-nofx-text-muted">
{text.renewHint}
</p>
</div>
)}
{state.mainWallet && (
<div className="rounded-xl border border-nofx-gold/20 bg-nofx-gold/5 p-3 space-y-3 text-xs">
<div className="flex items-center justify-between gap-3">
<span className="font-bold text-zinc-100">{text.balance}</span>
<span className="font-bold text-zinc-100">
{text.balance}
</span>
<button
type="button"
onClick={() => void refreshBalance()}
disabled={balanceLoading}
className="flex items-center gap-1 text-zinc-400 hover:text-nofx-gold disabled:opacity-60"
>
<RefreshCw className={`w-3 h-3 ${balanceLoading ? 'animate-spin' : ''}`} />
<RefreshCw
className={`w-3 h-3 ${balanceLoading ? 'animate-spin' : ''}`}
/>
{text.refresh}
</button>
</div>
{balanceError ? (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2 text-red-300">{balanceError}</div>
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2 text-red-300">
{balanceError}
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.withdrawable}</div>
<div className="mt-1 font-mono text-sm font-bold text-emerald-300">{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.withdrawable)} USDC`}</div>
<div className="mt-1 font-mono text-sm font-bold text-emerald-300">
{balanceLoading && !account
? 'Loading…'
: `${formatUSDC(account?.withdrawable)} USDC`}
</div>
</div>
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.equity}</div>
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">{balanceLoading && !account ? 'Loading…' : `${formatUSDC(account?.accountValue)} USDC`}</div>
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">
{balanceLoading && !account
? 'Loading…'
: `${formatUSDC(account?.accountValue)} USDC`}
</div>
</div>
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.marginUsed}</div>
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">{formatUSDC(account?.totalMarginUsed)} USDC</div>
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">
{formatUSDC(account?.totalMarginUsed)} USDC
</div>
</div>
<div className="rounded-lg bg-black/25 p-2">
<div className="text-zinc-500">{text.unrealizedPnl}</div>
<div className={`mt-1 font-mono text-sm font-bold ${(account?.unrealizedPnl ?? 0) >= 0 ? 'text-emerald-300' : 'text-red-300'}`}>{formatSignedUSDC(account?.unrealizedPnl)} USDC</div>
<div
className={`mt-1 font-mono text-sm font-bold ${(account?.unrealizedPnl ?? 0) >= 0 ? 'text-emerald-300' : 'text-red-300'}`}
>
{formatSignedUSDC(account?.unrealizedPnl)} USDC
</div>
</div>
</div>
)}
@@ -659,11 +1101,41 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
)}
<div className="grid grid-cols-1 gap-2">
{!state.mainWallet && <ActionButton busy={busy} onClick={connectWallet} label={text.connect} />}
{state.mainWallet && !agentReady && <ActionButton busy={busy} onClick={generateAgentWallet} label={text.generateAgent} />}
{agentReady && !agentApprovedReady && <ActionButton busy={busy} onClick={approveAgent} label={text.approveAgent} />}
{agentApprovedReady && !builderReady && <ActionButton busy={busy} onClick={approveBuilderFee} label={text.approveBuilder} />}
{builderReady && !state.savedExchangeId && <ActionButton busy={busy} onClick={saveExchange} label={text.save} />}
{!state.mainWallet && (
<ActionButton
busy={busy}
onClick={connectWallet}
label={text.connect}
/>
)}
{state.mainWallet && !agentReady && (
<ActionButton
busy={busy}
onClick={generateAgentWallet}
label={text.generateAgent}
/>
)}
{agentReady && !agentApprovedReady && (
<ActionButton
busy={busy}
onClick={approveAgent}
label={text.approveAgent}
/>
)}
{agentApprovedReady && !builderReady && (
<ActionButton
busy={busy}
onClick={approveBuilderFee}
label={text.approveBuilder}
/>
)}
{builderReady && !state.savedExchangeId && (
<ActionButton
busy={busy}
onClick={saveExchange}
label={text.save}
/>
)}
{complete && (
<>
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 p-3 text-sm text-emerald-200 flex items-center gap-2">
@@ -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'}
</button>
</>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-white/10">
<a href="https://app.hyperliquid.xyz/" target="_blank" rel="noopener noreferrer" className="text-xs text-zinc-500 hover:text-nofx-gold flex items-center gap-1">
<a
href="https://app.hyperliquid.xyz/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-zinc-500 hover:text-nofx-gold flex items-center gap-1"
>
Open Hyperliquid <ExternalLink className="w-3 h-3" />
</a>
<button type="button" onClick={resetFlow} className="text-xs text-zinc-500 hover:text-red-300">
<button
type="button"
onClick={resetFlow}
className="text-xs text-zinc-500 hover:text-red-300"
>
Reset
</button>
</div>
@@ -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 (
<button
type="button"

View File

@@ -28,6 +28,17 @@ export interface HyperliquidAccountSummary {
updatedAt: number
}
export interface HyperliquidAgentInfo {
name: string
address: string
validUntil: number // unix milliseconds
}
export interface HyperliquidAgentResponse {
agent: HyperliquidAgentInfo | null // the NOFX-managed agent, null when none approved
agents: HyperliquidAgentInfo[] // every approved agent for the wallet
}
export const walletApi = {
async generateWallet(): Promise<GeneratedWallet> {
const res = await fetch(`${API_BASE}/wallet/generate`, { method: 'POST' })
@@ -39,12 +50,29 @@ export const walletApi = {
return handleJSONResponse<HyperliquidConnectConfig>(res)
},
async getHyperliquidAccount(address: string): Promise<HyperliquidAccountSummary> {
const res = await fetch(`${API_BASE}/hyperliquid/account?address=${encodeURIComponent(address)}`)
async getHyperliquidAccount(
address: string
): Promise<HyperliquidAccountSummary> {
const res = await fetch(
`${API_BASE}/hyperliquid/account?address=${encodeURIComponent(address)}`
)
return handleJSONResponse<HyperliquidAccountSummary>(res)
},
async submitHyperliquidApproval(action: Record<string, unknown>, nonce: number, signature: HyperliquidSignature) {
async getHyperliquidAgent(
address: string
): Promise<HyperliquidAgentResponse> {
const res = await fetch(
`${API_BASE}/hyperliquid/agent?address=${encodeURIComponent(address)}`
)
return handleJSONResponse<HyperliquidAgentResponse>(res)
},
async submitHyperliquidApproval(
action: Record<string, unknown>,
nonce: number,
signature: HyperliquidSignature
) {
const res = await fetch(`${API_BASE}/hyperliquid/submit-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },