A ticker is not an asset: USDT at six decimals and USDT at eighteen
Precision is not a property of a ticker. The same stablecoin symbol gets issued by more than one token contract, and those contracts do not agree on how many decimal places the token has: six in some deployments, eighteen in others. A money type that keys on the symbol alone will add a six-decimal balance to an eighteen-decimal one, raise nothing, and hand you back a number that is wrong by a factor of a trillion with the correct symbol printed next to it.
That is a fiat habit, and it does not survive the move to tokens. With currency codes the precision really is a property of the code, so a ledger can key amounts on a short string and get away with it for years. With tokens the precision is a deployment decision, and the symbol is a display label that unrelated issuers are free to reuse. I wrote cryptomoney (https://github.com/polycratia/cryptomoney) around that fact: the asset is the pair of symbol and decimals, and nothing in the library lets a symbol stand in for it.
The failure mode is not a rounding error#
Base units are integers, and integers are the right storage format: exact, no binary fractions, nothing lost on the way into the database. What an integer does not carry is its scale. One hundred units of value is 100_000_000 at six decimals and 100_000_000_000_000_000_000 at eighteen, and both are plausible contents for a column named amount_units.
So the bug has two directions and both are expensive. Read an eighteen-decimal integer with six-decimal precision and the amount inflates by a factor of 10**12: the balance check passes, the payout goes out, and the treasury finds out before the user does. Read a six-decimal integer with eighteen-decimal precision and the same amount collapses into dust that rounds to zero on every screen, and then the support queue finds out within the hour.
Neither direction loses precision. The arithmetic is exact on both sides. The scale was simply a different number than the code assumed, and nothing in the type system had an opinion about it.
Make the asset the pair#
In cryptomoney, Asset is a frozen dataclass of a symbol and a decimal count, so equality compares both fields. Money is immutable, hashable, bound to exactly one asset, and every binary operation runs _require_same_asset before it touches a digit:
from cryptomoney import Asset, Money
USDT6 = Asset("USDT", 6)
USDT18 = Asset("USDT", 18)
USDT6 == USDT18 # False
Money("100", USDT6) + Money("100", USDT18)
# CurrencyMismatch: USDT and USDT are different assetsThat message reads strangely, and it is supposed to. "USDT and USDT are different assets" is precisely the sentence an engineer needs to see in a traceback, because it names the only thing separating the two operands: not the symbol, which matches, but the scale behind it. A comparison fails the same way. Mixing assets in >= is not a number that happens to be wrong, it is an exception.
The hashability matters more than it looks. If you group balances in a dict keyed by asset, a six-decimal and an eighteen-decimal USDT land in separate buckets and stay there. Key the same dict by asset.symbol and you have rebuilt the bug one layer up, in aggregation code that no longer has a type to complain to.
A registry is keyed by the symbol, so a registry is one namespace#
AssetRegistry maps symbols to assets. That means a single registry structurally cannot hold both definitions of USDT at once, and I consider that a feature rather than a limitation: it forces the question of which deployment you are talking about out into the open, at configuration time.
Two guards fall out of it. First, register refuses to redefine a symbol it already knows:
from cryptomoney import ASSETS, Asset, AssetRegistry
"USDT" in ASSETS # True
Asset("USDT", 18) in ASSETS # False
ASSETS.register(Asset("USDT", 18))
# ValueError: USDT is already registered with 6 decimal places;
# pass replace=True to override itNote the difference between those two membership checks. __contains__ with a string asks whether the symbol is known; with an Asset it compares the full pair. A config loader that validates with the string answer will accept a chain definition it has no business accepting.
Second, the override exists but you have to spell it out. replace=True is a visible decision in a diff, which is what you want from the one line that changes the scale of every amount in a deployment. Without it, two config fragments that disagree about USDT fail at startup instead of agreeing to disagree at payout time.
The bundled ASSETS registry is a convenience for the common case, not a source of truth, and in a service that touches more than one token deployment I treat its use as a default as a bug. Build a registry per deployment and pass it explicitly:
from cryptomoney import Asset, AssetRegistry, parse_money
six_decimal_chain = AssetRegistry([Asset("USDT", 6), Asset("USDC", 6)])
eighteen_decimal_chain = AssetRegistry([Asset("USDT", 18), Asset("USDC", 18)])
parse_money("100.5 USDT", assets=eighteen_decimal_chain)
# 100.500000000000000000 USDT
parse_money("100.5 USDT", assets=six_decimal_chain)
# 100.500000 USDTThose two calls differ in one argument and nothing else, and the argument is the chain. copy() gives you an independent registry to start from when a deployment is mostly conventional and differs in one token. A symbol that is not registered raises UnknownAsset rather than falling back to a guess, which is the right behaviour for a bridge that just met a token it was not configured for.
Where the mismatch surfaces, and where it quietly does not#
Parsing is asymmetric, and the asymmetry is worth internalising. parse_amount refuses text that needs more decimal places than the asset has, unless you explicitly accept the loss:
from decimal import ROUND_DOWN
from cryptomoney import Asset, parse_amount
parse_amount("0.0000005", Asset("USDT", 6))
# ParseError: '0.0000005' needs 7 decimal places and USDT has 6,
# pass rounding=... to accept the loss
parse_amount("0.0000005", Asset("USDT", 6), rounding=ROUND_DOWN)
# 0.000000 USDT
parse_amount("0.0000005", Asset("USDT", 18))
# 0.000000500000000000 USDTSo the lossy direction announces itself, and the inflating direction says nothing: an eighteen-decimal asset accepts six-decimal text without comment, because there is nothing wrong with it as text. The scale error is not detectable at the parse. It only becomes detectable when the amount meets another amount, which is why the arithmetic boundary has to be the one enforcing identity.
The same asymmetry shapes storage. Construction rejects an amount finer than its asset, so to_base_units never rounds: the integer you write is exact. What the column loses is which scale produced it. Rebuild with Money.from_base_units(units, asset) where the asset comes from the deployment's registry, never from a symbol string read out of the same row. Formatting helps you audit this after the fact, because from_base_units fixes the exponent at minus the asset's decimals and trailing zeros survive into str(). 100.000000 USDT and 100.000000000000000000 USDT are visibly different lines in a log, and that difference is the thing you want to grep for.
Crossing precisions is a conversion, with a remainder#
Once the pair is the identity, moving value between two deployments of the same symbol stops being addition and becomes a conversion you have to spell out. Six to eighteen is exact. Eighteen to six is not, and the part that does not fit has to go somewhere:
from cryptomoney import Asset, Money
USDT6 = Asset("USDT", 6)
USDT18 = Asset("USDT", 18)
amount = Money("12.345678901234567890", USDT18)
whole, dust = divmod(amount.to_base_units(), 10 ** (18 - 6))
Money.from_base_units(whole, USDT6) # 12.345678 USDT
dust # 901234567890 base units unaccounted forThat dust is the whole reason I want the conversion written out. A type that added the two balances together would have buried a number like this inside a total, where it is indistinguishable from a bad rate or a missing fee. Spelled out, it is a value with a name that needs a ledger line: rounded down to the house, credited on the next transfer, or refused outright because the amount is below what the destination can represent.
What I would do differently#
In earlier systems I keyed money on a currency string because fiat made that look safe, and because the first token integration involved exactly one deployment per symbol, so the pair and the symbol carried the same information. The pair becomes load-bearing on the day a second deployment appears, and that day arrives as a config change rather than a code change, which means the type has to have been right before anyone noticed the problem existed. Make the pair the identity in the first commit; retrofitting it means auditing every integer column in the system against a scale that was never recorded.
The second thing I would change earlier is the global default. A library-level registry is a reasonable convenience and an unreasonable source of truth, and the distinction stays invisible until two chains disagree. Treat the registry as deployment configuration, require it at every call site that turns text or integers into money, and let register fail loudly when two sources disagree about a symbol.
The symbol is for humans. The pair is for arithmetic. Keeping them separate costs you one extra field and buys the guarantee that a wrong scale is an exception rather than a payout.