feat: Hyperliquid deposit QR + spot<->perp transfer in the platform

The trading account could only be funded outside NOFX: no way to move
spot ('main wallet') USDC into the perp trading account, and no deposit
address/QR shown anywhere.

- API: /hyperliquid/account now also returns spot USDC (total/available)
  alongside the perp summary; /hyperliquid/submit-exchange accepts a
  validated usdClassTransfer action (positive plain amount, boolean
  toPerp, Mainnet chain) relayed like the existing approval actions
- Web: new HyperliquidFundsPanel in the exchange config modal with a
  deposit tab (wallet address QR + Arbitrum/spot routing notes) and a
  transfer tab (spot<->perp, signed by the user's own wallet via EIP-712
  UsdClassTransfer — user-signed actions derive the account from the
  signature, so the agent key NOFX holds cannot and does not move funds)
- Shared EIP-712/provider helpers extracted from HyperliquidWalletConnect
  into lib/hyperliquidWallet.ts
This commit is contained in:
tinkle-community
2026-07-24 14:07:13 +09:00
parent 5cd62e3c3a
commit 6195803e9e
7 changed files with 651 additions and 154 deletions

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowDownUp, Loader2, QrCode, RefreshCw } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { toast } from 'sonner'
import { api } from '../../lib/api'
import type { HyperliquidAccountSummary } from '../../lib/api/wallet'
import type { Language } from '../../i18n/translations'
import { copyWithToast } from '../../lib/clipboard'
import {
formatUSDC,
getPreferredWalletProvider,
normalizeAddress,
shortAddress,
signHyperliquidUserAction,
} from '../../lib/hyperliquidWallet'
interface HyperliquidFundsPanelProps {
language: Language
walletAddress: string
onTransferred?: () => void | Promise<void>
}
const TEXT = {
zh: {
deposit: '充值',
transfer: '划转',
balances: '余额',
spot: '现货(主钱包)',
perp: '合约(交易账户)',
available: '可用',
withdrawable: '可提取',
depositHint:
'向下方地址转入 USDC 即可充值:通过 Arbitrum One 链转入会自动进入合约账户(最低 5 USDC);从其他 Hyperliquid 账户现货转账则进入现货,需再划转到合约。',
depositWarn: '仅支持 USDC。请勿从交易所直接提现其他币种或其他链到此地址。',
copyAddress: '复制地址',
addressCopied: '地址已复制',
spotToPerp: '现货 → 合约',
perpToSpot: '合约 → 现货',
amount: '划转数量 (USDC)',
max: '全部',
submit: '签名并划转',
submitting: '划转中…',
connectFirst: '连接主钱包以签名划转',
connect: '连接钱包',
noProvider: '未检测到浏览器钱包插件(如 MetaMask / OKX)',
wrongWallet: (want: string, got: string) =>
`钱包地址不匹配:需要 ${want},当前连接 ${got}。划转签名必须来自主钱包本身。`,
invalidAmount: '请输入有效的划转数量',
exceedsBalance: '超出可用余额',
success: '划转成功,余额稍后刷新',
failed: '划转失败',
},
en: {
deposit: 'Deposit',
transfer: 'Transfer',
balances: 'Balances',
spot: 'Spot (main wallet)',
perp: 'Perp (trading account)',
available: 'available',
withdrawable: 'withdrawable',
depositHint:
'Send USDC to the address below: transfers on Arbitrum One are auto-credited to the perp account (min 5 USDC); spot transfers from another Hyperliquid account arrive in spot and need a transfer to perp.',
depositWarn:
'USDC only. Do not withdraw other tokens or use other chains to this address.',
copyAddress: 'Copy address',
addressCopied: 'Address copied',
spotToPerp: 'Spot → Perp',
perpToSpot: 'Perp → Spot',
amount: 'Amount (USDC)',
max: 'Max',
submit: 'Sign & transfer',
submitting: 'Transferring…',
connectFirst: 'Connect the main wallet to sign the transfer',
connect: 'Connect wallet',
noProvider: 'No browser wallet extension detected (MetaMask / OKX etc.)',
wrongWallet: (want: string, got: string) =>
`Wallet mismatch: expected ${want} but connected ${got}. The transfer must be signed by the main wallet itself.`,
invalidAmount: 'Enter a valid transfer amount',
exceedsBalance: 'Amount exceeds the available balance',
success: 'Transfer submitted, balances refresh shortly',
failed: 'Transfer failed',
},
}
export function HyperliquidFundsPanel({
language,
walletAddress,
onTransferred,
}: HyperliquidFundsPanelProps) {
const t = TEXT[language === 'zh' ? 'zh' : 'en']
const address = normalizeAddress(walletAddress)
const [tab, setTab] = useState<'deposit' | 'transfer'>('deposit')
const [account, setAccount] = useState<HyperliquidAccountSummary | null>(null)
const [loading, setLoading] = useState(false)
const [toPerp, setToPerp] = useState(true)
const [amount, setAmount] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const refresh = useCallback(async () => {
if (!address) return
setLoading(true)
try {
setAccount(await api.getHyperliquidAccount(address))
} catch {
// keep the previous snapshot; the panel stays usable
} finally {
setLoading(false)
}
}, [address])
useEffect(() => {
void refresh()
}, [refresh])
const availableFrom = toPerp
? (account?.spotUsdcAvailable ?? 0)
: (account?.withdrawable ?? 0)
async function submitTransfer() {
setError('')
const parsed = Number(amount)
if (!Number.isFinite(parsed) || parsed <= 0) {
setError(t.invalidAmount)
return
}
if (parsed > availableFrom + 1e-9) {
setError(t.exceedsBalance)
return
}
const provider = getPreferredWalletProvider()
if (!provider) {
setError(t.noProvider)
return
}
setBusy(true)
try {
const accounts = (await provider.request({
method: 'eth_requestAccounts',
})) as string[]
const signer = normalizeAddress(accounts?.[0] ?? '')
if (!signer) throw new Error(t.connectFirst)
if (signer !== address) {
throw new Error(t.wrongWallet(shortAddress(address), shortAddress(signer)))
}
const nonce = Date.now()
const action = {
type: 'usdClassTransfer',
signatureChainId: '0x66eee',
hyperliquidChain: 'Mainnet',
amount: String(parsed),
toPerp,
nonce,
}
const signature = await signHyperliquidUserAction(
provider,
signer,
action,
'HyperliquidTransaction:UsdClassTransfer',
[
{ name: 'hyperliquidChain', type: 'string' },
{ name: 'amount', type: 'string' },
{ name: 'toPerp', type: 'bool' },
{ name: 'nonce', type: 'uint64' },
]
)
await api.submitHyperliquidApproval(action, nonce, signature)
toast.success(t.success)
setAmount('')
await onTransferred?.()
// Hyperliquid settles the class transfer near-instantly; one refresh
// shortly after covers indexing lag.
setTimeout(() => void refresh(), 1500)
} catch (err) {
setError(err instanceof Error ? err.message : t.failed)
} finally {
setBusy(false)
}
}
if (!address) return null
return (
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<button
type="button"
onClick={() => setTab('deposit')}
className={`px-3 py-1.5 rounded-xl text-sm font-semibold flex items-center gap-1.5 ${
tab === 'deposit'
? 'bg-nofx-gold text-white'
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
}`}
>
<QrCode size={14} />
{t.deposit}
</button>
<button
type="button"
onClick={() => setTab('transfer')}
className={`px-3 py-1.5 rounded-xl text-sm font-semibold flex items-center gap-1.5 ${
tab === 'transfer'
? 'bg-nofx-gold text-white'
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
}`}
>
<ArrowDownUp size={14} />
{t.transfer}
</button>
</div>
<button
type="button"
onClick={() => void refresh()}
className="text-nofx-text-muted hover:text-nofx-text"
title={t.balances}
>
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<RefreshCw size={16} />
)}
</button>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg p-3">
<div className="text-nofx-text-muted text-xs">{t.spot}</div>
<div className="font-mono font-medium text-nofx-text">
{formatUSDC(account?.spotUsdc)} USDC
</div>
<div className="text-xs text-nofx-text-muted">
{t.available}: {formatUSDC(account?.spotUsdcAvailable)}
</div>
</div>
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg p-3">
<div className="text-nofx-text-muted text-xs">{t.perp}</div>
<div className="font-mono font-medium text-nofx-text">
{formatUSDC(account?.accountValue)} USDC
</div>
<div className="text-xs text-nofx-text-muted">
{t.withdrawable}: {formatUSDC(account?.withdrawable)}
</div>
</div>
</div>
{tab === 'deposit' ? (
<div className="space-y-3">
<div className="flex justify-center rounded-xl bg-white p-4">
<QRCodeSVG value={walletAddress} size={168} marginSize={1} />
</div>
<button
type="button"
onClick={() => void copyWithToast(walletAddress, t.addressCopied)}
className="w-full font-mono text-xs text-center break-all text-nofx-text-muted hover:text-nofx-gold"
title={t.copyAddress}
>
{walletAddress}
</button>
<p className="text-xs text-nofx-text-muted leading-5">{t.depositHint}</p>
<p className="text-xs text-amber-500">{t.depositWarn}</p>
</div>
) : (
<div className="space-y-3">
<div className="flex gap-2">
<button
type="button"
onClick={() => setToPerp(true)}
className={`flex-1 px-3 py-1.5 rounded-xl text-sm font-semibold ${
toPerp
? 'bg-nofx-gold text-white'
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
}`}
>
{t.spotToPerp}
</button>
<button
type="button"
onClick={() => setToPerp(false)}
className={`flex-1 px-3 py-1.5 rounded-xl text-sm font-semibold ${
!toPerp
? 'bg-nofx-gold text-white'
: 'bg-nofx-bg text-nofx-text-muted hover:text-nofx-text'
}`}
>
{t.perpToSpot}
</button>
</div>
<div>
<label className="text-xs text-nofx-text-muted">{t.amount}</label>
<div className="flex gap-2 mt-1">
<input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="flex-1 rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg px-3 py-1.5 text-sm font-mono text-nofx-text"
placeholder="0.00"
/>
<button
type="button"
onClick={() => setAmount(availableFrom.toFixed(2))}
className="px-3 py-1.5 rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg text-sm text-nofx-text-muted hover:text-nofx-text"
>
{t.max}
</button>
</div>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
disabled={busy}
onClick={() => void submitTransfer()}
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 size={14} className="animate-spin" />}
{busy ? t.submitting : t.submit}
</button>
<p className="text-xs text-nofx-text-muted">{t.connectFirst}</p>
</div>
)}
</div>
)
}

View File

@@ -18,31 +18,14 @@ import type {
HyperliquidAgentInfo,
} 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
}
import {
buildTypedData,
formatUSDC,
getPreferredWalletProvider,
normalizeAddress,
shortAddress,
splitSignature,
} from '../../lib/hyperliquidWallet'
type StepStatus = 'pending' | 'active' | 'done' | 'error'
@@ -81,11 +64,6 @@ const HYPERLIQUID_BUILDER_ADDRESS = '0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d'
// this exact string when approving the builder during wallet connect.
const HYPERLIQUID_BUILDER_MAX_FEE = '0.05%'
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`),
@@ -93,42 +71,6 @@ function copy(text: string, label: string) {
)
}
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'
? 'Supports MetaMask, Rabby, Coinbase, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.'
@@ -150,59 +92,12 @@ function formatAgentExpiry(validUntil: number, language: Language) {
return { dateStr, daysLeft }
}
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)

View File

@@ -4,6 +4,7 @@ import { t, type Language } from '../../i18n/translations'
import { api } from '../../lib/api'
import { useAuth } from '../../contexts/AuthContext'
import { HyperliquidWalletConnect } from '../common/HyperliquidWalletConnect'
import { HyperliquidFundsPanel } from '../common/HyperliquidFundsPanel'
import { getExchangeIcon } from '../common/ExchangeIcons'
import {
TwoStageKeyModal,
@@ -708,6 +709,12 @@ export function ExchangeConfigModal({
<div className="flex justify-start">
<HyperliquidWalletConnect language={language} isLoggedIn={Boolean(user)} variant="inline" />
</div>
{selectedExchange?.hyperliquidWalletAddr && (
<HyperliquidFundsPanel
language={language}
walletAddress={selectedExchange.hyperliquidWalletAddr}
/>
)}
</div>
)}

View File

@@ -25,6 +25,10 @@ export interface HyperliquidAccountSummary {
totalMarginUsed: number
unrealizedPnl: number
openPositions: number
/** Spot ("main wallet") USDC balance on Hyperliquid */
spotUsdc: number
/** Spot USDC not locked by open orders, transferable to perp */
spotUsdcAvailable: number
updatedAt: number
}

View File

@@ -0,0 +1,143 @@
/**
* Shared helpers for Hyperliquid wallet flows: injected EVM provider
* discovery, EIP-712 typed-data construction for Hyperliquid user-signed
* actions, and signature handling. Used by the connect/onboarding flow and
* the deposit/transfer funds panel.
*/
declare global {
interface Window {
ethereum?: WalletProvider & { providers?: WalletProvider[] }
}
}
export 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
}
export 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
})
}
export 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]
)
}
export function normalizeAddress(address: string) {
return address.trim().toLowerCase()
}
export function shortAddress(address?: string) {
if (!address) return ''
return `${address.slice(0, 6)}${address.slice(-4)}`
}
export function formatUSDC(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '--'
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
export 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,
}
}
export 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,
}
}
/**
* Sign a Hyperliquid user-signed action with the connected wallet and return
* the split signature. `signerAddress` must be the wallet that owns the
* Hyperliquid account — user-signed actions derive the acting account from
* the signature itself.
*/
export async function signHyperliquidUserAction(
provider: WalletProvider,
signerAddress: string,
action: Record<string, unknown>,
primaryType: string,
fields: { name: string; type: string }[]
) {
const typedData = buildTypedData(primaryType, fields, action)
const raw = await provider.request({
method: 'eth_signTypedData_v4',
params: [signerAddress, JSON.stringify(typedData)],
})
if (typeof raw !== 'string') {
throw new Error('Wallet returned an invalid signature')
}
return splitSignature(raw)
}