Using structured symbolic storage in Kontrol tests
Preliminaries
How is storage organised?
EVM storage is organised at a per-contract level, with each contract having 2 ^Int 256 32-byte storage slots available. Each slot can hold multiple pieces of data and understanding which storage slot corresponds to which part of contract data can get fairly complex, especially in the case of mappings and dynamically sized data. The detailed official guide to how storage slots are organised and computed can be found here.
SLOT is a byte array representing the slot to be updated;
VALUE is an integer representing the value to which a contract field in that slot should be updated;
MASK is a bit-mask that is used to zero the part of the slot corresponding to the contract field to be updated; and
SHIFT is a power of two that is used to shift the value so that it aligns with the part of the slot corresponding to the contract field to be updated.
For example, if we were updating a given 8-byte uint64 field stored in a given slot starting from byte 8 (note that byte indices are computed from right to left), this would be achieved as follows, taking:
MASK ==Int 115792089237316195423570985008687907852929702298719625576012656144555070980095, that is, ffffffffffffffffffffffffffffffff0000000000000000ffffffffffffffff in hex; and
Where can we find storage layout information for real-world contracts?
The Solidity compiler is able to provide complete information on the storage layout of each of the compiled contracts. This information can be found in the "storageLayout" field of the associated .json file, which is created if the 'storageLayout' option is passed to the extra_output parameter in compilation.
Running example: DualGovernance
As our running example, we take the DualGovernance contract from our recent Lido engagement, taken from a relevant commit of the codebase at the time of writing this guide, and focusing only on the part relevant for symbolic storage:
// ---// Aspects// ---/// @dev The functionality to manage the proposer -> executor pairs.Proposers.Context internal _proposers;/// @dev The functionality of the tiebreaker to handle "deadlocks"/// of the Dual Governance.Tiebreaker.Context internal _tiebreaker;/// @dev The state machine implementation controlling the state/// of the Dual Governance.DualGovernanceStateMachine.Context internal _stateMachine;// ---// Standalone State Variables// ---/// @dev The address of the Reseal Committee which is allowed to/// "reseal" sealables paused for a limited period of time when/// the Dual Governance proposal adoption is blocked.addressinternal _resealCommittee;
where the three used contexts are defined as follows:
where the other relevant types are defined as follows
typeDurationisuint32;structAddressSet { Set _inner;}structSet {bytes32[] _values;mapping(bytes32 value =>uint256) _positions;}enumState { Unset, Normal, VetoSignalling, VetoSignallingDeactivation, VetoCooldown, RageQuit}typeTimestampisuint40;
and where two of the contexts are even annotated with the expected storage slot structure. Given this information, and the official guide for storage slot structuring, we would expect the following:
Proposers.Context internal _proposers starts from slot 0 and occupies three slots (0, 1, and 2), one per each field of the context, as mappings require a dedicated entire slot.
Tiebreaker.Context internal _tiebreaker starts from slot 3 and, according to the annotations, occupies two slots. However, this annotation is incorrect because even though an address and a Duration can be packed into a single slot, EnumerableSet.AddressSet occupies two slots on its own.
DualGovernanceStateMachine.Context internal _stateMachine starts from slot 6 and, according to the annotations, occupies three slots. As we can see, this data has been carefully arranged to minimize slot occupancy, as slot 0 contains five pieces of data.
address internal _resealCommittee occupies a single slot, which would be slot 9.
Let us now compare our expectations with the information obtained after compilation, by inspecting the storageLayout field of the created DualGovernance.json file. We first find this:
which tells us that our assessment was correct in terms of top-level slot allocation, as can be seen by the four slot fields. To go deeper into the structure, we can look up the type of each slot. For example, searching for t_struct(Context)8128_storage yields:
from where we can confirm that the detailed information about slot allocation present in the comments of the definition of DualGovernanceStateMachine.Context is correct.
Symbolic storage management in Kontrol
Kontrol provides users with several tools that enable the creation of management of symbolic storage, and we will be using them here to demonstrate how to structure a part of the symbolic storage of the DualGovernance contract given above.
Various symbolic unsigned integers. First, we recall that Kontrol comes with cheatcodes that allow users to allocate symbolic unsigned integers of specified width (in bytes, between 1 and 32):
Fully symbolic storage. Next, we have a cheatcode that makes the storage of a given address completely symbolic:
functionsymbolicStorage(address) external;
overwriting any previous content that might have already been in said storage.
General slot management. Kontrol comes with two core functions following that allow users to load/store information from/to a specific part of a given slot. Both of these use the general slot update mechanism
which returns an unsigned integer that represents the information stored in slot slot of contract with address contractAddress, starting from offset offset (in bytes) and capturing width bytes. Its implementation is as follows:
function_loadData(address contractAddress,uint256 slot,uint256 offset,uint256 width) internalviewreturns (uint256 slotData) {// `offset` and `width` must not overflow the slotassert(offset + width <=32);// Read slot value slotData =uint256(vm.load(contractAddress,bytes32(slot)));// Shift value by `offset` bytes to the right slotData = slotData >> (8* offset);// Create the bit-mask for `width` bytesuint256 mask =2** (8* width) -1;// and apply it to isolate the desired data slotData = mask & slotData;}
Next, the function for storing slot data has the following signature:
which stores the unsigned integer value in slot slot of contract with address contractAddress, starting from offset offset (in bytes) and considering width bytes. Its implementation is as follows:
function_storeData(address contractAddress,uint256 slot,uint256 offset,uint256 width,uint256 value) internal {// `offset` and `width` must not overflow the slotassert(offset + width <=32);// and `value` must fit into the designated partassert(width ==32|| value <2** (8* width));// Construct slot update maskuint256 maskLeft = ~((2** (8* (offset + width))) -1);uint256 maskRight = (2** (8* offset)) -1;uint256 mask = maskLeft | maskRight;// Shift the value appropriatelyuint256 value = (2** (8* offset)) * value;// Get current slot valueuint256 slotValue =uint256(vm.load(contractAddress,bytes32(slot)));// update it slotValue = value | (mask & slotValue);// and store the updated value vm.store(contractAddress,bytes32(slot),bytes32(slotValue));}
In addition to these very low-level functions, the users have higher-level functions to operate with, such as:
Symbolic storage: DualGovernance. Below, we show how we use all of the above mechanisms to symbolically structure the part of the storage of the DualGovernance contract that is relevant to our tests:
There still exists room for considerable improvement of the way Kontrol handles symbolic slot updates, and here we discuss several such directions.
Automatically using slot-related information. Firstly, note that the _loadData and _storeData functions are error-prone, as the slots, offsets, and widths have to be provided manually by the user. At the same time, all of the required information already exists in the .json file of the compiled contract. Unfortunately, this information is not available directly programmatically outside of the contract in question---within the contract, we can reflect on it in assembly using .slot and .offset. Therefore, we would have to devise either a cheatcode-like mechanism (perhaps using the custom-step mechanism) or create automatically an additional Solidity file with all of the auxiliary functions associated with each contract.
Array slots and mapping slots. Next, we could benefit from automated computation of array slots and mapping slots. This already exists in some of the developments, for example: