Appearance
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 1012). 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 1015 amounts that we care/work with.
- However, uint256 type allows us to represent $10-18 to $1058, and most of it is a waste. In technical terms, we have the probability distribution for values larger than $1015 and smaller than $10-3 as negligible (i.e. P[val > 1015] ≈ 0 and P[val < 10-3] ≈ 0).
- Number of bits required to represent 1015 values = log2(1015) = 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 ofcintx
whileshift
bits in the least significant part, to help prevent obvious dev mistakes. For e.g. a number smaller than 256-1 its compressedcint64
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 touint248
).
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:
- Effects due to lossy compression
- Error estimation for
cint64
- Handling the error
- Error estimation for
- Losing precision due to incorrect use of
cintx
- 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 2m (less than 2m and greater than or equal to 2m-1).
For all values such that 2m - 1 - (2m-56 - 1) <= value
<= 2m - 1, the compressed value cvalue
is 2m - 1 - (2m-56 - 1).
The maximum error is 2m-56 - 1, approximating it to decimal: 10n-17 (log2(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 1012) to cint64
, the maximum error turns out to be 1012+1-17 = $10-4 = $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 2shift. If been careful with this too, the effects are minimized.
In general, one should follow the rule:
- When a smart contract has to transfer a compressed amount to a user, they should use a rounded down value (by using
amount.decompress()
). - 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. 1015 (or 250) 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 1077 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 1015 to 1030 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 256x up (price went up by 1016 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 26 or 64x. If the value goes down it's not a problem.
Copyright ​
Copyright and related rights waived via CC0.