General Limitations

These limitations apply at all times when working within the ZKsync context.

Reserved Address Range

On zkEVM, addresses in the range [0..2^16-1] are reserved for kernel space. Using these addresses within a test, even for mocking, may lead to undefined behavior. Therefore, the user addresses must range from 65536 onwards.

contract FooTest is Test {
    function testReservedAddress_Invalid() public {
        vm.mockCall(
            address(0),     // invalid
            abi.encodeWithSelector(bytes4(keccak256("number()"))),
            abi.encode(5)
        );
contract FooTest is Test {
    function testReservedAddress_Valid() public {
        vm.mockCall(
            address(65536),     // valid
            abi.encodeWithSelector(bytes4(keccak256("number()"))),
            abi.encode(5)
        );
    }
}

Additionally, during fuzz-testing, these addresses must be ignored. This can be done via either asserting vm.assume(address(value) >= 65536) or by setting no_zksync_reserved_addresses = true in fuzz configuration.

Origin Address

While foundry allows mocking the tx.origin address as normal, zkEVM will fail all calls to it. As such, the following code will not work:

library IFooBar {
    function number() return (uint8);
}

contract FooTest is Test {
    function testOriginAddress() public {
        address target = tx.origin;

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

        IFooBar(target).number() // will fail
    }
}

Bytecode Constraints

zkEVM asserts a bytecode to be valid if it satisfies the following constraints:

  • Its length in bytes is divisible by 32 (i.e. 32-byte words).
  • Has a length of less than 2^16 words.
  • Has an odd length in words.
contract FooTest is Test {
    function testBytecodeContraint() 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"
        );

    }
}

Bytecode Hash

Bytecode hashes output by zksolc are fundamentally different from the hash obtained via solc. The most glaring difference is that the first (most significant) byte denotes the version of the format, which is 1 at present. This leads to all zksolc bytecode hashes to begin with 1, whereas solc bytecodes are merely the keccak hash of the bytecode.

Any code-making assumptions about bytecode hashes around EVM-scope must be migrated to accommodate ZKsync’s bytecode hashes.

Address Derivation

zkEVM uses a different CREATE and CREATE2 address derivation strategy compared to EVM. This can lead to testing issues with the CREATE2 addresses that are hard-coded for EVM. Therefore, these tests must be updated to reflect the ZKsync-derived addresses.

function create2Address(sender: Address, bytecodeHash: BytesLike, salt: BytesLike, input: BytesLike) {
  const prefix = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("zksyncCreate2"));
  const inputHash = ethers.utils.keccak256(input);
  const addressBytes = ethers.utils.keccak256(ethers.utils.concat([prefix, ethers.utils.zeroPad(sender, 32), salt, bytecodeHash, inputHash])).slice(26);
  return ethers.utils.getAddress(addressBytes);
}

function createAddress(sender: Address, senderNonce: BigNumberish) {
  const prefix = ethers.utils.keccak256(ethers.utils.toUtf8Bytes("zksyncCreate"));
  const addressBytes = ethers.utils
    .keccak256(ethers.utils.concat([prefix, ethers.utils.zeroPad(sender, 32), ethers.utils.zeroPad(ethers.utils.hexlify(senderNonce), 32)]))
    .slice(26);

  return ethers.utils.getAddress(addressBytes);
}

Accessing Contract Bytecode and Hash

zkEVM does not allow obtaining bytecodes from address.code or computing their respective hashes, which will be raised as an error during compilation. This is particularly useful when computing CREATE2 addresses (see getNewAddressCreate2 below).

To circumvent this limitation, it is recommended to use the FFI functionality of cheatcodes:

contract Calculator {
    function add(uint8 a, uint8 b) return (uint8) {
        return a+b;
    }
}

contract FooTest is Test {
    function testContractBytecodeHash() public {
        string memory artifact = vm.readFile(
            "zkout/FooTest.sol/Calculator.json"
        );
        bytes32 bytecodeHash = vm.parseJsonBytes32(artifact, ".hash");
        bytes32 salt = 0x0000000000000000000000000000000000000001;
        
        ISystemContractDeployer deployer = ISystemContractDeployer(
            address(0x0000000000000000000000000000000000008006)
        );
        address addr = deployer.getNewAddressCreate2(
            address(this),
            salt,
            bytecodeHash,
            ""
        );
    }
}

Note that this requires adding read permissions in foundry.toml:

[profile.default]
...
fs_permissions = [{ access = "read", path = "./zkout/FooTest.sol/Calculator.json"}]