portfolio: Fix CryptoID balance issue and assertify tests (#1861)

* portfolio: Fix CryptoID balance issue and assertify tests

* portfolio: Expand context usage, enhance tests and a few other minor improvements

* portfolio: Further improvements and enhance common.IsValidCryptoAddress

* config, portfolio: Use v6.DefaultConfig, switch to context.WithCancel
This commit is contained in:
Adrian Gallagher
2025-03-28 12:41:01 +11:00
committed by GitHub
parent 06afde1460
commit cc05f7e6fd
16 changed files with 723 additions and 845 deletions

View File

@@ -77,8 +77,7 @@ func main() {
log.Println("Loaded config file.")
displayCurrency = cfg.Currency.FiatDisplayCurrency
port := portfolio.Base{}
port.Seed(cfg.Portfolio)
port := cfg.Portfolio
result := port.GetPortfolioSummary()
log.Println("Fetched portfolio data.")

View File

@@ -54,23 +54,24 @@ var (
// Public common Errors
var (
ErrNotYetImplemented = errors.New("not yet implemented")
ErrFunctionNotSupported = errors.New("unsupported wrapper function")
errInvalidCryptoCurrency = errors.New("invalid crypto currency")
ErrDateUnset = errors.New("date unset")
ErrStartAfterEnd = errors.New("start date after end date")
ErrStartEqualsEnd = errors.New("start date equals end date")
ErrStartAfterTimeNow = errors.New("start date is after current time")
ErrNilPointer = errors.New("nil pointer")
ErrEmptyParams = errors.New("empty parameters")
ErrCannotCalculateOffline = errors.New("cannot calculate offline, unsupported")
ErrNoResponse = errors.New("no response")
ErrTypeAssertFailure = errors.New("type assert failure")
ErrNoResults = errors.New("no results found")
ErrUnknownError = errors.New("unknown error")
ErrGettingField = errors.New("error getting field")
ErrSettingField = errors.New("error setting field")
ErrParsingWSField = errors.New("error parsing websocket field")
ErrNotYetImplemented = errors.New("not yet implemented")
ErrFunctionNotSupported = errors.New("unsupported wrapper function")
ErrAddressIsEmptyOrInvalid = errors.New("address is empty or invalid")
ErrUnsupportedCryptocurrency = errors.New("unsupported cryptocurrency") // TODO: Remove me, used because of an import cycle if we use the currency package
ErrDateUnset = errors.New("date unset")
ErrStartAfterEnd = errors.New("start date after end date")
ErrStartEqualsEnd = errors.New("start date equals end date")
ErrStartAfterTimeNow = errors.New("start date is after current time")
ErrNilPointer = errors.New("nil pointer")
ErrEmptyParams = errors.New("empty parameters")
ErrCannotCalculateOffline = errors.New("cannot calculate offline, unsupported")
ErrNoResponse = errors.New("no response")
ErrTypeAssertFailure = errors.New("type assert failure")
ErrNoResults = errors.New("no results found")
ErrUnknownError = errors.New("unknown error")
ErrGettingField = errors.New("error getting field")
ErrSettingField = errors.New("error setting field")
ErrParsingWSField = errors.New("error parsing websocket field")
)
var (
@@ -190,17 +191,30 @@ func IsEnabled(isEnabled bool) string {
// IsValidCryptoAddress validates your cryptocurrency address string using the
// regexp package // Validation issues occurring because "3" is contained in
// litecoin and Bitcoin addresses - non-fatal
func IsValidCryptoAddress(address, crypto string) (bool, error) {
func IsValidCryptoAddress(address, crypto string) error {
var matched bool
var err error
switch strings.ToLower(crypto) {
case "btc":
return regexp.MatchString("^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,90}$", address)
matched, err = regexp.MatchString("^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,90}$", address)
case "ltc":
return regexp.MatchString("^[L3M][a-km-zA-HJ-NP-Z1-9]{25,34}$", address)
matched, err = regexp.MatchString("^[L3M][a-km-zA-HJ-NP-Z1-9]{25,34}$", address)
case "eth":
return regexp.MatchString("^0x[a-km-z0-9]{40}$", address)
matched, err = regexp.MatchString("^0x[a-km-z0-9]{40}$", address)
default:
return false, fmt.Errorf("%w %s", errInvalidCryptoCurrency, crypto)
return fmt.Errorf("%w: %q", ErrUnsupportedCryptocurrency, crypto)
}
if err != nil {
return err
}
if !matched {
return fmt.Errorf("%w: %q", ErrAddressIsEmptyOrInvalid, address)
}
return nil
}
// YesOrNo returns a boolean variable to check if input is "y" or "yes"

View File

@@ -137,101 +137,30 @@ func TestIsEnabled(t *testing.T) {
func TestIsValidCryptoAddress(t *testing.T) {
t.Parallel()
b, err := IsValidCryptoAddress("1Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX", "bTC")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if !b {
t.Errorf("expected address '%s' to be valid", "1Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX")
tests := []struct {
name, addr, code string
err error
}{
{"Valid BTC legacy", "1Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX", "bTC", nil},
{"Valid BTC bech32", "bc1qw508d6qejxtdg4y5r3zarvaly0c5xw7kv8f3t4", "bTC", nil},
{"Invalid BTC (too long)", "an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", "bTC", ErrAddressIsEmptyOrInvalid},
{"Valid BTC bech32 (longer)", "bc1qc7slrfxkknqcq2jevvvkdgvrt8080852dfjewde450xdlk4ugp7szw5tk9", "bTC", nil},
{"Invalid BTC (starts with 0)", "0Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX", "bTC", ErrAddressIsEmptyOrInvalid},
{"Invalid LTC (BTC address)", "1Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX", "lTc", ErrAddressIsEmptyOrInvalid},
{"Valid LTC", "3CDJNfdWX8m2NwuGUV3nhXHXEeLygMXoAj", "lTc", nil},
{"Invalid LTC (starts with N)", "NCDJNfdWX8m2NwuGUV3nhXHXEeLygMXoAj", "lTc", ErrAddressIsEmptyOrInvalid},
{"Valid ETH", "0xb794f5ea0ba39494ce839613fffba74279579268", "eth", nil},
{"Invalid ETH (starts with xx)", "xxb794f5ea0ba39494ce839613fffba74279579268", "eth", ErrAddressIsEmptyOrInvalid},
{"Unsupported crypto", "xxb794f5ea0ba39494ce839613fffba74279579268", "wif", ErrUnsupportedCryptocurrency},
{"Empty address", "", "btc", ErrAddressIsEmptyOrInvalid},
}
b, err = IsValidCryptoAddress("bc1qw508d6qejxtdg4y5r3zarvaly0c5xw7kv8f3t4", "bTC")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if !b {
t.Errorf("expected address '%s' to be valid", "bc1qw508d6qejxtdg4y5r3zarvaly0c5xw7kv8f3t4")
}
b, err = IsValidCryptoAddress("an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx", "bTC")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if b {
t.Errorf("expected address '%s' to be invalid", "an84characterslonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1569pvx")
}
b, err = IsValidCryptoAddress("bc1qc7slrfxkknqcq2jevvvkdgvrt8080852dfjewde450xdlk4ugp7szw5tk9", "bTC")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if !b {
t.Errorf("expected address '%s' to be valid", "bc1qc7slrfxkknqcq2jevvvkdgvrt8080852dfjewde450xdlk4ugp7szw5tk9")
}
b, err = IsValidCryptoAddress("0Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX", "btc")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if b {
t.Errorf("expected address '%s' to be invalid", "0Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX")
}
b, err = IsValidCryptoAddress("1Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX", "lTc")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if b {
t.Errorf("expected address '%s' to be invalid", "1Mz7153HMuxXTuR2R1t78mGSdzaAtNbBWX")
}
b, err = IsValidCryptoAddress("3CDJNfdWX8m2NwuGUV3nhXHXEeLygMXoAj", "ltc")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if !b {
t.Errorf("expected address '%s' to be valid", "3CDJNfdWX8m2NwuGUV3nhXHXEeLygMXoAj")
}
b, err = IsValidCryptoAddress("NCDJNfdWX8m2NwuGUV3nhXHXEeLygMXoAj", "lTc")
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if b {
t.Errorf("expected address '%s' to be invalid", "NCDJNfdWX8m2NwuGUV3nhXHXEeLygMXoAj")
}
b, err = IsValidCryptoAddress(
"0xb794f5ea0ba39494ce839613fffba74279579268",
"eth",
)
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if !b {
t.Errorf("expected address '%s' to be valid", "0xb794f5ea0ba39494ce839613fffba74279579268")
}
b, err = IsValidCryptoAddress(
"xxb794f5ea0ba39494ce839613fffba74279579268",
"eTh",
)
if !errors.Is(err, nil) {
t.Errorf("received '%v' expected '%v'", err, nil)
}
if b {
t.Errorf("expected address '%s' to be invalid", "xxb794f5ea0ba39494ce839613fffba74279579268")
}
b, err = IsValidCryptoAddress(
"xxb794f5ea0ba39494ce839613fffba74279579268",
"ding",
)
if !errors.Is(err, errInvalidCryptoCurrency) {
t.Errorf("received '%v' expected '%v'", err, errInvalidCryptoCurrency)
}
if b {
t.Errorf("expected address '%s' to be invalid", "xxb794f5ea0ba39494ce839613fffba74279579268")
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.ErrorIs(t, IsValidCryptoAddress(tc.addr, tc.code), tc.err)
})
}
}

View File

@@ -107,7 +107,7 @@ type Config struct {
Currency currency.Config `json:"currencyConfig"`
Communications base.CommunicationsConfig `json:"communications"`
RemoteControl RemoteControlConfig `json:"remoteControl"`
Portfolio portfolio.Base `json:"portfolioAddresses"`
Portfolio *portfolio.Base `json:"portfolioAddresses"`
Exchanges []Exchange `json:"exchanges"`
BankAccounts []banking.Account `json:"bankAccounts"`

View File

@@ -20,7 +20,7 @@ func init() {
// Note: Do not be tempted to use a constant for Duration. Whilst defaults are still written to config, we need to manage default upgrades discretely.
var defaultFuturesTrackingSeekDuration = strconv.FormatInt(int64(time.Hour)*24*365, 10)
var defaultConfig = []byte(`{
var defaultConfigV5 = []byte(`{
"enabled": true,
"verbose": false,
"activelyTrackFuturesPositions": true,
@@ -37,7 +37,7 @@ func (v *Version5) UpgradeConfig(_ context.Context, e []byte) ([]byte, error) {
_, valueType, _, err := jsonparser.Get(e, "orderManager", "enabled")
switch {
case errors.Is(err, jsonparser.KeyPathNotFoundError), valueType == jsonparser.Null:
return jsonparser.Set(e, defaultConfig, "orderManager")
return jsonparser.Set(e, defaultConfigV5, "orderManager")
case err != nil:
return e, err
}

34
config/versions/v6.go Normal file
View File

@@ -0,0 +1,34 @@
package versions
import (
"context"
"errors"
"github.com/buger/jsonparser"
v6 "github.com/thrasher-corp/gocryptotrader/config/versions/v6"
)
// Version6 implements ConfigVersion
type Version6 struct{}
func init() {
Manager.registerVersion(6, &Version6{})
}
// UpgradeConfig checks and upgrades the portfolioAddresses.providers field
func (v *Version6) UpgradeConfig(_ context.Context, e []byte) ([]byte, error) {
_, valueType, _, err := jsonparser.Get(e, "portfolioAddresses", "providers")
switch {
case errors.Is(err, jsonparser.KeyPathNotFoundError), valueType == jsonparser.Null:
return jsonparser.Set(e, v6.DefaultConfig, "portfolioAddresses", "providers")
case err != nil:
return e, err
}
return e, nil
}
// DowngradeConfig removes the portfolioAddresses.providers field
func (v *Version6) DowngradeConfig(_ context.Context, e []byte) ([]byte, error) {
e = jsonparser.Delete(e, "portfolioAddresses", "providers")
return e, nil
}

View File

@@ -0,0 +1,18 @@
package v6
// DefaultConfig is the default config used for the version 6 portfolio providers upgrade
var DefaultConfig = []byte(`[
{
"name": "Ethplorer",
"enabled": true
},
{
"name": "XRPScan",
"enabled": true
},
{
"name": "CryptoID",
"enabled": false,
"apiKey": "Key"
}
]`)

View File

@@ -0,0 +1,52 @@
package versions
import (
"bytes"
"context"
"testing"
"github.com/buger/jsonparser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v6 "github.com/thrasher-corp/gocryptotrader/config/versions/v6"
)
var testData = []byte(`{
"portfolioAddresses": {
"addresses": [
{
"Address": "1JCe8z4jJVNXSjohjM4i9Hh813dLCNx2Sy",
"CoinType": "BTC",
"Balance": 0.00108832,
"Description": "",
"WhiteListed": false,
"ColdStorage": false,
"SupportedExchanges": ""
}
]
}
}`)
func TestUpgrade(t *testing.T) {
t.Parallel()
r, err := new(Version6).UpgradeConfig(context.Background(), testData)
require.NoError(t, err, "UpgradeConfig must not error")
require.True(t, bytes.Contains(r, v6.DefaultConfig))
r2, err := new(Version6).UpgradeConfig(context.Background(), r)
require.NoError(t, err, "UpgradeConfig must not error")
assert.Equal(t, r, r2, "UpgradeConfig should not affect an already upgraded config")
}
func TestDowngrade(t *testing.T) {
t.Parallel()
r, err := new(Version6).UpgradeConfig(context.Background(), testData)
require.NoError(t, err, "UpgradeConfig must not error")
require.True(t, bytes.Contains(r, v6.DefaultConfig))
r, err = new(Version6).DowngradeConfig(context.Background(), r)
require.NoError(t, err, "DowngradeConfig must not error")
_, _, _, err = jsonparser.Get(r, "portfolioAddresses", "providers") //nolint:dogsled // Return values not needed
assert.ErrorIs(t, err, jsonparser.KeyPathNotFoundError, "providers should be removed")
}

View File

@@ -258,6 +258,21 @@
"ColdStorage": false,
"SupportedExchanges": ""
}
],
"providers": [
{
"name": "Ethplorer",
"enabled": true
},
{
"name": "XRPScan",
"enabled": true
},
{
"name": "CryptoID",
"enabled": false,
"apiKey": "Key"
}
]
},
"exchanges": [

View File

@@ -409,7 +409,7 @@ func (bot *Engine) Start() error {
if bot.Settings.EnablePortfolioManager {
if bot.portfolioManager == nil {
if p, err := setupPortfolioManager(bot.ExchangeManager, bot.Settings.PortfolioManagerDelay, &bot.Config.Portfolio); err != nil {
if p, err := setupPortfolioManager(bot.ExchangeManager, bot.Settings.PortfolioManagerDelay, bot.Config.Portfolio); err != nil {
gctlog.Errorf(gctlog.Global, "portfolio manager unable to setup: %s", err)
} else {
bot.portfolioManager = p
@@ -586,7 +586,7 @@ func (bot *Engine) Stop() {
gctlog.Debugln(gctlog.Global, "Engine shutting down..")
if len(bot.portfolioManager.GetAddresses()) != 0 {
bot.Config.Portfolio = *bot.portfolioManager.GetPortfolio()
bot.Config.Portfolio = bot.portfolioManager.GetPortfolio()
}
if bot.gctScriptManager.IsRunning() {

View File

@@ -166,7 +166,7 @@ func (bot *Engine) SetSubsystem(subSystemName string, enable bool) error {
case PortfolioManagerName:
if enable {
if bot.portfolioManager == nil {
bot.portfolioManager, err = setupPortfolioManager(bot.ExchangeManager, bot.Settings.PortfolioManagerDelay, &bot.Config.Portfolio)
bot.portfolioManager, err = setupPortfolioManager(bot.ExchangeManager, bot.Settings.PortfolioManagerDelay, bot.Config.Portfolio)
if err != nil {
return err
}

View File

@@ -41,6 +41,7 @@ func setupPortfolioManager(e *ExchangeManager, portfolioManagerDelay time.Durati
if portfolioManagerDelay <= 0 {
portfolioManagerDelay = PortfolioSleepDelay
}
if cfg == nil {
cfg = &portfolio.Base{Addresses: []portfolio.Address{}}
}
@@ -130,21 +131,14 @@ func (m *portfolioManager) processPortfolio() {
allExchangesHoldings := m.getExchangeAccountInfo(exchanges)
m.seedExchangeAccountInfo(allExchangesHoldings)
data := m.base.GetPortfolioGroupedCoin()
data := m.base.GetPortfolioAddressesGroupedByCoin()
for key, value := range data {
err := m.base.UpdatePortfolio(value, key)
if err != nil {
log.Errorf(log.PortfolioMgr,
"PortfolioWatcher error %s for currency %s\n",
err,
key)
if err := m.base.UpdatePortfolio(context.TODO(), value, key); err != nil {
log.Errorf(log.PortfolioMgr, "Portfolio manager: UpdatePortfolio error: %s for currency %s\n", err, key)
continue
}
log.Debugf(log.PortfolioMgr,
"Portfolio manager: Successfully updated address balance for %s address(es) %s\n",
key,
value)
log.Debugf(log.PortfolioMgr, "Portfolio manager: Successfully updated address balance for %s address(es) %s\n", key, value)
}
atomic.CompareAndSwapInt32(&m.processing, 1, 0)
}
@@ -182,7 +176,7 @@ func (m *portfolioManager) seedExchangeAccountInfo(accounts []account.Holdings)
}
for j := range currencies {
if !m.base.ExchangeAddressExists(accounts[x].Exchange, currencies[j].Currency) {
if !m.base.ExchangeAddressCoinExists(accounts[x].Exchange, currencies[j].Currency) {
if currencies[j].Total <= 0 {
continue
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/encoding/json"
"github.com/thrasher-corp/gocryptotrader/log"
"golang.org/x/time/rate"
)
const (
@@ -27,55 +28,71 @@ const (
ExchangeAddress = "Exchange"
// PersonalAddress is a label for a personal/offline address
PersonalAddress = "Personal"
defaultAPIKey = "Key"
defaultInterval = 10 * time.Second
)
var errNotEthAddress = errors.New("not an Ethereum address")
var (
errProviderNotFound = errors.New("provider not found")
errProviderNotEnabled = errors.New("provider not enabled")
errProviderAPIKeyNotSet = errors.New("provider API key not set")
errPortfolioItemNotFound = errors.New("portfolio item not found")
errNoPortfolioItemsToWatch = errors.New("no portfolio items to watch")
)
// GetEthereumBalance single or multiple address information as
// EtherchainBalanceResponse
func (b *Base) GetEthereumBalance(address string) (EthplorerResponse, error) {
valid, _ := common.IsValidCryptoAddress(address, "eth")
if !valid {
return EthplorerResponse{}, errNotEthAddress
// GetEthereumAddressBalance fetches Ethereum address balance for a given address
func (b *Base) GetEthereumAddressBalance(ctx context.Context, address string) (float64, error) {
if err := common.IsValidCryptoAddress(address, "eth"); err != nil {
return 0, err
}
urlPath := fmt.Sprintf(
"%s/%s/%s?apiKey=freekey", ethplorerAPIURL, ethplorerAddressInfo, address,
)
apiKey := "freekey"
if p, ok := b.Providers.GetProvider("ethplorer"); ok && p.APIKey != "" {
apiKey = p.APIKey
}
result := EthplorerResponse{}
contents, err := common.SendHTTPRequest(context.TODO(),
http.MethodGet,
urlPath,
nil,
nil,
b.Verbose)
urlPath := ethplorerAPIURL + "/" + ethplorerAddressInfo + "/" + address + "?apiKey=" + apiKey
contents, err := common.SendHTTPRequest(ctx, http.MethodGet, urlPath, nil, nil, b.Verbose)
if err != nil {
return result, err
return 0, err
}
return result, json.Unmarshal(contents, &result)
var result EthplorerResponse
if err := json.Unmarshal(contents, &result); err != nil {
return 0, err
}
return result.ETH.Balance, nil
}
// GetCryptoIDAddress queries CryptoID for an address balance for a
// specified cryptocurrency
func (b *Base) GetCryptoIDAddress(address string, coinType currency.Code) (float64, error) {
ok, err := common.IsValidCryptoAddress(address, coinType.String())
if !ok || err != nil {
return 0, errors.New("invalid address")
// GetCryptoIDAddressBalance fetches the address balance for a specified cryptocurrency
func (b *Base) GetCryptoIDAddressBalance(ctx context.Context, address string, coinType currency.Code) (float64, error) {
if err := common.IsValidCryptoAddress(address, coinType.String()); err != nil {
return 0, err
}
url := fmt.Sprintf("%s/%s/api.dws?q=getbalance&a=%s",
cryptoIDAPIURL,
coinType.Lower(),
address)
p, ok := b.Providers.GetProvider("cryptoid")
if !ok {
return 0, fmt.Errorf("cryptoid: %w", errProviderNotFound)
}
contents, err := common.SendHTTPRequest(context.TODO(),
http.MethodGet,
url,
nil,
nil,
b.Verbose)
if p.APIKey == "" || p.APIKey == defaultAPIKey {
return 0, fmt.Errorf("cryptoid: %w", errProviderAPIKeyNotSet)
}
b.cryptoIDLimiterOnce.Do(func() {
b.cryptoIDLimiter = rate.NewLimiter(rate.Every(10*time.Second), 1)
})
if err := b.cryptoIDLimiter.Wait(ctx); err != nil {
return 0, fmt.Errorf("rate limiter wait error: %w", err)
}
urlPath := cryptoIDAPIURL + "/" + coinType.Lower().String() + "/api.dws?q=getbalance&a=" + address + "&key=" + p.APIKey
contents, err := common.SendHTTPRequest(ctx, http.MethodGet, urlPath, nil, nil, b.Verbose)
if err != nil {
return 0, err
}
@@ -84,83 +101,78 @@ func (b *Base) GetCryptoIDAddress(address string, coinType currency.Code) (float
return result, json.Unmarshal(contents, &result)
}
// GetRippleBalance returns the value for a ripple address
func (b *Base) GetRippleBalance(address string) (float64, error) {
// GetRippleAddressBalance returns the value for a ripple address
func (b *Base) GetRippleAddressBalance(ctx context.Context, address string) (float64, error) {
contents, err := common.SendHTTPRequest(ctx, http.MethodGet, xrpScanAPIURL+address, nil, nil, b.Verbose)
if err != nil {
return 0, err
}
var result XRPScanAccount
contents, err := common.SendHTTPRequest(context.TODO(),
http.MethodGet,
xrpScanAPIURL+address,
nil,
nil,
b.Verbose)
if err != nil {
if err := json.Unmarshal(contents, &result); err != nil {
return 0, err
}
err = json.Unmarshal(contents, &result)
if err != nil {
return 0, err
}
if (result == XRPScanAccount{}) {
return 0, errors.New("no balance info returned")
}
return result.XRPBalance, nil
}
// GetAddressBalance accesses the portfolio base and returns the balance by passed
// in address, coin type and description
func (b *Base) GetAddressBalance(address, description string, coinType currency.Code) (float64, bool) {
for x := range b.Addresses {
if b.Addresses[x].Address == address &&
b.Addresses[x].Description == description &&
b.Addresses[x].CoinType.Equal(coinType) {
return b.Addresses[x].Balance, true
}
b.mtx.RLock()
defer b.mtx.RUnlock()
idx := slices.IndexFunc(b.Addresses, func(a Address) bool {
return a.Address == address && a.Description == description && a.CoinType.Equal(coinType)
})
if idx == -1 {
return 0, false
}
return 0, false
return b.Addresses[idx].Balance, true
}
// ExchangeExists checks to see if an exchange exists in the portfolio base
func (b *Base) ExchangeExists(exchangeName string) bool {
for x := range b.Addresses {
if b.Addresses[x].Address == exchangeName {
return true
}
}
return false
b.mtx.RLock()
defer b.mtx.RUnlock()
return slices.ContainsFunc(b.Addresses, func(a Address) bool {
return a.Address == exchangeName && a.Description == ExchangeAddress
})
}
// AddressExists checks to see if there is an address associated with the
// portfolio base
func (b *Base) AddressExists(address string) bool {
for x := range b.Addresses {
if b.Addresses[x].Address == address {
return true
}
}
return false
b.mtx.RLock()
defer b.mtx.RUnlock()
return slices.ContainsFunc(b.Addresses, func(a Address) bool {
return a.Address == address
})
}
// ExchangeAddressExists checks to see if there is an exchange address
// ExchangeAddressCoinExists checks to see if there is an exchange address
// associated with the portfolio base
func (b *Base) ExchangeAddressExists(exchangeName string, coinType currency.Code) bool {
for x := range b.Addresses {
if b.Addresses[x].Address == exchangeName && b.Addresses[x].CoinType.Equal(coinType) {
return true
}
}
return false
func (b *Base) ExchangeAddressCoinExists(exchangeName string, coinType currency.Code) bool {
b.mtx.RLock()
defer b.mtx.RUnlock()
return slices.ContainsFunc(b.Addresses, func(a Address) bool {
return a.Address == exchangeName && a.CoinType.Equal(coinType) && a.Description == ExchangeAddress
})
}
// AddExchangeAddress adds an exchange address to the portfolio base
func (b *Base) AddExchangeAddress(exchangeName string, coinType currency.Code, balance float64) {
if b.ExchangeAddressExists(exchangeName, coinType) {
if b.ExchangeAddressCoinExists(exchangeName, coinType) {
b.UpdateExchangeAddressBalance(exchangeName, coinType, balance)
return
}
b.mtx.Lock()
defer b.mtx.Unlock()
b.Addresses = append(b.Addresses, Address{
Address: exchangeName,
CoinType: coinType,
@@ -171,6 +183,9 @@ func (b *Base) AddExchangeAddress(exchangeName string, coinType currency.Code, b
// UpdateAddressBalance updates the portfolio base balance
func (b *Base) UpdateAddressBalance(address string, amount float64) {
b.mtx.Lock()
defer b.mtx.Unlock()
for x := range b.Addresses {
if b.Addresses[x].Address == address {
b.Addresses[x].Balance = amount
@@ -180,6 +195,9 @@ func (b *Base) UpdateAddressBalance(address string, amount float64) {
// RemoveExchangeAddress removes an exchange address from the portfolio.
func (b *Base) RemoveExchangeAddress(exchangeName string, coinType currency.Code) {
b.mtx.Lock()
defer b.mtx.Unlock()
b.Addresses = slices.Clip(slices.DeleteFunc(b.Addresses, func(a Address) bool {
return a.Address == exchangeName && a.CoinType.Equal(coinType)
}))
@@ -188,6 +206,9 @@ func (b *Base) RemoveExchangeAddress(exchangeName string, coinType currency.Code
// UpdateExchangeAddressBalance updates the portfolio balance when checked
// against correct exchangeName and coinType.
func (b *Base) UpdateExchangeAddressBalance(exchangeName string, coinType currency.Code, balance float64) {
b.mtx.Lock()
defer b.mtx.Unlock()
for x := range b.Addresses {
if b.Addresses[x].Address == exchangeName && b.Addresses[x].CoinType.Equal(coinType) {
b.Addresses[x].Balance = balance
@@ -195,14 +216,14 @@ func (b *Base) UpdateExchangeAddressBalance(exchangeName string, coinType curren
}
}
// AddAddress adds an address to the portfolio base
// AddAddress adds an address to the portfolio base or updates its balance if it already exists
func (b *Base) AddAddress(address, description string, coinType currency.Code, balance float64) error {
if address == "" {
return errors.New("address is empty")
return common.ErrAddressIsEmptyOrInvalid
}
if coinType.String() == "" {
return errors.New("coin type is empty")
if coinType.IsEmpty() {
return currency.ErrCurrencyCodeEmpty
}
if description == ExchangeAddress {
@@ -211,6 +232,9 @@ func (b *Base) AddAddress(address, description string, coinType currency.Code, b
}
if !b.AddressExists(address) {
b.mtx.Lock()
defer b.mtx.Unlock()
b.Addresses = append(b.Addresses, Address{
Address: address,
CoinType: coinType,
@@ -220,11 +244,6 @@ func (b *Base) AddAddress(address, description string, coinType currency.Code, b
return nil
}
if balance <= 0 {
if err := b.RemoveAddress(address, description, coinType); err != nil {
return err
}
}
b.UpdateAddressBalance(address, balance)
return nil
}
@@ -233,84 +252,86 @@ func (b *Base) AddAddress(address, description string, coinType currency.Code, b
// coinType
func (b *Base) RemoveAddress(address, description string, coinType currency.Code) error {
if address == "" {
return errors.New("address is empty")
return common.ErrAddressIsEmptyOrInvalid
}
if coinType.String() == "" {
return errors.New("coin type is empty")
if coinType.IsEmpty() {
return currency.ErrCurrencyCodeEmpty
}
b.mtx.Lock()
defer b.mtx.Unlock()
idx := slices.IndexFunc(b.Addresses, func(a Address) bool {
return a.Address == address && a.CoinType.Equal(coinType) && a.Description == description
})
if idx == -1 {
return errors.New("portfolio item does not exist")
return errPortfolioItemNotFound
}
b.Addresses = slices.Clip(slices.Delete(b.Addresses, idx, idx+1))
return nil
}
// UpdatePortfolio adds to the portfolio addresses by coin type
func (b *Base) UpdatePortfolio(addresses []string, coinType currency.Code) error {
if strings.Contains(strings.Join(addresses, ","), ExchangeAddress) ||
strings.Contains(strings.Join(addresses, ","), PersonalAddress) {
func (b *Base) UpdatePortfolio(ctx context.Context, addresses []string, coinType currency.Code) error {
if slices.ContainsFunc(addresses, func(a string) bool {
return a == PersonalAddress || a == ExchangeAddress
}) {
return nil
}
var providerName string
var getBalance func(ctx context.Context, address string) (float64, error)
switch coinType {
case currency.ETH:
for x := range addresses {
result, err := b.GetEthereumBalance(addresses[x])
if err != nil {
return err
}
if result.Error.Message != "" {
return errors.New(result.Error.Message)
}
err = b.AddAddress(addresses[x],
PersonalAddress,
coinType,
result.ETH.Balance)
if err != nil {
return err
}
}
providerName = "Ethplorer"
getBalance = b.GetEthereumAddressBalance
case currency.XRP:
for x := range addresses {
result, err := b.GetRippleBalance(addresses[x])
if err != nil {
return err
}
err = b.AddAddress(addresses[x],
PersonalAddress,
coinType,
result)
if err != nil {
return err
}
providerName = "XRPScan"
getBalance = b.GetRippleAddressBalance
case currency.BTC, currency.LTC:
providerName = "CryptoID"
getBalance = func(ctx context.Context, address string) (float64, error) {
return b.GetCryptoIDAddressBalance(ctx, address, coinType)
}
default:
for x := range addresses {
result, err := b.GetCryptoIDAddress(addresses[x], coinType)
if err != nil {
return err
}
err = b.AddAddress(addresses[x],
PersonalAddress,
coinType,
result)
if err != nil {
return err
}
return fmt.Errorf("%w: %s", currency.ErrCurrencyNotSupported, coinType)
}
p, ok := b.Providers.GetProvider(providerName)
if !ok {
return fmt.Errorf("%w: %s", errProviderNotFound, providerName)
}
if !p.Enabled {
return fmt.Errorf("%w: %s", errProviderNotEnabled, providerName)
}
if p.Name == "CryptoID" && (p.APIKey == "" || p.APIKey == defaultAPIKey) {
return fmt.Errorf("%w: %s", errProviderAPIKeyNotSet, providerName)
}
var errs error
for x := range addresses {
balance, err := getBalance(ctx, addresses[x])
if err != nil {
errs = common.AppendError(errs, fmt.Errorf("error getting balance for %s: %w", addresses[x], err))
continue
}
if err := b.AddAddress(addresses[x], PersonalAddress, coinType, balance); err != nil {
errs = common.AppendError(errs, fmt.Errorf("error adding address %s: %w", addresses[x], err))
}
}
return nil
return errs
}
// GetPortfolioByExchange returns currency portfolio amount by exchange
func (b *Base) GetPortfolioByExchange(exchangeName string) map[currency.Code]float64 {
b.mtx.RLock()
defer b.mtx.RUnlock()
result := make(map[currency.Code]float64)
for x := range b.Addresses {
if strings.Contains(b.Addresses[x].Address, exchangeName) {
@@ -322,6 +343,9 @@ func (b *Base) GetPortfolioByExchange(exchangeName string) map[currency.Code]flo
// GetExchangePortfolio returns current portfolio base information
func (b *Base) GetExchangePortfolio() map[currency.Code]float64 {
b.mtx.RLock()
defer b.mtx.RUnlock()
result := make(map[currency.Code]float64)
for i := range b.Addresses {
if b.Addresses[i].Description != ExchangeAddress {
@@ -339,6 +363,9 @@ func (b *Base) GetExchangePortfolio() map[currency.Code]float64 {
// GetPersonalPortfolio returns current portfolio base information
func (b *Base) GetPersonalPortfolio() map[currency.Code]float64 {
b.mtx.RLock()
defer b.mtx.RUnlock()
result := make(map[currency.Code]float64)
for i := range b.Addresses {
if strings.EqualFold(b.Addresses[i].Description, ExchangeAddress) {
@@ -460,8 +487,11 @@ func (b *Base) GetPortfolioSummary() Summary {
return portfolioOutput
}
// GetPortfolioGroupedCoin returns portfolio base information grouped by coin
func (b *Base) GetPortfolioGroupedCoin() map[currency.Code][]string {
// GetPortfolioAddressesGroupedByCoin returns portfolio addresses grouped by coin
func (b *Base) GetPortfolioAddressesGroupedByCoin() map[currency.Code][]string {
b.mtx.RLock()
defer b.mtx.RUnlock()
result := make(map[currency.Code][]string)
for i := range b.Addresses {
if strings.EqualFold(b.Addresses[i].Description, ExchangeAddress) {
@@ -472,70 +502,84 @@ func (b *Base) GetPortfolioGroupedCoin() map[currency.Code][]string {
return result
}
// Seed appends a portfolio base object with another base portfolio
// addresses
func (b *Base) Seed(port Base) {
b.Addresses = port.Addresses
}
// StartPortfolioWatcher observes the portfolio object
func (b *Base) StartPortfolioWatcher() {
addrCount := len(b.Addresses)
log.Debugf(log.PortfolioMgr,
"PortfolioWatcher started: Have %d entries in portfolio.\n", addrCount,
)
for {
data := b.GetPortfolioGroupedCoin()
for key, value := range data {
err := b.UpdatePortfolio(value, key)
if err != nil {
log.Errorf(log.PortfolioMgr,
"PortfolioWatcher error %s for currency %s, val %v\n",
err,
key,
value)
func (b *Base) StartPortfolioWatcher(ctx context.Context, interval time.Duration) error {
if len(b.Addresses) == 0 {
return errNoPortfolioItemsToWatch
}
if interval <= 0 {
interval = defaultInterval
}
log.Infof(log.PortfolioMgr, "PortfolioWatcher started: Have %d entries in portfolio.\n", len(b.Addresses))
updatePortfolio := func() {
for key, value := range b.GetPortfolioAddressesGroupedByCoin() {
if err := b.UpdatePortfolio(ctx, value, key); err != nil {
log.Errorf(log.PortfolioMgr, "PortfolioWatcher: UpdatePortfolio error: %s for currency %s", err, key)
continue
}
log.Debugf(log.PortfolioMgr,
"PortfolioWatcher: Successfully updated address balance for %s address(es) %s\n",
key,
value)
log.Debugf(log.PortfolioMgr, "PortfolioWatcher: Successfully updated address balance for %s", key)
}
}
updatePortfolio()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Debugf(log.PortfolioMgr, "PortfolioWatcher stopped: context cancelled")
return ctx.Err()
case <-ticker.C:
updatePortfolio()
}
time.Sleep(time.Minute * 10)
}
}
// IsExchangeSupported checks if exchange is supported by portfolio address
func (b *Base) IsExchangeSupported(exchange, address string) (ret bool) {
for x := range b.Addresses {
if b.Addresses[x].Address != address {
continue
func (b *Base) IsExchangeSupported(exchange, address string) bool {
b.mtx.RLock()
defer b.mtx.RUnlock()
return slices.ContainsFunc(b.Addresses, func(a Address) bool {
if a.Address != address {
return false
}
exchangeList := strings.Split(b.Addresses[x].SupportedExchanges, ",")
exchangeList := strings.Split(a.SupportedExchanges, ",")
return common.StringSliceContainsInsensitive(exchangeList, exchange)
}
return
})
}
// IsColdStorage checks if address is a cold storage wallet
func (b *Base) IsColdStorage(address string) bool {
for x := range b.Addresses {
if b.Addresses[x].Address != address {
continue
}
return b.Addresses[x].ColdStorage
}
return false
b.mtx.RLock()
defer b.mtx.RUnlock()
return slices.ContainsFunc(b.Addresses, func(a Address) bool {
return a.Address == address && a.ColdStorage
})
}
// IsWhiteListed checks if address is whitelisted for withdraw transfers
func (b *Base) IsWhiteListed(address string) bool {
for x := range b.Addresses {
if b.Addresses[x].Address != address {
continue
}
return b.Addresses[x].WhiteListed
}
return false
b.mtx.RLock()
defer b.mtx.RUnlock()
return slices.ContainsFunc(b.Addresses, func(a Address) bool {
return a.Address == address && a.WhiteListed
})
}
// GetProvider returns a provider by name
func (p providers) GetProvider(name string) (provider, bool) {
for _, provider := range p {
if strings.EqualFold(provider.Name, name) {
return provider, true
}
}
return provider{}, false
}

View File

@@ -1,496 +1,274 @@
package portfolio
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/core"
"github.com/thrasher-corp/gocryptotrader/currency"
)
const (
testBTCAddress = "0x1D01TH0R53"
testInvalidBTCAddress = "0x1D01TH0R53"
testLTCAddress = "LX2LMYXtuv5tiYEMztSSoEZcafFPYJFRK1"
testBTCAddress = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
testETHAddress = "0xb794f5ea0ba39494ce839613fffba74279579268"
testXRPAddress = "rs8ZPbYqgecRcDzQpJYAMhSxSi5htsjnza"
cryptoIDAPIKey = ""
)
func TestGetEthereumBalance(t *testing.T) {
func TestGetEthereumAddressBalance(t *testing.T) {
t.Parallel()
b := Base{}
address := "0xb794f5ea0ba39494ce839613fffba74279579268"
nonsenseAddress := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
response, err := b.GetEthereumBalance(address)
if err != nil {
t.Errorf("Portfolio GetEthereumBalance() Error: %s", err)
}
_, err := b.GetEthereumAddressBalance(t.Context(), testBTCAddress)
assert.ErrorIs(t, err, common.ErrAddressIsEmptyOrInvalid)
if response.Address != "0xb794f5ea0ba39494ce839613fffba74279579268" {
t.Error("Portfolio GetEthereumBalance() address invalid")
}
_, err = b.GetEthereumBalance(nonsenseAddress)
if !errors.Is(err, errNotEthAddress) {
t.Errorf("received '%v', expected '%v'", err, errNotEthAddress)
}
_, err = b.GetEthereumAddressBalance(t.Context(), testETHAddress)
assert.NoError(t, err, "GetEthereumAddressBalance should not error")
}
func TestGetCryptoIDBalance(t *testing.T) {
func TestGetCryptoIDAddressBalance(t *testing.T) {
t.Parallel()
b := Base{}
ltcAddress := "LX2LMYXtuv5tiYEMztSSoEZcafFPYJFRK1"
_, err := b.GetCryptoIDAddress(ltcAddress, currency.LTC)
if err != nil {
t.Fatalf("TestGetCryptoIDBalance error: %s", err)
_, err := b.GetCryptoIDAddressBalance(t.Context(), testInvalidBTCAddress, currency.BTC)
assert.ErrorIs(t, err, common.ErrAddressIsEmptyOrInvalid)
_, err = b.GetCryptoIDAddressBalance(t.Context(), testLTCAddress, currency.LTC)
assert.ErrorIs(t, err, errProviderNotFound)
b.Providers = providers{{Name: "CryptoID"}}
_, err = b.GetCryptoIDAddressBalance(t.Context(), testLTCAddress, currency.LTC)
assert.ErrorIs(t, err, errProviderAPIKeyNotSet)
b.Providers[0].APIKey = "bob"
ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(time.Nanosecond))
defer cancel()
_, err = b.GetCryptoIDAddressBalance(ctx, testLTCAddress, currency.LTC)
assert.True(t, errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "rate limiter wait error"),
"GetCryptoIDAddressBalance should return DeadlineExceeded or rate limiter wait error")
if cryptoIDAPIKey == "" {
t.Skip("Skipping test as CryptoID API key is not set")
}
b.Providers[0].APIKey = cryptoIDAPIKey
_, err = b.GetCryptoIDAddressBalance(t.Context(), testLTCAddress, currency.LTC)
assert.NoError(t, err, "GetCryptoIDAddressBalance should not error")
}
func TestGetRippleAddressBalance(t *testing.T) {
t.Parallel()
b := Base{}
_, err := b.GetRippleAddressBalance(t.Context(), testXRPAddress)
assert.NoError(t, err, "GetRippleAddressBalance should not error")
}
func TestGetAddressBalance(t *testing.T) {
t.Parallel()
ltcAddress := "LdP8Qox1VAhCzLJNqrr74YovaWYyNBUWvL"
ltc := currency.LTC
description := "Description of Wallet"
balance := float64(1000)
const (
description = "Description of Wallet"
balance = 1000.0
)
b := Base{}
err := b.AddAddress(ltcAddress, description, ltc, balance)
if err != nil {
t.Error(err)
}
assert.NoError(t, b.AddAddress(testLTCAddress, description, currency.LTC, balance))
addBalance, _ := b.GetAddressBalance("LdP8Qox1VAhCzLJNqrr74YovaWYyNBUWvL",
description,
ltc)
r, ok := b.GetAddressBalance("meow", description, currency.LTC)
assert.False(t, ok, "GetAddressBalance should return false for non-existent address")
assert.Zero(t, r, "GetAddressBalance should return 0 for non-existent address")
if addBalance != balance {
t.Error("Incorrect value")
}
addBalance, found := b.GetAddressBalance("WigWham",
description,
ltc)
if addBalance != 0 {
t.Error("Incorrect value")
}
if found {
t.Error("Incorrect value")
}
}
func TestGetRippleBalance(t *testing.T) {
t.Parallel()
b := Base{}
nonsenseAddress := "Wigwham"
_, err := b.GetRippleBalance(nonsenseAddress)
if err == nil {
t.Error("error cannot be nil on a bad address")
}
rippleAddress := "r962iS5subzbVeXZN8MTzyEuuaQKo5qksh"
_, err = b.GetRippleBalance(rippleAddress)
if err != nil {
t.Error(err)
}
r, ok = b.GetAddressBalance(testLTCAddress, description, currency.LTC)
assert.True(t, ok, "GetAddressBalance should return true for existing address")
assert.Equal(t, balance, r, "GetAddressBalance should return the correct balance")
}
func TestExchangeExists(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("someaddress",
currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"),
0.02)
if err != nil {
t.Error(err)
}
if !newBase.ExchangeExists("someaddress") {
t.Error("expected exchange to exist")
}
if newBase.ExchangeExists("bla") {
t.Error("expected exchange to not exist")
}
b := Base{}
assert.False(t, b.ExchangeExists("someaddress"))
b.AddExchangeAddress("someaddress", currency.LTC, 0.02)
assert.True(t, b.ExchangeExists("someaddress"))
}
func TestAddressExists(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("someaddress",
currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"),
0.02)
if err != nil {
t.Error(err)
}
if !newBase.AddressExists("someaddress") {
t.Error("expected address to exist")
}
if newBase.AddressExists("bla") {
t.Error("expected address to not exist")
}
b := Base{}
assert.False(t, b.AddressExists("meow"))
assert.NoError(t, b.AddAddress("someaddress", "desc", currency.NewCode("LTCWALLETTEST"), 0.02))
assert.True(t, b.AddressExists("someaddress"))
}
func TestExchangeAddressExists(t *testing.T) {
func TestExchangeAddressCoinExists(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("someaddress",
currency.LTC.String(),
currency.LTC,
0.02)
if err != nil {
t.Error(err)
}
if !newBase.ExchangeAddressExists("someaddress", currency.LTC) {
t.Error("expected exchange address to exist")
}
if newBase.ExchangeAddressExists("TEST", currency.LTC) {
t.Error("expected exchange address to not exist")
}
b := Base{}
assert.False(t, b.ExchangeAddressCoinExists("someaddress", currency.LTC))
b.AddExchangeAddress("someaddress", currency.LTC, 0.02)
assert.True(t, b.ExchangeAddressCoinExists("someaddress", currency.LTC))
assert.False(t, b.ExchangeAddressCoinExists("someaddress", currency.BTC))
}
func TestAddExchangeAddress(t *testing.T) {
t.Parallel()
newBase := Base{}
newBase.AddExchangeAddress("Okx", currency.BTC, 100)
newBase.AddExchangeAddress("Okx", currency.BTC, 200)
if !newBase.ExchangeAddressExists("Okx", currency.BTC) {
t.Error("address doesn't exist")
}
b := Base{}
b.AddExchangeAddress("someaddress", currency.LTC, 69)
bal, ok := b.GetAddressBalance("someaddress", ExchangeAddress, currency.LTC)
assert.True(t, ok, "GetAddressBalance should return true for existing address")
assert.Equal(t, 69.0, bal, "GetAddressBalance should return the correct balance")
b.AddExchangeAddress("someaddress", currency.LTC, 420)
bal, ok = b.GetAddressBalance("someaddress", ExchangeAddress, currency.LTC)
assert.True(t, ok, "GetAddressBalance should return true for existing address")
assert.Equal(t, 420.0, bal, "GetAddressBalance should return the correct balance")
}
func TestUpdateAddressBalance(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("someaddress",
currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"),
0.02)
if err != nil {
t.Error(err)
}
newBase.UpdateAddressBalance("someaddress", 0.03)
value := newBase.GetPortfolioSummary()
if !value.Totals[0].Coin.Equal(currency.LTC) &&
value.Totals[0].Balance != 0.03 {
t.Error("UpdateUpdateAddressBalance error")
}
}
func TestRemoveAddress(t *testing.T) {
t.Parallel()
var newBase Base
if err := newBase.RemoveAddress("", "MEOW", currency.LTC); err == nil {
t.Error("invalid address should throw an error")
}
if err := newBase.RemoveAddress("Gibson", "", currency.NewCode("")); err == nil {
t.Error("invalid coin type should throw an error")
}
if err := newBase.RemoveAddress("HIDDENERINO", "MEOW", currency.LTC); err == nil {
t.Error("non-existent address should throw an error")
}
err := newBase.AddAddress("someaddr",
currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"),
420)
if err != nil {
t.Error(err)
}
if !newBase.AddressExists("someaddr") {
t.Error("address does not exist")
}
err = newBase.RemoveAddress("someaddr",
currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"))
if err != nil {
t.Error(err)
}
if newBase.AddressExists("someaddr") {
t.Error("address should not exist")
}
b := Base{}
assert.NoError(t, b.AddAddress("someaddress", "desc", currency.LTC, 0.02))
b.UpdateAddressBalance("someaddress", 0.03)
bal, ok := b.GetAddressBalance("someaddress", "desc", currency.LTC)
assert.True(t, ok, "GetAddressBalance should return true for existing address")
assert.Equal(t, 0.03, bal, "GetAddressBalance should return the correct balance")
}
func TestRemoveExchangeAddress(t *testing.T) {
t.Parallel()
newBase := Base{}
exchangeName := "BallerExchange"
coinType := currency.LTC
newBase.AddExchangeAddress(exchangeName, coinType, 420)
if !newBase.ExchangeAddressExists(exchangeName, coinType) {
t.Error("address does not exist")
}
newBase.RemoveExchangeAddress(exchangeName, coinType)
if newBase.ExchangeAddressExists(exchangeName, coinType) {
t.Error("address should not exist")
}
b := Base{}
b.AddExchangeAddress("BallerExchange", currency.LTC, 420)
bal, ok := b.GetAddressBalance("BallerExchange", ExchangeAddress, currency.LTC)
assert.True(t, ok, "GetAddressBalance should return true for existing address")
assert.Equal(t, 420.0, bal, "GetAddressBalance should return the correct balance")
b.RemoveExchangeAddress("BallerExchange", currency.LTC)
bal, ok = b.GetAddressBalance("BallerExchange", ExchangeAddress, currency.LTC)
assert.False(t, ok, "GetAddressBalance should return false for non-existent address")
assert.Zero(t, bal, "GetAddressBalance should return 0 for non-existent address")
}
func TestUpdateExchangeAddressBalance(t *testing.T) {
t.Parallel()
newBase := Base{}
newBase.AddExchangeAddress("someaddress", currency.LTC, 0.02)
b := Base{}
b.Seed(newBase)
b.AddExchangeAddress("someaddress", currency.LTC, 0.02)
b.UpdateExchangeAddressBalance("someaddress", currency.LTC, 0.04)
value := b.GetPortfolioSummary()
if !value.Totals[0].Coin.Equal(currency.LTC) && value.Totals[0].Balance != 0.04 {
t.Error("incorrect portfolio balance")
}
bal, ok := b.GetAddressBalance("someaddress", ExchangeAddress, currency.LTC)
assert.True(t, ok, "GetAddressBalance should return true for existing address")
assert.Equal(t, 0.04, bal, "GetAddressBalance should return the correct balance")
}
func TestAddAddress(t *testing.T) {
t.Parallel()
var newBase Base
if err := newBase.AddAddress("", "MEOW", currency.LTC, 1); err == nil {
t.Error("invalid address should throw an error")
}
b := Base{}
assert.ErrorIs(t, b.AddAddress("", "desc", currency.LTC, 0.02), common.ErrAddressIsEmptyOrInvalid)
assert.ErrorIs(t, b.AddAddress("someaddress", "", currency.EMPTYCODE, 0.02), currency.ErrCurrencyCodeEmpty)
assert.NoError(t, b.AddAddress("okx", ExchangeAddress, currency.LTC, 0.02))
assert.True(t, b.ExchangeAddressCoinExists("okx", currency.LTC), "ExchangeAddressCoinExists should return true for an existing address and coin")
assert.NoError(t, b.AddAddress("someaddress", PersonalAddress, currency.LTC, 0.03))
assert.True(t, b.AddressExists("someaddress"), "AddressExists should return true for an existing address")
assert.NoError(t, b.AddAddress("someaddress", PersonalAddress, currency.LTC, 69))
bal, ok := b.GetAddressBalance("someaddress", PersonalAddress, currency.LTC)
assert.True(t, ok, "GetAddressBalance should return true for existing address")
assert.Equal(t, 69.0, bal, "GetAddressBalance should return the correct balance")
}
if err := newBase.AddAddress("Gibson", "", currency.NewCode(""), 1); err == nil {
t.Error("invalid coin type should throw an error")
}
// test adding an exchange address
err := newBase.AddAddress("COINUT", ExchangeAddress, currency.LTC, 0)
if err != nil {
t.Errorf("failed to add address: %v", err)
}
// add a test portfolio address and amount
err = newBase.AddAddress("Gibson",
currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"),
0.02)
if err != nil {
t.Error(err)
}
// test updating the balance and make sure it's reflected
err = newBase.AddAddress("Gibson", currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"), 0.05)
if err != nil {
t.Error(err)
}
b, _ := newBase.GetAddressBalance("Gibson", "LTC",
currency.NewCode("LTCWALLETTEST"))
if b != 0.05 {
t.Error("invalid portfolio amount")
}
nb := Base{}
nb.Seed(newBase)
if !nb.AddressExists("Gibson") {
t.Error("AddAddress error")
}
// Test updating balance to <= 0, expected result is to remove the address.
// Fail if address still exists.
err = newBase.AddAddress("Gibson",
currency.LTC.String(),
currency.NewCode("LTCWALLETTEST"),
-1)
if err != nil {
t.Error(err)
}
if newBase.AddressExists("Gibson") {
t.Error("AddAddress error")
}
func TestRemoveAddress(t *testing.T) {
t.Parallel()
b := Base{}
assert.ErrorIs(t, b.RemoveAddress("", "desc", currency.LTC), common.ErrAddressIsEmptyOrInvalid)
assert.ErrorIs(t, b.RemoveAddress("someaddress", "", currency.EMPTYCODE), currency.ErrCurrencyCodeEmpty)
assert.ErrorIs(t, b.RemoveAddress("someaddress", "desc", currency.LTC), errPortfolioItemNotFound)
assert.NoError(t, b.AddAddress("someaddress", "desc", currency.LTC, 0.02))
assert.NoError(t, b.RemoveAddress("someaddress", "desc", currency.LTC))
assert.False(t, b.AddressExists("someaddress"), "AddressExists should return false for non-existent address")
}
func TestUpdatePortfolio(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.UpdatePortfolio([]string{"Testy"}, currency.LTC)
if err == nil {
t.Error("UpdatePortfolio error cannot be nil")
}
err = newBase.UpdatePortfolio([]string{
"LdP8Qox1VAhCzLJNqrr74YovaWYyNBUWvL",
"LVa8wZ983PvWtdwXZ8viK6SocMENLCXkEy",
},
currency.LTC,
)
if err != nil {
t.Error("UpdatePortfolio error", err)
}
err = newBase.UpdatePortfolio(
[]string{"Testy"}, currency.LTC,
)
if err == nil {
t.Error("UpdatePortfolio error cannot be nil")
b := Base{
Providers: providers{
{
Name: "XRPScan",
Enabled: true,
},
{
Name: "Ethplorer",
Enabled: true,
},
},
}
err = newBase.UpdatePortfolio([]string{
"0xb794f5ea0ba39494ce839613fffba74279579268",
},
currency.ETH)
if err != nil {
t.Error(err)
}
err = newBase.UpdatePortfolio([]string{
"TESTY",
},
currency.ETH)
if err == nil {
t.Error("UpdatePortfolio error cannot be nil")
}
assert.NoError(t, b.UpdatePortfolio(t.Context(), []string{PersonalAddress, ExchangeAddress}, currency.LTC))
assert.NoError(t, b.UpdatePortfolio(t.Context(), []string{testETHAddress}, currency.ETH))
assert.NoError(t, b.UpdatePortfolio(t.Context(), []string{testXRPAddress}, currency.XRP))
assert.ErrorIs(t, b.UpdatePortfolio(t.Context(), []string{testETHAddress}, currency.ADA), currency.ErrCurrencyNotSupported)
assert.ErrorIs(t, b.UpdatePortfolio(t.Context(), []string{testBTCAddress}, currency.BTC), errProviderNotFound)
err = newBase.UpdatePortfolio([]string{
ExchangeAddress,
PersonalAddress,
},
currency.LTC)
if err != nil {
t.Error(err)
}
b.Providers = append(b.Providers, provider{
Name: "CryptoID",
})
err = newBase.UpdatePortfolio([]string{
"r962iS5subzbVeXZN8MTzyEuuaQKo5qksh",
},
currency.XRP)
if err != nil {
t.Error(err)
}
assert.ErrorIs(t, b.UpdatePortfolio(t.Context(), []string{testLTCAddress}, currency.LTC), errProviderNotEnabled)
b.Providers[2].Enabled = true
assert.ErrorIs(t, b.UpdatePortfolio(t.Context(), []string{testLTCAddress}, currency.LTC), errProviderAPIKeyNotSet)
err = newBase.UpdatePortfolio([]string{
"TESTY",
},
currency.XRP)
if err == nil {
t.Error("error cannot be nil")
if cryptoIDAPIKey == "" {
t.Skip("Skipping test as CryptoID API key is not set")
}
b.Providers[2].APIKey = cryptoIDAPIKey
assert.NoError(t, b.UpdatePortfolio(t.Context(), []string{testLTCAddress}, currency.LTC))
assert.NoError(t, b.UpdatePortfolio(t.Context(), []string{testBTCAddress}, currency.BTC))
}
func TestGetPortfolioByExchange(t *testing.T) {
t.Parallel()
newBase := Base{}
newBase.AddExchangeAddress("Okx", currency.LTC, 0.07)
newBase.AddExchangeAddress("Bitfinex", currency.LTC, 0.05)
err := newBase.AddAddress("someaddress", "LTC", currency.NewCode(PersonalAddress), 0.03)
if err != nil {
t.Fatal(err)
}
value := newBase.GetPortfolioByExchange("Okx")
result, ok := value[currency.LTC]
if !ok {
t.Error("missing portfolio entry")
}
if result != 0.07 {
t.Error("incorrect result")
}
value = newBase.GetPortfolioByExchange("Bitfinex")
result, ok = value[currency.LTC]
if !ok {
t.Error("missing portfolio entry")
}
if result != 0.05 {
t.Error("incorrect result")
}
b := Base{}
b.AddExchangeAddress("Okx", currency.LTC, 0.07)
b.AddExchangeAddress("Bitfinex", currency.LTC, 0.05)
assert.NoError(t, b.AddAddress("someaddress", "LTC", currency.NewCode(PersonalAddress), 0.03))
assert.Equal(t, 0.07, b.GetPortfolioByExchange("Okx")[currency.LTC], "GetPortfolioByExchange should return the correct balance")
assert.Equal(t, 0.05, b.GetPortfolioByExchange("Bitfinex")[currency.LTC], "GetPortfolioByExchange should return the correct balance")
}
func TestGetExchangePortfolio(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("Okx", ExchangeAddress, currency.LTC, 0.03)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("Bitfinex", ExchangeAddress, currency.LTC, 0.05)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("someaddress", PersonalAddress, currency.LTC, 0.03)
if err != nil {
t.Fatal(err)
}
value := newBase.GetExchangePortfolio()
result, ok := value[currency.LTC]
if !ok {
t.Error("missing portfolio entry")
}
if result != 0.08 {
t.Error("result != 0.08")
}
b := Base{}
assert.NoError(t, b.AddAddress("Okx", ExchangeAddress, currency.LTC, 0.03))
assert.NoError(t, b.AddAddress("Bitfinex", ExchangeAddress, currency.LTC, 0.05))
assert.NoError(t, b.AddAddress("someaddress", PersonalAddress, currency.LTC, 0.03))
assert.Equal(t, 0.08, b.GetExchangePortfolio()[currency.LTC], "GetExchangePortfolio should return the correct balance")
}
func TestGetPersonalPortfolio(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("someaddress", PersonalAddress, currency.N2O, 0.02)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("anotheraddress", PersonalAddress, currency.N2O, 0.03)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("Exchange", ExchangeAddress, currency.N2O, 0.01)
if err != nil {
t.Fatal(err)
}
value := newBase.GetPersonalPortfolio()
result, ok := value[currency.N2O]
if !ok {
t.Error("GetPersonalPortfolio error")
}
if result != 0.05 {
t.Error("GetPersonalPortfolio result != 0.05")
}
b := Base{}
assert.NoError(t, b.AddAddress("someaddress", PersonalAddress, currency.WIF, 0.02))
assert.NoError(t, b.AddAddress("anotheraddress", PersonalAddress, currency.WIF, 0.03))
assert.NoError(t, b.AddAddress("Exchange", ExchangeAddress, currency.WIF, 0.01))
assert.Equal(t, 0.05, b.GetPersonalPortfolio()[currency.WIF], "GetPersonalPortfolio should return the correct balance")
}
func TestGetPortfolioSummary(t *testing.T) {
t.Parallel()
newBase := Base{}
b := Base{}
// Personal holdings
err := newBase.AddAddress("someaddress", PersonalAddress, currency.LTC, 1)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("someaddress2", PersonalAddress, currency.LTC, 2)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("someaddress3", PersonalAddress, currency.BTC, 100)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae",
PersonalAddress, currency.ETH, 865346880000000000)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("0x9edc81c813b26165f607a8d1b8db87a02f34307f",
PersonalAddress, currency.ETH, 165346880000000000)
if err != nil {
t.Fatal(err)
}
assert.NoError(t, b.AddAddress("someaddress", PersonalAddress, currency.LTC, 1))
assert.NoError(t, b.AddAddress("someaddress2", PersonalAddress, currency.LTC, 2))
assert.NoError(t, b.AddAddress("someaddress3", PersonalAddress, currency.BTC, 100))
assert.NoError(t, b.AddAddress("0xde0b295669a9fd93d5f28d9ec85e40f4cb697bae", PersonalAddress, currency.ETH, 69))
assert.NoError(t, b.AddAddress("0x9edc81c813b26165f607a8d1b8db87a02f34307f", PersonalAddress, currency.ETH, 420))
// Exchange holdings
newBase.AddExchangeAddress("Bitfinex", currency.LTC, 20)
newBase.AddExchangeAddress("Bitfinex", currency.BTC, 100)
newBase.AddExchangeAddress("Okx", currency.ETH, 42)
b.AddExchangeAddress("Bitfinex", currency.LTC, 20)
b.AddExchangeAddress("Bitfinex", currency.BTC, 100)
b.AddExchangeAddress("Okx", currency.ETH, 42)
value := newBase.GetPortfolioSummary()
value := b.GetPortfolioSummary()
getTotalsVal := func(c currency.Code) Coin {
for x := range value.Totals {
@@ -501,147 +279,118 @@ func TestGetPortfolioSummary(t *testing.T) {
return Coin{}
}
if !getTotalsVal(currency.LTC).Coin.Equal(currency.LTC) {
t.Error("mismatched currency")
}
if getTotalsVal(currency.ETH).Coin.Equal(currency.LTC) {
t.Error("mismatched currency")
}
if getTotalsVal(currency.LTC).Balance != 23 {
t.Error("incorrect balance")
}
if getTotalsVal(currency.BTC).Balance != 200 {
t.Error("incorrect balance")
}
assert.Equal(t, currency.LTC, getTotalsVal(currency.LTC).Coin, "Coin should be LTC")
assert.Equal(t, 23.0, getTotalsVal(currency.LTC).Balance, "LTC balance should be correct")
assert.Equal(t, 200.0, getTotalsVal(currency.BTC).Balance, "BTC balance should be correct")
assert.Equal(t, 69.0+420.0+42, getTotalsVal(currency.ETH).Balance, "ETH balance should be correct")
}
func TestGetPortfolioGroupedCoin(t *testing.T) {
func TestGetPortfolioAddressesGroupedByCoin(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("someaddress", currency.LTC.String(), currency.LTC, 0.02)
if err != nil {
t.Fatal(err)
}
err = newBase.AddAddress("Exchange", ExchangeAddress, currency.LTC, 0.05)
if err != nil {
t.Fatal(err)
}
value := newBase.GetPortfolioGroupedCoin()
if value[currency.LTC][0] != "someaddress" && len(value[currency.LTC][0]) != 1 {
t.Error("incorrect balance")
}
}
func TestSeed(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("someaddress", currency.LTC.String(), currency.LTC, 0.02)
if err != nil {
t.Fatal(err)
}
if !newBase.AddressExists("someaddress") {
t.Error("Seed error")
}
b := Base{}
assert.NoError(t, b.AddAddress(testLTCAddress, PersonalAddress, currency.LTC, 0.02))
assert.NoError(t, b.AddAddress("Exchange", ExchangeAddress, currency.LTC, 0.03))
assert.Len(t, b.GetPortfolioAddressesGroupedByCoin(), 1, "GetPortfolioAddressesGroupedByCoin should return the correct number of addresses")
assert.Equal(t, testLTCAddress, b.GetPortfolioAddressesGroupedByCoin()[currency.LTC][0], "GetPortfolioAddressesGroupedByCoin should return the correct address")
}
func TestIsExchangeSupported(t *testing.T) {
t.Parallel()
newBase := seedPortFolioForTest(t)
ret := newBase.IsExchangeSupported("BTC Markets", core.BitcoinDonationAddress)
if !ret {
t.Fatal("expected IsExchangeSupported() to return true")
}
ret = newBase.IsExchangeSupported("Kraken", core.BitcoinDonationAddress)
if ret {
t.Fatal("expected IsExchangeSupported() to return false")
b := Base{
Addresses: []Address{
{
Address: core.BitcoinDonationAddress,
SupportedExchanges: "Binance, BTC Markets",
},
},
}
assert.True(t, b.IsExchangeSupported("Binance", core.BitcoinDonationAddress), "IsExchangeSupported should return true for supported exchange")
assert.False(t, b.IsExchangeSupported("Coinbase", core.BitcoinDonationAddress), "IsExchangeSupported should return false for unsupported exchange")
assert.False(t, b.IsExchangeSupported("Binance", testBTCAddress), "IsExchangeSupported should return false for non-existent address")
}
func TestIsColdStorage(t *testing.T) {
t.Parallel()
newBase := seedPortFolioForTest(t)
ret := newBase.IsColdStorage(core.BitcoinDonationAddress)
if !ret {
t.Fatal("expected IsColdStorage() to return true")
}
ret = newBase.IsColdStorage(testBTCAddress)
if ret {
t.Fatal("expected IsColdStorage() to return false")
}
ret = newBase.IsColdStorage("hello")
if ret {
t.Fatal("expected IsColdStorage() to return false")
b := Base{
Addresses: []Address{
{
Address: core.BitcoinDonationAddress,
ColdStorage: true,
},
{
Address: testBTCAddress,
},
},
}
assert.True(t, b.IsColdStorage(core.BitcoinDonationAddress), "IsColdStorage should return true for cold storage address")
assert.False(t, b.IsColdStorage(testBTCAddress), "IsColdStorage should return false for non-cold storage address")
}
func TestIsWhiteListed(t *testing.T) {
t.Parallel()
b := seedPortFolioForTest(t)
ret := b.IsWhiteListed(core.BitcoinDonationAddress)
if !ret {
t.Fatal("expected IsWhiteListed() to return true")
}
ret = b.IsWhiteListed(testBTCAddress)
if ret {
t.Fatal("expected IsWhiteListed() to return false")
}
ret = b.IsWhiteListed("hello")
if ret {
t.Fatal("expected IsWhiteListed() to return false")
b := Base{
Addresses: []Address{
{
Address: core.BitcoinDonationAddress,
WhiteListed: true,
},
{
Address: testBTCAddress,
},
},
}
assert.True(t, b.IsWhiteListed(core.BitcoinDonationAddress), "IsWhiteListed should return true for whitelisted address")
assert.False(t, b.IsWhiteListed(testBTCAddress), "IsWhiteListed should return false for non-whitelisted address")
}
func TestStartPortfolioWatcher(t *testing.T) {
t.Parallel()
newBase := Base{}
err := newBase.AddAddress("LX2LMYXtuv5tiYEMztSSoEZcafFPYJFRK1",
currency.LTC.String(),
currency.NewCode(PersonalAddress),
0.02)
if err != nil {
t.Error(err)
}
err = newBase.AddAddress("Testy",
currency.LTC.String(),
currency.NewCode(PersonalAddress),
0.02)
if err != nil {
t.Error(err)
}
if !newBase.AddressExists("LX2LMYXtuv5tiYEMztSSoEZcafFPYJFRK1") {
t.Error("address does not exist")
}
go newBase.StartPortfolioWatcher()
}
func seedPortFolioForTest(t *testing.T) *Base {
t.Helper()
newBase := Base{}
err := newBase.AddAddress(core.BitcoinDonationAddress, "test", currency.BTC, 1500)
if err != nil {
t.Fatalf("failed to add portfolio address with reason: %v, unable to continue tests", err)
}
newBase.Addresses[0].WhiteListed = true
newBase.Addresses[0].ColdStorage = true
newBase.Addresses[0].SupportedExchanges = "BTC Markets,Binance"
err = newBase.AddAddress(testBTCAddress, "test", currency.BTC, 1500)
if err != nil {
t.Fatalf("failed to add portfolio address with reason: %v, unable to continue tests", err)
}
newBase.Addresses[1].SupportedExchanges = "BTC Markets,Binance"
b := Base{}
b.Seed(newBase)
if len(b.Addresses) == 0 {
t.Error("failed to seed")
}
return &b
assert.ErrorIs(t, b.StartPortfolioWatcher(t.Context(), time.Second), errNoPortfolioItemsToWatch)
assert.NoError(t, b.AddAddress(testXRPAddress, PersonalAddress, currency.XRP, 0.02))
ctx, cancel := context.WithCancel(t.Context())
cancel()
assert.ErrorIs(t, b.StartPortfolioWatcher(ctx, 0), context.Canceled, "StartPortfolioWatcher should return context.Canceled")
b.Providers = append(b.Providers, provider{
Name: "XRPScan",
Enabled: true,
})
ctx2, cancel2 := context.WithCancel(t.Context())
doneCh := make(chan error)
go func() {
doneCh <- b.StartPortfolioWatcher(ctx2, time.Second)
}()
assert.Eventually(t, func() bool {
portfolio := b.GetPersonalPortfolio()
xrpBalance, ok := portfolio[currency.XRP]
return ok && xrpBalance > 0.02
}, 10*time.Second, time.Second, "GetPersonalPortfolio should return a balance greater than 0.02")
cancel2()
assert.ErrorIs(t, <-doneCh, context.Canceled, "StartPortfolioWatcher should return a context canceled error")
}
func TestGetProvider(t *testing.T) {
t.Parallel()
b := Base{
Providers: providers{
{
Name: "XRPScan",
Enabled: true,
},
},
}
p, ok := b.Providers.GetProvider("XrPSCaN")
assert.True(t, ok, "GetProvider should return true for existing provider")
assert.Equal(t, "XRPScan", p.Name, "GetProvider should return the correct provider name")
assert.True(t, p.Enabled, "GetProvider should return the correct provider enabled status")
p, ok = b.Providers.GetProvider("NonExistent")
assert.False(t, ok, "GetProvider should return false for non-existent provider")
assert.Equal(t, provider{}, p, "GetProvider should return an empty provider for non-existent provider")
}

View File

@@ -1,15 +1,22 @@
package portfolio
import (
"sync"
"time"
"github.com/thrasher-corp/gocryptotrader/currency"
"golang.org/x/time/rate"
)
// Base holds the portfolio base addresses
type Base struct {
Addresses []Address `json:"addresses"`
Verbose bool
Providers providers `json:"providers,omitzero"`
Verbose bool `json:"verbose"`
mtx sync.RWMutex
cryptoIDLimiter *rate.Limiter
cryptoIDLimiterOnce sync.Once
}
// Address sub type holding address information for portfolio
@@ -159,3 +166,11 @@ type AccountInfo struct {
Twitter string `json:"twitter"`
Verified bool `json:"verified"`
}
type provider struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
APIKey string `json:"apiKey,omitempty"`
}
type providers []provider

View File

@@ -238,6 +238,21 @@
"ColdStorage": false,
"SupportedExchanges": ""
}
],
"providers": [
{
"name": "Ethplorer",
"enabled": true
},
{
"name": "XRPScan",
"enabled": true
},
{
"name": "CryptoID",
"enabled": false,
"apiKey": "Key"
}
]
},
"exchanges": [