polycratia

Parse crypto amounts like hostile input, declare rounding first

· 9 min read

Every crypto amount enters a system as a string: a form field, a JSON body from an exchange API, a row in a payout file, a webhook from a provider. Between that string and the ledger two decisions get made: whether the amount is representable in the asset at all, and what happens when it is not. In most codebases the first decision is skipped and the second one is implicit, so the amount becomes whatever the last arithmetic operation, or the database column, happened to leave behind. That is most of the bug class, I think. Precision belongs to the asset, and rounding is a policy you declare before you compute, not a residue you discover afterwards.

I have been building payment and crypto systems in production since 2018: custodial wallets for BTC and ETH, a fiat-to-crypto onramp, exchange order books, stablecoin rails in daily use. The amounts that gave me the most trouble were not the large ones. They were the ones that arrived with one digit too many and were quietly made to fit.

The parse is the last place there is still someone to ask#

When a user types 0.000000001 into a BTC withdrawal field, that string carries information: they asked for something the asset cannot express. There are two honest responses, and both of them require knowing it happened. You can refuse and show them the problem, or you can round by a rule you decided on in advance and tell them what you did.

The response that is not available, though it is the one that happens most in practice, is to truncate it on the way into a NUMERIC(18, 8) column and move on. By the time the value reaches the ledger the user is gone, the request is over, and the difference between what was asked for and what was recorded has turned into dust that will show up much later as reconciliation drift nobody can attribute.

So in cryptomoney the precision check lives in the parser, and its default is refusal:

python
from cryptomoney import BTC, USDT, parse_amount, parse_money

parse_amount("0.5", BTC)          # 0.5 BTC
parse_amount(" 1 234.50 ", USDT)  # 1234.50 USDT
parse_amount("1.25e3", USDT)      # 1250 USDT
parse_money("0.5 BTC")            # 0.5 BTC
parse_money("12.5btc")            # 12.5 BTC
parse_money("BTC 0.5")            # 0.5 BTC

The input is treated as text written by someone who may not be careful and may not be friendly. Whitespace, a leading sign, thousands separators, exponent notation, a symbol before or after or glued to the number: all of it is accepted, because all of it occurs in real payloads. What is not accepted is text that does not describe exactly one amount of exactly one asset, or an amount finer than the asset:

python
from decimal import ROUND_DOWN

parse_amount("1e-9", BTC)                       # ParseError: needs 9 decimal places
parse_amount("1e-9", BTC, rounding=ROUND_DOWN)  # 0.00000000 BTC
parse_amount("NaN", BTC)                        # ParseError
parse_amount(0.5, BTC)                          # TypeError: float is refused
parse_money("0.5 XMR")                          # UnknownAsset

The second line is where the whole design sits. Rounding is possible, it is just not free: you name the mode, at the call site, in the code path where you know what the amount means. A quote engine that floors a displayed rate and a withdrawal endpoint that must not create value out of nothing are different call sites with different answers, and neither of them should inherit a default written by a library author who has never seen either.

Floats are refused because the float already lost the evidence#

parse_amount(0.5, BTC) raising TypeError looks pedantic until you ask where the float came from. It came from a JSON decoder that turned the sender's text into a binary double before your code ever saw it. The original digits are gone at that point, and with them your ability to say whether the sender wrote a representable amount or not. A parser that accepts floats is not parsing, it is laundering a decision that was already made badly upstream.

The same rule holds at construction, so a rounding error cannot enter a balance through a different door:

python
from decimal import Decimal

from cryptomoney import BTC, ETH, Money

Money(Decimal("0.5"), BTC)         # 0.5 BTC
Money("0.000125", BTC)             # 0.000125 BTC
Money(0.1, BTC)                    # TypeError: float is refused
Money("0.000000001", BTC)          # ValueError: BTC is divisible into 8 decimal places
Money("1", BTC) + Money("1", ETH)  # CurrencyMismatch

In practice this means the boundary code has to hand over the raw text. For HTTP that is a decoder configured to keep numbers as strings, or a schema that types the field as a string. A small amount of friction in exactly one place, and what you get for it is the guarantee that every Money in the system was checked against its asset when it was born.

Still on the subject of hostile input: the parser also caps input length and bounds the exponent range, because an unbounded exponent in decimal arithmetic is a way to make a single field allocate an enormous number. Any parser that will run against public input needs the equivalent.

Precision is a property of the asset, so the asset has to be a value#

The check has to compare against something. In this library that something is not a constant in the codebase but a small frozen value object:

python
@dataclass(frozen=True, slots=True)
class Asset:
    """A crypto asset and the number of decimal places it is divisible into."""

    symbol: str
    decimals: int

Its constructor validates what it is given: the symbol must be a non-empty, unpadded string, and decimals must be a real int (a bool is explicitly rejected) within a fixed upper bound. An Asset that exists is an Asset that makes sense, which means the precision rule cannot be None at the moment a parse needs it.

The package ships a registry of common assets, and that registry is a convenience rather than an authority. The same ticker has different precision on different chains, and a system that hardcodes six decimals for USDT will eventually meet an eighteen-decimal deployment of it:

python
from cryptomoney import ASSETS, Asset, AssetRegistry

assets = ASSETS.copy()
assets.register(Asset("USDT", 18), replace=True)  # USDT on BNB Smart Chain
assets.register(Asset("XMR", 12))

own = AssetRegistry([Asset("POINTS", 0)])         # or start from nothing

parse_money looks symbols up in the default registry unless you pass assets=, and a symbol that is not registered raises UnknownAsset instead of guessing. An unknown ticker is a configuration gap, and the worst thing a parser can do with a configuration gap is invent a precision for it.

Rounding has no default anywhere, not just in the parser#

If rounding is a declared policy at the edge and an accident inside, you have moved the bug rather than fixed it. So the same rule runs through the arithmetic. Operations that are exact are plain operators. Operations that may not fit the asset's precision are methods that require a mode:

python
from decimal import ROUND_DOWN, ROUND_HALF_UP

from cryptomoney import BTC, Money

Money("0.5", BTC) / 3                                      # TypeError: / is refused
Money("0.5", BTC).divide(3, rounding=ROUND_DOWN)           # 0.16666666 BTC
Money("1", BTC).multiply("0.015", rounding=ROUND_HALF_UP)  # 0.01500000 BTC

Refusing / is the part people argue about, and it is the part I would keep. Division is where the money disappears, and an operator is a syntax that invites you not to think. A method with a mandatory keyword argument puts the policy where the reviewer sees it, in the diff.

Quantized division still loses the remainder, which is correct for a fee and wrong for a distribution. When the total has to survive (splitting a settlement across investors, dividing a batch payout) quantization is the wrong tool entirely, and the operation works in base units instead:

python
shares = Money("0.00000010", BTC).split(3)
[str(share) for share in shares]   # ['0.00000004 BTC', '0.00000003 BTC', '0.00000003 BTC']
sum(shares[1:], shares[0])         # 0.00000010 BTC

The remainder goes to the first shares and the parts add back up to the original. That property is worth more than any fairness heuristic, because it is the one an auditor checks.

The payoff for refusing unrepresentable amounts at the parse arrives at the chain boundary. Chain APIs speak integers (satoshi, wei), and conversion in both directions is exact precisely because an amount finer than its asset never existed in the first place:

python
from cryptomoney import BTC, USDT, Money, from_wei, to_satoshi

to_satoshi(Money("0.5", BTC))            # 50000000
from_wei(1)                              # 0.000000000000000001 ETH

Money("12.5", USDT).to_base_units()      # 12500000
Money.from_base_units(12500000, USDT)    # 12.500000 USDT

There is no rounding mode on those calls because there is nothing to round. The refusal at the edge is what makes the conversion at the other edge total.

What I would do differently#

Earlier systems I built put the leniency at the front and the truncation at the back: accept whatever the client sends, coerce it into a decimal, let the column width decide the precision. It reads as robustness. It is really a silent policy decision made by a schema migration, applied uniformly to fees, quotes, payouts and refunds, none of which want the same rule. The failure does not announce itself as a parse error. It announces itself as amounts that do not reconcile, in the smallest units, long after the request that created them.

The inversion I would now apply from the first commit is small: the only way to construct an amount is from text plus an asset, precision violations raise by default, and every operation that cannot be exact takes a rounding mode with no fallback. It costs a few explicit arguments at call sites. In exchange every rounding decision in the system is greppable, and the amounts that could not be represented were rejected while there was still someone on the other end of the request to tell...

react

$ new-project --brief

or email hey@polycratia.com