mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 06:22:49 +08:00
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:
@@ -51,7 +51,18 @@ type hyperliquidAccountSummary struct {
|
||||
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
||||
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
||||
OpenPositions int `json:"openPositions"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
// Spot USDC ("main wallet") balance, so the UI can offer spot->perp funding.
|
||||
SpotUSDC float64 `json:"spotUsdc"`
|
||||
SpotUSDCAvailable float64 `json:"spotUsdcAvailable"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type hyperliquidSpotState struct {
|
||||
Balances []struct {
|
||||
Coin string `json:"coin"`
|
||||
Total string `json:"total"`
|
||||
Hold string `json:"hold"`
|
||||
} `json:"balances"`
|
||||
}
|
||||
|
||||
type hyperliquidAgentInfo struct {
|
||||
@@ -118,43 +129,28 @@ func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
requestBody := map[string]any{
|
||||
"type": "clearinghouseState",
|
||||
"user": address,
|
||||
}
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance 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 balance 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 balance request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
var state hyperliquidClearinghouseState
|
||||
if err := json.Unmarshal(respBody, &state); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
|
||||
if err := postHyperliquidInfo(c, map[string]any{"type": "clearinghouseState", "user": address}, &state); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to query Hyperliquid balance", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Spot ("main wallet") balance is best-effort: a wallet with no spot assets
|
||||
// must not break the perp summary.
|
||||
var spotUSDC, spotAvailable float64
|
||||
var spotState hyperliquidSpotState
|
||||
if err := postHyperliquidInfo(c, map[string]any{"type": "spotClearinghouseState", "user": address}, &spotState); err == nil {
|
||||
for _, balance := range spotState.Balances {
|
||||
if strings.EqualFold(balance.Coin, "USDC") {
|
||||
spotUSDC = parseFloatOrZero(balance.Total)
|
||||
spotAvailable = spotUSDC - parseFloatOrZero(balance.Hold)
|
||||
if spotAvailable < 0 {
|
||||
spotAvailable = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
|
||||
if accountValue == 0 {
|
||||
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
|
||||
@@ -175,16 +171,48 @@ func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
||||
Address: address,
|
||||
AccountValue: accountValue,
|
||||
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
||||
TotalMarginUsed: marginUsed,
|
||||
UnrealizedPnl: unrealizedPnl,
|
||||
OpenPositions: openPositions,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
Address: address,
|
||||
AccountValue: accountValue,
|
||||
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
||||
TotalMarginUsed: marginUsed,
|
||||
UnrealizedPnl: unrealizedPnl,
|
||||
OpenPositions: openPositions,
|
||||
SpotUSDC: spotUSDC,
|
||||
SpotUSDCAvailable: spotAvailable,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// postHyperliquidInfo posts a query to the Hyperliquid info endpoint and
|
||||
// decodes the JSON response into out.
|
||||
func postHyperliquidInfo(c *gin.Context, requestBody map[string]any, out any) error {
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reach Hyperliquid: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("Hyperliquid returned status %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -267,6 +295,11 @@ func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "usdClassTransfer":
|
||||
if err := validateUsdClassTransferAction(req.Action); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
|
||||
return
|
||||
@@ -349,6 +382,29 @@ func validateApproveBuilderFeeAction(action map[string]any) error {
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
// validateUsdClassTransferAction guards the spot<->perp transfer relay. The
|
||||
// signature is produced by the user's own wallet, so the account moved is
|
||||
// always the signer's; validation only keeps malformed or SDK-style
|
||||
// subaccount-suffixed amounts from reaching Hyperliquid through NOFX.
|
||||
func validateUsdClassTransferAction(action map[string]any) error {
|
||||
rawAmount, ok := action["amount"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("missing amount")
|
||||
}
|
||||
amount := strings.TrimSpace(rawAmount)
|
||||
if strings.Contains(amount, " ") {
|
||||
return fmt.Errorf("invalid amount")
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(amount, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return fmt.Errorf("amount must be a positive number")
|
||||
}
|
||||
if _, ok := action["toPerp"].(bool); !ok {
|
||||
return fmt.Errorf("missing or invalid toPerp")
|
||||
}
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
func validateCommonHyperliquidSignedAction(action map[string]any) error {
|
||||
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
|
||||
return fmt.Errorf("invalid signatureChainId")
|
||||
|
||||
67
api/handler_hyperliquid_wallet_test.go
Normal file
67
api/handler_hyperliquid_wallet_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func usdClassTransferAction(overrides map[string]any) map[string]any {
|
||||
action := map[string]any{
|
||||
"type": "usdClassTransfer",
|
||||
"signatureChainId": "0x66eee",
|
||||
"hyperliquidChain": "Mainnet",
|
||||
"amount": "21.5",
|
||||
"toPerp": true,
|
||||
"nonce": float64(1784900000000),
|
||||
}
|
||||
for k, v := range overrides {
|
||||
if v == nil {
|
||||
delete(action, k)
|
||||
continue
|
||||
}
|
||||
action[k] = v
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func TestValidateUsdClassTransferActionAcceptsValidTransfer(t *testing.T) {
|
||||
if err := validateUsdClassTransferAction(usdClassTransferAction(nil)); err != nil {
|
||||
t.Fatalf("expected valid usdClassTransfer to pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsdClassTransferActionRejectsBadAmounts(t *testing.T) {
|
||||
cases := map[string]any{
|
||||
"zero": "0",
|
||||
"negative": "-5",
|
||||
"not number": "abc",
|
||||
"empty": "",
|
||||
// The SDK's subaccount suffix must not be relayable from the browser.
|
||||
"subaccount": "21.5 subaccount:0x1234",
|
||||
}
|
||||
for name, amount := range cases {
|
||||
err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"amount": amount}))
|
||||
if err == nil {
|
||||
t.Fatalf("%s: expected amount %q to be rejected", name, amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsdClassTransferActionRequiresBooleanToPerp(t *testing.T) {
|
||||
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": nil})); err == nil {
|
||||
t.Fatal("expected missing toPerp to be rejected")
|
||||
}
|
||||
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": "true"})); err == nil {
|
||||
t.Fatal("expected non-boolean toPerp to be rejected")
|
||||
}
|
||||
if err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"toPerp": false})); err != nil {
|
||||
t.Fatalf("expected toPerp=false (perp->spot) to be valid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsdClassTransferActionRequiresMainnetChain(t *testing.T) {
|
||||
err := validateUsdClassTransferAction(usdClassTransferAction(map[string]any{"hyperliquidChain": "Testnet"}))
|
||||
if err == nil || !strings.Contains(err.Error(), "hyperliquidChain") {
|
||||
t.Fatalf("expected Testnet chain to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
325
web/src/components/common/HyperliquidFundsPanel.tsx
Normal file
325
web/src/components/common/HyperliquidFundsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
143
web/src/lib/hyperliquidWallet.ts
Normal file
143
web/src/lib/hyperliquidWallet.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user