Skip to content
This repository was archived by the owner on Feb 1, 2024. It is now read-only.

Commit 335d191

Browse files
authored
Support dynamic CCXT headers for exchanges such as Coinbase (#314)
* 1 - add infrastructure for HeaderFn to networking lib and wiring into ccxt.go * 2 - make header function method with STATIC type and ability to pass in custom mappings to framework with wiring for CCXT * 3 - support backward compatible case of not having any pre-specified function int he exchange header value, added LIST_OF_HACKS.md * 4 - add dynamic header functions to support coinbase pro * 5 - base64 encode signature for coinbase pro, use COINBASEPRO prefix instead of COINBASE * 6 - show all function names when there is an error in MakeHeaderFn * 7 - update sample config files with sample config entries for coinbase, mark it as a tested exchange * 8 - updated Exchanges section of README.md
1 parent f83c435 commit 335d191

10 files changed

Lines changed: 255 additions & 23 deletions

File tree

LIST_OF_HACKS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# LIST OF HACKS
2+
3+
## Awating v2.0
4+
5+
Incomplete list of hacks in the codebase that should be fixed before upgrading to v2.0 which will change the API to Kelp in some way
6+
7+
- LOH-1 - support backward-compatible case of not having any pre-specified function
8+
9+
## Workarounds
10+
11+
Incomplete list of workaround hacks in the codebase that should be fixed at some point

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,11 +208,13 @@ For more details, check out the [examples section](#configuration-files-1) of th
208208

209209
Exchange integrations provide data to trading strategies and allow you to [hedge][hedge] your positions on different exchanges. The following [exchange integrations](plugins) are available **out of the box** with Kelp:
210210

211-
- sdex ([source](plugins/sdex.go)): The [Stellar Decentralized Exchange][sdex]
212-
- kraken ([source](plugins/krakenExchange.go)): [Kraken][kraken]
213-
- binance (_`"ccxt-binance"`_) ([source](plugins/ccxtExchange.go)): Binance via CCXT - only supports priceFeeds and mirroring (buysell, sell, and mirror strategy)
214-
- poloniex (_`"ccxt-poloniex"`_) ([source](plugins/ccxtExchange.go)): Poloniex via CCXT - only supports priceFeeds and mirroring (buysell, sell, and mirror strategy)
215-
- bittrex (_`"ccxt-bittrex"`_) ([source](plugins/ccxtExchange.go)): Bittrex via CCXT - only supports priceFeeds and mirroring (buysell, sell, and mirror strategy)
211+
- sdex (_`"sdex"`_) ([source](plugins/sdex.go)): The [Stellar Decentralized Exchange][sdex]
212+
- kraken (_`"kraken"`_) ([source](plugins/krakenExchange.go)): [Kraken][kraken] - recommended to use `ccxt-kraken` instead
213+
- kraken (via CCXT) (_`"ccxt-kraken"`_) ([source](plugins/ccxtExchange.go)): Kraken via CCXT - full two-way integration (tested)
214+
- binance (via CCXT) (_`"ccxt-binance"`_) ([source](plugins/ccxtExchange.go)): Binance via CCXT - full two-way integration (tested)
215+
- coinbasepro (via CCXT) (_`"ccxt-coinbasepro"`_) ([source](plugins/ccxtExchange.go)): Coinbase Pro via CCXT - full two-way integration (tested)
216+
- poloniex (via CCXT) (_`"ccxt-poloniex"`_) ([source](plugins/ccxtExchange.go)): Poloniex via CCXT - only tested on priceFeeds and one-way mirroring
217+
- bittrex (via CCXT) (_`"ccxt-bittrex"`_) ([source](plugins/ccxtExchange.go)): Bittrex via CCXT - only tested on priceFeeds and onw-way mirroring
216218

217219
## Plugins
218220

examples/configs/trader/sample_trader.cfg

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,26 @@ MAX_OP_FEE_STROOPS=5000
113113
# if your exchange requires additional parameters during initialization, list them here (only ccxt supported currently)
114114
# Note that some CCXT exchanges require additional parameters, e.g. coinbase pro requires a "password"
115115
#[[EXCHANGE_PARAMS]]
116-
#PARAM=""
117-
#VALUE=""
116+
#PARAM="password"
117+
#VALUE="<coinbasepro-api-passphrase-here>"
118118
#[[EXCHANGE_PARAMS]]
119119
#PARAM=""
120120
#VALUE=""
121121

122122
# if your exchange requires additional parameters as http headers, list them here (only ccxt supported currently)
123+
# e.g., coinbase pro requires CB-ACCESS-KEY, CB-ACCESS-SIGN, CB-ACCESS-TIMESTAMP, and CB-ACCESS-PASSPHRASE
123124
#[[EXCHANGE_HEADERS]]
124-
#HEADER=""
125-
#VALUE=""
125+
#HEADER="CB-ACCESS-KEY"
126+
#VALUE="STATIC:<coinbasepro-api-key-here>"
127+
#[[EXCHANGE_HEADERS]]
128+
#HEADER="CB-ACCESS-SIGN"
129+
#VALUE="COINBASEPRO__CB-ACCESS-SIGN:<coinbasepro-api-secret-here>"
130+
#[[EXCHANGE_HEADERS]]
131+
#HEADER="CB-ACCESS-TIMESTAMP"
132+
#VALUE="TIMESTAMP:" # leave the value as "TIMESTAMP:" for coinbasepro
133+
#[[EXCHANGE_HEADERS]]
134+
#HEADER="CB-ACCESS-PASSPHRASE"
135+
#VALUE="STATIC:<coinbasepro-passphrase-here>"
126136
#[[EXCHANGE_HEADERS]]
127137
#HEADER=""
128138
#VALUE=""

plugins/factory.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,9 @@ func getExchanges() map[string]ExchangeContainer {
180180
func loadExchanges() {
181181
// marked as tested if key exists in this map (regardless of bool value)
182182
testedCcxtExchanges := map[string]bool{
183-
"binance": true,
183+
"kraken": true,
184+
"binance": true,
185+
"coinbasepro": true,
184186
}
185187

186188
exchanges = &map[string]ExchangeContainer{

support/networking/headerFn.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package networking
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/stellar/kelp/support/utils"
8+
)
9+
10+
// HeaderFn represents a function that transforms headers
11+
type HeaderFn func(string, string, string) string // (string httpMethod, string requestPath, string body)
12+
13+
// makeStaticHeaderFn is a convenience method
14+
func makeStaticHeaderFn(value string) (HeaderFn, error) {
15+
// need to convert to HeaderFn to work as a api.ExchangeHeader.Value
16+
return HeaderFn(func(method string, requestPath string, body string) string {
17+
return value
18+
}), nil
19+
}
20+
21+
// HeaderFnFactory is a factory method for the HeaderFn
22+
type HeaderFnFactory func(string) (HeaderFn, error)
23+
24+
var defaultMappings = map[string]HeaderFnFactory{
25+
"STATIC": HeaderFnFactory(makeStaticHeaderFn),
26+
}
27+
28+
func headerFnNames(maps ...map[string]HeaderFnFactory) []string {
29+
names := []string{}
30+
for _, m := range maps {
31+
if m != nil {
32+
for k, _ := range m {
33+
names = append(names, k)
34+
}
35+
}
36+
}
37+
return utils.Dedupe(names)
38+
}
39+
40+
// MakeHeaderFn is a factory method that makes a HeaderFn
41+
func MakeHeaderFn(value string, primaryMappings map[string]HeaderFnFactory) (HeaderFn, error) {
42+
numSeparators := strings.Count(value, ":")
43+
44+
if numSeparators == 0 {
45+
// LOH-1 - support backward-compatible case of not having any pre-specified function
46+
return makeStaticHeaderFn(value)
47+
} else if numSeparators != 1 {
48+
names := headerFnNames(primaryMappings, defaultMappings)
49+
return nil, fmt.Errorf("invalid format of header value (%s), needs exactly one colon (:) to separate the header function from the input value to that function. list of available header functions: [%s]", value, strings.Join(names, ", "))
50+
}
51+
52+
valueParts := strings.Split(value, ":")
53+
fnType := valueParts[0]
54+
fnInputValue := valueParts[1]
55+
56+
if primaryMappings != nil {
57+
if makeHeaderFn, ok := primaryMappings[fnType]; ok {
58+
return makeHeaderFn(fnInputValue)
59+
}
60+
}
61+
62+
if makeHeaderFn, ok := defaultMappings[fnType]; ok {
63+
return makeHeaderFn(fnInputValue)
64+
}
65+
66+
names := headerFnNames(primaryMappings, defaultMappings)
67+
return nil, fmt.Errorf("invalid function prefix (%s) as part of header value (%s). list of available header functions: [%s]", fnType, value, strings.Join(names, ", "))
68+
}

support/networking/network.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@ import (
1111
"strings"
1212
)
1313

14+
// JSONRequestDynamicHeaders submits an HTTP web request and parses the response into the responseData object as JSON
15+
func JSONRequestDynamicHeaders(
16+
httpClient *http.Client,
17+
method string,
18+
reqURL string,
19+
data string,
20+
headers map[string]HeaderFn,
21+
responseData interface{}, // the passed in responseData should be a pointer
22+
errorKey string,
23+
) error {
24+
headersMap := map[string]string{}
25+
for header, fn := range headers {
26+
headersMap[header] = fn(method, reqURL, data)
27+
}
28+
29+
return JSONRequest(
30+
httpClient,
31+
method,
32+
reqURL,
33+
data,
34+
headersMap,
35+
responseData,
36+
errorKey,
37+
)
38+
}
39+
1440
// JSONRequest submits an HTTP web request and parses the response into the responseData object as JSON
1541
func JSONRequest(
1642
httpClient *http.Client,

support/sdk/ccxt.go

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type Ccxt struct {
3636
exchangeName string
3737
instanceName string
3838
markets map[string]CcxtMarket
39-
headersMap map[string]string
39+
headersMap map[string]networking.HeaderFn
4040
}
4141

4242
// CcxtMarket represents the result of a LoadMarkets call
@@ -162,9 +162,14 @@ func (c *Ccxt) initialize(apiKey api.ExchangeAPIKey, params []api.ExchangeParam,
162162
}
163163
c.markets = markets
164164

165-
headersMap := map[string]string{}
165+
headersMap := map[string]networking.HeaderFn{}
166+
ccxtHeaderMappings := makeHeaderMappingsFromNewTimestamp()
166167
for _, header := range headers {
167-
headersMap[header.Header] = header.Value
168+
headerFn, e := networking.MakeHeaderFn(header.Value, ccxtHeaderMappings)
169+
if e != nil {
170+
return fmt.Errorf("unable to make header function with key (%s) and value (%s): %s", header.Header, header.Value, e)
171+
}
172+
headersMap[header.Header] = headerFn
168173
}
169174
c.headersMap = headersMap
170175

@@ -216,7 +221,7 @@ func (c *Ccxt) newInstance(apiKey api.ExchangeAPIKey, params []api.ExchangeParam
216221
}
217222

218223
var newInstance map[string]interface{}
219-
e = networking.JSONRequest(c.httpClient, "POST", ccxtBaseURL+pathExchanges+"/"+c.exchangeName, string(jsonData), c.headersMap, &newInstance, "error")
224+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", ccxtBaseURL+pathExchanges+"/"+c.exchangeName, string(jsonData), c.headersMap, &newInstance, "error")
220225
if e != nil {
221226
return fmt.Errorf("error in web request when creating new exchange instance for exchange '%s': %s", c.exchangeName, e)
222227
}
@@ -233,7 +238,7 @@ func (c *Ccxt) symbolExists(tradingPair string) error {
233238
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName
234239
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
235240
var exchangeOutput interface{}
236-
e := networking.JSONRequest(c.httpClient, "GET", url, "", c.headersMap, &exchangeOutput, "error")
241+
e := networking.JSONRequestDynamicHeaders(c.httpClient, "GET", url, "", c.headersMap, &exchangeOutput, "error")
237242
if e != nil {
238243
return fmt.Errorf("error fetching details of exchange instance (exchange=%s, instanceName=%s): %s", c.exchangeName, c.instanceName, e)
239244
}
@@ -284,7 +289,7 @@ func (c *Ccxt) FetchTicker(tradingPair string) (map[string]interface{}, error) {
284289
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchTicker"
285290
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
286291
var output interface{}
287-
e = networking.JSONRequest(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
292+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
288293
if e != nil {
289294
return nil, fmt.Errorf("error fetching tickers for trading pair '%s': %s", tradingPair, e)
290295
}
@@ -324,7 +329,7 @@ func (c *Ccxt) FetchOrderBook(tradingPair string, limit *int) (map[string][]Ccxt
324329
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchOrderBook"
325330
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
326331
var output interface{}
327-
e = networking.JSONRequest(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
332+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
328333
if e != nil {
329334
return nil, fmt.Errorf("error fetching orderbook for trading pair '%s': %s", tradingPair, e)
330335
}
@@ -385,7 +390,7 @@ func (c *Ccxt) FetchTrades(tradingPair string) ([]CcxtTrade, error) {
385390
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchTrades"
386391
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
387392
output := []CcxtTrade{}
388-
e = networking.JSONRequest(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
393+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
389394
if e != nil {
390395
return nil, fmt.Errorf("error fetching trades for trading pair '%s': %s", tradingPair, e)
391396
}
@@ -418,7 +423,7 @@ func (c *Ccxt) FetchMyTrades(tradingPair string, limit int, maybeCursorStart int
418423
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchMyTrades"
419424
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
420425
output := []CcxtTrade{}
421-
e = networking.JSONRequest(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
426+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
422427
if e != nil {
423428
return nil, fmt.Errorf("error fetching trades for trading pair '%s': %s", tradingPair, e)
424429
}
@@ -437,7 +442,7 @@ func (c *Ccxt) FetchBalance() (map[string]CcxtBalance, error) {
437442
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchBalance"
438443
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
439444
var output interface{}
440-
e := networking.JSONRequest(c.httpClient, "POST", url, "", c.headersMap, &output, "error")
445+
e := networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, "", c.headersMap, &output, "error")
441446
if e != nil {
442447
return nil, fmt.Errorf("error fetching balance: %s", e)
443448
}
@@ -503,7 +508,7 @@ func (c *Ccxt) FetchOpenOrders(tradingPairs []string) (map[string][]CcxtOpenOrde
503508
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchOpenOrders"
504509
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
505510
var output interface{}
506-
e = networking.JSONRequest(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
511+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
507512
if e != nil {
508513
return nil, fmt.Errorf("error fetching open orders: %s", e)
509514
}
@@ -559,7 +564,7 @@ func (c *Ccxt) CreateLimitOrder(tradingPair string, side string, amount float64,
559564
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/createOrder"
560565
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
561566
var output interface{}
562-
e = networking.JSONRequest(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
567+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
563568
if e != nil {
564569
return nil, fmt.Errorf("error creating order: %s", e)
565570
}
@@ -598,7 +603,7 @@ func (c *Ccxt) CancelOrder(orderID string, tradingPair string) (*CcxtOpenOrder,
598603
url := ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/cancelOrder"
599604
// decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.")
600605
var output interface{}
601-
e = networking.JSONRequest(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
606+
e = networking.JSONRequestDynamicHeaders(c.httpClient, "POST", url, string(data), c.headersMap, &output, "error")
602607
if e != nil {
603608
return nil, fmt.Errorf("error canceling order: %s", e)
604609
}

support/sdk/headerFnMappings.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package sdk
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"encoding/base64"
7+
"fmt"
8+
"strconv"
9+
"strings"
10+
"time"
11+
12+
"github.com/stellar/kelp/support/networking"
13+
)
14+
15+
type ccxtMapper struct {
16+
timestamp int64
17+
}
18+
19+
// makeHeaderMappingsFromNewTimestamp creates a new ccxtMapper so the timestamp can be consistent across HeaderFns and returns the required map
20+
func makeHeaderMappingsFromNewTimestamp() map[string]networking.HeaderFnFactory {
21+
c := &ccxtMapper{
22+
timestamp: time.Now().Unix(),
23+
}
24+
25+
return map[string]networking.HeaderFnFactory{
26+
"COINBASEPRO__CB-ACCESS-SIGN": networking.HeaderFnFactory(c.coinbaseSignFn),
27+
"TIMESTAMP": networking.HeaderFnFactory(c.timestampFn),
28+
}
29+
}
30+
31+
func (c *ccxtMapper) coinbaseSignFn(base64EncodedSigningKey string) (networking.HeaderFn, error) {
32+
base64DecodedSigningKey, e := base64.StdEncoding.DecodeString(base64EncodedSigningKey)
33+
if e != nil {
34+
return nil, fmt.Errorf("could not decode signing key (%s): %s", base64EncodedSigningKey, e)
35+
}
36+
37+
// return this inline method casted as a HeaderFn to work as a headerValue
38+
return networking.HeaderFn(func(method string, requestPath string, body string) string {
39+
uppercaseMethod := strings.ToUpper(method)
40+
payload := fmt.Sprintf("%d%s%s%s", c.timestamp, uppercaseMethod, requestPath, body)
41+
42+
// sign
43+
mac := hmac.New(sha256.New, base64DecodedSigningKey)
44+
mac.Write([]byte(payload))
45+
signature := mac.Sum(nil)
46+
base64EncodedSignature := base64.StdEncoding.EncodeToString(signature)
47+
48+
return base64EncodedSignature
49+
}), nil
50+
}
51+
52+
func (c *ccxtMapper) timestampFn(_ string) (networking.HeaderFn, error) {
53+
return networking.HeaderFn(func(method string, requestPath string, body string) string {
54+
return strconv.FormatInt(c.timestamp, 10)
55+
}), nil
56+
}

support/utils/functions.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,17 @@ func StringSet(list []string) map[string]bool {
355355
}
356356
return m
357357
}
358+
359+
// Dedupe removes duplicates from the list
360+
func Dedupe(list []string) []string {
361+
seen := map[string]bool{}
362+
out := []string{}
363+
364+
for _, elem := range list {
365+
if _, ok := seen[elem]; !ok {
366+
out = append(out, elem)
367+
seen[elem] = true
368+
}
369+
}
370+
return out
371+
}

0 commit comments

Comments
 (0)