All skills

Prevent hidden token decimal bugs across EVM chains. Read decimals at run time, cache by chain and token, handle bridged tokens, and safely scale values.

  • 1 file
  • 7.4 KB
  • Updated 3 weeks ago
  • GitHub

Use this Skill: https://skilld.dev/gh/agenticluke/safe-token-decimals-plus/skill

This session only. Nothing lands on disk.

SKILL.md

≈40 tokens always: the name and description. ≈1.8k when used: this file.

EVM Token Decimals

Credit: This skill is a direct-port adaptation from ECC.

A wrong decimal value can make a balance or USD value wrong by a huge amount. The code may still run with no error.

Use This Skill When

Use this skill when you:

  • Read ERC-20 balances
  • Show token amounts to users
  • Find the USD value of tokens
  • Compare tokens across EVM chains
  • Work with wrapped or bridged tokens
  • Build bots, DeFi tools, or wallet apps

Core Rule

Never guess decimals from a token name or symbol.

Read decimals() from the token contract. Cache it by:

(chain_id, token_address)

Use the checksum or lower-case address in one set form.

Safe Steps

  1. Get the chain ID from the connected RPC.
  2. Check that the token address has contract code.
  3. Call decimals().
  4. Make sure the result is in a safe range.
  5. Cache it by chain ID and token address.
  6. Keep raw amounts as integers.
  7. Scale only when you show, compare, or price the amount.

Python Example

from decimal import Decimal
from functools import lru_cache
from web3 import Web3

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",
    },
]

def get_web3_for_chain(chain_id: int) -> Web3:
    raise NotImplementedError("Return the Web3 client for this chain")

@lru_cache(maxsize=512)
def get_decimals(chain_id: int, token_address: str) -> int:
    w3 = get_web3_for_chain(chain_id)
    token = Web3.to_checksum_address(token_address)

    if not w3.eth.get_code(token):
        raise ValueError(f"No contract code at {token} on chain {chain_id}")

    contract = w3.eth.contract(address=token, abi=ERC20_ABI)
    decimals = int(contract.functions.decimals().call())

    if decimals < 0 or decimals > 77:
        raise ValueError(f"Unsafe decimals value: {decimals}")

    return decimals

def get_token_balance(
    chain_id: int,
    token_address: str,
    wallet: str,
) -> Decimal:
    w3 = get_web3_for_chain(chain_id)
    token = Web3.to_checksum_address(token_address)
    owner = Web3.to_checksum_address(wallet)

    contract = w3.eth.contract(address=token, abi=ERC20_ABI)
    raw = contract.functions.balanceOf(owner).call()
    decimals = get_decimals(chain_id, token.lower())

    return Decimal(raw) / (Decimal(10) ** decimals)

Do not hard-code 1_000_000 just because a token with the same symbol uses six decimals on another chain.

Concrete Usage Example

A wallet has a raw balance of 12_345_678. The token reports six decimals.

raw = 12_345_678
decimals = 6

amount = Decimal(raw) / (Decimal(10) ** decimals)
print(amount)  # 12.345678

If the token price is $2.50:

price = Decimal("2.50")
usd_value = amount * price

print(usd_value)  # 30.8641950

Do not use float for these steps.

TypeScript With ethers

import { Contract, formatUnits } from "ethers";

const ERC20_ABI = [
  "function decimals() view returns (uint8)",
  "function balanceOf(address) view returns (uint256)",
];

async function getBalance(
  provider: any,
  tokenAddress: string,
  wallet: string,
): Promise<string> {
  const network = await provider.getNetwork();
  const code = await provider.getCode(tokenAddress);

  if (code === "0x") {
    throw new Error(
      `No contract code at ${tokenAddress} on chain ${network.chainId}`,
    );
  }

  const token = new Contract(tokenAddress, ERC20_ABI, provider);
  const [decimalsValue, raw] = await Promise.all([
    token.decimals(),
    token.balanceOf(wallet),
  ]);

  const decimals = Number(decimalsValue);

  if (!Number.isInteger(decimals) || decimals < 0 || decimals > 77) {
    throw new Error(`Unsafe decimals value: ${decimalsValue}`);
  }

  return formatUnits(raw, decimals);
}

Use bigint for raw values. Use a decimal math library for prices and totals.

Failed decimals() Calls

Some old or unusual tokens do not support decimals().

Do not silently use 18. That can hide a large error.

Use one of these safe choices:

  • Stop and mark the token as not supported.
  • Use a reviewed token list keyed by chain ID and address.
  • Allow a set fallback only for a known token.

Log the token address, chain ID, and fallback source. Never choose a fallback from the symbol alone.

Solidity WAD Scaling

This example scales an amount to 18 decimals:

interface IERC20Metadata {
    function decimals() external view returns (uint8);
}

error UnsafeDecimals(uint8 decimals);

function normalizeToWad(
    address token,
    uint256 amount
) internal view returns (uint256) {
    uint8 d = IERC20Metadata(token).decimals();

    if (d > 77) revert UnsafeDecimals(d);
    if (d == 18) return amount;

    if (d < 18) {
        uint256 factor = 10 ** (18 - d);
        return amount * factor;
    }

    return amount / (10 ** (d - 18));
}

Important limits:

  • Scaling up can overflow and revert.
  • Scaling down cuts off the remainder.
  • A bad token can return an unsafe value.
  • A token call can revert.
  • Do not call unknown tokens inside a key state change without handling failure.

Use checked math and state the rounding rule. For large values or price math, use a safe mulDiv library.

Bridged, Wrapped, and Proxy Tokens

The same asset may use different decimals on each chain.

Treat these as separate tokens:

(1, 0xToken...)
(42161, 0xToken...)

Also read decimals again when:

  • A bridge gives you a new token address
  • A wrapper gives you a new token address
  • A proxy contract is upgraded
  • Your app changes RPC networks
  • A cached value is old or not trusted

Use a cache time limit if proxy upgrades are possible. Clear the cache after a known upgrade.

Native Coins

A native coin is not an ERC-20 token. It has no token contract to call.

Get its decimals from trusted chain settings. Most EVM native coins use 18 decimals, but your app must not assume this for every chain.

Use a clear key for native coins. Do not use the zero address unless your whole app defines that rule.

Price and Compare Rules

Before you compare or price amounts:

  1. Check that both values use the right chain and token.
  2. Keep raw token amounts as integers.
  3. Read the token decimals.
  4. Read the price feed decimals too.
  5. Scale both values with exact math.
  6. Pick and state a rounding rule.

Token decimals and price feed decimals are separate values.

Quick Check

cast call <token_address> "decimals()(uint8)" --rpc-url <rpc>

Also check that the RPC is on the chain you expect:

cast chain-id --rpc-url <rpc>

Rules

  • Read decimals() at run time.
  • Cache by chain ID and token address.
  • Never cache by symbol or name.
  • Keep raw amounts as integers.
  • Use Decimal, bigint, or other exact math.
  • Do not silently use 18 after a failed call.
  • Check for missing contract code.
  • Reject unsafe decimal values.
  • Read decimals again after a bridge, wrap, or proxy upgrade.
  • Track rounding when scaling down.
  • Check price feed decimals on their own.
  • Test tokens with 0, 6, 8, 18, and more than 18 decimals.

Source: SKILL.md on GitHub

No third-party reports yet.

Signed by skilld at d504894. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 weeks ago.

Activeupdated 3 weeks ago
origin
ECC direct-port adaptation
version
1.0.0

README badge

README badge for agenticluke/safe-token-decimals-plus