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:
- Cargo.toml
- contracts/hello-soroban/Cargo.toml
[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
[package]
name = "hello-soroban"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
soroban-sdk = { workspace = true }
[dev_dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
[features]
testutils = ["soroban-sdk/testutils"]
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:
[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:
#![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 Symbol
s.
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]}}}]}
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:
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.