The IterableMap Contract

The IterableMap will maintain an internal array of the keys inserted to the map. In the next section, we will add an iteration function.

pragma solidity ^0.7.0;

contract IterableMap {
    mapping(uint => uint) internal map;
    function get(uint key) public view returns(uint) { return map[key]; }

    uint[] internal keys;
    function numOfKeys() external view returns (uint) { return keys.length; }

    function insert(uint key, uint value) external {
        require(value != 0, "0 is not a valid value");
        require (!contains(key), "key already exists");
        map[key] = value;
    }

    function remove(uint key) external {
        require (map[key] != 0, "Key does not exist");
        map[key] = 0;
        uint i = indexOf(key);
        if (i < keys.length - 1) {
            keys[i] = keys[keys.length-1];
        }
        keys.pop();
    }

    function contains(uint key) internal view returns (bool) {
        if (map[key] == 0) {
            return false;
        }

        return true;
    }

    function indexOf(uint key) internal view returns (uint) {
        for (uint i = 0 ; i < keys.length ; i++) {
            if (keys[i] == key) {
                return i;
            }
        }
        require(false, "Could not find key");
    }
}

We can now run the original spec file on the new contract. Unfortunately, not all rules are passing. The inverses rule is failing. The assertion message tells us Unwinding condition in a loop. It is the output whenever we encounter a loop that cannot be finitely unrolled. To prevent missed bugs, the Prover outputs an assertion error in the loop’s stop condition. We can control how many times the loops are unrolled, and in the future, the Prover will also support the specification of inductive invariants for full loop coverage. In our example, we can start by simply assuming loops can be fully unrolled even if only unrolled once by specifying --optimistic_loop in the command line for running the Prover.

Even then inverses still fails. Let’s consider the call trace for this rule:

We see that we were able to nullify the entry in the map, but the last operation that we see in the call trace under remove is that we load from keys a value of 0. It is known that the Solidity compiler associates the storage slot of an array to its length. Here we see that the read length is 0. This means the key array is empty. However, it shouldn’t have been empty after invoking insert. This is exactly the bug that we have in the code - we need to add the inserted key into the keys array:

function insert(uint key, uint value) external {
    require(value != 0, "0 is not a valid value");
    require (!contains(key), "key already exists");
    map[key] = value;
    keys.push(key);
}

Oddly enough, the rule still fails:

It is still reported that the length of keys is 0, but this is unexpected. We examine the operations performed by insert, and we see that it loaded a length of ff....ff, and then stored a length of 0. That is, our array filled-up and reached the length of max uint256. This may look absurd or unrealistic, but that’s where the power of the Prover lies - it doesn’t miss any edge case. If we believe it is unrealistic for the length of keys to reach the maximum value, we should:

  • assume that the length of the array is less than max uint256.

  • assert that it is indeed infeasible to directly overwrite the length slot, or to increment the length by more than 1 in each operation.

We start by adding a simple assumption in the rule. (We will later replace it with an assumption of an invariant, that will also assert that reaching max uint256 is infeasible.)

rule inverses(uint key, uint value) {
    uint max_uint = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
    require numOfKeys() < max_uint;
    env e;
    insert(e, key, value);
    env e2;
    require e2.msg.value == 0;
    remove@withrevert(e2, key);
    bool removeSucceeded = !lastReverted;
    assert removeSucceeded, "remove after insert must succeed";
    assert get(key) != value, "value of removed key must not be the inserted value";
}

(don’t forget to add numOfKeys to our envfree declarations!)

Adding iteration

Our goal in adding the keys variable was to allow iteration over the keys. We start with an extremely simple example, that sets all keys’ values to 100:

function iterate() external {
    for (uint i = 0 ; i < keys.length ; i++) {
        uint key = keys[i];
        doSomething(key, get(key));
    }
}

function doSomething(uint key, uint value) virtual internal {
    map[key] = 100;
}

We also want to add a basic check rule:

rule checkIterate() {
    env e;
    iterate(e);
    uint someKey;    
    require contains(someKey);
    assert get(someKey) == 100;
}

The rule fails with the following call trace:

Let’s unpack what can be seen here. First, the length of the keys array is 1, and we read a key 22f2. We then write 100 to it in the map and then iterate function is done. We then note that someKey, the key we want to check for, is not 22f2, but rather 20c9. While we assumed that it is contained in the map by using the contains function, it is not contained in the keys array. This is expected since the Prover’s starting state can be completely arbitrary, subject to constraints that we specify on it. We wish to leave the contains function to be an O(1) complexity function, and rather provide the tool with the invariants that will allow it to see only states that “make sense”, or in more precise terms, we only want to see states where the keys array contains exactly the same elements as the non-zero valued keys in the map.

In mathematical terms, the invariant that our IterableMap contract should satisfy is:

$$∀x.(map(x)≠0⟺∃i.0≤i≤keys.length∧keys[i]=x)$$

This invariant can be encoded directly in the spec file, as follows (for convenience we assume keys is public and has a getter):

invariant inMapIffInArray(uint x) 
    get(x) != 0 <=> 
        (exists uint i. 0 <= i && i < getNumOfKeys() && keys(i) == x)

It is not recommended to invoke the underlying contract directly within quantified expressions (such as exists uint i. ...). The complexity of the underlying bytecode might lead to timeouts, and thus it is recommended to move to ghost variables. Ghost variables, once properly instrumented, allow us to write specs that are separated from the many technicalities of low-level bytecode and are thus a powerful abstraction tool.

A soft introduction to ghosts

We will write the above invariant using ghost variables exclusively (see Ghosts for complete information about ghosts). First, we will declare ghost variables for the underlying map structure.

ghost mapping(uint => uint) _map;

The above declaration declares a ghost mapping. The ghost mapping takes a uint argument (representing a key in the map) and returns a uint value. We want _map to return for each given key the same value as the map in the code. We can state this property as an invariant:

invariant checkMapGhost(uint someKey) get(someKey) == _map[someKey]

Currently, the rule fails for all state-mutating functions, and even in the contract’s initial state after constructor (rule checkMapGhost_instate):

This is unsurprising. There is nothing in the spec that links the value of the ghost to its Solidity counterpart. To make that link, we write hooks. Hooks allow us to instrument the verified code, that is, to wrap a bytecode operation with our own code, defined in the spec file.

For example, we can hook on SSTORE operations that write to the underlying map as follows:

hook Sstore map[KEY uint k] uint v {
    _map[k] = v;
}

This hook will match every storage write to map[k], denoting the written value by v. Optionally, and not shown in the syntax above, we can also specify the overwritten value of map[k]. The body of the hook is the injected code. It will update the _map ghost.

If we run checkMapGhost with only the SSTORE hook, the rule will pass for all functions but fail in the initial state, where no values were written. It is possible to specify initial state axioms on ghosts.

Similarly, one could define SLOAD hooks:

hook Sload uint v map[KEY uint k] {
    require _map[k] == v;
}

This hook says that every time the Prover encounters an SLOAD operation that reads the value v from map[k], it will inject the code within the hook body after the SLOAD. This will make our checkMapGhost rule pass, but it’s also become a tautology, because it’s always true: by calling get we’re already calling instrumented code that requires _map[k] == v whenever we load an arbitrary value v from the key k.