Validate that an upgraded contract keeps same storage layout as the original one #37
Description
(follows from zeppelinos/zos-lib#63)
The problem
For an upgrade to be safe, we need to ensure that the new contract shares the same storage layout as the previous version, and only appends new variables, without modifying the existing ones.
For instance, given:
contract V1 {
uint a;
uint b;
}The following upgrade is valid, since it's only adding a new variable at the end of the storage:
contract V2 {
uint a;
uint b;
uint c;
}The following ones, on the other hand, are all invalid:
contract V2 {
uint a;
uint c; // New variable is inserted in the slot previously assigned to `b`
uint b;
}
contract Base {
uint base; // New `base` variable will be assigned the first slot
}
contract V2 is Base {
uint a;
uint b;
}
contract V2 {
uint a;
string b; // Type of a variable is changed
}Other potential breaking changes include modifying the fields of a struct, or changing inheritance order. Anything that alters the storage layout can potentially introduce bugs, and needs to be detected. To detect this, we need to figure out the storage layout, store it, and compare it when a new version is set.
Infering storage layout
The first step is to get the storage layout given a contract. The safest option to do this is to keep building on ethereum/solidity#4017, which is an enhancement to the Solidity compiler to provide this information directly. This requires considerable effort, and we'd need to wait until the new version of the compiler is released before we can make use of the feature.
A more lightweight alternative would be to extract this information from the AST. Assuming that the order of variables in storage is the same as the order in which they are declared (this is a safe assumption to make), we can walk the AST looking for nodes that correspond to state variables ("nodeType": "VariableDeclaration", "stateVariable": true), and store their order and type. Note that we'd also need to walk the linearizedBaseContracts (in the order specified) and also retrieve their state variables, to build a complete picture of the storage.
Ideally, we could output this information in a format as similar as possible as the one described here, so the transition to the information provided by solc is as smooth as possible.
Comparing storage layouts
Next step is to build a function that, given two storage layouts as extracted in the previous step, returns whether one is a valid extension of the other. It needs to check that variables types and names haven't changed, taking into account complex types (mappings, structs, etc) as well.
The output needs to be human-readable, so we can provide the user with information on where the problem is. Make sure the error is understandable when caused by a change in an ancestor: if a base contract changes its storage layout, it's something quite hidden to the user, so we need to be extra clear in those scenarios.
Recording storage info for a contract and checking
In order to compare a version with a new one, we need to store the storage layout information somewhere, and when the user proposes a new version, we perform the check. Ideally, we could to this when the user registers the new changes, but we need to have a git-like stage in place for that.
Instead, we can do that when the user runs zos push on a new version. On the first zos push, we store the storage layout info in the zos.network.json file (along with the bytecode hash). On subsequent ones, we recalculate the storage layout for the new contract, and compare it with the stored one. If it's ok, we allow the push and overwrite the storage layout. If not, we show the errors to the user. We'd also need to include an --ignore-warns-on-storage-layout (better name needed) flag so the user can push anyway, to work around false positives.