mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-27 14:02:43 +08:00
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
470 lines
15 KiB
Go
470 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
|
|
// 0.05% (5 bps) — matches BuilderInfo.Fee=50 charged at order placement.
|
|
// New wallet approvals sign this exact value; existing approvals at the
|
|
// prior 0.1% cap remain valid because 0.05% is within their approved max.
|
|
defaultHyperliquidBuilderMaxFee = "0.05%"
|
|
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
|
|
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
|
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
|
|
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
|
|
nofxHyperliquidAgentName = "NOFX Agent"
|
|
)
|
|
|
|
type hyperliquidSubmitRequest struct {
|
|
Action map[string]any `json:"action" binding:"required"`
|
|
Nonce int64 `json:"nonce" binding:"required"`
|
|
Signature struct {
|
|
R string `json:"r" binding:"required"`
|
|
S string `json:"s" binding:"required"`
|
|
V int `json:"v"`
|
|
} `json:"signature" binding:"required"`
|
|
}
|
|
|
|
type hyperliquidConfigResponse struct {
|
|
BuilderAddress string `json:"builderAddress"`
|
|
BuilderMaxFee string `json:"builderMaxFee"`
|
|
Chain string `json:"chain"`
|
|
SignatureChain string `json:"signatureChainId"`
|
|
}
|
|
|
|
type hyperliquidAccountSummary struct {
|
|
Address string `json:"address"`
|
|
AccountValue float64 `json:"accountValue"`
|
|
Withdrawable float64 `json:"withdrawable"`
|
|
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
|
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
|
OpenPositions int `json:"openPositions"`
|
|
// 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 {
|
|
Name string `json:"name"`
|
|
Address string `json:"address"`
|
|
ValidUntil int64 `json:"validUntil"` // unix milliseconds
|
|
}
|
|
|
|
type hyperliquidAgentResponse struct {
|
|
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
|
|
Agent *hyperliquidAgentInfo `json:"agent"`
|
|
// Agents lists every approved agent for the wallet (for visibility/cleanup).
|
|
Agents []hyperliquidAgentInfo `json:"agents"`
|
|
}
|
|
|
|
type hyperliquidClearinghouseState struct {
|
|
MarginSummary struct {
|
|
AccountValue string `json:"accountValue"`
|
|
TotalMarginUsed string `json:"totalMarginUsed"`
|
|
} `json:"marginSummary"`
|
|
CrossMarginSummary struct {
|
|
AccountValue string `json:"accountValue"`
|
|
TotalMarginUsed string `json:"totalMarginUsed"`
|
|
} `json:"crossMarginSummary"`
|
|
Withdrawable string `json:"withdrawable"`
|
|
AssetPositions []struct {
|
|
Position struct {
|
|
Szi string `json:"szi"`
|
|
UnrealizedPnl string `json:"unrealizedPnl"`
|
|
} `json:"position"`
|
|
} `json:"assetPositions"`
|
|
}
|
|
|
|
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
|
|
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
|
|
// it from the stored name, but we strip defensively before matching the slot.
|
|
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
|
|
|
|
func baseAgentName(name string) string {
|
|
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
|
|
}
|
|
|
|
func hyperliquidBuilderAddress() string {
|
|
return defaultHyperliquidBuilderAddress
|
|
}
|
|
|
|
func hyperliquidBuilderMaxFee() string {
|
|
return defaultHyperliquidBuilderMaxFee
|
|
}
|
|
|
|
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
|
|
c.JSON(http.StatusOK, hyperliquidConfigResponse{
|
|
BuilderAddress: hyperliquidBuilderAddress(),
|
|
BuilderMaxFee: hyperliquidBuilderMaxFee(),
|
|
Chain: "Mainnet",
|
|
SignatureChain: "0x66eee",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
|
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
|
if !isEVMAddress(address) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
|
return
|
|
}
|
|
|
|
var state hyperliquidClearinghouseState
|
|
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)
|
|
}
|
|
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
|
|
if marginUsed == 0 {
|
|
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
|
|
}
|
|
|
|
var unrealizedPnl float64
|
|
openPositions := 0
|
|
for _, position := range state.AssetPositions {
|
|
size := parseFloatOrZero(position.Position.Szi)
|
|
if size != 0 {
|
|
openPositions++
|
|
}
|
|
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
|
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.
|
|
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
|
|
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
|
if !isEVMAddress(address) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
|
return
|
|
}
|
|
|
|
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"})
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"})
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: 20 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode})
|
|
return
|
|
}
|
|
|
|
// extraAgents returns null when no agents are approved.
|
|
agents := []hyperliquidAgentInfo{}
|
|
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
|
|
if err := json.Unmarshal(respBody, &agents); err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
|
|
return
|
|
}
|
|
}
|
|
|
|
out := hyperliquidAgentResponse{Agents: agents}
|
|
for i := range agents {
|
|
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
|
|
agent := agents[i]
|
|
out.Agent = &agent
|
|
break
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
|
var req hyperliquidSubmitRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
|
|
return
|
|
}
|
|
|
|
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
actionType, _ := req.Action["type"].(string)
|
|
switch actionType {
|
|
case "approveAgent":
|
|
if err := validateApproveAgentAction(req.Action); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
case "approveBuilderFee":
|
|
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
|
|
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
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"action": req.Action,
|
|
"nonce": req.Nonce,
|
|
"signature": req.Signature,
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
|
|
return
|
|
}
|
|
|
|
client := &http.Client{Timeout: 20 * time.Second}
|
|
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
|
|
return
|
|
}
|
|
hlReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(hlReq)
|
|
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))
|
|
var decoded any
|
|
if len(respBody) > 0 {
|
|
_ = json.Unmarshal(respBody, &decoded)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
|
|
return
|
|
}
|
|
|
|
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
|
|
// {"status":"err","response":"<message>"}. Without this check a rejected
|
|
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
|
|
// to the user as success while nothing changes on-chain.
|
|
var hlResp struct {
|
|
Status string `json:"status"`
|
|
Response json.RawMessage `json:"response"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
|
|
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
|
|
if msg == "" {
|
|
msg = "Hyperliquid rejected the action"
|
|
}
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
|
|
}
|
|
|
|
func validateApproveAgentAction(action map[string]any) error {
|
|
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
|
|
return fmt.Errorf("missing agentAddress")
|
|
}
|
|
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
|
|
return fmt.Errorf("missing agentName")
|
|
}
|
|
return validateCommonHyperliquidSignedAction(action)
|
|
}
|
|
|
|
func validateApproveBuilderFeeAction(action map[string]any) error {
|
|
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
|
|
if builder != hyperliquidBuilderAddress() {
|
|
return fmt.Errorf("builder address mismatch")
|
|
}
|
|
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
|
|
return fmt.Errorf("builder max fee mismatch")
|
|
}
|
|
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")
|
|
}
|
|
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
|
|
return fmt.Errorf("invalid hyperliquidChain")
|
|
}
|
|
if _, err := actionNonce(action); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSubmittedNonce(action map[string]any, submitted int64) error {
|
|
actionValue, err := actionNonce(action)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if actionValue != submitted {
|
|
return fmt.Errorf("nonce mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isEVMAddress(address string) bool {
|
|
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
|
|
return false
|
|
}
|
|
for _, char := range address[2:] {
|
|
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseFloatOrZero(value string) float64 {
|
|
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func actionNonce(action map[string]any) (int64, error) {
|
|
raw, ok := action["nonce"]
|
|
if !ok {
|
|
return 0, fmt.Errorf("missing nonce")
|
|
}
|
|
switch value := raw.(type) {
|
|
case float64:
|
|
return int64(value), nil
|
|
case int64:
|
|
return value, nil
|
|
case json.Number:
|
|
return value.Int64()
|
|
case string:
|
|
return strconv.ParseInt(value, 10, 64)
|
|
default:
|
|
return 0, fmt.Errorf("invalid nonce")
|
|
}
|
|
}
|