Skip to main content

State Data

There are two types of data in a smart contract: state data and transient data. State data is data that is stored on the blockchain, and is persistent. Transient data is data that is stored during the execution of a transaction, and is not persistent. The second the transaction is over, the transient data is gone.

There are two parts to storing data on the blockchain: the model and the table. The model is the data structure that you will be storing, and the table is the container that holds the data. There are a few different types of tables, and each one has its own use case.

Data Models

A model is a data structure that you will be storing in an EOS++ table. It is a serializable C++ struct, and can contain any data type that is also serializable. All common data types are serializable, and you can also create your own serializable data types, such as other models that start with the TABLE keyword.

TABLE UserModel {
uint64_t id;
name eos_account;

uint64_t primary_key() const { return id; }
};

Defining a model is very similar to defining a C++ struct, but with a few differences. The first difference is that you must use the TABLE keyword instead of the struct keyword. The second difference is that you must define a primary_key function that returns a uint64_t. This function is used to determine the primary key of the table, which is used to index the table.

Think of this like a NoSQL database, where the primary key is the index of the table. The primary key is used to determine the location of the data in the table, and is used to retrieve the data from the table easily and efficiently.

Primary key data types

The primary key must be a uint64_t (you can also use name.value), and it must be unique for each row in the table. This means that you cannot have two rows with the same primary key. If you need to have multiple rows with the same primary key, you can use a secondary index.

Secondary key data types

A secondary index is more flexible than a primary key, and can be any of the following data types:

  • uint64_t
  • uint128_t
  • double
  • long double
  • checksum256

They can also include duplicate values, which means that you can have multiple rows with the same secondary key.

💰 Cost considerations

Each index costs RAM per row, so you should only use secondary indices when you need to. If you don't need to query the table by a certain field, then you should not create an index for that field.

Payer & Scope

Before we dig into how to store data in tables, we need to talk about scope and payer.

RAM Payer

The RAM payer is the account that will pay for the RAM that is used to store the data. This is either the account that is calling the contract, or the contract itself. This sometimes relies heavily on game-theory, and can be a complex decision. For now, you will just use the contract itself as the RAM payer.

You also cannot have an account that isn't part of the transaction's authorizations pay for RAM.

💰 Beware of RAM

RAM is a limited resource on the EOS blockchain, and you should be careful about how much RAM you allow others to use on your contracts. It's often better to make the user pay for the RAM, but this requires that you create incentives for them to spend their own RAM in return for something of perceived equal or greater value.

Scope

The scope of a table is a way to further segregate the data in the table. It is a uint64_t that is used to determine what bucket the data is stored in.

If we were to imagine the database as a JSON object, it might look like this:

tables.json
{
"users": {
1: [
{
"id": 1,
"eos_account": "bob"
},
{
"id": 2,
"eos_account": "sally"
}
],
2: [
{
"id": 1,
"eos_account": "joe"
}
]
}
}

As you can see above, you can have the same primary key in different scopes without there being a conflict. This is useful in a variety of different cases:

  • If you want to store data per-user
  • If you want to store data per-game-instance
  • If you want to store data per-user-inventory
  • etc

Multi-Index Table

The multi-index table is the most common way to store data on the EOS blockchain. It is a persistent key-value store that can be indexed in multiple ways, but always has a primary key. Going back to the NoSQL database analogy, you can think of the multi-index table as a collection of documents, and each index as a different way to query or fetch data from the collection.

Defining a table

To create a multi-index table you must have a model defined with at least a primary key. You can then create a multi-index table by using the multi_index template, and passing in the name of the table/collection and the model you want to use.

TABLE UserModel ...

using users_table = multi_index<"users"_n, UserModel>;

This will create a table called users that uses the UserModel model. You can then use this table to store and retrieve data from the blockchain.

Instantiating a table

To do anything with a table, you must first instantiate it. To do this, you must pass in the contract that owns the table, and the scope that you want to use.

ACTION test() {
name thisContract = get_self();
users_table users(thisContract, thisContract.value);

Inserting data

Now that you have a reference to an instantiated table, you can insert data into it. To do this, you can use the emplace function, which takes a lambda/anonymous function that accepts a reference to the model that you want to insert.

...

name ramPayer = thisContract;
users.emplace(ramPayer, [&](auto& row) {
row.id = 1;
row.eos_account = name("eosio");
});

You can also define a model first, and insert it into the entire row.

UserModel user = {
.id = 1,
.eos_account = name("eosio")
};

users.emplace(ramPayer, [&](auto& row) {
row = user;
});

Retrieving data

To retrieve data from a table, you will use the find method on the table, which takes the primary key of the row that you want to retrieve. This will return an iterator (reference) to the row.

auto iterator = users.find(1);

You need to check if you actually found the row, because if you didn't, then the iterator will be equal to the end iterator, which is a special iterator that represents the end of the table.

if (iterator != users.end()) {
// You found the row
}

You then have two ways of accessing the data in the row. The first way is to use the -> operator, which will give you a pointer to the row's data, and the second way is to use the * operator, which will give you the row's raw data.

UserModel user = *iterator;
uint64_t idFromRaw = user.id;
uint64_t idFromRef = iterator->id;

Modifying data

If you tried to call emplace twice you would get an error because the primary key already exists. To modify data in a table, you must use the modify method, which takes a reference to the iterator you want to modify, a RAM payer, and a lambda/anonymous function that allows us to modify the data.

users.modify(iterator, same_payer, [&](auto& row) {
row.eos_account = name("foobar");
});

What is same_payer

You can use same_payer to make the RAM payer the same as the original ram payer. This is useful if someone else has paid for the RAM, but you want to modify the data. If you don't use same_payer, then you will have to pay for the RAM yourself. You will also have to pay for the RAM if you are changing fields with mutable size, such as string or vector.

Removing data

To remove data from a table, you must use the erase method, which takes a reference to the iterator you want to remove.

users.erase(iterator);

Using a secondary index

Using a secondary index will allow you to query your table in a different way. For example, if you wanted to query your table by the eos_account field, you will need to create a secondary index on that field.

Redefining our model and table

To use a secondary index, you must first define it in your model. You do this by using the indexed_by template, and passing in the name of the index, and the type of the index.

TABLE UserModel {
uint64_t id;
name eos_account;

uint64_t primary_key() const { return id; }
uint64_t account_index() const { return eos_account.value; }
};

using users_table = multi_index<"users"_n, UserModel,
indexed_by<"byaccount"_n, const_mem_fun<UserModel, uint64_t, &UserModel::account_index>>
>;

The indexed_by template can be a bit confusing, so let's break it down.

indexed_by<
<name_of_index>,
const_mem_fun<
<model_to_use>,
<index_type>,
<pointer_to_index_function>
>
>

The name_of_index is the name of the index that you want to use. This can be anything, but it's best to use something that describes what the index is for.

The model_to_use is the model that you want to use for the index. This is usually the same model that you are using for the table, but it doesn't have to be. This is useful if you want to use a different model for the index, but still want to be able to access the data in the table.

The index_type is the type of the index, and is limited to the types we discussed earlier.

The pointer_to_index_function is a pointer to a function that returns the value that you want to use for the index. This function must be a const_mem_fun function, and must be a member function of the model that you are using for the index.

Using the secondary index

Now that you have a secondary index, you can use it to query your table. To do this, first get the index from the table, and then use the find method on the index, instead of using it directly on the table.

auto index = users.get_index<"byaccount"_n>();
auto iterator = index.find(name("eosio").value);

To modify data in the table using the secondary index, you use the modify method on the index, instead of using it directly on the table.

index.modify(iterator, same_payer, [&](auto& row) {
row.eos_account = name("foobar");
});

Singleton Table

A singleton table is a special type of table that can only have one row per scope. This is useful for storing data that you only want to have one instance of, such as a configuration, or a player's inventory.

The primary differences between a singleton table and a multi-index table are:

  • Singletons do not need a primary key on the model
  • Singletons can store any type of data, not just predefined models

Defining a table

To define a singleton table, you use the singleton template, and pass in the name of the table, and the type of the data that you want to store.

You also must import the singleton.hpp header file.

#include <eosio/singleton.hpp>

TABLE ConfigModel {
bool is_active;
};

using config_table = singleton<"config"_n, ConfigModel>;

using is_active_table = singleton<"isactive"_n, bool>;

The singleton template is identical to the multi_index template, except that it does not support secondary indices.

You've defined one table that stores a ConfigModel, and another table that stores a bool. Both tables hold the exact same data, but the bool table is more efficient because it does not need to store the added overhead that is caused by the ConfigModel struct.

Instantiating a table

Just like the multi_index table, you must first instantiate the table, and then you can use it.

name thisContract = get_self();
config_table configs(thisContract, thisContract.value);

The singleton table takes two parameters in its constructor. The first parameter is the contract that the table is owned by, and the second parameter is the scope.

Getting data

There are a few ways to get data from a singleton.

Get or fail

This will error out at runtime if there is no existing data. To prevent this, you can use the exists method to check if there is existing data.

if (!configs.exists()) {
// handle error
}
ConfigModel config = configs.get();
bool isActive = config.is_active;

Get or default

This will return a default value, but will not persist the value.

ConfigModel config = configs.get_or_default(ConfigModel{
.is_active = true
});

Get or create

This will return a default value, and will persist the value.

ConfigModel config = configs.get_or_create(ConfigModel{
.is_active = true
});

Setting data

To persist data in a singleton, you can use the set method, which takes a reference to the data that you want to set.

configs.set(ConfigModel{
.is_active = true
}, ramPayer);

Removing data

Once you've instantiated a singleton, it's easy to remove it. Just called the remove method on the instance itself.

configs.remove();