Restructured project for V2 refactor: **Structure Changes:** - Moved all V1 code to orig/ folder (preserved with git mv) - Created docs/planning/ directory - Added orig/README_V1.md explaining V1 preservation **Planning Documents:** - 00_V2_MASTER_PLAN.md: Complete architecture overview - Executive summary of critical V1 issues - High-level component architecture diagrams - 5-phase implementation roadmap - Success metrics and risk mitigation - 07_TASK_BREAKDOWN.md: Atomic task breakdown - 99+ hours of detailed tasks - Every task < 2 hours (atomic) - Clear dependencies and success criteria - Organized by implementation phase **V2 Key Improvements:** - Per-exchange parsers (factory pattern) - Multi-layer strict validation - Multi-index pool cache - Background validation pipeline - Comprehensive observability **Critical Issues Addressed:** - Zero address tokens (strict validation + cache enrichment) - Parsing accuracy (protocol-specific parsers) - No audit trail (background validation channel) - Inefficient lookups (multi-index cache) - Stats disconnection (event-driven metrics) Next Steps: 1. Review planning documents 2. Begin Phase 1: Foundation (P1-001 through P1-010) 3. Implement parsers in Phase 2 4. Build cache system in Phase 3 5. Add validation pipeline in Phase 4 6. Migrate and test in Phase 5 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
549 lines
15 KiB
Go
549 lines
15 KiB
Go
package arbitrum
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ethereum/go-ethereum"
|
|
"github.com/ethereum/go-ethereum/accounts/abi"
|
|
"github.com/ethereum/go-ethereum/common"
|
|
"github.com/ethereum/go-ethereum/ethclient"
|
|
|
|
"github.com/fraktal/mev-beta/internal/logger"
|
|
"github.com/fraktal/mev-beta/pkg/security"
|
|
)
|
|
|
|
// TokenMetadata contains comprehensive token information
|
|
type TokenMetadata struct {
|
|
Address common.Address `json:"address"`
|
|
Symbol string `json:"symbol"`
|
|
Name string `json:"name"`
|
|
Decimals uint8 `json:"decimals"`
|
|
TotalSupply *big.Int `json:"totalSupply"`
|
|
IsStablecoin bool `json:"isStablecoin"`
|
|
IsWrapped bool `json:"isWrapped"`
|
|
Category string `json:"category"` // "blue-chip", "defi", "meme", "unknown"
|
|
|
|
// Price information
|
|
PriceUSD float64 `json:"priceUSD"`
|
|
PriceETH float64 `json:"priceETH"`
|
|
LastUpdated time.Time `json:"lastUpdated"`
|
|
|
|
// Liquidity information
|
|
TotalLiquidityUSD float64 `json:"totalLiquidityUSD"`
|
|
MainPool common.Address `json:"mainPool"`
|
|
|
|
// Risk assessment
|
|
RiskScore float64 `json:"riskScore"` // 0.0 (safe) to 1.0 (high risk)
|
|
IsVerified bool `json:"isVerified"`
|
|
|
|
// Technical details
|
|
ContractVerified bool `json:"contractVerified"`
|
|
Implementation common.Address `json:"implementation"` // For proxy contracts
|
|
}
|
|
|
|
// TokenMetadataService manages token metadata extraction and caching
|
|
type TokenMetadataService struct {
|
|
client *ethclient.Client
|
|
logger *logger.Logger
|
|
|
|
// Caching
|
|
cache map[common.Address]*TokenMetadata
|
|
cacheMu sync.RWMutex
|
|
cacheTTL time.Duration
|
|
|
|
// Known tokens registry
|
|
knownTokens map[common.Address]*TokenMetadata
|
|
|
|
// Contract ABIs
|
|
erc20ABI string
|
|
proxyABI string
|
|
}
|
|
|
|
// NewTokenMetadataService creates a new token metadata service
|
|
func NewTokenMetadataService(client *ethclient.Client, logger *logger.Logger) *TokenMetadataService {
|
|
service := &TokenMetadataService{
|
|
client: client,
|
|
logger: logger,
|
|
cache: make(map[common.Address]*TokenMetadata),
|
|
cacheTTL: 1 * time.Hour,
|
|
knownTokens: getKnownArbitrumTokens(),
|
|
erc20ABI: getERC20ABI(),
|
|
proxyABI: getProxyABI(),
|
|
}
|
|
|
|
return service
|
|
}
|
|
|
|
// GetTokenMetadata retrieves comprehensive metadata for a token
|
|
func (s *TokenMetadataService) GetTokenMetadata(ctx context.Context, tokenAddr common.Address) (*TokenMetadata, error) {
|
|
// Check cache first
|
|
if cached := s.getCachedMetadata(tokenAddr); cached != nil {
|
|
return cached, nil
|
|
}
|
|
|
|
// Check known tokens registry
|
|
if known, exists := s.knownTokens[tokenAddr]; exists {
|
|
s.cacheMetadata(tokenAddr, known)
|
|
return known, nil
|
|
}
|
|
|
|
// Extract metadata from contract
|
|
metadata, err := s.extractMetadataFromContract(ctx, tokenAddr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to extract token metadata: %w", err)
|
|
}
|
|
|
|
// Enhance with additional data
|
|
if err := s.enhanceMetadata(ctx, metadata); err != nil {
|
|
s.logger.Debug(fmt.Sprintf("Failed to enhance metadata for %s: %v", tokenAddr.Hex(), err))
|
|
}
|
|
|
|
// Cache the result
|
|
s.cacheMetadata(tokenAddr, metadata)
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// extractMetadataFromContract extracts basic ERC20 metadata from the contract
|
|
func (s *TokenMetadataService) extractMetadataFromContract(ctx context.Context, tokenAddr common.Address) (*TokenMetadata, error) {
|
|
contractABI, err := abi.JSON(strings.NewReader(s.erc20ABI))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse ERC20 ABI: %w", err)
|
|
}
|
|
|
|
metadata := &TokenMetadata{
|
|
Address: tokenAddr,
|
|
LastUpdated: time.Now(),
|
|
}
|
|
|
|
// Get symbol
|
|
if symbol, err := s.callStringMethod(ctx, tokenAddr, contractABI, "symbol"); err == nil {
|
|
metadata.Symbol = symbol
|
|
} else {
|
|
s.logger.Debug(fmt.Sprintf("Failed to get symbol for %s: %v", tokenAddr.Hex(), err))
|
|
metadata.Symbol = "UNKNOWN"
|
|
}
|
|
|
|
// Get name
|
|
if name, err := s.callStringMethod(ctx, tokenAddr, contractABI, "name"); err == nil {
|
|
metadata.Name = name
|
|
} else {
|
|
s.logger.Debug(fmt.Sprintf("Failed to get name for %s: %v", tokenAddr.Hex(), err))
|
|
metadata.Name = "Unknown Token"
|
|
}
|
|
|
|
// Get decimals
|
|
if decimals, err := s.callUint8Method(ctx, tokenAddr, contractABI, "decimals"); err == nil {
|
|
metadata.Decimals = decimals
|
|
} else {
|
|
s.logger.Debug(fmt.Sprintf("Failed to get decimals for %s: %v", tokenAddr.Hex(), err))
|
|
metadata.Decimals = 18 // Default to 18 decimals
|
|
}
|
|
|
|
// Get total supply
|
|
if totalSupply, err := s.callBigIntMethod(ctx, tokenAddr, contractABI, "totalSupply"); err == nil {
|
|
metadata.TotalSupply = totalSupply
|
|
} else {
|
|
s.logger.Debug(fmt.Sprintf("Failed to get total supply for %s: %v", tokenAddr.Hex(), err))
|
|
metadata.TotalSupply = big.NewInt(0)
|
|
}
|
|
|
|
// Check if contract is verified
|
|
metadata.ContractVerified = s.isContractVerified(ctx, tokenAddr)
|
|
|
|
// Categorize token
|
|
metadata.Category = s.categorizeToken(metadata)
|
|
|
|
// Assess risk
|
|
metadata.RiskScore = s.assessRisk(metadata)
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// enhanceMetadata adds additional information to token metadata
|
|
func (s *TokenMetadataService) enhanceMetadata(ctx context.Context, metadata *TokenMetadata) error {
|
|
// Check if it's a stablecoin
|
|
metadata.IsStablecoin = s.isStablecoin(metadata.Symbol, metadata.Name)
|
|
|
|
// Check if it's a wrapped token
|
|
metadata.IsWrapped = s.isWrappedToken(metadata.Symbol, metadata.Name)
|
|
|
|
// Mark as verified if it's a known token
|
|
metadata.IsVerified = s.isVerifiedToken(metadata.Address)
|
|
|
|
// Check for proxy contract
|
|
if impl, err := s.getProxyImplementation(ctx, metadata.Address); err == nil && impl != (common.Address{}) {
|
|
metadata.Implementation = impl
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// callStringMethod calls a contract method that returns a string
|
|
func (s *TokenMetadataService) callStringMethod(ctx context.Context, contractAddr common.Address, contractABI abi.ABI, method string) (string, error) {
|
|
callData, err := contractABI.Pack(method)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to pack %s call: %w", method, err)
|
|
}
|
|
|
|
result, err := s.client.CallContract(ctx, ethereum.CallMsg{
|
|
To: &contractAddr,
|
|
Data: callData,
|
|
}, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s call failed: %w", method, err)
|
|
}
|
|
|
|
unpacked, err := contractABI.Unpack(method, result)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to unpack %s result: %w", method, err)
|
|
}
|
|
|
|
if len(unpacked) == 0 {
|
|
return "", fmt.Errorf("empty %s result", method)
|
|
}
|
|
|
|
if str, ok := unpacked[0].(string); ok {
|
|
return str, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("invalid %s result type: %T", method, unpacked[0])
|
|
}
|
|
|
|
// callUint8Method calls a contract method that returns a uint8
|
|
func (s *TokenMetadataService) callUint8Method(ctx context.Context, contractAddr common.Address, contractABI abi.ABI, method string) (uint8, error) {
|
|
callData, err := contractABI.Pack(method)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to pack %s call: %w", method, err)
|
|
}
|
|
|
|
result, err := s.client.CallContract(ctx, ethereum.CallMsg{
|
|
To: &contractAddr,
|
|
Data: callData,
|
|
}, nil)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s call failed: %w", method, err)
|
|
}
|
|
|
|
unpacked, err := contractABI.Unpack(method, result)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to unpack %s result: %w", method, err)
|
|
}
|
|
|
|
if len(unpacked) == 0 {
|
|
return 0, fmt.Errorf("empty %s result", method)
|
|
}
|
|
|
|
// Handle different possible return types
|
|
switch v := unpacked[0].(type) {
|
|
case uint8:
|
|
return v, nil
|
|
case *big.Int:
|
|
val, err := security.SafeUint64FromBigInt(v)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid decimal value: %w", err)
|
|
}
|
|
return security.SafeUint8(val)
|
|
default:
|
|
return 0, fmt.Errorf("invalid %s result type: %T", method, unpacked[0])
|
|
}
|
|
}
|
|
|
|
// callBigIntMethod calls a contract method that returns a *big.Int
|
|
func (s *TokenMetadataService) callBigIntMethod(ctx context.Context, contractAddr common.Address, contractABI abi.ABI, method string) (*big.Int, error) {
|
|
callData, err := contractABI.Pack(method)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to pack %s call: %w", method, err)
|
|
}
|
|
|
|
result, err := s.client.CallContract(ctx, ethereum.CallMsg{
|
|
To: &contractAddr,
|
|
Data: callData,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s call failed: %w", method, err)
|
|
}
|
|
|
|
unpacked, err := contractABI.Unpack(method, result)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unpack %s result: %w", method, err)
|
|
}
|
|
|
|
if len(unpacked) == 0 {
|
|
return nil, fmt.Errorf("empty %s result", method)
|
|
}
|
|
|
|
if bigInt, ok := unpacked[0].(*big.Int); ok {
|
|
return bigInt, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid %s result type: %T", method, unpacked[0])
|
|
}
|
|
|
|
// categorizeToken determines the category of a token
|
|
func (s *TokenMetadataService) categorizeToken(metadata *TokenMetadata) string {
|
|
symbol := strings.ToUpper(metadata.Symbol)
|
|
name := strings.ToUpper(metadata.Name)
|
|
|
|
// Blue-chip tokens
|
|
blueChip := []string{"WETH", "WBTC", "USDC", "USDT", "DAI", "ARB", "GMX", "GRT"}
|
|
for _, token := range blueChip {
|
|
if symbol == token {
|
|
return "blue-chip"
|
|
}
|
|
}
|
|
|
|
// DeFi tokens
|
|
if strings.Contains(name, "DAO") || strings.Contains(name, "FINANCE") ||
|
|
strings.Contains(name, "PROTOCOL") || strings.Contains(symbol, "LP") {
|
|
return "defi"
|
|
}
|
|
|
|
// Meme tokens (simple heuristics)
|
|
memeKeywords := []string{"MEME", "DOGE", "SHIB", "PEPE", "FLOKI"}
|
|
for _, keyword := range memeKeywords {
|
|
if strings.Contains(symbol, keyword) || strings.Contains(name, keyword) {
|
|
return "meme"
|
|
}
|
|
}
|
|
|
|
return "unknown"
|
|
}
|
|
|
|
// assessRisk calculates a risk score for the token
|
|
func (s *TokenMetadataService) assessRisk(metadata *TokenMetadata) float64 {
|
|
risk := 0.5 // Base risk
|
|
|
|
// Reduce risk for verified tokens
|
|
if metadata.ContractVerified {
|
|
risk -= 0.2
|
|
}
|
|
|
|
// Reduce risk for blue-chip tokens
|
|
if metadata.Category == "blue-chip" {
|
|
risk -= 0.3
|
|
}
|
|
|
|
// Increase risk for meme tokens
|
|
if metadata.Category == "meme" {
|
|
risk += 0.3
|
|
}
|
|
|
|
// Reduce risk for stablecoins
|
|
if metadata.IsStablecoin {
|
|
risk -= 0.4
|
|
}
|
|
|
|
// Increase risk for tokens with low total supply
|
|
if metadata.TotalSupply != nil && metadata.TotalSupply.Cmp(big.NewInt(1e15)) < 0 {
|
|
risk += 0.2
|
|
}
|
|
|
|
// Ensure risk is between 0 and 1
|
|
if risk < 0 {
|
|
risk = 0
|
|
}
|
|
if risk > 1 {
|
|
risk = 1
|
|
}
|
|
|
|
return risk
|
|
}
|
|
|
|
// isStablecoin checks if a token is a stablecoin
|
|
func (s *TokenMetadataService) isStablecoin(symbol, name string) bool {
|
|
stablecoins := []string{"USDC", "USDT", "DAI", "FRAX", "LUSD", "MIM", "UST", "BUSD"}
|
|
symbol = strings.ToUpper(symbol)
|
|
name = strings.ToUpper(name)
|
|
|
|
for _, stable := range stablecoins {
|
|
if symbol == stable || strings.Contains(name, stable) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return strings.Contains(name, "USD") || strings.Contains(name, "DOLLAR")
|
|
}
|
|
|
|
// isWrappedToken checks if a token is a wrapped version
|
|
func (s *TokenMetadataService) isWrappedToken(symbol, name string) bool {
|
|
return strings.HasPrefix(strings.ToUpper(symbol), "W") || strings.Contains(strings.ToUpper(name), "WRAPPED")
|
|
}
|
|
|
|
// isVerifiedToken checks if a token is in the verified list
|
|
func (s *TokenMetadataService) isVerifiedToken(addr common.Address) bool {
|
|
_, exists := s.knownTokens[addr]
|
|
return exists
|
|
}
|
|
|
|
// isContractVerified checks if the contract source code is verified
|
|
func (s *TokenMetadataService) isContractVerified(ctx context.Context, addr common.Address) bool {
|
|
// Check if contract has code
|
|
code, err := s.client.CodeAt(ctx, addr, nil)
|
|
if err != nil || len(code) == 0 {
|
|
return false
|
|
}
|
|
|
|
// In a real implementation, you would check with a verification service like Etherscan
|
|
// For now, we'll assume contracts with code are verified
|
|
return len(code) > 0
|
|
}
|
|
|
|
// getProxyImplementation gets the implementation address for proxy contracts
|
|
func (s *TokenMetadataService) getProxyImplementation(ctx context.Context, proxyAddr common.Address) (common.Address, error) {
|
|
// Try EIP-1967 standard storage slot
|
|
slot := common.HexToHash("0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc")
|
|
|
|
storage, err := s.client.StorageAt(ctx, proxyAddr, slot, nil)
|
|
if err != nil {
|
|
return common.Address{}, err
|
|
}
|
|
|
|
if len(storage) >= 20 {
|
|
return common.BytesToAddress(storage[12:32]), nil
|
|
}
|
|
|
|
return common.Address{}, fmt.Errorf("no implementation found")
|
|
}
|
|
|
|
// getCachedMetadata retrieves cached metadata if available and not expired
|
|
func (s *TokenMetadataService) getCachedMetadata(addr common.Address) *TokenMetadata {
|
|
s.cacheMu.RLock()
|
|
defer s.cacheMu.RUnlock()
|
|
|
|
cached, exists := s.cache[addr]
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
// Check if cache is expired
|
|
if time.Since(cached.LastUpdated) > s.cacheTTL {
|
|
return nil
|
|
}
|
|
|
|
return cached
|
|
}
|
|
|
|
// cacheMetadata stores metadata in the cache
|
|
func (s *TokenMetadataService) cacheMetadata(addr common.Address, metadata *TokenMetadata) {
|
|
s.cacheMu.Lock()
|
|
defer s.cacheMu.Unlock()
|
|
|
|
// Create a copy to avoid race conditions
|
|
cached := *metadata
|
|
cached.LastUpdated = time.Now()
|
|
s.cache[addr] = &cached
|
|
}
|
|
|
|
// getKnownArbitrumTokens returns a registry of known tokens on Arbitrum
|
|
func getKnownArbitrumTokens() map[common.Address]*TokenMetadata {
|
|
return map[common.Address]*TokenMetadata{
|
|
// WETH
|
|
common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"): {
|
|
Address: common.HexToAddress("0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"),
|
|
Symbol: "WETH",
|
|
Name: "Wrapped Ether",
|
|
Decimals: 18,
|
|
IsWrapped: true,
|
|
Category: "blue-chip",
|
|
IsVerified: true,
|
|
RiskScore: 0.1,
|
|
IsStablecoin: false,
|
|
},
|
|
// USDC
|
|
common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"): {
|
|
Address: common.HexToAddress("0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),
|
|
Symbol: "USDC",
|
|
Name: "USD Coin",
|
|
Decimals: 6,
|
|
Category: "blue-chip",
|
|
IsVerified: true,
|
|
RiskScore: 0.05,
|
|
IsStablecoin: true,
|
|
},
|
|
// ARB
|
|
common.HexToAddress("0x912CE59144191C1204E64559FE8253a0e49E6548"): {
|
|
Address: common.HexToAddress("0x912CE59144191C1204E64559FE8253a0e49E6548"),
|
|
Symbol: "ARB",
|
|
Name: "Arbitrum",
|
|
Decimals: 18,
|
|
Category: "blue-chip",
|
|
IsVerified: true,
|
|
RiskScore: 0.2,
|
|
},
|
|
// USDT
|
|
common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"): {
|
|
Address: common.HexToAddress("0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"),
|
|
Symbol: "USDT",
|
|
Name: "Tether USD",
|
|
Decimals: 6,
|
|
Category: "blue-chip",
|
|
IsVerified: true,
|
|
RiskScore: 0.1,
|
|
IsStablecoin: true,
|
|
},
|
|
// GMX
|
|
common.HexToAddress("0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a"): {
|
|
Address: common.HexToAddress("0xfc5A1A6EB076a2C7aD06eD22C90d7E710E35ad0a"),
|
|
Symbol: "GMX",
|
|
Name: "GMX",
|
|
Decimals: 18,
|
|
Category: "defi",
|
|
IsVerified: true,
|
|
RiskScore: 0.3,
|
|
},
|
|
}
|
|
}
|
|
|
|
// getERC20ABI returns the standard ERC20 ABI
|
|
func getERC20ABI() string {
|
|
return `[
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "name",
|
|
"outputs": [{"name": "", "type": "string"}],
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "symbol",
|
|
"outputs": [{"name": "", "type": "string"}],
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "decimals",
|
|
"outputs": [{"name": "", "type": "uint8"}],
|
|
"type": "function"
|
|
},
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "totalSupply",
|
|
"outputs": [{"name": "", "type": "uint256"}],
|
|
"type": "function"
|
|
}
|
|
]`
|
|
}
|
|
|
|
// getProxyABI returns a simple proxy ABI for implementation detection
|
|
func getProxyABI() string {
|
|
return `[
|
|
{
|
|
"constant": true,
|
|
"inputs": [],
|
|
"name": "implementation",
|
|
"outputs": [{"name": "", "type": "address"}],
|
|
"type": "function"
|
|
}
|
|
]`
|
|
}
|