Skip to content

Commit

Permalink
Created base bitfinex client and example usage
Browse files Browse the repository at this point in the history
  • Loading branch information
dasbd72 committed Mar 1, 2024
1 parent 35f7a63 commit 3f00fd7
Show file tree
Hide file tree
Showing 14 changed files with 443 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Developing exchanges
- binance
- okx
- pionex
- bitfinex

## Development Environment

Expand All @@ -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)
130 changes: 130 additions & 0 deletions bitfinex/client.go
Original file line number Diff line number Diff line change
@@ -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))
}
27 changes: 27 additions & 0 deletions bitfinex/errors.go
Original file line number Diff line number Diff line change
@@ -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("<APIError> code=%v, msg=\"%v\"", e[1], e[2])
}
b, _ := json.Marshal(e)
return fmt.Sprintf("<APIError> msg=%s", string(b))
}

// IsAPIError check if e is an API error
func IsAPIError(e error) bool {
_, ok := e.(*APIError)
return ok
}
69 changes: 69 additions & 0 deletions bitfinex/errors_test.go
Original file line number Diff line number Diff line change
@@ -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: "<APIError> msg=[]",
},
{
name: "non-empty",
e: APIError{"error", 10000, "error: test message"},
want: "<APIError> 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)
}
})
}
}
5 changes: 5 additions & 0 deletions bitfinex/go.mod
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions bitfinex/go.sum
Original file line number Diff line number Diff line change
@@ -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=
50 changes: 50 additions & 0 deletions bitfinex/request.go
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 52 additions & 0 deletions bitfinex/request_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
15 changes: 15 additions & 0 deletions bitfinex/time.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 3f00fd7

Please sign in to comment.