package tokens import ( "encoding/json" "fmt" "os" "sync" "time" "github.com/ethereum/go-ethereum/common" "github.com/fraktal/mev-beta/internal/logger" ) // TokenMetadata represents cached token information type TokenMetadata struct { Address common.Address `json:"address"` Symbol string `json:"symbol"` Name string `json:"name"` Decimals uint8 `json:"decimals"` TotalSupply string `json:"totalSupply,omitempty"` Verified bool `json:"verified"` FirstSeen time.Time `json:"firstSeen"` LastSeen time.Time `json:"lastSeen"` SeenCount uint64 `json:"seenCount"` } // MetadataCache manages token metadata with persistent storage type MetadataCache struct { cache map[common.Address]*TokenMetadata mutex sync.RWMutex logger *logger.Logger cacheFile string } // NewMetadataCache creates a new token metadata cache func NewMetadataCache(logger *logger.Logger) *MetadataCache { mc := &MetadataCache{ cache: make(map[common.Address]*TokenMetadata), logger: logger, cacheFile: "data/tokens.json", } // Ensure data directory exists os.MkdirAll("data", 0750) // Load persisted data mc.loadFromDisk() return mc } // Get retrieves token metadata from cache func (mc *MetadataCache) Get(address common.Address) (*TokenMetadata, bool) { mc.mutex.RLock() defer mc.mutex.RUnlock() metadata, exists := mc.cache[address] if exists { return metadata, true } return nil, false } // Set stores token metadata in cache func (mc *MetadataCache) Set(metadata *TokenMetadata) { mc.mutex.Lock() defer mc.mutex.Unlock() // Update last seen and count if existing, exists := mc.cache[metadata.Address]; exists { metadata.FirstSeen = existing.FirstSeen metadata.SeenCount = existing.SeenCount + 1 } else { metadata.FirstSeen = time.Now() metadata.SeenCount = 1 } metadata.LastSeen = time.Now() mc.cache[metadata.Address] = metadata // Persist every 10 additions if metadata.SeenCount%10 == 0 { go mc.saveToDisk() } } // GetOrCreate retrieves metadata or creates placeholder func (mc *MetadataCache) GetOrCreate(address common.Address) *TokenMetadata { if metadata, exists := mc.Get(address); exists { return metadata } // Create placeholder metadata := &TokenMetadata{ Address: address, Symbol: "UNKNOWN", Name: "Unknown Token", Decimals: 18, // Default assumption Verified: false, FirstSeen: time.Now(), LastSeen: time.Now(), SeenCount: 1, } mc.Set(metadata) return metadata } // Update modifies existing token metadata func (mc *MetadataCache) Update(address common.Address, symbol, name string, decimals uint8) { mc.mutex.Lock() defer mc.mutex.Unlock() metadata, exists := mc.cache[address] if !exists { metadata = &TokenMetadata{ Address: address, FirstSeen: time.Now(), } } metadata.Symbol = symbol metadata.Name = name metadata.Decimals = decimals metadata.Verified = true metadata.LastSeen = time.Now() metadata.SeenCount++ mc.cache[address] = metadata // Persist after verification go mc.saveToDisk() } // Count returns the number of cached tokens func (mc *MetadataCache) Count() int { mc.mutex.RLock() defer mc.mutex.RUnlock() return len(mc.cache) } // GetAll returns all cached tokens func (mc *MetadataCache) GetAll() map[common.Address]*TokenMetadata { mc.mutex.RLock() defer mc.mutex.RUnlock() // Create a copy to avoid race conditions result := make(map[common.Address]*TokenMetadata, len(mc.cache)) for addr, metadata := range mc.cache { result[addr] = metadata } return result } // GetVerified returns only verified tokens func (mc *MetadataCache) GetVerified() []*TokenMetadata { mc.mutex.RLock() defer mc.mutex.RUnlock() verified := make([]*TokenMetadata, 0) for _, metadata := range mc.cache { if metadata.Verified { verified = append(verified, metadata) } } return verified } // saveToDisk persists cache to disk func (mc *MetadataCache) saveToDisk() { mc.mutex.RLock() defer mc.mutex.RUnlock() // Convert map to slice for JSON marshaling tokens := make([]*TokenMetadata, 0, len(mc.cache)) for _, metadata := range mc.cache { tokens = append(tokens, metadata) } data, err := json.MarshalIndent(tokens, "", " ") if err != nil { mc.logger.Error(fmt.Sprintf("Failed to marshal token cache: %v", err)) return } if err := os.WriteFile(mc.cacheFile, data, 0644); err != nil { mc.logger.Error(fmt.Sprintf("Failed to save token cache: %v", err)) return } mc.logger.Debug(fmt.Sprintf("Saved %d tokens to cache", len(tokens))) } // loadFromDisk loads persisted cache func (mc *MetadataCache) loadFromDisk() { data, err := os.ReadFile(mc.cacheFile) if err != nil { // File doesn't exist yet, that's okay mc.logger.Debug("No existing token cache found, starting fresh") return } var tokens []*TokenMetadata if err := json.Unmarshal(data, &tokens); err != nil { mc.logger.Error(fmt.Sprintf("Failed to unmarshal token cache: %v", err)) return } mc.mutex.Lock() defer mc.mutex.Unlock() for _, metadata := range tokens { mc.cache[metadata.Address] = metadata } mc.logger.Info(fmt.Sprintf("Loaded %d tokens from cache", len(tokens))) } // SaveAndClose persists cache and cleans up func (mc *MetadataCache) SaveAndClose() { mc.saveToDisk() mc.logger.Info("Token metadata cache saved and closed") } // PruneOld removes tokens not seen in the last 30 days func (mc *MetadataCache) PruneOld(daysOld int) int { mc.mutex.Lock() defer mc.mutex.Unlock() cutoff := time.Now().AddDate(0, 0, -daysOld) pruned := 0 for addr, metadata := range mc.cache { if metadata.LastSeen.Before(cutoff) { delete(mc.cache, addr) pruned++ } } if pruned > 0 { mc.logger.Info(fmt.Sprintf("Pruned %d old tokens from cache", pruned)) go mc.saveToDisk() } return pruned } // GetStatistics returns cache statistics func (mc *MetadataCache) GetStatistics() map[string]interface{} { mc.mutex.RLock() defer mc.mutex.RUnlock() verified := 0 unverified := 0 totalSeen := uint64(0) for _, metadata := range mc.cache { if metadata.Verified { verified++ } else { unverified++ } totalSeen += metadata.SeenCount } return map[string]interface{}{ "total_tokens": len(mc.cache), "verified_tokens": verified, "unverified_tokens": unverified, "total_observations": totalSeen, "cache_file": mc.cacheFile, } }