mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-18 18:04:32 +08:00
fix: harden Hyperliquid agent renewal
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,6 +22,9 @@ const (
|
|||||||
defaultHyperliquidBuilderMaxFee = "0.05%"
|
defaultHyperliquidBuilderMaxFee = "0.05%"
|
||||||
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
|
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
|
||||||
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
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 {
|
type hyperliquidSubmitRequest struct {
|
||||||
@@ -50,6 +54,19 @@ type hyperliquidAccountSummary struct {
|
|||||||
UpdatedAt int64 `json:"updatedAt"`
|
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 {
|
type hyperliquidClearinghouseState struct {
|
||||||
MarginSummary struct {
|
MarginSummary struct {
|
||||||
AccountValue string `json:"accountValue"`
|
AccountValue string `json:"accountValue"`
|
||||||
@@ -68,6 +85,15 @@ type hyperliquidClearinghouseState struct {
|
|||||||
} `json:"assetPositions"`
|
} `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 {
|
func hyperliquidBuilderAddress() string {
|
||||||
return defaultHyperliquidBuilderAddress
|
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) {
|
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
||||||
var req hyperliquidSubmitRequest
|
var req hyperliquidSubmitRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
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})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
|
||||||
return
|
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})
|
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ func (s *Server) setupRoutes() {
|
|||||||
api.POST("/wallet/generate", s.handleWalletGenerate)
|
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/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/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)
|
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).
|
// Crypto related endpoints (no authentication required, not exposed to bot).
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Check, ChevronDown, Copy, ExternalLink, Loader2, RefreshCw, Shield, Wallet, X } from 'lucide-react'
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Shield,
|
||||||
|
Wallet,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { api } from '../../lib/api'
|
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'
|
import type { Language } from '../../i18n/translations'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -14,7 +27,10 @@ declare global {
|
|||||||
type WalletProvider = {
|
type WalletProvider = {
|
||||||
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
|
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>
|
||||||
on?: (event: string, handler: (...args: unknown[]) => void) => void
|
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
|
isMetaMask?: boolean
|
||||||
isRabby?: boolean
|
isRabby?: boolean
|
||||||
isOkxWallet?: boolean
|
isOkxWallet?: boolean
|
||||||
@@ -47,6 +63,16 @@ interface FlowState {
|
|||||||
|
|
||||||
const STORAGE_KEY = 'nofx.hyperliquid.connection.v6'
|
const STORAGE_KEY = 'nofx.hyperliquid.connection.v6'
|
||||||
const AGENT_NAME = 'NOFX Agent'
|
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'
|
const HYPERLIQUID_BUILDER_ADDRESS = '0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d'
|
||||||
// 0.05% (万5). Must match the server's defaultHyperliquidBuilderMaxFee and
|
// 0.05% (万5). Must match the server's defaultHyperliquidBuilderMaxFee and
|
||||||
// the BuilderInfo.Fee=50 (= 5 bps) used at order placement. The user signs
|
// the BuilderInfo.Fee=50 (= 5 bps) used at order placement. The user signs
|
||||||
@@ -69,11 +95,11 @@ function normalizeAddress(address: string) {
|
|||||||
return address.trim().toLowerCase()
|
return address.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function getWalletProviders(): WalletProvider[] {
|
function getWalletProviders(): WalletProvider[] {
|
||||||
const injected = window.ethereum
|
const injected = window.ethereum
|
||||||
if (!injected) return []
|
if (!injected) return []
|
||||||
const providers = Array.isArray(injected.providers) && injected.providers.length > 0
|
const providers =
|
||||||
|
Array.isArray(injected.providers) && injected.providers.length > 0
|
||||||
? injected.providers
|
? injected.providers
|
||||||
: [injected]
|
: [injected]
|
||||||
const seen = new Set<WalletProvider>()
|
const seen = new Set<WalletProvider>()
|
||||||
@@ -86,17 +112,19 @@ function getWalletProviders(): WalletProvider[] {
|
|||||||
|
|
||||||
function getPreferredWalletProvider(): WalletProvider | undefined {
|
function getPreferredWalletProvider(): WalletProvider | undefined {
|
||||||
const providers = getWalletProviders()
|
const providers = getWalletProviders()
|
||||||
return providers.find((provider) => provider.isRabby)
|
return (
|
||||||
|| providers.find((provider) => provider.isMetaMask)
|
providers.find((provider) => provider.isRabby) ||
|
||||||
|| providers.find((provider) => provider.isCoinbaseWallet)
|
providers.find((provider) => provider.isMetaMask) ||
|
||||||
|| providers.find((provider) => provider.isPhantom)
|
providers.find((provider) => provider.isCoinbaseWallet) ||
|
||||||
|| providers.find((provider) => provider.isBraveWallet)
|
providers.find((provider) => provider.isPhantom) ||
|
||||||
|| providers.find((provider) => provider.isBackpack)
|
providers.find((provider) => provider.isBraveWallet) ||
|
||||||
|| providers.find((provider) => provider.isOkxWallet)
|
providers.find((provider) => provider.isBackpack) ||
|
||||||
|| providers.find((provider) => provider.isTrust)
|
providers.find((provider) => provider.isOkxWallet) ||
|
||||||
|| providers.find((provider) => provider.isExodus)
|
providers.find((provider) => provider.isTrust) ||
|
||||||
|| providers.find((provider) => provider.isFrame)
|
providers.find((provider) => provider.isExodus) ||
|
||||||
|| providers[0]
|
providers.find((provider) => provider.isFrame) ||
|
||||||
|
providers[0]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function walletSupportLabel(language: Language) {
|
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.'
|
: '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) {
|
function formatUSDC(value?: number) {
|
||||||
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
|
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
|
||||||
return new Intl.NumberFormat('en-US', {
|
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 {
|
return {
|
||||||
domain: {
|
domain: {
|
||||||
name: 'HyperliquidSignTransaction',
|
name: 'HyperliquidSignTransaction',
|
||||||
@@ -171,24 +218,37 @@ function saveState(state: FlowState) {
|
|||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(safeState))
|
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 inline = variant === 'inline'
|
||||||
const [open, setOpen] = useState(inline)
|
const [open, setOpen] = useState(inline)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [state, setState] = useState<FlowState>(() => getSavedState())
|
const [state, setState] = useState<FlowState>(() => getSavedState())
|
||||||
|
const currentMainWalletRef = useRef(state.mainWallet)
|
||||||
|
currentMainWalletRef.current = state.mainWallet
|
||||||
const [account, setAccount] = useState<HyperliquidAccountSummary | null>(null)
|
const [account, setAccount] = useState<HyperliquidAccountSummary | null>(null)
|
||||||
const [balanceLoading, setBalanceLoading] = useState(false)
|
const [balanceLoading, setBalanceLoading] = useState(false)
|
||||||
const [balanceError, setBalanceError] = useState('')
|
const [balanceError, setBalanceError] = useState('')
|
||||||
|
const [agentInfo, setAgentInfo] = useState<HyperliquidAgentInfo | null>(null)
|
||||||
|
const [agentInfoLoading, setAgentInfoLoading] = useState(false)
|
||||||
const text = useMemo(
|
const text = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
title: language === 'zh' ? 'Hyperliquid 钱包' : 'Hyperliquid Wallet',
|
title: language === 'zh' ? 'Hyperliquid 钱包' : 'Hyperliquid Wallet',
|
||||||
connect: language === 'zh' ? '连接 Hyperliquid' : 'Connect Hyperliquid',
|
connect: language === 'zh' ? '连接 Hyperliquid' : 'Connect Hyperliquid',
|
||||||
connected: language === 'zh' ? '已连接' : 'Connected',
|
connected: language === 'zh' ? '已连接' : 'Connected',
|
||||||
mainWallet: language === 'zh' ? 'EVM 主钱包' : 'EVM main wallet',
|
mainWallet: language === 'zh' ? 'EVM 主钱包' : 'EVM main wallet',
|
||||||
generateAgent: language === 'zh' ? '生成 NOFX Agent 钱包' : 'Generate NOFX agent wallet',
|
generateAgent:
|
||||||
approveAgent: language === 'zh' ? '授权 Agent 交易' : 'Authorize agent trading',
|
language === 'zh'
|
||||||
approveBuilder: language === 'zh' ? '完成交易授权' : 'Finalize trading authorization',
|
? '生成 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',
|
save: language === 'zh' ? '保存到 NOFX' : 'Save to NOFX',
|
||||||
done: language === 'zh' ? '流程已完成' : 'Flow complete',
|
done: language === 'zh' ? '流程已完成' : 'Flow complete',
|
||||||
balance: language === 'zh' ? 'Hyperliquid 余额' : 'Hyperliquid balance',
|
balance: language === 'zh' ? 'Hyperliquid 余额' : 'Hyperliquid balance',
|
||||||
@@ -197,7 +257,25 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
marginUsed: language === 'zh' ? '已用保证金' : 'Margin used',
|
marginUsed: language === 'zh' ? '已用保证金' : 'Margin used',
|
||||||
unrealizedPnl: language === 'zh' ? '未实现盈亏' : 'Unrealized PnL',
|
unrealizedPnl: language === 'zh' ? '未实现盈亏' : 'Unrealized PnL',
|
||||||
refresh: language === 'zh' ? '刷新' : 'Refresh',
|
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]
|
[language]
|
||||||
)
|
)
|
||||||
@@ -206,21 +284,29 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
saveState(state)
|
saveState(state)
|
||||||
}, [state])
|
}, [state])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn || !state.mainWallet) return
|
if (!isLoggedIn || !state.mainWallet) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
api.getExchangeConfigs()
|
api
|
||||||
|
.getExchangeConfigs()
|
||||||
.then((configs) => {
|
.then((configs) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const existing = configs.find((exchange) =>
|
const existing = configs.find(
|
||||||
|
(exchange) =>
|
||||||
exchange.exchange_type === 'hyperliquid' &&
|
exchange.exchange_type === 'hyperliquid' &&
|
||||||
normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(state.mainWallet!)
|
normalizeAddress(exchange.hyperliquidWalletAddr || '') ===
|
||||||
|
normalizeAddress(state.mainWallet!)
|
||||||
)
|
)
|
||||||
if (!existing) return
|
if (!existing) return
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (normalizeAddress(prev.mainWallet || '') !== normalizeAddress(state.mainWallet!)) return prev
|
if (
|
||||||
const serverBuilderApproved = Boolean(existing.hyperliquidBuilderApproved)
|
normalizeAddress(prev.mainWallet || '') !==
|
||||||
|
normalizeAddress(state.mainWallet!)
|
||||||
|
)
|
||||||
|
return prev
|
||||||
|
const serverBuilderApproved = Boolean(
|
||||||
|
existing.hyperliquidBuilderApproved
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
prev.savedExchangeId === existing.id &&
|
prev.savedExchangeId === existing.id &&
|
||||||
prev.agentApproved === true &&
|
prev.agentApproved === true &&
|
||||||
@@ -247,7 +333,10 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (accounts: unknown) => {
|
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) {
|
if (next) {
|
||||||
setState((prev) => ({ ...prev, mainWallet: next }))
|
setState((prev) => ({ ...prev, mainWallet: next }))
|
||||||
}
|
}
|
||||||
@@ -260,9 +349,44 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && state.mainWallet) {
|
if (open && state.mainWallet) {
|
||||||
void refreshBalance(state.mainWallet)
|
void refreshBalance(state.mainWallet)
|
||||||
|
void refreshAgentInfo(state.mainWallet)
|
||||||
}
|
}
|
||||||
}, [open, 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) {
|
async function refreshBalance(address = state.mainWallet) {
|
||||||
if (!address) return
|
if (!address) return
|
||||||
setBalanceLoading(true)
|
setBalanceLoading(true)
|
||||||
@@ -272,7 +396,11 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
setAccount(summary)
|
setAccount(summary)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAccount(null)
|
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 {
|
} finally {
|
||||||
setBalanceLoading(false)
|
setBalanceLoading(false)
|
||||||
}
|
}
|
||||||
@@ -282,15 +410,20 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
if (!isLoggedIn) return false
|
if (!isLoggedIn) return false
|
||||||
try {
|
try {
|
||||||
const configs = await api.getExchangeConfigs()
|
const configs = await api.getExchangeConfigs()
|
||||||
const existing = configs.find((exchange) =>
|
const existing = configs.find(
|
||||||
|
(exchange) =>
|
||||||
exchange.exchange_type === 'hyperliquid' &&
|
exchange.exchange_type === 'hyperliquid' &&
|
||||||
normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(address)
|
normalizeAddress(exchange.hyperliquidWalletAddr || '') ===
|
||||||
|
normalizeAddress(address)
|
||||||
)
|
)
|
||||||
if (!existing) return false
|
if (!existing) return false
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
mainWallet: normalizeAddress(address),
|
mainWallet: normalizeAddress(address),
|
||||||
agentAddress: prev.mainWallet === normalizeAddress(address) ? prev.agentAddress : undefined,
|
agentAddress:
|
||||||
|
prev.mainWallet === normalizeAddress(address)
|
||||||
|
? prev.agentAddress
|
||||||
|
: undefined,
|
||||||
agentPrivateKey: undefined,
|
agentPrivateKey: undefined,
|
||||||
agentApproved: true,
|
agentApproved: true,
|
||||||
// Existing configs default to false in the backend unless the exact
|
// 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 agentApprovedReady = Boolean(state.agentApproved || savedReady)
|
||||||
const builderReady = Boolean(state.builderApproved)
|
const builderReady = Boolean(state.builderApproved)
|
||||||
const steps: { key: keyof FlowState; label: string; status: StepStatus }[] = [
|
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: 'mainWallet',
|
||||||
{ key: 'agentApproved', label: text.approveAgent, status: agentApprovedReady ? 'done' : agentReady ? 'active' : 'pending' },
|
label: text.mainWallet,
|
||||||
{ key: 'builderApproved', label: text.approveBuilder, status: builderReady ? 'done' : agentApprovedReady ? 'active' : 'pending' },
|
status: state.mainWallet ? 'done' : 'active',
|
||||||
{ key: 'savedExchangeId', label: text.save, status: state.savedExchangeId ? 'done' : builderReady ? 'active' : 'pending' },
|
},
|
||||||
|
{
|
||||||
|
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() {
|
async function connectWallet() {
|
||||||
setError('')
|
setError('')
|
||||||
const provider = getPreferredWalletProvider()
|
const provider = getPreferredWalletProvider()
|
||||||
if (!provider) {
|
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
|
return
|
||||||
}
|
}
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const accounts = await provider.request({ method: 'eth_requestAccounts' })
|
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')
|
if (!first) throw new Error('Wallet returned no account')
|
||||||
const normalized = normalizeAddress(first)
|
const normalized = normalizeAddress(first)
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
@@ -372,21 +538,29 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
}))
|
}))
|
||||||
toast.success('NOFX agent wallet generated')
|
toast.success('NOFX agent wallet generated')
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false)
|
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()
|
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 typedData = buildTypedData(primaryType, fields, action)
|
||||||
const raw = await provider.request({
|
const raw = await provider.request({
|
||||||
method: 'eth_signTypedData_v4',
|
method: 'eth_signTypedData_v4',
|
||||||
params: [state.mainWallet, JSON.stringify(typedData)],
|
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)
|
const signature = splitSignature(raw)
|
||||||
await api.submitHyperliquidApproval(action, Number(action.nonce), signature)
|
await api.submitHyperliquidApproval(action, Number(action.nonce), signature)
|
||||||
}
|
}
|
||||||
@@ -402,7 +576,7 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
signatureChainId: '0x66eee',
|
signatureChainId: '0x66eee',
|
||||||
hyperliquidChain: 'Mainnet',
|
hyperliquidChain: 'Mainnet',
|
||||||
agentAddress: state.agentAddress,
|
agentAddress: state.agentAddress,
|
||||||
agentName: AGENT_NAME,
|
agentName: buildAgentName(nonce),
|
||||||
nonce,
|
nonce,
|
||||||
}
|
}
|
||||||
await signAndSubmit(action, 'HyperliquidTransaction:ApproveAgent', [
|
await signAndSubmit(action, 'HyperliquidTransaction:ApproveAgent', [
|
||||||
@@ -411,8 +585,13 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
{ name: 'agentName', type: 'string' },
|
{ name: 'agentName', type: 'string' },
|
||||||
{ name: 'nonce', type: 'uint64' },
|
{ name: 'nonce', type: 'uint64' },
|
||||||
])
|
])
|
||||||
setState((prev) => ({ ...prev, agentApproved: true, savedExchangeId: undefined }))
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
agentApproved: true,
|
||||||
|
savedExchangeId: undefined,
|
||||||
|
}))
|
||||||
toast.success('Hyperliquid agent approved')
|
toast.success('Hyperliquid agent approved')
|
||||||
|
void refreshAgentInfo()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Agent approval failed')
|
setError(err instanceof Error ? err.message : 'Agent approval failed')
|
||||||
} finally {
|
} 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() {
|
async function approveBuilderFee() {
|
||||||
setError('')
|
setError('')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -457,11 +749,21 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
builderApproved: true,
|
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) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -470,15 +772,21 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
async function saveExchange() {
|
async function saveExchange() {
|
||||||
setError('')
|
setError('')
|
||||||
if (!isLoggedIn) {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (!state.mainWallet || !state.builderApproved) return
|
if (!state.mainWallet || !state.builderApproved) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const existing = (await api.getExchangeConfigs()).find((exchange) =>
|
const existing = (await api.getExchangeConfigs()).find(
|
||||||
|
(exchange) =>
|
||||||
exchange.exchange_type === 'hyperliquid' &&
|
exchange.exchange_type === 'hyperliquid' &&
|
||||||
normalizeAddress(exchange.hyperliquidWalletAddr || '') === normalizeAddress(state.mainWallet!)
|
normalizeAddress(exchange.hyperliquidWalletAddr || '') ===
|
||||||
|
normalizeAddress(state.mainWallet!)
|
||||||
)
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await api.updateExchangeConfigsEncrypted({
|
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 }))
|
setState((prev) => ({
|
||||||
toast.success(state.agentPrivateKey ? 'Hyperliquid account updated in NOFX' : 'Existing Hyperliquid account authorization updated')
|
...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
|
return
|
||||||
}
|
}
|
||||||
if (!state.agentPrivateKey) {
|
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({
|
const result = await api.createExchangeEncrypted({
|
||||||
exchange_type: 'hyperliquid',
|
exchange_type: 'hyperliquid',
|
||||||
@@ -510,10 +830,19 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
hyperliquid_builder_approved: true,
|
hyperliquid_builder_approved: true,
|
||||||
testnet: false,
|
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')
|
toast.success('Hyperliquid account saved to NOFX')
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -526,7 +855,8 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
...prev,
|
...prev,
|
||||||
agentApproved: prev.agentApproved || Boolean(prev.savedExchangeId),
|
agentApproved: prev.agentApproved || Boolean(prev.savedExchangeId),
|
||||||
builderApproved: false,
|
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" />
|
<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" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(open || inline) && (
|
{(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 className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold text-white">{text.title}</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-xs text-nofx-text-muted mt-1">
|
||||||
<div className="text-[11px] text-nofx-gold/80 mt-1">{walletSupportLabel(language)}</div>
|
{text.noCustody}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-nofx-gold/80 mt-1">
|
||||||
|
{walletSupportLabel(language)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!inline && (
|
{!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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -575,7 +917,8 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<div key={step.key} className="flex items-center gap-3 text-sm">
|
<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 ${
|
<div
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||||
step.status === 'done'
|
step.status === 'done'
|
||||||
? 'bg-emerald-400 text-black'
|
? 'bg-emerald-400 text-black'
|
||||||
: step.status === 'active'
|
: step.status === 'active'
|
||||||
@@ -583,9 +926,21 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
: 'bg-zinc-800 text-zinc-500'
|
: '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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -600,58 +955,145 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
{state.mainWallet && (
|
{state.mainWallet && (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-zinc-500">Main</span>
|
<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">
|
<button
|
||||||
{shortAddress(state.mainWallet)} <Copy className="w-3 h-3" />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{state.agentAddress && (
|
{state.agentAddress && (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-zinc-500">Agent</span>
|
<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">
|
<button
|
||||||
{shortAddress(state.agentAddress)} <Copy className="w-3 h-3" />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-zinc-500">Network</span>
|
<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>
|
</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>
|
||||||
|
)}
|
||||||
|
</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 && (
|
{state.mainWallet && (
|
||||||
<div className="rounded-xl border border-nofx-gold/20 bg-nofx-gold/5 p-3 space-y-3 text-xs">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void refreshBalance()}
|
onClick={() => void refreshBalance()}
|
||||||
disabled={balanceLoading}
|
disabled={balanceLoading}
|
||||||
className="flex items-center gap-1 text-zinc-400 hover:text-nofx-gold disabled:opacity-60"
|
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}
|
{text.refresh}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{balanceError ? (
|
{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="grid grid-cols-2 gap-2">
|
||||||
<div className="rounded-lg bg-black/25 p-2">
|
<div className="rounded-lg bg-black/25 p-2">
|
||||||
<div className="text-zinc-500">{text.withdrawable}</div>
|
<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>
|
||||||
<div className="rounded-lg bg-black/25 p-2">
|
<div className="rounded-lg bg-black/25 p-2">
|
||||||
<div className="text-zinc-500">{text.equity}</div>
|
<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>
|
||||||
<div className="rounded-lg bg-black/25 p-2">
|
<div className="rounded-lg bg-black/25 p-2">
|
||||||
<div className="text-zinc-500">{text.marginUsed}</div>
|
<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>
|
||||||
<div className="rounded-lg bg-black/25 p-2">
|
<div className="rounded-lg bg-black/25 p-2">
|
||||||
<div className="text-zinc-500">{text.unrealizedPnl}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -659,11 +1101,41 @@ export function HyperliquidWalletConnect({ language, isLoggedIn, variant = 'drop
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
{!state.mainWallet && <ActionButton busy={busy} onClick={connectWallet} label={text.connect} />}
|
{!state.mainWallet && (
|
||||||
{state.mainWallet && !agentReady && <ActionButton busy={busy} onClick={generateAgentWallet} label={text.generateAgent} />}
|
<ActionButton
|
||||||
{agentReady && !agentApprovedReady && <ActionButton busy={busy} onClick={approveAgent} label={text.approveAgent} />}
|
busy={busy}
|
||||||
{agentApprovedReady && !builderReady && <ActionButton busy={busy} onClick={approveBuilderFee} label={text.approveBuilder} />}
|
onClick={connectWallet}
|
||||||
{builderReady && !state.savedExchangeId && <ActionButton busy={busy} onClick={saveExchange} label={text.save} />}
|
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 && (
|
{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">
|
<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}
|
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"
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-white/10">
|
<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" />
|
Open Hyperliquid <ExternalLink className="w-3 h-3" />
|
||||||
</a>
|
</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
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ export interface HyperliquidAccountSummary {
|
|||||||
updatedAt: number
|
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 = {
|
export const walletApi = {
|
||||||
async generateWallet(): Promise<GeneratedWallet> {
|
async generateWallet(): Promise<GeneratedWallet> {
|
||||||
const res = await fetch(`${API_BASE}/wallet/generate`, { method: 'POST' })
|
const res = await fetch(`${API_BASE}/wallet/generate`, { method: 'POST' })
|
||||||
@@ -39,12 +50,29 @@ export const walletApi = {
|
|||||||
return handleJSONResponse<HyperliquidConnectConfig>(res)
|
return handleJSONResponse<HyperliquidConnectConfig>(res)
|
||||||
},
|
},
|
||||||
|
|
||||||
async getHyperliquidAccount(address: string): Promise<HyperliquidAccountSummary> {
|
async getHyperliquidAccount(
|
||||||
const res = await fetch(`${API_BASE}/hyperliquid/account?address=${encodeURIComponent(address)}`)
|
address: string
|
||||||
|
): Promise<HyperliquidAccountSummary> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/hyperliquid/account?address=${encodeURIComponent(address)}`
|
||||||
|
)
|
||||||
return handleJSONResponse<HyperliquidAccountSummary>(res)
|
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`, {
|
const res = await fetch(`${API_BASE}/hyperliquid/submit-exchange`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
Reference in New Issue
Block a user