跳转至主要内容

一切皆可缓存

二层网络缓存storage
中级
Ori Pomerantz
2022年9月15日
31 分钟阅读 minute read

当使用卷叠时,交易中一个字节的成本比一个存储插槽的成本要高得多。 因此,在链上缓存尽可能多的信息是有意义的。

在本文中,你将学习如何创建和使用缓存合约,使得任何可能被多次使用的参数值都会被缓存,并且(在第一次使用之后)可通过更少的字节数来使用,并学习如何编写使用此缓存的链下代码。

如果你想跳过这篇文章,直接查看源代码,参见此处(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 燃料,这样我们将此操作变为可选操作。

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

一个在值和其键之间的映射(opens in a new tab)。 在发送交易之前,对值进行编码时需要这些信息。

1 // Location n has the value for key n+1, because we need to preserve
2 // 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 already
2 // Only public to enable the test to work
3 function cacheWrite(uint _value) public returns (uint) {
4 // If the value is already in the cache, return the current key
5 if (val2key[_value] != 0) {
6 return val2key[_value];
7 }
复制

在缓存中多次存储相同的值是没有意义的。 如果值已经存在,只需返回现有的键。

1 // Since 0xFE is a special case, the largest key the cache can
2 // hold is 0x0D followed by 15 0xFF's. If the cache length is already that
3 // large, fail.
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 // Write the value using the next key
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 之前停止,这种方法仍然有效,因为在以太坊虚拟机中,未初始化的空间被视为零。

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

我们并不一定需要一个 32 字节的值。 这将消除多余的字节。

1 return _retVal;
2 } // _calldataVal
3
4
5 // Read a single parameter from the calldata, starting at _fromByte
6 function _readParam(uint _fromByte) internal
7 returns (uint _nextByte, uint _parameterValue)
8 {
复制

从 calldata 中读取单个参数。 请注意,我们需要返回的不仅仅是我们读取的值,还包括下一个字节的位置,因为参数的长度可以从 1 个字节到 33 个字节不等。

1 // The first byte tells us how to interpret the rest
2 uint8 _firstByte;
3
4 _firstByte = uint8(_calldataVal(_fromByte, 1));
复制

Solidity 试图通过禁止可能危险的隐式类型转换(opens in a new tab)来减少错误的数量。 降级操作,例如从 256 位降级为 8 位,需要为显式。

1
2 // Read the value, but do not write it to the cache
3 if (_firstByte == uint8(DONT_CACHE))
4 return(_fromByte+33, _calldataVal(_fromByte+1, 32));
5
6 // Read the value, and write it to the cache
7 if (_firstByte == uint8(INTO_CACHE)) {
8 uint _param = _calldataVal(_fromByte+1, 32);
9 cacheWrite(_param);
10 return(_fromByte+33, _param);
11 }
12
13 // If we got here it means that we need to read from the cache
14
15 // Number of extra bytes to read
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 // Read n parameters (functions know how many parameters they expect)
10 function _readParams(uint _paramNum) internal returns (uint[] memory) {
显示全部
复制

我们可以从 calldata 中获取我们拥有的参数数量,但是调用我们的函数知道它们期望的参数数量。 让这些函数告诉我们会更容易一些。

1 // The parameters we read
2 uint[] memory params = new uint[](_paramNum);
3
4 // Parameters start at byte 4, before that it's the function signature
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 // For testing _readParams, test reading four parameters
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 // 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];
2
3 // The value isn't in the cache yet, add it
4 if (_key == 0)
5 return bytes.concat(INTO_CACHE, bytes32(_val));
复制

以太坊虚拟机中,所有未初始化的存储被假定为零。 因此,如果我们在查找一个不存在的值的键时,会得到一个零。 在这种情况下,对其进行编码的字节为 INTO_CACHE(这样下次将被缓存),接着是实际的值。

1 // If the key is <0x10, return it as a single byte
2 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 0x1vvv
2 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 and
3 // simplicity.
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 // If we get here, something is wrong.
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// 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;
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 // Cache the same value multiple times, ensure that the key stays
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 // Read a uint from a memory buffer (to make sure we get back the parameters
2 // we sent out)
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 // Function signature for fourParams(), courtesy of
6 // https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
7 bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
8
9 // Just some constant values to see we're getting the correct values back
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;
复制

我们无法使用普通的应用程序二进制接口机制来调用使用缓存的函数,因此我们需要使用低级别的 <address>.call()(opens in a new tab) 机制。 该机制接受一个 bytes memory 类型的输入,并将其作为输出返回(同时返回一个布尔值)。

1 // First call, the cache is empty
2 _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 cache
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 // Second call, we can use the cache
2 _callInput = bytes.concat(
3 FOUR_PARAMS,
4
5 // First value in the Cache
6 bytes1(0x01),
复制

小于 16 的缓存键只占用一个字节。

1 // Second value, don't add it to the cache
2 cache.DONT_CACHE(),
3 bytes32(VAL_B),
4
5 // Third and fourth values, same value
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 // Test encodeVal when the key is more than a single byte
2 // Maximum three bytes because filling the cache to four bytes takes
3 // 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 .
4
5 _callInput = bytes.concat(
6 FOUR_PARAMS,
7 cache.encodeVal(0x000F), // One byte 0x0F
8 cache.encodeVal(0x0010), // Two bytes 0x1010
9 cache.encodeVal(0x0100), // Two bytes 0x1100
10 cache.encodeVal(0x1000) // Three bytes 0x201000
11 );
显示全部
复制

测试一个字节、两个字节和三个字节的值。 我们没有测试更多字节的值,因为编写足够的堆栈条目将需要很长时间(至少 0x10000000 个,约为 2.5 亿个)。

1 .
2 .
3 .
4 .
5 } // testEncodeValBig
6
7
8 // Test what with an excessively small buffer we get a revert
9 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 there
2 function testNoCacheKey() public {
3 .
4 .
5 .
6 _callInput = bytes.concat(
7 FOUR_PARAMS,
8
9 // First value, add it to the cache
10 cache.INTO_CACHE(),
11 bytes32(VAL_A),
12
13 // Second value
14 bytes1(0x0F),
15 bytes2(0x1234),
16 bytes11(0xA10102030405060708090A)
17 );
显示全部

该函数获取四个完全合法的参数,但是缓存是空的,因此没有值可供读取。

1 .
2 .
3 .
4 // Test what with an excessively long buffer everything works file
5 function testLongCalldata() public {
6 address _cacheAddr = address(cache);
7 bool _success;
8 bytes memory _callInput;
9 bytes memory _callOutput;
10
11 // First call, the cache is empty
12 _callInput = bytes.concat(
13 FOUR_PARAMS,
14
15 // First value, add it to the cache
16 cache.INTO_CACHE(), bytes32(VAL_A),
17
18 // Second value, add it to the cache
19 cache.INTO_CACHE(), bytes32(VAL_B),
20
21 // Third value, add it to the cache
22 cache.INTO_CACHE(), bytes32(VAL_C),
23
24 // Fourth value, add it to the cache
25 cache.INTO_CACHE(), bytes32(VAL_D),
26
27 // And another value for "good luck"
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(Write Once, Read Many,写入一次,读取多次)的概念。 如果一个键尚未被写入,你可以向其写入一个值。 如果该键已被写入,你可以进行回滚。

合约

这就是合约(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 // Make it easier to call us
2 // Function signature for writeEntryCached(), courtesy of
3 // https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
4 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 view
2 returns (uint _value, address _writtenBy, uint _writtenAtBlock)
复制

Read 函数是一个 view 函数,因此它不需要进行交易,并且不会消耗燃料。 因此,对于该参数来说,使用缓存没有任何好处。 对于 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持有足够以太币来支付交易费用的帐户的助记词。 你可以在此处免费获取 Optimism Goerli 网络的以太币(opens in a new tab)
    OPTIMISM_GOERLI_URLOptimism Goerli 的 URL。 公共端点 https://goerli.optimism.io 存在速率限制,但能够满足我们此处的需求
  5. 运行 index.js

    1node index.js

    这个示例应用程序首先将一个条目写入到 WORM,在 Etherscan 上显示 calldata 以及交易链接。 然后它会读回该条目,并显示它使用的键以及条目中的值(值、区块编号和作者)。

大多数客户端是普通的去中心化应用程序 JavaScript。 因此,我们只会介绍有趣的部分。

1.
2.
3.
4const main = async () => {
5 const func = await worm.WRITE_ENTRY_CACHED()
6
7 // Need a new key every time
8 const key = await worm.encodeVal(Number(new Date()))

每个时隙只能被写入一次,因此我们使用时间戳来确保不重复使用时隙。

1const val = await worm.encodeVal("0x600D")
2
3// Write an entry
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 // Read the entry just written
5 const realKey = '0x' + key.slice(4) // remove the FF flag
6 const entryRead = await worm.readEntry(realKey)
7 .
8 .
9 .
显示全部

对于读取条目,我们可以使用普通的机制。 在使用 view 函数时,不需要使用参数缓存。

结论

本文中的代码是概念验证,其目的是使想法易于理解。 对于一个生产就绪系统,你可能希望实现一些额外的功能:

  • 处理不是 uint256 类型的值。 例如,字符串。

  • 除了使用全局缓存,也可以建立用户与缓存之间的映射。 不同用户使用不同的值。

  • 用于地址的数值与用于其他目的的数值是不同的。 为地址单独创建一个缓存可能是有意义的。

  • 当前的缓存键采用的是“先来者,得最小键”算法。 前 16 个值可以作为单个字节发送。 接下来的 4080 个值以两个字节发送。 接下来的大约一百万个值是以三个字节发送。 生产系统应该在缓存条目上保留使用计数器,并重新组织它们,使得 16 个最常见的值使用一个字节,接下来的 4080 个最常见的值使用两个字节,依此类推。

    然而,这是一个潜在的危险操作。 设想以下事件序列:

    1. Noam Naive 调用 encodeVal 函数来对他想要向其中发送代币的地址进行编码。 该地址是应用程序中使用的最早一批地址之一,因此编码值为 0x06。 这是一个 view 函数,而不是一个交易,发生于 Noam 和他使用的节点之间,而其他人则对此毫不知情。

    2. Owen Owner 运行缓存重排序操作。 实际上,很少有人会使用那个地址,所以现在它被编码为 0x201122。 另一个数值,1018,被赋值为 0x06。

    3. Noam Naive 将他的代币发送到了 0x06 地址。 它们被发送到地址 0x0000000000000000000000000de0b6b3a7640000,由于没有人知道该地址的私钥,这些代币将永远留在那里。 Noam 不开心

    虽然有多种方法可以解决此问题,以及解决与缓存重新排序期间内存池中的交易相关的问题,但你必须对此有所了解。

由于我是 Optimism 的员工,这是我最熟悉的卷叠,所以这里我展示了使用 Optimism 进行缓存。 但是,对于任何内部处理费用较低的卷叠方案,这个方法应该是有效的,因为相比之下,将交易数据写入一层网络是主要费用。

上次修改时间: @nhsz(opens in a new tab), 2023年8月15日

本教程对你有帮助吗?