diff --git a/cmd/portfolio/portfolio.go b/cmd/portfolio/portfolio.go index 8d8b4f63..65ac71e8 100644 --- a/cmd/portfolio/portfolio.go +++ b/cmd/portfolio/portfolio.go @@ -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.") diff --git a/common/common.go b/common/common.go index 81728731..c69b7701 100644 --- a/common/common.go +++ b/common/common.go @@ -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" diff --git a/common/common_test.go b/common/common_test.go index dfb226f0..230324cf 100644 --- a/common/common_test.go +++ b/common/common_test.go @@ -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) + }) } } diff --git a/config/config_types.go b/config/config_types.go index 40bc7eaf..63c4493f 100644 --- a/config/config_types.go +++ b/config/config_types.go @@ -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"` diff --git a/config/versions/v5.go b/config/versions/v5.go index 0c5b0766..89b8238b 100644 --- a/config/versions/v5.go +++ b/config/versions/v5.go @@ -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 } diff --git a/config/versions/v6.go b/config/versions/v6.go new file mode 100644 index 00000000..b30ebbe6 --- /dev/null +++ b/config/versions/v6.go @@ -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 +} diff --git a/config/versions/v6/types.go b/config/versions/v6/types.go new file mode 100644 index 00000000..ae7f3096 --- /dev/null +++ b/config/versions/v6/types.go @@ -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" + } +]`) diff --git a/config/versions/v6_test.go b/config/versions/v6_test.go new file mode 100644 index 00000000..e7466c86 --- /dev/null +++ b/config/versions/v6_test.go @@ -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") +} diff --git a/config_example.json b/config_example.json index f0cd15f5..b48c493a 100644 --- a/config_example.json +++ b/config_example.json @@ -258,6 +258,21 @@ "ColdStorage": false, "SupportedExchanges": "" } + ], + "providers": [ + { + "name": "Ethplorer", + "enabled": true + }, + { + "name": "XRPScan", + "enabled": true + }, + { + "name": "CryptoID", + "enabled": false, + "apiKey": "Key" + } ] }, "exchanges": [ diff --git a/engine/engine.go b/engine/engine.go index 0a2c2580..5a36438d 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -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() { diff --git a/engine/helpers.go b/engine/helpers.go index 52a883ff..168215c1 100644 --- a/engine/helpers.go +++ b/engine/helpers.go @@ -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 } diff --git a/engine/portfolio_manager.go b/engine/portfolio_manager.go index 8745efde..0927bc37 100644 --- a/engine/portfolio_manager.go +++ b/engine/portfolio_manager.go @@ -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 } diff --git a/portfolio/portfolio.go b/portfolio/portfolio.go index f185785d..1469e231 100644 --- a/portfolio/portfolio.go +++ b/portfolio/portfolio.go @@ -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 } diff --git a/portfolio/portfolio_test.go b/portfolio/portfolio_test.go index 76bd6bd3..3cd49f29 100644 --- a/portfolio/portfolio_test.go +++ b/portfolio/portfolio_test.go @@ -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") } diff --git a/portfolio/portfolio_types.go b/portfolio/portfolio_types.go index 85655ce9..02ba5abb 100644 --- a/portfolio/portfolio_types.go +++ b/portfolio/portfolio_types.go @@ -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 diff --git a/testdata/configtest.json b/testdata/configtest.json index 13f9f074..ad1973f7 100644 --- a/testdata/configtest.json +++ b/testdata/configtest.json @@ -238,6 +238,21 @@ "ColdStorage": false, "SupportedExchanges": "" } + ], + "providers": [ + { + "name": "Ethplorer", + "enabled": true + }, + { + "name": "XRPScan", + "enabled": true + }, + { + "name": "CryptoID", + "enabled": false, + "apiKey": "Key" + } ] }, "exchanges": [