EVM Token Decimals — Guía Anti-Bug

ChainPulse Analytics · Módulo token_decimals.py · Portfolio Tracker Multi-Cadena

IA Ingeniería MLOps ERC-20 · EVM Nivel Avanzado
⚠ Bug real en producción — ChainPulse Analytics

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.

Tabla de decimales por cadena y token
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
El bug clásico y la corrección
❌ Código con bug (hardcoded)
# 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
✓ Corrección (runtime query)
# 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
Módulo completo — token_decimals.py
chainpulse/token_decimals.py Python · web3.py
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
Equivalente TypeScript (frontend dashboard)
lib/tokenDecimals.ts TypeScript · ethers v6
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);
}
Normalización en Solidity (contratos on-chain)
contracts/TokenUtils.sol Solidity ^0.8.20
// 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);
    }
}
Tests unitarios (resultado simulado)
$ python -m pytest chainpulse/tests/test_token_decimals.py -v
============================= test session starts ============================== platform linux -- Python 3.11.4, pytest-7.4.0, web3-6.11.0 collected 8 items
PASSED test_usdc_eth_6_decimals ............. USDC en ETH: 5000.00 USDC ✓ PASSED test_usdc_arbitrum_6_decimals ........ USDC en Arb: 12500.50 USDC ✓ PASSED test_wbtc_8_decimals ................. WBTC: 0.25000000 WBTC ✓ PASSED test_weth_18_decimals ................ WETH: 3.141592653589793 WETH ✓ PASSED test_cache_prevents_double_rpc ....... get_decimals() llamado 1x, no 3x ✓ PASSED test_fallback_on_revert .............. Token sin decimals() → 18 + WARNING ✓ WARNING test_fallback_on_revert: decimals() reverted on 0xDEAD... (chain 1) — usando 18 PASSED test_usd_value_decimal_precision ..... $1,234,567.89 (sin float drift) ✓ PASSED test_decimal_drift_detection ......... DRIFT ALERT si 6≠18 detectado ✓
============================== 8 passed, 1 warning in 0.43s ==============================
Reglas de oro
01
Siempre query runtime
Llama a decimals() en cada token; nunca hardcodees aunque "sepas" el valor.
02
Cache por (chain, address)
No por símbolo. USDC en ETH y USDC bridgeado son contratos distintos.
03
Decimal, no float
Usa Decimal / BigInt. Los floats pierden precisión silenciosamente.
04
Loguea el fallback
Si decimals() revierta y usas 18 por defecto, que quede visible en los logs.
05
Re-query tras bridge
Los tokens bridgeados o wrappers pueden cambiar decimales. Invalida la caché.
06
Normaliza antes de comparar
Al mezclar tokens en pools: normaliza todo a WAD (18 dec) antes de cualquier aritmética.