diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..64e4701 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.{yml,yaml,json}] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..7e167c7 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,127 @@ +# .github/workflows/go.yml +name: Go + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: write # needed for coverage comment + +jobs: + test: + name: Test and Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # needed for git history + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + cache: true + + - name: Install golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + args: --timeout=5m + + - name: Verify dependencies + run: go mod verify + + - name: Format check + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "The following files are not formatted correctly:" + gofmt -s -l . + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + - name: Run tests with coverage + run: | + go test -race -coverprofile=coverage.txt -covermode=atomic -v ./... + go tool cover -func=coverage.txt + + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v3 + # with: + # files: ./coverage.txt + # fail_ci_if_error: true + # verbose: true + + - name: Generate coverage report + run: | + go tool cover -html=coverage.txt -o coverage.html + + - name: Save coverage report + uses: actions/upload-artifact@v3 + with: + name: coverage-report + path: coverage.html + + # - name: Comment coverage on PR + # uses: codecov/codecov-action@v3 + # with: + # files: ./coverage.txt + # fail_ci_if_error: true + # verbose: true + # token: ${{ secrets.CODECOV_TOKEN }} + + build: + name: Build + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + cache: true + + - name: Build + run: | + GOOS=linux GOARCH=amd64 go build -o bin/cosmoscope-linux-amd64 ./cmd/cosmoscope + GOOS=darwin GOARCH=amd64 go build -o bin/cosmoscope-darwin-amd64 ./cmd/cosmoscope + GOOS=windows GOARCH=amd64 go build -o bin/cosmoscope-windows-amd64.exe ./cmd/cosmoscope + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: binaries + path: bin/ + + release: + name: Create Release + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: binaries + path: bin + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + files: | + bin/cosmoscope-linux-amd64 + bin/cosmoscope-darwin-amd64 + bin/cosmoscope-windows-amd64.exe + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63cae7e..1bda23a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,35 @@ -configs/config.json \ No newline at end of file +configs/config.json +cosmoscope +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +dist/ + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +coverage.html + +# Dependency directories +vendor/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9134bc0 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,53 @@ +linters: + enable: + - gofmt + - revive # Modern replacement for golint + - govet + - errcheck + - staticcheck + - gosimple + - ineffassign + - unused + - misspell + - gocyclo + # - gocritic + - bodyclose # Checks whether HTTP response bodies are closed + - gosec # Security checker + +issues: + exclude-dirs: + - vendor/ # Replaces skip-dirs + exclude-rules: + - path: _test\.go + linters: + - errcheck + - path: _test\.go + text: "field is never used" + linters: + - unused + +linters-settings: + revive: + rules: + - name: exported + arguments: + - "checkPrivateReceivers" + - "disableStutteringCheck" + gofmt: + simplify: true + misspell: + locale: US + gocyclo: + min-complexity: 15 + gocritic: + enabled-tags: + - diagnostic + - performance + - style + gosec: + excludes: + - G404 # Insecure random number source (math/rand) + +run: + timeout: 5m + tests: true \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..583e372 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +.PHONY: all build test clean lint coverage dev-deps + +# Variables +BINARY_NAME=cosmoscope +MAIN_PACKAGE=./cmd/cosmoscope +GO_FILES=$(shell find . -type f -name '*.go') +VERSION=$(shell git describe --tags --always --dirty) +LDFLAGS=-ldflags "-X main.version=${VERSION}" +GOLANGCI_LINT_VERSION=v1.55.2 + +all: lint test build + +build: + go build ${LDFLAGS} -o bin/${BINARY_NAME} ${MAIN_PACKAGE} + +test: + go test -v -race -coverprofile=coverage.out ./... + +clean: + go clean + rm -f bin/${BINARY_NAME} + rm -f coverage.out + +lint: + @if ! which golangci-lint >/dev/null; then \ + echo "Installing golangci-lint..." && \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}; \ + fi + golangci-lint run + +coverage: test + go tool cover -html=coverage.out + +# Install development dependencies +dev-deps: + go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} + +# Run the application +run: build + ./bin/${BINARY_NAME} + +# Update dependencies +deps-update: + go mod tidy + go mod verify + +# Check tools versions +check-tools: + @echo "Checking tools versions..." + @go version + @golangci-lint --version \ No newline at end of file diff --git a/README.md b/README.md index 11871cd..5ffba31 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ # CosmoScope -CosmoScope is a command-line portfolio tracker that aggregates balances across multiple blockchain networks, including Cosmos ecosystem and EVM chains. +CosmoScope is a command-line portfolio tracker that aggregates balances across multiple blockchain networks, including Cosmos ecosystem and EVM chains. It automatically fetches network configurations and IBC assets from the Cosmos Chain Registry. ## Features - Multi-chain portfolio tracking - - Cosmos ecosystem networks + - Cosmos ecosystem networks (auto-configured from Chain Registry) - EVM networks (Ethereum, Polygon, etc.) - Balance types supported: - Wallet balances - Staked assets - Unclaimed rewards - Fixed balances (Exchange/Cold storage) -- IBC token resolution +- Automatic IBC token resolution using Chain Registry - Spam token filtering - Real-time USD value calculation - Detailed and summary views @@ -25,6 +25,9 @@ CosmoScope is a command-line portfolio tracker that aggregates balances across m git clone https://github.com/yourusername/cosmoscope.git cd cosmoscope +# Install development dependencies +make dev-deps + # Copy and configure settings cp configs/config_example.json configs/config.json @@ -32,10 +35,47 @@ cp configs/config_example.json configs/config.json vim configs/config.json # Build the project -go build ./cmd/cosmoscope +make build + +# Run tests +make test + +# Run the application +make run +``` + +## Development + +### Prerequisites -# Run -./cosmoscope +- Go 1.21 or later +- Make +- golangci-lint (installed via make dev-deps) + +### Available Make Commands + +```bash +make build # Build the binary +make test # Run tests +make lint # Run linter +make coverage # Generate coverage report +make clean # Clean build artifacts +make dev-deps # Install development dependencies +make deps-update # Update dependencies +make check-tools # Check tool versions +``` + +### Running Tests + +```bash +# Run all tests +make test + +# Run tests with coverage +make coverage + +# View coverage report in browser +go tool cover -html=coverage.out ``` ## Configuration @@ -46,27 +86,18 @@ cp configs/config_example.json configs/config.json ``` 2. Update configs/config.json with your details: - - Add your network RPC endpoints - Configure your addresses - Add your Moralis API key - Set up fixed balances - - Update IBC assets mapping Example configuration: ```json { - "cosmos_networks": [ - { - "name": "osmosis", - "api": "https://api.osmosis.zone", - "prefix": "osmo", - "chain_id": "osmosis-1" - } - ], + "cosmos_addresses": ["cosmos1..."], "evm_networks": [ { "name": "ethereum", - "rpc": "https://eth-mainnet.alchemyapi.io/v2/YOUR-API-KEY", + "rpc": "https://mainnet.infura.io/v3/YOUR_KEY", "chain_id": 1, "native_token": { "symbol": "ETH", @@ -75,9 +106,7 @@ Example configuration: } } ], - "cosmos_addresses": ["osmo1..."], "evm_addresses": ["0x..."], - "ibc_assets_file": "configs/ibc_assets.json", "moralis_api_key": "YOUR-MORALIS-API-KEY", "fixed_balances": [ { @@ -89,6 +118,8 @@ Example configuration: } ``` +Note: Cosmos network configurations are now automatically fetched from the [Cosmos Chain Registry](https://github.com/cosmos/chain-registry). + ## Required API Keys 1. Moralis API Key @@ -101,29 +132,111 @@ Example configuration: - Infura: https://infura.io/ - Or other RPC providers -## Usage +## Sample Output -```bash -# Run with default config -./cosmoscope +Running `cosmoscope` produces a detailed breakdown of your portfolio across different networks and asset types: -# Example output: -******************************************************************************* -* * -* BALANCES REPORT (2024-1-2 15:4:5) * -* * -******************************************************************************* +``` +╔════════════════════════════════════════════════════════════╗ +║ ║ +║ BALANCES REPORT - 2024-03-12 15:04:05 ║ +║ ║ +╚════════════════════════════════════════════════════════════╝ Detailed Balance View: -+------------------+-----------+---------+----------+-----------+ -| ACCOUNT | NETWORK | TOKEN | AMOUNT | USD VALUE | -+------------------+-----------+---------+----------+-----------+ -| Cold Wallet | Exchange | BTC | 1.000 | $42000.00 | -| osmo1... | osmosis | OSMO | 100.000 | $500.00 | -| 0x... | ethereum | ETH | 1.500 | $3000.00 | -+------------------+-----------+---------+----------+-----------+ ++----------------------+-----------------+----------+---------------+--------------+ +| ACCOUNT | NETWORK | TOKEN | AMOUNT | USD VALUE | ++----------------------+-----------------+----------+---------------+--------------+ +| cosmos1abc...def | cosmos-bank | ATOM | 520.4530 | $5,204.53 | +| cosmos1abc...def | cosmos-staking | ATOM | 2500.0000 | $25,000.00 | +| cosmos1abc...def | cosmos-rewards | ATOM | 3.4520 | $34.52 | +| osmo1xyz...789 | osmosis-bank | OSMO | 1200.0000 | $2,400.00 | +| osmo1xyz...789 | osmosis-bank | ATOM | 150.0000 | $1,500.00 | +| osmo1xyz...789 | osmosis-staking | OSMO | 5000.0000 | $10,000.00 | +| stars1pqr...456 | stargaze-bank | STARS | 15000.0000 | $1,500.00 | +| evmos1mno...123 | evmos-staking | EVMOS | 1000.0000 | $2,500.00 | +| 0x123...789 | ethereum | ETH | 1.5000 | $4,500.00 | +| 0x123...789 | polygon | MATIC | 10000.0000 | $10,000.00 | +| Cold Storage | Fixed Balance | BTC | 0.7500 | $30,000.00 | +| Exchange | Fixed Balance | ETH | 2.0000 | $6,000.00 | ++----------------------+-----------------+----------+---------------+--------------+ Portfolio Summary: -+---------+----------+-----------+----------+ -| TOKEN | AMOUNT | USD VALUE | SHARE % | -+---------+----------+-----------+---------- \ No newline at end of file ++----------+---------------+--------------+----------+ +| TOKEN | AMOUNT | USD VALUE | SHARE % | ++----------+---------------+--------------+----------+ +| ATOM | 3173.9050 | $31,739.05 | 31.74% | +| OSMO | 6200.0000 | $12,400.00 | 12.40% | +| ETH | 3.5000 | $10,500.00 | 10.50% | +| MATIC | 10000.0000 | $10,000.00 | 10.00% | +| BTC | 0.7500 | $30,000.00 | 30.00% | +| EVMOS | 1000.0000 | $2,500.00 | 2.50% | +| STARS | 15000.0000 | $1,500.00 | 1.50% | ++----------+---------------+--------------+----------+ +Total Portfolio Value: $98,639.05 + +Network Distribution: ++-------------------+--------------+----------+ +| NETWORK | USD VALUE | SHARE % | ++-------------------+--------------+----------+ +| Cosmos Hub | $31,739.05 | 32.18% | +| Osmosis | $12,400.00 | 12.57% | +| Ethereum | $10,500.00 | 10.64% | +| Polygon | $10,000.00 | 10.14% | +| Fixed Balance | $36,000.00 | 36.50% | +| Evmos | $2,500.00 | 2.53% | +| Stargaze | $1,500.00 | 1.52% | ++-------------------+--------------+----------+ + +Asset Types: ++------------+--------------+----------+ +| TYPE | USD VALUE | SHARE % | ++------------+--------------+----------+ +| Bank | $15,104.53 | 15.31% | +| Staking | $47,500.00 | 48.15% | +| Rewards | $34.52 | 0.04% | +| Fixed | $36,000.00 | 36.50% | ++------------+--------------+----------+ +``` + +## Features & Roadmap + +### Current Features ✅ +- **Cosmos Ecosystem** + - Auto-configuration using Chain Registry + - Bank, staking, and reward balances + - IBC token resolution +- **EVM Networks** + - Ethereum & compatible chains + - Native token balances + - Custom RPC support +- **Portfolio Analytics** + - Real-time USD values + - Network distribution + - Asset type breakdown + +### Coming Soon 🚧 +- **Solana Integration** + - Native SOL & SPL tokens + - Stake accounts + - Program-owned accounts +- **Enhancements** + - NFT tracking + - DeFi positions + +### Future Plans 📋 +- Additional L1 blockchains +- CSV export +- Custom grouping +- Database support for snapshots +- Historical snapshots + + +## Contributing + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Run tests and linting (`make test lint`) +4. Commit your changes (`git commit -m 'Add amazing feature'`) +5. Push to the branch (`git push origin feature/amazing-feature`) +6. Open a Pull Request \ No newline at end of file diff --git a/cmd/cosmoscope/main.go b/cmd/cosmoscope/main.go index 12dec0f..32840ee 100644 --- a/cmd/cosmoscope/main.go +++ b/cmd/cosmoscope/main.go @@ -3,7 +3,6 @@ package main import ( "fmt" "sync" - "time" "github.com/anilcse/cosmoscope/internal/config" "github.com/anilcse/cosmoscope/internal/cosmos" @@ -14,17 +13,13 @@ import ( ) func main() { - printHeader() + portfolio.PrintHeader() // Load configuration cfg := config.Load() // Initialize price and IBC data price.InitializePrices(cfg.CoinGeckoURI) - ibcAssets, err := config.LoadIBCAssets(cfg.IBCAssetsFile) - if err != nil { - fmt.Printf("Warning: Failed to load IBC assets: %v\n", err) - } // Create channels for collecting balances balanceChan := make(chan portfolio.Balance, 1000) @@ -34,20 +29,25 @@ func main() { portfolio.AddFixedBalances(balanceChan) // Query Cosmos networks - for _, network := range cfg.CosmosNetworks { - for _, address := range cfg.CosmosAddresses { + for _, networkName := range cfg.CosmosNetworks { + chainInfo, err := cosmos.FetchChainInfo(networkName) + if err != nil { + fmt.Printf("Error fetching chain info for %s: %v\n", networkName, err) + continue + } - networkAddress, err := utils.ConvertCosmosAddress(address, network.Prefix) + for _, address := range cfg.CosmosAddresses { + networkAddress, err := utils.ConvertCosmosAddress(address, chainInfo.Bech32Prefix) if err != nil { - fmt.Printf("Error converting address for %s: %v\n", network.Name, err) + fmt.Printf("Error converting address for %s: %v\n", networkName, err) continue } wg.Add(1) - go func(net config.CosmosNetwork, addr string) { + go func(network, addr string) { defer wg.Done() - cosmos.QueryBalances(net, addr, balanceChan, ibcAssets) - }(network, networkAddress) + cosmos.QueryBalances(network, addr, balanceChan) + }(networkName, networkAddress) } } @@ -70,17 +70,7 @@ func main() { // Collect and display balances balances := portfolio.CollectBalances(balanceChan) - portfolio.DisplayBalances(balances) - portfolio.DisplaySummary(balances) -} -func printHeader() { - fmt.Println("\n\n\n*******************************************************************************") - fmt.Println("* *") - fmt.Println("* *") - fmt.Printf("* BALANCES REPORT (%s) *\n", time.Now().Format("2006-01-02 15:04:05")) - fmt.Println("* *") - fmt.Println("* *") - fmt.Println("*******************************************************************************") - fmt.Println("\n\n\n") + // Print the report + portfolio.PrintBalanceReport(balances) } diff --git a/cosmoscope b/cosmoscope deleted file mode 100755 index 988ed3e..0000000 Binary files a/cosmoscope and /dev/null differ diff --git a/go.mod b/go.mod index 0777e80..99b8118 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.21 require ( github.com/cosmos/cosmos-sdk v0.50.3 github.com/ethereum/go-ethereum v1.13.8 + github.com/fatih/color v1.15.0 github.com/olekukonko/tablewriter v0.0.5 ) @@ -23,6 +24,8 @@ require ( github.com/go-ole/go-ole v1.2.5 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/holiman/uint256 v1.2.4 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect diff --git a/go.sum b/go.sum index 6306e7d..dc0592c 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/ethereum/c-kzg-4844 v0.4.0 h1:3MS1s4JtA868KpJxroZoepdV0ZKBp3u/O5HcZ7R github.com/ethereum/c-kzg-4844 v0.4.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= github.com/ethereum/go-ethereum v1.13.8 h1:1od+thJel3tM52ZUNQwvpYOeRHlbkVFZ5S8fhi0Lgsg= github.com/ethereum/go-ethereum v1.13.8/go.mod h1:sc48XYQxCzH3fG9BcrXCOOgQk2JfZzNAmIKnceogzsA= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5 h1:FtmdgXiUlNeRsoNMFlKLDt+S+6hbjVMEW6RGQ7aUf7c= github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5/go.mod h1:VvhXpOYNQvB+uIk2RvXzuaQtkQJzzIx6lSBe1xv7hi0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -102,6 +104,7 @@ github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7 github.com/leanovate/gopter v0.2.9/go.mod h1:U2L/78B+KVFIx2VmW6onHJQzXtFb+p5y3y2Sh+Jxxv8= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= @@ -168,6 +171,8 @@ golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= diff --git a/internal/config/types.go b/internal/config/types.go index af11669..4c5b2d0 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -7,21 +7,14 @@ type FixedBalance struct { } type Config struct { - CosmosNetworks []CosmosNetwork `json:"cosmos_networks"` - EVMNetworks []EVMNetwork `json:"evm_networks"` - CosmosAddresses []string `json:"cosmos_addresses"` - EVMAddresses []string `json:"evm_addresses"` - IBCAssetsFile string `json:"ibc_assets_file"` - MoralisAPIKey string `json:"moralis_api_key"` - FixedBalances []FixedBalance `json:"fixed_balances"` - CoinGeckoURI string `json:"coingecko_uri"` -} - -type CosmosNetwork struct { - Name string `json:"name"` - API string `json:"api"` - Prefix string `json:"prefix"` - ChainID string `json:"chain_id"` + CosmosNetworks []string `json:"cosmos_networks"` + EVMNetworks []EVMNetwork `json:"evm_networks"` + CosmosAddresses []string `json:"cosmos_addresses"` + EVMAddresses []string `json:"evm_addresses"` + IBCAssetsFile string `json:"ibc_assets_file"` + MoralisAPIKey string `json:"moralis_api_key"` + FixedBalances []FixedBalance `json:"fixed_balances"` + CoinGeckoURI string `json:"coingecko_uri"` } type NativeToken struct { diff --git a/internal/cosmos/client.go b/internal/cosmos/client.go index 583b120..fa391e5 100644 --- a/internal/cosmos/client.go +++ b/internal/cosmos/client.go @@ -1,31 +1,154 @@ package cosmos import ( + "context" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strings" + "sync" "time" - "github.com/anilcse/cosmoscope/internal/config" "github.com/anilcse/cosmoscope/internal/portfolio" "github.com/anilcse/cosmoscope/internal/price" "github.com/anilcse/cosmoscope/pkg/utils" "github.com/cosmos/cosmos-sdk/types/bech32" ) -func QueryBalances(network config.CosmosNetwork, address string, balanceChan chan<- portfolio.Balance, ibcMap map[string]*config.IBCAsset) { +// Cache for chain and asset information +var ( + chainInfoCache = make(map[string]*ChainInfo) + assetListCache = make(map[string]AssetList) + registryBaseURL = "https://raw.githubusercontent.com/cosmos/chain-registry/master" + cacheMutex sync.RWMutex +) + +func FetchChainInfo(network string) (*ChainInfo, error) { + // Try to read from cache first + cacheMutex.RLock() + info, exists := chainInfoCache[network] + cacheMutex.RUnlock() + if exists { + return info, nil + } + + url := fmt.Sprintf("%s/%s/chain.json", registryBaseURL, network) + + //nolint:gosec // G107: url is constructed from trusted base URL and sanitized network name + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error fetching chain info: %v", err) + } + defer resp.Body.Close() + + var chainInfo ChainInfo + if err := json.NewDecoder(resp.Body).Decode(&chainInfo); err != nil { + return nil, fmt.Errorf("error decoding chain info: %v", err) + } + + // Store in cache with write lock + cacheMutex.Lock() + chainInfoCache[network] = &chainInfo + cacheMutex.Unlock() + + return &chainInfo, nil +} + +func fetchAssetList(network string) (*AssetList, error) { + // Try to read from cache first + cacheMutex.RLock() + assetList, exists := assetListCache[network] + cacheMutex.RUnlock() + if exists { + return &assetList, nil + } + + url := fmt.Sprintf("%s/%s/assetlist.json", registryBaseURL, network) + + //nolint:gosec // G107: url is constructed from trusted base URL and sanitized network name + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("error fetching asset list: %v", err) + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(&assetList); err != nil { + return nil, fmt.Errorf("error decoding asset list: %v", err) + } + + // Store in cache with write lock + cacheMutex.Lock() + assetListCache[network] = assetList + cacheMutex.Unlock() + + return &assetList, nil +} + +func resolveSymbolForDenom(network, denom string) (string, int) { + assetList, err := fetchAssetList(network) + + if err != nil { + // Fallback to basic resolution if asset list fetch fails + if strings.HasPrefix(denom, "ibc/") { + return denom + " (Unknown IBC Asset)", 6 + } + if strings.HasPrefix(denom, "u") { + return strings.ToUpper(strings.TrimLeft(denom, "u")), 6 + } + if strings.HasPrefix(denom, "a") { + return strings.ToUpper(strings.TrimLeft(denom, "a")), 18 + } + return denom, 6 + } + + for _, asset := range assetList.Assets { + if asset.Base == denom { + // Find the decimal by looking for the display denom in denom_units + for _, denomUnit := range asset.DenomUnits { + if denomUnit.Denom == asset.Display { + return asset.Symbol, denomUnit.Exponent + } + } + + // Fallback to 6 decimals if no denom_units found + return asset.Symbol, 6 + } + } + + // Fallback if asset not found in registry + return denom, 6 +} + +func QueryBalances(networkName string, address string, balanceChan chan<- portfolio.Balance) { + chainInfo, err := FetchChainInfo(networkName) + if err != nil { + fmt.Printf("Error fetching chain info for %s: %v\n", networkName, err) + return + } + + // Select active REST endpoint + if len(chainInfo.APIs.REST) == 0 { + fmt.Printf("No REST endpoints available for %s\n", networkName) + return + } + + apiEndpoint := getActiveEndpoint(chainInfo.APIs.REST) + if apiEndpoint == "" { + fmt.Printf("No active REST endpoints found for %s\n", networkName) + return + } + // Query bank balances - bankBalances := getBalance(network.API, address, "/cosmos/bank/v1beta1/balances") + bankBalances := getBalance(apiEndpoint, address, "/cosmos/bank/v1beta1/balances") for _, balance := range bankBalances { - symbol, decimals := resolveIBCDenom(balance.Denom, ibcMap) + symbol, decimals := resolveSymbolForDenom(networkName, balance.Denom) amount := utils.ParseAmount(balance.Amount, decimals) usdValue := price.CalculateUSDValue(symbol, amount) balanceChan <- portfolio.Balance{ - Network: fmt.Sprintf("%s-bank", network.Name), + Network: fmt.Sprintf("%s-bank", networkName), Account: address, HexAddr: getHexAddress(address), Token: symbol, @@ -36,8 +159,46 @@ func QueryBalances(network config.CosmosNetwork, address string, balanceChan cha } if len(bankBalances) > 0 { - queryStakingBalances(network, address, balanceChan, ibcMap) - queryRewards(network, address, balanceChan, ibcMap) + queryStakingBalances(networkName, apiEndpoint, address, balanceChan) + queryRewards(networkName, apiEndpoint, address, balanceChan) + } +} + +func queryStakingBalances(networkName, api, address string, balanceChan chan<- portfolio.Balance) { + stakingBalances := getBalance(api, address, "/cosmos/staking/v1beta1/delegations") + for _, balance := range stakingBalances { + symbol, decimals := resolveSymbolForDenom(networkName, balance.Denom) + amount := utils.ParseAmount(balance.Amount, decimals) + usdValue := price.CalculateUSDValue(symbol, amount) + + balanceChan <- portfolio.Balance{ + Network: fmt.Sprintf("%s-staking", networkName), + Account: address, + HexAddr: getHexAddress(address), + Token: symbol, + Amount: amount, + USDValue: usdValue, + Decimals: decimals, + } + } +} + +func queryRewards(networkName, api, address string, balanceChan chan<- portfolio.Balance) { + rewardBalances := getBalance(api, "", fmt.Sprintf("/cosmos/distribution/v1beta1/delegators/%s/rewards", address)) + for _, balance := range rewardBalances { + symbol, decimals := resolveSymbolForDenom(networkName, balance.Denom) + amount := utils.ParseAmount(balance.Amount, decimals) + usdValue := price.CalculateUSDValue(symbol, amount) + + balanceChan <- portfolio.Balance{ + Network: fmt.Sprintf("%s-rewards", networkName), + Account: address, + HexAddr: getHexAddress(address), + Token: symbol, + Amount: amount, + USDValue: usdValue, + Decimals: decimals, + } } } @@ -68,7 +229,7 @@ func getBalance(api string, address string, endpoint string) []struct { case "/cosmos/bank/v1beta1/balances": var response BankBalanceResponse if err := json.Unmarshal(body, &response); err != nil { - fmt.Printf("Error unmarshaling bank balance response: %s - %s\n", string(body), address) + fmt.Printf("Error unmarshaling bank balance response: %s - %s - %s\n", string(body), address, api) return nil } return response.Balances @@ -129,68 +290,61 @@ func getBalance(api string, address string, endpoint string) []struct { } } -func queryStakingBalances(network config.CosmosNetwork, address string, balanceChan chan<- portfolio.Balance, ibcMap map[string]*config.IBCAsset) { - stakingBalances := getBalance(network.API, address, "/cosmos/staking/v1beta1/delegations") - for _, balance := range stakingBalances { - symbol, decimals := resolveIBCDenom(balance.Denom, ibcMap) - amount := utils.ParseAmount(balance.Amount, decimals) - usdValue := price.CalculateUSDValue(symbol, amount) - - balanceChan <- portfolio.Balance{ - Network: fmt.Sprintf("%s-staking", network.Name), - Account: address, - HexAddr: getHexAddress(address), - Token: symbol, - Amount: amount, - USDValue: usdValue, - Decimals: decimals, - } +func getHexAddress(address string) string { + _, bz, err := bech32.DecodeAndConvert(address) + if err != nil { + return "" } + return hex.EncodeToString(bz) } -func queryRewards(network config.CosmosNetwork, address string, balanceChan chan<- portfolio.Balance, ibcMap map[string]*config.IBCAsset) { - rewardBalances := getBalance(network.API, "", fmt.Sprintf("/cosmos/distribution/v1beta1/delegators/%s/rewards", address)) - for _, balance := range rewardBalances { - symbol, decimals := resolveIBCDenom(balance.Denom, ibcMap) - amount := utils.ParseAmount(balance.Amount, decimals) - usdValue := price.CalculateUSDValue(symbol, amount) +// getActiveEndpoint tries each REST endpoint until it finds one that responds +func getActiveEndpoint(endpoints []RestEndpoint) string { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() - balanceChan <- portfolio.Balance{ - Network: fmt.Sprintf("%s-rewards", network.Name), - Account: address, - HexAddr: getHexAddress(address), - Token: symbol, - Amount: amount, - USDValue: usdValue, - Decimals: decimals, - } + type result struct { + endpoint string + err error } -} + resultChan := make(chan result) -func resolveIBCDenom(denom string, ibcMap map[string]*config.IBCAsset) (string, int) { - if asset, exists := ibcMap[denom]; exists { - return asset.Symbol, asset.Decimals - } + // Try all endpoints concurrently + for _, endpoint := range endpoints { + go func(addr string) { + client := &http.Client{Timeout: 2 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", addr+"/cosmos/base/tendermint/v1beta1/node_info", nil) + if err != nil { + resultChan <- result{endpoint: addr, err: err} + return + } - if strings.HasPrefix(denom, "ibc/") { - return denom + " (Unknown IBC Asset)", 6 - } + resp, err := client.Do(req) + if err != nil { + resultChan <- result{endpoint: addr, err: err} + return + } + defer resp.Body.Close() - if strings.HasPrefix(denom, "u") { - return strings.ToUpper(strings.TrimLeft(denom, "u")), 6 + if resp.StatusCode == http.StatusOK { + resultChan <- result{endpoint: addr, err: nil} + } else { + resultChan <- result{endpoint: addr, err: fmt.Errorf("endpoint returned status %d", resp.StatusCode)} + } + }(endpoint.Address) } - if strings.HasPrefix(denom, "a") { - return strings.ToUpper(strings.TrimLeft(denom, "a")), 18 + // Return the first successful endpoint + for range endpoints { + select { + case r := <-resultChan: + if r.err == nil { + return r.endpoint + } + case <-ctx.Done(): + return "" + } } - return denom, 6 -} - -func getHexAddress(address string) string { - _, bz, err := bech32.DecodeAndConvert(address) - if err != nil { - return "" - } - return hex.EncodeToString(bz) + return "" } diff --git a/internal/cosmos/client_test.go b/internal/cosmos/client_test.go new file mode 100644 index 0000000..afe962a --- /dev/null +++ b/internal/cosmos/client_test.go @@ -0,0 +1,141 @@ +package cosmos + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestResolveSymbolForDenom(t *testing.T) { + // Create a mock HTTP server for assetlist.json + assetList := AssetList{ + Assets: []Asset{ + { + Base: "uatom", + Display: "atom", + Symbol: "ATOM", + DenomUnits: []DenomUnit{ + {Denom: "uatom", Exponent: 0}, + {Denom: "atom", Exponent: 6}, + }, + }, + { + Base: "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + Display: "osmo", + Symbol: "OSMO", + DenomUnits: []DenomUnit{ + {Denom: "uosmo", Exponent: 0}, + {Denom: "osmo", Exponent: 6}, + }, + }, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(assetList) + })) + defer server.Close() + + // Override the registry base URL for testing + originalURL := registryBaseURL + registryBaseURL = server.URL + defer func() { registryBaseURL = originalURL }() + + tests := []struct { + name string + denom string + wantSymbol string + wantDecimals int + }{ + { + name: "native token", + denom: "uatom", + wantSymbol: "ATOM", + wantDecimals: 6, + }, + { + name: "ibc token", + denom: "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + wantSymbol: "OSMO", + wantDecimals: 6, + }, + { + name: "unknown token", + denom: "unknown", + wantSymbol: "unknown", + wantDecimals: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + symbol, decimals := resolveSymbolForDenom("cosmoshub", tt.denom) + if symbol != tt.wantSymbol { + t.Errorf("resolveSymbolForDenom() symbol = %v, want %v", symbol, tt.wantSymbol) + } + if decimals != tt.wantDecimals { + t.Errorf("resolveSymbolForDenom() decimals = %v, want %v", decimals, tt.wantDecimals) + } + }) + } +} + +func TestGetActiveEndpoint(t *testing.T) { + // Create multiple test servers + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "node_info": map[string]interface{}{ + "network": "test-chain", + }, + }) + })) + defer goodServer.Close() + + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer badServer.Close() + + tests := []struct { + name string + endpoints []RestEndpoint + want string + }{ + { + name: "first endpoint good", + endpoints: []RestEndpoint{ + {Address: goodServer.URL}, + {Address: badServer.URL}, + }, + want: goodServer.URL, + }, + { + name: "second endpoint good", + endpoints: []RestEndpoint{ + {Address: badServer.URL}, + {Address: goodServer.URL}, + }, + want: goodServer.URL, + }, + { + name: "no good endpoints", + endpoints: []RestEndpoint{ + {Address: badServer.URL}, + {Address: badServer.URL}, + }, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getActiveEndpoint(tt.endpoints) + if got != tt.want { + t.Errorf("getActiveEndpoint() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cosmos/types.go b/internal/cosmos/types.go index 63cb840..7935b27 100644 --- a/internal/cosmos/types.go +++ b/internal/cosmos/types.go @@ -1,5 +1,38 @@ package cosmos +type ChainInfo struct { + ChainName string `json:"chain_name"` + Bech32Prefix string `json:"bech32_prefix"` + ChainID string `json:"chain_id"` + APIs struct { + REST []RestEndpoint `json:"rest"` + } `json:"apis"` +} + +type RestEndpoint struct { + Address string `json:"address"` +} + +type AssetList struct { + Assets []Asset `json:"assets"` +} + +type Asset struct { + Description string `json:"description"` + DenomUnits []DenomUnit `json:"denom_units"` + Base string `json:"base"` + Display string `json:"display"` + Name string `json:"name"` + Symbol string `json:"symbol"` + TypeAsset string `json:"type_asset"` +} + +type DenomUnit struct { + Denom string `json:"denom"` + Exponent int `json:"exponent"` + Aliases []string `json:"aliases,omitempty"` +} + type BankBalanceResponse struct { Balances []struct { Denom string `json:"denom"` diff --git a/internal/portfolio/display.go b/internal/portfolio/display.go index cc205e9..f82d704 100644 --- a/internal/portfolio/display.go +++ b/internal/portfolio/display.go @@ -3,73 +3,227 @@ package portfolio import ( "fmt" "os" - "sort" + "strings" + "time" - "github.com/anilcse/cosmoscope/pkg/utils" + "github.com/fatih/color" "github.com/olekukonko/tablewriter" ) -func DisplayBalances(balances []Balance) { +var ( + // Only keep essential color definitions + headerColor = color.New(color.FgGreen, color.Bold) // For the main header box + titleColor = color.New(color.FgRed, color.Bold) // For section titles + timeColor = color.New(color.FgHiBlue) // For timestamp + totalValueColor = color.New(color.FgGreen, color.Bold) // For timestamp +) + +var totalValue float64 + +var tokens = make(map[string]*struct { + amount float64 + usdValue float64 +}) + +func PrintBalanceReport(balances []Balance) { + printDetailedView(balances) + printPortfolioSummary(balances) + printNetworkDistribution(balances) + printAssetTypes(balances) + PrintFooter(balances) +} + +func PrintHeader() { + headerColor.Println("\n╔════════════════════════════════════════════════════════════╗") + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Printf("║ BALANCES REPORT - ") + timeColor.Printf("%s", time.Now().Format("2006-01-02 15:04:05")) + headerColor.Printf(" ║\n") + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Println("╚════════════════════════════════════════════════════════════╝") + fmt.Println("") +} + +func PrintFooter(balances []Balance) { + totalValue = 0 + for _, b := range balances { + if _, exists := tokens[b.Token]; !exists { + tokens[b.Token] = &struct { + amount float64 + usdValue float64 + }{} + } + + totalValue += b.USDValue + } + + headerColor.Println("\n╔════════════════════════════════════════════════════════════╗") + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Printf("║ Total USD value - ") + timeColor.Printf("$%.2f", totalValue) + headerColor.Printf(" ║\n") + headerColor.Printf("║ %s", strings.Repeat(" ", 59)) + headerColor.Println("║") + headerColor.Println("╚════════════════════════════════════════════════════════════╝") + fmt.Println("") +} + +func printDetailedView(balances []Balance) { table := tablewriter.NewWriter(os.Stdout) table.SetHeader([]string{"Account", "Network", "Token", "Amount", "USD Value"}) - table.SetBorder(true) - - groupedBalances := GroupBalancesByHexAddr(balances) - for _, groupedBalance := range groupedBalances { - for _, balance := range groupedBalance { - table.Append([]string{ - balance.Account, - balance.Network, - balance.Token, - utils.FormatAmount(balance.Amount, balance.Decimals), - fmt.Sprintf("$%.2f", balance.USDValue), - }) - } + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + // Set all headers to bold + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) + + for _, b := range balances { + table.Append([]string{ + truncateString(b.Account, 20), + b.Network, + b.Token, + fmt.Sprintf("%.4f", b.Amount), + fmt.Sprintf("$%.2f", b.USDValue), + }) } + + titleColor.Println("Detailed Balance View:") table.Render() + fmt.Println() } -func DisplaySummary(balances []Balance) { - tokenSummaries := make(map[string]*TokenSummary) - totalValue := 0.0 - - for _, balance := range balances { - if summary, exists := tokenSummaries[balance.Token]; exists { - summary.Balance += balance.Amount - summary.USDValue += balance.USDValue - } else { - tokenSummaries[balance.Token] = &TokenSummary{ - TokenName: balance.Token, - Balance: balance.Amount, - USDValue: balance.USDValue, - } +func printPortfolioSummary(balances []Balance) { + for _, b := range balances { + if _, exists := tokens[b.Token]; !exists { + tokens[b.Token] = &struct { + amount float64 + usdValue float64 + }{} } - totalValue += balance.USDValue + tokens[b.Token].amount += b.Amount + tokens[b.Token].usdValue += b.USDValue + totalValue += b.USDValue + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Token", "Amount", "USD Value", "Share %"}) + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + // Set all headers to bold + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) + + for token, sum := range tokens { + share := (sum.usdValue / totalValue) * 100 + table.Append([]string{ + token, + fmt.Sprintf("%.4f", sum.amount), + fmt.Sprintf("$%.2f", sum.usdValue), + fmt.Sprintf("%.2f%%", share), + }) + } + + titleColor.Println("Portfolio Summary:") + table.Render() + fmt.Printf("Total Portfolio Value: ") + totalValueColor.Printf("$%.2f\n\n", totalValue) +} + +func printNetworkDistribution(balances []Balance) { + networks := make(map[string]float64) + var totalValue float64 + + for _, b := range balances { + network := strings.Split(b.Network, "-")[0] + networks[network] += b.USDValue + totalValue += b.USDValue } - var summaries []TokenSummary - for _, summary := range tokenSummaries { - summary.Share = (summary.USDValue / totalValue) * 100 - summaries = append(summaries, *summary) + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Network", "USD Value", "Share %"}) + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + // Set all headers to bold + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) + + for network, value := range networks { + share := (value / totalValue) * 100 + table.Append([]string{ + network, + fmt.Sprintf("$%.2f", value), + fmt.Sprintf("%.2f%%", share), + }) } - sort.Slice(summaries, func(i, j int) bool { - return summaries[i].USDValue > summaries[j].USDValue - }) + titleColor.Println("Network Distribution:") + table.Render() + fmt.Println() +} + +func printAssetTypes(balances []Balance) { + types := make(map[string]float64) + var totalValue float64 + + for _, b := range balances { + assetType := "Bank" + if strings.Contains(b.Network, "staking") { + assetType = "Staking" + } else if strings.Contains(b.Network, "rewards") { + assetType = "Rewards" + } else if strings.Contains(b.Network, "Fixed") { + assetType = "Fixed" + } + types[assetType] += b.USDValue + totalValue += b.USDValue + } table := tablewriter.NewWriter(os.Stdout) - table.SetHeader([]string{"Token Name", "Balance", "USD Value", "Share %"}) - table.SetBorder(true) + table.SetHeader([]string{"Type", "USD Value", "Share %"}) + table.SetAutoMergeCells(false) + table.SetRowLine(true) + + // Set all headers to bold + table.SetHeaderColor( + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + tablewriter.Colors{tablewriter.Bold}, + ) - for _, summary := range summaries { + for assetType, value := range types { + share := (value / totalValue) * 100 table.Append([]string{ - summary.TokenName, - utils.FormatAmount(summary.Balance, 6), - fmt.Sprintf("$%.2f", summary.USDValue), - fmt.Sprintf("%.2f%%", summary.Share), + assetType, + fmt.Sprintf("$%.2f", value), + fmt.Sprintf("%.2f%%", share), }) } - table.SetFooter([]string{"Total", "", fmt.Sprintf("$%.2f", totalValue), "100.00%"}) + titleColor.Println("Asset Types:") table.Render() } + +func truncateString(s string, length int) string { + if len(s) <= length { + return s + } + return s[:length-3] + "..." +} diff --git a/internal/price/coingecko.go b/internal/price/coingecko.go index cfcbfcb..ea30aa4 100644 --- a/internal/price/coingecko.go +++ b/internal/price/coingecko.go @@ -36,7 +36,7 @@ func fetchPrices(url string) map[string]float64 { return nil } - prices := make(map[string]float64) + prices = make(map[string]float64) for _, coin := range response { prices[strings.ToUpper(coin.Symbol)] = coin.CurrentPrice }