一切皆可缓存
当使用卷叠时,交易中一个字节的成本比一个存储插槽的成本要高得多。 因此,在链上缓存尽可能多的信息是有意义的。
在本文中,你将学习如何创建和使用缓存合约,使得任何可能被多次使用的参数值都会被缓存,并且(在第一次使用之后)可通过更少的字节数来使用,并学习如何编写使用此缓存的链下代码。
如果你想跳过这篇文章,直接查看源代码,参见此处(opens in a new tab)。 开发堆栈是 Foundry(opens in a new tab)。
总体设计
为了简单起见,我们将假定所有交易参数是 uint256
,长度为 32 个字节。 当我们收到交易时,将对每个参数进行解析,如下所示:
如果第一个字节是
0xFF
,则将接下来的 32 个字节作为参数值并将其写入缓存。如果第一个字节是
0xFE
,则将接下来的 32 个字节作为参数值,但不将其写入缓存。对于任何其他值,将前四位作为额外字节的数量,将后四位作为缓存键的最高有效位。 以下是一些示例:
calldata 中的字节 缓存键 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
缓存操作
缓存是在 Cache.sol
(opens in a new tab) 中实现的。 我们逐行学习它。
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;345contract Cache {67 bytes1 public constant INTO_CACHE = 0xFF;8 bytes1 public constant DONT_CACHE = 0xFE;复制
这些常量用于解释特殊情况,其中我们提供了所有信息,但是是否希望将其写入缓存是可选的。 写入缓存需要对之前未使用的存储插槽执行两次 SSTORE
(opens in a new tab) 操作,每次操作花费 22100 燃料,这样我们将此操作变为可选操作。
12 mapping(uint => uint) public val2key;复制
一个在值和其键之间的映射(opens in a new tab)。 在发送交易之前,对值进行编码时需要这些信息。
1 // Location n has the value for key n+1, because we need to preserve2 // zero as "not in the cache".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 // Write a value to the cache if it's not there already2 // Only public to enable the test to work3 function cacheWrite(uint _value) public returns (uint) {4 // If the value is already in the cache, return the current key5 if (val2key[_value] != 0) {6 return val2key[_value];7 }复制
在缓存中多次存储相同的值是没有意义的。 如果值已经存在,只需返回现有的键。
1 // Since 0xFE is a special case, the largest key the cache can2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that3 // large, fail.4 // 1 2 3 4 5 6 7 8 9 A B C D E F5 require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,6 "cache overflow");复制
我认为我们永远无法获得如此庞大的缓存(大约 1.8*1037 个条目,需要大约 1027 TB 的存储空间)。 然而,我知道“640kB 始终足够了”(opens in a new tab)。 这一测试非常实惠。
1 // Write the value using the next key2 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;34 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
之前停止,这种方法仍然有效,因为在以太坊虚拟机中,未初始化的空间被视为零。
1 _retVal = _retVal >> (256-length*8);复制
我们并不一定需要一个 32 字节的值。 这将消除多余的字节。
1 return _retVal;2 } // _calldataVal345 // Read a single parameter from the calldata, starting at _fromByte6 function _readParam(uint _fromByte) internal7 returns (uint _nextByte, uint _parameterValue)8 {复制
从 calldata 中读取单个参数。 请注意,我们需要返回的不仅仅是我们读取的值,还包括下一个字节的位置,因为参数的长度可以从 1 个字节到 33 个字节不等。
1 // The first byte tells us how to interpret the rest2 uint8 _firstByte;34 _firstByte = uint8(_calldataVal(_fromByte, 1));复制
Solidity 试图通过禁止可能危险的隐式类型转换(opens in a new tab)来减少错误的数量。 降级操作,例如从 256 位降级为 8 位,需要为显式。
12 // Read the value, but do not write it to the cache3 if (_firstByte == uint8(DONT_CACHE))4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));56 // Read the value, and write it to the cache7 if (_firstByte == uint8(INTO_CACHE)) {8 uint _param = _calldataVal(_fromByte+1, 32);9 cacheWrite(_param);10 return(_fromByte+33, _param);11 }1213 // If we got here it means that we need to read from the cache1415 // Number of extra bytes to read16 uint8 _extraBytes = _firstByte / 16;显示全部复制
取出低位四位(半字节(opens in a new tab)),并将其与其他字节组合以从缓存中读取值。
1 uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +2 _calldataVal(_fromByte+1, _extraBytes);34 return (_fromByte+_extraBytes+1, cacheRead(_key));56 } // _readParam789 // Read n parameters (functions know how many parameters they expect)10 function _readParams(uint _paramNum) internal returns (uint[] memory) {显示全部复制
我们可以从 calldata 中获取我们拥有的参数数量,但是调用我们的函数知道它们期望的参数数量。 让这些函数告诉我们会更容易一些。
1 // The parameters we read2 uint[] memory params = new uint[](_paramNum);34 // Parameters start at byte 4, before that it's the function signature5 uint _atByte = 4;67 for(uint i=0; i<_paramNum; i++) {8 (_atByte, params[i]) = _readParam(_atByte);9 }显示全部复制
读取参数,直到你获取所需的数量。 如果我们超出了 calldata 的末尾,_readParams
将会回滚调用。
12 return(params);3 } // readParams45 // For testing _readParams, test reading four parameters6 function fourParam() public7 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 // Get a value, return bytes that will encode it (using the cache if possible)2 function encodeVal(uint _val) public view returns(bytes memory) {复制
encodeVal
是一个由链下代码调用的函数,用于帮助创建使用缓存的 calldata。 它接收一个值,并返回对其进行编码的字节。 该函数是一个 view
函数,因此不需要进行交易,并且在被外部调用时不需要支付任何燃料费用。
1 uint _key = val2key[_val];23 // The value isn't in the cache yet, add it4 if (_key == 0)5 return bytes.concat(INTO_CACHE, bytes32(_val));复制
在以太坊虚拟机中,所有未初始化的存储被假定为零。 因此,如果我们在查找一个不存在的值的键时,会得到一个零。 在这种情况下,对其进行编码的字节为 INTO_CACHE
(这样下次将被缓存),接着是实际的值。
1 // If the key is <0x10, return it as a single byte2 if (_key < 0x10)3 return bytes.concat(bytes1(uint8(_key)));复制
单字节是最简单的。 我们使用 bytes.concat
(opens in a new tab) 函数将 bytes<n>
类型转换为可以是任意长度的字节数组。 尽管名称如此,但当只提供一个参数时,它仍能正常工作。
1 // Two byte value, encoded as 0x1vvv2 if (_key < 0x1000)3 return bytes.concat(bytes2(uint16(_key) | 0x1000));复制
当我们有一个小于 163 的键时,我们可以用两个字节来表示它。 我们首先将一个 256 位的值 _key
转换为一个 16 位的值,并使用逻辑“或”将额外字节数添加到第一个字节。 然后我们将其转换为 bytes2
值,继而可以转换为 bytes
。
1 // There is probably a clever way to do the following lines as a loop,2 // but it's a view function so I'm optimizing for programmer time and3 // simplicity.45 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 // If we get here, something is wrong.2 revert("Error in encodeVal, should not happen");复制
如果到了这一步,意味着我们得到了一个键,其值不小于 16*25615。 但是 cacheWrite
对键进行了限制,因此我们甚至无法达到 14*25616(其首字节为 0xFE,因此看起来像 DONT_CACHE
)。 添加一个测试来防止未来的开发者引入错误,并不需要太多成本。
1 } // encodeVal23} // Cache复制
测试缓存
Foundry 的一个优势是允许你使用 Solidity 编写测试(opens in a new tab),这使得编写单元测试更加容易。 Cache
类的测试可以在此处(opens in a new tab)找到。 由于测试代码通常会有很多重复的部分,本文仅说明有趣的部分。
1// SPDX-License-Identifier: UNLICENSED2pragma solidity ^0.8.13;34import "forge-std/Test.sol";567// Need to run `forge test -vv` for the console.8import "forge-std/console.sol";复制
这只是一个样板文件,用于使用测试包和 console.log
。
1import "src/Cache.sol";复制
我们需要知道我们正在测试的合约。
1contract CacheTest is Test {2 Cache cache;34 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 }45 for(uint i=1; i<5000; i++) {6 assertEq(cache.cacheRead(i), i*i);复制
这是你进行实际测试时使用的方法,使用 assert...
函数(opens in a new tab)。 在这种情况下,我们检查我们写入的值是否是我们读取的值。 我们可以忽略 cache.cacheWrite
的结果,因为我们知道缓存键是按线性分配的。
1 }2 } // testCaching345 // Cache the same value multiple times, ensure that the key stays6 // the same7 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 // Read a uint from a memory buffer (to make sure we get back the parameters2 // we sent out)3 function toUint256(bytes memory _bytes, uint256 _start) internal pure4 returns (uint256)复制
从 bytes memory
缓冲区中读取一个 256 位的字。 这个实用功能使我们能够验证,在运行使用缓存的函数调用时,我们是否收到了正确的结果。
1 {2 require(_bytes.length >= _start + 32, "toUint256_outOfBounds");3 uint256 tempUint;45 assembly {6 tempUint := mload(add(add(_bytes, 0x20), _start))7 }复制
Yul 不支持超出 uint256
的数据结构,因此当你引用更复杂的数据结构,例如内存缓冲区 _bytes
时,你会得到该结构的地址。 Solidity 将 bytes memory
类型的值存储为一个 32 字节的字,其中包含长度信息,后跟实际的字节。因此,要获取字节数量 _start
,我们需要计算 _bytes+32+_start
。
12 return tempUint;3 } // toUint25645 // Function signature for fourParams(), courtesy of6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;89 // Just some constant values to see we're getting the correct values back10 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;复制
我们无法使用普通的应用程序二进制接口机制来调用使用缓存的函数,因此我们需要使用低级别的 <address>.call()
(opens in a new tab) 机制。 该机制接受一个 bytes memory
类型的输入,并将其作为输出返回(同时返回一个布尔值)。
1 // First call, the cache is empty2 _callInput = bytes.concat(3 FOUR_PARAMS,复制
对于同一个合约来说,支持缓存函数(用于从交易直接调用)和非缓存函数(用于从其他智能合约调用)是很有用的。 为了做到这一点,我们需要继续依赖 Solidity 机制来调用正确的函数,而不是将所有内容放在一个 feedback
函数(opens in a new tab)中。 这样做可以大大简化可组合性的实现。 在大多数情况下,一个字节就足够标识函数了,所以我们浪费了三个字节(16*3= 48 单位燃料)。 然而,就我撰写此文时而言,这 48 单位燃料的成本为 0.07 美分,对于更简单、错误更少的代码而言,这是合理的成本。
1 // First value, add it to the cache2 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 // Second call, we can use the cache2 _callInput = bytes.concat(3 FOUR_PARAMS,45 // First value in the Cache6 bytes1(0x01),复制
小于 16 的缓存键只占用一个字节。
1 // Second value, don't add it to the cache2 cache.DONT_CACHE(),3 bytes32(VAL_B),45 // Third and fourth values, same value6 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 // Test encodeVal when the key is more than a single byte2 // Maximum three bytes because filling the cache to four bytes takes3 // too long.4 function testEncodeValBig() public {5 // Put a number of values in the cache.6 // To keep things simple, use key n for value n.7 for(uint i=1; i<0x1FFF; i++) {8 cache.cacheWrite(i);9 }显示全部复制
上述 testEncodeVal
函数只将四个值写入缓存中,因此并未检查处理多字节值的函数部分(opens in a new tab)。 但是那段代码很复杂且容易出错。
该函数的第一部分是一个循环,这个循环将从 1 到 0x1FFF 的所有值按顺序写入缓存,因此我们将能够对这些值进行编码并知道这些值的去向。
1 .2 .3 .45 _callInput = bytes.concat(6 FOUR_PARAMS,7 cache.encodeVal(0x000F), // One byte 0x0F8 cache.encodeVal(0x0010), // Two bytes 0x10109 cache.encodeVal(0x0100), // Two bytes 0x110010 cache.encodeVal(0x1000) // Three bytes 0x20100011 );显示全部复制
测试一个字节、两个字节和三个字节的值。 我们没有测试更多字节的值,因为编写足够的堆栈条目将需要很长时间(至少 0x10000000 个,约为 2.5 亿个)。
1 .2 .3 .4 .5 } // testEncodeValBig678 // Test what with an excessively small buffer we get a revert9 function testShortCalldata() public {显示全部复制
测试在参数不足的异常情况下会发生什么。
1 .2 .3 .4 (_success, _callOutput) = _cacheAddr.call(_callInput);5 assertEq(_success, false);6 } // testShortCalldata复制
由于发生了回滚,我们应该得到的结果是 false
。
1 // Call with cache keys that aren't there2 function testNoCacheKey() public {3 .4 .5 .6 _callInput = bytes.concat(7 FOUR_PARAMS,89 // First value, add it to the cache10 cache.INTO_CACHE(),11 bytes32(VAL_A),1213 // Second value14 bytes1(0x0F),15 bytes2(0x1234),16 bytes11(0xA10102030405060708090A)17 );显示全部
该函数获取四个完全合法的参数,但是缓存是空的,因此没有值可供读取。
1 .2 .3 .4 // Test what with an excessively long buffer everything works file5 function testLongCalldata() public {6 address _cacheAddr = address(cache);7 bool _success;8 bytes memory _callInput;9 bytes memory _callOutput;1011 // First call, the cache is empty12 _callInput = bytes.concat(13 FOUR_PARAMS,1415 // First value, add it to the cache16 cache.INTO_CACHE(), bytes32(VAL_A),1718 // Second value, add it to the cache19 cache.INTO_CACHE(), bytes32(VAL_B),2021 // Third value, add it to the cache22 cache.INTO_CACHE(), bytes32(VAL_C),2324 // Fourth value, add it to the cache25 cache.INTO_CACHE(), bytes32(VAL_D),2627 // And another value for "good luck"28 bytes4(0x31112233)29 );显示全部复制
该函数发送五个值。 我们知道第五个值因为不是有效缓存条目而被忽略,但若未包含该值,将会导致回滚。
1 (_success, _callOutput) = _cacheAddr.call(_callInput);2 assertEq(_success, true);3 .4 .5 .6 } // testLongCalldata78} // CacheTest9显示全部复制
一个应用示例
使用 Solidity 语言来编写测试非常重要,但最终,一个去中心化应用程序需要能够处理来自链外的请求才能发挥其实用性。 本文演示了如何在去中心化应用程序中使用缓存,其中使用了 WORM
(Write Once, Read Many,写入一次,读取多次)的概念。 如果一个键尚未被写入,你可以向其写入一个值。 如果该键已被写入,你可以进行回滚。
合约
这就是合约(opens in a new tab)。 这主要重复了我们已经完成的 Cache
和 CacheTest
部分内容,因此我们只涵盖有趣的部分。
1import "./Cache.sol";23contract 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 // Make it easier to call us2 // Function signature for writeEntryCached(), courtesy of3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d34 bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;复制
由于我们并未遵循应用程序二进制接口规范,调用 writeEntryCached
的外部代码需要手动构建 calldata,而不是使用 worm.writeEntryCached
。 使用此常量值只是为了更方便地进行编写。
请注意,尽管我们将 WRITE_ENTRY_CACHED
定义为一个状态变量,但要在外部读取它,必须使用它的 getter 函数,即 worm.WRITE_ENTRY_CACHED()
。
1 function readEntry(uint key) public view2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)复制
Read 函数是一个 view
函数,因此它不需要进行交易,并且不会消耗燃料。 因此,对于该参数来说,使用缓存没有任何好处。 对于 view 函数,最好使用更简单的标准机制。
测试代码
这是合约的测试代码(opens in a new tab)。 同样,让我们只关注有趣的部分。
1 function testWReadWrite() public {2 worm.writeEntry(0xDEAD, 0x60A7);34 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);23 .4 .5 .67 _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)。 要使用该代码:
克隆 git 存储库:
1git clone https://github.com/qbzzt/20220915-all-you-can-cache.git安装必要的软件包:
1cd javascript2yarn复制配置文件:
1cp .env.example .env编辑
.env
文件以进行配置:参数 值 MNEMONIC 持有足够以太币来支付交易费用的帐户的助记词。 你可以在此处免费获取 Optimism Goerli 网络的以太币(opens in a new tab)。 OPTIMISM_GOERLI_URL Optimism Goerli 的 URL。 公共端点 https://goerli.optimism.io
存在速率限制,但能够满足我们此处的需求运行
index.js
。1node index.js这个示例应用程序首先将一个条目写入到 WORM,在 Etherscan 上显示 calldata 以及交易链接。 然后它会读回该条目,并显示它使用的键以及条目中的值(值、区块编号和作者)。
大多数客户端是普通的去中心化应用程序 JavaScript。 因此,我们只会介绍有趣的部分。
1.2.3.4const main = async () => {5 const func = await worm.WRITE_ENTRY_CACHED()67 // Need a new key every time8 const key = await worm.encodeVal(Number(new Date()))
每个时隙只能被写入一次,因此我们使用时间戳来确保不重复使用时隙。
1const val = await worm.encodeVal("0x600D")23// Write an entry4const calldata = func + key.slice(2) + val.slice(2)
Ethers 期望调用数据是一个十六进制字符串,即以 0x
开头,后跟偶数个十六进制数字。 由于 key
和 val
都以 0x
开头,我们需要去除这些头部信息。
1const tx = await worm.populateTransaction.writeEntryCached()2tx.data = calldata34sentTx = await wallet.sendTransaction(tx)
与 Solidity 的测试代码一样,我们无法正常调用缓存函数。 我们需要改用一个更低级别的机制。
1 .2 .3 .4 // Read the entry just written5 const realKey = '0x' + key.slice(4) // remove the FF flag6 const entryRead = await worm.readEntry(realKey)7 .8 .9 .显示全部
对于读取条目,我们可以使用普通的机制。 在使用 view
函数时,不需要使用参数缓存。
结论
本文中的代码是概念验证,其目的是使想法易于理解。 对于一个生产就绪系统,你可能希望实现一些额外的功能:
处理不是
uint256
类型的值。 例如,字符串。除了使用全局缓存,也可以建立用户与缓存之间的映射。 不同用户使用不同的值。
用于地址的数值与用于其他目的的数值是不同的。 为地址单独创建一个缓存可能是有意义的。
当前的缓存键采用的是“先来者,得最小键”算法。 前 16 个值可以作为单个字节发送。 接下来的 4080 个值以两个字节发送。 接下来的大约一百万个值是以三个字节发送。 生产系统应该在缓存条目上保留使用计数器,并重新组织它们,使得 16 个最常见的值使用一个字节,接下来的 4080 个最常见的值使用两个字节,依此类推。
然而,这是一个潜在的危险操作。 设想以下事件序列:
Noam Naive 调用
encodeVal
函数来对他想要向其中发送代币的地址进行编码。 该地址是应用程序中使用的最早一批地址之一,因此编码值为 0x06。 这是一个view
函数,而不是一个交易,发生于 Noam 和他使用的节点之间,而其他人则对此毫不知情。Owen Owner 运行缓存重排序操作。 实际上,很少有人会使用那个地址,所以现在它被编码为 0x201122。 另一个数值,1018,被赋值为 0x06。
Noam Naive 将他的代币发送到了 0x06 地址。 它们被发送到地址
0x0000000000000000000000000de0b6b3a7640000
,由于没有人知道该地址的私钥,这些代币将永远留在那里。 Noam 不开心。
虽然有多种方法可以解决此问题,以及解决与缓存重新排序期间内存池中的交易相关的问题,但你必须对此有所了解。
由于我是 Optimism 的员工,这是我最熟悉的卷叠,所以这里我展示了使用 Optimism 进行缓存。 但是,对于任何内部处理费用较低的卷叠方案,这个方法应该是有效的,因为相比之下,将交易数据写入一层网络是主要费用。
上次修改时间: @nhsz(opens in a new tab), 2023年8月15日