2020-10-02 02:10:59 +00:00
|
|
|
package max
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"crypto/hmac"
|
|
|
|
"crypto/sha256"
|
|
|
|
"encoding/base64"
|
|
|
|
"encoding/hex"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"io/ioutil"
|
|
|
|
"math"
|
2021-06-07 16:12:22 +00:00
|
|
|
"net"
|
2020-10-02 02:10:59 +00:00
|
|
|
"net/http"
|
2021-06-07 10:04:34 +00:00
|
|
|
"net/http/httputil"
|
2020-10-02 02:10:59 +00:00
|
|
|
"net/url"
|
2020-10-10 09:50:49 +00:00
|
|
|
"reflect"
|
2020-10-02 02:10:59 +00:00
|
|
|
"regexp"
|
|
|
|
"strconv"
|
|
|
|
"sync/atomic"
|
|
|
|
"time"
|
|
|
|
|
2021-02-05 14:30:47 +00:00
|
|
|
"github.com/c9s/bbgo/pkg/util"
|
2021-02-22 07:16:12 +00:00
|
|
|
"github.com/c9s/bbgo/pkg/version"
|
2021-06-07 15:11:53 +00:00
|
|
|
"github.com/pkg/errors"
|
|
|
|
log "github.com/sirupsen/logrus"
|
2020-10-02 02:10:59 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// ProductionAPIURL is the official MAX API v2 Endpoint
|
|
|
|
ProductionAPIURL = "https://max-api.maicoin.com/api/v2"
|
|
|
|
|
2021-02-22 07:16:12 +00:00
|
|
|
UserAgent = "bbgo/" + version.Version
|
2020-10-02 02:10:59 +00:00
|
|
|
|
2021-02-22 07:16:12 +00:00
|
|
|
defaultHTTPTimeout = time.Second * 30
|
2021-02-23 08:39:48 +00:00
|
|
|
|
2021-02-23 14:53:00 +00:00
|
|
|
// 2018-09-01 08:00:00 +0800 CST
|
2021-02-23 08:39:48 +00:00
|
|
|
TimestampSince = 1535760000
|
2020-10-02 02:10:59 +00:00
|
|
|
)
|
|
|
|
|
2021-06-07 10:04:34 +00:00
|
|
|
var debugRequestDump = false
|
|
|
|
var debugMaxRequestPayload = false
|
2021-06-07 16:08:03 +00:00
|
|
|
var addUserAgentHeader = true
|
|
|
|
|
|
|
|
var httpTransportMaxIdleConnsPerHost = http.DefaultMaxIdleConnsPerHost
|
|
|
|
var httpTransportMaxIdleConns = 100
|
2021-06-07 16:19:57 +00:00
|
|
|
var httpTransportIdleConnTimeout = 90 * time.Second
|
2021-02-23 08:39:48 +00:00
|
|
|
|
|
|
|
func init() {
|
2021-06-07 16:08:03 +00:00
|
|
|
debugMaxRequestPayload, _ = util.GetEnvVarBool("DEBUG_MAX_REQUEST_PAYLOAD")
|
|
|
|
debugRequestDump, _ = util.GetEnvVarBool("DEBUG_MAX_REQUEST")
|
|
|
|
addUserAgentHeader, _ = util.GetEnvVarBool("DISABLE_MAX_USER_AGENT_HEADER")
|
|
|
|
|
|
|
|
if val, ok := util.GetEnvVarInt("HTTP_TRANSPORT_MAX_IDLE_CONNS_PER_HOST"); ok {
|
|
|
|
httpTransportMaxIdleConnsPerHost = val
|
|
|
|
}
|
|
|
|
|
|
|
|
if val, ok := util.GetEnvVarInt("HTTP_TRANSPORT_MAX_IDLE_CONNS"); ok {
|
|
|
|
httpTransportMaxIdleConns = val
|
|
|
|
}
|
2021-06-07 16:19:57 +00:00
|
|
|
if val, ok := util.GetEnvVarDuration("HTTP_TRANSPORT_IDLE_CONN_TIMEOUT"); ok {
|
|
|
|
httpTransportIdleConnTimeout = val
|
|
|
|
}
|
2021-02-23 08:39:48 +00:00
|
|
|
}
|
|
|
|
|
2020-10-02 02:10:59 +00:00
|
|
|
var logger = log.WithField("exchange", "max")
|
|
|
|
|
|
|
|
var htmlTagPattern = regexp.MustCompile("<[/]?[a-zA-Z-]+.*?>")
|
|
|
|
|
|
|
|
// The following variables are used for nonce.
|
|
|
|
|
|
|
|
// timeOffset is used for nonce
|
|
|
|
var timeOffset int64 = 0
|
|
|
|
|
|
|
|
// serverTimestamp is used for storing the server timestamp, default to Now
|
|
|
|
var serverTimestamp = time.Now().Unix()
|
|
|
|
|
|
|
|
// reqCount is used for nonce, this variable counts the API request count.
|
|
|
|
var reqCount int64 = 0
|
|
|
|
|
|
|
|
type RestClient struct {
|
|
|
|
client *http.Client
|
|
|
|
|
|
|
|
BaseURL *url.URL
|
|
|
|
|
|
|
|
// Authentication
|
|
|
|
APIKey string
|
|
|
|
APISecret string
|
|
|
|
|
2021-06-07 10:04:34 +00:00
|
|
|
AccountService *AccountService
|
|
|
|
PublicService *PublicService
|
|
|
|
TradeService *TradeService
|
|
|
|
OrderService *OrderService
|
|
|
|
RewardService *RewardService
|
2021-05-11 14:35:31 +00:00
|
|
|
WithdrawalService *WithdrawalService
|
2020-10-02 02:10:59 +00:00
|
|
|
// OrderBookService *OrderBookService
|
|
|
|
// MaxTokenService *MaxTokenService
|
|
|
|
// MaxKLineService *KLineService
|
|
|
|
// CreditService *CreditService
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewRestClientWithHttpClient(baseURL string, httpClient *http.Client) *RestClient {
|
|
|
|
u, err := url.Parse(baseURL)
|
|
|
|
if err != nil {
|
|
|
|
panic(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
var client = &RestClient{
|
|
|
|
client: httpClient,
|
|
|
|
BaseURL: u,
|
|
|
|
}
|
|
|
|
|
|
|
|
client.AccountService = &AccountService{client}
|
|
|
|
client.TradeService = &TradeService{client}
|
|
|
|
client.PublicService = &PublicService{client}
|
|
|
|
client.OrderService = &OrderService{client}
|
2021-02-22 10:45:44 +00:00
|
|
|
client.RewardService = &RewardService{client}
|
2021-05-11 14:35:31 +00:00
|
|
|
client.WithdrawalService = &WithdrawalService{client}
|
2021-02-22 10:45:44 +00:00
|
|
|
|
2020-10-02 02:10:59 +00:00
|
|
|
// client.MaxTokenService = &MaxTokenService{client}
|
|
|
|
client.initNonce()
|
|
|
|
return client
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewRestClient(baseURL string) *RestClient {
|
2021-06-07 16:12:22 +00:00
|
|
|
// create an isolated http transport rather than the default one
|
|
|
|
transport := &http.Transport{
|
|
|
|
Proxy: http.ProxyFromEnvironment,
|
|
|
|
DialContext: (&net.Dialer{
|
|
|
|
Timeout: 30 * time.Second,
|
|
|
|
KeepAlive: 30 * time.Second,
|
|
|
|
}).DialContext,
|
|
|
|
ForceAttemptHTTP2: true,
|
|
|
|
MaxIdleConns: httpTransportMaxIdleConns,
|
|
|
|
MaxIdleConnsPerHost: httpTransportMaxIdleConnsPerHost,
|
2021-06-07 16:19:57 +00:00
|
|
|
IdleConnTimeout: httpTransportIdleConnTimeout,
|
2021-06-07 16:12:22 +00:00
|
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
|
|
}
|
2021-06-07 16:08:03 +00:00
|
|
|
|
|
|
|
client := &http.Client{
|
|
|
|
Timeout: defaultHTTPTimeout,
|
|
|
|
Transport: transport,
|
|
|
|
}
|
|
|
|
|
|
|
|
return NewRestClientWithHttpClient(baseURL, client)
|
2020-10-02 02:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Auth sets api key and secret for usage is requests that requires authentication.
|
|
|
|
func (c *RestClient) Auth(key string, secret string) *RestClient {
|
|
|
|
c.APIKey = key
|
|
|
|
c.APISecret = secret
|
|
|
|
return c
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *RestClient) initNonce() {
|
|
|
|
var clientTime = time.Now()
|
|
|
|
var err error
|
|
|
|
serverTimestamp, err = c.PublicService.Timestamp()
|
|
|
|
if err != nil {
|
|
|
|
logger.WithError(err).Panic("failed to sync timestamp with Max")
|
|
|
|
}
|
|
|
|
|
|
|
|
// 1 is for the request count mod 0.000 to 0.999
|
|
|
|
timeOffset = serverTimestamp - clientTime.Unix() - 1
|
2020-10-10 09:50:49 +00:00
|
|
|
|
|
|
|
logger.Infof("loaded max server timestamp: %d offset=%d", serverTimestamp, timeOffset)
|
2020-10-02 02:10:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (c *RestClient) getNonce() int64 {
|
|
|
|
var seconds = time.Now().Unix()
|
|
|
|
var rc = atomic.AddInt64(&reqCount, 1)
|
|
|
|
return (seconds+timeOffset)*1000 + int64(math.Mod(float64(rc), 1000.0))
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewRequest create new API request. Relative url can be provided in refURL.
|
|
|
|
func (c *RestClient) newRequest(method string, refURL string, params url.Values, body []byte) (*http.Request, error) {
|
|
|
|
rel, err := url.Parse(refURL)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if params != nil {
|
|
|
|
rel.RawQuery = params.Encode()
|
|
|
|
}
|
|
|
|
var req *http.Request
|
|
|
|
u := c.BaseURL.ResolveReference(rel)
|
|
|
|
|
|
|
|
req, err = http.NewRequest(method, u.String(), bytes.NewReader(body))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2021-06-07 16:08:03 +00:00
|
|
|
if addUserAgentHeader {
|
|
|
|
req.Header.Add("User-Agent", UserAgent)
|
|
|
|
}
|
2020-10-02 02:10:59 +00:00
|
|
|
return req, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// newAuthenticatedRequest creates new http request for authenticated routes.
|
2021-06-08 18:36:32 +00:00
|
|
|
func (c *RestClient) newAuthenticatedRequest(m string, refURL string, data interface{}, rel *url.URL) (*http.Request, error) {
|
|
|
|
var err error
|
|
|
|
if rel == nil {
|
|
|
|
rel, err = url.Parse(refURL)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2020-10-02 02:10:59 +00:00
|
|
|
}
|
|
|
|
|
2020-10-06 10:44:56 +00:00
|
|
|
var p []byte
|
|
|
|
|
|
|
|
switch d := data.(type) {
|
2020-10-17 16:05:54 +00:00
|
|
|
|
|
|
|
case nil:
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
"nonce": c.getNonce(),
|
|
|
|
"path": c.BaseURL.ResolveReference(rel).Path,
|
|
|
|
}
|
|
|
|
p, err = json.Marshal(payload)
|
|
|
|
|
2020-10-06 10:44:56 +00:00
|
|
|
case map[string]interface{}:
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
"nonce": c.getNonce(),
|
|
|
|
"path": c.BaseURL.ResolveReference(rel).Path,
|
|
|
|
}
|
|
|
|
|
|
|
|
for k, v := range d {
|
|
|
|
payload[k] = v
|
|
|
|
}
|
|
|
|
|
|
|
|
p, err = json.Marshal(payload)
|
|
|
|
|
|
|
|
default:
|
2020-10-10 09:50:49 +00:00
|
|
|
params, err := getPrivateRequestParamsObject(data)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrapf(err, "unsupported payload type: %T", d)
|
|
|
|
}
|
|
|
|
|
|
|
|
params.Nonce = c.getNonce()
|
|
|
|
params.Path = c.BaseURL.ResolveReference(rel).Path
|
2020-10-02 02:10:59 +00:00
|
|
|
|
2020-10-10 09:50:49 +00:00
|
|
|
p, err = json.Marshal(d)
|
2020-10-02 02:10:59 +00:00
|
|
|
}
|
|
|
|
|
2021-02-23 08:39:48 +00:00
|
|
|
if debugMaxRequestPayload {
|
|
|
|
log.Infof("request payload: %s", p)
|
|
|
|
}
|
|
|
|
|
2020-10-02 02:10:59 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2020-10-08 14:03:25 +00:00
|
|
|
if len(c.APIKey) == 0 {
|
|
|
|
return nil, errors.New("empty api key")
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(c.APISecret) == 0 {
|
|
|
|
return nil, errors.New("empty api secret")
|
|
|
|
}
|
|
|
|
|
2020-10-02 02:10:59 +00:00
|
|
|
req, err := c.newRequest(m, refURL, nil, p)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
encoded := base64.StdEncoding.EncodeToString(p)
|
|
|
|
|
|
|
|
req.Header.Add("Content-Type", "application/json")
|
2021-06-07 10:04:34 +00:00
|
|
|
// accept is not necessary
|
|
|
|
// req.Header.Add("Accept", "application/json")
|
2020-10-02 02:10:59 +00:00
|
|
|
req.Header.Add("X-MAX-ACCESSKEY", c.APIKey)
|
|
|
|
req.Header.Add("X-MAX-PAYLOAD", encoded)
|
|
|
|
req.Header.Add("X-MAX-SIGNATURE", signPayload(encoded, c.APISecret))
|
|
|
|
|
2021-06-07 10:04:34 +00:00
|
|
|
if debugRequestDump {
|
2021-06-08 18:18:37 +00:00
|
|
|
dump, err2 := httputil.DumpRequestOut(req, true)
|
|
|
|
if err2 != nil {
|
|
|
|
log.Errorf("dump request error: %v", err2)
|
|
|
|
} else {
|
|
|
|
fmt.Printf("REQUEST:\n%s", dump)
|
2021-06-07 10:04:34 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-10-02 02:10:59 +00:00
|
|
|
return req, nil
|
|
|
|
}
|
|
|
|
|
2020-10-10 09:50:49 +00:00
|
|
|
func getPrivateRequestParamsObject(v interface{}) (*PrivateRequestParams, error) {
|
|
|
|
vt := reflect.ValueOf(v)
|
|
|
|
|
|
|
|
if vt.Kind() == reflect.Ptr {
|
|
|
|
vt = vt.Elem()
|
|
|
|
}
|
|
|
|
|
|
|
|
if vt.Kind() != reflect.Struct {
|
|
|
|
return nil, errors.New("reflect error: given object is not a struct" + vt.Kind().String())
|
|
|
|
}
|
|
|
|
|
|
|
|
if !vt.CanSet() {
|
|
|
|
return nil, errors.New("reflect error: can not set object")
|
|
|
|
}
|
|
|
|
|
|
|
|
field := vt.FieldByName("PrivateRequestParams")
|
|
|
|
if !field.IsValid() {
|
|
|
|
return nil, errors.New("reflect error: field PrivateRequestParams not found")
|
|
|
|
}
|
|
|
|
|
|
|
|
if field.IsNil() {
|
|
|
|
field.Set(reflect.ValueOf(&PrivateRequestParams{}))
|
|
|
|
}
|
|
|
|
|
|
|
|
params, ok := field.Interface().(*PrivateRequestParams)
|
|
|
|
if !ok {
|
|
|
|
return nil, errors.New("reflect error: failed to cast value to *PrivateRequestParams")
|
|
|
|
}
|
|
|
|
|
|
|
|
return params, nil
|
|
|
|
}
|
|
|
|
|
2020-10-02 02:10:59 +00:00
|
|
|
func signPayload(payload string, secret string) string {
|
|
|
|
var sig = hmac.New(sha256.New, []byte(secret))
|
|
|
|
_, err := sig.Write([]byte(payload))
|
|
|
|
if err != nil {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
return hex.EncodeToString(sig.Sum(nil))
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *RestClient) Do(req *http.Request) (resp *http.Response, err error) {
|
|
|
|
return c.client.Do(req)
|
|
|
|
}
|
|
|
|
|
|
|
|
// sendRequest sends the request to the API server and handle the response
|
2021-02-05 14:30:47 +00:00
|
|
|
func (c *RestClient) sendRequest(req *http.Request) (*util.Response, error) {
|
2021-06-08 18:21:32 +00:00
|
|
|
resp, err := c.client.Do(req)
|
2020-10-02 02:10:59 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// newResponse reads the response body and return a new Response object
|
2021-02-05 14:30:47 +00:00
|
|
|
response, err := util.NewResponse(resp)
|
2020-10-02 02:10:59 +00:00
|
|
|
if err != nil {
|
|
|
|
return response, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check error, if there is an error, return the ErrorResponse struct type
|
2021-02-06 01:16:43 +00:00
|
|
|
if response.IsError() {
|
2021-05-23 04:11:27 +00:00
|
|
|
errorResponse, err := ToErrorResponse(response)
|
2020-10-02 02:10:59 +00:00
|
|
|
if err != nil {
|
|
|
|
return response, err
|
|
|
|
}
|
|
|
|
return response, errorResponse
|
|
|
|
}
|
|
|
|
|
|
|
|
return response, nil
|
|
|
|
}
|
|
|
|
|
2021-02-05 14:30:47 +00:00
|
|
|
func (c *RestClient) sendAuthenticatedRequest(m string, refURL string, data map[string]interface{}) (*util.Response, error) {
|
2021-06-08 18:36:32 +00:00
|
|
|
req, err := c.newAuthenticatedRequest(m, refURL, data, nil)
|
2020-10-02 02:10:59 +00:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
response, err := c.sendRequest(req)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return response, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// FIXME: should deprecate the polling usage from the websocket struct
|
|
|
|
func (c *RestClient) GetTrades(market string, lastTradeID int64) ([]byte, error) {
|
|
|
|
params := url.Values{}
|
|
|
|
params.Add("market", market)
|
|
|
|
if lastTradeID > 0 {
|
|
|
|
params.Add("from", strconv.Itoa(int(lastTradeID)))
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.get("/trades", params)
|
|
|
|
}
|
|
|
|
|
|
|
|
// get sends GET http request to the api endpoint, the urlPath must start with a slash '/'
|
|
|
|
func (c *RestClient) get(urlPath string, values url.Values) ([]byte, error) {
|
|
|
|
var reqURL = c.BaseURL.String() + urlPath
|
|
|
|
|
|
|
|
// Create request
|
|
|
|
req, err := http.NewRequest("GET", reqURL, nil)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("could not init request: %s", err.Error())
|
|
|
|
}
|
|
|
|
|
|
|
|
req.URL.RawQuery = values.Encode()
|
|
|
|
req.Header.Add("User-Agent", UserAgent)
|
|
|
|
|
|
|
|
// Execute request
|
|
|
|
resp, err := c.client.Do(req)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("could not execute request: %s", err.Error())
|
|
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
// Load request
|
|
|
|
body, err := ioutil.ReadAll(resp.Body)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("could not read response: %s", err.Error())
|
|
|
|
}
|
|
|
|
|
|
|
|
return body, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ErrorResponse is the custom error type that is returned if the API returns an
|
|
|
|
// error.
|
|
|
|
type ErrorField struct {
|
|
|
|
Code int `json:"code"`
|
|
|
|
Message string `json:"message"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ErrorResponse struct {
|
2021-02-05 14:30:47 +00:00
|
|
|
*util.Response
|
2020-10-02 02:10:59 +00:00
|
|
|
Err ErrorField `json:"error"`
|
|
|
|
}
|
|
|
|
|
|
|
|
func (r *ErrorResponse) Error() string {
|
|
|
|
return fmt.Sprintf("%s %s: %d %d %s",
|
|
|
|
r.Response.Response.Request.Method,
|
|
|
|
r.Response.Response.Request.URL.String(),
|
|
|
|
r.Response.Response.StatusCode,
|
|
|
|
r.Err.Code,
|
|
|
|
r.Err.Message,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2021-05-23 04:11:27 +00:00
|
|
|
// ToErrorResponse tries to convert/parse the server response to the standard Error interface object
|
|
|
|
func ToErrorResponse(response *util.Response) (errorResponse *ErrorResponse, err error) {
|
2020-10-02 02:10:59 +00:00
|
|
|
errorResponse = &ErrorResponse{Response: response}
|
|
|
|
|
|
|
|
contentType := response.Header.Get("content-type")
|
|
|
|
switch contentType {
|
|
|
|
case "text/json", "application/json", "application/json; charset=utf-8":
|
|
|
|
var err = response.DecodeJSON(errorResponse)
|
|
|
|
if err != nil {
|
|
|
|
return errorResponse, errors.Wrapf(err, "failed to decode json for response: %d %s", response.StatusCode, string(response.Body))
|
|
|
|
}
|
|
|
|
return errorResponse, nil
|
|
|
|
case "text/html":
|
|
|
|
// convert 5xx error from the HTML page to the ErrorResponse
|
|
|
|
errorResponse.Err.Message = htmlTagPattern.ReplaceAllLiteralString(string(response.Body), "")
|
|
|
|
return errorResponse, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return errorResponse, fmt.Errorf("unexpected response content type %s", contentType)
|
|
|
|
}
|