Config: Add versioning (#1671)

* Config: Version Management

* Engine: Improve visibility of TestConfigAllJsonResponse failures

* Config: Update cmd/config to allow upgrades

* Config: Add Version2 to rename GDAX

* Config: Restructure versioning to share types

This restructure allows us to share types between versions, avoids
needing to import the versions, and puts the test fixtures in same
package.
It's a win on all fronts

* Config: Fix SetNTPCheck using log

Called from engine before logger is inited, and also just wrong to use
log to communicate with user

* Config: Improve TestMigrateConfig

* Config: Drop requirement for versions to be registered in sequence

Checking the versions at Deploy is much saner.

* Config: Fix file encrypted but flag not set

* Config: Add -edit and encryption upgrade to cmd/config

This simplifies the handling for encryption prompts by moving it to a
field on config, allowing us to simplify all the places were were
passing around config

Also moves password entry to being secure (echo-off)

* Tests: Fix inconsistent should/must assertions
This commit is contained in:
Gareth Kirwan
2024-12-09 04:04:16 +00:00
committed by GitHub
parent 00556f274d
commit 219ed903bc
22 changed files with 1285 additions and 910 deletions

View File

@@ -1,82 +1,177 @@
package main
import (
"encoding/json"
"errors"
"flag"
"log"
"fmt"
"os"
"slices"
"strings"
"github.com/buger/jsonparser"
"github.com/thrasher-corp/gocryptotrader/common/file"
"github.com/thrasher-corp/gocryptotrader/config"
)
// EncryptOrDecrypt returns a string from a boolean
func EncryptOrDecrypt(encrypt bool) string {
if encrypt {
return "encrypted"
}
return "decrypted"
}
var commands = []string{"upgrade", "encrypt", "decrypt"}
func main() {
var inFile, outFile, key string
var encrypt bool
fmt.Println("GoCryptoTrader: config-helper tool")
defaultCfgFile := config.DefaultFilePath()
flag.StringVar(&inFile, "infile", defaultCfgFile, "The config input file to process.")
flag.StringVar(&outFile, "outfile", defaultCfgFile+".out", "The config output file.")
flag.BoolVar(&encrypt, "encrypt", true, "Whether to encrypt or decrypt.")
flag.StringVar(&key, "key", "", "The key to use for AES encryption.")
flag.Parse()
log.Println("GoCryptoTrader: config-helper tool.")
var in, out, keyStr string
var inplace bool
if key == "" {
result, err := config.PromptForConfigKey(false)
if err != nil {
log.Fatalf("Unable to obtain encryption/decryption key: %s", err)
}
key = string(result)
fs := flag.NewFlagSet("config", flag.ExitOnError)
fs.Usage = func() { usage(fs) }
fs.StringVar(&in, "in", defaultCfgFile, "The config input file to process")
fs.StringVar(&out, "out", "[in].out", "The config output file")
fs.BoolVar(&inplace, "edit", false, "Edit; Save result to the original file")
fs.StringVar(&keyStr, "key", "", "The key to use for AES encryption")
cmd, args := parseCommand(os.Args[1:])
if cmd == "" {
usage(fs)
os.Exit(2)
}
if err := fs.Parse(args); err != nil {
fatal(err.Error())
}
if inplace {
out = in
} else if out == "[in].out" {
out = in + ".out"
}
key := []byte(keyStr)
var err error
switch cmd {
case "upgrade":
err = upgradeFile(in, out, key)
case "decrypt":
err = encryptWrapper(in, out, key, false, decryptFile)
case "encrypt":
err = encryptWrapper(in, out, key, true, encryptFile)
}
fileData, err := os.ReadFile(inFile)
if err != nil {
log.Fatalf("Unable to read input file %s. Error: %s.", inFile, err)
fatal(err.Error())
}
if config.ConfirmECS(fileData) && encrypt {
log.Println("File is already encrypted. Decrypting..")
encrypt = false
}
if !config.ConfirmECS(fileData) && !encrypt {
var result interface{}
errf := json.Unmarshal(fileData, &result)
if errf != nil {
log.Fatal(errf)
}
log.Println("File is already decrypted. Encrypting..")
encrypt = true
}
var data []byte
if encrypt {
data, err = config.EncryptConfigFile(fileData, []byte(key))
if err != nil {
log.Fatalf("Unable to encrypt config data. Error: %s.", err)
}
} else {
data, err = config.DecryptConfigFile(fileData, []byte(key))
if err != nil {
log.Fatalf("Unable to decrypt config data. Error: %s.", err)
}
}
err = file.Write(outFile, data)
if err != nil {
log.Fatalf("Unable to write output file %s. Error: %s", outFile, err)
}
log.Printf(
"Successfully %s input file %s and wrote output to %s.\n",
EncryptOrDecrypt(encrypt), inFile, outFile,
)
fmt.Println("Success! File written to " + out)
}
func upgradeFile(in, out string, key []byte) error {
c := &config.Config{
EncryptionKeyProvider: func(_ bool) ([]byte, error) {
if len(key) != 0 {
return key, nil
}
return config.PromptForConfigKey(false)
},
}
if err := c.ReadConfigFromFile(in, true); err != nil {
return err
}
return c.SaveConfigToFile(out)
}
type encryptFunc func(string, []byte) ([]byte, error)
func encryptWrapper(in, out string, key []byte, confirmKey bool, fn encryptFunc) error {
if len(key) == 0 {
var err error
if key, err = config.PromptForConfigKey(confirmKey); err != nil {
return err
}
}
outData, err := fn(in, key)
if err != nil {
return err
}
if err := file.Write(out, outData); err != nil {
return fmt.Errorf("unable to write output file %s; Error: %w", out, err)
}
return nil
}
func encryptFile(in string, key []byte) ([]byte, error) {
if config.IsFileEncrypted(in) {
return nil, errors.New("file is already encrypted")
}
outData, err := config.EncryptConfigFile(readFile(in), key)
if err != nil {
return nil, fmt.Errorf("unable to encrypt config data. Error: %w", err)
}
return outData, nil
}
func decryptFile(in string, key []byte) ([]byte, error) {
if !config.IsFileEncrypted(in) {
return nil, errors.New("file is already decrypted")
}
outData, err := config.DecryptConfigFile(readFile(in), key)
if err != nil {
return nil, fmt.Errorf("unable to decrypt config data. Error: %w", err)
}
if outData, err = jsonparser.Set(outData, []byte("-1"), "encryptConfig"); err != nil {
return nil, fmt.Errorf("unable to decrypt config data. Error: %w", err)
}
return outData, nil
}
func readFile(in string) []byte {
fileData, err := os.ReadFile(in)
if err != nil {
fatal("Unable to read input file " + in + "; Error: " + err.Error())
}
return fileData
}
func fatal(msg string) {
fmt.Fprintln(os.Stderr, msg)
os.Exit(2)
}
// parseCommand will return the single non-flag parameter from os.Args, and return the remaining args
// If none is provided, too many, usage() will be called and exit 1
func parseCommand(a []string) (cmd string, args []string) {
cmds, rem := []string{}, []string{}
for _, s := range a {
if slices.Contains(commands, s) {
cmds = append(cmds, s)
} else {
rem = append(rem, s)
}
}
switch len(cmds) {
case 0:
fmt.Fprintln(os.Stderr, "No command provided")
case 1: //
return cmds[0], rem
default:
fmt.Fprintln(os.Stderr, "Too many commands provided: "+strings.Join(cmds, ", "))
}
return "", nil
}
// usage prints command usage and exits 1
func usage(fs *flag.FlagSet) {
//nolint:dupword // deliberate duplication of commands
fmt.Fprintln(os.Stderr, `
Usage:
config [arguments] <command>
The commands are:
encrypt encrypt infile and write to outfile
decrypt decrypt infile and write to outfile
upgrade upgrade the version of a decrypted config file
The arguments are:`)
fs.PrintDefaults()
}

View File

@@ -1,18 +0,0 @@
package main
import "testing"
func TestEncryptOrDecrypt(t *testing.T) {
reValue := EncryptOrDecrypt(true)
if reValue != "encrypted" {
t.Error(
"expected encrypted",
)
}
reValue = EncryptOrDecrypt(false)
if reValue != "decrypted" {
t.Error(
"expected decrypted",
)
}
}

View File

@@ -69,6 +69,8 @@ var (
ErrNoResponse = errors.New("no response")
ErrTypeAssertFailure = errors.New("type assert failure")
ErrUnknownError = errors.New("unknown error")
ErrGettingField = errors.New("error getting field")
ErrSettingField = errors.New("error setting field")
)
var (

View File

@@ -3,6 +3,7 @@ package config
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -19,6 +20,7 @@ import (
"github.com/thrasher-corp/gocryptotrader/common/convert"
"github.com/thrasher-corp/gocryptotrader/common/file"
"github.com/thrasher-corp/gocryptotrader/communications/base"
"github.com/thrasher-corp/gocryptotrader/config/versions"
"github.com/thrasher-corp/gocryptotrader/connchecker"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/currency/forexprovider"
@@ -30,9 +32,9 @@ import (
)
var (
// errExchangeConfigIsNil defines an error when the config is nil
errExchangeConfigIsNil = errors.New("exchange config is nil")
errPairsManagerIsNil = errors.New("currency pairs manager is nil")
errDecryptFailed = errors.New("failed to decrypt config after 3 attempts")
)
// GetCurrencyConfig returns currency configurations
@@ -40,28 +42,22 @@ func (c *Config) GetCurrencyConfig() currency.Config {
return c.Currency
}
// GetExchangeBankAccounts returns banking details associated with an exchange
// for depositing funds
// GetExchangeBankAccounts returns banking details associated with an exchange for depositing funds
func (c *Config) GetExchangeBankAccounts(exchangeName, id, depositingCurrency string) (*banking.Account, error) {
m.Lock()
defer m.Unlock()
e, err := c.GetExchangeConfig(exchangeName)
if err != nil {
return nil, err
}
for x := range c.Exchanges {
if strings.EqualFold(c.Exchanges[x].Name, exchangeName) {
for y := range c.Exchanges[x].BankAccounts {
if strings.EqualFold(c.Exchanges[x].BankAccounts[y].ID, id) {
if common.StringSliceCompareInsensitive(
strings.Split(c.Exchanges[x].BankAccounts[y].SupportedCurrencies, ","),
depositingCurrency) {
return &c.Exchanges[x].BankAccounts[y], nil
}
}
for y := range e.BankAccounts {
if strings.EqualFold(e.BankAccounts[y].ID, id) {
if common.StringSliceCompareInsensitive(strings.Split(e.BankAccounts[y].SupportedCurrencies, ","), depositingCurrency) {
return &e.BankAccounts[y], nil
}
}
}
return nil, fmt.Errorf("exchange %s bank details not found for %s",
exchangeName,
depositingCurrency)
return nil, fmt.Errorf("exchange %s bank details not found for %s", exchangeName, depositingCurrency)
}
// UpdateExchangeBankAccounts updates the configuration for the associated
@@ -840,263 +836,207 @@ func (c *Config) UpdateExchangeConfig(e *Exchange) error {
// exchanges
func (c *Config) CheckExchangeConfigValues() error {
if len(c.Exchanges) == 0 {
return errors.New("no exchange configs found")
return errNoEnabledExchanges
}
exchanges := 0
for i := range c.Exchanges {
if strings.EqualFold(c.Exchanges[i].Name, "GDAX") {
c.Exchanges[i].Name = "CoinbasePro"
}
e := &c.Exchanges[i]
// Check to see if the old API storage format is used
if c.Exchanges[i].APIKey != nil {
if e.APIKey != nil {
// It is, migrate settings to new format
c.Exchanges[i].API.AuthenticatedSupport = *c.Exchanges[i].AuthenticatedAPISupport
if c.Exchanges[i].AuthenticatedWebsocketAPISupport != nil {
c.Exchanges[i].API.AuthenticatedWebsocketSupport = *c.Exchanges[i].AuthenticatedWebsocketAPISupport
e.API.AuthenticatedSupport = *e.AuthenticatedAPISupport
if e.AuthenticatedWebsocketAPISupport != nil {
e.API.AuthenticatedWebsocketSupport = *e.AuthenticatedWebsocketAPISupport
}
c.Exchanges[i].API.Credentials.Key = *c.Exchanges[i].APIKey
c.Exchanges[i].API.Credentials.Secret = *c.Exchanges[i].APISecret
e.API.Credentials.Key = *e.APIKey
e.API.Credentials.Secret = *e.APISecret
if c.Exchanges[i].APIAuthPEMKey != nil {
c.Exchanges[i].API.Credentials.PEMKey = *c.Exchanges[i].APIAuthPEMKey
if e.APIAuthPEMKey != nil {
e.API.Credentials.PEMKey = *e.APIAuthPEMKey
}
if c.Exchanges[i].APIAuthPEMKeySupport != nil {
c.Exchanges[i].API.PEMKeySupport = *c.Exchanges[i].APIAuthPEMKeySupport
if e.APIAuthPEMKeySupport != nil {
e.API.PEMKeySupport = *e.APIAuthPEMKeySupport
}
if c.Exchanges[i].ClientID != nil {
c.Exchanges[i].API.Credentials.ClientID = *c.Exchanges[i].ClientID
if e.ClientID != nil {
e.API.Credentials.ClientID = *e.ClientID
}
// Flush settings
c.Exchanges[i].AuthenticatedAPISupport = nil
c.Exchanges[i].AuthenticatedWebsocketAPISupport = nil
c.Exchanges[i].APIKey = nil
c.Exchanges[i].APISecret = nil
c.Exchanges[i].ClientID = nil
c.Exchanges[i].APIAuthPEMKeySupport = nil
c.Exchanges[i].APIAuthPEMKey = nil
c.Exchanges[i].APIURL = nil
c.Exchanges[i].APIURLSecondary = nil
c.Exchanges[i].WebsocketURL = nil
e.AuthenticatedAPISupport = nil
e.AuthenticatedWebsocketAPISupport = nil
e.APIKey = nil
e.APISecret = nil
e.ClientID = nil
e.APIAuthPEMKeySupport = nil
e.APIAuthPEMKey = nil
e.APIURL = nil
e.APIURLSecondary = nil
e.WebsocketURL = nil
}
if c.Exchanges[i].Features == nil {
c.Exchanges[i].Features = &FeaturesConfig{}
if e.Features == nil {
e.Features = &FeaturesConfig{}
}
if c.Exchanges[i].SupportsAutoPairUpdates != nil {
c.Exchanges[i].Features.Supports.RESTCapabilities.AutoPairUpdates = *c.Exchanges[i].SupportsAutoPairUpdates
c.Exchanges[i].Features.Enabled.AutoPairUpdates = *c.Exchanges[i].SupportsAutoPairUpdates
c.Exchanges[i].SupportsAutoPairUpdates = nil
if e.SupportsAutoPairUpdates != nil {
e.Features.Supports.RESTCapabilities.AutoPairUpdates = *e.SupportsAutoPairUpdates
e.Features.Enabled.AutoPairUpdates = *e.SupportsAutoPairUpdates
e.SupportsAutoPairUpdates = nil
}
if c.Exchanges[i].Websocket != nil {
c.Exchanges[i].Features.Enabled.Websocket = *c.Exchanges[i].Websocket
c.Exchanges[i].Websocket = nil
if e.Websocket != nil {
e.Features.Enabled.Websocket = *e.Websocket
e.Websocket = nil
}
// Check if see if the new currency pairs format is empty and flesh it out if so
if c.Exchanges[i].CurrencyPairs == nil {
c.Exchanges[i].CurrencyPairs = new(currency.PairsManager)
c.Exchanges[i].CurrencyPairs.Pairs = make(map[asset.Item]*currency.PairStore)
if err := e.CurrencyPairs.SetDelimitersFromConfig(); err != nil {
return fmt.Errorf("%s: %w", e.Name, err)
}
if c.Exchanges[i].PairsLastUpdated != nil {
c.Exchanges[i].CurrencyPairs.LastUpdated = *c.Exchanges[i].PairsLastUpdated
}
assets := e.CurrencyPairs.GetAssetTypes(false)
if len(assets) == 0 {
e.Enabled = false
log.Warnf(log.ConfigMgr, "%s no assets found, disabling...", e.Name)
continue
}
c.Exchanges[i].CurrencyPairs.ConfigFormat = c.Exchanges[i].ConfigCurrencyPairFormat
c.Exchanges[i].CurrencyPairs.RequestFormat = c.Exchanges[i].RequestCurrencyPairFormat
var availPairs, enabledPairs currency.Pairs
if c.Exchanges[i].AvailablePairs != nil {
availPairs = *c.Exchanges[i].AvailablePairs
}
if c.Exchanges[i].EnabledPairs != nil {
enabledPairs = *c.Exchanges[i].EnabledPairs
}
c.Exchanges[i].CurrencyPairs.UseGlobalFormat = true
err := c.Exchanges[i].CurrencyPairs.Store(asset.Spot, &currency.PairStore{
AssetEnabled: convert.BoolPtr(true),
Available: availPairs,
Enabled: enabledPairs,
})
if err != nil {
return err
}
// flush old values
c.Exchanges[i].PairsLastUpdated = nil
c.Exchanges[i].ConfigCurrencyPairFormat = nil
c.Exchanges[i].RequestCurrencyPairFormat = nil
c.Exchanges[i].AssetTypes = nil
c.Exchanges[i].AvailablePairs = nil
c.Exchanges[i].EnabledPairs = nil
} else {
if err := c.Exchanges[i].CurrencyPairs.SetDelimitersFromConfig(); err != nil {
return fmt.Errorf("%s: %w", c.Exchanges[i].Name, err)
}
assets := c.Exchanges[i].CurrencyPairs.GetAssetTypes(false)
if len(assets) == 0 {
c.Exchanges[i].Enabled = false
log.Warnf(log.ConfigMgr, "%s no assets found, disabling...", c.Exchanges[i].Name)
continue
}
var atLeastOne bool
for index := range assets {
err := c.Exchanges[i].CurrencyPairs.IsAssetEnabled(assets[index])
if err != nil {
if errors.Is(err, currency.ErrAssetIsNil) {
// Checks if we have an old config without the ability to
// enable disable the entire asset
log.Warnf(log.ConfigMgr,
"Exchange %s: upgrading config for asset type %s and setting enabled.\n",
c.Exchanges[i].Name,
assets[index])
err = c.Exchanges[i].CurrencyPairs.SetAssetEnabled(assets[index], true)
if err != nil {
return err
}
atLeastOne = true
}
continue
}
atLeastOne = true
}
if !atLeastOne {
// turn on an asset if all disabled
log.Warnf(log.ConfigMgr,
"%s assets disabled, turning on asset %s",
c.Exchanges[i].Name,
assets[0])
err := c.Exchanges[i].CurrencyPairs.SetAssetEnabled(assets[0], true)
if err != nil {
for _, a := range assets {
if err := e.CurrencyPairs.IsAssetEnabled(a); errors.Is(err, currency.ErrAssetIsNil) {
// Checks if we have an old config without the ability to enable disable the entire asset
log.Warnf(log.ConfigMgr, "Exchange %s: upgrading config for asset type %s and setting enabled.\n", e.Name, a)
if err := e.CurrencyPairs.SetAssetEnabled(a, true); err != nil {
return err
}
}
}
if c.Exchanges[i].Enabled {
if c.Exchanges[i].Name == "" {
log.Errorf(log.ConfigMgr, ErrExchangeNameEmpty, i)
c.Exchanges[i].Enabled = false
continue
if enabled := e.CurrencyPairs.GetAssetTypes(true); len(enabled) == 0 {
// turn on an asset if all disabled
log.Warnf(log.ConfigMgr, "%s assets disabled, turning on asset %s", e.Name, assets[0])
if err := e.CurrencyPairs.SetAssetEnabled(assets[0], true); err != nil {
return err
}
if (c.Exchanges[i].API.AuthenticatedSupport || c.Exchanges[i].API.AuthenticatedWebsocketSupport) &&
c.Exchanges[i].API.CredentialsValidator != nil {
var failed bool
if c.Exchanges[i].API.CredentialsValidator.RequiresKey &&
(c.Exchanges[i].API.Credentials.Key == "" || c.Exchanges[i].API.Credentials.Key == DefaultAPIKey) {
failed = true
}
if c.Exchanges[i].API.CredentialsValidator.RequiresSecret &&
(c.Exchanges[i].API.Credentials.Secret == "" || c.Exchanges[i].API.Credentials.Secret == DefaultAPISecret) {
failed = true
}
if c.Exchanges[i].API.CredentialsValidator.RequiresClientID &&
(c.Exchanges[i].API.Credentials.ClientID == DefaultAPIClientID || c.Exchanges[i].API.Credentials.ClientID == "") {
failed = true
}
if failed {
c.Exchanges[i].API.AuthenticatedSupport = false
c.Exchanges[i].API.AuthenticatedWebsocketSupport = false
log.Warnf(log.ConfigMgr, WarningExchangeAuthAPIDefaultOrEmptyValues, c.Exchanges[i].Name)
}
}
if !c.Exchanges[i].Features.Supports.RESTCapabilities.AutoPairUpdates &&
!c.Exchanges[i].Features.Supports.WebsocketCapabilities.AutoPairUpdates {
lastUpdated := convert.UnixTimestampToTime(c.Exchanges[i].CurrencyPairs.LastUpdated)
lastUpdated = lastUpdated.AddDate(0, 0, pairsLastUpdatedWarningThreshold)
if lastUpdated.Unix() <= time.Now().Unix() {
log.Warnf(log.ConfigMgr,
WarningPairsLastUpdatedThresholdExceeded,
c.Exchanges[i].Name,
pairsLastUpdatedWarningThreshold)
}
}
if c.Exchanges[i].HTTPTimeout <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s HTTP Timeout value not set, defaulting to %v.\n",
c.Exchanges[i].Name,
defaultHTTPTimeout)
c.Exchanges[i].HTTPTimeout = defaultHTTPTimeout
}
if c.Exchanges[i].WebsocketResponseCheckTimeout <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket response check timeout value not set, defaulting to %v.",
c.Exchanges[i].Name,
DefaultWebsocketResponseCheckTimeout)
c.Exchanges[i].WebsocketResponseCheckTimeout = DefaultWebsocketResponseCheckTimeout
}
if c.Exchanges[i].WebsocketResponseMaxLimit <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket response max limit value not set, defaulting to %v.",
c.Exchanges[i].Name,
DefaultWebsocketResponseMaxLimit)
c.Exchanges[i].WebsocketResponseMaxLimit = DefaultWebsocketResponseMaxLimit
}
if c.Exchanges[i].WebsocketTrafficTimeout <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket response traffic timeout value not set, defaulting to %v.",
c.Exchanges[i].Name,
DefaultWebsocketTrafficTimeout)
c.Exchanges[i].WebsocketTrafficTimeout = DefaultWebsocketTrafficTimeout
}
if c.Exchanges[i].Orderbook.WebsocketBufferLimit <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket orderbook buffer limit value not set, defaulting to %v.",
c.Exchanges[i].Name,
defaultWebsocketOrderbookBufferLimit)
c.Exchanges[i].Orderbook.WebsocketBufferLimit = defaultWebsocketOrderbookBufferLimit
}
if c.Exchanges[i].Orderbook.PublishPeriod == nil || c.Exchanges[i].Orderbook.PublishPeriod.Nanoseconds() < 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket orderbook publish period value not set, defaulting to %v.",
c.Exchanges[i].Name,
DefaultOrderbookPublishPeriod)
publishPeriod := DefaultOrderbookPublishPeriod
c.Exchanges[i].Orderbook.PublishPeriod = &publishPeriod
}
err := c.CheckPairConsistency(c.Exchanges[i].Name)
if err != nil {
log.Errorf(log.ConfigMgr,
"Exchange %s: CheckPairConsistency error: %s\n",
c.Exchanges[i].Name,
err)
c.Exchanges[i].Enabled = false
continue
}
for x := range c.Exchanges[i].BankAccounts {
if !c.Exchanges[i].BankAccounts[x].Enabled {
continue
}
err := c.Exchanges[i].BankAccounts[x].Validate()
if err != nil {
c.Exchanges[i].BankAccounts[x].Enabled = false
log.Warnln(log.ConfigMgr, err.Error())
}
}
exchanges++
}
if !e.Enabled {
continue
}
if e.Name == "" {
log.Errorf(log.ConfigMgr, "%s: #%d", errExchangeNameEmpty, i)
e.Enabled = false
continue
}
if (e.API.AuthenticatedSupport || e.API.AuthenticatedWebsocketSupport) &&
e.API.CredentialsValidator != nil {
var failed bool
if e.API.CredentialsValidator.RequiresKey &&
(e.API.Credentials.Key == "" || e.API.Credentials.Key == DefaultAPIKey) {
failed = true
}
if e.API.CredentialsValidator.RequiresSecret &&
(e.API.Credentials.Secret == "" || e.API.Credentials.Secret == DefaultAPISecret) {
failed = true
}
if e.API.CredentialsValidator.RequiresClientID &&
(e.API.Credentials.ClientID == DefaultAPIClientID || e.API.Credentials.ClientID == "") {
failed = true
}
if failed {
e.API.AuthenticatedSupport = false
e.API.AuthenticatedWebsocketSupport = false
log.Warnf(log.ConfigMgr, warningExchangeAuthAPIDefaultOrEmptyValues, e.Name)
}
}
if !e.Features.Supports.RESTCapabilities.AutoPairUpdates &&
!e.Features.Supports.WebsocketCapabilities.AutoPairUpdates {
lastUpdated := convert.UnixTimestampToTime(e.CurrencyPairs.LastUpdated)
lastUpdated = lastUpdated.AddDate(0, 0, pairsLastUpdatedWarningThreshold)
if lastUpdated.Unix() <= time.Now().Unix() {
log.Warnf(log.ConfigMgr,
warningPairsLastUpdatedThresholdExceeded,
e.Name,
pairsLastUpdatedWarningThreshold)
}
}
if e.HTTPTimeout <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s HTTP Timeout value not set, defaulting to %v.\n",
e.Name,
defaultHTTPTimeout)
e.HTTPTimeout = defaultHTTPTimeout
}
if e.WebsocketResponseCheckTimeout <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket response check timeout value not set, defaulting to %v.",
e.Name,
DefaultWebsocketResponseCheckTimeout)
e.WebsocketResponseCheckTimeout = DefaultWebsocketResponseCheckTimeout
}
if e.WebsocketResponseMaxLimit <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket response max limit value not set, defaulting to %v.",
e.Name,
DefaultWebsocketResponseMaxLimit)
e.WebsocketResponseMaxLimit = DefaultWebsocketResponseMaxLimit
}
if e.WebsocketTrafficTimeout <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket response traffic timeout value not set, defaulting to %v.",
e.Name,
DefaultWebsocketTrafficTimeout)
e.WebsocketTrafficTimeout = DefaultWebsocketTrafficTimeout
}
if e.Orderbook.WebsocketBufferLimit <= 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket orderbook buffer limit value not set, defaulting to %v.",
e.Name,
defaultWebsocketOrderbookBufferLimit)
e.Orderbook.WebsocketBufferLimit = defaultWebsocketOrderbookBufferLimit
}
if e.Orderbook.PublishPeriod == nil || e.Orderbook.PublishPeriod.Nanoseconds() < 0 {
log.Warnf(log.ConfigMgr,
"Exchange %s Websocket orderbook publish period value not set, defaulting to %v.",
e.Name,
DefaultOrderbookPublishPeriod)
publishPeriod := DefaultOrderbookPublishPeriod
e.Orderbook.PublishPeriod = &publishPeriod
}
err := c.CheckPairConsistency(e.Name)
if err != nil {
log.Errorf(log.ConfigMgr,
"Exchange %s: CheckPairConsistency error: %s\n",
e.Name,
err)
e.Enabled = false
continue
}
for x := range e.BankAccounts {
if !e.BankAccounts[x].Enabled {
continue
}
err := e.BankAccounts[x].Validate()
if err != nil {
e.BankAccounts[x].Enabled = false
log.Warnln(log.ConfigMgr, err.Error())
}
}
exchanges++
}
if exchanges == 0 {
return errors.New(ErrNoEnabledExchanges)
return errNoEnabledExchanges
}
return nil
}
@@ -1350,8 +1290,8 @@ func (c *Config) SetNTPCheck(input io.Reader) (string, error) {
defer m.Unlock()
reader := bufio.NewReader(input)
log.Warnln(log.ConfigMgr, "Your system time is out of sync, this may cause issues with trading")
log.Warnln(log.ConfigMgr, "How would you like to show future notifications? (a)lert at startup / (w)arn periodically / (d)isable")
fmt.Println("Your system time is out of sync, this may cause issues with trading")
fmt.Println("How would you like to show future notifications? (a)lert at startup / (w)arn periodically / (d)isable")
var resp string
answered := false
@@ -1376,8 +1316,7 @@ func (c *Config) SetNTPCheck(input io.Reader) (string, error) {
resp = "Future notifications for out of time sync has been disabled"
answered = true
default:
log.Warnln(log.ConfigMgr,
"Invalid option selected, please try again (a)lert / (w)arn / (d)isable")
fmt.Println("Invalid option selected, please try again (a)lert / (w)arn / (d)isable")
}
}
return resp, nil
@@ -1510,13 +1449,8 @@ func GetFilePath(configFile string) (configPath string, isImplicitDefaultPath bo
// config directory as `File` or `EncryptedFile` depending on whether the config
// is encrypted
func migrateConfig(configFile, targetDir string) (string, error) {
data, err := os.ReadFile(configFile)
if err != nil {
return "", err
}
var target string
if ConfirmECS(data) {
if IsFileEncrypted(configFile) {
target = EncryptedFile
} else {
target = File
@@ -1530,115 +1464,96 @@ func migrateConfig(configFile, targetDir string) (string, error) {
return configFile, nil
}
err = file.Move(configFile, target)
if err != nil {
if err := file.Move(configFile, target); err != nil {
return "", err
}
return target, nil
}
// ReadConfigFromFile reads the configuration from the given file
// if target file is encrypted, prompts for encryption key
// Also - if not in dryrun mode - it checks if the configuration needs to be encrypted
// and stores the file as encrypted, if necessary (prompting for encryption key)
func (c *Config) ReadConfigFromFile(configPath string, dryrun bool) error {
defaultPath, _, err := GetFilePath(configPath)
// ReadConfigFromFile loads Config from the path
// If encrypted, prompts for encryption key
// Unless dryrun checks if the configuration needs to be encrypted and resaves, prompting for key
func (c *Config) ReadConfigFromFile(path string, dryrun bool) error {
var err error
path, _, err = GetFilePath(path)
if err != nil {
return err
}
confFile, err := os.Open(defaultPath)
f, err := os.Open(path)
if err != nil {
return err
}
defer confFile.Close()
result, wasEncrypted, err := ReadConfig(confFile, func() ([]byte, error) { return PromptForConfigKey(false) })
if err != nil {
return fmt.Errorf("error reading config %w", err)
}
// Override values in the current config
*c = *result
defer f.Close()
if dryrun || wasEncrypted || c.EncryptConfig == fileEncryptionDisabled {
if err := c.readConfig(f); err != nil {
return err
}
if dryrun || c.EncryptConfig != fileEncryptionPrompt || IsFileEncrypted(path) {
return nil
}
if c.EncryptConfig == fileEncryptionPrompt {
confirm, err := promptForConfigEncryption()
if err != nil {
log.Errorf(log.ConfigMgr, "The encryption prompt failed, ignoring for now, next time we will prompt again. Error: %s\n", err)
return nil
}
if confirm {
c.EncryptConfig = fileEncryptionEnabled
return c.SaveConfigToFile(defaultPath)
}
c.EncryptConfig = fileEncryptionDisabled
err = c.SaveConfigToFile(defaultPath)
if err != nil {
log.Errorf(log.ConfigMgr, "Cannot save config. Error: %s\n", err)
}
}
return nil
return c.saveWithEncryptPrompt(path)
}
// ReadConfig verifies and checks for encryption and loads the config from a JSON object.
// Prompts for decryption key, if target data is encrypted.
// Returns the loaded configuration and whether it was encrypted.
func ReadConfig(configReader io.Reader, keyProvider func() ([]byte, error)) (*Config, bool, error) {
reader := bufio.NewReader(configReader)
pref, err := reader.Peek(len(EncryptConfirmString))
// readConfig loads config from a io.Reader into the config object
// versions manager will upgrade/downgrade if appropriate
// If encrypted, prompts for encryption key
func (c *Config) readConfig(d io.Reader) error {
j, err := io.ReadAll(d)
if err != nil {
return nil, false, err
return err
}
if !ConfirmECS(pref) {
// Read unencrypted configuration
decoder := json.NewDecoder(reader)
c := &Config{}
err = decoder.Decode(c)
return c, false, err
if IsEncrypted(j) {
if j, err = c.decryptConfig(j); err != nil {
return err
}
}
conf, err := readEncryptedConfWithKey(reader, keyProvider)
return conf, true, err
if j, err = versions.Manager.Deploy(context.Background(), j); err != nil {
return err
}
return json.Unmarshal(j, c)
}
// readEncryptedConf reads encrypted configuration and requests key from provider
func readEncryptedConfWithKey(reader *bufio.Reader, keyProvider func() ([]byte, error)) (*Config, error) {
fileData, err := io.ReadAll(reader)
if err != nil {
return nil, err
// saveWithEncryptPrompt will prompt the user if they want to encrypt their config
// If they agree, c.EncryptConfig is set to Enabled, the config is encrypted and saved
// Otherwise, c.EncryptConfig is set to Disabled and the file is resaved
func (c *Config) saveWithEncryptPrompt(path string) error {
if confirm, err := promptForConfigEncryption(); err != nil {
return nil //nolint:nilerr // Ignore encryption prompt failures; The user will be prompted again
} else if confirm {
c.EncryptConfig = fileEncryptionEnabled
return c.SaveConfigToFile(path)
}
c.EncryptConfig = fileEncryptionDisabled
return c.SaveConfigToFile(path)
}
// decryptConfig reads encrypted configuration and requests key from provider
func (c *Config) decryptConfig(j []byte) ([]byte, error) {
for range maxAuthFailures {
key, err := keyProvider()
f := c.EncryptionKeyProvider
if f == nil {
f = PromptForConfigKey
}
key, err := f(false)
if err != nil {
log.Errorf(log.ConfigMgr, "PromptForConfigKey err: %s", err)
continue
}
var c *Config
c, err = readEncryptedConf(bytes.NewReader(fileData), key)
d, err := c.decryptConfigData(j, key)
if err != nil {
log.Errorln(log.ConfigMgr, "Could not decrypt and deserialise data with given key. Invalid password?", err)
continue
}
return c, nil
return d, nil
}
return nil, errors.New("failed to decrypt config after 3 attempts")
}
func readEncryptedConf(reader io.Reader, key []byte) (*Config, error) {
c := &Config{}
data, err := c.decryptConfigData(reader, key)
if err != nil {
return nil, err
}
err = json.Unmarshal(data, c)
return c, err
return nil, errDecryptFailed
}
// SaveConfigToFile saves your configuration to your desired path as a JSON object.
@@ -1661,13 +1576,12 @@ func (c *Config) SaveConfigToFile(configPath string) error {
}
}
}()
return c.Save(provider, func() ([]byte, error) { return PromptForConfigKey(true) })
return c.Save(provider)
}
// Save saves your configuration to the writer as a JSON object
// with encryption, if configured
// Save saves your configuration to the writer as a JSON object with encryption, if configured
// If there is an error when preparing the data to store, the writer is never requested
func (c *Config) Save(writerProvider func() (io.Writer, error), keyProvider func() ([]byte, error)) error {
func (c *Config) Save(writerProvider func() (io.Writer, error)) error {
payload, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
@@ -1676,14 +1590,15 @@ func (c *Config) Save(writerProvider func() (io.Writer, error), keyProvider func
if c.EncryptConfig == fileEncryptionEnabled {
// Ensure we have the key from session or from user
if len(c.sessionDK) == 0 {
var key []byte
key, err = keyProvider()
if err != nil {
f := c.EncryptionKeyProvider
if f == nil {
f = PromptForConfigKey
}
var key, sessionDK, storedSalt []byte
if key, err = f(true); err != nil {
return err
}
var sessionDK, storedSalt []byte
sessionDK, storedSalt, err = makeNewSessionDK(key)
if err != nil {
if sessionDK, storedSalt, err = makeNewSessionDK(key); err != nil {
return err
}
c.sessionDK, c.storedSalt = sessionDK, storedSalt
@@ -1746,30 +1661,20 @@ func (c *Config) CheckRemoteControlConfig() {
// CheckConfig checks all config settings
func (c *Config) CheckConfig() error {
err := c.CheckLoggerConfig()
if err != nil {
log.Errorf(log.ConfigMgr,
"Failed to configure logger, some logging features unavailable: %s\n",
err)
if err := c.CheckLoggerConfig(); err != nil {
log.Errorf(log.ConfigMgr, "Failed to configure logger, some logging features unavailable: %s\n", err)
}
err = c.checkDatabaseConfig()
if err != nil {
log.Errorf(log.DatabaseMgr,
"Failed to configure database: %v",
err)
if err := c.checkDatabaseConfig(); err != nil {
log.Errorf(log.DatabaseMgr, "Failed to configure database: %v", err)
}
err = c.CheckExchangeConfigValues()
if err != nil {
return fmt.Errorf(ErrCheckingConfigValues, err)
if err := c.CheckExchangeConfigValues(); err != nil {
return fmt.Errorf("%w: %w", errCheckingConfigValues, err)
}
err = c.checkGCTScriptConfig()
if err != nil {
log.Errorf(log.ConfigMgr,
"Failed to configure gctscript, feature has been disabled: %s\n",
err)
if err := c.checkGCTScriptConfig(); err != nil {
log.Errorf(log.ConfigMgr, "Failed to configure gctscript, feature has been disabled: %s\n", err)
}
c.CheckConnectionMonitorConfig()
@@ -1782,15 +1687,12 @@ func (c *Config) CheckConfig() error {
c.CheckRemoteControlConfig()
c.CheckSyncManagerConfig()
err = c.CheckCurrencyConfigValues()
if err != nil {
if err := c.CheckCurrencyConfigValues(); err != nil {
return err
}
if c.GlobalHTTPTimeout <= 0 {
log.Warnf(log.ConfigMgr,
"Global HTTP Timeout value not set, defaulting to %v.\n",
defaultHTTPTimeout)
log.Warnf(log.ConfigMgr, "Global HTTP Timeout value not set, defaulting to %v.\n", defaultHTTPTimeout)
c.GlobalHTTPTimeout = defaultHTTPTimeout
}
@@ -1805,7 +1707,7 @@ func (c *Config) CheckConfig() error {
func (c *Config) LoadConfig(configPath string, dryrun bool) error {
err := c.ReadConfigFromFile(configPath, dryrun)
if err != nil {
return fmt.Errorf(ErrFailureOpeningConfig, configPath, err)
return fmt.Errorf("%w (%s): %w", ErrFailureOpeningConfig, configPath, err)
}
return c.CheckConfig()
}

View File

@@ -8,29 +8,39 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"github.com/buger/jsonparser"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/crypto"
"golang.org/x/crypto/scrypt"
"golang.org/x/term"
)
const (
// EncryptConfirmString has a the general confirmation string to allow us to
// see if the file is correctly encrypted
EncryptConfirmString = "THORS-HAMMER"
// SaltPrefix string
SaltPrefix = "~GCT~SO~SALTY~"
// SaltRandomLength is the number of random bytes to append after the prefix string
SaltRandomLength = 12
saltRandomLength = 12
)
errAESBlockSize = "config file data is too small for the AES required block size"
// Public errors
var (
ErrSettingEncryptConfig = errors.New("error setting EncryptConfig during encrypt config file")
)
var (
errAESBlockSize = errors.New("config file data is too small for the AES required block size")
errNoPrefix = errors.New("data does not start with Encryption Prefix")
errKeyIsEmpty = errors.New("key is empty")
errUserInput = errors.New("error getting user input")
// encryptionPrefix is a prefix to tell us the file is encrypted
encryptionPrefix = []byte("THORS-HAMMER")
saltPrefix = []byte("~GCT~SO~SALTY~")
)
// promptForConfigEncryption asks for encryption confirmation
// returns true if encryption was desired, false otherwise
func promptForConfigEncryption() (bool, error) {
log.Println("Would you like to encrypt your config file (y/n)?")
fmt.Println("Would you like to encrypt your config file (y/n)?")
input := ""
if _, err := fmt.Scanln(&input); err != nil {
@@ -40,52 +50,52 @@ func promptForConfigEncryption() (bool, error) {
return common.YesOrNo(input), nil
}
// Unencrypted provides the default key provider implementation for unencrypted files
func Unencrypted() ([]byte, error) {
return nil, errors.New("encryption key was requested, no key provided")
}
// PromptForConfigKey asks for configuration key
// if initialSetup is true, the password needs to be repeated
func PromptForConfigKey(initialSetup bool) ([]byte, error) {
var cryptoKey []byte
for {
log.Println("Please enter in your password: ")
pwPrompt := func(i *[]byte) error {
_, err := fmt.Scanln(i)
return err
}
var p1 []byte
err := pwPrompt(&p1)
func PromptForConfigKey(confirmKey bool) ([]byte, error) {
for range 3 {
key, err := getSensitiveInput("Please enter encryption key: ")
if err != nil {
return nil, err
return nil, fmt.Errorf("%w: %w", errUserInput, err)
}
if !initialSetup {
cryptoKey = p1
break
if len(key) == 0 {
continue
}
var p2 []byte
log.Println("Please re-enter your password: ")
err = pwPrompt(&p2)
if !confirmKey {
return key, nil
}
conf, err := getSensitiveInput("Please re-enter key: ")
if err != nil {
return nil, err
return nil, fmt.Errorf("%w: %w", errUserInput, err)
}
if bytes.Equal(p1, p2) {
cryptoKey = p1
break
if bytes.Equal(key, conf) {
return key, nil
}
log.Println("Passwords did not match, please try again.")
fmt.Println("Keys did not match, please try again.")
}
return cryptoKey, nil
return nil, fmt.Errorf("%w: %w", errUserInput, io.EOF)
}
// EncryptConfigFile encrypts configuration data that is parsed in with a key
// and returns it as a byte array with an error
// getSensitiveInput reads input from stdin, with echo off if stdin is a terminal
func getSensitiveInput(prompt string) (resp []byte, err error) {
fmt.Print(prompt)
defer fmt.Println()
if term.IsTerminal(int(os.Stdin.Fd())) {
return term.ReadPassword(int(os.Stdin.Fd()))
}
// Can't use bufio.* because it consumes the whole input in one go, even with s.Buffer(1)
for buf := make([]byte, 1); err == nil && buf[0] != '\n'; {
if _, err = os.Stdin.Read(buf); err == nil {
resp = append(resp, buf[0])
}
}
return bytes.TrimRight(resp, "\r\n"), err
}
// EncryptConfigFile encrypts json config data with a key
func EncryptConfigFile(configData, key []byte) ([]byte, error) {
sessionDK, salt, err := makeNewSessionDK(key)
if err != nil {
@@ -98,9 +108,14 @@ func EncryptConfigFile(configData, key []byte) ([]byte, error) {
return c.encryptConfigFile(configData)
}
// encryptConfigFile encrypts configuration data that is parsed in with a key
// and returns it as a byte array with an error
// encryptConfigFile encrypts json config data with a key
// The EncryptConfig field is set to config enabled (1)
func (c *Config) encryptConfigFile(configData []byte) ([]byte, error) {
configData, err := jsonparser.Set(configData, []byte("1"), "encryptConfig")
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrSettingEncryptConfig, err)
}
block, err := aes.NewCipher(c.sessionDK)
if err != nil {
return nil, err
@@ -115,42 +130,39 @@ func (c *Config) encryptConfigFile(configData []byte) ([]byte, error) {
stream := cipher.NewCFBEncrypter(block, iv)
stream.XORKeyStream(ciphertext[aes.BlockSize:], configData)
appendedFile := []byte(EncryptConfirmString)
appendedFile = append(appendedFile, c.storedSalt...)
appendedFile := append(bytes.Clone(encryptionPrefix), c.storedSalt...)
appendedFile = append(appendedFile, ciphertext...)
return appendedFile, nil
}
// DecryptConfigFile decrypts configuration data with the supplied key and
// returns the un-encrypted data as a byte array with an error
func DecryptConfigFile(configData, key []byte) ([]byte, error) {
reader := bytes.NewReader(configData)
return (&Config{}).decryptConfigData(reader, key)
// DecryptConfigFile decrypts config data with a key
func DecryptConfigFile(d, key []byte) ([]byte, error) {
return (&Config{}).decryptConfigData(d, key)
}
// decryptConfigData decrypts configuration data with the supplied key and
// returns the un-encrypted data as a byte array with an error
func (c *Config) decryptConfigData(configReader io.Reader, key []byte) ([]byte, error) {
err := skipECS(configReader)
if err != nil {
return nil, err
// decryptConfigData decrypts config data with a key
func (c *Config) decryptConfigData(d, key []byte) ([]byte, error) {
if !bytes.HasPrefix(d, encryptionPrefix) {
return d, errNoPrefix
}
origKey := key
configData, err := io.ReadAll(configReader)
d = bytes.TrimPrefix(d, encryptionPrefix)
sessionDK, storedSalt, err := makeNewSessionDK(key)
if err != nil {
return nil, err
}
if ConfirmSalt(configData) {
salt := make([]byte, len(SaltPrefix)+SaltRandomLength)
salt = configData[0:len(salt)]
if bytes.HasPrefix(d, saltPrefix) {
salt := make([]byte, len(saltPrefix)+saltRandomLength)
salt = d[0:len(salt)]
key, err = getScryptDK(key, salt)
if err != nil {
return nil, err
}
configData = configData[len(salt):]
d = d[len(salt):]
}
blockDecrypt, err := aes.NewCipher(key)
@@ -158,58 +170,49 @@ func (c *Config) decryptConfigData(configReader io.Reader, key []byte) ([]byte,
return nil, err
}
if len(configData) < aes.BlockSize {
return nil, errors.New(errAESBlockSize)
if len(d) < aes.BlockSize {
return nil, errAESBlockSize
}
iv := configData[:aes.BlockSize]
configData = configData[aes.BlockSize:]
iv, d := d[:aes.BlockSize], d[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(blockDecrypt, iv)
stream.XORKeyStream(configData, configData)
result := configData
stream.XORKeyStream(d, d)
sessionDK, storedSalt, err := makeNewSessionDK(origKey)
if err != nil {
return nil, err
}
c.sessionDK, c.storedSalt = sessionDK, storedSalt
return result, nil
return d, nil
}
// ConfirmSalt checks whether the encrypted data contains a salt
func ConfirmSalt(file []byte) bool {
return bytes.Contains(file, []byte(SaltPrefix))
// IsEncrypted returns if the data sequence is encrypted
func IsEncrypted(data []byte) bool {
return bytes.HasPrefix(data, encryptionPrefix)
}
// ConfirmECS confirms that the encryption confirmation string is found
func ConfirmECS(file []byte) bool {
return bytes.Contains(file, []byte(EncryptConfirmString))
}
// skipECS skips encryption confirmation string
// or errors, if the prefix wasn't found
func skipECS(file io.Reader) error {
buf := make([]byte, len(EncryptConfirmString))
if _, err := io.ReadFull(file, buf); err != nil {
return err
// IsFileEncrypted returns if the file is encrypted
// Returns false on error opening or reading
func IsFileEncrypted(f string) bool {
r, err := os.Open(f)
if err != nil {
return false
}
if string(buf) != EncryptConfirmString {
return errors.New("data does not start with ECS")
defer r.Close()
prefix := make([]byte, len(encryptionPrefix))
if _, err = io.ReadFull(r, prefix); err != nil {
return false
}
return nil
return bytes.Equal(prefix, encryptionPrefix)
}
func getScryptDK(key, salt []byte) ([]byte, error) {
if len(key) == 0 {
return nil, errors.New("key is empty")
return nil, errKeyIsEmpty
}
return scrypt.Key(key, salt, 32768, 8, 1, 32)
}
func makeNewSessionDK(key []byte) (dk, storedSalt []byte, err error) {
storedSalt, err = crypto.GetRandomSalt([]byte(SaltPrefix), SaltRandomLength)
storedSalt, err = crypto.GetRandomSalt(saltPrefix, saltRandomLength)
if err != nil {
return nil, nil, err
}

View File

@@ -2,128 +2,110 @@ package config
import (
"bytes"
"fmt"
"crypto/aes"
"io"
"os"
"path/filepath"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPromptForConfigEncryption(t *testing.T) {
t.Parallel()
confirm, err := promptForConfigEncryption()
if confirm {
t.Error("promptForConfigEncryption return incorrect bool")
}
if err == nil {
t.Error("Expected error as there is no input")
}
require.ErrorIs(t, err, io.EOF)
require.False(t, confirm)
}
func TestPromptForConfigKey(t *testing.T) {
t.Parallel()
byteyBite, err := PromptForConfigKey(true)
if err == nil && len(byteyBite) > 1 {
t.Errorf("PromptForConfigKey: %s", err)
}
withInteractiveResponse(t, "\n\n", func() {
_, err := PromptForConfigKey(false)
require.ErrorIs(t, err, io.EOF)
})
_, err = PromptForConfigKey(false)
if err == nil {
t.Error("Expected error")
}
withInteractiveResponse(t, "pass\n", func() {
k, err := PromptForConfigKey(false)
require.NoError(t, err)
assert.Equal(t, "pass", string(k))
})
withInteractiveResponse(t, "what\nwhat\n", func() {
k, err := PromptForConfigKey(true)
require.NoError(t, err)
assert.Equal(t, "what", string(k))
})
withInteractiveResponse(t, "what\nno\n", func() {
_, err := PromptForConfigKey(true)
require.ErrorIs(t, err, io.EOF, "PromptForConfigKey must EOF when asking for another input but none is given")
})
withInteractiveResponse(t, "what\nno\nwhat\nno\nwhat\nno\n", func() {
_, err := PromptForConfigKey(true)
require.ErrorIs(t, err, io.EOF, "PromptForConfigKey must EOF when asking for another input but none is given")
})
withInteractiveResponse(t, "what\nno\nwhat\nno\nwhat\nwhat\n", func() {
k, err := PromptForConfigKey(true)
require.NoError(t, err, "PromptForConfigKey must not error if the user eventually answers consistently")
assert.Equal(t, "what", string(k))
})
}
func TestEncryptConfigFile(t *testing.T) {
t.Parallel()
_, err := EncryptConfigFile([]byte("test"), nil)
if err == nil {
t.Fatal("Expected error")
}
require.ErrorIs(t, err, errKeyIsEmpty)
c := &Config{
sessionDK: []byte("a"),
}
_, err = c.encryptConfigFile([]byte("test"))
if err == nil {
t.Fatal("Expected error")
}
_, err = c.encryptConfigFile([]byte(`test`))
require.ErrorIs(t, err, ErrSettingEncryptConfig)
_, err = c.encryptConfigFile([]byte(`{"test":1}`))
require.Error(t, err)
require.IsType(t, aes.KeySizeError(1), err)
sessDk, salt, err := makeNewSessionDK([]byte("asdf"))
if err != nil {
t.Fatal(err)
}
require.NoError(t, err, "makeNewSessionDK must not error")
c = &Config{
sessionDK: sessDk,
storedSalt: salt,
}
_, err = c.encryptConfigFile([]byte("test"))
if err != nil {
t.Fatal(err)
}
_, err = c.encryptConfigFile([]byte(`{"test":1}`))
require.NoError(t, err)
}
func TestDecryptConfigFile(t *testing.T) {
t.Parallel()
result, err := EncryptConfigFile([]byte("test"), []byte("key"))
if err != nil {
t.Fatal(err)
}
e, err := EncryptConfigFile([]byte(`{"test":1}`), []byte("key"))
require.NoError(t, err)
_, err = DecryptConfigFile(result, nil)
if err == nil {
t.Fatal("Expected error")
}
d, err := DecryptConfigFile(e, []byte("key"))
require.NoError(t, err)
assert.Equal(t, `{"test":1,"encryptConfig":1}`, string(d), "encryptConfig should be set to 1 after first encryption")
_, err = DecryptConfigFile(e, nil)
require.ErrorIs(t, err, errKeyIsEmpty)
_, err = DecryptConfigFile([]byte("test"), nil)
if err == nil {
t.Fatal("Expected error")
}
require.ErrorIs(t, err, errNoPrefix)
_, err = DecryptConfigFile([]byte("test"), []byte("AAAAAAAAAAAAAAAA"))
if err == nil {
t.Fatalf("Expected %s", errAESBlockSize)
}
result, err = EncryptConfigFile([]byte("test"), []byte("key"))
if err != nil {
t.Fatal(err)
}
_, err = DecryptConfigFile(result, []byte("key"))
if err != nil {
t.Fatal(err)
}
_, err = DecryptConfigFile(encryptionPrefix, []byte("AAAAAAAAAAAAAAAA"))
require.ErrorIs(t, err, errAESBlockSize)
}
func TestConfirmECS(t *testing.T) {
func TestIsEncrypted(t *testing.T) {
t.Parallel()
ECStest := []byte(EncryptConfirmString)
if !ConfirmECS(ECStest) {
t.Errorf("TestConfirmECS: Error finding ECS.")
}
}
func TestRemoveECS(t *testing.T) {
t.Parallel()
ECStest := []byte(EncryptConfirmString)
reader := bytes.NewReader(ECStest)
err := skipECS(reader)
if err != nil {
t.Error(err)
}
// Attempt read
var buf []byte
_, err = reader.Read(buf)
if err != io.EOF {
t.Errorf("TestConfirmECS: Error ECS not deleted.")
}
assert.True(t, IsEncrypted(encryptionPrefix))
assert.False(t, IsEncrypted([]byte("mhmmm. Donuts.")))
}
func TestMakeNewSessionDK(t *testing.T) {
@@ -189,7 +171,7 @@ func TestEncryptTwiceReusesSaltButNewCipher(t *testing.T) {
t.Fatalf("Problem reading file %s: %s\n", enc2, err)
}
// length of prefix + salt
l := len(EncryptConfirmString+SaltPrefix) + SaltRandomLength
l := len(encryptionPrefix) + len(saltPrefix) + saltRandomLength
// Even though prefix, including salt with the random bytes is the same
if !bytes.Equal(data1[:l], data2[:l]) {
t.Error("Salt is not reused.")
@@ -208,44 +190,20 @@ func TestSaveAndReopenEncryptedConfig(t *testing.T) {
// Save encrypted config
enc := filepath.Join(tempDir, "encrypted.dat")
err := withInteractiveResponse(t, "pass\npass\n", func() error {
return c.SaveConfigToFile(enc)
withInteractiveResponse(t, "pass\npass\n", func() {
err := c.SaveConfigToFile(enc)
require.NoError(t, err, "SaveConfigToFile must not error")
})
if err != nil {
t.Fatalf("Problem storing config in file %s: %s\n", enc, err)
}
readConf := &Config{}
err = withInteractiveResponse(t, "pass\n", func() error {
withInteractiveResponse(t, "pass\n", func() {
// Load with no existing state, key is read from the prepared file
return readConf.ReadConfigFromFile(enc, true)
err := readConf.ReadConfigFromFile(enc, true)
require.NoError(t, err, "ReadConfigFromFile must not error")
})
// Verify
if err != nil {
t.Fatalf("Problem reading config in file %s: %s\n", enc, err)
}
if c.Name != readConf.Name || c.EncryptConfig != readConf.EncryptConfig {
t.Error("Loaded conf not the same as original")
}
}
// setAnswersFile sets the given file as the current stdin
// returns the close function to defer for reverting the stdin
func setAnswersFile(t *testing.T, answerFile string) func() {
t.Helper()
oldIn := os.Stdin
inputFile, err := os.Open(answerFile)
if err != nil {
t.Fatalf("Problem opening temp file at %s: %s\n", answerFile, err)
}
os.Stdin = inputFile
return func() {
inputFile.Close()
os.Stdin = oldIn
}
assert.Equal(t, "myCustomName", readConf.Name, "Name should be correct")
assert.Equal(t, 1, readConf.EncryptConfig, "EncryptConfig should be set correctly")
}
func TestReadConfigWithPrompt(t *testing.T) {
@@ -260,14 +218,13 @@ func TestReadConfigWithPrompt(t *testing.T) {
// Save config
testConfigFile := filepath.Join(tempDir, "config.json")
err := c.SaveConfigToFile(testConfigFile)
if err != nil {
t.Fatalf("Problem saving config file in %s: %s\n", tempDir, err)
}
require.NoError(t, err, "SaveConfigToFile must not error")
// Run the test
c = &Config{}
err = withInteractiveResponse(t, "y\npass\npass\n", func() error {
return c.ReadConfigFromFile(testConfigFile, false)
withInteractiveResponse(t, "y\npass\npass\n", func() {
err = c.ReadConfigFromFile(testConfigFile, false)
require.NoError(t, err, "ReadConfigFromFile must not error")
})
if err != nil {
t.Fatalf("Problem reading config file at %s: %s\n", testConfigFile, err)
@@ -281,33 +238,24 @@ func TestReadConfigWithPrompt(t *testing.T) {
if c.EncryptConfig != fileEncryptionEnabled {
t.Error("Config encryption flag should be set after prompts")
}
if !ConfirmECS(data) {
t.Error("Config file should be encrypted after prompts")
}
assert.True(t, IsEncrypted(data), "data should be encrypted after prompts")
}
func TestReadEncryptedConfigFromReader(t *testing.T) {
t.Parallel()
keyProvider := func() ([]byte, error) { return []byte("pass"), nil }
c := &Config{
EncryptionKeyProvider: func(_ bool) ([]byte, error) { return []byte("pass"), nil },
}
// Encrypted conf for: `{"name":"test"}` with key `pass`
confBytes := []byte{84, 72, 79, 82, 83, 45, 72, 65, 77, 77, 69, 82, 126, 71, 67, 84, 126, 83, 79, 126, 83, 65, 76, 84, 89, 126, 246, 110, 128, 3, 30, 168, 172, 160, 198, 176, 136, 62, 152, 155, 253, 176, 16, 48, 52, 246, 44, 29, 151, 47, 217, 226, 178, 12, 218, 113, 248, 172, 195, 232, 136, 104, 9, 199, 20, 4, 71, 4, 253, 249}
conf, encrypted, err := ReadConfig(bytes.NewReader(confBytes), keyProvider)
if err != nil {
t.Errorf("TestReadConfig %s", err)
}
if !encrypted {
t.Errorf("Expected encrypted config %s", err)
}
if conf.Name != "test" {
t.Errorf("Conf not properly loaded %s", err)
}
err := c.readConfig(bytes.NewReader(confBytes))
require.NoError(t, err)
assert.Equal(t, "test", c.Name)
// Change the salt
confBytes[20] = 0
conf, _, err = ReadConfig(bytes.NewReader(confBytes), keyProvider)
if err == nil {
t.Errorf("Expected unable to decrypt, but got %+v", conf)
}
err = c.readConfig(bytes.NewReader(confBytes))
require.ErrorIs(t, err, errDecryptFailed)
}
// TestSaveConfigToFileWithErrorInPasswordPrompt should preserve the original file
@@ -318,58 +266,36 @@ func TestSaveConfigToFileWithErrorInPasswordPrompt(t *testing.T) {
}
testData := []byte("testdata")
f, err := os.CreateTemp("", "")
if err != nil {
t.Fatal(err)
}
require.NoError(t, err, "CreateTemp must not error")
targetFile := f.Name()
defer os.Remove(targetFile)
_, err = io.Copy(f, bytes.NewReader(testData))
if err != nil {
t.Fatal(err)
}
err = f.Close()
if err != nil {
t.Fatal(err)
}
err = withInteractiveResponse(t, "\n\n", func() error {
require.NoError(t, err, "io.Copy must not error")
require.NoError(t, f.Close(), "file Close must not error")
withInteractiveResponse(t, "\n\n", func() {
err = c.SaveConfigToFile(targetFile)
if err == nil {
t.Error("Expected error")
}
return nil
require.ErrorIs(t, err, io.EOF, "SaveConfigToFile must not error")
})
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(targetFile)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(data, testData) {
t.Errorf("Expected contents %s, but was %s", testData, data)
}
require.NoError(t, err, "ReadFile must not error")
assert.Equal(t, testData, data)
}
func withInteractiveResponse(t *testing.T, response string, body func() error) error {
t.Helper()
// Answers to the prompt
responseFile, err := os.CreateTemp("", "*.in")
if err != nil {
return fmt.Errorf("problem creating temp file: %w", err)
}
_, err = responseFile.WriteString(response)
if err != nil {
return fmt.Errorf("problem writing to temp file at %s: %w", responseFile.Name(), err)
}
err = responseFile.Close()
if err != nil {
return fmt.Errorf("problem closing temp file at %s: %w", responseFile.Name(), err)
}
defer os.Remove(responseFile.Name())
// Temporarily replace Stdin with a custom input
cleanup := setAnswersFile(t, responseFile.Name())
defer cleanup()
return body()
func withInteractiveResponse(tb testing.TB, response string, fn func()) {
tb.Helper()
f, err := os.CreateTemp("", "*.in")
require.NoError(tb, err, "CreateTemp must not error")
defer f.Close()
defer os.Remove(f.Name())
_, err = f.WriteString(response)
require.NoError(tb, err, "WriteString must not error")
_, err = f.Seek(0, 0)
require.NoError(tb, err, "Seek must not error")
defer func(orig *os.File) { os.Stdin = orig }(os.Stdin)
os.Stdin = f
fn()
}

View File

@@ -93,13 +93,9 @@ func TestGetExchangeBankAccounts(t *testing.T) {
}},
}
_, err := cfg.GetExchangeBankAccounts(bfx, "", "USD")
if err != nil {
t.Error("GetExchangeBankAccounts error", err)
}
require.NoError(t, err)
_, err = cfg.GetExchangeBankAccounts("Not an exchange", "", "Not a currency")
if err == nil {
t.Error("GetExchangeBankAccounts, no error returned for invalid exchange")
}
require.ErrorIs(t, err, ErrExchangeNotFound)
}
func TestCheckBankAccountConfig(t *testing.T) {
@@ -1310,18 +1306,8 @@ func TestCheckExchangeConfigValues(t *testing.T) {
t.Fatal(err)
}
cfg.Exchanges[0].Name = "GDAX"
err = cfg.CheckExchangeConfigValues()
if err != nil {
t.Error(err)
}
if cfg.Exchanges[0].Name != "CoinbasePro" {
t.Error("exchange name should have been updated from GDAX to CoinbasePRo")
}
// Test API settings migration
sptr := func(s string) *string { return &s }
int64ptr := func(i int64) *int64 { return &i }
cfg.Exchanges[0].APIKey = sptr("awesomeKey")
cfg.Exchanges[0].APISecret = sptr("meowSecret")
@@ -1378,96 +1364,6 @@ func TestCheckExchangeConfigValues(t *testing.T) {
t.Error("unexpected values")
}
p1, err := currency.NewPairDelimiter(testPair, "-")
if err != nil {
t.Fatal(err)
}
// Test currency pair migration
setupPairs := func(emptyAssets bool) {
cfg.Exchanges[0].CurrencyPairs = nil
p := currency.Pairs{
p1,
}
cfg.Exchanges[0].PairsLastUpdated = int64ptr(1234567)
if !emptyAssets {
cfg.Exchanges[0].AssetTypes = sptr("spot")
}
cfg.Exchanges[0].AvailablePairs = &p
cfg.Exchanges[0].EnabledPairs = &p
cfg.Exchanges[0].ConfigCurrencyPairFormat = &currency.PairFormat{
Uppercase: true,
Delimiter: "-",
}
cfg.Exchanges[0].RequestCurrencyPairFormat = &currency.PairFormat{
Uppercase: false,
Delimiter: "~",
}
}
setupPairs(false)
err = cfg.CheckExchangeConfigValues()
if err != nil {
t.Error(err)
}
setupPairs(true)
err = cfg.CheckExchangeConfigValues()
if err != nil {
t.Error(err)
}
if cfg.Exchanges[0].CurrencyPairs.LastUpdated != 1234567 {
t.Error("last updated has wrong value")
}
pFmt := cfg.Exchanges[0].CurrencyPairs.ConfigFormat
if pFmt.Delimiter != "-" ||
!pFmt.Uppercase {
t.Error("unexpected config format values")
}
pFmt = cfg.Exchanges[0].CurrencyPairs.RequestFormat
if pFmt.Delimiter != "~" ||
pFmt.Uppercase {
t.Error("unexpected request format values")
}
if !cfg.Exchanges[0].CurrencyPairs.GetAssetTypes(false).Contains(asset.Spot) ||
!cfg.Exchanges[0].CurrencyPairs.UseGlobalFormat {
t.Error("unexpected results")
}
pairs, err := cfg.Exchanges[0].CurrencyPairs.GetPairs(asset.Spot, true)
if err != nil {
t.Fatal(err)
}
if len(pairs) == 0 || pairs.Join() != testPair {
t.Error("pairs not set properly")
}
pairs, err = cfg.Exchanges[0].CurrencyPairs.GetPairs(asset.Spot, false)
if err != nil {
t.Fatal(err)
}
if len(pairs) == 0 || pairs.Join() != testPair {
t.Error("pairs not set properly")
}
// Ensure that all old settings are flushed
if cfg.Exchanges[0].PairsLastUpdated != nil ||
cfg.Exchanges[0].ConfigCurrencyPairFormat != nil ||
cfg.Exchanges[0].RequestCurrencyPairFormat != nil ||
cfg.Exchanges[0].AssetTypes != nil ||
cfg.Exchanges[0].AvailablePairs != nil ||
cfg.Exchanges[0].EnabledPairs != nil {
t.Error("unexpected results")
}
// Test AutoPairUpdates
cfg.Exchanges[0].Features.Supports.RESTCapabilities.AutoPairUpdates = false
cfg.Exchanges[0].Features.Supports.WebsocketCapabilities.AutoPairUpdates = false
@@ -1644,15 +1540,11 @@ func TestCheckExchangeConfigValues(t *testing.T) {
cfg.Exchanges[0].CurrencyPairs.Pairs[asset.Spot].Enabled = nil
cfg.Exchanges[0].CurrencyPairs.Pairs[asset.Spot].AssetEnabled = convert.BoolPtr(false)
err = cfg.CheckExchangeConfigValues()
if err != nil {
t.Error(err)
}
require.NoError(t, err)
cfg.Exchanges[0].CurrencyPairs.Pairs = make(map[asset.Item]*currency.PairStore)
err = cfg.CheckExchangeConfigValues()
if err == nil {
t.Error("err cannot be nil")
}
assert.ErrorIs(t, err, errNoEnabledExchanges, "Exchanges without any pairs should be disabled")
}
func TestReadConfigFromFile(t *testing.T) {
@@ -1670,22 +1562,14 @@ func TestReadConfigFromFile(t *testing.T) {
func TestReadConfigFromReader(t *testing.T) {
t.Parallel()
c := &Config{}
confString := `{"name":"test"}`
conf, encrypted, err := ReadConfig(strings.NewReader(confString), Unencrypted)
if err != nil {
t.Errorf("TestReadConfig %s", err)
}
if encrypted {
t.Errorf("Expected unencrypted config %s", err)
}
if conf.Name != "test" {
t.Errorf("Conf not properly loaded %s", err)
}
err := c.readConfig(strings.NewReader(confString))
require.NoError(t, err)
assert.Equal(t, "test", c.Name)
_, _, err = ReadConfig(strings.NewReader("{}"), Unencrypted)
if err == nil {
t.Error("TestReadConfig error cannot be nil")
}
err = c.readConfig(strings.NewReader("{}"))
require.NoError(t, err, "Reading a config shorter than encryptionPrefix must not error EOF")
}
func TestLoadConfig(t *testing.T) {
@@ -2057,10 +1941,8 @@ func TestCheckCurrencyConfigValues(t *testing.T) {
func TestPreengineConfigUpgrade(t *testing.T) {
t.Parallel()
var c Config
if err := c.LoadConfig("../testdata/preengine_config.json", false); err != nil {
t.Fatal(err)
}
err := new(Config).LoadConfig("../testdata/preengine_config.json", false)
require.NoError(t, err)
}
func TestRemoveExchange(t *testing.T) {
@@ -2138,75 +2020,59 @@ func TestMigrateConfig(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
cleanup func(t *testing.T)
args args
want string
wantErr bool
wantErr error
}{
{
name: "nonexisting",
args: args{
configFile: "not-exists.json",
},
wantErr: true,
wantErr: os.ErrNotExist,
},
{
name: "source present, no target dir",
setup: func(t *testing.T) {
t.Helper()
test, err := os.Create("test.json")
if err != nil {
t.Fatal(err)
}
test.Close()
},
cleanup: func(t *testing.T) {
t.Helper()
os.Remove("test.json")
test, err := os.Create(filepath.Join(dir, "test.json"))
require.NoError(t, err, "os.Create must not error")
require.NoError(t, test.Close(), "file Close must not error")
},
args: args{
configFile: "test.json",
configFile: filepath.Join(dir, "test.json"),
targetDir: filepath.Join(dir, "new"),
},
want: filepath.Join(dir, "new", File),
wantErr: false,
want: filepath.Join(dir, "new", File),
},
{
name: "source same as target",
setup: func(t *testing.T) {
t.Helper()
err := file.Write(filepath.Join(dir, File), nil)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err, "file.Write must not error")
},
args: args{
configFile: filepath.Join(dir, File),
targetDir: dir,
},
want: filepath.Join(dir, File),
wantErr: false,
want: filepath.Join(dir, File),
},
{
name: "source and target present",
setup: func(t *testing.T) {
t.Helper()
err := file.Write(filepath.Join(dir, File), nil)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err, "file.Write must not error")
err = file.Write(filepath.Join(dir, "src", EncryptedFile), nil)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err, "file.Write must not error")
},
args: args{
configFile: filepath.Join(dir, "src", EncryptedFile),
targetDir: dir,
},
want: filepath.Join(dir, "src", EncryptedFile),
// We only expect warning
wantErr: false,
want: filepath.Join(dir, "src", EncryptedFile),
wantErr: nil, // We only expect warning
},
}
@@ -2215,19 +2081,13 @@ func TestMigrateConfig(t *testing.T) {
if tt.setup != nil {
tt.setup(t)
}
if tt.cleanup != nil {
defer tt.cleanup(t)
}
got, err := migrateConfig(tt.args.configFile, tt.args.targetDir)
if (err != nil) != tt.wantErr {
t.Errorf("migrateConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("migrateConfig() = %v, want %v", got, tt.want)
}
if err == nil && !file.Exists(got) {
t.Errorf("migrateConfig: %v should exist", got)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr, "migrateConfig must error correctly")
} else {
require.NoError(t, err, "migrateConfig must not error")
require.Equal(t, tt.want, got, "migrateConfig must return the correct file")
require.Truef(t, file.Exists(got), "migrateConfig return file `%s` must exist", got)
}
})
}

View File

@@ -58,12 +58,8 @@ const (
// Constants here hold some messages
const (
ErrExchangeNameEmpty = "exchange #%d name is empty"
ErrNoEnabledExchanges = "no exchanges enabled"
ErrFailureOpeningConfig = "fatal error opening %s file. Error: %s"
ErrCheckingConfigValues = "fatal error checking config values. Error: %s"
WarningExchangeAuthAPIDefaultOrEmptyValues = "exchange %s authenticated API support disabled due to default/empty APIKey/Secret/ClientID values"
WarningPairsLastUpdatedThresholdExceeded = "exchange %s last manual update of available currency pairs has exceeded %d days. Manual update required!"
warningExchangeAuthAPIDefaultOrEmptyValues = "exchange %s authenticated API support disabled due to default/empty APIKey/Secret/ClientID values"
warningPairsLastUpdatedThresholdExceeded = "exchange %s last manual update of available currency pairs has exceeded %d days. Manual update required!"
)
// Constants here define unset default values displayed in the config.json
@@ -78,19 +74,24 @@ const (
// Public errors exported by this package
var (
ErrExchangeNotFound = errors.New("exchange not found")
ErrExchangeNotFound = errors.New("exchange not found")
ErrFailureOpeningConfig = errors.New("fatal error opening file")
)
var (
cfg Config
m sync.Mutex
errNoEnabledExchanges = errors.New("no exchanges enabled")
errCheckingConfigValues = errors.New("fatal error checking config values")
errExchangeNameEmpty = errors.New("exchange name is empty")
)
// Config is the overarching object that holds all the information for
// prestart management of Portfolio, Communications, Webserver and Enabled
// Exchanges
// prestart management of Portfolio, Communications, Webserver and Enabled Exchanges
type Config struct {
Name string `json:"name"`
Version int `json:"version"`
DataDirectory string `json:"dataDirectory"`
EncryptConfig int `json:"encryptConfig"`
GlobalHTTPTimeout time.Duration `json:"globalHTTPTimeout"`
@@ -118,10 +119,14 @@ type Config struct {
Cryptocurrencies *currency.Currencies `json:"cryptocurrencies,omitempty"`
SMS *base.SMSGlobalConfig `json:"smsGlobal,omitempty"`
// encryption session values
storedSalt []byte
sessionDK []byte
storedSalt []byte
sessionDK []byte
EncryptionKeyProvider EncryptionKeyProvider `json:"-"`
}
// EncryptionKeyProvider is a function config can use to prompt the user for an encryption key
type EncryptionKeyProvider func(confirmKey bool) ([]byte, error)
// OrderManager holds settings used for the order manager
type OrderManager struct {
Enabled *bool `json:"enabled"`
@@ -197,24 +202,18 @@ type Exchange struct {
Orderbook Orderbook `json:"orderbook"`
// Deprecated settings which will be removed in a future update
AvailablePairs *currency.Pairs `json:"availablePairs,omitempty"`
EnabledPairs *currency.Pairs `json:"enabledPairs,omitempty"`
AssetTypes *string `json:"assetTypes,omitempty"`
PairsLastUpdated *int64 `json:"pairsLastUpdated,omitempty"`
ConfigCurrencyPairFormat *currency.PairFormat `json:"configCurrencyPairFormat,omitempty"`
RequestCurrencyPairFormat *currency.PairFormat `json:"requestCurrencyPairFormat,omitempty"`
AuthenticatedAPISupport *bool `json:"authenticatedApiSupport,omitempty"`
AuthenticatedWebsocketAPISupport *bool `json:"authenticatedWebsocketApiSupport,omitempty"`
APIKey *string `json:"apiKey,omitempty"`
APISecret *string `json:"apiSecret,omitempty"`
APIAuthPEMKeySupport *bool `json:"apiAuthPemKeySupport,omitempty"`
APIAuthPEMKey *string `json:"apiAuthPemKey,omitempty"`
APIURL *string `json:"apiUrl,omitempty"`
APIURLSecondary *string `json:"apiUrlSecondary,omitempty"`
ClientID *string `json:"clientId,omitempty"`
SupportsAutoPairUpdates *bool `json:"supportsAutoPairUpdates,omitempty"`
Websocket *bool `json:"websocket,omitempty"`
WebsocketURL *string `json:"websocketUrl,omitempty"`
AuthenticatedAPISupport *bool `json:"authenticatedApiSupport,omitempty"`
AuthenticatedWebsocketAPISupport *bool `json:"authenticatedWebsocketApiSupport,omitempty"`
APIKey *string `json:"apiKey,omitempty"`
APISecret *string `json:"apiSecret,omitempty"`
APIAuthPEMKeySupport *bool `json:"apiAuthPemKeySupport,omitempty"`
APIAuthPEMKey *string `json:"apiAuthPemKey,omitempty"`
APIURL *string `json:"apiUrl,omitempty"`
APIURLSecondary *string `json:"apiUrlSecondary,omitempty"`
ClientID *string `json:"clientId,omitempty"`
SupportsAutoPairUpdates *bool `json:"supportsAutoPairUpdates,omitempty"`
Websocket *bool `json:"websocket,omitempty"`
WebsocketURL *string `json:"websocketUrl,omitempty"`
}
// Profiler defines the profiler configuration to enable pprof

View File

@@ -0,0 +1,57 @@
package versions
import (
"context"
"errors"
)
// TestVersion1 is an empty and incompatible Version for testing
type TestVersion1 struct{}
// TestVersion2 is test fixture
type TestVersion2 struct {
ConfigErr bool
ExchErr bool
}
var (
errUpgrade = errors.New("do you expect me to talk?")
errDowngrade = errors.New("no, I expect you to die")
)
// UpgradeConfig errors if v.ConfigErr is true
func (v *TestVersion2) UpgradeConfig(_ context.Context, c []byte) ([]byte, error) {
if v.ConfigErr {
return c, errUpgrade
}
return c, nil
}
// DowngradeConfig errors if v.ConfigErr is true
func (v *TestVersion2) DowngradeConfig(_ context.Context, c []byte) ([]byte, error) {
if v.ConfigErr {
return c, errDowngrade
}
return c, nil
}
// Exchanges returns just Juan
func (v *TestVersion2) Exchanges() []string {
return []string{"Juan"}
}
// UpgradeExchange errors if ExchErr is true
func (v *TestVersion2) UpgradeExchange(_ context.Context, e []byte) ([]byte, error) {
if v.ExchErr {
return e, errUpgrade
}
return e, nil
}
// DowngradeExchange errors if ExchErr is true
func (v *TestVersion2) DowngradeExchange(_ context.Context, e []byte) ([]byte, error) {
if v.ExchErr {
return e, errDowngrade
}
return e, nil
}

24
config/versions/v0.go Normal file
View File

@@ -0,0 +1,24 @@
package versions
import (
"context"
)
// Version0 is a baseline version with no changes, so we can downgrade back to nothing
// It does not implement any upgrade interfaces
type Version0 struct {
}
func init() {
Manager.registerVersion(0, &Version0{})
}
// UpgradeConfig is an empty stub
func (v *Version0) UpgradeConfig(_ context.Context, j []byte) ([]byte, error) {
return j, nil
}
// DowngradeConfig is an empty stub
func (v *Version0) DowngradeConfig(_ context.Context, j []byte) ([]byte, error) {
return j, nil
}

View File

@@ -0,0 +1,17 @@
package v0
// Exchange contains a sub-section of exchange config
type Exchange struct {
AvailablePairs string `json:"availablePairs,omitempty"`
EnabledPairs string `json:"enabledPairs,omitempty"`
PairsLastUpdated int64 `json:"pairsLastUpdated,omitempty"`
ConfigCurrencyPairFormat *PairFormat `json:"configCurrencyPairFormat,omitempty"`
RequestCurrencyPairFormat *PairFormat `json:"requestCurrencyPairFormat,omitempty"`
}
// PairFormat contains pair formatting config
type PairFormat struct {
Uppercase bool `json:"uppercase"`
Delimiter string `json:"delimiter,omitempty"`
Separator string `json:"separator,omitempty"`
}

59
config/versions/v1.go Normal file
View File

@@ -0,0 +1,59 @@
package versions
import (
"context"
"encoding/json"
"github.com/buger/jsonparser"
v0 "github.com/thrasher-corp/gocryptotrader/config/versions/v0"
v1 "github.com/thrasher-corp/gocryptotrader/config/versions/v1"
)
// Version1 is an ExchangeVersion to upgrade currency pair format for exchanges
type Version1 struct {
}
func init() {
Manager.registerVersion(1, &Version1{})
}
// Exchanges returns all exchanges: "*"
func (v *Version1) Exchanges() []string { return []string{"*"} }
// UpgradeExchange will upgrade currency pair format
func (v *Version1) UpgradeExchange(_ context.Context, e []byte) ([]byte, error) {
if _, d, _, err := jsonparser.Get(e, "currencyPairs"); err == nil && d == jsonparser.Object {
return e, nil
}
d := &v0.Exchange{}
if err := json.Unmarshal(e, d); err != nil {
return e, err
}
p := &v1.PairsManager{
UseGlobalFormat: true,
LastUpdated: d.PairsLastUpdated,
ConfigFormat: d.ConfigCurrencyPairFormat,
RequestFormat: d.RequestCurrencyPairFormat,
Pairs: v1.FullStore{
"spot": {
Available: d.AvailablePairs,
Enabled: d.EnabledPairs,
},
},
}
j, err := json.Marshal(p)
if err != nil {
return e, err
}
for _, f := range []string{"pairsLastUpdated", "configCurrencyPairFormat", "requestCurrencyPairFormat", "assetTypes", "availablePairs", "enabledPairs"} {
e = jsonparser.Delete(e, f)
}
return jsonparser.Set(e, j, "currencyPairs")
}
// DowngradeExchange doesn't do anything for v1; There's no downgrade path since the original state is lossy and v1 was before versioning
func (v *Version1) DowngradeExchange(_ context.Context, e []byte) ([]byte, error) {
return e, nil
}

View File

@@ -0,0 +1,21 @@
package v1
import v0 "github.com/thrasher-corp/gocryptotrader/config/versions/v0"
// PairsManager contains exchange pair management config
type PairsManager struct {
BypassConfigFormatUpgrades bool `json:"bypassConfigFormatUpgrades"`
RequestFormat *v0.PairFormat `json:"requestFormat,omitempty"`
ConfigFormat *v0.PairFormat `json:"configFormat,omitempty"`
UseGlobalFormat bool `json:"useGlobalFormat,omitempty"`
LastUpdated int64 `json:"lastUpdated,omitempty"`
Pairs FullStore `json:"pairs"`
}
// FullStore contains a pair store by asset name
type FullStore map[string]struct {
Enabled string `json:"enabled"`
Available string `json:"available"`
RequestFormat *v0.PairFormat `json:"requestFormat,omitempty"`
ConfigFormat *v0.PairFormat `json:"configFormat,omitempty"`
}

View File

@@ -0,0 +1,31 @@
package versions
import (
"bytes"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVersion1Upgrade(t *testing.T) {
t.Parallel()
v := &Version1{}
in := []byte(`{"name":"Wibble","pairsLastUpdated":1566798411,"assetTypes":"spot","configCurrencyPairFormat":{"uppercase":true,"delimiter":"_"},"requestCurrencyPairFormat":{"uppercase":false,"delimiter":"_","separator":"-"},"enabledPairs":"LTC_BTC","availablePairs":"LTC_BTC,ETH_BTC,BTC_USD"}`)
exp := []byte(`{"name":"Wibble","currencyPairs":{"bypassConfigFormatUpgrades":false,"requestFormat":{"uppercase":false,"delimiter":"_","separator":"-"},"configFormat":{"uppercase":true,"delimiter":"_"},"useGlobalFormat":true,"lastUpdated":1566798411,"pairs":{"spot":{"enabled":"LTC_BTC","available":"LTC_BTC,ETH_BTC,BTC_USD"}}}}`)
out, err := v.UpgradeExchange(context.Background(), in)
require.NoError(t, err)
require.NotEmpty(t, out)
assert.Equal(t, string(exp), string(out))
}
func TestVersion1Downgrade(t *testing.T) {
t.Parallel()
in := []byte("just leave me alone, mkay?")
out, err := new(Version1).DowngradeExchange(context.Background(), bytes.Clone(in))
require.NoError(t, err)
assert.Equal(t, out, in)
}

38
config/versions/v2.go Normal file
View File

@@ -0,0 +1,38 @@
package versions
import (
"context"
"github.com/buger/jsonparser"
)
// Version2 is an ExchangeVersion to change the name of GDAX to CoinbasePro
type Version2 struct{}
func init() {
Manager.registerVersion(2, &Version2{})
}
const (
from = "GDAX"
to = "CoinbasePro"
)
// Exchanges returns just GDAX and CoinbasePro
func (v *Version2) Exchanges() []string { return []string{from, to} }
// UpgradeExchange will change the exchange name from GDAX to CoinbasePro
func (v *Version2) UpgradeExchange(_ context.Context, e []byte) ([]byte, error) {
if n, err := jsonparser.GetString(e, "name"); err == nil && n == from {
return jsonparser.Set(e, []byte(`"`+to+`"`), "name")
}
return e, nil
}
// DowngradeExchange will change the exchange name from CoinbasePro to GDAX
func (v *Version2) DowngradeExchange(_ context.Context, e []byte) ([]byte, error) {
if n, err := jsonparser.GetString(e, "name"); err == nil && n == to {
return jsonparser.Set(e, []byte(`"`+from+`"`), "name")
}
return e, nil
}

View File

@@ -0,0 +1,37 @@
package versions
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVersion2Upgrade(t *testing.T) {
t.Parallel()
for _, tt := range [][]string{
{"GDAX", "CoinbasePro"},
{"Kraken", "Kraken"},
{"CoinbasePro", "CoinbasePro"},
} {
out, err := new(Version2).UpgradeExchange(context.Background(), []byte(`{"name":"`+tt[0]+`"}`))
require.NoError(t, err)
require.NotEmpty(t, out)
assert.Equalf(t, `{"name":"`+tt[1]+`"}`, string(out), "Test exchange name %s", tt[0])
}
}
func TestVersion2Downgrade(t *testing.T) {
t.Parallel()
for _, tt := range [][]string{
{"GDAX", "GDAX"},
{"Kraken", "Kraken"},
{"CoinbasePro", "GDAX"},
} {
out, err := new(Version2).DowngradeExchange(context.Background(), []byte(`{"name":"`+tt[0]+`"}`))
require.NoError(t, err)
require.NotEmpty(t, out)
assert.Equalf(t, `{"name":"`+tt[1]+`"}`, string(out), "Test exchange name %s", tt[0])
}
}

198
config/versions/versions.go Normal file
View File

@@ -0,0 +1,198 @@
// Package versions handles config upgrades and downgrades
/*
- Versions must be stateful, and not rely upon type definitions in the config pkg
- Instead versions should localise types into vN/types.go to avoid issues with subsequent changes
- Versions must upgrade to the next version. Do not retrospectively change versions to match new type changes. Create a new version
- Versions must implement ExchangeVersion or ConfigVersion, and may implement both
*/
package versions
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"slices"
"strconv"
"sync"
"github.com/buger/jsonparser"
"github.com/thrasher-corp/gocryptotrader/common"
)
var (
errMissingVersion = errors.New("missing version")
errVersionIncompatible = errors.New("version does not implement ConfigVersion or ExchangeVersion")
errModifyingExchange = errors.New("error modifying exchange config")
errNoVersions = errors.New("error retrieving latest config version: No config versions are registered")
errApplyingVersion = errors.New("error applying version")
)
// ConfigVersion is a version that affects the general configuration
type ConfigVersion interface {
UpgradeConfig(context.Context, []byte) ([]byte, error)
DowngradeConfig(context.Context, []byte) ([]byte, error)
}
// ExchangeVersion is a version that affects specific exchange configurations
type ExchangeVersion interface {
Exchanges() []string // Use `*` for all exchanges
UpgradeExchange(context.Context, []byte) ([]byte, error)
DowngradeExchange(context.Context, []byte) ([]byte, error)
}
// manager contains versions registerVersioned during import init
type manager struct {
m sync.RWMutex
versions []any
}
// Manager is a public instance of the config version manager
var Manager = &manager{}
// Deploy upgrades or downgrades the config between versions
func (m *manager) Deploy(ctx context.Context, j []byte) ([]byte, error) {
if err := m.checkVersions(); err != nil {
return j, err
}
target, err := m.latest()
if err != nil {
return j, err
}
m.m.RLock()
defer m.m.RUnlock()
current64, err := jsonparser.GetInt(j, "version")
current := int(current64)
switch {
case errors.Is(err, jsonparser.KeyPathNotFoundError):
current = -1
case err != nil:
return j, fmt.Errorf("%w `version`: %w", common.ErrGettingField, err)
case target == current:
return j, nil
}
for current != target {
next := current + 1
action := "upgrade"
configMethod := ConfigVersion.UpgradeConfig
exchMethod := ExchangeVersion.UpgradeExchange
if target < current {
next = current - 1
action = "downgrade"
configMethod = ConfigVersion.DowngradeConfig
exchMethod = ExchangeVersion.DowngradeExchange
}
log.Printf("Running %s to config version %v\n", action, next)
patch := m.versions[next]
if cPatch, ok := patch.(ConfigVersion); ok {
if j, err = configMethod(cPatch, ctx, j); err != nil {
return j, fmt.Errorf("%w %s to %v: %w", errApplyingVersion, action, next, err)
}
}
if ePatch, ok := patch.(ExchangeVersion); ok {
if j, err = exchangeDeploy(ctx, ePatch, exchMethod, j); err != nil {
return j, fmt.Errorf("%w %s to %v: %w", errApplyingVersion, action, next, err)
}
}
current = next
if j, err = jsonparser.Set(j, []byte(strconv.Itoa(current)), "version"); err != nil {
return j, fmt.Errorf("%w `version` during %s to %v: %w", common.ErrSettingField, action, next, err)
}
}
log.Println("Version management finished")
return j, nil
}
func exchangeDeploy(ctx context.Context, patch ExchangeVersion, method func(ExchangeVersion, context.Context, []byte) ([]byte, error), j []byte) ([]byte, error) {
var errs error
wanted := patch.Exchanges()
var i int
eFunc := func(exchOrig []byte, _ jsonparser.ValueType, _ int, _ error) {
defer func() { i++ }()
name, err := jsonparser.GetString(exchOrig, "name")
if err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w: %w `name`: %w", errModifyingExchange, common.ErrGettingField, err))
return
}
for _, want := range wanted {
if want != "*" && want != name {
continue
}
exchNew, err := method(patch, ctx, exchOrig)
if err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w: %w", errModifyingExchange, err))
continue
}
if !bytes.Equal(exchNew, exchOrig) {
if j, err = jsonparser.Set(j, exchNew, "exchanges", "["+strconv.Itoa(i)+"]"); err != nil {
errs = common.AppendError(errs, fmt.Errorf("%w: %w `exchanges.[%d]`: %w", errModifyingExchange, common.ErrSettingField, i, err))
}
}
}
}
v, dataType, _, err := jsonparser.Get(j, "exchanges")
switch {
case errors.Is(err, jsonparser.KeyPathNotFoundError), dataType != jsonparser.Array:
return j, nil
case err != nil:
return j, fmt.Errorf("%w: %w `exchanges`: %w", errModifyingExchange, common.ErrGettingField, err)
}
if _, err := jsonparser.ArrayEach(bytes.Clone(v), eFunc); err != nil {
return j, err
}
return j, errs
}
// registerVersion takes instances of config versions and adds them to the registry
func (m *manager) registerVersion(ver int, v any) {
m.m.Lock()
defer m.m.Unlock()
if ver >= len(m.versions) {
m.versions = slices.Grow(m.versions, ver+1)[:ver+1]
}
m.versions[ver] = v
}
// latest returns the highest version number
func (m *manager) latest() (int, error) {
m.m.RLock()
defer m.m.RUnlock()
if len(m.versions) == 0 {
return 0, errNoVersions
}
return len(m.versions) - 1, nil
}
// checkVersions ensures that registered versions are consistent
func (m *manager) checkVersions() error {
m.m.RLock()
defer m.m.RUnlock()
for ver, v := range m.versions {
switch v.(type) {
case ExchangeVersion, ConfigVersion:
default:
return fmt.Errorf("%w: %v", errVersionIncompatible, ver)
}
if v == nil {
return fmt.Errorf("%w: v%v", errMissingVersion, ver)
}
}
return nil
}

View File

@@ -0,0 +1,113 @@
package versions
import (
"context"
"testing"
"github.com/buger/jsonparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/common"
)
func TestDeploy(t *testing.T) {
t.Parallel()
m := manager{}
_, err := m.Deploy(context.Background(), []byte(``))
assert.ErrorIs(t, err, errNoVersions)
m.registerVersion(1, &TestVersion1{})
_, err = m.Deploy(context.Background(), []byte(``))
require.ErrorIs(t, err, errVersionIncompatible)
m = manager{}
m.registerVersion(0, &Version0{})
_, err = m.Deploy(context.Background(), []byte(`not an object`))
require.ErrorIs(t, err, jsonparser.KeyPathNotFoundError, "Must throw the correct error trying to add version to bad json")
require.ErrorIs(t, err, common.ErrSettingField, "Must throw the correct error trying to add version to bad json")
require.ErrorContains(t, err, "version", "Must throw the correct error trying to add version to bad json")
_, err = m.Deploy(context.Background(), []byte(`{"version":"not an int"}`))
require.ErrorIs(t, err, common.ErrGettingField, "Must throw the correct error trying to get version from bad json")
in := []byte(`{"version":0,"exchanges":[{"name":"Juan"}]}`)
j, err := m.Deploy(context.Background(), in)
require.NoError(t, err)
require.Equal(t, string(in), string(j))
m.registerVersion(1, &Version1{})
j, err = m.Deploy(context.Background(), in)
require.NoError(t, err)
require.Contains(t, string(j), `"version":1`)
m.versions = m.versions[:1]
j, err = m.Deploy(context.Background(), j)
require.NoError(t, err)
require.Contains(t, string(j), `"version":0`)
m.versions = append(m.versions, &TestVersion2{ConfigErr: true, ExchErr: false}) // Bit hacky, but this will actually work
_, err = m.Deploy(context.Background(), j)
require.ErrorIs(t, err, errUpgrade)
m.versions[1] = &TestVersion2{ConfigErr: false, ExchErr: true}
_, err = m.Deploy(context.Background(), in)
require.Implements(t, (*ExchangeVersion)(nil), m.versions[1])
require.ErrorIs(t, err, errUpgrade)
}
// TestExchangeDeploy exercises exchangeDeploy
// There are a number of error paths we can't currently cover without exposing unacceptable risks to the hot-paths as well
func TestExchangeDeploy(t *testing.T) {
t.Parallel()
m := manager{}
_, err := m.Deploy(context.Background(), []byte(``))
assert.ErrorIs(t, err, errNoVersions)
v := &TestVersion2{}
in := []byte(`{"version":0,"exchanges":[{}]}`)
_, err = exchangeDeploy(context.Background(), v, ExchangeVersion.UpgradeExchange, in)
require.ErrorIs(t, err, errModifyingExchange)
require.ErrorIs(t, err, common.ErrGettingField)
require.ErrorIs(t, err, jsonparser.KeyPathNotFoundError)
require.ErrorContains(t, err, "`name`")
in = []byte(`{"version":0,"exchanges":[{"name":"Juan"},{"name":"Megashaft"}]}`)
_, err = exchangeDeploy(context.Background(), v, ExchangeVersion.UpgradeExchange, in)
require.NoError(t, err)
}
func TestRegisterVersion(t *testing.T) {
t.Parallel()
m := manager{}
m.registerVersion(0, &Version0{})
assert.NotEmpty(t, m.versions)
m.registerVersion(2, &TestVersion2{})
require.Equal(t, 3, len(m.versions), "Must allocate a space for missing version 1")
require.NotNil(t, m.versions[2], "Must put Version 2 in the correct slot")
require.Nil(t, m.versions[1], "Must leave Version 1 alone")
m.registerVersion(1, &TestVersion1{})
require.Equal(t, 3, len(m.versions), "Must leave len alone when registering out-of-sequence")
require.NotNil(t, m.versions[1], "Must put Version 1 in the correct slot")
}
func TestLatest(t *testing.T) {
t.Parallel()
m := manager{}
_, err := m.latest()
require.ErrorIs(t, err, errNoVersions)
m.registerVersion(0, &Version0{})
m.registerVersion(1, &Version1{})
v, err := m.latest()
require.NoError(t, err)
assert.Equal(t, 1, v)
m.registerVersion(2, &Version2{})
v, err = m.latest()
require.NoError(t, err)
assert.Equal(t, 2, v)
}

View File

@@ -10,6 +10,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/config"
)
@@ -269,9 +270,16 @@ func TestConfigAllJsonResponse(t *testing.T) {
var responseConfig config.Config
err = json.Unmarshal(body, &responseConfig)
assert.NoError(t, err, "Unmarshal should not error")
for _, e := range responseConfig.Exchanges {
for i, e := range responseConfig.Exchanges {
err = e.CurrencyPairs.SetDelimitersFromConfig()
assert.NoError(t, err, "SetDelimitersFromConfig should not error")
// Using require here makes it much easier to isolate differences per-exchange than below
// We look into pointers separately
for a, p := range e.CurrencyPairs.Pairs {
require.Equalf(t, c.Exchanges[i].CurrencyPairs.Pairs[a], p, "%s exchange Config CurrencyManager Pairs for asset %s must match api response", e.Name, a)
}
require.Equalf(t, c.Exchanges[i].CurrencyPairs, e.CurrencyPairs, "%s exchange Config CurrencyManager must match api response", e.Name)
require.Equalf(t, c.Exchanges[i], e, "%s exchange Config must match api response", e.Name) // require here makes it much easier to isolate differences than below
}
assert.Equal(t, c, responseConfig, "Config should match api response")
}

View File

@@ -134,7 +134,7 @@ func loadConfigWithSettings(settings *Settings, flagSet map[string]bool) (*confi
conf := &config.Config{}
err = conf.ReadConfigFromFile(filePath, settings.EnableDryRun)
if err != nil {
return nil, fmt.Errorf(config.ErrFailureOpeningConfig, filePath, err)
return nil, fmt.Errorf("%w %s: %w", config.ErrFailureOpeningConfig, filePath, err)
}
// Apply overrides from settings
if flagSet["datadir"] {

1
go.mod
View File

@@ -26,6 +26,7 @@ require (
github.com/volatiletech/null v8.0.0+incompatible
golang.org/x/crypto v0.29.0
golang.org/x/net v0.31.0
golang.org/x/term v0.26.0
golang.org/x/text v0.20.0
golang.org/x/time v0.8.0
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697

2
go.sum
View File

@@ -313,6 +313,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=