A minimal Go implementation of the Hyperliquid API for E2E testing without requiring the real Hyperliquid service to be online or having USDT available.
- Signature validation and wallet isolation - Recovers wallet addresses from ECDSA signatures for proper multi-wallet testing
- In-memory order state - Tracks orders for realistic responses
- WebSocket support - Real-time order updates and market data (BBO) with wallet-based filtering
- Unlimited requests - No rate limiting
- Simple responses - Returns success for all valid operations
git clone https://github.com/recomma/hyperliquid-mock.git
cd hyperliquid-mock
go build -o hyperliquid-mockgo get github.com/recomma/hyperliquid-mockThen import in your tests:
import "github.com/recomma/hyperliquid-mock/server"./hyperliquid-mock -addr :8080Or using go run:
go run main.go -addr :8080The server accepts the following flags:
-addr- Server address (default::8080)
Point your Recomma application to use the mock server by setting:
export HYPERLIQUIDURL="http://localhost:8080"Or in your secrets configuration file.
The upstream-api-docs/ directory contains documentation for the real Hyperliquid API. This is provided as a reference for LLMs and developers who want to expand the mock server's capabilities. The mock server currently implements only a subset of the full API.
Exchange endpoint (/exchange):
order- Order creationcancel/cancelByCloid- Order cancellationmodify- Single order modificationbatchModify- Batch order modifications
Info endpoint (/info):
orderStatus- Query order by OID or CLOIDmetaAndAssetCtxs- Perpetual futures metadataspotMetaAndAssetCtxs- Spot trading metadatameta- Simplified perpetual metadataspotMeta- Simplified spot metadata
All other action types return a generic success response but do not implement real functionality.
Real-time data streaming for order updates and market data. See WEBSOCKET.md for detailed documentation.
Supported subscriptions:
orderUpdates- Real-time order status updatesl2Book/bbo- Best bid/offer market data
Example connection:
const ws = new WebSocket('ws://localhost:8080/ws');
ws.onopen = () => {
ws.send(JSON.stringify({
method: 'subscribe',
subscription: { type: 'orderUpdates', user: '0x1234...' }
}));
};
ws.onmessage = (event) => {
const update = JSON.parse(event.data);
console.log('Order update:', update);
};The mock server implements proper wallet isolation to support multi-wallet testing scenarios. Each order is tagged with the wallet address recovered from its ECDSA signature, ensuring that:
- WebSocket subscriptions only receive updates for orders belonging to the subscribed wallet
- Order queries only return orders owned by the requesting wallet
- Different wallets can operate independently without interference
The server recovers wallet addresses from ECDSA signatures using the same EIP-712 signing format as the real Hyperliquid API:
- Msgpack-encodes the action with sorted keys
- Appends nonce, vault address (optional), and expiration timestamp (optional)
- Creates an EIP-712 typed data structure with "Agent" phantom type
- Recovers the public key from the signature
- Derives the Ethereum address
This allows the mock server to properly isolate orders by wallet without requiring manual configuration.
// Create two different wallets
privateKey1, _ := crypto.GenerateKey()
privateKey2, _ := crypto.GenerateKey()
wallet1 := crypto.PubkeyToAddress(privateKey1.PublicKey).Hex()
wallet2 := crypto.PubkeyToAddress(privateKey2.PublicKey).Hex()
// Create exchanges for each wallet
exchange1 := hyperliquid.NewExchange(ctx, privateKey1, ts.URL(), nil, "", wallet1, nil)
exchange2 := hyperliquid.NewExchange(ctx, privateKey2, ts.URL(), nil, "", wallet2, nil)
// Orders are automatically isolated by wallet
exchange1.Order(ctx, order1, nil) // Only visible to wallet1
exchange2.Order(ctx, order2, nil) // Only visible to wallet2See server/integration_test.go for complete multi-wallet test examples.
Handles trading actions:
- Order creation
- Order modification
- Order cancellation
Example request (order creation):
{
"action": {
"type": "order",
"orders": [{
"coin": "ETH",
"is_buy": true,
"sz": 1.5,
"limit_px": 3000.00,
"reduce_only": false,
"order_type": {"limit": {"tif": "Gtc"}},
"cloid": "00000000000000000000000000000001"
}]
},
"nonce": 1234567890,
"signature": {
"r": "0x...",
"s": "0x...",
"v": 27
}
}Example response:
{
"status": "ok",
"response": {
"type": "order",
"data": {
"statuses": [
{
"resting": {
"oid": 1000001
}
}
]
}
}
}Handles queries:
orderStatus- Query order status by OID or CLOIDmetaAndAssetCtxs- Get perpetual futures metadataspotMetaAndAssetCtxs- Get spot trading metadata
Example request (order status):
{
"type": "orderStatus",
"oid": 1000001
}Example response:
{
"status": "order",
"order": {
"order": {
"coin": "ETH",
"side": "B",
"limitPx": "3000.00",
"sz": "1.5",
"oid": 1000001,
"timestamp": 1234567890000,
"origSz": "1.5",
"cloid": "00000000000000000000000000000001"
},
"status": "open",
"statusTimestamp": 1234567890000
}
}Health check endpoint.
Example response:
{
"status": "healthy",
"time": "1234567890"
}The mock server can be embedded directly in your Go tests with automatic request capture and inspection.
import (
"context"
"crypto/ecdsa"
"testing"
"github.com/ethereum/go-ethereum/crypto"
"github.com/recomma/hyperliquid-mock/server"
"github.com/sonirico/go-hyperliquid"
)
func TestMyHyperliquidCode(t *testing.T) {
// Create isolated test server (auto-cleanup)
ts := server.NewTestServer(t)
ctx := context.Background()
// Configure Hyperliquid client to use the mock
privateKey, _ := crypto.HexToECDSA("your-private-key-hex")
pub := privateKey.Public()
pubECDSA, _ := pub.(*ecdsa.PublicKey)
walletAddr := crypto.PubkeyToAddress(*pubECDSA).Hex()
exchange := hyperliquid.NewExchange(
ctx,
privateKey,
ts.URL(), // Use mock server URL
nil, // meta
"", // vaultAddr (empty for non-vault)
walletAddr,
nil, // spotMeta
)
// Run your test code
// CLOID must be exactly 32 hex characters (16 bytes)
token := make([]byte, 16)
rand.Read(token)
cloid := hex.EncodeToString(token)
status, err := exchange.Order(ctx, hyperliquid.CreateOrderRequest{
Coin: "ETH",
IsBuy: true,
Size: 1.0,
Price: 3000.0,
OrderType: hyperliquid.OrderType{
Limit: &hyperliquid.LimitOrderType{Tif: hyperliquid.TifGtc},
},
ClientOrderID: &cloid,
}, nil)
// Inspect what was sent to the mock server
requests := ts.GetExchangeRequests()
if len(requests) != 1 {
t.Fatalf("Expected 1 request, got %d", len(requests))
}
// Check server state
order, exists := ts.GetOrder(cloid)
if !exists {
t.Fatal("Order not found")
}
if order.Status != "open" {
t.Errorf("Expected status open, got %s", order.Status)
}
}You can provide your own slog logger to the test
server to observe every request and response as they flow through the mock. This
is especially useful when running with DEBUG level logging:
import (
"context"
"crypto/ecdsa"
"log/slog"
"os"
"testing"
"github.com/ethereum/go-ethereum/crypto"
"github.com/recomma/hyperliquid-mock/server"
"github.com/sonirico/go-hyperliquid"
)
func TestMyHyperliquidCode(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ts := server.NewTestServer(t, server.WithLogger(logger))
defer ts.Close()
// ... use ts in your tests ...
}Each test gets its own server instance on a random port with isolated request history:
func TestOrderCreation(t *testing.T) {
ts := server.NewTestServer(t)
// ts.RequestCount() == 0
// Make requests...
}
func TestOrderCancellation(t *testing.T) {
ts := server.NewTestServer(t)
// Completely separate from TestOrderCreation
// ts.RequestCount() == 0
}Capture and inspect all requests sent to the mock:
// Get typed exchange requests
exchangeReqs := ts.GetExchangeRequests()
// Get typed info requests
infoReqs := ts.GetInfoRequests()
// Get raw requests with headers/body
rawReqs := ts.GetRequests()
for _, req := range rawReqs {
fmt.Println(req.Method, req.Path)
fmt.Println(string(req.Body))
fmt.Println(req.Headers)
}Check the server's internal order state:
// Get order by CLOID (must be the exact CLOID used when creating the order)
order, exists := ts.GetOrder("00000000000000000000000000000001")
if exists {
fmt.Println(order.Order.Coin) // "ETH"
fmt.Println(order.Status) // "open"
fmt.Println(order.Order.Oid) // 1000001
}
// Get order by OID
order, exists := ts.GetOrderByOid(1000001)The FillOrder helper mutates the in-memory order state that powers /info
responses, letting you simulate trade executions directly from your tests.
// Simulate a full fill at 3025.50 (remaining size defaults to zero)
if err := ts.FillOrder(cloid, 3025.50); err != nil {
t.Fatalf("FillOrder failed: %v", err)
}
// Later, /info orderStatus will report the order as filled with size 0
order, _ := ts.GetOrder(cloid)
fmt.Println(order.Status) // "filled"
fmt.Println(order.Order.Sz) // "0"
fmt.Println(order.Order.LimitPx) // "3025.5"
// Simulate a partial fill by specifying the filled quantity
if err := ts.FillOrder(cloid, 2999.25, server.WithFillSize(0.4)); err != nil {
t.Fatalf("FillOrder failed: %v", err)
}
order, _ = ts.GetOrder(cloid)
fmt.Println(order.Status) // "open" (still has remaining size)
fmt.Println(order.Order.Sz) // "0.6" (remaining amount)Clear captured requests between test phases:
// Phase 1: Setup
makeOrder(ts, "ETH", 1.0)
assert.Equal(t, 1, ts.RequestCount())
// Clear history
ts.ClearRequests()
assert.Equal(t, 0, ts.RequestCount())
// Phase 2: Test actual behavior
makeOrder(ts, "BTC", 0.5)
assert.Equal(t, 1, ts.RequestCount())See server/integration_test.go for complete working examples including:
- Testing with real go-hyperliquid library
- Order creation, modification, cancellation
- Order status queries
- Parallel test safety
- Request inspection patterns
You can also test the mock server manually using curl:
# Health check
curl http://localhost:8080/health
# Create an order (note: CLOID must be 32 hex characters)
curl -X POST http://localhost:8080/exchange \
-H "Content-Type: application/json" \
-d '{
"action": {
"type": "order",
"orders": [{
"coin": "ETH",
"is_buy": true,
"sz": 1.0,
"limit_px": 3000.00,
"cloid": "00000000000000000000000000000001"
}]
},
"nonce": 123,
"signature": {"r": "0x", "s": "0x", "v": 27}
}'
# Query metadata
curl -X POST http://localhost:8080/info \
-H "Content-Type: application/json" \
-d '{"type": "metaAndAssetCtxs"}'- No real order matching - Orders are just stored in memory, no actual trading logic
- Limited WebSocket subscriptions - Only orderUpdates and l2Book/bbo (no trades, candles, etc.)
- Simplified responses - Some fields may be omitted or simplified compared to production API
- No persistence - All state is lost when the server restarts
- No rate limiting - Unlimited requests accepted
- Testnet behavior - Signature recovery uses testnet EIP-712 domain (chainId: 1337)
hyperliquid-mock/
├── main.go # Binary entry point
├── go.mod # Go module file
├── go.sum # Go dependencies
├── README.md # This file
├── WEBSOCKET.md # WebSocket API documentation
├── upstream-api-docs/ # Real Hyperliquid API reference (for expansion)
│ ├── exchange-endpoint.md
│ ├── info-endpoint.md
│ ├── perpetuals.md
│ ├── spot.md
│ ├── websocket.md
│ └── subscriptions.md
└── server/
├── server.go # HTTP server setup
├── handlers.go # Request handlers
├── handlers_test.go # Handler unit tests
├── signature_recovery.go # ECDSA signature recovery for wallet isolation
├── websocket.go # WebSocket connection & subscription management
├── websocket_example_test.go # WebSocket usage examples
├── types.go # Request/response types
├── state.go # In-memory state management
├── testserver.go # Test helpers & request capture
├── testserver_test.go # TestServer usage examples
└── integration_test.go # Full integration tests with go-hyperliquid
- Add types to
server/types.go - Implement handler in
server/handlers.go - Register route in
server/server.go
This is a testing utility for the Recomma project.