Skip to main content

2. Storing Data

Now that we've built a basic Hello World example to see the rough structure of Soroban contracts, we'll write a simple contract that stores and retrieves data. This will help you see the basics of Soroban's storage system. We'll also organize the two contracts as one combined project using a Cargo Workspace, which is a common pattern for Soroban projects.

This is going to follow along with the increment example, which has a single function that increments an internal counter and returns the value. If you want to see a working example, try it in GitPod.

This tutorial assumes that you've already completed the previous steps in Getting Started: Setup and Hello World.

Setting up a multi-contract project

Many Soroban projects need more than one contract. Cargo makes this easy with workspaces, though it doesn't yet give a way to initialize a new project as a workspace (see #8365). Let's set it up manually.

Rather than just a hello-soroban folder, we want a new soroban-tutorial folder with a contracts folder inside, into which we'll move the existing hello-soroban project. As a diff, we want this:

-hello-soroban
+soroban-tutorial/contracts/hello-soroban

So change into the parent directory of hello-soroban and:

mkdir -p soroban-tutorial/contracts
mv hello-soroban soroban-tutorial/contracts
cd soroban-tutorial

You're going to want some Rust and Cargo stuff in different spots. From the new project root, run:

rm contracts/hello-soroban/Cargo.lock
mv contracts/hello-soroban/target .
cp contracts/hello-soroban/Cargo.toml .

Note that we copied the Cargo.toml file. That's because we're going to need some of it in the root and some of it in the subdirectory.

In the root Cargo.toml:

  • remove the [package], [lib], [features], and [dev_dependencies] sections
  • keep the [release.*] stuff
  • replace the line [dependencies] with [workspace.dependencies]
  • add a [workspace] section

In the project-specific Cargo.toml, keep roughly the opposite sections, and use the dependency versions as specified in the workspace root. It all ends up looking like this:

[workspace]
resolver = "2"
members = [
"contracts/*",
]

[workspace.dependencies]
soroban-sdk = "0.9.2"

[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true

[profile.release-with-logs]
inherits = "release"
debug-assertions = true

Now make sure everything works:

soroban contract build

Everything should build.

cargo test

All tests should pass.

Code

Rather than initializing this contract with cargo new, let's copy the hello-soroban project:

cp -r contracts/hello-soroban contracts/incrementor

You'll need to update the Cargo.toml file to reflect the correct name:

contracts/incrementor/Cargo.toml
 [package]
-name = "hello-soroban"
+name = "incrementor"
version = "0.1.0"
edition = "2021"

And now in contracts/incrementor/src/lib.rs, we'll replace the contents with the following:

contracts/incrementor/src/lib.rs
#![no_std]
use soroban_sdk::{contract, contractimpl, log, symbol_short, Env, Symbol};

const COUNTER: Symbol = symbol_short!("COUNTER");

#[contract]
pub struct IncrementorContract;

#[contractimpl]
impl IncrementorContract {
/// Increment an internal counter; return the new value.
pub fn increment(env: Env) -> u32 {
let mut count: u32 = env.storage().instance().get(&COUNTER).unwrap_or(0);

count += 1;

log!(&env, "count: {}", count);

env.storage().instance().set(&COUNTER, &count);

env.storage().instance().bump(100);

count
}
}

#[cfg(test)]
mod test;

Make sure it builds:

soroban contract build

Check that it built:

ls target/wasm32-unknown-unknown/release/*.wasm

You should see both hello_soroban.wasm and incrementor.wasm.

How it Works

Follow along in your contracts/incrementor/src/lib.rs file.

Contract Data Keys

Contract data is associated with a key. The key is the value that can be used at a later time to lookup the value.

Symbol is a short (up to 32 characters long) string type with limited character space (only a-zA-z0-9_ characters are allowed). Identifiers like contract function names are represented by Symbols.

The symbol_short!() macro is a convenient way to pre-compute short symbols up to 9 characters in length at compile time using Symbol::short. It generates a compile-time constant that adheres to the valid character set of letters (a-zA-Z), numbers (0-9), and underscores (_). If a symbol exceeds the 9-character limit, Symbol::new should be utilized for creating symbols at runtime.

const COUNTER: Symbol = symbol_short!("COUNTER");

Contract Data Access

The Env.storage() function is used to access and update contract data. The executing contract is the only contract that can query or modify contract data that it has stored. The data stored is viewable on ledger anywhere the ledger is viewable, but contracts executing within the Soroban environment are restricted to their own data.

The get() function gets the current value associated with the counter key.

let mut count: u32 = env
.storage()
.instance()
.get(&COUNTER)
.unwrap_or(0); // If no value set, assume 0.

If no value is currently stored, the value given to unwrap_or(...) is returned instead.

Values stored as contract data and retrieved are transmitted from the environment and expanded into the type specified. In this case a u32. If the value can be expanded, the type returned will be a u32. Otherwise, if a developer caused it to be some other type, a panic would occur at the unwrap.

The set() function stores the new count value against the key, replacing the existing value.

Managing Contract Data Lifetimes with bump()

All contract data has a "lifetime" that must be periodically bumped. If an entry's lifetime is not periodically bumped, the entry will eventually reach the end of its lifetime and "expire". You can learn more about this in the State Expiration document.

For now, it's worth knowing that there are three kinds of storage: Persistent, Temporary, and Instance. This contract only uses Instance storage: env.storage().instance(). Every time the counter is incremented, this storage gets bumped by 100 ledgers, or about 500 seconds.

Run on Sandbox

Let's invoke this contract the same way we invoked the Hello World contract. We'll use --id 2, since we already used --id 1 for the Hello World contract. For Sandbox, these values are stored in a .soroban/ledger.json file in the current directory; you can always remove this file to reset the ledger.

soroban contract invoke \
--wasm target/wasm32-unknown-unknown/release/incrementor.wasm \
--id 2 \
-- \
increment

You should see the following output:

1

Rerun the invoke with the --footprint option to view the footprint of the invocation, which is the ledger entries that the contract will read or write to.

soroban contract invoke \
--wasm target/wasm32-unknown-unknown/release/incrementor.wasm \
--id 2 \
--footprint \
-- \
increment

You should see:

Footprint: {"readOnly":[{"contractData":{"contractId":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"key":{"static":"ledgerKeyContractCode"}}}],"readWrite":[{"contractData":{"contractId":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"key":{"symbol":[67,79,85,78,84,69,82]}}}]}
Footprint formats are unstable

Soroban is a pre-release and at this time outputs footprints in an unstable JSON format.

Run it without the --footprint a few more times to watch the count change.

Use contract read to inspect what the full storage looks like after a few runs.

soroban contract read --id 1 --key COUNTER

Tests

Open the contracts/increment/src/test.rs file and replace the contents with:

contracts/incrementor/src/test.rs
use crate::{IncrementorContract, IncrementorContractClient};
use soroban_sdk::Env;

#[test]
fn increment() {
let env = Env::default();
let contract_id = env.register_contract(None, IncrementorContract);
let client = IncrementorContractClient::new(&env, &contract_id);

assert_eq!(client.increment(), 1);
assert_eq!(client.increment(), 2);
assert_eq!(client.increment(), 3);
}

This uses the same concepts described in the Hello World example.

Make sure it passes:

cargo test

You'll see that this runs tests for the whole workspace; both the Hello World contract and the new Incrementor.

If you want to see the output of the log! call, run the tests with --nocapture:

cargo test -- --nocapture

You should see the output:

running 1 test
count: U32(0)
count: U32(1)
count: U32(2)
test test::incrementor ... ok

Take it further

Can you figure out how to add get_current_value function to the contract? What about decrement or reset functions?

Commit your changes

Looking at your git diff will be interesting now. It's probably kind of noisy if you just run git status or git diff right away, but once you git add ., git will understand the renames (aka "moves") of all the old hello-soroban files better.

Go ahead and commit it.

git commit -m "add incrementor contract"

Summary

In this section, we added a new contract to this project, reorganizing the project as a multi-contract project using Cargo Workspaces. The new contract made use of Soroban's storage capabilities to store and retrieve data. We also learned about the different kinds of storage and how to manage their lifetimes.

Next we'll learn to deploy contracts to Soroban's Testnet network and interact with them over RPC using the CLI.