From 3f00fd7cd2e3beff6173a4ed7621fcace1d4715b Mon Sep 17 00:00:00 2001 From: dasbd72 Date: Fri, 1 Mar 2024 17:38:29 +0800 Subject: [PATCH] Created base bitfinex client and example usage --- .github/workflows/go.yml | 3 + README.md | 2 + bitfinex/client.go | 130 +++++++++++++++++++++++++++++++++++++++ bitfinex/errors.go | 27 ++++++++ bitfinex/errors_test.go | 69 +++++++++++++++++++++ bitfinex/go.mod | 5 ++ bitfinex/go.sum | 2 + bitfinex/request.go | 50 +++++++++++++++ bitfinex/request_test.go | 52 ++++++++++++++++ bitfinex/time.go | 15 +++++ bitfinex/time_test.go | 41 ++++++++++++ cmd/ccy-cli/bitfinex.go | 38 ++++++++++++ cmd/ccy-cli/go.mod | 3 + cmd/ccy-cli/main.go | 6 ++ 14 files changed, 443 insertions(+) create mode 100644 bitfinex/client.go create mode 100644 bitfinex/errors.go create mode 100644 bitfinex/errors_test.go create mode 100644 bitfinex/go.mod create mode 100644 bitfinex/go.sum create mode 100644 bitfinex/request.go create mode 100644 bitfinex/request_test.go create mode 100644 bitfinex/time.go create mode 100644 bitfinex/time_test.go create mode 100644 cmd/ccy-cli/bitfinex.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 462e2c7..27dd143 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -27,6 +27,9 @@ jobs: - name: OKX Unit Test run: go test -v github.com/dasbd72/go-exchange-sdk/okx + - name: Bitfinex Unit Test + run: go test -v github.com/dasbd72/go-exchange-sdk/bitfinex + - name: MAX Unit Test run: go test -v github.com/dasbd72/go-exchange-sdk/max diff --git a/README.md b/README.md index 8027e45..da53ed0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Developing exchanges - binance - okx - pionex +- bitfinex ## Development Environment @@ -32,4 +33,5 @@ PIONEX_API_SECRET=your-api-secret - [Binance API](https://binance-docs.github.io/apidocs/spot/en/#introduction) - [OKX API](https://www.okx.com/docs-v5/en/#overview) - [Pionex API](https://pionex-doc.gitbook.io/apidocs/) +- [Bitfinex API](https://docs.bitfinex.com/docs) - [MAX API](https://max.maicoin.com/documents/api) diff --git a/bitfinex/client.go b/bitfinex/client.go new file mode 100644 index 0000000..1d3c7f6 --- /dev/null +++ b/bitfinex/client.go @@ -0,0 +1,130 @@ +package bitfinex + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha512" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" +) + +type Client struct { + apiKey string + apiSecret string + publicApiEndpoint string + privateApiEndpoint string + httpClient *http.Client +} + +const ( + basePublicApiURL = "https://api-pub.bitfinex.com/v2" + basePrivateApiURL = "https://api.bitfinex.com/v2" + + apiKeyHeader = "bfx-apikey" + nonceHeader = "bfx-nonce" + signatureHeader = "bfx-signature" +) + +// NewClient initialize an API client instance with API key and secret key. +// You should always call this function before using this SDK. +// Services will be created by the form client.NewXXXService(). +func NewClient(apiKey, apiSecret string) *Client { + return &Client{ + apiKey: apiKey, + apiSecret: apiSecret, + publicApiEndpoint: basePublicApiURL, + privateApiEndpoint: basePrivateApiURL, + httpClient: http.DefaultClient, + } +} + +func (c *Client) CallAPI(ctx context.Context, r *Request, opts ...RequestOption) (data []byte, err error) { + // set request options from user + for _, opt := range opts { + opt(r) + } + + req, err := c.getHttpRequest(ctx, r) + if err != nil { + return []byte{}, err + } + res, err := c.httpClient.Do(req) + if err != nil { + return []byte{}, err + } + data, err = io.ReadAll(res.Body) + if err != nil { + return []byte{}, err + } + defer func() { + cerr := res.Body.Close() + // Only overwrite the retured error if the original error was nil and an + // error occurred while closing the body. + if err == nil && cerr != nil { + err = cerr + } + }() + + if res.StatusCode >= http.StatusBadRequest { + apiErr := &APIError{} + e := json.Unmarshal(data, apiErr) + if e != nil { + fmt.Fprintf(os.Stderr, "failed to unmarshal json: %s from %s\n", e, string(data)) + } + return nil, apiErr + } + return data, nil +} + +func (c *Client) getHttpRequest(ctx context.Context, r *Request) (*http.Request, error) { + apiEndpoint := c.publicApiEndpoint + path := fmt.Sprintf("%s%s", r.endpoint, r.subEndpoint) + query := url.Values{} + header := http.Header{} + body := "" + if r.method == http.MethodGet { + for k, v := range r.params { + query.Add(k, fmt.Sprintf("%v", v)) + } + if len(query) > 0 { + path += "?" + query.Encode() + } + } else { + b, err := json.Marshal(r.params) + if err != nil { + return nil, err + } + body = string(b) + if body == "{}" { + body = "" + } + header.Add("Content-Type", "application/json") + header.Add("accept", "application/json") + } + if r.secType == SecTypePrivate { + apiEndpoint = c.privateApiEndpoint + nonce := currentTimestamp() + header.Set(apiKeyHeader, c.apiKey) + header.Set(nonceHeader, nonce) + header.Set(signatureHeader, sign(c.apiSecret, fmt.Sprintf("/api/v2%s%s%s", path, nonce, body))) + } + // create request + req, err := http.NewRequestWithContext(ctx, r.method, fmt.Sprintf("%s%s", apiEndpoint, path), bytes.NewBuffer([]byte(body))) + if err != nil { + return nil, err + } + req.Header = header + return req, nil +} + +func sign(secret, message string) string { + mac := hmac.New(sha512.New384, []byte(secret)) + mac.Write([]byte(message)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/bitfinex/errors.go b/bitfinex/errors.go new file mode 100644 index 0000000..dcb7d89 --- /dev/null +++ b/bitfinex/errors.go @@ -0,0 +1,27 @@ +package bitfinex + +import ( + "encoding/json" + "fmt" +) + +// APIError define API error +// +// Refer to https://binance-docs.github.io/apidocs/spot/en/#error-codes +// for error code details +type APIError []interface{} + +// Error return error code and message +func (e APIError) Error() string { + if len(e) == 3 { + return fmt.Sprintf(" code=%v, msg=\"%v\"", e[1], e[2]) + } + b, _ := json.Marshal(e) + return fmt.Sprintf(" msg=%s", string(b)) +} + +// IsAPIError check if e is an API error +func IsAPIError(e error) bool { + _, ok := e.(*APIError) + return ok +} diff --git a/bitfinex/errors_test.go b/bitfinex/errors_test.go new file mode 100644 index 0000000..cdfa54f --- /dev/null +++ b/bitfinex/errors_test.go @@ -0,0 +1,69 @@ +package bitfinex + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestError(t *testing.T) { + tests := []struct { + name string + e APIError + want string + }{ + { + name: "empty", + e: APIError{}, + want: " msg=[]", + }, + { + name: "non-empty", + e: APIError{"error", 10000, "error: test message"}, + want: " code=10000, msg=\"error: test message\"", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.e.Error() + if diff := cmp.Diff(got, test.want); diff != "" { + t.Errorf("Error() got unexpected result: (-got +want)\n%s", diff) + } + }) + } +} + +func TestIsAPIError(t *testing.T) { + tests := []struct { + name string + e error + want bool + }{ + { + name: "nil", + e: nil, + want: false, + }, + { + name: "APIError", + e: &APIError{}, + want: true, + }, + { + name: "other", + e: fmt.Errorf("test"), + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := IsAPIError(test.e) + if got != test.want { + t.Errorf("IsAPIError() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/bitfinex/go.mod b/bitfinex/go.mod new file mode 100644 index 0000000..6471163 --- /dev/null +++ b/bitfinex/go.mod @@ -0,0 +1,5 @@ +module github.com/dasbd72/go-exchange-sdk/bitfinex + +go 1.22.0 + +require github.com/google/go-cmp v0.6.0 diff --git a/bitfinex/go.sum b/bitfinex/go.sum new file mode 100644 index 0000000..5a8d551 --- /dev/null +++ b/bitfinex/go.sum @@ -0,0 +1,2 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/bitfinex/request.go b/bitfinex/request.go new file mode 100644 index 0000000..57ddb5f --- /dev/null +++ b/bitfinex/request.go @@ -0,0 +1,50 @@ +package bitfinex + +import "net/http" + +type SecType int + +const ( + // SecTypePublic is for public API + SecTypePublic SecType = iota + // SecTypePrivate is for private API + SecTypePrivate +) + +// Request define an API request, build with Request_builder +type Request struct { + method string + endpoint string + subEndpoint string + secType SecType + params map[string]interface{} +} + +// Request_builder define a builder for Request +type Request_builder struct { + Method string + Endpoint string + SubEndpoint string + SecType SecType + Params map[string]interface{} +} + +// Build create a new Request +func (b Request_builder) Build() *Request { + if b.Method == "" { + b.Method = http.MethodGet + } + if b.Params == nil { + b.Params = map[string]interface{}{} + } + return &Request{ + method: b.Method, + endpoint: b.Endpoint, + subEndpoint: b.SubEndpoint, + secType: b.SecType, + params: b.Params, + } +} + +// RequestOption define option type for request +type RequestOption func(*Request) diff --git a/bitfinex/request_test.go b/bitfinex/request_test.go new file mode 100644 index 0000000..d46077a --- /dev/null +++ b/bitfinex/request_test.go @@ -0,0 +1,52 @@ +package bitfinex + +import ( + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestBuild(t *testing.T) { + tests := []struct { + name string + b Request_builder + want *Request + }{ + { + name: "empty", + b: Request_builder{}, + want: &Request{ + method: http.MethodGet, + secType: SecTypePublic, + params: map[string]interface{}{}, + }, + }, + { + name: "non-empty", + b: Request_builder{ + Method: http.MethodPost, + Endpoint: "/test", + SubEndpoint: "/BTCUSD", + SecType: SecTypePrivate, + Params: map[string]interface{}{"test": "test"}, + }, + want: &Request{ + method: http.MethodPost, + endpoint: "/test", + subEndpoint: "/BTCUSD", + secType: SecTypePrivate, + params: map[string]interface{}{"test": "test"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := test.b.Build() + if diff := cmp.Diff(got, test.want, cmp.AllowUnexported(Request{})); diff != "" { + t.Errorf("Build() got unexpected result: (-got, +want)\n%s", diff) + } + }) + } +} diff --git a/bitfinex/time.go b/bitfinex/time.go new file mode 100644 index 0000000..d1d3182 --- /dev/null +++ b/bitfinex/time.go @@ -0,0 +1,15 @@ +package bitfinex + +import ( + "fmt" + "time" +) + +func currentTimestamp() string { + return fmt.Sprintf("%d", formatTimestamp(time.Now())) +} + +// formatTimestamp formats a time into Unix timestamp in milliseconds, as requested by Binance. +func formatTimestamp(t time.Time) int64 { + return t.UnixNano() / int64(time.Millisecond) +} diff --git a/bitfinex/time_test.go b/bitfinex/time_test.go new file mode 100644 index 0000000..4d0204e --- /dev/null +++ b/bitfinex/time_test.go @@ -0,0 +1,41 @@ +package bitfinex + +import ( + "testing" + "time" +) + +func TestFormatTimestamp(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + t time.Time + want int64 + }{ + { + name: "1970-01-01 00:00:00 (epoch)", + t: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), + want: 0, + }, + { + name: "2016-06-01 01:01:01", + t: time.Date(2016, 6, 1, 1, 1, 1, 0, time.UTC), + want: 1464742861000, + }, + { + name: "2018-06-01 01:01:01", + t: time.Date(2018, 6, 1, 1, 1, 1, 0, time.UTC), + want: 1527814861000, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := formatTimestamp(test.t) + if got != test.want { + t.Errorf("formatTimestamp(%v) got %v, want %v", test.t, got, test.want) + } + }) + } +} diff --git a/cmd/ccy-cli/bitfinex.go b/cmd/ccy-cli/bitfinex.go new file mode 100644 index 0000000..3da8964 --- /dev/null +++ b/cmd/ccy-cli/bitfinex.go @@ -0,0 +1,38 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + + "github.com/dasbd72/go-exchange-sdk/bitfinex" + "github.com/spf13/cobra" +) + +func Bitfinex(cmd *cobra.Command, args []string) { + ctx := context.Background() + // Create a new bitfinex client + c := bitfinex.NewClient( + os.Getenv("BFX_API_KEY"), + os.Getenv("BFX_API_SECRET"), + ) + + res, err := c.CallAPI(ctx, bitfinex.Request_builder{ + Method: http.MethodPost, + Endpoint: "/auth/r/wallets", + SecType: bitfinex.SecTypePrivate, + Params: map[string]interface{}{}, + }.Build()) + // res, err := c.CallAPI(ctx, bitfinex.Request_builder{ + // Method: http.MethodGet, + // Endpoint: "/ticker", + // SubEndpoint: "/tBTCUSD", + // SecType: bitfinex.SecTypePrivate, + // Params: map[string]interface{}{}, + // }.Build()) + if err != nil { + log.Fatal(err) + } + log.Println(string(res)) +} diff --git a/cmd/ccy-cli/go.mod b/cmd/ccy-cli/go.mod index f6a4f4c..5751bfe 100644 --- a/cmd/ccy-cli/go.mod +++ b/cmd/ccy-cli/go.mod @@ -8,10 +8,13 @@ replace github.com/dasbd72/go-exchange-sdk/okx => ../../okx replace github.com/dasbd72/go-exchange-sdk/max => ../../max +replace github.com/dasbd72/go-exchange-sdk/bitfinex => ../../bitfinex + replace github.com/dasbd72/go-exchange-sdk/manager => ../../manager require ( github.com/dasbd72/go-exchange-sdk/binance v0.0.0-00010101000000-000000000000 + github.com/dasbd72/go-exchange-sdk/bitfinex v0.0.0-00010101000000-000000000000 github.com/dasbd72/go-exchange-sdk/manager v0.0.0-00010101000000-000000000000 github.com/dasbd72/go-exchange-sdk/max v0.0.0-00010101000000-000000000000 github.com/dasbd72/go-exchange-sdk/okx v0.0.0-00010101000000-000000000000 diff --git a/cmd/ccy-cli/main.go b/cmd/ccy-cli/main.go index 369f6e5..80ac1f6 100644 --- a/cmd/ccy-cli/main.go +++ b/cmd/ccy-cli/main.go @@ -36,6 +36,11 @@ func main() { Short: "Max commands", Run: Max, } + bitfinexCmd := &cobra.Command{ + Use: "bitfinex", + Short: "Bitfinex commands", + Run: Bitfinex, + } balanceCmd := &cobra.Command{ Use: "balance", Short: "Get balance", @@ -45,6 +50,7 @@ func main() { root.AddCommand(okxCmd) root.AddCommand(binanceCmd) root.AddCommand(maxCmd) + root.AddCommand(bitfinexCmd) root.AddCommand(balanceCmd) root.Execute() }