From 6195803e9e16ad53748a3d3c38c1e9f4edbb23e8 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 24 Jul 2026 14:07:13 +0900 Subject: [PATCH] feat: Hyperliquid deposit QR + spot<->perp transfer in the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/handler_hyperliquid_wallet.go | 138 +++++--- api/handler_hyperliquid_wallet_test.go | 67 ++++ .../common/HyperliquidFundsPanel.tsx | 325 ++++++++++++++++++ .../common/HyperliquidWalletConnect.tsx | 121 +------ .../components/trader/ExchangeConfigModal.tsx | 7 + web/src/lib/api/wallet.ts | 4 + web/src/lib/hyperliquidWallet.ts | 143 ++++++++ 7 files changed, 651 insertions(+), 154 deletions(-) create mode 100644 api/handler_hyperliquid_wallet_test.go create mode 100644 web/src/components/common/HyperliquidFundsPanel.tsx create mode 100644 web/src/lib/hyperliquidWallet.ts diff --git a/api/handler_hyperliquid_wallet.go b/api/handler_hyperliquid_wallet.go index ce5d1b92..c4b57ab2 100644 --- a/api/handler_hyperliquid_wallet.go +++ b/api/handler_hyperliquid_wallet.go @@ -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") diff --git a/api/handler_hyperliquid_wallet_test.go b/api/handler_hyperliquid_wallet_test.go new file mode 100644 index 00000000..5feb397a --- /dev/null +++ b/api/handler_hyperliquid_wallet_test.go @@ -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) + } +} diff --git a/web/src/components/common/HyperliquidFundsPanel.tsx b/web/src/components/common/HyperliquidFundsPanel.tsx new file mode 100644 index 00000000..482319ac --- /dev/null +++ b/web/src/components/common/HyperliquidFundsPanel.tsx @@ -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 +} + +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(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 ( +
+
+
+ + +
+ +
+ +
+
+
{t.spot}
+
+ {formatUSDC(account?.spotUsdc)} USDC +
+
+ {t.available}: {formatUSDC(account?.spotUsdcAvailable)} +
+
+
+
{t.perp}
+
+ {formatUSDC(account?.accountValue)} USDC +
+
+ {t.withdrawable}: {formatUSDC(account?.withdrawable)} +
+
+
+ + {tab === 'deposit' ? ( +
+
+ +
+ +

{t.depositHint}

+

{t.depositWarn}

+
+ ) : ( +
+
+ + +
+
+ +
+ 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" + /> + +
+
+ {error &&

{error}

} + +

{t.connectFirst}

+
+ )} +
+ ) +} diff --git a/web/src/components/common/HyperliquidWalletConnect.tsx b/web/src/components/common/HyperliquidWalletConnect.tsx index 5ba76353..6e7eb472 100644 --- a/web/src/components/common/HyperliquidWalletConnect.tsx +++ b/web/src/components/common/HyperliquidWalletConnect.tsx @@ -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 - 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() - 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 -) { - 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) diff --git a/web/src/components/trader/ExchangeConfigModal.tsx b/web/src/components/trader/ExchangeConfigModal.tsx index d75f56b9..03af97ec 100644 --- a/web/src/components/trader/ExchangeConfigModal.tsx +++ b/web/src/components/trader/ExchangeConfigModal.tsx @@ -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({
+ {selectedExchange?.hyperliquidWalletAddr && ( + + )} )} diff --git a/web/src/lib/api/wallet.ts b/web/src/lib/api/wallet.ts index 659cd1d6..8ec8d669 100644 --- a/web/src/lib/api/wallet.ts +++ b/web/src/lib/api/wallet.ts @@ -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 } diff --git a/web/src/lib/hyperliquidWallet.ts b/web/src/lib/hyperliquidWallet.ts new file mode 100644 index 00000000..8a15e6f5 --- /dev/null +++ b/web/src/lib/hyperliquidWallet.ts @@ -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 + 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() + 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 +) { + 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, + 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) +}