A wei must not print as 1E-18: fixed-point money formatting
A money type that is exact in memory and lossy the moment it becomes text has not removed the rounding bug. It moved the bug to the last line of the call stack. Python's Decimal renders a single wei as 1E-18, a hand-rolled units / 10**8 renders one satoshi as 1e-08, and a round(amount, 2) dropped into a template picks a rounding mode that nobody in the system ever chose. In cryptomoney I treat the output path as part of the type: text is fixed point, shortening is allowed only inside the asset's own precision, and an explicit rounding mode is required as soon as a nonzero digit would disappear.
Exactness ends where the string starts#
The arithmetic side of this library is careful. A float is refused at construction and every amount is bound to one asset, and anything that might not fit the asset's grid is a method with a mandatory rounding argument rather than an operator. I wrote about that split in an earlier post: Python's operator protocol is fixed-arity, so there is physically nowhere to put a rounding mode, which is why / raises and divide(..., rounding=...) does not.
None of that care survives str() on the underlying Decimal. The decimal spec says to switch to scientific notation once the adjusted exponent drops below -6, and ETH has eighteen decimal places:
from decimal import Decimal
str(Decimal('1E-18')) # '1E-18'
f"{Decimal('1E-18')}" # '1E-18', __format__ with an empty spec is str
f"{Decimal('1E-18'):f}" # '0.000000000000000001'Decimal is not at fault here. Exponent notation is exact, and it even round-trips through this library's own parser, since the number pattern in parsing.py accepts an exponent. Everything outside this library is the problem: you read a receipt, a spreadsheet opens a CSV export, someone diffs yesterday's balance dump against today's, a support agent holds a number on a screen next to a number in a log. 1E-18 and 0.000000000000000001 are one amount and two strings, and a system that emits both has turned its own output into a parsing problem.
So Money.format builds its text from the f presentation type (f"{magnitude:f}"), which is fixed point by definition. One wei prints as one wei:
from cryptomoney import from_wei
str(from_wei(1)) # '0.000000000000000001 ETH'__str__ is just format with its defaults, so the cheapest thing a caller can do is also the correct one. That matters more than it sounds: the formatting rules below only hold if nobody has a reason to reach past them.
Shortening is a different operation from rendering#
Once rendering is settled, the interesting question is what a caller gets when they want fewer decimal places than the asset has. Eighteen zeros behind a round ETH amount are noise, and a fee table with eight columns of trailing zeros is unreadable. But trimming noise and dropping digits look the same at the call site, and only one of them is lossy.
The distinction format draws is between zeros and significant digits. The helper that decides it is small enough to read in full:
def _significant_decimals(amount: Decimal) -> int:
"""The decimal places the amount needs once its trailing zeros are dropped."""
digits = amount.as_tuple().digits
exponent = int(amount.as_tuple().exponent)
if not any(digits):
return 0
while exponent < 0 and digits[-1] == 0:
digits = digits[:-1]
exponent += 1
return max(0, -exponent)Every amount inside Money has already been quantized to the asset's exponent at construction, so equal amounts carry the very same digit tuple: 0.5 and 0.50000000 and fifty million satoshi are one value with one hash and one text. That padding is what makes the question answerable, because the digits actually present tell you how many of them mean anything.
From there the rules fall out. A requested decimals must be an int and not a bool, or you get a TypeError. It has to sit between zero and the asset's own decimals, or you get a ValueError, because asking BTC for nine places is asking for a digit that cannot exist. If the request is at least the significant count, the quantize that follows cannot lose anything: it only pads or drops zeros, and the ROUND_HALF_UP default has no decision to make. If the request is below that count, the caller's mode is the only thing standing between a display helper and a silently altered number, so there is no default at all:
from decimal import ROUND_DOWN
from cryptomoney import BTC, Money
fee = Money('0.00012500', BTC)
fee.format() # '0.00012500 BTC'
fee.format(trim=True) # '0.000125 BTC'
fee.format(decimals=6) # '0.000125 BTC', zeros only
fee.format(decimals=4, rounding=ROUND_DOWN) # '0.0001 BTC', a digit was chosen away
fee.format(decimals=4) # ValueError: pass rounding
fee.format(decimals=9) # ValueError: above BTC's precision
Money('0.5', BTC).format(decimals=8) # '0.50000000 BTC'
Money('0.5', BTC).format(decimals=2) # '0.50 BTC'trim=True is the unconditional version of the same idea: it strips trailing zeros from the fraction and cannot touch a significant digit, so it needs no mode and no bound. Padding and un-padding are free, rounding is not. The rounding argument runs through the same validator as everywhere else in the library, which raises TypeError for a non-string and ValueError for a mode outside the eight the decimal module defines, so a typo like 'ROUND_SOMEHOW' fails at the call and not three releases later, when someone wonders why a report is off by a satoshi.
The code is at github.com/polycratia/cryptomoney if you want to read the whole path.
Signs that do not lie#
Sign handling is where display code usually collects its small untruths, so I wrote the rules out instead of leaving them to be inferred. The leading minus is stripped before the integer and fraction are split on the point, which means grouping and padding operate on the magnitude and do not have to reason about a sign character. The sign then goes back on deliberately: a minus for a negative amount, a plus only when the caller asked for plus and the amount is strictly above zero, and nothing at all for zero.
That last clause is the one worth saying out loud. A delta column that renders +0.00 makes a claim about direction that the number does not support, and a negative zero is an artifact of arithmetic rather than a fact about a balance. Construction already folds negative zero to zero, so the two agree:
from decimal import Decimal
Money(Decimal('-0.00000000'), BTC).format(symbol=False) # '0.00000000'
Money.from_base_units(0, BTC).format(trim=True, symbol=False) # '0'Grouping that survives the trip back#
Thousands separators are the point where most money formatters stop being reversible. Here the integer part is routed through a grouping helper with a single module-level separator, a comma, and the parser's separator class accepts that comma along with spaces, underscores, apostrophes and the two non-breaking spaces. The output of the formatter is valid input to the parser:
from cryptomoney import USDT, Money, parse_money
text = Money('1234567.5', USDT).format(group=True)
text # '1,234,567.500000 USDT'
parse_money(text) == Money('1234567.5', USDT) # True
Money('1234567.5', USDT).format(group=True, trim=True) # '1,234,567.5 USDT'The separator is deliberately not configurable and deliberately not locale-aware. A locale that groups with a dot produces 1.234.567,5, which this parser rejects, and rightly so: a dot is already the decimal point, and a string where one character means two things cannot be read back unless you know which locale wrote it. Presentation for a specific audience is a layer above a money type, not a feature of it. What this type guarantees is narrower and more useful: what you see is what the system can read.
What I would do differently#
Two things nag at me.
First, format(decimals=..., rounding=...) turns a display call into a rounding decision. The mandatory mode keeps it honest, but a stricter design would keep formatting purely textual and force the caller to produce a rounded Money first, so the lossy step shows up in the data flow instead of in a template argument. I chose against that because the check would then live further from where people look, and a developer wiring up a fee column is exactly the person who needs to be stopped. I still think that was right, and I am less sure of it than of anything else here...
Second, trim, group, symbol and plus are four booleans on one call. Four reads fine as keywords, six would not, and the honest fix at that point is a small presentation value rather than another flag. Four is not yet a reason to add a type.
The general lesson carries past crypto amounts. A value type is only as exact as its widest boundary, and for money that boundary is usually not the database, it is the string. If the type refuses a float at the front door and then hands you 1E-18 at the back, it has only relocated the failure to the place where nobody writes tests.