メインコンテンツへスキップ

キャッシュでできること

レイヤー2
キャッシュ
ストレージ
中級
Ori Pomerantz
2022年9月15日
38 分の読書

ロールアップを使うと、トランザクションのバイトあたりのコストは、ストレージスロットのコストよりもはるかに高くなってしまいます。 そのため、オンチェーンに可能な限り多くの情報をキャッシュするほうが合理的です。

この記事では、複数回使用される可能性のあるパラメータの値をキャッシュして、(初回以降では) はるかに少ないバイト数で使えるようにするキャッシュコントラクトの作成および使用方法を学びます。また、このキャッシュを使用するオフチェーンコードの書き方についても説明します。

記事をスキップしてソースコードだけを見たい場合は、こちらopens in a new tabをご覧ください。 開発スタックはFoundryopens in a new tabです。

全体設計

わかりやすくするために、すべてのトランザクションのパラメータはuint256、32バイト長であると仮定します。 トランザクションを受け取ると、次のように各パラメータをパースします。

  1. 先頭のバイトが0xFFの場合、次の32バイトをパラメータの値として取得し、キャッシュに書き込みます。

  2. 先頭のバイトが0xFEの場合、次の32バイトをパラメータの値として取得しますが、キャッシュには書き込み_ません_。

  3. その他の値の場合、上位4ビットを追加のバイト数として、下位4ビットをキャッシュキーの最上位ビットとして取得します。 以下に、いくつかの例を示します。

    calldataのバイトキャッシュキー
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

キャッシュ操作

キャッシュはCache.solopens in a new tabに実装されています。 一行ずつ見ていきましょう。

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4
5contract Cache {
6
7 bytes1 public constant INTO_CACHE = 0xFF;
8 bytes1 public constant DONT_CACHE = 0xFE;

これらの定数は、すべての情報を提供し、それをキャッシュに書き込むかどうかの特殊なケースを解釈するために使用されます。 キャッシュへの書き込みには、以前は使用されていなかったストレージスロットに対して2回のSSTOREopens in a new tab操作が必要で、それぞれ22100ガスがかかるため、オプションとしています。

1
2 mapping(uint => uint) public val2key;

値とそのキーの間のマッピングopens in a new tabです。 この情報は、トランザクションを送信する前に値をエンコードするために必要です。

1 // 位置nにはキーn+1の値があります。なぜなら、
2 // ゼロを「キャッシュにない」状態として保持する必要があるためです。
3 uint[] public key2val;

キーを割り当て、簡単にするために順次行うため、キーから値へのマッピングには配列を使用できます。

1 function cacheRead(uint _key) public view returns (uint) {
2 require(_key <= key2val.length, "初期化されていないキャッシュエントリを読み込んでいます");
3 return key2val[_key-1];
4 } // cacheRead

キャッシュから値を読み取ります。

1 // 値がまだキャッシュにない場合に書き込む
2 // テストを機能させるためにのみpublicに設定
3 function cacheWrite(uint _value) public returns (uint) {
4 // 値がすでにキャッシュにある場合は、現在のキーを返す
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }

同じ値をキャッシュに複数回入れる意味はありません。 値がすでに存在する場合は、既存のキーを返します。

1 // 0xFEは特殊なケースであるため、キャッシュが保持できる
2 // 最大のキーは、0x0Dの後に15個の0xFFが続くものです。キャッシュ長がすでに
3 // その大きさになっている場合は失敗します。
4 // 1 2 3 4 5 6 7 8 9 A B C D E F
5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
6 "キャッシュオーバーフロー");

これほど大きなキャッシュを得ることはないでしょう (約1.8*1037エントリで、保存には約1027TBが必要です)。 しかし、私は"640kBあれば常に十分"opens in a new tabという言葉を覚えているほどには年をとっています。 このテストは非常に安価です。

1 // 次のキーを使用して値を書き込む
2 val2key[_value] = key2val.length+1;

逆引き参照 (値からキーへ) を追加します。

1 key2val.push(_value);

順引き参照 (キーから値へ) を追加します。 値を順次割り当てるため、最後の配列値の後に追加するだけです。

1 return key2val.length;
2 } // cacheWrite

新しい値が格納されているセルであるkey2valの新しい長さを返します。

1 function _calldataVal(uint startByte, uint length)
2 private pure returns (uint)

この関数は、任意の長さ (最大32バイト、ワードサイズ) のcalldataから値を読み取ります。

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "_calldataValの長さ制限は32バイトです");
6 require(length + startByte <= msg.data.length,
7 "_calldataValがcalldatasizeを超えて読み取ろうとしています");

この関数は内部関数であるため、コードの残りの部分が正しく書かれていれば、これらのテストは不要です。 しかし、大してコストがかからないので、あってもよいでしょう。

1 assembly {
2 _retVal := calldataload(startByte)
3 }

このコードはYulopens in a new tabで書かれています。 calldataから32バイトの値を読み取ります。 startByte+32より前にcalldataが停止しても、EVMでは初期化されていない領域はゼロとみなされるため、これは機能します。

1 _retVal = _retVal >> (256-length*8);

必ずしも32バイトの値が必要というわけではありません。 これにより、余分なバイトが取り除かれます。

1 } // _calldataVal
2
3
4 // calldataから_fromByteを開始位置として単一のパラメータを読み取る
5 function _readParam(uint _fromByte) internal
6 returns (uint _nextByte, uint _parameterValue)
7 {

calldataから単一のパラメータを読み取ります。 パラメータは1バイトから33バイトの範囲になる可能性があるため、読み取った値だけでなく、次のバイトの位置も返す必要があることに注意してください。

1 // 最初のバイトで残りの解釈方法がわかる
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));

Solidityは、危険な可能性のある暗黙の型変換opens in a new tabを禁止することで、バグの数を減らそうとします。 ダウングレード、たとえば256ビットから8ビットへの変換は、明示的に行う必要があります。

1
2 // 値を読み取るが、キャッシュには書き込まない
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // 値を読み取り、キャッシュに書き込む
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // ここまで来たということは、キャッシュから読み取る必要があるということ
14
15 // 読み取る追加バイト数
16 uint8 _extraBytes = _firstByte / 16;
すべて表示

下位ニブルopens in a new tabを取り、それを他のバイトと組み合わせてキャッシュから値を読み取ります。

1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
2 _calldataVal(_fromByte+1, _extraBytes);
3
4 return (_fromByte+_extraBytes+1, cacheRead(_key));
5
6 } // _readParam
7
8
9 // n個のパラメータを読み取る (関数は期待するパラメータ数を知っている)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
すべて表示

パラメータ数はcalldata自体から取得できますが、私たちを呼び出す関数は期待するパラメータ数を知っています。 それらの関数に教えてもらう方が簡単です。

1 // 読み取ったパラメータ
2 uint[] memory params = new uint[](_paramNum);
3
4 // パラメータは4バイト目から始まります。それより前は関数シグネチャです
5 uint _atByte = 4;
6
7 for(uint i=0; i<_paramNum; i++) {
8 (_atByte, params[i]) = _readParam(_atByte);
9 }
すべて表示

必要な数になるまでパラメータを読み取ります。 calldataの末尾を超えた場合、_readParamsは呼び出しをリバートします。

1
2 return(params);
3 } // readParams
4
5 // _readParamsのテストのため、4つのパラメータの読み取りをテストする
6 function fourParam() public
7 returns (uint256,uint256,uint256,uint256)
8 {
9 uint[] memory params;
10 params = _readParams(4);
11 return (params[0], params[1], params[2], params[3]);
12 } // fourParam
すべて表示

Foundryの大きな利点の1つは、Solidityでテストを書くことができる点です(下記の「キャッシュのテスト」を参照)。 これにより、単体テストが非常に簡単になります。 これは4つのパラメータを読み取って返し、テストでそれらが正しいことを検証できるようにする関数です。

1 // 値を取得し、それをエンコードするバイト列を返す (可能であればキャッシュを使用)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeValは、オフチェーンコードがキャッシュを使用するcalldataの作成を支援するために呼び出す関数です。 単一の値を受け取り、それをエンコードするバイト列を返します。 この関数はviewなので、トランザクションを必要とせず、外部から呼び出してもガスはかかりません。

1 uint _key = val2key[_val];
2
3 // 値はまだキャッシュにないので、追加する
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));

EVMでは、初期化されていないストレージはすべてゼロであるとみなされます。 そのため、存在しない値のキーを探すと、ゼロが返されます。 その場合、それをエンコードするバイト列はINTO_CACHE (次回キャッシュされるように) となり、その後に実際の値が続きます。

1 // キーが0x10未満の場合、単一バイトとして返す
2 if (_key < 0x10)
3 return bytes.concat(bytes1(uint8(_key)));

単一バイトが最も簡単です。 bytes.concatopens in a new tabを使用して、bytes<n>型を任意の長さのバイト配列に変換するだけです。 その名前にもかかわらず、引数が1つだけ提供された場合でも正常に動作します。

1 // 2バイト値、0x1vvvとしてエンコード
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

163未満のキーがある場合、それを2バイトで表現できます。 まず、256ビットの値である_keyを16ビットの値に変換し、論理和を使用して最初のバイトに追加バイト数を加えます。 次に、それをbytesに変換できるbytes2の値に入れます。

1 // 以降の行をループとして実行する賢い方法があるかもしれませんが、
2 // これはview関数なので、プログラマの時間と
3 // 単純さを最適化しています。
4
5 if (_key < 16*256**2)
6 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
7 if (_key < 16*256**3)
8 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
9 .
10 .
11 .
12 if (_key < 16*256**14)
13 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
14 if (_key < 16*256**15)
15 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
すべて表示

その他の値 (3バイト、4バイトなど) は、フィールドサイズが異なるだけで、同じように処理されます。

1 // ここに到達した場合、何かが間違っています。
2 revert("encodeValのエラー、発生するはずがありません");

ここに到達するということは、16*25615以上のキーを取得したということです。 しかし、cacheWriteはキーを制限するため、14*25616 (最初のバイトが0xFEとなり、DONT_CACHEのように見える) に達することさえありません。 しかし、将来のプログラマーがバグを混入させる場合に備えてテストを追加しても、大したコストはかかりません。

1 } // encodeVal
2
3} // Cache

キャッシュのテスト

Foundryの利点の1つは、Solidityでテストを書くことができるopens in a new tabことで、これにより単体テストが書きやすくなります。 Cacheクラスのテストはこちらopens in a new tabにあります。 テストコードは繰り返しが多くなりがちなので、この記事では興味深い部分のみを説明します。

1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Test.sol";
5
6
7// consoleを使用するには、`forge test -vv`を実行する必要があります。
8import "forge-std/console.sol";

これは、テストパッケージとconsole.logを使用するために必要な、単なる定型コードです。

1import "src/Cache.sol";

テスト対象のコントラクトをインポートする必要があります。

1contract CacheTest is Test {
2 Cache cache;
3
4 function setUp() public {
5 cache = new Cache();
6 }

setUp関数は各テストの前に呼び出されます。 この場合、新しいキャッシュを作成するだけなので、テストが互いに影響することはありません。

1 function testCaching() public {

テストはtestで始まる名前の関数です。 この関数は、値を書き込んで再度読み取ることにより、基本的なキャッシュ機能を確認します。

1 for(uint i=1; i<5000; i++) {
2 cache.cacheWrite(i*i);
3 }
4
5 for(uint i=1; i<5000; i++) {
6 assertEq(cache.cacheRead(i), i*i);

これは、assert... 関数opens in a new tabを使用して実際のテストを行う方法です。 この場合、書き込んだ値が読み取った値と等しいことを確認します。 cache.cacheWriteの結果は、キャッシュキーが線形に割り当てられることがわかっているので、破棄できます。

1 }
2 } // testCaching
3
4
5 // 同じ値を複数回キャッシュし、キーが
6 // 同じままであることを確認する
7 function testRepeatCaching() public {
8 for(uint i=1; i<100; i++) {
9 uint _key1 = cache.cacheWrite(i);
10 uint _key2 = cache.cacheWrite(i);
11 assertEq(_key1, _key2);
12 }
すべて表示

まず、各値をキャッシュに2回書き込み、キーが同じであることを確認します (つまり、2回目の書き込みは実際には行われなかったということです)。

1 for(uint i=1; i<100; i+=3) {
2 uint _key = cache.cacheWrite(i);
3 assertEq(_key, i);
4 }
5 } // testRepeatCaching

理論上は、連続したキャッシュ書き込みに影響しないバグが存在する可能性があります。 そのため、ここでは連続していない書き込みをいくつか行い、値がまだ書き換えられていないことを確認します。

1 // メモリバッファからuintを読み取る (送信したパラメータが
2 // 返ってくることを確認するため)
3 function toUint256(bytes memory _bytes, uint256 _start) internal pure
4 returns (uint256)

bytes memoryバッファから256ビットのワードを読み取ります。 このユーティリティ関数を使用すると、キャッシュを使用する関数呼び出しを実行したときに、正しい結果を受け取ったことを確認できます。

1 {
2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
3 uint256 tempUint;
4
5 assembly {
6 tempUint := mload(add(add(_bytes, 0x20), _start))
7 }

Yulはuint256を超えるデータ構造をサポートしていないため、メモリバッファ_bytesのような、より高度なデータ構造を参照すると、その構造のアドレスが取得されます。 Solidityはbytes memoryの値を、長さを含む32バイトのワードとして格納し、その後に実際のバイトが続くため、_start番目のバイトを取得するには_bytes+32+_startを計算する必要があります。

1
2 return tempUint;
3 } // toUint256
4
5 // fourParams()の関数シグネチャ、提供元:
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // 正しい値が返ってくることを確認するための定数値
10 uint256 constant VAL_A = 0xDEAD60A7;
11 uint256 constant VAL_B = 0xBEEF;
12 uint256 constant VAL_C = 0x600D;
13 uint256 constant VAL_D = 0x600D60A7;
すべて表示

テストに必要な定数です。

1 function testReadParam() public {

readParamsを使用する関数fourParams()を呼び出し、パラメータを正しく読み取れることをテストします。

1 address _cacheAddr = address(cache);
2 bool _success;
3 bytes memory _callInput;
4 bytes memory _callOutput;

キャッシュを使用する関数を呼び出すには、通常のABIメカニズムが使用できないため、低レベルの<address>.call()opens in a new tabメカニズムを使用する必要があります。 このメカニズムは、入力としてbytes memoryを受け取り、それを出力として(ブール値と同様に)返します。

1 // 最初の呼び出し、キャッシュは空
2 _callInput = bytes.concat(
3 FOUR_PARAMS,

同じコントラクトが、キャッシュされた関数(トランザクションからの直接呼び出し用)とキャッシュされていない関数(他のスマートコントラクトからの呼び出し用)の両方をサポートすると便利です。 そのためには、すべてをフォールバック関数opens in a new tabに置くのではなく、引き続きSolidityのメカニズムに頼って正しい関数を呼び出す必要があります。 これにより、構成可能性が大幅に向上します。 ほとんどの場合、関数を識別するには1バイトで十分なため、3バイト(16*3=48ガス)を無駄にしています。 しかし、この記事を書いている時点では、その48ガスのコストは0.07セントであり、これはより単純でバグが発生しにくいコードのための妥当なコストです。

1 // 最初の値、キャッシュに追加する
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

最初の値: キャッシュに書き込む必要がある完全な値であることを示すフラグで、その後に32バイトの値が続きます。 他の3つの値も同様ですが、VAL_Bはキャッシュに書き込まれず、VAL_Cは3番目と4番目の両方のパラメータである点が異なります。

1 .
2 .
3 .
4 );
5 (_success, _callOutput) = _cacheAddr.call(_callInput);

ここで実際にCacheコントラクトを呼び出します。

1 assertEq(_success, true);

呼び出しが成功することを期待します。

1 assertEq(cache.cacheRead(1), VAL_A);
2 assertEq(cache.cacheRead(2), VAL_C);

空のキャッシュから始めて、VAL_Aの後にVAL_Cを追加します。 最初のキーが1で、2番目のキーが2であることを期待します。

1 assertEq(toUint256(_callOutput,0), VAL_A);
2 assertEq(toUint256(_callOutput,32), VAL_B);
3 assertEq(toUint256(_callOutput,64), VAL_C);
4 assertEq(toUint256(_callOutput,96), VAL_C);

出力は4つのパラメータです。 ここで、それが正しいことを検証します。

1 // 2回目の呼び出し、キャッシュを使用できる
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // キャッシュ内の最初の値
6 bytes1(0x01),

16未満のキャッシュキーは、ちょうど1バイトになります。

1 // 2番目の値、キャッシュに追加しない
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // 3番目と4番目の値、同じ値
6 bytes1(0x02),
7 bytes1(0x02)
8 );
9 .
10 .
11 .
12 } // testReadParam
すべて表示

呼び出し後のテストは、最初の呼び出し後のテストと同一です。

1 function testEncodeVal() public {

この関数はtestReadParamに似ていますが、パラメータを明示的に書き込む代わりにencodeVal()を使用する点が異なります。

1 .
2 .
3 .
4 _callInput = bytes.concat(
5 FOUR_PARAMS,
6 cache.encodeVal(VAL_A),
7 cache.encodeVal(VAL_B),
8 cache.encodeVal(VAL_C),
9 cache.encodeVal(VAL_D)
10 );
11 .
12 .
13 .
14 assertEq(_callInput.length, 4+1*4);
15 } // testEncodeVal
すべて表示

testEncodeVal()での唯一の追加テストは、_callInputの長さが正しいかを確認することです。 最初の呼び出しでは、4+33*4となります。 2回目では、すべての値がすでにキャッシュ内にあるため、4+1*4となります。

1 // キーが1バイト以上の場合のencodeValのテスト
2 // 4バイトまでキャッシュを埋めるのは
3 // 時間がかかりすぎるため、最大3バイトとする。
4 function testEncodeValBig() public {
5 // いくつかの値をキャッシュに入れる。
6 // 簡単にするため、値nにキーnを使用する。
7 for(uint i=1; i<0x1FFF; i++) {
8 cache.cacheWrite(i);
9 }
すべて表示

上記のtestEncodeVal関数は4つの値しかキャッシュに書き込まないため、マルチバイト値を扱う関数の部分opens in a new tabはチェックされません。 しかし、そのコードは複雑でエラーが発生しやすくなっています。

この関数の最初の部分は、1から0x1FFFまでのすべての値を順番にキャッシュに書き込むループなので、これらの値をエンコードして、どこに行くのかを知ることができます。

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // 1バイト 0x0F
8 cache.encodeVal(0x0010), // 2バイト 0x1010
9 cache.encodeVal(0x0100), // 2バイト 0x1100
10 cache.encodeVal(0x1000) // 3バイト 0x201000
11 );
すべて表示

1バイト、2バイト、3バイトの値をテストします。 十分なスタックエントリ (少なくとも0x10000000、約2億5千万) を書き込むには時間がかかりすぎるため、それ以上のテストは行いません。

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // 短すぎるバッファでリバートされることをテストする
9 function testShortCalldata() public {
すべて表示

パラメータが不十分な異常ケースで何が起こるかをテストします。

1 .
2 .
3 .
4 (_success, _callOutput) = _cacheAddr.call(_callInput);
5 assertEq(_success, false);
6 } // testShortCalldata

リバートされるので、得られる結果はfalseになるはずです。

1 // 存在しないキャッシュキーで呼び出す
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // 最初の値、キャッシュに追加する
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // 2番目の値
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
すべて表示

この関数は、キャッシュが空で読み込む値がないことを除けば、4つの完全に正当なパラメータを取得します。

1 .
2 .
3 .
4 // 長すぎるバッファでもすべてがうまくいくことをテストする
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // 最初の呼び出し、キャッシュは空
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // 最初の値、キャッシュに追加する
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // 2番目の値、キャッシュに追加する
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // 3番目の値、キャッシュに追加する
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // 4番目の値、キャッシュに追加する
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // 「幸運を祈って」もう1つの値
28 bytes4(0x31112233)
29 );
すべて表示

この関数は5つの値を送信します。 5番目の値は有効なキャッシュエントリではないため無視されることがわかります。もし含まれていなければリバートを引き起こしていたでしょう。

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
すべて表示

サンプルアプリケーション

Solidityでテストを書くのは非常に良いことですが、結局のところ、dappが役立つためにはチェーンの外部からのリクエストを処理できる必要があります。 この記事では、「Write Once, Read Many (一度書き込み、多数回読み取り)」を意味するWORMを使用して、dappでキャッシングを使用する方法を実演します。 キーがまだ書き込まれていない場合は、値を書き込むことができます。 キーがすでに書き込まれている場合は、リバートされます。

コントラクト

こちらがコントラクトopens in a new tabです。 これは主にCacheCacheTestで既に行ったことを繰り返しているので、興味深い部分のみを取り上げます。

1import "./Cache.sol";
2
3contract WORM is Cache {

Cacheを使用する最も簡単な方法は、自分のコントラクトでそれを継承することです。

1 function writeEntryCached() external {
2 uint[] memory params = _readParams(2);
3 writeEntry(params[0], params[1]);
4 } // writeEntryCached

この関数は、上記のCacheTestfourParamに似ています。 ABI仕様に従っていないため、関数にパラメータを宣言しないのが最善です。

1 // 呼び出しを容易にする
2 // writeEntryCached()の関数シグネチャ、提供元:
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

ABI仕様に従っていないため、writeEntryCachedを呼び出す外部コードは、worm.writeEntryCachedを使用する代わりに、手動でcalldataを構築する必要があります。 この定数値があると、その記述が楽になります。

WRITE_ENTRY_CACHEDを状態変数として定義しても、それを外部から読み取るには、そのゲッター関数であるworm.WRITE_ENTRY_CACHED()を使用する必要があることに注意してください。

1 function readEntry(uint key) public view
2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)

読み取り関数はviewなので、トランザクションを必要とせず、ガスもかかりません。 結果として、パラメータにキャッシュを使うメリットはありません。 ビュー関数では、より単純な標準メカニズムを使う方が最善です。

テストコード

こちらがコントラクトのテストコードopens in a new tabです。 繰り返しになりますが、興味深い部分のみを見ていきましょう。

1 function testWReadWrite() public {
2 worm.writeEntry(0xDEAD, 0x60A7);
3
4 vm.expectRevert(bytes("entry already written"));
5 worm.writeEntry(0xDEAD, 0xBEEF);

これ (vm.expectRevert)opens in a new tabは、Foundryテストで次の呼び出しが失敗すること、および失敗の報告理由を指定する方法です。 これは、<contract>.<function name>構文を使用する場合に適用されます。これは、calldataを構築して低レベルインターフェース (<contract>.call()など) を使用してコントラクトを呼び出すのとは異なります。

1 function testReadWriteCached() public {
2 uint cacheGoat = worm.cacheWrite(0x60A7);

ここでは、cacheWriteがキャッシュキーを返すという事実を利用します。 これは本番環境での使用を想定していません。cacheWriteは状態を変更するため、トランザクション中にしか呼び出せません。 トランザクションには戻り値がありません。結果がある場合、その結果はイベントとして発行されることになっています。 そのため、cacheWriteの戻り値はオンチェーンコードからのみアクセスでき、オンチェーンコードはパラメータのキャッシュを必要としません。

1 (_success,) = address(worm).call(_callInput);

これは、<contract address>.call()には2つの戻り値があるが、最初の値しか気にしないことをSolidityに伝える方法です。

1 (_success,) = address(worm).call(_callInput);
2 assertEq(_success, false);

低レベルの<address>.call()関数を使用しているため、vm.expectRevert()を使用できず、呼び出しから得られるブール値の成功値を見る必要があります。

1 event EntryWritten(uint indexed key, uint indexed value);
2
3 .
4 .
5 .
6
7 _callInput = bytes.concat(
8 worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
9 vm.expectEmit(true, true, false, false);
10 emit EntryWritten(a, b);
11 (_success,) = address(worm).call(_callInput);
すべて表示

これは、Foundryでコードがイベントを正しく発行するopens in a new tabことを検証する方法です。

クライアント

Solidityのテストでは得られないものの1つは、自分のアプリケーションにコピー&ペーストできるJavaScriptコードです。 そのコードを書くために、WORMをOptimismopens in a new tabの新しいテストネットであるOptimism Goerliopens in a new tabにデプロイしました。 アドレスは0xd34335b1d818cee54e3323d3246bd31d94e6a78aopens in a new tabです。

クライアントのJavaScriptコードはこちらで確認できますopens in a new tab。 使用方法は次のとおりです。

  1. gitリポジトリをクローンします。

    1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
  2. 必要なパッケージをインストールします。

    1cd javascript
    2yarn
  3. 設定ファイルをコピーします。

    1cp .env.example .env
  4. .envを編集して設定を行います。

    パラメータ
    MNEMONICトランザクションの支払いに十分なETHを持っているアカウントのニーモニック。 こちらでOptimismのGoerliネットワークの無料ETHを手に入れられますopens in a new tab
    OPTIMISM_GOERLI_URLOptimism GoerliのURL。 公開エンドポイントhttps://goerli.optimism.ioは、レート制限がありますが、ここで必要な用途には十分です。
  5. index.jsを実行します。

    1node index.js

    このサンプルアプリケーションは、まずWORMにエントリを書き込み、calldataとEtherscan上のトランザクションへのリンクを表示します。 次に、そのエントリを読み返し、使用するキーとエントリ内の値 (値、ブロック番号、作成者) を表示します。

クライアントのほとんどは通常のDapp JavaScriptです。 そのため、ここでも興味深い部分のみを取り上げます。

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // 毎回新しいキーが必要
8 const key = await worm.encodeVal(Number(new Date()))

特定のスロットには一度しか書き込めないため、タイムスタンプを使用してスロットを再利用しないようにします。

1const val = await worm.encodeVal("0x600D")
2
3// エントリを書き込む
4const calldata = func + key.slice(2) + val.slice(2)

Ethersは、コールデータが16進文字列、つまり0xの後に偶数個の16進数が続くことを期待します。 keyvalは両方とも0xで始まるため、これらのヘッダーを削除する必要があります。

1const tx = await worm.populateTransaction.writeEntryCached()
2tx.data = calldata
3
4sentTx = await wallet.sendTransaction(tx)

Solidityのテストコードと同様に、キャッシュされた関数を通常の方法で呼び出すことはできません。 代わりに、より低レベルのメカニズムを使用する必要があります。

1 .
2 .
3 .
4 // 書き込んだばかりのエントリを読み取る
5 const realKey = '0x' + key.slice(4) // FFフラグを削除する
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
すべて表示

エントリの読み取りには、通常のメカニズムを使用できます。 view関数では、パラメータのキャッシュを使う必要はありません。

結論

この記事のコードは概念実証 (プルーフ・オブ・コンセプト) であり、アイデアを理解しやすくすることを目的としています。 本番環境のシステムでは、いくつかの追加機能を実装することをお勧めします。

  • uint256ではない値を処理します。 例えば、文字列です。

  • グローバルキャッシュの代わりに、ユーザーとキャッシュの間のマッピングを持つことを検討します。 異なるユーザーは異なる値を使用します。

  • アドレスに使用される値は、他の目的で使用される値とは異なります。 アドレス専用の個別のキャッシュを持つことが合理的かもしれません。

  • 現在、キャッシュキーは「先着、最小キー」アルゴリズムに基づいています。 最初の16個の値は、単一バイトとして送信できます。 次の4080個の値は、2バイトとして送信できます。 次の約100万個の値は3バイト、などとなります。 本番システムでは、キャッシュエントリの使用カウンタを保持し、最も_一般的な_16個の値が1バイト、次に一般的な4080個の値が2バイトになるように再編成する必要があります。

    ただし、これは潜在的に危険な操作です。 次の一連のイベントを想像してみてください。

    1. Noam NaiveがencodeValを呼び出して、トークンを送りたいアドレスをエンコードします。 そのアドレスはアプリケーションで最初に使用されたアドレスの1つなので、エンコードされた値は0x06です。 これはトランザクションではなくview関数なので、Noamと彼が使用するノード間のやり取りであり、他の誰も知りません。

    2. Owen Ownerがキャッシュの並べ替え操作を実行します。 そのアドレスを実際に使用する人はほとんどいないため、現在は0x201122としてエンコードされています。 別の値である1018に、0x06が割り当てられます。

    3. Noam Naiveは自分のトークンを0x06に送信します。 トークンはアドレス0x0000000000000000000000000de0b6b3a7640000に送られますが、そのアドレスの秘密鍵を誰も知らないため、そこにスタックしてしまいます。 Noamは_不満_です。

    この問題、およびキャッシュの再順序付け中にメモリプール内にあるトランザクションの関連問題を解決する方法はありますが、その存在を認識しておく必要があります。

私はOptimismの従業員であり、これが最もよく知っているロールアップなので、ここではOptimismを使ってキャッシュを実演しました。 しかし、これは内部処理に最小限のコストしかかからないどのロールアップでも機能するはずです。そのため、比較するとL1へのトランザクションデータの書き込みが主な費用となります。

私の他の作品はこちらでご覧いただけますopens in a new tab.

最終更新: 2026年2月25日

このチュートリアルは役に立ちましたか?