Chuyển đến nội dung chính

Tất cả những gì bạn có thể lưu vào bộ nhớ đệm

lớp 2
bộ nhớ đệm
lưu trữ
mở rộng quy mô
Trung cấp
Ori Pomerantz
15 tháng 9, 2022
30 phút đọc

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:

  1. 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.

  2. 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.

  3. Đố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ọiKhóa bộ nhớ đệm
    0x0F0x0F
    0x10,0x100x10
    0x12,0xAC0x02AC
    0x2D,0xEA, 0xD60x0DEAD6

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.

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.

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á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.

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á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.

Đầ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.

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.

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().

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.

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.

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ỷ).

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.

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.

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.

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 CacheCacheTest, 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.

Đâ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.examplejavascript/index.js cho mạng lưới mục tiêu của bạn. Để sử dụng nó:

  1. Sao chép kho lưu trữ git:

    git clone https://github.com/qbzzt/20220915-all-you-can-cache.git
    
  2. Cài đặt các gói cần thiết:

    cd javascript
    yarn
    
  3. Sao chép tệp cấu hình:

    cp .env.example .env
    
  4. Chỉnh sửa .env cho cấu hình của bạn:

    Tham sốGiá trị
    MNEMONICCụ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_URLURL 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.io hoặc một điểm cuối khác từ nhà cung cấp của bạn.
  5. 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ả keyval đề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 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:

    1. 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àm view, 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ó.

    2. 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.

    3. Noam Naive gửi token của mình đến 0x06. Chúng đi đến địa chỉ 0x0000000000000000000000000de0b6b3a7640000 và 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).