El tracker mostraba saldos de USDC en Arbitrum multiplicados por 1,000,000,000,000× porque hardcodeaba decimals=18. Un usuario con 5,000 USDC veía $5,000,000,000,000 USD en el dashboard. Root cause: USDC tiene 6 decimales, no 18.
| Token | Dirección (Ethereum) | Decimales | Cadenas verificadas | Advertencia |
|---|---|---|---|---|
| USDC | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 | 6 | ETH:1 Arb:42161 Base:8453 Pol:137 | Bridged USDC puede diferir |
| USDT | 0xdAC17F958D2ee523a2206206994597C13D831ec7 | 6 | ETH:1 Pol:137 | Consultar siempre en runtime |
| WBTC | 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599 | 8 | ETH:1 Arb:42161 | Sigue a BTC (8 satoshis) |
| WETH | 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 | 18 | ETH:1 Arb:42161 Base:8453 | Consistente cross-chain |
| DAI | 0x6B175474E89094C44Da98b954EedeAC495271d0F | 18 | ETH:1 Pol:137 | 18 decimales, sí — raro en stables |
| CHAINP | 0x9f8F72aA9304c8B593d555F12eF6589cC3A579A2 | 18 | ETH:1 Base:8453 | Token nativo ChainPulse |
# NO HACER: decimal hardcodeado def get_balance_buggy(w3, token, wallet): contract = w3.eth.contract( address=token, abi=ERC20_ABI ) raw = contract.functions.balanceOf(wallet).call() # BUG: asume 18 siempre return raw / 10**18 # USDC (6 dec) con 5000 unidades reales: # raw = 5_000_000_000 # resultado = 5_000_000_000 / 1e18 # = 0.000000005 USD ← INCORRECTO
# CORRECTO: consultar decimals() en runtime def get_balance(w3, token, wallet): contract = w3.eth.contract( address=token, abi=ERC20_ABI ) # query on-chain, no hardcode decimals = contract.functions.decimals().call() raw = contract.functions.balanceOf(wallet).call() return Decimal(raw) / Decimal(10**decimals) # USDC (6 dec) con 5000 unidades reales: # decimals = 6 (obtenido del contrato) # = 5_000_000_000 / 1_000_000 # = 5000.00 USDC ← CORRECTO
from __future__ import annotations import logging from decimal import Decimal, getcontext from functools import lru_cache from typing import Optional from web3 import Web3 getcontext().prec = 28 # precisión Decimal suficiente para DeFi log = logging.getLogger(__name__) # ABI mínimo: sólo las funciones que necesitamos ERC20_ABI = [ {"name": "decimals", "type": "function", "inputs": [], "outputs": [{"type": "uint8"}], "stateMutability": "view"}, {"name": "balanceOf", "type": "function", "inputs": [{"name": "account", "type": "address"}], "outputs": [{"type": "uint256"}], "stateMutability": "view"}, ] # Mapa de RPC endpoints por chain_id RPC_URLS: dict[int, str] = { 1: "https://eth.llamarpc.com", # Ethereum Mainnet 42161: "https://arb1.arbitrum.io/rpc", # Arbitrum One 137: "https://polygon-rpc.com", # Polygon PoS 8453: "https://mainnet.base.org", # Base } def get_web3_for_chain(chain_id: int) -> Web3: """Devuelve una instancia Web3 para la cadena dada.""" rpc = RPC_URLS.get(chain_id) if not rpc: raise ValueError(f"chain_id {chain_id} no configurado") return Web3(Web3.HTTPProvider(rpc)) @lru_cache(maxsize=512) def get_decimals(chain_id: int, token_address: str) -> int: """Consulta decimals() en runtime. Cache por (chain_id, token_address). Key insight: cachear por (chain, dirección), NO por símbolo. USDC en ETH y USDC bridgeado en una L2 pueden tener contratos distintos con decimales distintos. """ w3 = get_web3_for_chain(chain_id) checksum_addr = Web3.to_checksum_address(token_address) contract = w3.eth.contract(address=checksum_addr, abi=ERC20_ABI) try: decimals = contract.functions.decimals().call() log.debug("decimals(%s, %s) = %d", chain_id, token_address, decimals) return decimals except Exception as exc: # Algunos tokens viejos o no-estándar revierten en decimals() log.warning( "decimals() reverted on %s (chain %s): %s — usando 18 por defecto", token_address, chain_id, exc ) return 18 # fallback visible en logs, no silencioso def get_token_balance( w3: Web3, chain_id: int, token_address: str, wallet: str, ) -> Decimal: """Devuelve el balance normalizado de un token ERC-20. Usa Decimal (no float) para evitar pérdida de precisión en valores grandes o con muchos decimales. Crítico para WBTC (8 dec) o tokens con precios >$10k/unidad. """ checksum_token = Web3.to_checksum_address(token_address) checksum_wallet = Web3.to_checksum_address(wallet) contract = w3.eth.contract(address=checksum_token, abi=ERC20_ABI) decimals = get_decimals(chain_id, token_address) raw: int = contract.functions.balanceOf(checksum_wallet).call() return Decimal(raw) / Decimal(10 ** decimals) def usd_value(balance: Decimal, price_usd: Decimal) -> Decimal: """Calcula valor USD con aritmética exacta. price_usd debe venir como Decimal, nunca float. Usa str() al convertir desde JSON: Decimal(str(price_json)) no Decimal(price_json). """ return (balance * price_usd).quantize(Decimal("0.01")) def check_decimal_drift( chain_id: int, token_address: str, expected_decimals: int, ) -> Optional[str]: """Alerta si el decimals() on-chain difiere del valor esperado. Útil para detectar tokens bridgeados que cambiaron decimales. Devuelve mensaje de alerta o None si todo OK. """ actual = get_decimals(chain_id, token_address) if actual != expected_decimals: msg = ( f"DECIMAL DRIFT DETECTED: {token_address} en chain {chain_id} " f"tiene {actual} decimales, se esperaban {expected_decimals}. " f"Revisar si es un token bridgeado o wrapper." ) log.error(msg) return msg return None
import { Contract, JsonRpcProvider, formatUnits } from 'ethers'; const ERC20_ABI = [ 'function decimals() view returns (uint8)', 'function balanceOf(address) view returns (uint256)', ]; // Cache en memoria para la sesión del usuario const decimalsCache = new Map<string, number>(); export async function getDecimals( provider: JsonRpcProvider, chainId: number, tokenAddress: string, ): Promise<number> { const key = `${chainId}:${tokenAddress.toLowerCase()}`; if (decimalsCache.has(key)) return decimalsCache.get(key)!; const token = new Contract(tokenAddress, ERC20_ABI, provider); const d: number = await token.decimals(); decimalsCache.set(key, d); return d; } export async function getBalance( provider: JsonRpcProvider, chainId: number, tokenAddress: string, wallet: string, ): Promise<string> { const [decimals, raw] = await Promise.all([ getDecimals(provider, chainId, tokenAddress), new Contract(tokenAddress, ERC20_ABI, provider).balanceOf(wallet), ]); // formatUnits usa BigInt internamente: sin pérdida de precisión return formatUnits(raw, decimals); }
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IERC20Metadata { function decimals() external view returns (uint8); } library TokenUtils { /// @notice Normaliza `amount` a WAD (18 decimales) para comparaciones internas. /// @dev Necesario cuando se mezclan tokens con distintos decimales en el mismo pool. function normalizeToWad( address token, uint256 amount ) internal view returns (uint256) { uint8 d = IERC20Metadata(token).decimals(); if (d == 18) return amount; if (d < 18) return amount * 10 ** (18 - d); // scale up return amount / 10 ** (d - 18); // scale down (raro) } /// @notice Desnormaliza desde WAD al decimal nativo del token. function fromWad( address token, uint256 wadAmount ) internal view returns (uint256) { uint8 d = IERC20Metadata(token).decimals(); if (d == 18) return wadAmount; if (d < 18) return wadAmount / 10 ** (18 - d); return wadAmount * 10 ** (d - 18); } }
decimals() en cada token; nunca hardcodees aunque "sepas" el valor.Decimal / BigInt. Los floats pierden precisión silenciosamente.decimals() revierta y usas 18 por defecto, que quede visible en los logs.