Dynamically-sized Storage Types

Pint includes a number of very useful data structures called storage collections. Most other data types represent one specific value, but storage collections can contain multiple values. Unlike the built-in array and tuple types, the amount of data that these collections hold does not need to be known at compile time and can grow or shrink as the blockchain state evolve. Each kind of collection has different capabilities and costs, and choosing an appropriate one highly depends on the situation at hand. In this chapter, we'll discuss two collections that are used very often in Pint contracts:

  • Storage Map: allows you to associated a value with a particular key.
  • Storage Vector: allows you to store a variable number of values.

We'll discuss how to create and update storage maps and storage vectors.

Storage Map

The first collection we will look at is the storage map. A storage map stores a mapping of keys of some type K to values of some type V using a hashing function, which determines how it places these keys and values in storage. Hash maps are useful when you want to look up data by using a key that can be of any type. For example, in the Subcurrency contract, we kept track of each user’s token balance in a map in which each key is a user’s address and the values are each user’s balance. Given a user address, you can retrieve their balance.

Creating a New Storage Map

Because storage maps are a storage type, they must always be declared inside a storage block. The storage map type is a built-in type with a specific syntax:

storage {
    // ...
    balances: (b256 => int),
    // ...
}

Here, a new storage map, called balances, is declared that maps b256 keys to int values. The storage map type is always declared using parentheses that contain two types separated by a =>. It should be clear that storage maps are homogeneous, that is, all of the keys must have the same type as each other, and all of the values must have the same type as well.

Accessing Values in a HashMap

We can get a value out of the storage map by providing its key in between square brackets, similar to how arrays are accessed:

var from_address: b256;
var receiver_address: b256;
state from_balance = storage::balances[from_address];
state receiver_balance = storage::balances[receiver_address];

Here, from_balance will have the value that's associated with from_address and receiver_balance will have the value that's associated with receiver_address. Because the values returned are storage values, we must assign them to state variables. Using a storage map access expression in any other context results in a compile error.

"Updating" a Storage Map

As we've mentioned a few times already, explicitly "updating" anything in Pint is not a valid operation because Pint has no sequential execution. However, as in the case with statically-sized storage types, we can require that the next value of a specific entry in a storage map satisfy some constraint(s):

var my_address: b256;
state my_bal = mut storage::balances[my_address];
constraint my_bal' == my_bal + 1000000;

Here, we're requiring that our balance go up by 1 million, by applying the "prime" operator on the state variable that holds our current balance. Of course, requiring a change in state does not mean it will actually happen! Otherwise, we can all become instantly rich by submitting predicates like this. Unless a solution that does not violate any of the deployed rules (i.e. constraints) is submitted by a solver, the desired state change will never be satisfied.

"Missing" Keys

Now, you may be wondering what happens if a key is missing from a storage map and we try to access it anyways. In Pint, a nil is returned. In the previous example, if the balance of my_address was never actually modified in the past, then my_bal would be equal to nil and therefore, the expression my_bal + 1000000 would panic. To avoid this problem, we can first check if my_bal is nil before trying to use it in an arithmetic operation:

if my_bal != nil {
    constraint my_bal' == my_bal + 1000000;
} else {
    constraint my_bal' == 1000000;
}

Here, if my_bal is not nil, then the constraint remains the same as before. Otherwise, we simply update my_bal to 1000000 (as if my_bal was previously 0!).

Complex Maps

Storage maps can be declared to be arbitrarily complex. They can also be nested!

storage {
    // ...
    complex_map: ( { int, int } => { bool, b256 } ),
    nested_map: (b256 => (int => bool)),
    // ...
}

In the example above, the fist storage map maps a tuple to another tuple. The second storage map maps a b256 to another map! The way to access entries in these maps should is fairly intuitive and is exactly what you'd expect:

state complex_read: b256 = storage::complex_map[{42, 69}].1;

var addr1: b256;
state nested_read: bool = storage::nested_map[addr1][100];

The first storage access reads a tuple value using a key that itself is a tuple, and then accesses its second field. The second storage access is a nested map access using two index operators. Note that the first index operator accesses the first key (b256 in this case) and the second index operator accesses the second key (int in this case).

Illegal Uses of the Storage Map Type

It may be tempting to write code like this:

storage {
    // ...
    nested_map: (b256 => (int => bool)),
    // ...
}

predicate test {
    var addr: b256;

    state nested_map = storage::nested_map; // Expecting to return the "whole" map
    state nested_map_inner = storage::nested_map[addr]; // Expecting to return the "whole" inner map
}

However, the compiler will disallow this by emitting the following errors:

Error: state variables cannot have storage map type
   ╭─[bad.pnt:8:1]
   │
 8 │ state nested_map = storage::nested_map; // Expecting to return the "whole" map
   │ ───────────────────┬──────────────────
   │                    ╰──────────────────── found state variable of type storage map here
───╯
Error: state variables cannot have storage map type
   ╭─[bad.pnt:9:1]
   │
 9 │ state nested_map_inner = storage::nested_map[addr]; // Expecting to return the "whole" inner map
   │ ─────────────────────────┬────────────────────────
   │                          ╰────────────────────────── found state variable of type storage map here
───╯

Hopefully the errors are clear enough. What the compiler is telling us here is that we cannot declare state variables that hold entire storage maps. A storage map is not exactly an object that we can store a reference to or copy/move around and state variables can only have statically-sized types!

Storage Vector

Note: Storage vectors are work-in-progress