ERC-20 amounts are meaningless without the token's decimals
An ERC-20 balance is a uint256 counting the token's smallest unit, and nothing in that integer tells you how many units make one token. You have to ask the contract. Assume eighteen decimals against a six-decimal token and every amount you compute is off by a factor of a million, with no revert, no exception, and a perfectly valid integer on both sides of the mistake.
I have run stablecoin payment rails in production long enough to stop treating this as a display concern. It is not formatting. It is the gap between a payout of one and a half dollars and a payout of one and a half million, written in code that type-checks either way.
The chain stores units, your product stores amounts#
The whole problem fits in one table. These are the tokens most payment systems actually touch:
| Token | Decimals | 1.5 tokens, in units |
|---|---|---|
| USDT | 6 | 1500000 |
| USDC | 6 | 1500000 |
| WBTC | 8 | 150000000 |
| DAI | 18 | 1500000000000000000 |
Every one of those integers is a legal uint256. If your encoder scales 1.5 by 10**18 and hands the result to USDT, the call data is well-formed, the ABI encoding is correct, the transaction is valid, and the transfer either moves a million times too much or reverts on an insufficient balance. Which of the two you get depends on how well funded the sender happens to be. That is the genuinely dangerous part: the failure mode is not deterministic. On a thin hot wallet it looks like a funding bug. On a well-funded one it looks like nothing at all until reconciliation runs.
No invariant in your system catches it either. There is no checksum on scale. A number does not describe itself: 1500000000000000000 is a valid amount of some token, just not of this one.
Decimals are a property of the contract, and optional at that#
In the ERC-20 standard, decimals is an optional method. Most tokens implement it, tokens are free not to, and the value that comes back is the token's business rather than a convention you can assume. Six for the dollar stablecoins, eighteen for DAI and most of the long tail, eight for WBTC because it inherited Bitcoin's granularity. There is no default.
So it gets read. In erc20-transfers (https://github.com/polycratia/erc20-transfers) that read is one call, encoded and decoded like every other:
from erc20_transfers import decode_decimals, encode_decimals
decimals = decode_decimals(eth_call(token, encode_decimals()))What the library deliberately does not do is remember the answer for you. decimals is a required argument on every conversion. That looks like friction until you price the alternative: a library that caches on your behalf owns a cache whose key it cannot see. It does not know which chain you are on, whether the address you passed is the token you think it is, or whether your process lives long enough for the cache to matter. Making the parameter explicit pushes that decision down to the only layer with enough context to make it.
In my own services that layer is the token registry, and the cache key is the pair that actually identifies a contract:
_decimals: dict[tuple[int, str], int] = {}
def decimals_for(chain_id: int, token: str) -> int:
key = (chain_id, token.lower())
if key not in _decimals:
_decimals[key] = decode_decimals(eth_call(token, encode_decimals()))
return _decimals[key]Not the symbol. Symbols are not unique, are not stable, and are the first thing a counterfeit token copies. The same three letters on two chains are two contracts with independent decimals, and the cheap way to learn this is to key your registry on the address before you find out the expensive way.
Convert at the edges, and only at the edges#
Once decimals stop being a constant, the interesting question is where in the system they get to appear. My answer, after enough rewrites of the same code: in exactly two places, both of them boundaries with a human on the other side.
Input is the first one. A person types 1.5, and that string means nothing until it is bound to a token:
from decimal import Decimal
from erc20_transfers import encode_transfer, to_units
amount = to_units(Decimal("1.5"), decimals=decimals) # 1500000 on USDT
data = encode_transfer(to=bob, amount=amount)Display is the second one, and it needs the same pairing to read a balance back off the chain:
from erc20_transfers import TokenAmount, encode_balance_of
balance = TokenAmount.from_return_data(
eth_call(token, encode_balance_of(account=alice)), decimals=decimals
)
print(balance.amount, balance.units) # 1.500000 1500000Between those two edges, nothing needs decimals, and that is the actual payoff of the discipline. Comparing units to units is scale-free. check_allowance takes a required and a current and never asks what a token is; measure_received compares a balance before against a balance after; the amount inside encode_checked_transfer_from is an integer of the smallest unit. An internal function that handles money in units cannot be given the wrong scale, because it does no scaling at all.
The corollary is a lint rule you can apply by reading: any internal function whose signature takes an amount as a Decimal or a float, with no token beside it, is a scale bug waiting for the wrong token to arrive. The amount and the token identity travel together, or the amount is not yet meaningful.
Refusing is better than rounding#
The boundary has a second job, and it is the one most homegrown helpers get wrong. Not every decimal number is representable in a given token. Six decimals cannot hold a seventh digit. The convenient behaviour is to round it away. The correct behaviour is to refuse:
to_units(Decimal("1.0000005"), decimals=6) # raisesA silently dropped digit is a loss that reconciles to nothing. It is tiny, half a millionth of a token, and it happens on the leg where you convert a price, an exchange rate or a fee percentage into a transfer. Do it on every payout and you get a slow drift between what your ledger recorded and what the chain moved, with no single transaction anyone can point at. Raising turns that into an input validation problem, visible at the top of the call stack, where a human decides whether to round up, round down, or reject the request. That is a product decision and it does not belong inside a units helper.
The same reasoning is why floats are refused outright. 0.1 is not 0.1 in binary, and a value that is already approximate does not become exact by being multiplied by a power of ten. Decimal in, integer out, or an exception.
What I would do differently#
Three things, all learned by not doing them first.
Read decimals when a token is admitted to the system, not when a transfer is about to go out. Onboarding is a place where a failed contract call is an operator's problem and the token simply does not become available. The payout path is not: there, an RPC hiccup on a metadata read has no good outcome, and whatever fallback you write under deadline pressure will probably turn into the constant you were trying to avoid.
Treat a token that does not answer decimals as unsupported rather than as eighteen. The method is optional in the standard, so a missing answer is legitimate, but defaulting is guessing, and guessing about scale is the one guess with a million-fold error bar.
Store the decimals you used alongside every recorded amount. Contracts are immutable and in practice decimals do not change, but your registry row can be corrected, re-imported, or written by an earlier version of your code. If a historical payout carries the scale it was computed with, a bad registry row is a display artefact you can fix. If it does not, every amount you ever recorded hangs off a mutable row, and you have to trust that row to read your own books.
None of this is difficult. It is the difference between an amount and a number, held consistently, at two boundaries instead of everywhere...