forked from DecentralizedClimateFoundation/DCIPs
279 lines
15 KiB
Markdown
279 lines
15 KiB
Markdown
---
|
|
eip: 3772
|
|
title: Compressed Integers
|
|
description: Using lossy compression on uint256 to improve gas costs, ideally by a factor up to 4x.
|
|
author: Soham Zemse (@zemse)
|
|
discussions-to: https://github.com/ethereum/EIPs/issues/3772
|
|
status: Stagnant
|
|
type: Standards Track
|
|
category: ERC
|
|
created: 2021-08-27
|
|
---
|
|
|
|
## Abstract
|
|
|
|
This document specifies compression of `uint256` to smaller data structures like `uint64`, `uint96`, `uint128`, for optimizing costs for storage. The smaller data structure (represented as `cintx`) is divided into two parts, in the first one we store `significant` bits and in the other number of left `shift`s needed on the significant bits to decompress. This document also includes two specifications for decompression due to the nature of compression being lossy, i.e. it causes underflow.
|
|
|
|
## Motivation
|
|
|
|
- Storage is costly, each storage slot costs almost $0.8 to initialize and $0.2 to update (20 gwei, 2000 ETHUSD).
|
|
- Usually, we store money amounts in `uint256` which takes up one entire slot.
|
|
- If it's DAI value, the range we work with most is 0.001 DAI to 1T DAI (or 10<sup>12</sup>). If it's ETH value, the range we work with most is 0.000001 ETH to 1B ETH. Similarly, any token of any scale has a reasonable range of 10<sup>15</sup> amounts that we care/work with.
|
|
- However, uint256 type allows us to represent $10<sup>-18</sup> to $10<sup>58</sup>, and most of it is a waste. In technical terms, we have the probability distribution for values larger than $10<sup>15</sup> and smaller than $10<sup>-3</sup> as negligible (i.e. P[val > 10<sup>15</sup>] ≈ 0 and P[val < 10<sup>-3</sup>] ≈ 0).
|
|
- Number of bits required to represent 10<sup>15</sup> values = log<sub>2</sub>(10<sup>15</sup>) = 50 bits. So just 50 bits (instead of 256) are reasonably enough to represent a practical range of money, causing a very negligible difference.
|
|
|
|
## Specification
|
|
|
|
In this specification, the structure for representing a compressed value is represented using `cintx`, where x is the number of bits taken by the entire compressed value. On the implementation level, an `uintx` can be used for storing a `cintx` value.
|
|
|
|
### Compression
|
|
|
|
#### uint256 into cint64 (up to cint120)
|
|
|
|
The rightmost, or least significant, 8 bits in `cintx` are reserved for storing the shift and the rest available bits are used to store the significant bits starting from the first `1` bit in `uintx`.
|
|
|
|
```solidity
|
|
struct cint64 { uint56 significant; uint8 shift; }
|
|
|
|
// ...
|
|
|
|
struct cint120 { uint112 significant; uint8 shift; }
|
|
```
|
|
|
|
#### uint256 into cint128 (up to cint248)
|
|
|
|
The rightmost, or least significant, 7 bits in `cintx` are reserved for storing the shift and the rest available bits are used to store the significant bits starting from the first one bit in `uintx`.
|
|
|
|
> In the following code example, `uint7` is used just for representation purposes only, but it should be noted that uints in Solidity are in multiples of 8.
|
|
|
|
```solidity
|
|
struct cint128 { uint121 significant; uint7 shift; }
|
|
|
|
// ...
|
|
|
|
struct cint248 { uint241 significant; uint7 shift; }
|
|
```
|
|
|
|
Examples:
|
|
|
|
```
|
|
Example:
|
|
uint256 value: 2**100, binary repr: 1000000...(hundred zeros)
|
|
cint64 { significant: 10000000...(55 zeros), shift: 00101101 (45 in decimal)}
|
|
|
|
Example:
|
|
uint256 value: 2**100-1, binary repr: 111111...(hundred ones)
|
|
cint64 { significant: 11111111...(56 ones), shift: 00101100 (44 in decimal) }
|
|
```
|
|
|
|
### Decompression
|
|
|
|
Two decompression methods are defined: a normal `decompress` and a `decompressRoundingUp`.
|
|
|
|
```solidity
|
|
library CInt64 {
|
|
// packs the uint256 amount into a cint64
|
|
function compress(uint256) internal returns (cint64) {}
|
|
|
|
// unpacks cint64, by shifting the significant bits left by shift
|
|
function decompress(cint64) internal returns (uint256) {}
|
|
|
|
// unpacks cint64, by shifting the significant bits left by shift
|
|
// and having 1s in the shift bits
|
|
function decompressRoundingUp(cint64) internal returns (uint256) {}
|
|
}
|
|
```
|
|
|
|
#### Normal Decompression
|
|
|
|
The `significant` bits in the `cintx` are moved to a `uint256` space and shifted left by `shift`.
|
|
|
|
> NOTE: In the following example, cint16 is used for visual demonstration purposes. But it should be noted that it is definitely not safe for storing money amounts because its significant bits capacity is 8, while at least 50 bits are required for storing money amounts.
|
|
|
|
```
|
|
Example:
|
|
cint16{significant:11010111, shift:00000011}
|
|
decompressed uint256: 11010111000 // shifted left by 3
|
|
|
|
Example:
|
|
cint64 { significant: 11111111...(56 ones), shift: 00101100 (44 in decimal) }
|
|
decompressed uint256: 1111...(56 ones)0000...(44 zeros)
|
|
```
|
|
|
|
#### Decompression along with rounding up
|
|
|
|
The `significant` bits in the `cintx` are moved to a `uint256` space and shifted left by `shift` and the least significant `shift` bits are `1`s.
|
|
|
|
```
|
|
Example:
|
|
cint16{significant:11011110, shift:00000011}
|
|
decompressed rounded up value: 11011110111 // shifted left by 3 and 1s instead of 0s
|
|
|
|
Example:
|
|
cint64 { significant: 11111111...(56 ones), shift: 00101100 (44 in decimal) }
|
|
decompressed uint256: 1111...(100 ones)
|
|
```
|
|
|
|
This specification is to be used by a new smart contract for managing its internal state so that any state mutating calls to it can be cheaper. These compressed values on a smart contract's state are something that should not be exposed to the external world (other smart contracts or clients). A smart contract should expose a decompressed value if needed.
|
|
|
|
## Rationale
|
|
|
|
- The `significant` bits are stored in the most significant part of `cintx` while `shift` bits in the least significant part, to help prevent obvious dev mistakes. For e.g. a number smaller than 2<sup>56</sup>-1 its compressed `cint64` value would be itself if the arrangement were to be opposite than specified. If a developer forgets to uncompress a value before using it, this case would still pass if the compressed value is the same as decompressed value.
|
|
- It should be noted that using `cint64` doesn't render gas savings automatically. The solidity compiler needs to pack more data into the same storage slot.
|
|
- Also the packing and unpacking adds some small cost too.
|
|
- Though this design can also be seen as a binary floating point representation, however using floating point numbers on EVM is not in the scope of this ERC. The primary goal of floating point numbers is to be able to represent a wider range in an available number of bits, while the goal of compression in this ERC is to keep as much precision as possible. Hence, it specifies for the use of minimum exponent/shift bits (i.e 8 up to `uint120` and 7 up to `uint248`).
|
|
|
|
```solidity
|
|
// uses 3 slots
|
|
struct UserData1 {
|
|
uint64 amountCompressed;
|
|
bytes32 hash;
|
|
address beneficiary;
|
|
}
|
|
|
|
// uses 2 slots
|
|
struct UserData2 {
|
|
uint64 amountCompressed;
|
|
address beneficiary;
|
|
bytes32 hash;
|
|
}
|
|
```
|
|
|
|
## Backwards Compatibility
|
|
|
|
There are no known backward-incompatible issues.
|
|
|
|
## Reference Implementation
|
|
|
|
On the implementation level `uint64` may be used directly, or with custom types introduced in 0.8.9.
|
|
|
|
```soldity
|
|
function compress(uint256 full) public pure returns (uint64 cint) {
|
|
uint8 bits = mostSignificantBitPosition(full);
|
|
if (bits <= 55) {
|
|
cint = uint64(full) << 8;
|
|
} else {
|
|
bits -= 55;
|
|
cint = (uint64(full >> bits) << 8) + bits;
|
|
}
|
|
}
|
|
|
|
function decompress(uint64 cint) public pure returns (uint256 full) {
|
|
uint8 bits = uint8(cint % (1 << 9));
|
|
full = uint256(cint >> 8) << bits;
|
|
}
|
|
|
|
function decompressRoundingUp(uint64 cint) public pure returns (uint256 full) {
|
|
uint8 bits = uint8(cint % (1 << 9));
|
|
full = uint256(cint >> 8) << bits + ((1 << bits) - 1);
|
|
}
|
|
```
|
|
|
|
The above gist has `library CInt64` that contains demonstrative logic for compression, decompression, and arithmetic for `cint64`. The gist also has an example contract that uses the library for demonstration purposes.
|
|
|
|
The CInt64 format is intended only for storage, while dev should convert it to uint256 form using suitable logic (decompress or decompressRoundingUp) to perform any arithmetic on it.
|
|
|
|
## Security Considerations
|
|
|
|
The following security considerations are discussed:
|
|
|
|
1. Effects due to lossy compression
|
|
- Error estimation for `cint64`
|
|
- Handling the error
|
|
2. Losing precision due to incorrect use of `cintx`
|
|
3. Compressing something other than money `uint256`s.
|
|
|
|
### 1. Effects due to lossy compression
|
|
|
|
When a value is compressed, it causes underflow, i.e. some less significant bits are sacrificed. This results in a `cintx` value whose decompressed value is less than or equal to the actual `uint256` value.
|
|
|
|
```solidity
|
|
uint a = 2**100 - 1; // 100 # of 1s in binary format
|
|
uint c = a.compress().decompress();
|
|
|
|
a > c; // true
|
|
a - (2**(100 - 56) - 1) == c; // true
|
|
|
|
// Visual example:
|
|
// before: 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
|
|
// after: 1111111111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000
|
|
```
|
|
|
|
#### Error estimation for cint64
|
|
|
|
Let's consider we have a `value` of the order 2<sup>m</sup> (less than 2<sup>m</sup> and greater than or equal to 2<sup>m-1</sup>).
|
|
|
|
For all values such that 2<sup>m</sup> - 1 - (2<sup>m-56</sup> - 1) <= `value` <= 2<sup>m</sup> - 1, the compressed value `cvalue` is 2<sup>m</sup> - 1 - (2<sup>m-56</sup> - 1).
|
|
|
|
The maximum error is 2<sup>m-56</sup> - 1, approximating it to decimal: 10<sup>n-17</sup> (log<sub>2</sub>(56) is 17). Here `n` is number of decimal digits + 1.
|
|
|
|
For e.g. compressing a value of the order $1,000,000,000,000 (or 1T or 10<sup>12</sup>) to `cint64`, the maximum error turns out to be 10<sup>12+1-17</sup> = $10<sup>-4</sup> = $0.0001. This means the precision after 4 decimal places is lost, or we can say that the uncompressed value is at maximum $0.0001 smaller. Similarly, if someone is storing $1,000,000 into `cint64`, the uncompressed value would be at maximum $0.0000000001 smaller. In comparison, the storage costs are almost $0.8 to initialize and $0.2 to update (20 gwei, 2000 ETHUSD).
|
|
|
|
#### Handling the error
|
|
|
|
Note that compression makes the value slightly smaller (underflow). But we also have another operation that also does that. In integer math, the division is a lossy operation (causing underflow). For instance,
|
|
|
|
```solidity
|
|
10000001 / 2 == 5000000 // true
|
|
```
|
|
|
|
The result of the division operation is not always exact, but it's smaller than the actual value, in some cases as in the above example. Though, most engineers try to reduce this effect by doing all the divisions at the end.
|
|
|
|
```
|
|
1001 / 2 * 301 == 150500 // true
|
|
1001 * 301 / 2 == 150650 // true
|
|
```
|
|
|
|
The division operation has been in use in the wild, and plenty of lossy integer divisions have taken place, causing DeFi users to get very very slightly less withdrawal amounts, which they don't even notice. If been careful, then the risk is very negligible. Compression is similar, in the sense that it is also a division by 2<sup>shift</sup>. If been careful with this too, the effects are minimized.
|
|
|
|
In general, one should follow the rule:
|
|
|
|
1. When a smart contract has to transfer a compressed amount to a user, they should use a rounded down value (by using `amount.decompress()`).
|
|
2. When a smart contract has to transferFrom a compressed amount from a user to itself, i.e charging for some bill, they should use a rounded up value (by using `amount.decompressUp()`).
|
|
|
|
The above ensures that smart contract does not loose money due to the compression, it is the user who receives less funds or pays more funds. The extent of rounding is something that is negligible enough for the user. Also just to mention, this rounding up and down pattern is observed in many projects including UniswapV3.
|
|
|
|
### 2. Losing precision due to incorrect use of `cintx`
|
|
|
|
This is an example where dev errors while using compression can be a problem.
|
|
|
|
Usual user amounts mostly have an max entropy of 50, i.e. 10<sup>15</sup> (or 2<sup>50</sup>) values in use, that is the reason why we find uint56 enough for storing significant bits. However, let's see an example:
|
|
|
|
```solidity
|
|
uint64 sharesC = // reading compressed value from storage;
|
|
uint64 price = // CALL;
|
|
uint64 amountC = sharesC.cmuldiv(price, PRICE_UNIT);
|
|
user.transfer(amountC.uncompress());
|
|
```
|
|
|
|
The above code results in a serious precision loss. `sharesC` has an entropy of 50, as well as `priceC` also has an entropy of 50. When we multiply them, we get a value that contains entropies of both, and hence, an entropy of 100. After multiplication is done, `cmul` compresses the value, which drops the entropy of `amountC` to 56 (as we have uint56 there to store significant bits).
|
|
|
|
To prevent entropy/precision from dropping, we get out from compression.
|
|
|
|
```solidity
|
|
uint64 sharesC = shares.compress();
|
|
uint64 priceC = price.compress();
|
|
uint256 amount = sharesC.uncompress() * price / PRICE_UNIT;
|
|
user.transfer(amount);
|
|
```
|
|
|
|
Compression is only useful when writing to storage while doing arithmetic with them should be done very carefully.
|
|
|
|
### 3. Compressing something other than money `uint256`s.
|
|
|
|
Compressed Integers is intended to only compress money amount. Technically there are about 10<sup>77</sup> values that a `uint256` can store but most of those values have a flat distribution i.e. the probability is 0 or extremely negligible. (What is a probability that a user would be depositing 1000T DAI or 1T ETH to a contract? In normal circumstances it doesn't happen, unless someone has full access to the mint function). Only the amounts that people work with have a non-zero distribution ($0.001 DAI to $1T or 10<sup>15</sup> to 10<sup>30</sup> in uint256). 50 bits are enough to represent this information, just to round it we use 56 bits for precision.
|
|
|
|
Using the same method for compressing something else which have a completely different probability distribution will likely result in a problem. It's best to just not compress if you're not sure about the distribution of values your `uint256` is going to take. And also, for things you think you are sure about using compression for, it's better to give more thought if compression can result in edge cases (e.g. in previous multiplication example).
|
|
|
|
### 4. Compressing Stable vs Volatile money amounts
|
|
|
|
Since we have a dynamic `uint8 shift` value that can move around. So even if you wanted to represent 1 Million SHIBA INU tokens or 0.0002 WBTC (both $10 as of this writing), cint64 will pick its top 56 significant bits which will take care of the value representation.
|
|
|
|
It can be a problem for volatile tokens if the coin is extremely volatile wrt user's native currency. Imagine a very unlikely case where a coin goes 2<sup>56</sup>x up (price went up by 10<sup>16</sup> lol). In such cases `uint56` might not be enough as even its least significant bit is very valuable. If such insanely volatile tokens are to be stored, you should store more significant bits, i.e. using `cint96` or `cint128`.
|
|
|
|
`cint64` has 56 bits for storing significant, when only 50 were required. Hence there are 6 extra bits, which means that it is fine if the $ value of the cryptocurrency stored in cint64 increases by 2<sup>6</sup> or 64x. If the value goes down it's not a problem.
|
|
|
|
## Copyright
|
|
|
|
Copyright and related rights waived via [CC0](../LICENSE.md).
|