From 85e36d21f78a512d5351b923fb1a907a6198a244 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:41:44 +0000 Subject: [PATCH 1/3] fix: normalize Ethereum addresses for case-insensitive comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem WebSocket clients intermittently failed to receive orderUpdates when multiple wallets were connected concurrently. The issue appeared to be a race condition but was actually a case-sensitive string comparison bug. ## Root Cause Ethereum addresses were compared using exact string matching: - Wallet recovery (via signature) returns checksummed addresses (e.g., "0xeb5Df7323c643f01b8C0643bE808a0e6486621e8") - WebSocket subscriptions may use lowercase addresses (e.g., "0xeb5df7323c643f01b8c0643be808a0e6486621e8") - String comparison: "0xeb5Df7..." != "0xeb5df7..." ❌ This caused order broadcasts to be filtered out incorrectly, appearing intermittent because different test wallets have different checksums. ## Solution Normalize all Ethereum addresses to lowercase before comparison, as Ethereum addresses are case-insensitive (EIP-55 checksum is optional). ## Changes - Added normalizeAddress() helper function to both websocket.go and handlers.go - Updated WebSocket subscription storage to normalize addresses (websocket.go:207) - Updated broadcast filtering to compare normalized addresses (websocket.go:383) - Updated order status queries to compare normalized addresses (handlers.go:551) - Added comprehensive test suite for address normalization ## Testing Added address_normalization_test.go with tests covering: - Identical addresses (lowercase, uppercase, mixed case) - EIP-55 checksummed vs non-checksummed addresses - Different addresses (should not match) - Empty addresses Fixes the intermittent WebSocket orderUpdates delivery issue in concurrent/parallel test execution scenarios. --- server/address_normalization_test.go | 71 ++++++++++++++++++++++++++++ server/handlers.go | 10 +++- server/websocket.go | 16 +++++-- 3 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 server/address_normalization_test.go diff --git a/server/address_normalization_test.go b/server/address_normalization_test.go new file mode 100644 index 0000000..a32184e --- /dev/null +++ b/server/address_normalization_test.go @@ -0,0 +1,71 @@ +package server + +import ( + "testing" +) + +// TestNormalizeAddress verifies address normalization for case-insensitive comparison +func TestNormalizeAddress(t *testing.T) { + tests := []struct { + name string + addr1 string + addr2 string + expected bool // should they be equal after normalization? + }{ + { + name: "identical lowercase addresses", + addr1: "0xeb5df7323c643f01b8c0643be808a0e6486621e8", + addr2: "0xeb5df7323c643f01b8c0643be808a0e6486621e8", + expected: true, + }, + { + name: "identical uppercase addresses", + addr1: "0XEB5DF7323C643F01B8C0643BE808A0E6486621E8", + addr2: "0XEB5DF7323C643F01B8C0643BE808A0E6486621E8", + expected: true, + }, + { + name: "mixed case vs lowercase (EIP-55 checksum)", + addr1: "0xeb5Df7323c643f01b8C0643bE808a0e6486621e8", // checksummed + addr2: "0xeb5df7323c643f01b8c0643be808a0e6486621e8", // lowercase + expected: true, + }, + { + name: "mixed case vs uppercase", + addr1: "0xeb5Df7323c643f01b8C0643bE808a0e6486621e8", // checksummed + addr2: "0XEB5DF7323C643F01B8C0643BE808A0E6486621E8", // uppercase + expected: true, + }, + { + name: "different addresses (lowercase)", + addr1: "0xeb5df7323c643f01b8c0643be808a0e6486621e8", + addr2: "0x628f0be408bdf24451a1c30c452abbb9cfb50a18", + expected: false, + }, + { + name: "different addresses (mixed case)", + addr1: "0xeb5Df7323c643f01b8C0643bE808a0e6486621e8", + addr2: "0x628f0bE408bdf24451a1c30C452abbB9cfb50A18", + expected: false, + }, + { + name: "empty addresses", + addr1: "", + addr2: "", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + norm1 := normalizeAddress(tt.addr1) + norm2 := normalizeAddress(tt.addr2) + equal := norm1 == norm2 + + if equal != tt.expected { + t.Errorf("normalizeAddress(%q) vs normalizeAddress(%q):\ngot equal=%v, want equal=%v\nnorm1=%q, norm2=%q", + tt.addr1, tt.addr2, equal, tt.expected, norm1, norm2) + } + }) + } +} diff --git a/server/handlers.go b/server/handlers.go index 1dbaa9e..14ebfcc 100644 --- a/server/handlers.go +++ b/server/handlers.go @@ -10,6 +10,12 @@ import ( "time" ) +// normalizeAddress converts an Ethereum address to lowercase for case-insensitive comparison. +// Ethereum addresses are case-insensitive; the mixed case (EIP-55 checksum) is optional validation. +func normalizeAddress(addr string) string { + return strings.ToLower(addr) +} + // Handler manages HTTP requests for the mock server type Handler struct { state *State @@ -546,7 +552,9 @@ func (h *Handler) handleOrderStatus(req InfoRequest) OrderQueryResult { return OrderQueryResult{Status: "unknown_cloid"} } - if order.Order.User != req.User { + // Compare normalized addresses for case-insensitive matching + // Ethereum addresses are case-insensitive; EIP-55 checksum is optional + if normalizeAddress(order.Order.User) != normalizeAddress(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"} } diff --git a/server/websocket.go b/server/websocket.go index 1f0af8d..2442f84 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strings" "sync" "time" @@ -89,6 +90,12 @@ type BBOUpdate struct { BBO [2]WsLevel `json:"bbo"` // [bid, ask] } +// normalizeAddress converts an Ethereum address to lowercase for case-insensitive comparison. +// Ethereum addresses are case-insensitive; the mixed case (EIP-55 checksum) is optional validation. +func normalizeAddress(addr string) string { + return strings.ToLower(addr) +} + // NewWebSocketManager creates a new WebSocket manager func NewWebSocketManager(logger *slog.Logger) *WebSocketManager { if logger == nil { @@ -196,11 +203,13 @@ func (wsm *WebSocketManager) handleSubscribe(state *ConnectionState, sub map[str wsm.sendError(state.conn, "Missing user address for orderUpdates") return } + // Normalize address for case-insensitive comparison + normalizedUser := normalizeAddress(user) state.mu.Lock() - state.orderUpdatesUser = user + state.orderUpdatesUser = normalizedUser state.mu.Unlock() - wsm.logger.Info("subscribed to orderUpdates", "user", user) + wsm.logger.Info("subscribed to orderUpdates", "user", user, "normalized", normalizedUser) // Send subscription acknowledgment wsm.sendSubscriptionResponse(state.conn, sub) @@ -370,7 +379,8 @@ func (wsm *WebSocketManager) broadcastOrderUpdates() { // Send if: // 1. Order has no wallet (signature recovery failed) - backward compatibility // 2. Order wallet matches subscriber wallet - proper isolation - if orderUser == "" || orderUser == subscribedUser { + // (Compare normalized addresses for case-insensitive matching) + if orderUser == "" || normalizeAddress(orderUser) == subscribedUser { shouldSend = true } } From d6ed146ed83ebf41c05c1b36263d6d00e88f26c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:45:21 +0000 Subject: [PATCH 2/3] fix: remove duplicate normalizeAddress declaration The normalizeAddress function was declared in both handlers.go and websocket.go, causing a compilation error. Since they're in the same package, only one declaration is needed. Kept the declaration in handlers.go and removed the duplicate from websocket.go. Both files can access the shared function. --- server/websocket.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/server/websocket.go b/server/websocket.go index 2442f84..03aa134 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -90,12 +90,6 @@ type BBOUpdate struct { BBO [2]WsLevel `json:"bbo"` // [bid, ask] } -// normalizeAddress converts an Ethereum address to lowercase for case-insensitive comparison. -// Ethereum addresses are case-insensitive; the mixed case (EIP-55 checksum) is optional validation. -func normalizeAddress(addr string) string { - return strings.ToLower(addr) -} - // NewWebSocketManager creates a new WebSocket manager func NewWebSocketManager(logger *slog.Logger) *WebSocketManager { if logger == nil { From 8f61d0644ef63b7ba3d861d8654f0d97e458f6bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Nov 2025 16:56:39 +0000 Subject: [PATCH 3/3] fix: remove unused strings import from websocket.go --- server/websocket.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/websocket.go b/server/websocket.go index 03aa134..12859d6 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -4,7 +4,6 @@ import ( "encoding/json" "log/slog" "net/http" - "strings" "sync" "time"