Skill 寫法圖鑑

evm-token-decimals

affaan-m | 金融與投資 | 原文 | MIT

避免跨 EVM 鏈的代幣 decimals 錯位,執行時查詢並正確正規化

中文翻譯

---
name: evm-token-decimals
description: 預防跨 EVM 鏈默默發生的 decimal 不一致 bug。涵蓋 runtime 查詢 decimal、感知鏈的快取、橋接代幣的精度漂移,以及給 bot、dashboard 和 DeFi 工具用的安全正規化。在處理跨 EVM 鏈的代幣數量,或餘額、價格、轉帳金額差了好幾個數量級時使用。
metadata:
  version: "1.0.0"
  origin: ECC direct-port adaptation
---

# EVM 代幣 Decimals

默默發生的 decimal 不一致,是最容易讓你出貨的餘額或 USD 價值差了好幾個數量級、卻完全不報錯的原因之一。

## 何時使用

- 在 Python、TypeScript 或 Solidity 中讀取 ERC-20 餘額
- 從鏈上餘額計算法幣價值
- 比較多條 EVM 鏈上的代幣數量
- 處理橋接過來的資產
- 建立投資組合追蹤器、bot 或聚合器

## 運作方式

絕對不要假設穩定幣在每個地方用的 decimals 都一樣。在 runtime 查詢 `decimals()`,用 `(chain_id, token_address)` 當 key 做快取,並在價值計算上用 decimal 安全的數學。

## 範例

### 在 runtime 查詢 decimals

```python
from decimal import Decimal
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_token_balance(w3: Web3, token_address: str, wallet: str) -> Decimal:
    contract = w3.eth.contract(
        address=Web3.to_checksum_address(token_address),
        abi=ERC20_ABI,
    )
    decimals = contract.functions.decimals().call()
    raw = contract.functions.balanceOf(Web3.to_checksum_address(wallet)).call()
    return Decimal(raw) / Decimal(10 ** decimals)
```

不要寫死 `1_000_000`,因為同一個符號在別的地方通常有 6 位 decimals。

### 依鏈與代幣做快取

```python
from functools import lru_cache

@lru_cache(maxsize=512)
def get_decimals(chain_id: int, token_address: str) -> int:
    w3 = get_web3_for_chain(chain_id)
    contract = w3.eth.contract(
        address=Web3.to_checksum_address(token_address),
        abi=ERC20_ABI,
    )
    return contract.functions.decimals().call()
```

### 防禦式處理特殊代幣

```python
try:
    decimals = contract.functions.decimals().call()
except Exception:
    logging.warning(
        "decimals() reverted on %s (chain %s), defaulting to 18",
        token_address,
        chain_id,
    )
    decimals = 18
```

記錄 fallback 並讓它保持可見。舊的或不標準的代幣仍然存在。

### 在 Solidity 中正規化成 18 位 decimal 的 WAD

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

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);
    return amount / 10 ** (d - 18);
}
```

### 搭配 ethers 的 TypeScript

```typescript
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 token = new Contract(tokenAddress, ERC20_ABI, provider);
  const [decimals, raw] = await Promise.all([
    token.decimals(),
    token.balanceOf(wallet),
  ]);
  return formatUnits(raw, decimals);
}
```

### 快速的鏈上檢查

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

## 規則

- 一律在 runtime 查詢 `decimals()`
- 依鏈加代幣地址做快取,不要用符號
- 用 `Decimal`、`BigInt` 或等價的精確數學,不要用 float
- 橋接或 wrapper 變更之後,重新查詢 decimals
- 比較或定價之前,先一致地正規化內部帳務

英文原文

---
name: evm-token-decimals
description: Prevent silent decimal mismatch bugs across EVM chains. Covers runtime decimal lookup, chain-aware caching, bridged-token precision drift, and safe normalization for bots, dashboards, and DeFi tools. Use when handling token amounts across EVM chains, or when a balance, price, or transfer amount is off by orders of magnitude.
metadata:
  version: "1.0.0"
  origin: ECC direct-port adaptation
---

# EVM Token Decimals

Silent decimal mismatches are one of the easiest ways to ship balances or USD values that are off by orders of magnitude without throwing an error.

## When to Use

- Reading ERC-20 balances in Python, TypeScript, or Solidity
- Calculating fiat values from on-chain balances
- Comparing token amounts across multiple EVM chains
- Handling bridged assets
- Building portfolio trackers, bots, or aggregators

## How It Works

Never assume stablecoins use the same decimals everywhere. Query `decimals()` at runtime, cache by `(chain_id, token_address)`, and use decimal-safe math for value calculations.

## Examples

### Query decimals at runtime

```python
from decimal import Decimal
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_token_balance(w3: Web3, token_address: str, wallet: str) -> Decimal:
    contract = w3.eth.contract(
        address=Web3.to_checksum_address(token_address),
        abi=ERC20_ABI,
    )
    decimals = contract.functions.decimals().call()
    raw = contract.functions.balanceOf(Web3.to_checksum_address(wallet)).call()
    return Decimal(raw) / Decimal(10 ** decimals)
```

Do not hardcode `1_000_000` because a symbol usually has 6 decimals somewhere else.

### Cache by chain and token

```python
from functools import lru_cache

@lru_cache(maxsize=512)
def get_decimals(chain_id: int, token_address: str) -> int:
    w3 = get_web3_for_chain(chain_id)
    contract = w3.eth.contract(
        address=Web3.to_checksum_address(token_address),
        abi=ERC20_ABI,
    )
    return contract.functions.decimals().call()
```

### Handle odd tokens defensively

```python
try:
    decimals = contract.functions.decimals().call()
except Exception:
    logging.warning(
        "decimals() reverted on %s (chain %s), defaulting to 18",
        token_address,
        chain_id,
    )
    decimals = 18
```

Log the fallback and keep it visible. Old or non-standard tokens still exist.

### Normalize to 18-decimal WAD in Solidity

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

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);
    return amount / 10 ** (d - 18);
}
```

### TypeScript with ethers

```typescript
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 token = new Contract(tokenAddress, ERC20_ABI, provider);
  const [decimals, raw] = await Promise.all([
    token.decimals(),
    token.balanceOf(wallet),
  ]);
  return formatUnits(raw, decimals);
}
```

### Quick on-chain check

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

## Rules

- Always query `decimals()` at runtime
- Cache by chain plus token address, not symbol
- Use `Decimal`, `BigInt`, or equivalent exact math, not float
- Re-query decimals after bridging or wrapper changes
- Normalize internal accounting consistently before comparison or pricing