はじめに
『DApps開発入門』という本や色々記事を書いているかるでねです。
今回は、uint
型のストレージコストを最適化する仕組みを提案しているERC3772についてまとめていきます!
以下にまとめられているものを翻訳・要約・補足しながらまとめていきます。
他にも様々なEIPについてまとめています。
概要
ERC3772は、uint256
型のデータを uint64
や uint96
、uint128
などの小さなデータ構造へ圧縮する手法を提案しています。
目的は、ストレージコストの最適化です。
圧縮された整数型(cintx
と呼ばれます)は、以下の2つの部分に分かれています。
- 有効ビット(significant bits)
- 値の情報を持つ部分。
- 左シフト量(left shift count)
- 有効ビットに何回左シフトを適用するかを示す数。
この圧縮手法は非可逆圧縮であり、圧縮時に精度の低下が発生する可能性があるため、復元方法についても2通りの仕様が用意されています。
動機
Ethereumにおけるストレージ操作には高いコストがかかります。
1つのストレージスロットの更新には、約0.2ドル(ガス単価20gwei)かかります。
そのため、コントラクトで扱う金額を uint256
型で保存するのはコスト効率が悪いです。
例えば、DAIで扱う金額の範囲は 0.001 DAI〜1兆 DAI(10¹²)程度であり、ETHでは 0.000001 ETH〜10億 ETH が使用範囲です。
このようなトークンの実用的な値の範囲はおおよそ 10¹⁵ 程度に収まります。
一方、uint256
型では 10⁻¹⁸〜10⁵⁸ という非常に広い範囲を表現可能ですが、実際に利用される値はこの中のごく一部に過ぎません。
つまり、ほとんどの範囲は無駄に消費されており、確率的にも 10¹⁵ より大きな値や 10⁻³ より小さな値を使う可能性は非常に低いと見なせます。
理論的に 10¹⁵ を表現するために必要なビット数は log₂(10¹⁵) ≒ 50ビットであり、256ビットも使う必要はありません。
したがって、50ビット程度で十分な精度を持ちながら、ストレージコストを大幅に削減できる圧縮手法が有効と考えられます。
仕様
圧縮形式の仕様
ERC3772では、uint256
型の値をより小さなサイズの整数型に圧縮した形式を cintx
と呼びます。
ここで x
は圧縮後のビット長を表しており、Solidityでは uintx
型で実装されます。
圧縮方式
圧縮は、元の uint256
の値から最初に現れる 1
ビット以降の有効ビットを取り出して保存します。
そしてその値を復元するために必要な左シフト回数を別途記録します。
cint64
~ cint120
形式
この形式では、圧縮されたデータ構造の右端(最下位)8ビットを shift
値として使用し、残りのビットを significant
(有効ビット)として利用します。
struct cint64 { uint56 significant; uint8 shift; }
struct cint120 { uint112 significant; uint8 shift; }
cint128
~ cint248
形式
この形式では、右端の 7
ビットを shift
とし、残りを significant
に使います。
Solidity における uint7
は実在しないため、実装時は uint8
を使用します。
struct cint128 { uint121 significant; uint7 shift; }
struct cint248 { uint241 significant; uint7 shift; }
圧縮例
-
value = 2**100
の場合 → バイナリ
1
の後に100個の 0
⇒ cint64 { significant: 1 << 55, shift: 45 }
-
value = 2**100 - 1
の場合 → バイナリ
先頭から100個の 1
⇒ cint64 { significant: 最大値(56bit), shift: 44 }
復元方式
復元には2種類の方法があります。
通常の復元
significant
を uint256
の空間に配置し、shift
分だけ左シフトすることで元の値を復元します。
例:
cint16 { significant: 11010111, shift: 3 }
⇒ 復元値: 11010111000
cint64 { significant: 56個の1, shift: 44 }
⇒ 復元値: 56個の1 + 44個の0(合計100ビット)
繰り上げ復元
こちらは、左シフト後の末尾ビットを全て 1
にすることで、元の値より大きくなるよう復元します。
これは不確実性がある場合の安全策として使用されます。
例:
cint16 { significant: 11011110, shift: 3 }
⇒ 復元値: 11011110111
cint64 { significant: 56個の1, shift: 44 }
⇒ 復元値: 合計100個の1(末尾が全て1になっている)
コントラクト内での使用方針
ERC3772は、スマートコントラクトが内部状態を効率的に管理するために使用されるべきものです。
圧縮された値はあくまで内部用であり、外部(他のコントラクトやフロントエンド)には公開すべきではありません。
外部に値を提供する必要がある場合は、復元した値を返すべきです。
補足
ERC3772では、圧縮された値 cintx
は以下のような構造になっています。
- 上位ビット
- 有効ビット
- 下位ビット
- シフト量
この配置は、開発者がよくあるミスを避けるための設計です。
例えば、256 - 1
のような比較的小さな数値を cint64
に圧縮した場合、もし shift
が上位ビットに、significant
が下位ビットに格納されていたら、圧縮後のデータが元の数値と同じになる可能性があります。
このとき、開発者が decompress
を忘れてそのまま値を使っても、バグに気づかずに処理が進んでしまうリスクがあります。
それを防ぐために、significant
を上位に、shift
を下位に配置しています。
圧縮によるガス節約についての注意点
cint64
を使えば必ずしも自動的にガスが節約されるわけではありません。
Solidityのコンパイラが、構造体内の複数の変数を同じストレージスロットにパックできた場合にのみガス節約が実現されます。
また、圧縮および復元処理にはわずかながら追加の計算コストもかかります。
そのため、圧縮の導入はアクセス頻度やデータ構造のサイズとのバランスを考えて行う必要があります。
浮動小数点との違い
この圧縮方式は構造的には2進浮動小数点数のようにも見えますが、ERC3772の目的はそれとは異なります。
- 浮動小数点数の目的
- 限られたビット数でより広い数値範囲を表現すること
- 本圧縮方式の目的
- 実用的な範囲内で**できるだけ精度を保ちながらストレージサイズを小さくすること
そのため、指数部(shift)に使用するビット数を最小限に抑えています(8ビット:cint120
まで、7ビット:cint248
まで)。
ストレージスロットの節約例
以下の構造体を例に取ると、圧縮によるストレージ効率の違いが明確になります。
// 3スロット消費
struct UserData1 {
uint64 amountCompressed;
bytes32 hash;
address beneficiary;
}
// 2スロットで収まる(圧縮+レイアウト工夫による効果)
struct UserData2 {
uint64 amountCompressed;
address beneficiary;
bytes32 hash;
}
このように、変数の順番や型のサイズを工夫することで、構造体のストレージスロット数を減らすため結果としてガスコストの最適化につなげることができます。
参考実装
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);
}
セキュリティ
圧縮による値の減少(アンダーフロー)
圧縮では、有効ビット数が制限されるため、下位ビットが失われて元の uint256
よりも小さい値になる可能性があります。
例えば、2**100 - 1
のような値を cint64
に圧縮すると、復元後の値は下記のようになります。
a = 2**100 - 1;
c = compress(a).decompress();
a > c; // true
つまり、正確な値ではなく、近似値となる点に注意が必要です。
cint64
における誤差の見積もり
圧縮により最大で 2**(m-56) - 1
の誤差が生じます。
例えば、10¹²(1兆)を cint64
に圧縮した場合、最大誤差は約 0.0001(10⁻⁴)となります。
これはほとんどのユースケースでは無視できる程度です。
誤差の扱い方
圧縮された値は小さくなる傾向がありますが、整数除算も同様に誤差を生む処理です。
DeFiプロジェクトでは、既に除算によるわずかな精度低下が一般的に受け入れられています。
したがって、以下のようなルールが推奨されます。
- ユーザーにトークンを送るときは
decompress()
(切り捨て)を使う - ユーザーからトークンを受け取るとき(請求など)は
decompressRoundingUp()
(切り上げ)を使う
このルールにより、コントラクトが損をせずにユーザーの受け取り金額や支払い金額はわずかに変動しますが、実用上問題にならない程度に抑えられます。
この手法は UniswapV3 などでも使われています。
誤った使い方による精度損失
圧縮したままのデータを直接演算に使うと、精度が著しく失われる可能性があります。
特に、圧縮された複数の値を掛け合わせたあと再び圧縮すると、情報量が大幅に減少してしまいます。
誤った例。
uint64 sharesC = // 圧縮済み
uint64 price = // 通常の値
uint64 amountC = sharesC.cmuldiv(price, PRICE_UNIT);
user.transfer(amountC.uncompress());
正しい方法。
uint64 sharesC = shares.compress();
uint64 priceC = price.compress();
uint256 amount = sharesC.uncompress() * price / PRICE_UNIT;
user.transfer(amount);
演算は圧縮解除後に行うことが原則です。
圧縮はあくまでストレージ書き込み時の最適化手段であるべきです。
金額以外の uint256
を圧縮しない
この圧縮手法は、トークンや通貨の金額に特化しています。
uint256
には 10⁷⁷ 通りの値がありますが、実際に使われる値は 10¹⁵〜10³⁰ 程度に集中しており、それを見越して 56
ビットを選定しています。
それ以外の目的(例えばランダムな値や異常に大きな範囲の値)で使うと、分布に合わず大きな精度損失やエッジケースを引き起こす可能性があります。
圧縮すべきか判断がつかない場合は、圧縮しない方が安全です。
安定した通貨と変動性の高い通貨の圧縮
cint64
は 56
ビットの有効ビットを持つため、例えば 1,000,000 SHIBA INU や 0.0002 WBTC(いずれも約10ドル)のような値も正確に扱えます。
ただし、非常に変動性の高いトークン(例:価格が256倍になるようなケース)では、最下位ビットの1つでも大きな価値を持つことがあります。
そのような場合には、より多くのビット(cint96
や cint128
)を使用する必要**があります。
なお、cint64
は50ビットで十分なところに6ビットの余裕を持たせているため、価格が約64倍程度まで上昇しても耐えられます。
一方で価格が下落する分には問題ありません。
引用
Soham Zemse (@zemse), "ERC-3772: Compressed Integers [DRAFT]," Ethereum Improvement Proposals, no. 3772, August 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-3772.
最後に
今回は「uint
型のストレージコストを最適化する仕組みを提案しているERC3772」についてまとめてきました!
いかがだったでしょうか?
質問などがある方は以下のTwitterのDMなどからお気軽に質問してください!
他の媒体でも情報発信しているのでぜひ他も見ていってください!