Skip to content

Commit d0abc0c

Browse files
committed
Merge branch 'anil/display' into main
Add Solana token tracking and config alignment.
2 parents 0869026 + 87cde5e commit d0abc0c

10 files changed

Lines changed: 445 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
configs/config.json
33

44
# Build output
5-
cosmoscope
5+
/cosmoscope
66
bin/
77
dist/
88

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ CosmoScope is a command-line portfolio tracker that aggregates balances across m
66

77
- Multi-chain portfolio tracking
88
- Cosmos ecosystem networks (auto-configured from Chain Registry)
9-
- EVM networks (Ethereum, Polygon, etc.)
9+
- EVM networks (Ethereum, Polygon, Base, BSC, etc.)
10+
- Solana (native SOL and major SPL tokens)
1011
- Balance types supported:
1112
- Wallet balances
1213
- Staked assets
@@ -108,6 +109,14 @@ Example configuration:
108109
}
109110
],
110111
"evm_addresses": ["0x..."],
112+
"solana_networks": [
113+
{
114+
"name": "mainnet",
115+
"rpc": "https://api.mainnet-beta.solana.com",
116+
"chain_id": "mainnet-beta"
117+
}
118+
],
119+
"solana_addresses": ["YOUR_SOLANA_ADDRESS"],
111120
"moralis_api_key": "YOUR-MORALIS-API-KEY",
112121
"fixed_balances": [
113122
{
@@ -133,6 +142,12 @@ Note: Cosmos network configurations are now automatically fetched from the [Cosm
133142
- Infura: https://infura.io/
134143
- Or other RPC providers
135144

145+
3. Solana RPC Endpoint (optional)
146+
- Public: https://api.mainnet-beta.solana.com
147+
- Helius: https://helius.dev/
148+
- QuickNode: https://www.quicknode.com/
149+
- Tracks native SOL and curated SPL tokens (USDC, USDT, JUP, RAY, BONK, etc.)
150+
136151
## Sample Output
137152

138153
Running `cosmoscope` produces a detailed breakdown of your portfolio across different networks and asset types:
@@ -210,7 +225,12 @@ Asset Types:
210225
- **EVM Networks**
211226
- Ethereum & compatible chains
212227
- Native token balances
228+
- ERC-20 tokens via Moralis
213229
- Custom RPC support
230+
- **Solana**
231+
- Native SOL balances via JSON-RPC
232+
- SPL token balances (USDC, USDT, JUP, RAY, BONK, and more)
233+
- Spam filtering for unpriced/unknown mints
214234
- **Portfolio Analytics**
215235
- Real-time USD values
216236
- Network distribution
@@ -219,13 +239,10 @@ Asset Types:
219239
### Coming Soon 🚧
220240
- **Exchange Support**
221241
- Integrate exchanges apis
222-
- **Solana Integration**
223-
- Native SOL & SPL tokens
224-
- Stake accounts
225-
- Program-owned accounts
226242
- **Enhancements**
227243
- NFT tracking
228244
- DeFi positions
245+
- Solana stake accounts
229246

230247
### Future Plans 📋
231248
- Additional L1 blockchains

cmd/cosmoscope/main.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/anilcse/cosmoscope/internal/evm"
1111
"github.com/anilcse/cosmoscope/internal/portfolio"
1212
"github.com/anilcse/cosmoscope/internal/price"
13+
"github.com/anilcse/cosmoscope/internal/solana"
1314
"github.com/anilcse/cosmoscope/pkg/utils"
1415
)
1516

@@ -67,6 +68,19 @@ func main() {
6768
time.Sleep(500 * time.Millisecond)
6869
}
6970

71+
// Query Solana wallets
72+
for _, network := range cfg.ActiveSolanaNetworks() {
73+
fmt.Printf("Querying %s solana network...\n", network.Name)
74+
for _, address := range cfg.ActiveSolanaAddresses() {
75+
wg.Add(1)
76+
go func(net config.SolanaNetwork, addr string) {
77+
defer wg.Done()
78+
solana.QueryBalances(net, addr, balanceChan)
79+
}(network, address)
80+
}
81+
time.Sleep(500 * time.Millisecond)
82+
}
83+
7084
// Close channel after all goroutines complete
7185
go func() {
7286
wg.Wait()

configs/config_example.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929
"evm_addresses": [
3030
"0x40FD27A96CDBffC90ab3b83bF695911426A69fD5"
3131
],
32+
"solana_networks": [
33+
{
34+
"name": "mainnet",
35+
"rpc": "https://api.mainnet-beta.solana.com",
36+
"chain_id": "mainnet-beta"
37+
}
38+
],
39+
"solana_addresses": [
40+
"YOUR_SOLANA_ADDRESS"
41+
],
3242
"fixed_balances": [
3343
{
3444
"token": "BTC",

internal/config/types.go

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,50 @@ type FixedBalance struct {
77
}
88

99
type Config struct {
10-
CosmosNetworks []string `json:"cosmos_networks"`
11-
EVMNetworks []EVMNetwork `json:"evm_networks"`
12-
CosmosAddresses []string `json:"cosmos_addresses"`
13-
EVMAddresses []string `json:"evm_addresses"`
14-
IBCAssetsFile string `json:"ibc_assets_file"`
15-
MoralisAPIKey string `json:"moralis_api_key"`
16-
FixedBalances []FixedBalance `json:"fixed_balances"`
17-
CoinGeckoURI string `json:"coingecko_uri"`
10+
CosmosNetworks []string `json:"cosmos_networks"`
11+
EVMNetworks []EVMNetwork `json:"evm_networks"`
12+
SolanaNetworks []SolanaNetwork `json:"solana_networks"`
13+
Solana SolanaConfig `json:"solana"` // legacy single-RPC config
14+
CosmosAddresses []string `json:"cosmos_addresses"`
15+
EVMAddresses []string `json:"evm_addresses"`
16+
SolanaAddresses []string `json:"solana_addresses"`
17+
IBCAssetsFile string `json:"ibc_assets_file"`
18+
MoralisAPIKey string `json:"moralis_api_key"`
19+
FixedBalances []FixedBalance `json:"fixed_balances"`
20+
CoinGeckoURI string `json:"coingecko_uri"`
21+
}
22+
23+
type SolanaNetwork struct {
24+
Name string `json:"name"`
25+
RPC string `json:"rpc"`
26+
ChainID string `json:"chain_id"`
27+
}
28+
29+
// SolanaConfig supports the legacy nested config block.
30+
type SolanaConfig struct {
31+
RPC string `json:"rpc"`
32+
Addresses []string `json:"addresses"`
33+
}
34+
35+
func (c Config) ActiveSolanaNetworks() []SolanaNetwork {
36+
if len(c.SolanaNetworks) > 0 {
37+
return c.SolanaNetworks
38+
}
39+
if c.Solana.RPC != "" {
40+
return []SolanaNetwork{{
41+
Name: "mainnet",
42+
RPC: c.Solana.RPC,
43+
ChainID: "mainnet-beta",
44+
}}
45+
}
46+
return nil
47+
}
48+
49+
func (c Config) ActiveSolanaAddresses() []string {
50+
if len(c.SolanaAddresses) > 0 {
51+
return c.SolanaAddresses
52+
}
53+
return c.Solana.Addresses
1854
}
1955

2056
type NativeToken struct {

internal/price/coingecko.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ var (
4141
"DYDX": 2.0, "STARS": 0.01, "INJ": 20.0, "ROSE": 0.05,
4242
"DOT": 7.0, "SOL": 100.0, "NOCK": 0.001,
4343
"MANTRA": 0.5, "OM": 0.5, "DYM": 2.0, "CHEQ": 0.02, "ATONE": 1.0,
44+
"JUP": 1.0, "RAY": 3.0, "BONK": 0.00002, "WIF": 2.0, "ORCA": 2.0,
45+
"PYTH": 0.4, "JTO": 2.0, "MSOL": 200.0, "STSOL": 200.0, "RENDER": 7.0,
4446
}
4547
)
4648

internal/solana/client.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package solana
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"time"
11+
12+
"github.com/anilcse/cosmoscope/internal/config"
13+
"github.com/anilcse/cosmoscope/internal/portfolio"
14+
"github.com/anilcse/cosmoscope/internal/price"
15+
"github.com/anilcse/cosmoscope/pkg/utils"
16+
)
17+
18+
var httpClient = &http.Client{Timeout: 15 * time.Second}
19+
20+
func QueryBalances(network config.SolanaNetwork, address string, balanceChan chan<- portfolio.Balance) {
21+
if network.RPC == "" {
22+
return
23+
}
24+
25+
networkName := network.Name
26+
if networkName == "" {
27+
networkName = "solana"
28+
}
29+
30+
if balance, ok := querySOLBalance(network.RPC, address, networkName); ok && balance.Amount > 0 {
31+
balanceChan <- balance
32+
}
33+
34+
for _, programID := range []string{tokenProgramID, token2022Program} {
35+
tokens, err := querySPLBalances(network.RPC, address, programID, networkName)
36+
if err != nil {
37+
fmt.Printf("Error fetching SPL balances for %s: %v\n", address, err)
38+
continue
39+
}
40+
for _, token := range tokens {
41+
balanceChan <- token
42+
}
43+
}
44+
}
45+
46+
func querySOLBalance(rpc, address, networkName string) (portfolio.Balance, bool) {
47+
result, err := rpcCall(rpc, "getBalance", []interface{}{address})
48+
if err != nil {
49+
fmt.Printf("Error fetching SOL balance for %s: %v\n", address, err)
50+
return portfolio.Balance{}, false
51+
}
52+
53+
var balance balanceResult
54+
if err := json.Unmarshal(result, &balance); err != nil {
55+
fmt.Printf("Error decoding SOL balance for %s: %v\n", address, err)
56+
return portfolio.Balance{}, false
57+
}
58+
59+
amount := float64(balance.Value) / lamportsPerSOL
60+
usdValue := price.CalculateUSDValue("SOL", amount)
61+
62+
return portfolio.Balance{
63+
Network: networkName,
64+
Account: address,
65+
Token: "SOL",
66+
Amount: amount,
67+
USDValue: usdValue,
68+
Decimals: 9,
69+
}, true
70+
}
71+
72+
func querySPLBalances(rpc, address, programID, networkName string) ([]portfolio.Balance, error) {
73+
params := []interface{}{
74+
address,
75+
map[string]string{"programId": programID},
76+
map[string]string{"encoding": "jsonParsed"},
77+
}
78+
79+
result, err := rpcCall(rpc, "getTokenAccountsByOwner", params)
80+
if err != nil {
81+
return nil, err
82+
}
83+
84+
var accounts tokenAccountsResult
85+
if err := json.Unmarshal(result, &accounts); err != nil {
86+
return nil, fmt.Errorf("decoding token accounts: %w", err)
87+
}
88+
89+
var balances []portfolio.Balance
90+
for _, entry := range accounts.Value {
91+
info := entry.Account.Data.Parsed.Info
92+
if info.TokenAmount.UIAmount == 0 && info.TokenAmount.Amount == "0" {
93+
continue
94+
}
95+
96+
symbol, decimals, ok := lookupMint(info.Mint)
97+
if !ok {
98+
continue
99+
}
100+
if symbol == "SOL" {
101+
continue // native SOL handled separately
102+
}
103+
if !price.HasPrice(symbol) {
104+
continue
105+
}
106+
107+
amount := info.TokenAmount.UIAmount
108+
if amount == 0 {
109+
amount = utils.ParseAmount(info.TokenAmount.Amount, info.TokenAmount.Decimals)
110+
}
111+
112+
balances = append(balances, portfolio.Balance{
113+
Network: networkName,
114+
Account: address,
115+
Token: symbol,
116+
Amount: amount,
117+
USDValue: price.CalculateUSDValue(symbol, amount),
118+
Decimals: decimals,
119+
})
120+
}
121+
122+
return balances, nil
123+
}
124+
125+
func rpcCall(rpc, method string, params []interface{}) (json.RawMessage, error) {
126+
payload, err := json.Marshal(rpcRequest{
127+
JSONRPC: "2.0",
128+
ID: 1,
129+
Method: method,
130+
Params: params,
131+
})
132+
if err != nil {
133+
return nil, err
134+
}
135+
136+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
137+
defer cancel()
138+
139+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpc, bytes.NewReader(payload))
140+
if err != nil {
141+
return nil, err
142+
}
143+
req.Header.Set("Content-Type", "application/json")
144+
145+
resp, err := httpClient.Do(req)
146+
if err != nil {
147+
return nil, err
148+
}
149+
defer resp.Body.Close()
150+
151+
body, err := io.ReadAll(resp.Body)
152+
if err != nil {
153+
return nil, err
154+
}
155+
156+
var response rpcResponse
157+
if err := json.Unmarshal(body, &response); err != nil {
158+
return nil, fmt.Errorf("decoding rpc response: %w", err)
159+
}
160+
if response.Error != nil {
161+
return nil, fmt.Errorf("rpc error %d: %s", response.Error.Code, response.Error.Message)
162+
}
163+
if response.Result.raw == nil {
164+
return nil, fmt.Errorf("empty rpc result for %s", method)
165+
}
166+
167+
return json.RawMessage(response.Result.raw), nil
168+
}

0 commit comments

Comments
 (0)