mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 22:36:58 +08:00
fix: harden Hyperliquid agent renewal
This commit is contained in:
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
? '续期需要先登录 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
|
||||
}`}
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user