
Preventing ERC-20 Decimals Mismatch Bugs in Solidity
Why decimals matter
The ERC-20 standard defines decimals() as an optional metadata function, not a strict accounting rule. In practice, many tokens use 18 decimals, but plenty do not:
- USDC: 6 decimals
- WBTC: 8 decimals
- WETH: 18 decimals
- Some governance or legacy tokens: 0, 2, or other custom values
A token amount is stored in the smallest unit, not in human-readable form. For example:
1 USDC=1_000_000base units1 WETH=1_000_000_000_000_000_000base units
If your contract compares raw integers across tokens without normalization, the result is often wrong by orders of magnitude.
Common failure modes
Decimals bugs usually appear in one of these places:
| Area | Typical mistake | Impact |
|---|---|---|
| Deposits | Treating all tokens as 18 decimals | Incorrect share minting or crediting |
| Price conversion | Mixing oracle prices with raw token units | Wrong valuation and liquidation logic |
| Withdrawals | Returning the wrong amount after scaling | User losses or protocol insolvency |
| UI helpers | Formatting on-chain values without token metadata | Misleading balances and quotes |
| Cross-token accounting | Summing heterogeneous assets directly | Broken portfolio or reserve math |
The core rule: never assume 18 decimals
A robust contract should always ask the token for its decimals, cache the result if appropriate, and normalize amounts before comparing or combining them.
However, there is an important nuance: not every token implements decimals() correctly, and some tokens may revert or return unusual values. Your design should handle that reality explicitly.
Safe assumptions
Use these principles:
- Store amounts in token-native units internally whenever possible
- Avoid converting unless you must compare or aggregate across tokens.
- Normalize only at boundaries
- Convert for pricing, accounting, or display, not for every internal operation.
- Treat decimals as configuration, not a constant
- Especially for multi-asset systems, vaults, and routers.
- Validate token metadata on initialization
- Reject unsupported decimals ranges if your math depends on them.
A practical example: incorrect normalization
Consider a vault that accepts USDC and mints shares based on deposited value. A naive implementation might assume 18 decimals and scale everything by 1e18.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20Metadata {
function decimals() external view returns (uint8);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
}
contract BadVault {
IERC20Metadata public immutable asset;
constructor(address _asset) {
asset = IERC20Metadata(_asset);
}
function deposit(uint256 amount) external {
// Incorrect: assumes amount is already 18-decimal normalized
uint256 normalized = amount * 1e12;
require(asset.transferFrom(msg.sender, address(this), amount), "transfer failed");
// Example share minting logic based on normalized amount
_mintShares(msg.sender, normalized);
}
function _mintShares(address, uint256) internal pure {
// omitted
}
}If asset is USDC, amount is already in 6-decimal units. Multiplying by 1e12 may be appropriate for some internal math, but only if the rest of the system is designed around that convention. If the contract later uses the same value as though it were a token amount, the accounting becomes inconsistent.
The bug is not the multiplication itself; the bug is failing to define a single, consistent unit model.
Design a unit model first
Before writing code, decide what each variable represents:
- Token-native units: raw ERC-20 amounts
- Normalized units: common precision, often 18 decimals
- Value units: price-adjusted amounts, such as USD with 18 decimals
A clean contract should document this explicitly. For example:
assetAmount: token-native unitsscaledAmount: normalized to 18 decimalsusdValue: USD value with 18 decimals
This naming discipline prevents many mistakes.
Recommended conversion helpers
Use small, pure helper functions for scaling:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
library DecimalMath {
uint256 internal constant WAD = 1e18;
function scaleTo18(uint256 amount, uint8 decimals) internal pure returns (uint256) {
if (decimals == 18) return amount;
if (decimals < 18) return amount * (10 ** (18 - decimals));
return amount / (10 ** (decimals - 18));
}
function scaleFrom18(uint256 amount, uint8 decimals) internal pure returns (uint256) {
if (decimals == 18) return amount;
if (decimals < 18) return amount / (10 ** (18 - decimals));
return amount * (10 ** (decimals - 18));
}
}This pattern is simple, but it is only safe if you understand the trade-offs:
- Scaling up can overflow for very large amounts.
- Scaling down truncates precision.
- Repeated conversions can accumulate rounding loss.
For production systems, consider bounding supported decimals and validating maximum deposit sizes.
Handling rounding explicitly
Decimals mismatches are often really rounding bugs in disguise. When converting between precisions, you must choose whether to round down, round up, or reject dust.
Rounding policy options
| Policy | Behavior | Best for |
|---|---|---|
| Round down | Favors the protocol, may leave dust | Minting shares, conservative accounting |
| Round up | Favors the user, may over-credit slightly | Fee calculations, minimum payouts |
| Reject dust | Revert if precision would be lost | High-integrity accounting systems |
A common safe choice is to round down when minting shares and round down when converting value to avoid over-crediting users. But if you round down in both deposit and withdrawal paths, users may lose value to truncation over time. The protocol should define a consistent policy and document it.
Example: conservative conversion
function toNormalized(uint256 amount, uint8 decimals) internal pure returns (uint256) {
if (decimals == 18) return amount;
if (decimals < 18) return amount * (10 ** (18 - decimals));
return amount / (10 ** (decimals - 18)); // rounds down
}If you need exactness, add a dust check:
function toNormalizedExact(uint256 amount, uint8 decimals) internal pure returns (uint256) {
if (decimals == 18) return amount;
uint256 factor = 10 ** (decimals > 18 ? decimals - 18 : 18 - decimals);
if (decimals > 18) {
require(amount % factor == 0, "precision loss");
return amount / factor;
}
return amount * factor;
}This is stricter, but it avoids silent truncation.
Validate decimals at the boundary
If your protocol only supports a limited set of tokens, validate their decimals during setup. This is especially useful for vaults, lending markets, and accounting systems where math is built around a fixed precision.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20Metadata {
function decimals() external view returns (uint8);
}
contract AssetConfig {
struct TokenConfig {
address token;
uint8 decimals;
}
mapping(address => TokenConfig) public configs;
function registerToken(address token) external {
uint8 d = IERC20Metadata(token).decimals();
require(d <= 18, "unsupported decimals");
configs[token] = TokenConfig({token: token, decimals: d});
}
}Why reject decimals above 18 in many systems? Because scaling up from 18 to a larger precision can overflow more easily and complicate value math. That said, some protocols do support higher precision by using 256-bit-safe intermediate math and carefully bounded inputs. The key is to make the choice explicit.
Don’t mix token units with oracle units
A very common bug occurs when developers combine token amounts with oracle prices without aligning decimals.
Suppose:
- Token amount is in 6 decimals
- Oracle price is in 8 decimals
- You want USD value in 18 decimals
The formula must account for all three precisions.
Example conversion
function usdValue(
uint256 tokenAmount,
uint8 tokenDecimals,
uint256 price,
uint8 priceDecimals
) internal pure returns (uint256) {
uint256 normalizedAmount = tokenAmount * (10 ** (18 - tokenDecimals));
uint256 normalizedPrice = price * (10 ** (18 - priceDecimals));
// Result is 18-decimal USD value
return (normalizedAmount * normalizedPrice) / 1e18;
}This works only if tokenDecimals <= 18 and priceDecimals <= 18. If not, you need the inverse scaling path and careful overflow checks.
A safer approach is to use a tested fixed-point math library and define one canonical precision for your protocol, such as 18 decimals for all internal value calculations.
Cache decimals, but verify trust assumptions
Calling decimals() repeatedly is usually cheap, but caching is still useful for gas efficiency and consistency. Once cached, however, the value becomes part of your trust model.
When caching is appropriate
- The token is immutable and well-known
- The contract is initialized once and never changes asset metadata
- You want deterministic behavior across transactions
When caching is risky
- The token is upgradeable or non-standard
- The token contract can change metadata behavior
- You support arbitrary user-supplied tokens
For untrusted tokens, prefer reading metadata once at onboarding and storing the result. If the token later changes behavior, your protocol remains internally consistent.
Test with non-18-decimal assets
Many teams test only with 18-decimal mock tokens, which hides bugs until mainnet integration. Your test suite should include at least:
- 6-decimal token mock
- 8-decimal token mock
- 18-decimal token mock
- A token that returns unexpected decimals, such as 0 or 255
- A token that reverts on
decimals()
What to assert
- Deposits mint the correct number of shares
- Withdrawals return the expected token-native amount
- Price conversions preserve intended value
- Rounding behavior is documented and consistent
- Unsupported tokens are rejected cleanly
A good invariant is: for any supported token, a deposit followed by an immediate withdrawal should not create or destroy value beyond documented rounding loss.
Best practices checklist
Use this checklist when building contracts that handle heterogeneous ERC-20 assets:
- Store and document the unit of every amount variable
- Normalize only when comparing or aggregating across assets
- Read and validate token decimals during initialization
- Reject unsupported precision ranges if your math cannot handle them
- Define a clear rounding policy
- Test with non-18-decimal tokens
- Avoid repeated scaling between units unless necessary
- Keep oracle decimals and token decimals separate in code and naming
- Prefer conservative rounding in user-facing mint/burn flows
A practical pattern for vaults and routers
For vaults, lending pools, and swap routers, a strong pattern is:
- Accept token-native input
- Convert to a canonical internal precision
- Perform all accounting in canonical units
- Convert back to token-native units only when transferring out
This reduces the number of places where precision can go wrong. It also makes audits easier because reviewers can verify a single conversion boundary instead of chasing ad hoc scaling throughout the codebase.
If your protocol supports multiple assets, store per-asset metadata in a struct:
struct AssetInfo {
address token;
uint8 decimals;
bool enabled;
}Then use that metadata consistently in all pricing and accounting functions.
Conclusion
Decimals mismatch bugs are subtle because they rarely cause immediate reverts. Instead, they distort value silently. In Solidity security reviews, this class of bug often appears in vaults, fee systems, and cross-asset accounting logic where developers assume a uniform precision that does not exist.
The safest approach is to define a canonical unit model, validate token metadata at the boundary, and convert amounts only through explicit helper functions. Combined with thorough testing against non-18-decimal tokens, this eliminates a large and avoidable category of financial errors.
