feat(trader): wire Hyperliquid wallet and quick trade flow

- Add wallet API endpoints and exchange storage fields for Hyperliquid

- Normalize quick trade order paths, symbols, and builder fee coverage

- Add frontend wallet connect and quick trade helpers
This commit is contained in:
tinklefund
2026-05-25 01:24:58 +08:00
parent f37fc9f887
commit c7c003cc3c
16 changed files with 1731 additions and 253 deletions

View File

@@ -0,0 +1,707 @@
import { useEffect, useMemo, 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 { Language } from '../../i18n/translations'
declare global {
interface Window {
ethereum?: WalletProvider & { providers?: WalletProvider[] }
}
}
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
isMetaMask?: boolean
isRabby?: boolean
isOkxWallet?: boolean
isCoinbaseWallet?: boolean
isTrust?: boolean
isPhantom?: boolean
isBackpack?: boolean
isBraveWallet?: boolean
isExodus?: boolean
isFrame?: boolean
}
type StepStatus = 'pending' | 'active' | 'done' | 'error'
interface HyperliquidWalletConnectProps {
language: Language
isLoggedIn: boolean
variant?: 'dropdown' | 'inline'
}
interface FlowState {
mainWallet?: string
agentAddress?: string
agentPrivateKey?: string
agentApproved?: boolean
builderApproved?: boolean
savedExchangeId?: string
reusedSavedExchange?: boolean
}
const STORAGE_KEY = 'nofx.hyperliquid.connection.v6'
const AGENT_NAME = 'NOFX Agent'
const HYPERLIQUID_BUILDER_ADDRESS = '0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d'
const HYPERLIQUID_BUILDER_MAX_FEE = '0.1%'
function shortAddress(address?: string) {
if (!address) return ''
return `${address.slice(0, 6)}${address.slice(-4)}`
}
function copy(text: string, label: string) {
navigator.clipboard?.writeText(text).then(
() => toast.success(`${label} copied`),
() => toast.error('Copy failed')
)
}
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 seen = new Set<WalletProvider>()
return providers.filter((provider) => {
if (!provider || seen.has(provider)) return false
seen.add(provider)
return true
})
}
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]
}
function walletSupportLabel(language: Language) {
return language === 'zh'
? '支持 MetaMask、Rabby、Coinbase、Phantom、Brave、Backpack、OKX、Trust 等 EVM 钱包。'
: 'Supports MetaMask, Rabby, Coinbase Wallet, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.'
}
function formatUSDC(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
function formatSignedUSDC(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
const sign = value > 0 ? '+' : ''
return `${sign}${formatUSDC(value)}`
}
function splitSignature(signature: string) {
const hex = signature.startsWith('0x') ? signature.slice(2) : signature
if (hex.length !== 130) {
throw new Error('Invalid wallet signature length')
}
const v = parseInt(hex.slice(128, 130), 16)
return {
r: `0x${hex.slice(0, 64)}`,
s: `0x${hex.slice(64, 128)}`,
v: v < 27 ? v + 27 : v,
}
}
function buildTypedData(primaryType: string, fields: { name: string; type: string }[], message: Record<string, unknown>) {
return {
domain: {
name: 'HyperliquidSignTransaction',
version: '1',
chainId: 421614,
verifyingContract: '0x0000000000000000000000000000000000000000',
},
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
[primaryType]: fields,
},
primaryType,
message,
}
}
function getSavedState(): FlowState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function saveState(state: FlowState) {
const safeState = { ...state }
if (safeState.savedExchangeId) {
delete safeState.agentPrivateKey
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(safeState))
}
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 [account, setAccount] = useState<HyperliquidAccountSummary | null>(null)
const [balanceLoading, setBalanceLoading] = useState(false)
const [balanceError, setBalanceError] = useState('')
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',
save: language === 'zh' ? '保存到 NOFX' : 'Save to NOFX',
done: language === 'zh' ? '流程已完成' : 'Flow complete',
balance: language === 'zh' ? 'Hyperliquid 余额' : 'Hyperliquid balance',
withdrawable: language === 'zh' ? '可用' : 'Withdrawable',
equity: language === 'zh' ? '权益' : 'Equity',
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.',
}),
[language]
)
useEffect(() => {
saveState(state)
}, [state])
useEffect(() => {
if (!isLoggedIn || !state.mainWallet) return
let cancelled = false
api.getExchangeConfigs()
.then((configs) => {
if (cancelled) return
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 (
prev.savedExchangeId === existing.id &&
prev.agentApproved === true &&
prev.builderApproved === serverBuilderApproved &&
prev.reusedSavedExchange === true
) {
return prev
}
return {
...prev,
agentPrivateKey: undefined,
agentApproved: true,
builderApproved: serverBuilderApproved,
savedExchangeId: existing.id,
reusedSavedExchange: true,
}
})
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [isLoggedIn, state.mainWallet])
useEffect(() => {
const handler = (accounts: unknown) => {
const next = Array.isArray(accounts) && typeof accounts[0] === 'string' ? normalizeAddress(accounts[0]) : undefined
if (next) {
setState((prev) => ({ ...prev, mainWallet: next }))
}
}
const provider = getPreferredWalletProvider()
provider?.on?.('accountsChanged', handler)
return () => provider?.removeListener?.('accountsChanged', handler)
}, [])
useEffect(() => {
if (open && state.mainWallet) {
void refreshBalance(state.mainWallet)
}
}, [open, state.mainWallet])
async function refreshBalance(address = state.mainWallet) {
if (!address) return
setBalanceLoading(true)
setBalanceError('')
try {
const summary = await api.getHyperliquidAccount(address)
setAccount(summary)
} catch (err) {
setAccount(null)
setBalanceError(err instanceof Error ? err.message : 'Failed to load Hyperliquid balance')
} finally {
setBalanceLoading(false)
}
}
async function reuseSavedExchangeIfPresent(address: string) {
if (!isLoggedIn) return false
try {
const configs = await api.getExchangeConfigs()
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,
agentPrivateKey: undefined,
agentApproved: true,
// Existing configs default to false in the backend unless the exact
// approveBuilderFee flow has already persisted a successful approval.
builderApproved: Boolean(existing.hyperliquidBuilderApproved),
savedExchangeId: existing.id,
reusedSavedExchange: true,
}))
return true
} catch {
return false
}
}
const savedReady = Boolean(state.savedExchangeId)
const agentReady = Boolean(state.agentAddress || savedReady)
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' },
]
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.')
return
}
setBusy(true)
try {
const accounts = await provider.request({ method: 'eth_requestAccounts' })
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) => {
const sameWallet = prev.mainWallet === normalized
return {
...prev,
mainWallet: normalized,
agentAddress: sameWallet ? prev.agentAddress : undefined,
agentPrivateKey: sameWallet ? prev.agentPrivateKey : undefined,
agentApproved: sameWallet ? prev.agentApproved : false,
builderApproved: sameWallet ? prev.builderApproved : false,
savedExchangeId: sameWallet ? prev.savedExchangeId : undefined,
reusedSavedExchange: sameWallet ? prev.reusedSavedExchange : false,
}
})
await Promise.all([
refreshBalance(normalized),
reuseSavedExchangeIfPresent(normalized),
])
} catch (err) {
setError(err instanceof Error ? err.message : 'Wallet connection failed')
} finally {
setBusy(false)
}
}
async function generateAgentWallet() {
setError('')
if (!state.mainWallet) return
setBusy(true)
try {
const wallet = await api.generateWallet()
setState((prev) => ({
...prev,
agentAddress: normalizeAddress(wallet.address),
agentPrivateKey: wallet.private_key,
agentApproved: false,
builderApproved: false,
savedExchangeId: undefined,
}))
toast.success('NOFX agent wallet generated')
} catch (err) {
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 }[]) {
const provider = getPreferredWalletProvider()
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')
const signature = splitSignature(raw)
await api.submitHyperliquidApproval(action, Number(action.nonce), signature)
}
async function approveAgent() {
setError('')
if (!state.agentAddress) return
setBusy(true)
try {
const nonce = Date.now()
const action = {
type: 'approveAgent',
signatureChainId: '0x66eee',
hyperliquidChain: 'Mainnet',
agentAddress: state.agentAddress,
agentName: AGENT_NAME,
nonce,
}
await signAndSubmit(action, 'HyperliquidTransaction:ApproveAgent', [
{ name: 'hyperliquidChain', type: 'string' },
{ name: 'agentAddress', type: 'address' },
{ name: 'agentName', type: 'string' },
{ name: 'nonce', type: 'uint64' },
])
setState((prev) => ({ ...prev, agentApproved: true, savedExchangeId: undefined }))
toast.success('Hyperliquid agent approved')
} catch (err) {
setError(err instanceof Error ? err.message : 'Agent approval failed')
} finally {
setBusy(false)
}
}
async function approveBuilderFee() {
setError('')
setBusy(true)
try {
const nonce = Date.now()
const action = {
type: 'approveBuilderFee',
signatureChainId: '0x66eee',
hyperliquidChain: 'Mainnet',
maxFeeRate: HYPERLIQUID_BUILDER_MAX_FEE,
builder: normalizeAddress(HYPERLIQUID_BUILDER_ADDRESS),
nonce,
}
await signAndSubmit(action, 'HyperliquidTransaction:ApproveBuilderFee', [
{ name: 'hyperliquidChain', type: 'string' },
{ name: 'maxFeeRate', type: 'string' },
{ name: 'builder', type: 'address' },
{ name: 'nonce', type: 'uint64' },
])
if (isLoggedIn && state.savedExchangeId && state.mainWallet) {
await api.updateExchangeConfigsEncrypted({
exchanges: {
[state.savedExchangeId]: {
enabled: true,
api_key: '',
secret_key: '',
passphrase: '',
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_builder_approved: true,
testnet: false,
},
},
})
}
setState((prev) => ({
...prev,
builderApproved: true,
savedExchangeId: prev.reusedSavedExchange ? prev.savedExchangeId : undefined,
}))
toast.success(language === 'zh' ? '交易授权已完成' : 'Trading authorization finalized')
} catch (err) {
setError(err instanceof Error ? err.message : (language === 'zh' ? '交易授权失败' : 'Trading authorization failed'))
} finally {
setBusy(false)
}
}
async function saveExchange() {
setError('')
if (!isLoggedIn) {
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!)
)
if (existing) {
await api.updateExchangeConfigsEncrypted({
exchanges: {
[existing.id]: {
enabled: true,
api_key: state.agentPrivateKey || '',
secret_key: '',
passphrase: '',
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_builder_approved: true,
testnet: false,
},
},
})
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')
}
const result = await api.createExchangeEncrypted({
exchange_type: 'hyperliquid',
account_name: `Hyperliquid ${shortAddress(state.mainWallet)}`,
enabled: true,
api_key: state.agentPrivateKey,
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_builder_approved: true,
testnet: 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')
} finally {
setBusy(false)
}
}
function resetTradingAuthorization() {
setOpen(true)
setError('')
setState((prev) => ({
...prev,
agentApproved: prev.agentApproved || Boolean(prev.savedExchangeId),
builderApproved: false,
reusedSavedExchange: Boolean(prev.savedExchangeId) || prev.reusedSavedExchange,
}))
}
function resetFlow() {
window.localStorage.removeItem(STORAGE_KEY)
setState({})
setAccount(null)
setBalanceError('')
setError('')
}
return (
<div className={inline ? 'relative w-full' : 'relative'}>
{!inline && (
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-bold transition-all border ${
complete
? 'bg-emerald-500/10 border-emerald-400/30 text-emerald-300'
: 'bg-nofx-gold/10 border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/20'
}`}
>
<Wallet className="w-4 h-4" />
<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="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>
{!inline && (
<button type="button" onClick={() => setOpen(false)} className="p-1 rounded hover:bg-white/10 text-zinc-500">
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="p-4 space-y-4">
<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'
}`}
>
{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>
</div>
))}
</div>
{error && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-300">
{error}
</div>
)}
<div className="rounded-xl border border-white/10 bg-black/25 p-3 space-y-2 text-xs">
{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>
</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>
</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>
</div>
</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>
<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' : ''}`} />
{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="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>
<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>
<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>
<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>
</div>
)}
</div>
)}
<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} />}
{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">
<Shield className="w-4 h-4" /> {text.done}
</div>
<button
type="button"
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'}
</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">
Open Hyperliquid <ExternalLink className="w-3 h-3" />
</a>
<button type="button" onClick={resetFlow} className="text-xs text-zinc-500 hover:text-red-300">
Reset
</button>
</div>
</div>
</div>
)}
</div>
)
}
function ActionButton({ busy, onClick, label }: { busy: boolean; onClick: () => void; label: string }) {
return (
<button
type="button"
disabled={busy}
onClick={onClick}
className="w-full flex items-center justify-center gap-2 rounded-xl bg-nofx-gold px-4 py-3 text-sm font-bold text-black transition hover:bg-yellow-400 disabled:opacity-60 disabled:cursor-not-allowed"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{label}
</button>
)
}

View File

@@ -7,6 +7,7 @@ import type {
CreateTraderRequest,
AIModel,
Exchange,
ExchangeAccountState,
} from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
@@ -44,6 +45,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [editingTrader, setEditingTrader] = useState<any>(null)
const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [exchangeAccountStates, setExchangeAccountStates] = useState<Record<string, ExchangeAccountState>>({})
const [isExchangeAccountStatesLoading, setIsExchangeAccountStatesLoading] = useState(false)
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
@@ -56,18 +59,26 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
return
}
const [
modelConfigs,
exchangeConfigs,
models,
] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
])
setAllModels(modelConfigs)
setAllExchanges(exchangeConfigs)
setSupportedModels(models)
setIsExchangeAccountStatesLoading(true)
try {
const [
modelConfigs,
exchangeConfigs,
models,
accountStateResponse,
] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
api.getExchangeAccountState().catch(() => ({ states: {} })),
])
setAllModels(modelConfigs)
setAllExchanges(exchangeConfigs)
setExchangeAccountStates(accountStateResponse.states || {})
setSupportedModels(models)
} finally {
setIsExchangeAccountStatesLoading(false)
}
}
// Toggle wallet address visibility for a trader
@@ -96,6 +107,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
})
}
// Copy wallet address to clipboard
const handleCopyAddress = async (id: string, address: string) => {
try {
@@ -691,6 +703,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<ConfigStatusGrid
configuredModels={configuredModels}
configuredExchanges={configuredExchanges}
exchangeAccountStates={exchangeAccountStates}
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
visibleExchangeAddresses={visibleExchangeAddresses}
copiedId={copiedId}
language={language}

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'
import type { Exchange } from '../../types'
import { t, type Language } from '../../i18n/translations'
import { api } from '../../lib/api'
import { useAuth } from '../../contexts/AuthContext'
import { HyperliquidWalletConnect } from '../common/HyperliquidWalletConnect'
import { getExchangeIcon } from '../common/ExchangeIcons'
import {
TwoStageKeyModal,
@@ -151,6 +153,7 @@ export function ExchangeConfigModal({
onClose,
language,
}: ExchangeConfigModalProps) {
const { user } = useAuth()
// Step: 0 = select exchange, 1 = configure
const [currentStep, setCurrentStep] = useState(editingExchangeId ? 1 : 0)
const [selectedExchangeType, setSelectedExchangeType] = useState('')
@@ -170,9 +173,6 @@ export function ExchangeConfigModal({
const [asterSigner, setAsterSigner] = useState('')
const [asterPrivateKey, setAsterPrivateKey] = useState('')
// Hyperliquid fields
const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('')
// Lighter fields
const [lighterWalletAddr, setLighterWalletAddr] = useState('')
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
@@ -219,7 +219,6 @@ export function ExchangeConfigModal({
setAsterUser(selectedExchange.asterUser || '')
setAsterSigner(selectedExchange.asterSigner || '')
setAsterPrivateKey('')
setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '')
setLighterWalletAddr(selectedExchange.lighterWalletAddr || '')
setLighterApiKeyPrivateKey('')
setLighterApiKeyIndex(selectedExchange.lighterApiKeyIndex || 0)
@@ -278,12 +277,6 @@ export function ExchangeConfigModal({
setSecureInputTarget(null)
}
const maskSecret = (secret: string) => {
if (!secret || secret.length === 0) return ''
if (secret.length <= 8) return '*'.repeat(secret.length)
return secret.slice(0, 4) + '*'.repeat(Math.max(secret.length - 8, 4)) + secret.slice(-4)
}
const handleSelectExchange = (exchangeType: string) => {
setSelectedExchangeType(exchangeType)
setCurrentStep(1)
@@ -321,8 +314,8 @@ export function ExchangeConfigModal({
if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
} else if (currentExchangeType === 'hyperliquid') {
if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return
await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), '', '', testnet, hyperliquidWalletAddr.trim())
toast.error(language === 'zh' ? 'Hyperliquid 请使用钱包授权流程连接。' : 'Use the wallet authorization flow to connect Hyperliquid.')
return
} else if (currentExchangeType === 'aster') {
if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return
await onSave(exchangeId, exchangeType, trimmedAccountName, '', '', '', testnet, undefined, asterUser.trim(), asterSigner.trim(), asterPrivateKey.trim())
@@ -688,32 +681,28 @@ export function ExchangeConfigModal({
</>
)}
{/* Hyperliquid Fields */}
{/* Hyperliquid Wallet Authorization */}
{currentExchangeType === 'hyperliquid' && (
<>
<div className="space-y-4">
<div className="p-4 rounded-xl" style={{ background: 'rgba(127, 231, 204, 0.1)', border: '1px solid rgba(127, 231, 204, 0.3)' }}>
<div className="flex items-start gap-2">
<span style={{ fontSize: '16px' }}>🔐</span>
<div>
<div className="text-sm font-semibold mb-1" style={{ color: '#7FE7CC' }}>{t('hyperliquidAgentWalletTitle', language)}</div>
<div className="text-xs" style={{ color: '#848E9C' }}>{t('hyperliquidAgentWalletDesc', language)}</div>
<div className="text-sm font-semibold mb-1" style={{ color: '#7FE7CC' }}>
{language === 'zh' ? 'Hyperliquid 必须走钱包授权' : 'Hyperliquid requires wallet authorization'}
</div>
<div className="text-xs leading-5" style={{ color: '#848E9C' }}>
{language === 'zh'
? '不再支持手动填写私钥/API Key。请用 MetaMask、Rabby、OKX、Coinbase Wallet 等 EVM 钱包完成连接、Agent 授权和 Builder fee 授权。'
: 'Manual private-key/API-key entry is disabled. Use MetaMask, Rabby, OKX, Coinbase Wallet or another EVM wallet to connect, authorize the agent, and approve the builder fee.'}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>{t('hyperliquidAgentPrivateKey', language)}</label>
<div className="flex gap-2">
<input type="text" value={maskSecret(apiKey)} readOnly placeholder={t('enterHyperliquidAgentPrivateKey', language)} className="flex-1 px-4 py-3 rounded-xl" style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} />
<button type="button" onClick={() => setSecureInputTarget('hyperliquid')} className="px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:scale-105" style={{ background: '#7FE7CC', color: '#000' }}>
{apiKey ? t('secureInputReenter', language) : t('secureInputButton', language)}
</button>
</div>
<div className="flex justify-start">
<HyperliquidWalletConnect language={language} isLoggedIn={Boolean(user)} variant="inline" />
</div>
<div className="space-y-2">
<label className="text-sm font-semibold" style={{ color: '#EAECEF' }}>{t('hyperliquidMainWalletAddress', language)}</label>
<input type="text" value={hyperliquidWalletAddr} onChange={(e) => setHyperliquidWalletAddr(e.target.value)} placeholder={t('enterHyperliquidMainWalletAddress', language)} className="w-full px-4 py-3 rounded-xl" style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }} required />
</div>
</>
</div>
)}
{/* Lighter Fields */}
@@ -758,18 +747,20 @@ export function ExchangeConfigModal({
{/* Buttons */}
<div className="flex gap-3 pt-4">
<button type="button" onClick={handleBack} className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5" style={{ background: '#2B3139', color: '#848E9C' }}>
{editingExchangeId ? t('cancel', language) : t('exchangeConfig.back', language)}
</button>
<button
type="submit"
disabled={isSaving || !accountName.trim()}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#F0B90B', color: '#000' }}
>
{isSaving ? t('saving', language) : (
<>{t('saveConfig', language)} <ArrowRight className="w-4 h-4" /></>
)}
{currentExchangeType === 'hyperliquid' ? t('closeGuide', language) : editingExchangeId ? t('cancel', language) : t('exchangeConfig.back', language)}
</button>
{currentExchangeType !== 'hyperliquid' && (
<button
type="submit"
disabled={isSaving || !accountName.trim()}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#F0B90B', color: '#000' }}
>
{isSaving ? t('saving', language) : (
<>{t('saveConfig', language)} <ArrowRight className="w-4 h-4" /></>
)}
</button>
)}
</div>
</form>
)}

55
web/src/lib/api/wallet.ts Normal file
View File

@@ -0,0 +1,55 @@
import { API_BASE, handleJSONResponse } from './helpers'
export interface GeneratedWallet {
address: string
private_key: string
}
export interface HyperliquidConnectConfig {
builderAddress: string
builderMaxFee: string
chain: string
signatureChainId: string
}
export interface HyperliquidSignature {
r: string
s: string
v: number
}
export interface HyperliquidAccountSummary {
address: string
accountValue: number
withdrawable: number
totalMarginUsed: number
unrealizedPnl: number
openPositions: number
updatedAt: number
}
export const walletApi = {
async generateWallet(): Promise<GeneratedWallet> {
const res = await fetch(`${API_BASE}/wallet/generate`, { method: 'POST' })
return handleJSONResponse<GeneratedWallet>(res)
},
async getHyperliquidConnectConfig(): Promise<HyperliquidConnectConfig> {
const res = await fetch(`${API_BASE}/hyperliquid/connect-config`)
return handleJSONResponse<HyperliquidConnectConfig>(res)
},
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) {
const res = await fetch(`${API_BASE}/hyperliquid/submit-exchange`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, nonce, signature }),
})
return handleJSONResponse<{ success: boolean; response?: unknown }>(res)
},
}

View File

@@ -0,0 +1,144 @@
import { api } from './api'
import type { MarketSymbol } from './api/data'
import type { AIModel, Exchange, StrategyConfig } from '../types'
export interface QuickTradeResult {
traderId: string
traderName: string
strategyId: string
strategyName: string
symbol: string
display: string
reusedTrader: boolean
}
function compactSymbolName(symbol: string) {
return symbol.replace(/^xyz:/i, '').replace(/[^A-Za-z0-9_-]+/g, '').slice(0, 16) || 'SYMBOL'
}
function pickEnabledModel(models: AIModel[]) {
return models.find((m) => m.enabled)
}
function pickHyperliquidExchange(exchanges: Exchange[]) {
return exchanges.find((e) => {
const type = (e.exchange_type || e.id || '').toLowerCase()
return type === 'hyperliquid' && e.enabled && !!e.hyperliquidWalletAddr?.trim()
})
}
function buildSingleSymbolConfig(base: StrategyConfig, symbol: string, language: 'zh' | 'en'): StrategyConfig {
const staticCoinSource = {
source_type: 'static' as const,
static_coins: [symbol],
excluded_coins: [],
use_ai500: false,
use_oi_top: false,
use_oi_low: false,
use_hyper_all: false,
use_hyper_main: false,
}
const customPrompt =
language === 'zh'
? `只交易 Hyperliquid USDC 永续合约 ${symbol}。每次决策必须先检查账户余额、现有持仓、最新价格、趋势、成交量、资金费率和风险限制。没有明确优势时保持观望。单标的策略,不要切换到其他币种。`
: `Trade only the Hyperliquid USDC perpetual market ${symbol}. Before every decision, check balance, current positions, latest price, trend, volume, funding, and risk limits. Stay flat when there is no clear edge. Single-symbol strategy; do not switch to other symbols.`
return {
...base,
strategy_type: 'ai_trading',
language,
coin_source: staticCoinSource,
custom_prompt: customPrompt,
ai_config: {
...(base.ai_config || {}),
coin_source: staticCoinSource,
indicators: base.ai_config?.indicators || base.indicators!,
risk_control: base.ai_config?.risk_control || base.risk_control!,
prompt_sections: base.ai_config?.prompt_sections || base.prompt_sections,
custom_prompt: customPrompt,
},
}
}
export async function createHyperliquidQuickTrader(
symbolInput: MarketSymbol | { symbol: string; display?: string },
language: 'zh' | 'en'
): Promise<QuickTradeResult> {
const symbol = symbolInput.symbol
const display = symbolInput.display || symbol
const compact = compactSymbolName(display)
const traderName = `HL ${compact} Quick`.slice(0, 50)
const strategyName = `HL ${compact} Strategy`.slice(0, 50)
const [models, exchanges, traders, strategies] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getTraders(true),
api.getStrategies().catch(() => []),
])
const model = pickEnabledModel(models)
if (!model) {
throw new Error(language === 'zh' ? '没有可用 AI 模型,请先在 Config 里启用模型。' : 'No enabled AI model. Enable a model in Config first.')
}
const exchange = pickHyperliquidExchange(exchanges)
if (!exchange) {
throw new Error(language === 'zh' ? '没有可用 Hyperliquid 钱包,请先连接并保存 Hyperliquid。' : 'No usable Hyperliquid wallet. Connect and save Hyperliquid first.')
}
const existingTrader = traders.find((tr: any) =>
String(tr.name || '').toLowerCase() === traderName.toLowerCase() ||
(String(tr.exchange_id || '') === exchange.id && String(tr.trading_symbols || '').split(',').map((s) => s.trim()).includes(symbol))
)
if (existingTrader) {
const existing = existingTrader as any
return {
traderId: existing.trader_id || existing.id,
traderName: existing.trader_name || existing.name || traderName,
strategyId: existing.strategy_id || '',
strategyName,
symbol,
display,
reusedTrader: true,
}
}
let strategy = strategies.find((s: any) => String(s.name || '').toLowerCase() === strategyName.toLowerCase()) as any
if (!strategy?.id) {
const defaultConfig = await api.getDefaultStrategyConfig()
const config = buildSingleSymbolConfig(defaultConfig, symbol, language)
strategy = await api.createStrategy({
name: strategyName,
description:
language === 'zh'
? `Hyperliquid ${display} 单标的快速交易策略。`
: `Hyperliquid ${display} single-symbol quick trading strategy.`,
config,
} as any)
}
const trader = await api.createTrader({
name: traderName,
ai_model_id: model.id,
exchange_id: exchange.id,
strategy_id: strategy.id,
scan_interval_minutes: 5,
trading_symbols: symbol,
show_in_competition: false,
custom_prompt:
language === 'zh'
? `固定只交易 Hyperliquid ${symbol},不要扩展到其他标的。启动前再次检查余额、仓位和风险。`
: `Only trade Hyperliquid ${symbol}; do not expand to other symbols. Re-check balance, positions, and risk before starting.`,
})
return {
traderId: trader.trader_id || (trader as any).id,
traderName: trader.trader_name || (trader as any).name || traderName,
strategyId: strategy.id,
strategyName,
symbol,
display,
reusedTrader: false,
}
}