Testing

Understand the differences in testing smart contracts using Foundry ZKsync.

Bytecode Constraints

Issue

On ZKsync, bytecode must conform to specific size and length constraints to be considered valid. These constraints ensure that the bytecode operates correctly within ZKsync's modified zkEVM environment. Bytecode that does not meet these criteria will result in compilation or deployment errors.

The bytecode constraints are as follows:

  • The bytecode length (in bytes) must be divisible by 32 (32-byte words).
  • The bytecode must have fewer than 2^16 words.
  • The bytecode length (in words) must be an odd number.

Problematic Code

Consider a test scenario where we attempt to manually set the bytecode at a specific address using Foundry’s vm.etch cheatcode. In this example, some attempts to etch the bytecode violate the size and word alignment rules.

contract FooTest is Test {
    function testFoo() public {
        // Invalid, word-size of 1 byte
        vm.etch(address(65536), hex"00");

        // Invalid, even number of words
        vm.etch(
            address(65536),
            hex"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
        );

        // Valid, 32-byte word, odd number of words
        vm.etch(
            address(65536),
            hex"0000000000000000000000000000000000000000000000000000000000000000"
        );
    }
}

In this code:

  • The first vm.etch call uses a 1-byte word, which is invalid because ZKsync requires the bytecode length to be divisible by 32 bytes.
  • The second vm.etch call uses a bytecode of 64 bytes (even number of words), which is also invalid because ZKsync requires an odd number of words.
  • The third vm.etch call uses a valid bytecode length of 32 bytes (1 word), which is both divisible by 32 bytes and contains an odd number of words.

Error

The first two cases will produce errors due to violating the bytecode size and word constraints:

Error: Invalid bytecode length: not divisible by 32
Error: Invalid bytecode: length of even number of words

These errors indicate that the bytecode fails to meet ZKsync’s requirements for bytecode validity.

Solution

To resolve these errors, ensure that the bytecode conforms to the following rules:

  • The length of the bytecode must always be divisible by 32 bytes.
  • The total word count (in 32-byte words) must be an odd number.
  • The bytecode should be less than 2^16 words in length.

Fixed Code Example

Here’s how to correctly etch the bytecode in ZKsync, following the bytecode constraints:

contract FooTest is Test {
    function testFoo() public {
        // Valid, 32-byte word, odd number of words
        vm.etch(
            address(65536),
            hex"0000000000000000000000000000000000000000000000000000000000000000"
        );
    }
}

In this fixed example:

  • The bytecode length is exactly 32 bytes (one 32-byte word), which satisfies ZKsync's requirement for bytecode size divisibility by 32.
  • The total word count is odd (1 word), meeting the requirement for an odd number of words.

Cheatcode Limitations

Issue

In ZKsync's zkEVM, cheatcodes are only supported at the root level of an executing test, meaning they must be called outside of any CREATE or CALL operations that are dispatched to the zkEVM. Cheatcodes used within contract constructors or function calls that dispatch transactions will not work and can lead to undefined behavior.

Valid Cheatcode Usage

Cheatcodes can be used normally in tests as long as they are outside of any contract creation (CREATE) or external function calls (CALL). Here's an example demonstrating valid cheatcode usage:

contract MyContract {
    function getNumber() public returns (uint256) {
        return 42;
    }
}

contract FooTest is Test {
    function testFoo_1() public {
        vm.roll(10);                    // valid
        vm.assertEq(10, block.number);   // valid cheatcode usage
    }

    function testFoo_2() public {
        vm.roll(10);                    // valid
        new MyContract();                // valid because vm.roll is called outside of contract creation
    }

    function testFoo_3() public {
        vm.roll(10);                    // valid
        MyContract testContract = new MyContract();
        testContract.getNumber();        // valid as cheatcodes are used before the contract interaction
    }
}

In these cases, the vm.roll cheatcode is valid because it is called outside of any contract creation or function calls that interact with zkEVM.

Cheatcode Usage in Libraries

Since libraries do not result in a CREATE or CALL, you can use cheatcodes within library functions without issues. Here's an example of how libraries can use cheatcodes:

library MyLibrary {
    function setBlockNumber(uint256 value) public {
        vm.roll(value);                 // valid cheatcode usage in a library
    }
}

contract FooTest is Test {
    function testFoo_1() public {
        vm.roll(10);                    // valid
        vm.assertEq(10, block.number);
        MyLibrary.setBlockNumber(20);    // valid cheatcode usage within a library
        vm.assertEq(10, block.number);   // valid
    }
}

Libraries can call cheatcodes since they do not trigger a contract creation or external call, making them valid for cheatcode usage in ZKsync.

Problematic Code

Cheatcodes used within contract constructors or functions that dispatch transactions to the zkEVM will result in undefined behavior or fail to execute. Here's an example demonstrating invalid cheatcode usage:

contract MyContract {
    constructor() {
        vm.roll(20);                    // invalid cheatcode usage inside a constructor
    }

    function getNumber() public returns (uint256) {
        vm.roll(20);                    // invalid cheatcode usage inside a function
        return 42;
    }
}

contract FooTest is Test {
    function testFoo_1() public {
        vm.roll(10);                    // valid
        MyContract testContract = new MyContract();  // invalid due to vm.roll in constructor
        testContract.getNumber();        // invalid due to vm.roll in function
    }
}

In this example, vm.roll is used inside the constructor and a function of MyContract, which will not work on ZKsync because the cheatcode is executed within a CREATE or CALL operation.

Error

Although the error messages may not be explicit, calling cheatcodes inside a contract's constructor or function will either lead to undefined behavior or failure during the test execution.

Solution

To ensure cheatcodes work as expected on ZKsync, always call them outside of contract constructors or external function calls. Use libraries or test-level cheatcodes for reliable test execution.

Fixed Code Example

Here’s an example of valid cheatcode usage in a test:

contract MyContract {
    function getNumber() public returns (uint256) {
        return 42;
    }
}

contract FooTest is Test {
    function testFoo() public {
        vm.roll(10);                    // valid cheatcode usage
        MyContract testContract = new MyContract();  // no cheatcodes inside contract
        testContract.getNumber();        // no cheatcodes inside function
    }
}

This example ensures that the cheatcodes are called only at the test level, outside any contract creation or external function calls.

Forking

When using forking cheatcodes such as vm.selectFork or vm.createSelectFork in tests, the execution context automatically switches based on the network being forked.

  • Forking to a ZKsync Network: If the RPC endpoint supports ZKsync (verified by checking the presence of the zks_L1ChainId method), the test execution switches to the ZKsync context. This means the test will follow ZKsync-specific behaviors and limitations.
  • Forking to a non-ZKsync network: If the selected fork is not a ZKsync endpoint, the test execution remains in the standard EVM context.

Cheatcode Override

To manually control the execution context, a custom cheatcode vm.zkVm is provided:

  • Enabling ZKsync mode: Use vm.zkVm(true) to switch the test execution to ZKsync mode.
  • Switching back to EVM mode: Use vm.zkVm(false) to revert back to the standard EVM mode during test execution.

Note

Using the --zksync flag when running tests is equivalent to placing vm.zkVm(true) as the first statement in the test, automatically setting the execution to ZKsync mode.

Origin Address

Issue

In ZKSync's zkEVM, calls to tx.origin are not supported, unlike in Ethereum where tx.origin can be used to retrieve the original external account that initiated the transaction. As a result, any attempts to mock or interact with tx.origin in ZKSync will fail.

Problematic Code

In the following example, the tx.origin address is used in a mocked call. While this works in Ethereum, it will not work in ZKsync because tx.origin is unsupported.

library IFooBar {
    function number() external view returns (uint8);
}

contract FooTest is Test {
    function testFoo() public {
        address target = tx.origin; // Invalid on ZKsync

        vm.mockCall(
            address(target),
            abi.encodeWithSelector(bytes4(keccak256("number()"))),
            abi.encode(5)
        );

        IFooBar(target).number(); // This call will fail on ZKsync
    }
}

In this code, tx.origin is used to obtain the address of the original transaction sender, but on ZKsync, any interaction involving tx.origin will fail.

Error

Attempting to use tx.origin on ZKsync will result in failed transactions or mocked calls. However, ZKSync may not provide specific error messages related to the failure, making it difficult to trace the root cause.

Solution

You should use msg.sender instead of using tx.origin to track the address of the current external caller, which is supported on ZKsync.

Fixed Code Example

library IFooBar {
    function number() external view returns (uint8);
}

contract FooTest is Test {
    function testFoo() public {
        address target = msg.sender; // Valid on ZKsync

        vm.mockCall(
            address(target),
            abi.encodeWithSelector(bytes4(keccak256("number()"))),
            abi.encode(5)
        );

        IFooBar(target).number(); // This will now work on ZKsync
    }
}

In this fixed example, msg.sender is used instead of tx.origin, which ensures compatibility with ZKsync.

Reserved Address Range

Issue

On ZKsync's zkEVM, addresses in the range [0..2^16-1] are reserved for kernel space, meaning they are not available for contract deployment or other user-level interactions. Attempting to use these addresses, either directly or through mocking, can lead to undefined behavior in your tests.

Problematic Code

In this test example, the address 0 is used in a mocked call. Since this address is part of the reserved range, using it will result in undefined behavior and test failures.

contract FooTest is Test {
    function testFoo() public {
        // Invalid: Using an address within the reserved range
        vm.mockCall(
            address(0),
            abi.encodeWithSelector(bytes4(keccak256("number()"))),
            abi.encode(5)
        );
    }
}

This example attempts to mock a call to address(0), which is part of the reserved kernel space and therefore invalid on ZKsync.

Error

Using addresses within the reserved range may lead to undefined behavior and possible test failures without specific error messages. It's crucial to avoid these addresses entirely to ensure test reliability.

Solution

Ensure that addresses used for testing and mocking are outside of the reserved kernel space range. Start from address 65536 onwards for any user-level interaction.

In fuzz testing, you can exclude the reserved address range either by setting assumptions or using specific fuzz configuration.

Here’s a corrected test example:

Fixed Code Example

contract FooTest is Test {
    function testFoo() public {
        // Valid: Using an address outside of the reserved range
        vm.mockCall(
            address(65536),
            abi.encodeWithSelector(bytes4(keccak256("number()"))),
            abi.encode(5)
        );
    }
}

In this corrected example, we use address(65536), which is a valid user-space address in ZKsync.

Additional Configuration

During fuzz testing, it's important to avoid generating addresses within the reserved range. This can be done in two ways:

  1. Using vm.assume: You can assert that the address generated in fuzz testing is greater than or equal to 65536:
    vm.assume(address(value) >= 65536);
    
  2. Fuzz Configuration: Alternatively, you can set the no_zksync_reserved_addresses option in your fuzz configuration to automatically exclude addresses in the reserved range.
    [profile.default]
    fuzz = { no_zksync_reserved_addresses = true }
    

These approaches ensure that fuzz testing avoids the reserved kernel space, preventing test failures and ensuring compliance with ZKsync's address rules.


Made with ❤️ by the ZKsync Community