diff --git a/README.md b/README.md index 91c1c01..8aac05f 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ A minimal Go implementation of the Hyperliquid API for E2E testing without requi ## Features -- **No authentication validation** - Accepts all requests without signature verification +- **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) +- **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 @@ -110,6 +110,47 @@ ws.onmessage = (event) => { }; ``` +## Wallet Isolation + +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 + +### Signature Recovery + +The server recovers wallet addresses from ECDSA signatures using the same EIP-712 signing format as the real Hyperliquid API: + +1. Msgpack-encodes the action with sorted keys +2. Appends nonce, vault address (optional), and expiration timestamp (optional) +3. Creates an EIP-712 typed data structure with "Agent" phantom type +4. Recovers the public key from the signature +5. Derives the Ethereum address + +This allows the mock server to properly isolate orders by wallet without requiring manual configuration. + +### Testing Multi-Wallet Scenarios + +```go +// 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 wallet2 +``` + +See `server/integration_test.go` for complete multi-wallet test examples. + ### POST /exchange Handles trading actions: @@ -129,7 +170,7 @@ Handles trading actions: "limit_px": 3000.00, "reduce_only": false, "order_type": {"limit": {"tif": "Gtc"}}, - "cloid": "0x1234..." + "cloid": "00000000000000000000000000000001" }] }, "nonce": 1234567890, @@ -188,7 +229,7 @@ Handles queries: "oid": 1000001, "timestamp": 1234567890000, "origSz": "1.5", - "cloid": "0x1234..." + "cloid": "00000000000000000000000000000001" }, "status": "open", "statusTimestamp": 1234567890000 @@ -247,7 +288,10 @@ func TestMyHyperliquidCode(t *testing.T) { ) // Run your test code - cloid := "my-cloid" + // 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, @@ -266,7 +310,7 @@ func TestMyHyperliquidCode(t *testing.T) { } // Check server state - order, exists := ts.GetOrder("my-cloid") + order, exists := ts.GetOrder(cloid) if !exists { t.Fatal("Order not found") } @@ -347,8 +391,8 @@ for _, req := range rawReqs { Check the server's internal order state: ```go -// Get order by CLOID -order, exists := ts.GetOrder("my-cloid-123") +// 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" @@ -421,7 +465,7 @@ You can also test the mock server manually using curl: # Health check curl http://localhost:8080/health -# Create an order +# Create an order (note: CLOID must be 32 hex characters) curl -X POST http://localhost:8080/exchange \ -H "Content-Type: application/json" \ -d '{ @@ -432,7 +476,7 @@ curl -X POST http://localhost:8080/exchange \ "is_buy": true, "sz": 1.0, "limit_px": 3000.00, - "cloid": "test-order-1" + "cloid": "00000000000000000000000000000001" }] }, "nonce": 123, @@ -447,12 +491,12 @@ curl -X POST http://localhost:8080/info \ ## Limitations -- **No signature validation** - All requests are accepted regardless of signature -- **No real order matching** - Orders are just stored in memory +- **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 +- **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) ## Development @@ -476,6 +520,7 @@ hyperliquid-mock/ ├── 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 diff --git a/WEBSOCKET.md b/WEBSOCKET.md index 0294689..9124eb6 100644 --- a/WEBSOCKET.md +++ b/WEBSOCKET.md @@ -324,17 +324,20 @@ This implementation follows the official Hyperliquid WebSocket API specification - `/upstream-api-docs/subscriptions.md` Key differences from production API: -- User tracking simplified (uses placeholder in mock) - BBO updates are manual/on-demand (vs. real-time market data) - Limited to orderUpdates and l2Book/bbo subscriptions -- No authentication required +- Uses testnet EIP-712 domain for signature verification (chainId: 1337) + +## Implemented Features + +- **Wallet-based order filtering** - Orders are automatically isolated by wallet address +- **Signature recovery** - ECDSA signature verification extracts wallet addresses +- **Real-time order updates** - WebSocket broadcasts filtered by wallet ownership ## Future Enhancements Potential additions (not currently in scope): - Additional subscription types (trades, candles, userFills, etc.) - Automatic periodic BBO updates -- User-based order filtering -- WebSocket authentication - Connection heartbeat/ping-pong - Reconnection handling diff --git a/go.mod b/go.mod index c2444ba..ea8d027 100644 --- a/go.mod +++ b/go.mod @@ -3,32 +3,38 @@ module github.com/recomma/hyperliquid-mock go 1.25.0 require ( - github.com/ethereum/go-ethereum v1.16.5 - github.com/sonirico/go-hyperliquid v0.16.1 + github.com/ethereum/go-ethereum v1.16.7 + github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/go-msgpack v0.5.5 + github.com/sonirico/go-hyperliquid v0.21.0 ) require ( + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect github.com/consensys/gnark-crypto v0.19.0 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect github.com/elastic/go-windows v1.0.2 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/holiman/uint256 v1.3.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/sonirico/vago v0.9.0 // indirect github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/supranational/blst v0.3.16 // indirect github.com/valyala/fastjson v1.6.4 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect @@ -40,5 +46,6 @@ require ( golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect howett.net/plist v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index c60e512..248a78d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= @@ -27,6 +29,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3 github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0= github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= +github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ= +github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -42,11 +46,17 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= @@ -85,8 +95,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/sonirico/go-hyperliquid v0.16.1 h1:s7IvY8BNSm0zhvgt+fEWykAEufIeZN6jZjPCs20qOj8= -github.com/sonirico/go-hyperliquid v0.16.1/go.mod h1:m3AY4P0ngPt7pRqovV1pt4E5dkzDLoPAJ7Hr7UHf4tY= +github.com/sonirico/go-hyperliquid v0.21.0 h1:IXoZEcGiVL/h4TgJrOuMaj9ptM+e7vW4q+MYJesVG5U= +github.com/sonirico/go-hyperliquid v0.21.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w= github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo= github.com/sonirico/vago v0.9.0/go.mod h1:fZxV1RzMe2eaZokbbDvuyoOzG3YapzqRQoOiD9VyJH0= github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY= diff --git a/server/handlers.go b/server/handlers.go index f4b4c6b..1dbaa9e 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -42,6 +42,8 @@ func NewHandler(opts ...Option) *Handler { } } +// recoverWalletFromSignature is implemented in signature_recovery.go + // HandleExchange handles POST /exchange requests func (h *Handler) HandleExchange(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -58,6 +60,17 @@ func (h *Handler) HandleExchange(w http.ResponseWriter, r *http.Request) { h.logger.Debug("exchange request received", "request", req) + // Extract wallet address from signature + walletAddr, err := h.recoverWalletFromSignature(&req) + if err != nil { + h.logger.Debug("signature recovery failed, order will not be wallet-isolated", "error", err) + // For backward compatibility, allow requests without valid signatures + // Orders created without a wallet will be accessible to all queries + walletAddr = "" + } else { + h.logger.Info("recovered wallet address from signature", "wallet", walletAddr) + } + // Parse the action to determine the operation type actionMap, ok := req.Action.(map[string]interface{}) if !ok { @@ -70,11 +83,11 @@ func (h *Handler) HandleExchange(w http.ResponseWriter, r *http.Request) { if order, ok := actionMap["type"].(string); ok { switch order { case "order": - response = h.handleOrder(actionMap) + response = h.handleOrder(actionMap, walletAddr) case "cancel", "cancelByCloid": response = h.handleCancel(actionMap) case "modify": - response = h.handleModify(actionMap) + response = h.handleModify(actionMap, walletAddr) case "batchModify": response = h.handleBatchModify(actionMap) default: @@ -83,7 +96,7 @@ func (h *Handler) HandleExchange(w http.ResponseWriter, r *http.Request) { } else { // Try to detect action type from the structure if _, hasOrders := actionMap["orders"]; hasOrders { - response = h.handleOrder(actionMap) + response = h.handleOrder(actionMap, walletAddr) } else if _, hasCancels := actionMap["cancels"]; hasCancels { response = h.handleCancel(actionMap) } else if _, hasModifies := actionMap["modifies"]; hasModifies { @@ -100,7 +113,7 @@ func (h *Handler) HandleExchange(w http.ResponseWriter, r *http.Request) { } // handleOrder processes order creation/modification -func (h *Handler) handleOrder(action map[string]interface{}) ExchangeResponse { +func (h *Handler) handleOrder(action map[string]interface{}, walletAddr string) ExchangeResponse { // Extract order details var statuses []OrderStatusResponse @@ -108,12 +121,12 @@ func (h *Handler) handleOrder(action map[string]interface{}) ExchangeResponse { if orders, ok := action["orders"].([]interface{}); ok { for _, o := range orders { orderMap, _ := o.(map[string]interface{}) - status := h.processOrder(orderMap) + status := h.processOrder(orderMap, walletAddr) statuses = append(statuses, status) } } else { // Single order - status := h.processOrder(action) + status := h.processOrder(action, walletAddr) statuses = append(statuses, status) } @@ -129,7 +142,7 @@ func (h *Handler) handleOrder(action map[string]interface{}) ExchangeResponse { } // processOrder creates or modifies a single order -func (h *Handler) processOrder(orderMap map[string]interface{}) OrderStatusResponse { +func (h *Handler) processOrder(orderMap map[string]interface{}, walletAddr string) OrderStatusResponse { // Handle both full field names and abbreviated format // Abbreviated: a=asset, b=is_buy, c=cloid, o=oid, p=price, s=size var coin string @@ -230,7 +243,7 @@ func (h *Handler) processOrder(orderMap map[string]interface{}) OrderStatusRespo cloid = fmt.Sprintf("mock-%d", time.Now().UnixNano()) } - newOid := h.state.CreateOrder(cloid, coin, side, pxStr, szStr) + newOid := h.state.CreateOrder(cloid, coin, side, pxStr, szStr, walletAddr) return OrderStatusResponse{ Resting: &RestingStatus{Oid: newOid, Cloid: &cloid}, @@ -272,7 +285,7 @@ func (h *Handler) mapAssetIndexToCoin(index int) string { } // handleModify processes a single order modification -func (h *Handler) handleModify(action map[string]interface{}) ExchangeResponse { +func (h *Handler) handleModify(action map[string]interface{}, walletAddr string) ExchangeResponse { // Extract oid (can be numeric or hex string) var oid int64 var hasOid bool @@ -331,7 +344,7 @@ func (h *Handler) handleModify(action map[string]interface{}) ExchangeResponse { orderMap["o"] = float64(oid) // Process the modification - status := h.processOrder(orderMap) + status := h.processOrder(orderMap, walletAddr) return ExchangeResponse{ Status: "ok", @@ -511,24 +524,21 @@ func (h *Handler) HandleInfo(w http.ResponseWriter, r *http.Request) { // handleOrderStatus queries order status by cloid or oid func (h *Handler) handleOrderStatus(req InfoRequest) OrderQueryResult { - var order *OrderDetail - var exists bool + var ( + order *OrderDetail + exists bool + ) - if req.Oid != nil && !req.Oid.Valid() && req.Cloid == nil { - if raw := req.Oid.Raw(); raw != "" { - req.Cloid = &raw - } + if req.Oid == nil && req.User == "" { + return OrderQueryResult{Status: "unknown_cloid"} } - // Try to query by OID first - if req.Oid != nil && req.Oid.Valid() { + switch { + case req.Oid != nil && req.Oid.Raw() == "" && req.Oid.Valid(): order, exists = h.state.GetOrderByOid(req.Oid.Int64()) - } else if req.Cloid != nil && *req.Cloid != "" { - // Query by CLOID - order, exists = h.state.GetOrder(*req.Cloid) - } else if req.User != "" { - // In a real implementation, we'd filter by user - // For the mock, we just return unknown + case req.Oid != nil && req.Oid.Raw() != "": + order, exists = h.state.GetOrder(req.Oid.Raw()) + case req.User != "": return OrderQueryResult{Status: "unknown_cloid"} } @@ -536,10 +546,11 @@ func (h *Handler) handleOrderStatus(req InfoRequest) OrderQueryResult { return OrderQueryResult{Status: "unknown_cloid"} } - return OrderQueryResult{ - Status: "order", - Order: order, + if order.Order.User != req.User { + h.logger.Debug("order present but user does not match", slog.String("order.User", order.Order.User), slog.String("req.User", req.User)) + return OrderQueryResult{Status: "unknown_cloid"} } + return OrderQueryResult{Status: "order", Order: order} } // handleMetaAndAssetCtxs returns mock perpetual futures metadata diff --git a/server/handlers_test.go b/server/handlers_test.go index b13dc29..3788e5e 100644 --- a/server/handlers_test.go +++ b/server/handlers_test.go @@ -2,12 +2,89 @@ package server import ( "bytes" + "crypto/ecdsa" "encoding/json" "net/http" "net/http/httptest" "testing" + + "github.com/ethereum/go-ethereum/crypto" + hyperliquid "github.com/sonirico/go-hyperliquid" + "github.com/stretchr/testify/require" ) +const sampleOrderActionJSON = `{ + "grouping": "na", + "type": "order", + "builder": { "b": "mock-builder", "f": 1 }, + "orders": [ + { + "a": 1, + "b": true, + "p": "3200.5", + "s": "0.75", + "r": false, + "c": "00000000000000000000000000000001", + "t": { + "limit": { "tif": "GTC" } + } + } + ] +}` + +// TestRecoverWalletFromSignature ensures we can recover the wallet address for a +// signed request using the deterministic msgpack encoding path. +func TestRecoverWalletFromSignature(t *testing.T) { + handler := NewHandler() + + privKey, err := crypto.GenerateKey() + require.NoError(t, err) + expectedWallet := crypto.PubkeyToAddress(privKey.PublicKey).Hex() + + var action map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(sampleOrderActionJSON), &action)) + + const nonce int64 = 42 + + signature := signActionForTest(t, privKey, action, nonce) + req := &ExchangeRequest{ + Action: action, + Nonce: nonce, + } + req.Signature.R = signature.R + req.Signature.S = signature.S + req.Signature.V = signature.V + + wallet, err := handler.recoverWalletFromSignature(req) + require.NoError(t, err) + require.Equal(t, expectedWallet, wallet) +} + +type testSignature struct { + R string + S string + V int +} + +func signActionForTest(t *testing.T, key *ecdsa.PrivateKey, action map[string]interface{}, nonce int64) testSignature { + t.Helper() + + body, err := json.Marshal(action) + require.NoError(t, err) + + var typed hyperliquid.OrderAction + require.NoError(t, json.Unmarshal(body, &typed)) + + sig, err := hyperliquid.SignL1Action(key, typed, "", nonce, nil, false) + require.NoError(t, err) + + return testSignature{ + R: sig.R, + S: sig.S, + V: sig.V, + } +} + // TestHandleInfo_Meta tests the /info endpoint with type "meta" func TestHandleInfo_Meta(t *testing.T) { handler := NewHandler() @@ -143,7 +220,7 @@ func TestProcessOrderRejectsLowPriceBtcIOC(t *testing.T) { }, } - status := handler.processOrder(orderMap) + status := handler.processOrder(orderMap, "0xwallet1") if status.Resting != nil { t.Fatalf("expected no resting order, got %#v", status.Resting) @@ -174,7 +251,7 @@ func TestProcessOrderAllowsOtherIocOrders(t *testing.T) { }, } - status := handler.processOrder(orderMap) + status := handler.processOrder(orderMap, "0xwallet1") if status.Error != nil { t.Fatalf("expected no error, got %q", *status.Error) diff --git a/server/integration_test.go b/server/integration_test.go index d1d13c8..f3ad2ef 100644 --- a/server/integration_test.go +++ b/server/integration_test.go @@ -3,12 +3,16 @@ package server_test import ( "context" "crypto/ecdsa" + "crypto/rand" "fmt" + "log/slog" + "os" "testing" "github.com/ethereum/go-ethereum/crypto" "github.com/recomma/hyperliquid-mock/server" "github.com/sonirico/go-hyperliquid" + "github.com/stretchr/testify/require" ) // TestIntegrationWithGoHyperliquid demonstrates using the mock server with the real go-hyperliquid library @@ -18,14 +22,13 @@ func TestIntegrationWithGoHyperliquid(t *testing.T) { ctx := context.Background() // Create a test private key - privateKey, err := crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") - if err != nil { - t.Fatalf("Failed to create private key: %v", err) - } + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) - // Get wallet address from private key pub := privateKey.Public() - pubECDSA, _ := pub.(*ecdsa.PublicKey) + pubECDSA, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok, "expected ECDSA public key") + // Get wallet address from private key walletAddr := crypto.PubkeyToAddress(*pubECDSA).Hex() t.Logf("Creating exchange client for wallet: %s", walletAddr) @@ -51,7 +54,10 @@ func TestIntegrationWithGoHyperliquid(t *testing.T) { } // Create an order using the real go-hyperliquid library - cloid := "test-order-1" + // CLOID must be exactly 32 hex characters (16 bytes) + token := make([]byte, 16) + rand.Read(token) + cloid := fmt.Sprintf("0x%x", token) orderReq := hyperliquid.CreateOrderRequest{ Coin: "ETH", IsBuy: true, @@ -110,21 +116,125 @@ func TestIntegrationWithGoHyperliquid(t *testing.T) { } } +// TestIntegrationWithGoHyperliquid demonstrates using the mock server with the real go-hyperliquid library +func TestIntegrationWithGoHyperliquidWithNumericOID(t *testing.T) { + // Create isolated test server + ts := server.NewTestServer(t) + ctx := context.Background() + + // Create a test private key + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + pub := privateKey.Public() + pubECDSA, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok, "expected ECDSA public key") + // Get wallet address from private key + walletAddr := crypto.PubkeyToAddress(*pubECDSA).Hex() + + t.Logf("Creating exchange client for wallet: %s", walletAddr) + t.Logf("Mock server URL: %s", ts.URL()) + + // Create exchange client pointing to mock server + // This will internally call NewInfo which fetches meta from the server + exchange := hyperliquid.NewExchange( + ctx, + privateKey, + ts.URL(), // Point to our mock server! + nil, // Meta will be fetched from mock + "", // vaultAddr (empty for non-vault accounts) + walletAddr, + nil, // SpotMeta will be fetched from mock + ) + + // Log what requests were made during exchange creation + infoReqs := ts.GetInfoRequests() + t.Logf("Info requests made during NewExchange: %d", len(infoReqs)) + for i, req := range infoReqs { + t.Logf(" Request %d: type=%s", i, req.Type) + } + + // Create an order using the real go-hyperliquid library + orderReq := hyperliquid.CreateOrderRequest{ + Coin: "ETH", + IsBuy: true, + Size: 1.5, + Price: 3000.0, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifGtc, + }, + }, + } + + status, err := exchange.Order(ctx, orderReq, nil) + if err != nil { + t.Fatalf("Failed to create order: %v", err) + } + + // Verify we got a response + if status.Resting == nil { + t.Error("Expected resting order status") + } + + // Now inspect what was actually sent to the mock server + exchangeReqs := ts.GetExchangeRequests() + if len(exchangeReqs) != 1 { + t.Fatalf("Expected 1 exchange request, got %d", len(exchangeReqs)) + } + + // Verify the request structure + req := exchangeReqs[0] + if req.Nonce == 0 { + t.Error("Expected non-zero nonce") + } + + if req.Signature.R == "" || req.Signature.S == "" { + t.Error("Expected signature to be present") + } + + // Verify order was stored in mock server state + order, exists := ts.GetOrder(*status.Resting.ClientID) + if !exists { + t.Fatal("Order not found in server state") + } + + if order.Order.Coin != "ETH" { + t.Errorf("Expected coin ETH, got %s", order.Order.Coin) + } + + if order.Order.Side != "B" { + t.Errorf("Expected side B (buy), got %s", order.Order.Side) + } + + if order.Status != "open" { + t.Errorf("Expected status open, got %s", order.Status) + } +} + // TestOrderModificationWithGoHyperliquid demonstrates testing order modifications func TestOrderModificationWithGoHyperliquid(t *testing.T) { ts := server.NewTestServer(t) ctx := context.Background() - privateKey, _ := crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") + // Create a test private key + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + pub := privateKey.Public() - pubECDSA, _ := pub.(*ecdsa.PublicKey) + pubECDSA, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok, "expected ECDSA public key") + // Get wallet address from private key walletAddr := crypto.PubkeyToAddress(*pubECDSA).Hex() exchange := hyperliquid.NewExchange(ctx, privateKey, ts.URL(), nil, "", walletAddr, nil) // Create initial order - cloid := "test-modify-order" - _, err := exchange.Order(ctx, hyperliquid.CreateOrderRequest{ + // CLOID must be exactly 32 hex characters (16 bytes) + token := make([]byte, 16) + rand.Read(token) + cloid := fmt.Sprintf("0x%x", token) + _, err = exchange.Order(ctx, hyperliquid.CreateOrderRequest{ Coin: "BTC", IsBuy: true, Size: 0.5, @@ -143,15 +253,12 @@ func TestOrderModificationWithGoHyperliquid(t *testing.T) { if !exists { t.Fatal("Order not found") } - oid := order.Order.Oid // Clear request history to focus on the modify ts.ClearRequests() - // Modify the order (use OID as hex string) - oidHex := fmt.Sprintf("0x%x", oid) _, err = exchange.ModifyOrder(ctx, hyperliquid.ModifyOrderRequest{ - Oid: oidHex, + Cloid: &hyperliquid.Cloid{Value: *order.Order.Cloid}, Order: hyperliquid.CreateOrderRequest{ Coin: "BTC", IsBuy: true, @@ -191,16 +298,24 @@ func TestOrderCancellationWithGoHyperliquid(t *testing.T) { ts := server.NewTestServer(t) ctx := context.Background() - privateKey, _ := crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") + // Create a test private key + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + pub := privateKey.Public() - pubECDSA, _ := pub.(*ecdsa.PublicKey) + pubECDSA, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok, "expected ECDSA public key") + // Get wallet address from private key walletAddr := crypto.PubkeyToAddress(*pubECDSA).Hex() exchange := hyperliquid.NewExchange(ctx, privateKey, ts.URL(), nil, "", walletAddr, nil) // Create an order - cloid := "test-cancel-order" - _, err := exchange.Order(ctx, hyperliquid.CreateOrderRequest{ + // CLOID must be exactly 32 hex characters (16 bytes) + token := make([]byte, 16) + rand.Read(token) + cloid := fmt.Sprintf("0x%x", token) + _, err = exchange.Order(ctx, hyperliquid.CreateOrderRequest{ Coin: "SOL", IsBuy: false, Size: 10.0, @@ -249,19 +364,28 @@ func TestOrderCancellationWithGoHyperliquid(t *testing.T) { // TestQueryOrderStatusWithGoHyperliquid demonstrates testing order status queries func TestQueryOrderStatusWithGoHyperliquid(t *testing.T) { - ts := server.NewTestServer(t) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ts := server.NewTestServer(t, server.WithLogger(logger)) ctx := context.Background() - privateKey, _ := crypto.HexToECDSA("0000000000000000000000000000000000000000000000000000000000000001") + // Create a test private key + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + pub := privateKey.Public() - pubECDSA, _ := pub.(*ecdsa.PublicKey) + pubECDSA, ok := pub.(*ecdsa.PublicKey) + require.True(t, ok, "expected ECDSA public key") + // Get wallet address from private key walletAddr := crypto.PubkeyToAddress(*pubECDSA).Hex() // Create exchange to make an order exchange := hyperliquid.NewExchange(ctx, privateKey, ts.URL(), nil, "", walletAddr, nil) - cloid := "test-query-order" - _, err := exchange.Order(ctx, hyperliquid.CreateOrderRequest{ + // CLOID must be exactly 32 hex characters (16 bytes) + token := make([]byte, 16) + rand.Read(token) + cloid := fmt.Sprintf("0x%x", token) + _, err = exchange.Order(ctx, hyperliquid.CreateOrderRequest{ Coin: "ARB", IsBuy: true, Size: 100.0, diff --git a/server/signature_recovery.go b/server/signature_recovery.go new file mode 100644 index 0000000..236ee7b --- /dev/null +++ b/server/signature_recovery.go @@ -0,0 +1,329 @@ +package server + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + hyperliquid "github.com/sonirico/go-hyperliquid" + "github.com/vmihailenco/msgpack/v5" +) + +// recoverWalletFromSignature attempts to recover the wallet address from an /exchange request. +func (h *Handler) recoverWalletFromSignature(req *ExchangeRequest) (string, error) { + if req == nil { + return "", fmt.Errorf("nil exchange request") + } + + msgHash, err := computeExchangeMessageHash(req) + if err != nil { + return "", err + } + + sigBytes, err := decodeExchangeSignature(req.Signature.R, req.Signature.S, req.Signature.V) + if err != nil { + return "", err + } + + pubKey, err := crypto.SigToPub(msgHash, sigBytes) + if err != nil { + return "", fmt.Errorf("failed to recover public key: %w", err) + } + + return crypto.PubkeyToAddress(*pubKey).Hex(), nil +} + +func computeExchangeMessageHash(req *ExchangeRequest) ([]byte, error) { + action, err := normalizeActionPayload(req.Action) + if err != nil { + return nil, fmt.Errorf("failed to normalize action: %w", err) + } + + actionHash, err := encodeActionHash(action, req.Nonce, req.VaultAddress, req.ExpiresAfter) + if err != nil { + return nil, err + } + + // Test server defaults to testnet behaviour. + const isMainnet = false + + phantomAgent := constructPhantomAgent(actionHash, isMainnet) + typedData := l1Payload(phantomAgent, isMainnet) + + domainSeparator, err := typedData.HashStruct("EIP712Domain", typedData.Domain.Map()) + if err != nil { + return nil, fmt.Errorf("failed to hash domain: %w", err) + } + + typedHash, err := hashStructLenient(typedData, typedData.PrimaryType, typedData.Message) + if err != nil { + return nil, fmt.Errorf("failed to hash typed data: %w", err) + } + + raw := []byte{0x19, 0x01} + raw = append(raw, domainSeparator...) + raw = append(raw, typedHash...) + return crypto.Keccak256(raw), nil +} + +func normalizeActionPayload(action interface{}) (interface{}, error) { + if action == nil { + return nil, fmt.Errorf("missing action payload") + } + + body, err := json.Marshal(action) + if err != nil { + return nil, fmt.Errorf("failed to marshal action: %w", err) + } + + var probe struct { + Type string `json:"type"` + } + if err := json.Unmarshal(body, &probe); err != nil { + return nil, fmt.Errorf("failed to inspect action type: %w", err) + } + + if probe.Type == "" { + var raw map[string]interface{} + if err := json.Unmarshal(body, &raw); err == nil { + switch { + case raw["orders"] != nil: + probe.Type = "order" + case raw["cancels"] != nil: + // Determine if cancels use cloinds or oids based on element shape. + if cancels, ok := raw["cancels"].([]interface{}); ok && len(cancels) > 0 { + if cancelEntry, _ := cancels[0].(map[string]interface{}); cancelEntry != nil { + if _, ok := cancelEntry["cloid"]; ok { + probe.Type = "cancelByCloid" + } else { + probe.Type = "cancel" + } + } + } + case raw["modifies"] != nil: + probe.Type = "batchModify" + case raw["order"] != nil: + probe.Type = "modify" + } + } + } + + switch probe.Type { + case "order": + var typed hyperliquid.OrderAction + if err := json.Unmarshal(body, &typed); err != nil { + return nil, fmt.Errorf("failed to decode order action: %w", err) + } + return typed, nil + case "modify": + var typed hyperliquid.ModifyAction + if err := json.Unmarshal(body, &typed); err != nil { + return nil, fmt.Errorf("failed to decode modify action: %w", err) + } + return typed, nil + case "batchModify": + var typed hyperliquid.BatchModifyAction + if err := json.Unmarshal(body, &typed); err != nil { + return nil, fmt.Errorf("failed to decode batchModify action: %w", err) + } + return typed, nil + case "cancel": + var typed hyperliquid.CancelAction + if err := json.Unmarshal(body, &typed); err != nil { + return nil, fmt.Errorf("failed to decode cancel action: %w", err) + } + return typed, nil + case "cancelByCloid": + var typed hyperliquid.CancelByCloidAction + if err := json.Unmarshal(body, &typed); err != nil { + return nil, fmt.Errorf("failed to decode cancelByCloid action: %w", err) + } + return typed, nil + default: + return nil, fmt.Errorf("unsupported action type %q", probe.Type) + } +} + +func encodeActionHash(action interface{}, nonce int64, vaultAddress *string, expiresAfter *int64) ([]byte, error) { + var buf bytes.Buffer + enc := msgpack.NewEncoder(&buf) + enc.UseCompactInts(true) + + if err := enc.Encode(action); err != nil { + return nil, fmt.Errorf("failed to msgpack encode action: %w", err) + } + data := convertStr16ToStr8(buf.Bytes()) + + if nonce < 0 { + return nil, fmt.Errorf("nonce cannot be negative: %d", nonce) + } + nonceBytes := make([]byte, 8) + binary.BigEndian.PutUint64(nonceBytes, uint64(nonce)) + data = append(data, nonceBytes...) + + vault := "" + if vaultAddress != nil { + vault = *vaultAddress + } + + if vault == "" { + data = append(data, 0x00) + } else { + addrBytes, err := addressToBytes(vault) + if err != nil { + return nil, err + } + data = append(data, 0x01) + data = append(data, addrBytes...) + } + + if expiresAfter != nil { + if *expiresAfter < 0 { + return nil, fmt.Errorf("expiresAfter cannot be negative: %d", *expiresAfter) + } + data = append(data, 0x00) + expBytes := make([]byte, 8) + binary.BigEndian.PutUint64(expBytes, uint64(*expiresAfter)) + data = append(data, expBytes...) + } + + return crypto.Keccak256(data), nil +} + +func convertStr16ToStr8(data []byte) []byte { + result := make([]byte, 0, len(data)) + for i := 0; i < len(data); { + b := data[i] + if b == 0xda && i+2 < len(data) { + length := (int(data[i+1]) << 8) | int(data[i+2]) + if length < 256 && i+3+length <= len(data) { + result = append(result, 0xd9, byte(length)) + i += 3 + result = append(result, data[i:i+length]...) + i += length + continue + } + } + result = append(result, b) + i++ + } + return result +} + +func addressToBytes(address string) ([]byte, error) { + address = strings.TrimPrefix(strings.TrimPrefix(address, "0x"), "0X") + if len(address)%2 == 1 { + address = "0" + address + } + + bytes, err := hex.DecodeString(address) + if err != nil { + return nil, fmt.Errorf("invalid vault address: %w", err) + } + if len(bytes) != 20 { + return nil, fmt.Errorf("vault address must be 20 bytes, got %d", len(bytes)) + } + + return bytes, nil +} + +func constructPhantomAgent(hash []byte, isMainnet bool) map[string]interface{} { + source := "b" + if isMainnet { + source = "a" + } + return map[string]interface{}{ + "source": source, + "connectionId": hash, + } +} + +func l1Payload(phantomAgent map[string]interface{}, isMainnet bool) apitypes.TypedData { + chainID := math.HexOrDecimal256(*big.NewInt(1337)) + return apitypes.TypedData{ + Domain: apitypes.TypedDataDomain{ + ChainId: &chainID, + Name: "Exchange", + Version: "1", + VerifyingContract: "0x0000000000000000000000000000000000000000", + }, + Types: apitypes.Types{ + "Agent": []apitypes.Type{ + {Name: "source", Type: "string"}, + {Name: "connectionId", Type: "bytes32"}, + }, + "EIP712Domain": []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + }, + PrimaryType: "Agent", + Message: phantomAgent, + } +} + +func hashStructLenient(typedData apitypes.TypedData, primaryType string, message map[string]interface{}) ([]byte, error) { + types := typedData.Types[primaryType] + filtered := make(map[string]interface{}, len(types)) + for _, t := range types { + if val, ok := message[t.Name]; ok { + filtered[t.Name] = val + } + } + return typedData.HashStruct(primaryType, filtered) +} + +func decodeExchangeSignature(rHex, sHex string, v int) ([]byte, error) { + rBytes, err := decodeSignatureComponent(rHex) + if err != nil { + return nil, fmt.Errorf("invalid signature R: %w", err) + } + sBytes, err := decodeSignatureComponent(sHex) + if err != nil { + return nil, fmt.Errorf("invalid signature S: %w", err) + } + + signature := make([]byte, 65) + copy(signature[32-len(rBytes):32], rBytes) + copy(signature[64-len(sBytes):64], sBytes) + + switch v { + case 27, 28: + signature[64] = byte(v - 27) + case 0, 1: + signature[64] = byte(v) + default: + return nil, fmt.Errorf("invalid signature recovery id: %d", v) + } + + return signature, nil +} + +func decodeSignatureComponent(value string) ([]byte, error) { + if value == "" { + return nil, fmt.Errorf("empty value") + } + + component := strings.TrimPrefix(strings.TrimPrefix(value, "0x"), "0X") + if len(component)%2 == 1 { + component = "0" + component + } + + bytes, err := hex.DecodeString(component) + if err != nil { + return nil, err + } + if len(bytes) > 32 { + return nil, fmt.Errorf("component too large (%d bytes)", len(bytes)) + } + return bytes, nil +} diff --git a/server/state.go b/server/state.go index 6949f84..72519fa 100644 --- a/server/state.go +++ b/server/state.go @@ -1,6 +1,7 @@ package server import ( + "strings" "sync" "sync/atomic" "time" @@ -28,7 +29,7 @@ func (s *State) SetWebSocketManager(wsm *WebSocketManager) { } // CreateOrder adds a new order to the state -func (s *State) CreateOrder(cloid string, coin string, side string, limitPx string, sz string) int64 { +func (s *State) CreateOrder(cloid string, coin string, side string, limitPx string, sz string, user string) int64 { s.mu.Lock() defer s.mu.Unlock() @@ -45,12 +46,14 @@ func (s *State) CreateOrder(cloid string, coin string, side string, limitPx stri Timestamp: now, OrigSz: sz, Cloid: &cloid, + User: user, }, Status: "open", StatusTimestamp: now, } - s.orders[cloid] = order + key := canonicalizeCloidKey(cloid) + s.orders[key] = order // Broadcast order update via WebSocket if s.wsm != nil { @@ -65,7 +68,8 @@ func (s *State) ModifyOrder(cloid string, limitPx string, sz string) (int64, boo s.mu.Lock() defer s.mu.Unlock() - order, exists := s.orders[cloid] + key := canonicalizeCloidKey(cloid) + order, exists := s.orders[key] if !exists { return 0, false } @@ -111,7 +115,8 @@ func (s *State) CancelOrder(cloid string) bool { s.mu.Lock() defer s.mu.Unlock() - order, exists := s.orders[cloid] + key := canonicalizeCloidKey(cloid) + order, exists := s.orders[key] if !exists { return false } @@ -155,7 +160,8 @@ func (s *State) GetOrder(cloid string) (*OrderDetail, bool) { s.mu.RLock() defer s.mu.RUnlock() - order, exists := s.orders[cloid] + key := canonicalizeCloidKey(cloid) + order, exists := s.orders[key] if !exists { return nil, false } @@ -179,3 +185,13 @@ func (s *State) GetOrderByOid(oid int64) (*OrderDetail, bool) { return nil, false } + +func canonicalizeCloidKey(cloid string) string { + if len(cloid) >= 2 { + prefix := cloid[:2] + if prefix == "0x" || prefix == "0X" { + return strings.ToLower(cloid[2:]) + } + } + return cloid +} diff --git a/server/testserver.go b/server/testserver.go index cc40094..11dce5b 100644 --- a/server/testserver.go +++ b/server/testserver.go @@ -342,7 +342,7 @@ func (ts *TestServer) FillOrder(cloid string, fillPrice float64, opts ...FillOpt state.mu.Lock() defer state.mu.Unlock() - order, exists := state.orders[cloid] + order, exists := state.orders[canonicalizeCloidKey(cloid)] if !exists { return fmt.Errorf("unknown cloid %s", cloid) } diff --git a/server/testserver_test.go b/server/testserver_test.go index ee19945..9d24bf3 100644 --- a/server/testserver_test.go +++ b/server/testserver_test.go @@ -3,7 +3,9 @@ package server_test import ( "bytes" "encoding/json" + "log/slog" "net/http" + "os" "testing" "time" @@ -174,7 +176,8 @@ func TestMultipleOrders(t *testing.T) { func TestTestServerFillOrder(t *testing.T) { t.Run("full fill", func(t *testing.T) { - ts := server.NewTestServer(t) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ts := server.NewTestServer(t, server.WithLogger(logger)) makeExchangeRequest(t, ts.URL(), "BTC", 1.0, 50000.0) cloid := extractCloid(t, ts) @@ -183,7 +186,12 @@ func TestTestServerFillOrder(t *testing.T) { t.Fatalf("FillOrder returned error: %v", err) } - result := queryOrderStatus(t, ts.URL(), cloid) + order, exists := ts.GetOrder(cloid) + if !exists { + t.Fatal("order not found after creation") + } + + result := queryOrderStatus(t, ts.URL(), order.Order.User, cloid) if result.Status != "order" { t.Fatalf("expected order status response, got %s", result.Status) @@ -209,7 +217,8 @@ func TestTestServerFillOrder(t *testing.T) { }) t.Run("partial fill", func(t *testing.T) { - ts := server.NewTestServer(t) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ts := server.NewTestServer(t, server.WithLogger(logger)) makeExchangeRequest(t, ts.URL(), "ETH", 1.0, 3000.0) cloid := extractCloid(t, ts) @@ -218,7 +227,12 @@ func TestTestServerFillOrder(t *testing.T) { t.Fatalf("FillOrder returned error: %v", err) } - result := queryOrderStatus(t, ts.URL(), cloid) + order, exists := ts.GetOrder(cloid) + if !exists { + t.Fatal("order not found after creation") + } + + result := queryOrderStatus(t, ts.URL(), order.Order.User, cloid) if result.Status != "order" { t.Fatalf("expected order status response, got %s", result.Status) @@ -349,12 +363,15 @@ func makeInfoRequest(t *testing.T, baseURL, reqType string) { } } -func queryOrderStatus(t *testing.T, baseURL, cloid string) server.OrderQueryResult { +func queryOrderStatus(t *testing.T, baseURL, user, cloid string) server.OrderQueryResult { t.Helper() payload := map[string]interface{}{ - "type": "orderStatus", - "cloid": cloid, + "type": "orderStatus", + "oid": cloid, + } + if user != "" { + payload["user"] = user } body, err := json.Marshal(payload) diff --git a/server/types.go b/server/types.go index 3235fab..5b4b4a4 100644 --- a/server/types.go +++ b/server/types.go @@ -8,9 +8,11 @@ import ( // ExchangeRequest represents a request to the /exchange endpoint type ExchangeRequest struct { - Action interface{} `json:"action"` - Nonce int64 `json:"nonce"` - Signature struct { + Action interface{} `json:"action"` + Nonce int64 `json:"nonce"` + VaultAddress *string `json:"vaultAddress,omitempty"` + ExpiresAfter *int64 `json:"expiresAfter,omitempty"` + Signature struct { R string `json:"r"` S string `json:"s"` V int `json:"v"` @@ -31,10 +33,9 @@ type ExchangeActionData struct { // InfoRequest represents a request to the /info endpoint type InfoRequest struct { - Type string `json:"type"` - User string `json:"user,omitempty"` - Oid *FlexibleOid `json:"oid,omitempty"` - Cloid *string `json:"cloid,omitempty"` + Type string `json:"type"` + User string `json:"user,omitempty"` + Oid *FlexibleOid `json:"oid,omitempty"` } // FlexibleOid captures order identifiers that can be provided either as raw @@ -138,6 +139,7 @@ type OrderInfo struct { Timestamp int64 `json:"timestamp"` OrigSz string `json:"origSz"` Cloid *string `json:"cloid,omitempty"` + User string `json:"user,omitempty"` // Wallet address that owns this order } // MetaUniverse represents a trading pair in the metadata diff --git a/server/websocket.go b/server/websocket.go index 8735f37..1f0af8d 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -65,6 +65,7 @@ type WsBasicOrder struct { Timestamp int64 `json:"timestamp"` OrigSz string `json:"origSz"` Cloid *string `json:"cloid,omitempty"` + User string `json:"user,omitempty"` // Wallet address that owns this order } // L2BookUpdate represents a market data update to broadcast @@ -328,6 +329,7 @@ func (wsm *WebSocketManager) BroadcastOrderUpdate(order *OrderDetail) { Timestamp: order.Order.Timestamp, OrigSz: order.Order.OrigSz, Cloid: order.Order.Cloid, + User: order.Order.User, }, Status: order.Status, StatusTimestamp: order.StatusTimestamp, @@ -345,22 +347,40 @@ func (wsm *WebSocketManager) BroadcastOrderUpdate(order *OrderDetail) { } // broadcastOrderUpdates broadcasts order updates to subscribed clients -// In the mock server, we broadcast to all orderUpdates subscribers +// Orders are filtered by wallet - each client only receives updates for their own orders +// Orders without a wallet (signature recovery failed) are broadcast to all subscribers for backward compatibility func (wsm *WebSocketManager) broadcastOrderUpdates() { for update := range wsm.orderUpdatesCh { wsm.mu.RLock() for conn, state := range wsm.connections { state.mu.RLock() - // In the mock, broadcast to anyone subscribed to orderUpdates - if state.orderUpdatesUser != "" { - state.mu.RUnlock() + subscribedUser := state.orderUpdatesUser + state.mu.RUnlock() + + // Skip if not subscribed to orderUpdates + if subscribedUser == "" { + continue + } + + // Determine if this update should be sent to this subscriber + shouldSend := false + if len(update.Orders) > 0 { + orderUser := update.Orders[0].Order.User + + // Send if: + // 1. Order has no wallet (signature recovery failed) - backward compatibility + // 2. Order wallet matches subscriber wallet - proper isolation + if orderUser == "" || orderUser == subscribedUser { + shouldSend = true + } + } + + if shouldSend { msg := map[string]interface{}{ "channel": "orderUpdates", "data": update.Orders, } wsm.sendJSON(conn, msg) - } else { - state.mu.RUnlock() } } wsm.mu.RUnlock()