Testing
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:
- Using
vm.assume
: You can assert that the address generated in fuzz testing is greater than or equal to65536
:vm.assume(address(value) >= 65536);
- 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.