diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ab35a182..e4b04233 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -157,7 +157,7 @@ jobs: if [ -z "$LATEST_TAG" ]; then # No tags exist yet, start with v1.0.0 - NEW_VERSION="v1.1.0" + NEW_VERSION="v1.0.0" else if [ "${{ matrix.client }}" == "common" ]; then CURRENT_VERSION=${LATEST_TAG#${{ matrix.client }}/v} diff --git a/common/CHANGELOG.md b/common/CHANGELOG.md index 5f036411..33655299 100644 --- a/common/CHANGELOG.md +++ b/common/CHANGELOG.md @@ -1,5 +1,16 @@ ### Changelog +## 1.2.0 - 2026-01-23 + +### Added (1) + +- Added `Alpha` base url + +### Changed (2) + +- Updated `PrepareRequest` and `SendRequest` methods to include `signed` boolean parameter to indicate if the request requires signing. +- Fixed Lock issue in `WebsocketStreams` `Unsubscribe` method by ensuring proper unlocking of mutex after stream removal. + ## 1.1.0 - 2026-01-13 - Updated `WebsocketStreams` `Subscribe` method to support `int32` format for `id` parameter diff --git a/common/common/constants.go b/common/common/constants.go index 827d9723..6c2a43fc 100644 --- a/common/common/constants.go +++ b/common/common/constants.go @@ -39,6 +39,9 @@ const ( // Algo API URLs const AlgoRestApiProdUrl = "https://api.binance.com" +// Alpha API URLs +const AlphaRestApiProdUrl = "https://www.binance.com" + // C2C API URLs const C2CRestApiProdUrl = "https://api.binance.com" diff --git a/common/common/utils.go b/common/common/utils.go index 353a9f42..22258e95 100644 --- a/common/common/utils.go +++ b/common/common/utils.go @@ -415,8 +415,9 @@ func ParseRateLimitHeaders(header http.Header) ([]RateLimit, error) { // @param method The HTTP method (GET, POST, etc.). // @param queryParams The query parameters for the request. // @param cfg The configuration containing API keys, secrets, and other settings. +// @param signed A boolean indicating whether the request requires signing. // @return The response from the REST API or an error if the request fails. -func SendRequest[T any](ctx context.Context, path string, method string, queryParams url.Values, bodyParams interface{}, cfg *ConfigurationRestAPI) (*RestApiResponse[T], error) { +func SendRequest[T any](ctx context.Context, path string, method string, queryParams url.Values, bodyParams interface{}, cfg *ConfigurationRestAPI, signed bool) (*RestApiResponse[T], error) { var ( localVarHeaderParams = make(map[string]string) localVarHTTPContentTypes = []string{} @@ -427,7 +428,7 @@ func SendRequest[T any](ctx context.Context, path string, method string, queryPa localVarHeaderParams["Content-Type"] = localVarHTTPContentType } - req, err := PrepareRequest(ctx, path, method, localVarHeaderParams, queryParams, bodyParams, cfg) + req, err := PrepareRequest(ctx, path, method, localVarHeaderParams, queryParams, bodyParams, cfg, signed) if err != nil { return &RestApiResponse[T]{}, err } @@ -443,7 +444,6 @@ func SendRequest[T any](ctx context.Context, path string, method string, queryPa var lastErr error httpClient := SetupProxy(cfg) - for attempt := 0; attempt <= retries; attempt++ { resp, err := httpClient.Do(req) if err != nil { @@ -543,6 +543,7 @@ func SendRequest[T any](ctx context.Context, path string, method string, queryPa // @param headerParams A map of header parameters to include in the request. // @param queryParams The query parameters for the request. // @param c The configuration containing API keys, secrets, and other settings. +// @param signed A boolean indicating whether the request requires signing. // @return The prepared HTTP request or an error if preparation fails. func PrepareRequest( ctx context.Context, @@ -550,7 +551,8 @@ func PrepareRequest( headerParams map[string]string, queryParams url.Values, bodyParams interface{}, - c *ConfigurationRestAPI) (localVarRequest *http.Request, err error) { + c *ConfigurationRestAPI, + signed bool) (localVarRequest *http.Request, err error) { reqURL, err := url.Parse(path) if err != nil { @@ -608,34 +610,36 @@ func PrepareRequest( } } - if c.ApiSecret != "" { - paramsToSign += "×tamp=" + strconv.FormatInt(time.Now().UnixMilli(), 10) + if signed && c != nil { + if c.ApiSecret != "" { + paramsToSign += "×tamp=" + strconv.FormatInt(time.Now().UnixMilli(), 10) - signer := hmac.New(sha256.New, []byte(c.ApiSecret)) - signer.Write([]byte(paramsToSign)) - signature := signer.Sum(nil) - if err != nil { - return nil, err + signer := hmac.New(sha256.New, []byte(c.ApiSecret)) + signer.Write([]byte(paramsToSign)) + signature := signer.Sum(nil) + if err != nil { + return nil, err + } + paramsToSign += "&signature=" + fmt.Sprintf("%x", signature) } - paramsToSign += "&signature=" + fmt.Sprintf("%x", signature) - } - if c.PrivateKey != "" { - paramsToSign += "×tamp=" + strconv.FormatInt(time.Now().UnixMilli(), 10) + if c.PrivateKey != "" { + paramsToSign += "×tamp=" + strconv.FormatInt(time.Now().UnixMilli(), 10) - if c.Signer == nil { - key, err := LoadPrivateKey(c.PrivateKey, c.PrivateKeyPassphrase) + if c.Signer == nil { + key, err := LoadPrivateKey(c.PrivateKey, c.PrivateKeyPassphrase) + if err != nil { + panic(err) + } + c.Signer = &cryptoSigner{s: key} + } + + signature, _ := c.Signer.Sign([]byte(paramsToSign)) if err != nil { panic(err) } - c.Signer = &cryptoSigner{s: key} - } - - signature, _ := c.Signer.Sign([]byte(paramsToSign)) - if err != nil { - panic(err) + paramsToSign += "&signature=" + base64.StdEncoding.EncodeToString(signature) } - paramsToSign += "&signature=" + base64.StdEncoding.EncodeToString(signature) } reqURL.RawQuery = paramsToSign diff --git a/common/common/websocket.go b/common/common/websocket.go index 890b4842..756cebe9 100644 --- a/common/common/websocket.go +++ b/common/common/websocket.go @@ -1301,6 +1301,8 @@ func (w *WebsocketStreams) Unsubscribe(streams []string) error { conn.mu.Lock() delete(conn.StreamCallbackMap, stream) + conn.mu.Unlock() + log.Printf("Unsubscribed from stream %s", stream) } diff --git a/common/tests/unit/utils_test.go b/common/tests/unit/utils_test.go index ccfe57ef..cfcf9c52 100644 --- a/common/tests/unit/utils_test.go +++ b/common/tests/unit/utils_test.go @@ -20,7 +20,6 @@ import ( "github.com/binance/binance-connector-go/common/common" ) -// Mock type implementing MappedNullable type mockMappedNullable struct { Data map[string]interface{} Err error @@ -79,7 +78,7 @@ func TestParameterAddToHeaderOrQuery_StructMappedNullable(t *testing.T) { if result.Data["field1"] != "value1" { t.Errorf("Expected field1=value1, got %v", result.Data["field1"]) } - if result.Data["field2"] != float64(123) { // JSON numbers become float64 + if result.Data["field2"] != float64(123) { t.Errorf("Expected field2=123, got %v", result.Data["field2"]) } } @@ -349,7 +348,7 @@ func TestParseRateLimitHeaders_RetryAfter(t *testing.T) { func TestParseRateLimitHeaders_InvalidCount(t *testing.T) { h := http.Header{} - h.Set("X-MBX-USED-WEIGHT-1m", "abc") // invalid number + h.Set("X-MBX-USED-WEIGHT-1m", "abc") rates, err := common.ParseRateLimitHeaders(h) if err != nil { t.Fatal(err) @@ -393,7 +392,7 @@ func TestSendRequest_Success(t *testing.T) { cfg := &common.ConfigurationRestAPI{} - resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg) + resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg, false) if err != nil { t.Fatal(err) } @@ -403,6 +402,34 @@ func TestSendRequest_Success(t *testing.T) { } } +func TestSendRequest_Signing(t *testing.T) { + cfg := &common.ConfigurationRestAPI{ + ApiKey: "apikey123", + ApiSecret: "secretkey456", + } + handler := func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + signature := query.Get("signature") + if signature == "" { + t.Errorf("Expected signature parameter to be set") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + if _, err := fmt.Fprintln(w, `{"message":"signed"}`); err != nil { + t.Fatal(err) + } + } + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg, true) + if err != nil { + t.Fatal(err) + } + if resp.Status != 200 || resp.Data.Message != "signed" { + t.Errorf("Unexpected response: %+v", resp) + } +} + func TestSendRequest_RetryLogic(t *testing.T) { attempts := 0 handler := func(w http.ResponseWriter, r *http.Request) { @@ -417,12 +444,11 @@ func TestSendRequest_RetryLogic(t *testing.T) { Backoff: 0, } - // Mock retry logic originalShouldRetry = common.ShouldRetryRequest common.ShouldRetryRequest = mockShouldRetry defer func() { common.ShouldRetryRequest = originalShouldRetry }() - resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg) + resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg, false) if err == nil { t.Fatalf("Expected error for server failure") } @@ -445,7 +471,7 @@ func TestSendRequest_HTTPErrorStatus(t *testing.T) { cfg := &common.ConfigurationRestAPI{} - resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg) + resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg, false) if err == nil { t.Fatalf("Expected error for 404") } @@ -477,7 +503,7 @@ func TestSendRequest_ContentEncodingGzip(t *testing.T) { cfg := &common.ConfigurationRestAPI{} - resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg) + resp, err := common.SendRequest[SampleResponse](context.Background(), server.URL, "GET", url.Values{}, nil, cfg, false) if err != nil { t.Fatal(err) } @@ -494,7 +520,7 @@ func TestPrepareRequest_BasicHeadersAndQuery(t *testing.T) { query := url.Values{} query.Set("param", "value") - req, err := common.PrepareRequest(context.Background(), "https://example.com/test", "GET", map[string]string{}, query, nil, cfg) + req, err := common.PrepareRequest(context.Background(), "https://example.com/test", "GET", map[string]string{}, query, nil, cfg, false) if err != nil { t.Fatal(err) } @@ -508,12 +534,38 @@ func TestPrepareRequest_BasicHeadersAndQuery(t *testing.T) { } } +func TestPrepareRequest_Signing(t *testing.T) { + cfg := &common.ConfigurationRestAPI{ + ApiKey: "apikey123", + ApiSecret: "secretkey456", + } + query := url.Values{} + query.Set("param", "value") + + req, err := common.PrepareRequest(context.Background(), "https://example.com/test", "GET", map[string]string{}, query, nil, cfg, true) + if err != nil { + t.Fatal(err) + } + + if req.Header.Get("X-MBX-APIKEY") != "apikey123" { + t.Errorf("Expected API key header set") + } + + if req.URL.Query().Get("param") != "value" { + t.Errorf("Expected query param preserved") + } + + if req.URL.Query().Get("signature") == "" { + t.Errorf("Expected signature parameter to be set") + } +} + func TestPrepareRequest_CustomHeadersAndCompression(t *testing.T) { cfg := &common.ConfigurationRestAPI{ CustomHeaders: map[string]string{"X-Custom": "abc"}, Compression: true, } - req, err := common.PrepareRequest(context.Background(), "https://example.com", "GET", map[string]string{}, url.Values{}, nil, cfg) + req, err := common.PrepareRequest(context.Background(), "https://example.com", "GET", map[string]string{}, url.Values{}, nil, cfg, false) if err != nil { t.Fatal(err) } @@ -534,7 +586,7 @@ func TestPrepareRequest_WithURLValuesBody(t *testing.T) { body.Set("key2", "value2") req, err := common.PrepareRequest(context.Background(), "https://example.com", "POST", - map[string]string{}, url.Values{}, body, cfg) + map[string]string{}, url.Values{}, body, cfg, false) if err != nil { t.Fatal(err) } @@ -542,10 +594,6 @@ func TestPrepareRequest_WithURLValuesBody(t *testing.T) { if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { t.Errorf("Expected Content-Type header to be set") } - - // Verify the body is included in paramsToSign - // We can't directly check paramsToSign as it's not returned, but we can verify the behavior - // by checking that the request would be properly signed if ApiSecret was set } func TestPrepareRequest_WithMapStringStringBody(t *testing.T) { @@ -556,7 +604,7 @@ func TestPrepareRequest_WithMapStringStringBody(t *testing.T) { } req, err := common.PrepareRequest(context.Background(), "https://example.com", "POST", - map[string]string{}, url.Values{}, body, cfg) + map[string]string{}, url.Values{}, body, cfg, false) if err != nil { t.Fatal(err) } @@ -575,7 +623,7 @@ func TestPrepareRequest_WithMapStringInterfaceBody(t *testing.T) { } req, err := common.PrepareRequest(context.Background(), "https://example.com", "POST", - map[string]string{}, url.Values{}, body, cfg) + map[string]string{}, url.Values{}, body, cfg, false) if err != nil { t.Fatal(err) } @@ -596,7 +644,7 @@ func TestPrepareRequest_WithJSONBody(t *testing.T) { } req, err := common.PrepareRequest(context.Background(), "https://example.com", "POST", - map[string]string{}, url.Values{}, body, cfg) + map[string]string{}, url.Values{}, body, cfg, false) if err != nil { t.Fatal(err) } @@ -610,7 +658,7 @@ func TestPrepareRequest_WithNilBody(t *testing.T) { cfg := &common.ConfigurationRestAPI{} req, err := common.PrepareRequest(context.Background(), "https://example.com", "GET", - map[string]string{}, url.Values{}, nil, cfg) + map[string]string{}, url.Values{}, nil, cfg, false) if err != nil { t.Fatal(err) } @@ -628,7 +676,7 @@ func TestPrepareRequest_WithQueryAndBody(t *testing.T) { body.Set("bodyParam", "bodyValue") req, err := common.PrepareRequest(context.Background(), "https://example.com", "POST", - map[string]string{}, query, body, cfg) + map[string]string{}, query, body, cfg, false) if err != nil { t.Fatal(err) } diff --git a/common/tests/unit/websocket_test.go b/common/tests/unit/websocket_test.go index 2a69f007..dc9b71fd 100644 --- a/common/tests/unit/websocket_test.go +++ b/common/tests/unit/websocket_test.go @@ -734,10 +734,6 @@ func TestProcessMessage_ResponseMessageHandled(t *testing.T) { }, }} - // doneChan := make(chan []byte, 1) - // mockPendingMsgs := sync.Map{} - // mockPendingMsgs.Store("0", doneChan) - conn := &common.WebSocketConnection{ Id: "test-connection", Connected: common.OPEN,