跳至主要内容

任你快取

Layer 2
快取
儲存
中等
Ori Pomerantz
2022年9月15日
32 分鐘閱讀

使用卷軸時,交易中一個位元組的成本遠高於一個儲存時隙的成本。 因此,盡可能在鏈上快取資訊是合理的。

在本文中,您將學習如何創建和使用快取合約,讓任何可能被多次使用的參數值都被快取,並在(首次使用後)能以更少的位元組來取用,以及如何撰寫使用此快取的鏈外程式碼。

如果您想跳過文章,直接查看原始程式碼,請點擊這裡 (opens in a new tab)。 開發堆疊為 Foundry (opens in a new tab)

總體設計

為求簡單,我們假設所有交易參數都是 uint256,長度為 32 位元組。 當我們收到一筆交易時,我們會像這樣解析每個參數:

  1. 如果第一個位元組是 0xFF,則將接下來的 32 個位元組作為參數值並寫入快取。

  2. 如果第一個位元組是 0xFE,則將接下來的 32 個位元組作為參數值,但_不_寫入快取。

  3. 對於任何其他值,將前四個位元作為附加位元組的數量,後四個位元作為快取鍵的最高有效位。 下面有些範例:

    calldata 中的位元組快取鍵
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

快取操作

快取在 Cache.sol (opens 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;

這些常數用於解釋特殊情況,即我們提供所有資訊,並選擇是否要將其寫入快取。 寫入快取需要對先前未使用的儲存時隙進行兩次 SSTORE (opens in a new tab) 操作,每次成本為 22100 Gas,因此我們將其設為可選。

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, "Reading uninitialize cache entry");
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 "cache overflow");

我不認為我們會有這麼大的快取 (約 1.8*1037 個項目,需要約 1027 TB 來儲存)。 然而,我的年紀足以記得 "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)

此函數從 calldata 讀取任意長度 (最多 32 位元組,即一個字組的大小) 的值。

1 {
2 uint _retVal;
3
4 require(length < 0x21,
5 "_calldataVal length limit is 32 bytes");
6 require(length + startByte <= msg.data.length,
7 "_calldataVal trying to read beyond calldatasize");

這個函數是內部的,所以如果其餘的程式碼都撰寫正確,這些測試就不是必需的。 不過,它們的成本不高,所以不妨保留。

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

此程式碼使用 Yul (opens in a new tab) 撰寫。 它從 calldata 讀取一個 32 位元組的值。 即使 calldata 在 startByte+32 之前就結束了,這段程式碼也能運作,因為 EVM 中未初始化的空間會被視為零。

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

我們不一定需要一個 32 位元組的值。 這會移除多餘的位元組。

1 return _retVal;
2 } // _calldataVal
3
4
5 // 從 calldata 讀取單一參數,從 _fromByte 開始
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {

從 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;
顯示全部

取較低的半位元組 (nibble) (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,測試讀取四個參數
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 的一個大優點是它允許用 Solidity 撰寫測試 (見下文的測試快取)。 這讓單元測試變得容易得多。 這是一個讀取四個參數並回傳它們的函數,以便測試可以驗證它們是否正確。

1 // 取得一個值,回傳將其編碼的位元組 (如果可能,使用快取)
2 function encodeVal(uint _val) public view returns(bytes memory) {

encodeVal 是一個由鏈外程式碼呼叫的函數,用來幫助創建使用快取的 calldata。 它接收單一值並回傳對其編碼的位元組。 此函數是 view 函數,所以不需要交易,且從外部呼叫時不消耗任何 Gas。

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.concat (opens in a new tab)bytes<n> 類型轉換為任意長度的位元組陣列。 儘管有這個名稱,但當只提供一個參數時,它也能正常運作。

1 // 兩位元組值,編碼為 0x1vvv
2 if (_key < 0x1000)
3 return bytes.concat(bytes2(uint16(_key) | 0x1000));

當我們的鍵小於 163 時,我們可以用兩個位元組來表示它。 我們首先將 256 位元值的 _key 轉換為 16 位元值,並使用邏輯「或」將額外位元組的數量加到第一個位元組上。 然後我們將其轉換為 bytes2 值,該值可以轉換為 bytes

1 // 可能有更聰明的方法以迴圈方式處理以下幾行,
2 // 但這是一個 view 函數,所以我為了節省程式員時間和簡化而進行優化。
3
4 if (_key < 16*256**2)
5 return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
6 if (_key < 16*256**3)
7 return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
8 .
9 .
10 .
11 if (_key < 16*256**14)
12 return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
13 if (_key < 16*256**15)
14 return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
顯示全部

其他值 (3 位元組、4 位元組等) 以相同的方式處理,只是欄位大小不同。

1 // 如果執行到這裡,表示出了問題。
2 revert("Error in encodeVal, should not happen");

如果我們執行到這裡,表示我們得到了一個不小於 16*25615 的鍵。 但是 cacheWrite 限制了鍵的範圍,所以我們甚至無法達到 14*25616 (其第一個位元組會是 0xFE,看起來就像 DONT_CACHE)。 但是,為了防止未來的程式員引入錯誤,增加一個測試並不會花費太多成本。

1 } // encodeVal
2
3} // Cache

測試快取

Foundry 的優點之一是 它允許您用 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// 需要執行 `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 // the same
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 }
顯示全部

首先,我們將每個值寫入快取兩次,並確保鍵是相同的 (表示第二次寫入沒有真正發生)。

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 function toUint256(bytes memory _bytes, uint256 _start) internal pure
3 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 {

呼叫 fourParams(),一個使用 readParams 的函數,來測試我們是否能正確讀取參數。

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,

讓同一個合約同時支援快取函數 (用於直接從交易呼叫) 和非快取函數 (用於從其他智能合約呼叫) 是很有用的。 為此,我們需要繼續依賴 Solidity 的機制來呼叫正確的函數,而不是將所有東西都放在一個 fallback 函數 (opens in a new tab)中。 這樣做使得可組合性變得容易得多。 在大多數情況下,單一位元組就足以識別函數,所以我們浪費了三個位元組 (16*3=48 Gas)。 然而,在我寫這篇文章的時候,那 48 Gas 的成本是 0.07 美分,對於更簡單、更少錯誤的程式碼來說,這是一個合理的成本。

1 // 第一個值,將它加入快取
2 cache.INTO_CACHE(),
3 bytes32(VAL_A),

第一個值:一個旗標,表示這是一個需要寫入快取的完整值,後面跟著值的 32 個位元組。 其他三個值是相似的,除了 VAL_B 沒有寫入快取,而 VAL_C 既是第三個參數也是第四個參數。

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。

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);

輸出是四個參數。 這裡我們驗證它是正確的。

1 // 第二次呼叫,我們可以使用快取
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // 快取中的第一個值
6 bytes1(0x01),

小於 16 的快取鍵只有一個位元組。

1 // 第二個值,不要將它加入快取
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // 第三和第四個值,相同的值
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。 對於第二次,其中每個值都已在快取中,它是 4+1*4。

1 // 當鍵超過一個位元組時測試 encodeVal
2 // 最多三個位元組,因為填滿快取到四個位元組需要太長時間。
3 function testEncodeValBig() public {
4 // 將一些值放入快取。
5 // 為保持簡單,對值 n 使用鍵 n。
6 for(uint i=1; i<0x1FFF; i++) {
7 cache.cacheWrite(i);
8 }

上面的 testEncodeVal 函數只向快取中寫入四個值,因此 處理多位元組值的函數部分 (opens in a new tab) 沒有被檢查到。 但那段程式碼很複雜且容易出錯。

這個函數的第一部分是一個迴圈,它按順序將從 1 到 0x1FFF 的所有值寫入快取,這樣我們就能夠編碼這些值並知道它們的位置。

1 .
2 .
3 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // 一個位元組 0x0F
8 cache.encodeVal(0x0010), // 兩個位元組 0x1010
9 cache.encodeVal(0x0100), // 兩個位元組 0x1100
10 cache.encodeVal(0x1000) // 三個位元組 0x201000
11 );
顯示全部

測試一個位元組、兩個位元組和三個位元組的值。 我們沒有測試超過這個範圍,因為寫入足夠多的堆疊項目 (至少 0x10000000,大約二十五億) 會花費太長時間。

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // 測試當緩衝區過小時,我們會得到一個 revert
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 // 第二個值
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
顯示全部

這個函數得到四個完全合法的參數,但快取是空的,所以沒有值可以讀取。

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 // 第二個值,將它加入快取
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // 第三個值,將它加入快取
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // 第四個值,將它加入快取
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // 再加上一個值來「祝好運」
28 bytes4(0x31112233)
29 );
顯示全部

此函數傳送五個值。 我們知道第五個值被忽略了,因為它不是一個有效的快取項目,如果它被包含進去,就會導致還原。

1 (_success, _callOutput) = _cacheAddr.call(_callInput);
2 assertEq(_success, true);
3 .
4 .
5 .
6 } // testLongCalldata
7
8} // CacheTest
9
顯示全部

一個範例應用程式

用 Solidity 寫測試固然很好,但終究去中心化應用程式需要能夠處理來自鏈外的請求才能派上用場。 本文示範如何在一個名為 WORM (意指「寫一次,讀多次」) 的去中心化應用程式中使用快取。 如果一個鍵尚未寫入,您可以將一個值寫入其中。 如果鍵已經寫入,您會得到一個還原。

合約

這是合約 (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

這個函數與上面 CacheTest 中的 fourParam 相似。 因為我們不遵循 ABI 規範,最好不要在函數中聲明任何參數。

1 // 讓我們更容易被呼叫
2 // writeEntryCached() 的函數簽章,由
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3 提供
4 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;

呼叫 writeEntryCached 的外部程式碼將需要手動建構 calldata,而不是使用 worm.writeEntryCached,因為我們不遵循 ABI 規範。 有這個常數值只是為了讓撰寫更容易。

請注意,即使我們將 WRITE_ENTRY_CACHED 定義為狀態變數,要從外部讀取它,也必須使用它的 getter 函數,即 worm.WRITE_ENTRY_CACHED()

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

讀取函數是 view 函數,所以它不需要交易,也不消耗 Gas。 因此,對參數使用快取沒有任何好處。 對於 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);

這就是我們告訴 Solidity,雖然 <contract address>.call() 有兩個回傳值,但我們只關心第一個。

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 測試得不到的一件事,就是可以複製貼上到您自己應用程式中的 JavaScript 程式碼。 為了撰寫這段程式碼,我將 WORM 部署到了 Optimism Goerli (opens in a new tab),這是 Optimism (opens in a new tab) 的新測試網。 它的地址是 0xd34335b1d818cee54e3323d3246bd31d94e6a78a (opens 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 上交易的連結。 然後它會讀回該項目,並顯示它使用的鍵以及項目中的值 (值、區塊號和作者)。

用戶端大部分是正常的去中心化應用程式 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 期望呼叫資料是一個十六進制字串,即 0x 後面跟著偶數個十六進制數字。 由於 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 的值。 例如,字串。

  • 與其使用全域快取,或許可以建立使用者與快取之間的映射。 不同的使用者使用不同的值。

  • 用於地址的值與用於其他目的的值是不同的。 單獨為地址建立一個快取可能是有意義的。

  • 目前,快取鍵採用「先到先得,鍵值最小」的演算法。 前十六個值可以作為單一位元組傳送。 接下來的 4080 個值可以作為兩個位元組傳送。 接下來大約一百萬個值是三個位元組,依此類推。 一個生產系統應該對快取項目保留使用計數器,並重新組織它們,以便十六個_最常用_的值是一個位元組,接下來的 4080 個最常用值是兩個位元組,依此類推。

    然而,這是一個潛在危險的操作。 想像以下事件序列:

    1. 天真的諾姆 (Noam Naive) 呼叫 encodeVal 來編碼他想傳送代幣的地址。 該地址是應用程式上最早使用的地址之一,所以編碼後的值是 0x06。 這是一個 view 函數,不是一個交易,所以它只發生在諾姆和他使用的節點之間,沒有其他人知道。

    2. 擁有者歐文 (Owen Owner) 執行快取重新排序操作。 很少有人真正使用那個地址,所以它現在被編碼為 0x201122。 一個不同的值,1018,被指派為 0x06。

    3. 天真的諾姆將他的代幣傳送到 0x06。 它們被送到地址 0x0000000000000000000000000de0b6b3a7640000,而且由於沒有人知道該地址的私密金鑰,它們就卡在那裡了。 諾姆_非常不開心_。

    有辦法解決這個問題,以及在快取重新排序期間交易在記憶體池中的相關問題,但您必須意識到這一點。

我在這裡用 Optimism 來示範快取,因為我是 Optimism 的員工,這是我最了解的 rollup。 但它應該適用於任何對內部處理收取最低成本的 rollup,這樣相比之下,將交易資料寫入 L1 就成了主要開銷。

在此查看我的更多作品 (opens in a new tab)

頁面最後更新時間: 2026年2月25日

這個使用教學對你有幫助嗎?