This article explains basics of Fuel smart contract's storage mechanics. Examples will be written in Sway.
Storage is in Fuel blockchain system is used to store information beyond a single call to a contract, this means that it is part of the Fuel state. It can be considered as a key-value type of data structure where the key is the slot number. There are 2^256 slots available for each smart contract deployed on Fuel. The 256 number was choosen to match the sha256 hashing function. The value stored in each slot is also 256 bits long (32 bytes).
Storage variables need to be firstly declared in a storage
block, like in the example below:
storage {
var: u64 = 0,
}
Note that the variables declared in the storage
block must be initialized. Otherwise, the compiler
will return an error.
How storage can be accessed from a contract method? Let's write a simple example:
#[storage(read)]
fn read_var() -> u64 {
storage.var.read()
}
#[storage(read, write)]
fn write_var(v: u64) {
storage.var.write(v);
}
We need to use read()
to access the contents of the variable. Why? Because
the storage.var
represents an object of type StorageKey<u64>
and to operate on a
a StorageKey
object you need to use read()
and write()
methods. The StorageKey
read and write implementation
can be found here and
the struct definition here.
Now that we know how to access storage variables and how the storage is structured it's time
to answer the question in which slot our variable var
is stored. If you read carefully the StorageKey
source code you noticed that there is the slot
member of the StorageKey
struct. It's value defines
the slot where the variable is held. But we don't know how is this value generated, we didn't set it
in our smart contract. The value is populated by the compiler at build time.
Before forc version 0.60.0 the slot
value was generated by sha256 hashing of string storage_X
, where
X
is the consecutive number of the variable declaration in the storage
block starting from 0. In our
case of var
the string would be storage_0
. We can obtain the hash in the following way:
$ echo -n "storage_0" | shasum -a 256 -
f383b0ce51358be57daa3b725fe44acdb2d880604e367199080b4379c41bb6ed -
We can see the slot value in the out/debug/<project-name>-storage_slots.json
file which is generated
at compile time:
[
{
"key": "f383b0ce51358be57daa3b725fe44acdb2d880604e367199080b4379c41bb6ed",
"value": "0000000000000000000000000000000000000000000000000000000000000000"
}
]
After upgrading to at least 0.61.0 the method for generating storage keys is changed. The hashed value
is computed using string storage.<variable-name>
(if no namespaces are used). We can see that
in the slots JSON file:
[
{
"key": "311196eb246844ea3118263d38051d8d840906389f1c07726a2743e52c7aadcb",
"value": "0000000000000000000000000000000000000000000000000000000000000000"
},
]
That's how we compute the hash:
$ echo -n "storage.var" | shasum -a 256 -
311196eb246844ea3118263d38051d8d840906389f1c07726a2743e52c7aadcb -
0.61.0 changed also the namespaces concept in the Sway storage implementation. Namespaces now can be declared
within the storage
block and variables placed within, eg:
storage {
ns1 {
var_ns1: u64 = 1,
}
}
In that case the key
(or slot
) will be computed using the following string: storage::ns1.var_ns1
. As we
can see the namespace identifier is also used to compute the hash. Namespaces can be nested and if that is
the case then more ::<namespace>
sections will be added to the string.
You can also specify the slot directly in the storage
block using the in
keyword:
storage {
var in 0x02dac99c283f16bc91b74f6942db7f012699a2ad51272b15207b9cc14a70dbae: u64,
}
We can see the slot inside the JSON file:
[
{
"key": "02dac99c283f16bc91b74f6942db7f012699a2ad51272b15207b9cc14a70dbae",
"value": "0000000000000000000000000000000000000000000000000000000000000000"
}
]
Details can be found in the PR and the Sway compiler code here.
Each StorageKey
struct has the offset
field which represents the place within the slot where the value
is placed. It looks like the compiler is always putting there a value of 0
.
Let's see how our values are written into the storage. We will create a small struct
with two u64
fields
and we will assign them some values. Then we will have a look at the slot JSON file. The code looks like this:
struct S {
a: u64,
b: u64,
}
storage {
s: S = S { a: 1, b: 2 },
}
Because we assigned some values other than zeros to our struct's fields, we should see them in our storage layout:
[
{
"key": "06ee74b45a12fd83e7df88440f9b3aef7a275760bf6742de13f2007316af87cb",
"value": "0000000000000001000000000000000200000000000000000000000000000000"
}
]
And indeed we do! They are placed from left to right. Each field of the struct occupies 64 bits as per the field's types.
Now let's see what happens if our struct is bigger than 256 bits. Let's create one like this:
struct S {
a: u64,
b: u64,
c: u64,
d: u64,
e: u64,
}
storage {
s: S = S { a: 1, b: 2, c: 3, d: 4, e: 5},
}
We can see the results in the storage layout:
[
{
"key": "06ee74b45a12fd83e7df88440f9b3aef7a275760bf6742de13f2007316af87cb",
"value": "0000000000000001000000000000000200000000000000030000000000000004"
},
{
"key": "06ee74b45a12fd83e7df88440f9b3aef7a275760bf6742de13f2007316af87cc",
"value": "0000000000000005000000000000000000000000000000000000000000000000"
}
]
Our struct now occupies two slots, where the second slot key
is incremented by one from the one calculated using the
method demostrated above. We can use structs to pack our storage densely and hence use less slots.
StorageMap
is a type which allows you to store contents in Sway storage that are addressable using
a defined key. It is similar to Solidity mapping
concept. An example declaration of a StorageMap
that will use tuple (Identity, AssetId)
to address value of type u64
:
storage {
balance_of: StorageMap<(Identity, AssetId), u64> = StorageMap {},
}
To access values of the StorageMap
we would use the insert()
and get()
methods. An example insertion
would look like this:
storage.balance_of.insert((address, asset), new_balance);
This is pretty strightforward. Regarding reading it is a bit more complex, because storage
slots in Sway when not initialized behave differently during reading. The low level function read()
as
defined here returns Option<T>
.
When the slot was previously written to, it will return Some(value)
, but if it wasn't it will be None
.
This approach is propagated up to the StorageMap.get()
hence we need to handle it e.g. in the following way:
let status = storage.balance_of.get((account, asset)).try_read();
match status {
Option::Some(balance) => balance,
Option::None => 0,
}
Now, when we insert()
data, into which slot does it go? In the end the StorageMap
declaration balance_of
has its own slot which is calculated as shown in the previous sections. Yet here we can include multiple
keys with different values into the StorageMap
. Obviously this needs to go into different slots.
Let's see how insert()
is implemented:
#[storage(read, write)]
pub fn insert(self, key: K, value: V)
where
K: Hash,
{
let key = sha256((key, self.field_id()));
write::<V>(key, 0, value);
}
So we are using the field_id()
and key
to compute a hash which will determine our storage slot. field_id()
is calculated similarly to the slot
, details in the PR and the Sway compiler code here.