owned this note
owned this note
Published
Linked with GitHub
## Context
Fixed decimal 18 math is only good within a certain range.
Currently our approach to fixed decimal math is to try to scale everything up to 18 decimals and then apply operations at this scale.
The benefit of this is that end user rainlang authors do not need to know the difference between integers and decimals or to handle rescaling between different tokens themselves.
We go off-spec to pull decimals from tokens directly in order to do math.
But we have several outstanding issues:
- No negative number support
- Precision loss when working across numbers that _differ_ in scale by many OOMs
- We rolled our own abstract->concrete parsing from literals to real values, which could have edge cases that we haven't handled yet
## Negative numbers
Basically all specs and interfaces onchain use `uint256` for input and output which means the full 32 byte space of a number is dedicated to representing a positive integer.
If we want negative numbers we need to set aside at least 1 bit for the negative sign, which is either going to remove half the integers that we can represent precisely (or at all), or we're going to have to do very awkward things with larger-than-32-byte numbers. In EVM terms this would pragmatically mean 64 byte numbers as it doesn't support memory, arithmetic or storage operations on anything other than 32 bytes (with the exception of mstore8 which isn't that useful on its own).
On a case by case basis we will have to deal with the loss of precision, which can only be:
- Error and refuse to lose the precision
- E.g. for token balances/supply etc.
- Accept the precision loss somehow
- E.g. may be safe for token approvals that are routinely "infinite" for no practical reason
## Large differences in scale
Consider the case of `mul(1e18 pow(0.5 x))` in rainlang.
As `x` grows, the result of `pow(0.5 x)` becomes ever smaller, losing precision as it does.
As the final result approaches `1`, i.e. `pow(0.5 x)` approaches `1 / 1e18` we approach there being zero precision. At some point we reach `1e-18` which is the smallest representable value in 18 decimal fixed point math, and then the next possible value is `0`.
This means our simple exponential decay model can never reach a value sub-1.
In defi this is problematic because large differences between the magnitude of values are fairly routine in general.
To make matters worse, precision loss compounds and is difficult to visualise/predict/track at the logic level. A rainlang author can easily introduce some low precision value, which they proceed to do further math with in a trading strategy, destroying the precision of everything downstream.
## What we want
Typically in defi we _don't_ use all 32 bytes to represent token values (with the notable exception of "infinite approve"). The maximum `uint256` value is ~1.15e77 which is a solid _59 OOMs_ larger than 1e18. There are no _major_ tokens that have a circulating supply anywhere near 10^59 (although i'm sure there are some exotic tokens that make a point of a near-max supply).
There's "only" 10^57 atoms in our entire solar system, so we can fit 100x that in decimal 18 math in a `uint256`, so we're talking big numbers here.
Even the maximum `uint128` value which only uses _half_ the available space gives ~3.4e38 as a maximum value which gives us 20 OOMs to play with in 18 decimal fixed point math.
It would seem reasonable that we set aside a few bytes to track sign and scale within the number itself.
For example, 1 bit for a sign and 2 bytes for a signed exponent would use 17 bits total, yielding 239 bits to represent integers precisely with, which is ~8.8e71 values. For basically all "real" use cases in defi, the difference between having 59 OOMs worth of precise values and 53 OOMs of values is exactly no difference at all.
In the example above, when calculating `pow(0.5 x)` we would want the precision of the coefficient to remain as high as possible, while the exponent becomes increasingly negative, thus preserving significantly _more_ precision than the decimal 18 fixed point approach would. When multiplying this small number by `1e18` we could consider the exponents of both the larger and smaller number in the multiplication logic to yield a final number that is both smaller than `1` and still precise.
## Isn't this just floats? Floats are bad.
Sorta.
If we go down this road of tracking the scale and sign either separately or in the number we will reinvent half of floats badly. Without the ~50 years of real world usage in basically every imaginable industry/use case.
Also, floats are bad for defi but let's discuss why.
### Floats are bad: Imprecise decimals
Javascript uses floats. Pop a console and write `0.1 + 0.7` and the result is... `0.7999999999999999`. Clearly this is no good for tokens that have 18 decimals if we can't even represent things Xe-1 and lower correctly.
### Floats are bad: Imprecise integers
In the JS console write `9007199254740991 + 2` and we get `9007199254740992`. Clearly rubbish for tokens if we can't get past Xe15 safely.
### Floats are bad: Imprecise abstract to concrete conversion
Humans write in decimal values. A decimal _string_ written by a human is an abstract concept with a real value taught to humans in school. When people talk about floats they usually implictly mean _binary_ floats, as this is the hardware supported standard since 1985. _Binary_ floats don't have an exact concrete representation of all the _decimal_ numbers that have valid abstract representations in their valid range.
For example, `0.1` is a valid and exact decimal value in the range that basically any float covers, but is an infinitely repeating sequence in binary, so has no exact representation. Much like 1/3 repeats infinitely as 0.333... in decimal, 0.1 is 0.0001100110011... infinitely in binary.
Humans understand and expect 1/3 to be an imprecise 0.33.. up to some limit, and they expect 1/10 to be exactly 0.1, because they work and think in decimals 100% of the time. The mismatch between decimal strings and binary floats is invisible and unintuitive, and leads to rounding that humans don't expect, which is bad for finance where all surprises (in math) are bad surprises.
### Floats are bad: Special values and silent failures
Floats are designed to be general purpose workhorses that are lenient for beginners rather than optimised for specialists.
One of the repurcussions of this is that every possible operation on a float will produce an output for all possible inputs.
This means that floats produce mathematical nonsense.
Pop open a JS console and write `1/0` and you get... `Infinity`.
This is simply not true. In _calculus_ the _limit_ of `1/x` is infinity as x->0 but this is not the same as simply saying that `1/0` is a specific value "infinity".
Anything divided by `0` is simply impossible when we're talking about money.
Then, to keep some kind of internal consistency here, floats introduce a difference between +0 and -0. We now have two distinct values that are "equal", i.e. `-0 === 0` but produce different mathematical outputs.
`1/0` => `Infinity`
`1/-0` => `-Infinity`
And despite their equality, `-0` sorts below `0` in any sorting/ordering logic.
None of this is because of math. There's no such thing as "positive zero" and "negative zero" in math. There are _limits_ in _calculus_ that approach zero from above/below, but floats act as though these limits are just real values like everything else, which is not how math works. We don't say that the difference between `+1` and `-1` is the limit of approaching `1` from above and below... there's nothing special about `0` _mathematically_ that justifies this treatment.
All of this is to avoid error handling without an explicit error type.
Except that floats _do_ have an error type. It's `NaN`. And there are both "quiet `NaN`" and "signalling `NaN`" where the latter actually does encode error messages in it.
However, this was invented in 1985 and yet I've never actually used a programming language that handles signalling `NaN` values as though they were an error result type, it's not like Rust will let me `NaN?` and have `?` treat it like an error enum in a `Result`... If this was ever a good idea, we've had 40 years to realise it.
Even the division by `0` is inconsistent because `0/0` is `NaN`, not some flavour of `Infinity`.
There are no native float operations for handling these `NaN` values gracefully, they all have to be handled out of band, which means `x + y / z` is just asking for `z` to be `0` and propagate some "special" value into the `x + ...` operation (which silently changes the behaviour of addition to return `NaN` even though `+` has no way to produce `NaN` itself). The ergonomic usage of floats is the incorrect one, you have to guard against `z` being `0` upstream and/or downstream of every operation, with silent errors if you get it wrong.
You can't even check for a `NaN` with equality because every `NaN` is explicitly NOT equal to `NaN` (even if they are the same variant with binary equivalent concrete representation). Every programming language that deals with floats needs a special case `.isNaN()` that should probably be called every single time a division is done (and doesn't even cover +/- infinity), in every codebase, in every language, which completely undermines the "for beginners" reasoning that led to these design decisions in the first place.
Worse, the specific `NaN` produced by operations is not even deterministic across all platforms, causing huge headaches for p2p systems that want to e.g. use wasm. Tooling like wasmer has had to introduce concepts like "NaN normalisation" just to workaround this and have different machines agree on the outcome of basic math.
It's not unusual at all for GUIs written in JS to show `NaN` values that have propagated all the way to the end user, simply due to some empty value casting itself to a `0` and making its way into a division somewhere.
None of this is good for defi.
Bad inputs that lead to literal nonsense outputs need to _error_ if they ever appear in a financial transaction. Protocols need to be designed such that errors that rollback a transaction don't inadvertently trap user funds permanently, rather than just yoloing "infinity" or "nan" into transactions.
## More of what we want
So some _useful_ things to note about floats:
- The 2008 revision of floats defines a _decimal_ float https://en.wikipedia.org/wiki/IEEE_754-2008_revision
- Which was completely absent in the original 1985 version
- Which aren't supported by most hardware afaik but at least they have a definition, which was largely copied from existing software implementations in java/.net/etc.
- There _is_ a range of values for which floats _are_ precisely defined, and all math _will_ give the correct exact results, for both binary and decimal floats
- This accurate range is actually larger for binary floats because they're more efficient per-bit
- But decimal floats map cleanly to things that humans write in decimals outside this range, whereas binary floats only approximate decimal strings outside the safe range
- The range is entirely due to the number of bits used to represent values, more bits => bigger safe range
- 64 bit binary floats in JS definitely are too small for defi
- 256 bit decimal floats might be large enough however
- There are formal definitions of floats that go up to 256 bits for binary floats and 128 bits for decimal floats
- There is a "general decimal arithmetic" https://speleotrove.com/decimal/ that is compatible with but more general than the IEEE standards, with an in-depth implementation guide, and covers arbitrary precision (i.e. can handle 256 bits)
- There are reference implementations of every algorithm in arbitrary precision and bit constrained optimised form
- There are language agnostic tests cases for every algorithm
- There's a long list of algorithms with descriptions of how they should work, so we know when we are "done"
- There's a well defined process for both parsing and formatting decimal strings
So perhaps what we want to solve our problems is:
A **256 bit decimal float**, with
- 1 sign bit (negative numbers)
- 16 exponent bits (+/- 32767 OOMs)
- 239 coefficient bits (8.8e71 safe values total, 53 OOMs for 18 decimal tokens)
Following and implementing as much of the math as we feel is useful from the "general decimal arithmetic" guide
BUT
- We error instead of producing "special values"
- `NaN`
- `Infinity`
- `-Infinity`
- We treat `-0` as `0` on input and always return `0` on output
- I.e. `-0` is not produceable with mathematical operations
- The literal string `-0` actually maps to `0`
- We continue to treat the _value_ `1e18` as "one" for all defi logic
- This guarantees that we have _at least_ as much precision for all math as we do currently, up to the safe max value of 8.8e71
- We always round down by default, as per EVM
This gives us:
- A standard number library
- that anyone can use
- based on existing standards that can be easily referenced
- by auditors and other people
- with clearly reasoned/explained deviations
- About as much precision as we're ever going to get/need
- Equally precise as 18 decimal fixed point over 53 OOMs
- More precise than 18 decimal fixed point for numbers a few OOMs smaller than "one"
- More precise when doing calculations with numbers that are many OOMs apart from each other
- All imprecision is handled consistently in the numeric stack, rather than ad-hoc throughout the codebase
- Decimal literals will always map exactly to the expected decimal value, or round in an intuitive way
- A good fallback for precision loss when converting to/from `uint256`
- Much better than simply saturating at `type(int256).max`, we simply lose the least significant data from our large unsigned integer
- Can still error if precision loss is unacceptible during conversion, such as for token balances, etc.
- Negative decimal numbers
- Hard errors on mathematical nonsense for safety in financial logic
- Follow EVM standards by default like rounding rather than float standards
- More closely match other protocols
- Optionally implement other rounding directions if we feel the need
## Implementation/performance
No idea what the implementation difficulty or performance will be in terms of gas.
Probably difficult and a fair bit :sweat_smile:
But what is the alternative? Ad hoc precision hacks forever? Needing users to understand "precision" just to write relatively basic logic?
We could probably start with a relatively basic implementation with a few operations in a library just to POC the idea, and see how difficult it is to build upon.