Tất cả những gì bạn có thể lưu vào bộ nhớ đệm
Khi sử dụng các bản cuộn, chi phí của một byte trong giao dịch đắt hơn rất nhiều so với chi phí của một khe lưu trữ. Do đó, việc lưu vào bộ nhớ đệm càng nhiều thông tin càng tốt trên chuỗi là điều hợp lý.
Trong bài viết này, bạn sẽ tìm hiểu cách tạo và sử dụng một hợp đồng bộ nhớ đệm theo cách mà bất kỳ giá trị tham số nào có khả năng được sử dụng nhiều lần sẽ được lưu vào bộ nhớ đệm và có sẵn để sử dụng (sau lần đầu tiên) với số lượng byte nhỏ hơn nhiều, cũng như cách viết mã ngoài chuỗi sử dụng bộ nhớ đệm này.
Nếu bạn muốn bỏ qua bài viết và chỉ xem mã nguồn, nó ở đây (opens in a new tab). Ngăn xếp phát triển là Foundry (opens in a new tab).
Thiết kế tổng thể
Để đơn giản, chúng ta sẽ giả định tất cả các tham số giao dịch là uint256, dài 32 byte. Khi nhận được một giao dịch, chúng ta sẽ phân tích cú pháp từng tham số như sau:
-
Nếu byte đầu tiên là
0xFF, hãy lấy 32 byte tiếp theo làm giá trị tham số và ghi nó vào bộ nhớ đệm. -
Nếu byte đầu tiên là
0xFE, hãy lấy 32 byte tiếp theo làm giá trị tham số nhưng không ghi nó vào bộ nhớ đệm. -
Đối với bất kỳ giá trị nào khác, hãy lấy bốn bit trên cùng làm số lượng byte bổ sung và bốn bit dưới cùng làm các bit quan trọng nhất của khóa bộ nhớ đệm. Dưới đây là một số ví dụ:
Các byte trong dữ liệu lệnh gọi Khóa bộ nhớ đệm 0x0F 0x0F 0x10,0x10 0x10 0x12,0xAC 0x02AC 0x2D,0xEA, 0xD6 0x0DEAD6
Thao tác với bộ nhớ đệm
Bộ nhớ đệm được triển khai trong Cache.sol (opens in a new tab). Hãy cùng xem xét từng dòng.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Cache {
bytes1 public constant INTO_CACHE = 0xFF;
bytes1 public constant DONT_CACHE = 0xFE;
Các hằng số này được sử dụng để diễn giải các trường hợp đặc biệt khi chúng ta cung cấp tất cả thông tin và muốn ghi nó vào bộ nhớ đệm hay không. Việc ghi vào bộ nhớ đệm yêu cầu hai thao tác SSTORE (opens in a new tab) vào các khe lưu trữ chưa được sử dụng trước đó với chi phí là 22100 Gas mỗi thao tác, vì vậy chúng ta làm cho nó trở thành tùy chọn.
mapping(uint => uint) public val2key;
Một ánh xạ (opens in a new tab) giữa các giá trị và khóa của chúng. Thông tin này là cần thiết để mã hóa các giá trị trước khi bạn gửi giao dịch.
// Vị trí n có giá trị cho khóa n+1, bởi vì chúng ta cần giữ lại
// số 0 có nghĩa là "không có trong bộ nhớ đệm".
uint[] public key2val;
Chúng ta có thể sử dụng một mảng cho việc ánh xạ từ khóa sang giá trị vì chúng ta gán các khóa và để đơn giản, chúng ta thực hiện việc đó một cách tuần tự.
function cacheRead(uint _key) public view returns (uint) {
require(_key <= key2val.length, "Reading uninitialize cache entry");
return key2val[_key-1];
} // cacheRead
Đọc một giá trị từ bộ nhớ đệm.
// Ghi một giá trị vào bộ nhớ đệm nếu nó chưa có ở đó
// Chỉ để ở chế độ public để cho phép thử nghiệm hoạt động
function cacheWrite(uint _value) public returns (uint) {
// Nếu giá trị đã có trong bộ nhớ đệm, trả về khóa hiện tại
if (val2key[_value] != 0) {
return val2key[_value];
}
Không có lý do gì để đưa cùng một giá trị vào bộ nhớ đệm nhiều lần. Nếu giá trị đã có ở đó, chỉ cần trả về khóa hiện có.
// Vì 0xFE là một trường hợp đặc biệt, khóa lớn nhất mà bộ nhớ đệm có thể
// chứa là 0x0D theo sau là 15 giá trị 0xFF. Nếu độ dài bộ nhớ đệm đã đạt mức đó
// thì thất bại.
// 1 2 3 4 5 6 7 8 9 A B C D E F
require(key2val.length+1 < 0x0DFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,
"cache overflow");
Tôi không nghĩ chúng ta sẽ bao giờ có một bộ nhớ đệm lớn như vậy (khoảng 1.8*1037 mục nhập, sẽ cần khoảng 1027 TB để lưu trữ). Tuy nhiên, tôi đủ già để nhớ câu nói "640kB sẽ luôn là đủ" (opens in a new tab). Bài kiểm tra này rất rẻ.
// Ghi giá trị bằng khóa tiếp theo
val2key[_value] = key2val.length+1;
Thêm tra cứu ngược (từ giá trị sang khóa).
key2val.push(_value);
Thêm tra cứu xuôi (từ khóa sang giá trị). Vì chúng ta gán các giá trị một cách tuần tự, chúng ta chỉ cần thêm nó vào sau giá trị mảng cuối cùng.
return key2val.length;
} // cacheWrite
Trả về độ dài mới của key2val, đây là ô nơi giá trị mới được lưu trữ.
function _calldataVal(uint startByte, uint length)
private pure returns (uint)
Hàm này đọc một giá trị từ dữ liệu lệnh gọi có độ dài tùy ý (lên đến 32 byte, kích thước từ).
{
uint _retVal;
require(length < 0x21,
"_calldataVal length limit is 32 bytes");
require(length + startByte <= msg.data.length,
"_calldataVal trying to read beyond calldatasize");
Hàm này là nội bộ (internal), vì vậy nếu phần còn lại của mã được viết chính xác thì các bài kiểm tra này không bắt buộc. Tuy nhiên, chúng không tốn nhiều chi phí nên chúng ta cũng có thể giữ chúng.
assembly {
_retVal := calldataload(startByte)
}
Mã này được viết bằng Yul (opens in a new tab). Nó đọc một giá trị 32 byte từ dữ liệu lệnh gọi. Điều này hoạt động ngay cả khi dữ liệu lệnh gọi dừng trước startByte+32 vì không gian chưa được khởi tạo trong EVM được coi là bằng không.
_retVal = _retVal >> (256-length*8);
Chúng ta không nhất thiết muốn một giá trị 32 byte. Điều này giúp loại bỏ các byte thừa.
return _retVal;
} // _calldataVal
// Đọc một tham số duy nhất từ dữ liệu lệnh gọi, bắt đầu tại _fromByte
function _readParam(uint _fromByte) internal
returns (uint _nextByte, uint _parameterValue)
{
Đọc một tham số duy nhất từ dữ liệu lệnh gọi. Lưu ý rằng chúng ta cần trả về không chỉ giá trị chúng ta đã đọc, mà còn cả vị trí của byte tiếp theo vì các tham số có thể có độ dài từ 1 byte đến 33 byte.
// Byte đầu tiên cho chúng ta biết cách diễn giải phần còn lại
uint8 _firstByte;
_firstByte = uint8(_calldataVal(_fromByte, 1));
Solidity cố gắng giảm số lượng lỗi bằng cách cấm các chuyển đổi kiểu ngầm định (opens in a new tab) có khả năng gây nguy hiểm. Việc hạ cấp, ví dụ từ 256 bit xuống 8 bit, cần phải rõ ràng.
// Đọc giá trị, nhưng không ghi nó vào bộ nhớ đệm
if (_firstByte == uint8(DONT_CACHE))
return(_fromByte+33, _calldataVal(_fromByte+1, 32));
// Đọc giá trị và ghi nó vào bộ nhớ đệm
if (_firstByte == uint8(INTO_CACHE)) {
uint _param = _calldataVal(_fromByte+1, 32);
cacheWrite(_param);
return(_fromByte+33, _param);
}
// Nếu chúng ta đến được đây có nghĩa là chúng ta cần đọc từ bộ nhớ đệm
// Số byte bổ sung cần đọc
uint8 _extraBytes = _firstByte / 16;
Lấy nibble (opens in a new tab) thấp hơn và kết hợp nó với các byte khác để đọc giá trị từ bộ nhớ đệm.
uint _key = (uint256(_firstByte & 0x0F) << (8*_extraBytes)) +
_calldataVal(_fromByte+1, _extraBytes);
return (_fromByte+_extraBytes+1, cacheRead(_key));
} // _readParam
// Đọc n tham số (các hàm biết chúng mong đợi bao nhiêu tham số)
function _readParams(uint _paramNum) internal returns (uint[] memory) {
Chúng ta có thể lấy số lượng tham số mà chúng ta có từ chính dữ liệu lệnh gọi, nhưng các hàm gọi chúng ta biết chúng mong đợi bao nhiêu tham số. Sẽ dễ dàng hơn nếu để chúng cho chúng ta biết.
// Các tham số chúng ta đọc
uint[] memory params = new uint[](_paramNum);
// Các tham số bắt đầu ở byte 4, trước đó là chữ ký hàm
uint _atByte = 4;
for(uint i=0; i<_paramNum; i++) {
(_atByte, params[i]) = _readParam(_atByte);
}
Đọc các tham số cho đến khi bạn có đủ số lượng cần thiết. Nếu chúng ta vượt qua phần cuối của dữ liệu lệnh gọi, _readParams sẽ hoàn nguyên lệnh gọi.
return(params);
} // readParams
// Để thử nghiệm _readParams, hãy thử nghiệm đọc bốn tham số
function fourParam() public
returns (uint256,uint256,uint256,uint256)
{
uint[] memory params;
params = _readParams(4);
return (params[0], params[1], params[2], params[3]);
} // fourParam
Một lợi thế lớn của Foundry là nó cho phép viết các bài kiểm tra bằng Solidity (xem phần Kiểm tra bộ nhớ đệm bên dưới). Điều này làm cho các bài kiểm tra đơn vị (unit test) dễ dàng hơn rất nhiều. Đây là một hàm đọc bốn tham số và trả về chúng để bài kiểm tra có thể xác minh chúng đã chính xác.
// Lấy một giá trị, trả về các byte sẽ mã hóa nó (sử dụng bộ nhớ đệm nếu có thể)
function encodeVal(uint _val) public view returns(bytes memory) {
encodeVal là một hàm mà mã ngoài chuỗi gọi để giúp tạo dữ liệu lệnh gọi sử dụng bộ nhớ đệm. Nó nhận một giá trị duy nhất và trả về các byte mã hóa nó. Hàm này là một view, vì vậy nó không yêu cầu giao dịch và khi được gọi từ bên ngoài sẽ không tốn bất kỳ Gas nào.
uint _key = val2key[_val];
// Giá trị chưa có trong bộ nhớ đệm, hãy thêm nó vào
if (_key == 0)
return bytes.concat(INTO_CACHE, bytes32(_val));
Trong EVM, tất cả bộ nhớ chưa được khởi tạo được giả định là các số không. Vì vậy, nếu chúng ta tìm kiếm khóa cho một giá trị không có ở đó, chúng ta sẽ nhận được số không. Trong trường hợp đó, các byte mã hóa nó là INTO_CACHE (để nó sẽ được lưu vào bộ nhớ đệm vào lần tới), theo sau là giá trị thực tế.
// Nếu khóa <0x10, trả về nó dưới dạng một byte duy nhất
if (_key < 0x10)
return bytes.concat(bytes1(uint8(_key)));
Các byte đơn là dễ nhất. Chúng ta chỉ cần sử dụng bytes.concat (opens in a new tab) để biến một kiểu bytes<n> thành một mảng byte có thể có độ dài bất kỳ. Bất chấp cái tên, nó hoạt động tốt khi chỉ được cung cấp một đối số.
// Giá trị hai byte, được mã hóa thành 0x1vvv
if (_key < 0x1000)
return bytes.concat(bytes2(uint16(_key) | 0x1000));
Khi chúng ta có một khóa nhỏ hơn 163, chúng ta có thể biểu diễn nó bằng hai byte. Đầu tiên, chúng ta chuyển đổi _key, là một giá trị 256 bit, thành một giá trị 16 bit và sử dụng toán tử OR logic để thêm số lượng byte bổ sung vào byte đầu tiên. Sau đó, chúng ta chỉ cần đưa nó vào một giá trị bytes2, giá trị này có thể được chuyển đổi thành bytes.
// Có lẽ có một cách thông minh để thực hiện các dòng sau dưới dạng một vòng lặp,
// nhưng đây là một hàm view nên tôi đang tối ưu hóa cho thời gian của lập trình viên và
// sự đơn giản.
if (_key < 16*256**2)
return bytes.concat(bytes3(uint24(_key) | (0x2 * 16 * 256**2)));
if (_key < 16*256**3)
return bytes.concat(bytes4(uint32(_key) | (0x3 * 16 * 256**3)));
.
.
.
if (_key < 16*256**14)
return bytes.concat(bytes15(uint120(_key) | (0xE * 16 * 256**14)));
if (_key < 16*256**15)
return bytes.concat(bytes16(uint128(_key) | (0xF * 16 * 256**15)));
Các giá trị khác (3 byte, 4 byte, v.v.) được xử lý theo cùng một cách, chỉ với các kích thước trường khác nhau.
// Nếu chúng ta đến được đây, có điều gì đó không ổn.
revert("Error in encodeVal, should not happen");
Nếu chúng ta đến được đây, điều đó có nghĩa là chúng ta đã nhận được một khóa không nhỏ hơn 16*25615. Nhưng cacheWrite giới hạn các khóa nên chúng ta thậm chí không thể đạt tới 14*25616 (sẽ có byte đầu tiên là 0xFE, vì vậy nó sẽ trông giống như DONT_CACHE). Nhưng không tốn nhiều chi phí để thêm một bài kiểm tra trong trường hợp một lập trình viên trong tương lai đưa ra một lỗi.
} // encodeVal
} // Cache
Kiểm tra bộ nhớ đệm
Một trong những lợi thế của Foundry là nó cho phép bạn viết các bài kiểm tra bằng Solidity (opens in a new tab), điều này giúp việc viết các bài kiểm tra đơn vị dễ dàng hơn. Các bài kiểm tra cho lớp Cache ở đây (opens in a new tab). Vì mã kiểm tra có tính lặp đi lặp lại, như các bài kiểm tra thường vậy, bài viết này chỉ giải thích những phần thú vị.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
// Cần chạy `forge test -vv` cho bảng điều khiển.
import "forge-std/console.sol";
Đây chỉ là mã soạn sẵn (boilerplate) cần thiết để sử dụng gói kiểm tra và console.log.
import "src/Cache.sol";
Chúng ta cần biết hợp đồng mà chúng ta đang kiểm tra.
contract CacheTest is Test {
Cache cache;
function setUp() public {
cache = new Cache();
}
Hàm setUp được gọi trước mỗi bài kiểm tra. Trong trường hợp này, chúng ta chỉ tạo một bộ nhớ đệm mới, để các bài kiểm tra của chúng ta sẽ không ảnh hưởng lẫn nhau.
function testCaching() public {
Các bài kiểm tra là các hàm có tên bắt đầu bằng test. Hàm này kiểm tra chức năng bộ nhớ đệm cơ bản, ghi các giá trị và đọc lại chúng.
for(uint i=1; i<5000; i++) {
cache.cacheWrite(i*i);
}
for(uint i=1; i<5000; i++) {
assertEq(cache.cacheRead(i), i*i);
Đây là cách bạn thực hiện kiểm tra thực tế, sử dụng các hàm assert... (opens in a new tab). Trong trường hợp này, chúng ta kiểm tra xem giá trị chúng ta đã ghi có phải là giá trị chúng ta đã đọc hay không. Chúng ta có thể loại bỏ kết quả của cache.cacheWrite vì chúng ta biết rằng các khóa bộ nhớ đệm được gán tuyến tính.
}
} // testCaching
// Lưu vào bộ nhớ đệm cùng một giá trị nhiều lần, đảm bảo rằng khóa vẫn
// giữ nguyên
function testRepeatCaching() public {
for(uint i=1; i<100; i++) {
uint _key1 = cache.cacheWrite(i);
uint _key2 = cache.cacheWrite(i);
assertEq(_key1, _key2);
}
Đầu tiên, chúng ta ghi mỗi giá trị hai lần vào bộ nhớ đệm và đảm bảo các khóa giống nhau (có nghĩa là lần ghi thứ hai không thực sự xảy ra).
for(uint i=1; i<100; i+=3) {
uint _key = cache.cacheWrite(i);
assertEq(_key, i);
}
} // testRepeatCaching
Về lý thuyết, có thể có một lỗi không ảnh hưởng đến các lần ghi bộ nhớ đệm liên tiếp. Vì vậy, ở đây chúng ta thực hiện một số lần ghi không liên tiếp và thấy các giá trị vẫn không bị ghi lại.
// Đọc một uint từ bộ đệm bộ nhớ (để đảm bảo chúng ta nhận lại các tham số
// mà chúng ta đã gửi đi)
function toUint256(bytes memory _bytes, uint256 _start) internal pure
returns (uint256)
Đọc một từ 256 bit từ bộ đệm bytes memory. Hàm tiện ích này cho phép chúng ta xác minh rằng chúng ta nhận được kết quả chính xác khi chạy một lệnh gọi hàm sử dụng bộ nhớ đệm.
{
require(_bytes.length >= _start + 32, "toUint256_outOfBounds");
uint256 tempUint;
assembly {
tempUint := mload(add(add(_bytes, 0x20), _start))
}
Yul không hỗ trợ các cấu trúc dữ liệu ngoài uint256, vì vậy khi bạn tham chiếu đến một cấu trúc dữ liệu phức tạp hơn, chẳng hạn như bộ đệm bộ nhớ _bytes, bạn sẽ nhận được địa chỉ của cấu trúc đó. Solidity lưu trữ các giá trị bytes memory dưới dạng một từ 32 byte chứa độ dài, theo sau là các byte thực tế, vì vậy để lấy số byte _start, chúng ta cần tính toán _bytes+32+_start.
return tempUint;
} // toUint256
// Chữ ký hàm cho fourParams(), được cung cấp bởi
// https://www.4byte.directory/signatures/?bytes4_signature=0x3edc1e6d
bytes4 constant FOUR_PARAMS = 0x3edc1e6d;
// Chỉ là một số giá trị không đổi để xem chúng ta có nhận lại đúng các giá trị hay không
uint256 constant VAL_A = 0xDEAD60A7;
uint256 constant VAL_B = 0xBEEF;
uint256 constant VAL_C = 0x600D;
uint256 constant VAL_D = 0x600D60A7;
Một số hằng số chúng ta cần để kiểm tra.
function testReadParam() public {
Gọi fourParams(), một hàm sử dụng readParams, để kiểm tra xem chúng ta có thể đọc các tham số một cách chính xác hay không.
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
Chúng ta không thể sử dụng cơ chế ABI thông thường để gọi một hàm sử dụng bộ nhớ đệm, vì vậy chúng ta cần sử dụng cơ chế <address>.call() (opens in a new tab) cấp thấp. Cơ chế đó lấy một bytes memory làm đầu vào và trả về nó (cũng như một giá trị Boolean) làm đầu ra.
// Lần gọi đầu tiên, bộ nhớ đệm trống
_callInput = bytes.concat(
FOUR_PARAMS,
Sẽ rất hữu ích nếu cùng một hợp đồng hỗ trợ cả các hàm được lưu vào bộ nhớ đệm (đối với các lệnh gọi trực tiếp từ các giao dịch) và các hàm không được lưu vào bộ nhớ đệm (đối với các lệnh gọi từ các hợp đồng thông minh khác). Để làm điều đó, chúng ta cần tiếp tục dựa vào cơ chế Solidity để gọi đúng hàm, thay vì đặt mọi thứ vào một hàm fallback (opens in a new tab). Làm điều này giúp khả năng kết hợp dễ dàng hơn rất nhiều. Một byte duy nhất sẽ đủ để xác định hàm trong hầu hết các trường hợp, vì vậy chúng ta đang lãng phí ba byte (16*3=48 Gas). Tuy nhiên, khi tôi viết bài này, 48 Gas đó có giá 0,07 xu, đây là một chi phí hợp lý cho mã đơn giản hơn, ít bị lỗi hơn.
// Giá trị đầu tiên, thêm nó vào bộ nhớ đệm
cache.INTO_CACHE(),
bytes32(VAL_A),
Giá trị đầu tiên: Một cờ cho biết đó là một giá trị đầy đủ cần được ghi vào bộ nhớ đệm, theo sau là 32 byte của giá trị. Ba giá trị còn lại tương tự nhau, ngoại trừ việc VAL_B không được ghi vào bộ nhớ đệm và VAL_C vừa là tham số thứ ba vừa là tham số thứ tư.
.
.
.
);
(_success, _callOutput) = _cacheAddr.call(_callInput);
Đây là nơi chúng ta thực sự gọi hợp đồng Cache.
assertEq(_success, true);
Chúng ta mong đợi lệnh gọi sẽ thành công.
assertEq(cache.cacheRead(1), VAL_A);
assertEq(cache.cacheRead(2), VAL_C);
Chúng ta bắt đầu với một bộ nhớ đệm trống và sau đó thêm VAL_A theo sau là VAL_C. Chúng ta mong đợi cái đầu tiên có khóa 1 và cái thứ hai có khóa 2.
assertEq(toUint256(_callOutput,0), VAL_A);
assertEq(toUint256(_callOutput,32), VAL_B);
assertEq(toUint256(_callOutput,64), VAL_C);
assertEq(toUint256(_callOutput,96), VAL_C);
Đầu ra là bốn tham số. Ở đây chúng ta xác minh nó là chính xác.
// Lần gọi thứ hai, chúng ta có thể sử dụng bộ nhớ đệm
_callInput = bytes.concat(
FOUR_PARAMS,
// Giá trị đầu tiên trong Bộ nhớ đệm
bytes1(0x01),
Các khóa bộ nhớ đệm dưới 16 chỉ là một byte.
// Giá trị thứ hai, không thêm nó vào bộ nhớ đệm
cache.DONT_CACHE(),
bytes32(VAL_B),
// Giá trị thứ ba và thứ tư, cùng một giá trị
bytes1(0x02),
bytes1(0x02)
);
.
.
.
} // testReadParam
Các bài kiểm tra sau lệnh gọi giống hệt với các bài kiểm tra sau lệnh gọi đầu tiên.
function testEncodeVal() public {
Hàm này tương tự như testReadParam, ngoại trừ việc thay vì viết các tham số một cách rõ ràng, chúng ta sử dụng encodeVal().
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(VAL_A),
cache.encodeVal(VAL_B),
cache.encodeVal(VAL_C),
cache.encodeVal(VAL_D)
);
.
.
.
assertEq(_callInput.length, 4+1*4);
} // testEncodeVal
Bài kiểm tra bổ sung duy nhất trong testEncodeVal() là xác minh rằng độ dài của _callInput là chính xác. Đối với lệnh gọi đầu tiên, nó là 4+33*4. Đối với lệnh gọi thứ hai, nơi mọi giá trị đã có trong bộ nhớ đệm, nó là 4+1*4.
// Thử nghiệm encodeVal khi khóa lớn hơn một byte duy nhất
// Tối đa ba byte vì việc lấp đầy bộ nhớ đệm đến bốn byte mất
// quá nhiều thời gian.
function testEncodeValBig() public {
// Đặt một số giá trị vào bộ nhớ đệm.
// Để giữ cho mọi thứ đơn giản, hãy sử dụng khóa n cho giá trị n.
for(uint i=1; i<0x1FFF; i++) {
cache.cacheWrite(i);
}
Hàm testEncodeVal ở trên chỉ ghi bốn giá trị vào bộ nhớ đệm, vì vậy phần của hàm xử lý các giá trị nhiều byte (opens in a new tab) không được kiểm tra. Nhưng mã đó rất phức tạp và dễ xảy ra lỗi.
Phần đầu tiên của hàm này là một vòng lặp ghi tất cả các giá trị từ 1 đến 0x1FFF vào bộ nhớ đệm theo thứ tự, vì vậy chúng ta sẽ có thể mã hóa các giá trị đó và biết chúng sẽ đi đâu.
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
cache.encodeVal(0x000F), // Một byte 0x0F
cache.encodeVal(0x0010), // Hai byte 0x1010
cache.encodeVal(0x0100), // Hai byte 0x1100
cache.encodeVal(0x1000) // Ba byte 0x201000
);
Kiểm tra các giá trị một byte, hai byte và ba byte. Chúng ta không kiểm tra vượt quá mức đó vì sẽ mất quá nhiều thời gian để ghi đủ các mục ngăn xếp (ít nhất là 0x10000000, khoảng một phần tư tỷ).
.
.
.
.
} // testEncodeValBig
// Thử nghiệm xem với một bộ đệm quá nhỏ, chúng ta có nhận được một hoàn nguyên hay không
function testShortCalldata() public {
Kiểm tra điều gì xảy ra trong trường hợp bất thường khi không có đủ tham số.
.
.
.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, false);
} // testShortCalldata
Vì nó hoàn nguyên, kết quả chúng ta sẽ nhận được là false.
// Gọi với các khóa bộ nhớ đệm không có ở đó
function testNoCacheKey() public {
.
.
.
_callInput = bytes.concat(
FOUR_PARAMS,
// Giá trị đầu tiên, thêm nó vào bộ nhớ đệm
cache.INTO_CACHE(),
bytes32(VAL_A),
// Giá trị thứ hai
bytes1(0x0F),
bytes2(0x1234),
bytes11(0xA10102030405060708090A)
);
Hàm này nhận được bốn tham số hoàn toàn hợp lệ, ngoại trừ việc bộ nhớ đệm trống nên không có giá trị nào ở đó để đọc.
.
.
.
// Thử nghiệm xem với một bộ đệm quá dài, mọi thứ có hoạt động ổn không
function testLongCalldata() public {
address _cacheAddr = address(cache);
bool _success;
bytes memory _callInput;
bytes memory _callOutput;
// Lần gọi đầu tiên, bộ nhớ đệm trống
_callInput = bytes.concat(
FOUR_PARAMS,
// Giá trị đầu tiên, thêm nó vào bộ nhớ đệm
cache.INTO_CACHE(), bytes32(VAL_A),
// Giá trị thứ hai, thêm nó vào bộ nhớ đệm
cache.INTO_CACHE(), bytes32(VAL_B),
// Giá trị thứ ba, thêm nó vào bộ nhớ đệm
cache.INTO_CACHE(), bytes32(VAL_C),
// Giá trị thứ tư, thêm nó vào bộ nhớ đệm
cache.INTO_CACHE(), bytes32(VAL_D),
// Và một giá trị khác để "lấy may"
bytes4(0x31112233)
);
Hàm này gửi năm giá trị. Chúng ta biết rằng giá trị thứ năm bị bỏ qua vì nó không phải là một mục bộ nhớ đệm hợp lệ, điều này sẽ gây ra việc hoàn nguyên nếu nó không được bao gồm.
(_success, _callOutput) = _cacheAddr.call(_callInput);
assertEq(_success, true);
.
.
.
} // testLongCalldata
} // CacheTest
Một ứng dụng mẫu
Viết các bài kiểm tra bằng Solidity là rất tốt, nhưng cuối cùng thì một ứng dụng phi tập trung (dapp) cần phải có khả năng xử lý các yêu cầu từ bên ngoài chuỗi để trở nên hữu ích. Bài viết này trình bày cách sử dụng bộ nhớ đệm trong một dapp với WORM, viết tắt của "Write Once, Read Many" (Ghi một lần, Đọc nhiều lần). Nếu một khóa chưa được ghi, bạn có thể ghi một giá trị vào nó. Nếu khóa đã được ghi, bạn sẽ nhận được một lệnh hoàn nguyên.
Hợp đồng
Đây là hợp đồng (opens in a new tab). Nó chủ yếu lặp lại những gì chúng ta đã làm với Cache và CacheTest, vì vậy chúng ta chỉ đề cập đến những phần thú vị.
import "./Cache.sol";
contract WORM is Cache {
Cách dễ nhất để sử dụng Cache là kế thừa nó trong hợp đồng của riêng chúng ta.
function writeEntryCached() external {
uint[] memory params = _readParams(2);
writeEntry(params[0], params[1]);
} // writeEntryCached
Hàm này tương tự như fourParam trong CacheTest ở trên. Vì chúng ta không tuân theo các thông số kỹ thuật ABI, tốt nhất là không khai báo bất kỳ tham số nào vào hàm.
// Làm cho việc gọi chúng tôi dễ dàng hơn
// Chữ ký hàm cho writeEntryCached(), được cung cấp bởi
// https://www.4byte.directory/signatures/?bytes4_signature=0xe4e4f2d3
bytes4 constant public WRITE_ENTRY_CACHED = 0xe4e4f2d3;
Mã bên ngoài gọi writeEntryCached sẽ cần phải xây dựng dữ liệu lệnh gọi theo cách thủ công, thay vì sử dụng worm.writeEntryCached, vì chúng ta không tuân theo các thông số kỹ thuật ABI. Việc có giá trị hằng số này chỉ giúp việc viết nó dễ dàng hơn.
Lưu ý rằng mặc dù chúng ta định nghĩa WRITE_ENTRY_CACHED là một biến trạng thái, để đọc nó từ bên ngoài, cần phải sử dụng hàm getter cho nó, worm.WRITE_ENTRY_CACHED().
function readEntry(uint key) public view
returns (uint _value, address _writtenBy, uint _writtenAtBlock)
Hàm đọc là một view, vì vậy nó không yêu cầu giao dịch và không tốn Gas. Do đó, không có lợi ích gì khi sử dụng bộ nhớ đệm cho tham số. Với các hàm view, tốt nhất là sử dụng cơ chế tiêu chuẩn đơn giản hơn.
Mã kiểm tra
Đây là mã kiểm tra cho hợp đồng (opens in a new tab). Một lần nữa, chúng ta hãy chỉ xem xét những gì thú vị.
function testWReadWrite() public {
worm.writeEntry(0xDEAD, 0x60A7);
vm.expectRevert(bytes("entry already written"));
worm.writeEntry(0xDEAD, 0xBEEF);
Đây (vm.expectRevert) (opens in a new tab) là cách chúng ta chỉ định trong một bài kiểm tra Foundry rằng lệnh gọi tiếp theo sẽ thất bại và lý do được báo cáo cho sự thất bại. Điều này áp dụng khi chúng ta sử dụng cú pháp <contract>.<function name>() thay vì xây dựng dữ liệu lệnh gọi và gọi hợp đồng bằng giao diện cấp thấp (<contract>.call(), v.v.).
function testReadWriteCached() public {
uint cacheGoat = worm.cacheWrite(0x60A7);
Ở đây chúng ta sử dụng thực tế là cacheWrite trả về khóa bộ nhớ đệm. Đây không phải là thứ chúng ta mong đợi sử dụng trong sản xuất, vì cacheWrite thay đổi trạng thái và do đó chỉ có thể được gọi trong một giao dịch. Các giao dịch không có giá trị trả về, nếu chúng có kết quả thì những kết quả đó được cho là sẽ được phát ra dưới dạng các sự kiện. Vì vậy, giá trị trả về của cacheWrite chỉ có thể truy cập được từ mã trên chuỗi và mã trên chuỗi không cần lưu tham số vào bộ nhớ đệm.
(_success,) = address(worm).call(_callInput);
Đây là cách chúng ta nói với Solidity rằng mặc dù <contract address>.call() có hai giá trị trả về, chúng ta chỉ quan tâm đến giá trị đầu tiên.
(_success,) = address(worm).call(_callInput);
assertEq(_success, false);
Vì chúng ta sử dụng hàm <address>.call() cấp thấp, chúng ta không thể sử dụng vm.expectRevert() và phải xem xét giá trị thành công boolean mà chúng ta nhận được từ lệnh gọi.
event EntryWritten(uint indexed key, uint indexed value);
.
.
.
_callInput = bytes.concat(
worm.WRITE_ENTRY_CACHED(), worm.encodeVal(a), worm.encodeVal(b));
vm.expectEmit(true, true, false, false);
emit EntryWritten(a, b);
(_success,) = address(worm).call(_callInput);
Đây là cách chúng ta xác minh rằng mã phát ra một sự kiện một cách chính xác (opens in a new tab) trong Foundry.
Máy khách
Một điều bạn không có được với các bài kiểm tra Solidity là mã JavaScript mà bạn có thể cắt và dán vào ứng dụng của riêng mình. Phiên bản gốc của hướng dẫn này đã triển khai WORM lên Optimism Goerli, mạng lưới này hiện đã ngừng hoạt động. Để chạy máy khách ngày nay, hãy triển khai lại WORM lên một mạng lưới OP Stack được hỗ trợ như OP Sepolia (opens in a new tab), sau đó sử dụng địa chỉ hợp đồng thu được trong máy khách JavaScript.
Bạn có thể xem mã JavaScript cho máy khách tại đây (opens in a new tab). Kho lưu trữ mẫu được viết cho Optimism Goerli, vì vậy trước khi chạy nó, hãy cập nhật điểm cuối RPC và URL của trình khám phá trong javascript/.env.example và javascript/index.js cho mạng lưới mục tiêu của bạn. Để sử dụng nó:
-
Sao chép kho lưu trữ git:
git clone https://github.com/qbzzt/20220915-all-you-can-cache.git -
Cài đặt các gói cần thiết:
cd javascript yarn -
Sao chép tệp cấu hình:
cp .env.example .env -
Chỉnh sửa
.envcho cấu hình của bạn:Tham số Giá trị MNEMONIC Cụm từ ghi nhớ cho một tài khoản có đủ ETH để thanh toán cho một giao dịch. Tài liệu về vòi của Optimism (opens in a new tab) liệt kê các vòi mạng thử nghiệm hiện tại. OPTIMISM_GOERLI_URL URL RPC cho mạng lưới nơi bạn triển khai lại WORM. Đối với OP Sepolia, hãy sử dụng điểm cuối RPC của OP Sepolia như https://sepolia.optimism.iohoặc một điểm cuối khác từ nhà cung cấp của bạn. -
Chạy
index.js.node index.jsỨng dụng mẫu này trước tiên ghi một mục nhập vào WORM, hiển thị dữ liệu lệnh gọi và một liên kết đến giao dịch trên một trình khám phá khối. Sau đó, nó đọc lại mục nhập đó và hiển thị khóa mà nó sử dụng cùng với các giá trị trong mục nhập (giá trị, số khối và tác giả).
Phần lớn máy khách là JavaScript dapp thông thường. Vì vậy, một lần nữa chúng ta sẽ chỉ xem xét những phần thú vị.
.
.
.
const main = async () => {
const func = await worm.WRITE_ENTRY_CACHED()
// Cần một khóa mới mỗi lần
const key = await worm.encodeVal(Number(new Date()))
Một khe nhất định chỉ có thể được ghi vào một lần, vì vậy chúng ta sử dụng dấu thời gian để đảm bảo chúng ta không sử dụng lại các khe.
const val = await worm.encodeVal("0x600D")
// Ghi một mục nhập
const calldata = func + key.slice(2) + val.slice(2)
Ethers mong đợi dữ liệu lệnh gọi là một chuỗi hex, 0x theo sau là một số chẵn các chữ số thập lục phân. Vì cả key và val đều bắt đầu bằng 0x, chúng ta cần loại bỏ các phần đầu đó.
const tx = await worm.populateTransaction.writeEntryCached()
tx.data = calldata
sentTx = await wallet.sendTransaction(tx)
Giống như với mã kiểm tra Solidity, chúng ta không thể gọi một hàm được lưu vào bộ nhớ đệm một cách bình thường. Thay vào đó, chúng ta cần sử dụng một cơ chế cấp thấp hơn.
.
.
.
// Đọc mục nhập vừa được ghi
const realKey = '0x' + key.slice(4) // loại bỏ cờ FF
const entryRead = await worm.readEntry(realKey)
.
.
.
Để đọc các mục nhập, chúng ta có thể sử dụng cơ chế bình thường. Không cần sử dụng bộ nhớ đệm tham số với các hàm view.
Kết luận
Mã trong bài viết này là một bằng chứng khái niệm (proof of concept), mục đích là làm cho ý tưởng dễ hiểu. Đối với một hệ thống sẵn sàng cho sản xuất, bạn có thể muốn triển khai một số chức năng bổ sung:
-
Xử lý các giá trị không phải là
uint256. Ví dụ: chuỗi. -
Thay vì một bộ nhớ đệm toàn cục, có thể có một ánh xạ giữa người dùng và bộ nhớ đệm. Những người dùng khác nhau sử dụng các giá trị khác nhau.
-
Các giá trị được sử dụng cho địa chỉ khác biệt với các giá trị được sử dụng cho các mục đích khác. Có thể hợp lý khi có một bộ nhớ đệm riêng chỉ dành cho các địa chỉ.
-
Hiện tại, các khóa bộ nhớ đệm dựa trên thuật toán "đến trước, khóa nhỏ nhất". Mười sáu giá trị đầu tiên có thể được gửi dưới dạng một byte duy nhất. 4080 giá trị tiếp theo có thể được gửi dưới dạng hai byte. Khoảng một triệu giá trị tiếp theo là ba byte, v.v. Một hệ thống sản xuất nên giữ các bộ đếm mức sử dụng trên các mục bộ nhớ đệm và tổ chức lại chúng sao cho mười sáu giá trị phổ biến nhất là một byte, 4080 giá trị phổ biến nhất tiếp theo là hai byte, v.v.
Tuy nhiên, đó là một hoạt động có khả năng gây nguy hiểm. Hãy tưởng tượng chuỗi sự kiện sau:
-
Noam Naive gọi
encodeValđể mã hóa địa chỉ mà anh ta muốn gửi token đến. Địa chỉ đó là một trong những địa chỉ đầu tiên được sử dụng trên ứng dụng, vì vậy giá trị được mã hóa là 0x06. Đây là một hàmview, không phải là một giao dịch, vì vậy nó nằm giữa Noam và nút mà anh ta sử dụng, và không ai khác biết về nó. -
Owen Owner chạy thao tác sắp xếp lại bộ nhớ đệm. Rất ít người thực sự sử dụng địa chỉ đó, vì vậy bây giờ nó được mã hóa thành 0x201122. Một giá trị khác, 1018, được gán là 0x06.
-
Noam Naive gửi token của mình đến 0x06. Chúng đi đến địa chỉ
0x0000000000000000000000000de0b6b3a7640000và vì không ai biết khóa riêng tư cho địa chỉ đó, chúng chỉ bị kẹt ở đó. Noam không vui chút nào.
Có nhiều cách để giải quyết vấn đề này và vấn đề liên quan đến các giao dịch nằm trong mempool trong quá trình sắp xếp lại bộ nhớ đệm, nhưng bạn phải nhận thức được điều đó.
-
Tôi đã trình bày bộ nhớ đệm ở đây với Optimism, vì tôi là một nhân viên của Optimism và đây là bản cuộn mà tôi biết rõ nhất. Nhưng nó sẽ hoạt động với bất kỳ bản cuộn nào tính chi phí tối thiểu cho quá trình xử lý nội bộ, do đó, khi so sánh, việc ghi dữ liệu giao dịch vào lớp 1 (l1) là chi phí chính.
Xem tại đây để biết thêm về công việc của tôi (opens in a new tab).