Skip to main content

Testing with Ledger Snapshot

Ledger snapshots can be used to test smart contracts with a local copy of the ledger’s entries. Learn how to create a ledger snapshot and use it for smart contract testing in a few simple steps. The examples here will cover the following use cases:

  • Read stored value from the ledger data
  • Read stored value by calling a smart contract function
  • Read the balance of an account

1. Create a default project

Before getting into creating a ledger snapshot and getting into the testing, let’s first create a simple smart contract that can be used for the test examples.

Get started by creating the default Hello World contract:

stellar contract init <project_name>

The smart contract has two functions: set_value() for storing a value, and get_value() for reading the value from storage. Replace the default hello() function with these two functions:

#![no_std]
use soroban_sdk::{contract, contractimpl, vec, Env, String, Vec};

#[contract]
pub struct Contract;

#[contractimpl]
impl Contract {
pub fn set_value(env: Env, key: String, value: u32) {
env.storage().persistent().set(&key, &value);
}

pub fn get_value(env: Env, key: String) -> u32 {
env.storage().persistent().get(&key).unwrap_or(0)
}
}

Since we want to write a test that reads data stored on the ledger, or more precisely, in a snapshot of the ledger, we must build, deploy, and invoke the smart contract.

Build contract

Use the Stellar CLI to build the contract:

stellar contract build

Deploy contract

Next, we deploy the contract to Testnet:

stellar contract deploy \
--wasm target/wasm32v1-none/release/hello_world.wasm \
--source alice \
--network testnet

The deploy command will return the contract’s address (e.g., CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN), which will be used in the following steps.

####Invoke contract

Finally, we invoke the set_value() function to store a value on the Testnet ledger:

stellar contract invoke \
--id CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN \
--source alice \
--network testnet \
-- \
set_value \
--key "count" --value 123456

Now we have a working, deployed smart contract function that has stored a value on the ledger. You can verify the value by invoking the get_value() function.

2. Create a ledger snapshot

A snapshot of the ledger can be created using the Stellar CLI. The CLI command allows you to customize and limit the scope of the snapshot, since it’s most likely not necessary for you to create a snapshot of all ledger entries. See the documentation for full details about how to limit the snapshot.

For the examples used here, we want to limit the ledger snapshot to include entries related to:

  • The smart contract
  • The user alice
  • The native token address (XLM)

From the smart contract project root, create the ledger snapshot with this command:

stellar snapshot create \
--output json \
--network testnet \
--address CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN \
--address alice \
--address CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC

The command will store the snapshot as a JSON file named snapshot.json.

This is a sample of the snapshot, which shows the value stored when the contract function set_value() was invoked:

{
"protocol_version": 23,
"sequence_number": 801343,
"timestamp": 0,
"network_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472",
"base_reserve": 1,
"min_persistent_entry_ttl": 0,
"min_temp_entry_ttl": 0,
"max_entry_ttl": 0,
"ledger_entries": [
...
[
{
"contract_data": {
"contract": "CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN",
"key": {
"string": "count"
},
"durability": "persistent"
}
},
[
{
"last_modified_ledger_seq": 801330,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN",
"key": {
"string": "count"
},
"durability": "persistent",
"val": {
"u32": 123456
}
}
},
"ext": "v0"
},
4294967295
]
],
]
...
}

We can now start to write a test script that loads the snapshot into the test environment.

If the smart contract function set_value() is invoked again, run the stellar snapshot create command again to ensure the snapshot reflects the ledger.

3. Read stored values and balances

To illustrate how smart contract tests can utilize the ledger snapshot data, we will read the stored value in two different ways and get the balance of the user alice.

Loading snapshot data

As a starting point, we use the test file test.rs included in the default smart contract project we created and modified. Since the hello() function was removed and replaced with the functions to store and get a value, the test for hello() can also be removed.

First, the ledger snapshot data is loaded into the environment:

#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
;
}

Read stored value from the ledger

In this first example, we read the stored value directly from the ledger using env.storage(). The method env.as_contract() allows us to execute code in the context of a given contract ID. This means we can execute code as if we were inside the contract.

First, we define the contract ID for the test, which is the contract ID returned when the smart contract was deployed.

Then env.as_contract() is used to get the value from storage, which means from the ledger. When the contract function set_value() was invoked, we used count as the key, so the same key is used to get the value.

#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
);

let contract_id = Address::from_str(
&env,
"CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN"
);

env.as_contract(&contract_id, || {
let val: u32 = env.storage().persistent().get(
&String::from_str(&env, "count")
).unwrap_or(0);
assert_eq!(val, 123456);
});
}

Now run the test:

cargo test

The expected value is 123456, and the value is checked by assert_eq!(). If the test doesn’t pass, the test will panic.

Read stored value with contract function

The more typical way of reading a stored value in tests is to call the contract function that reads the value. The test for this approach is the same as if the default environment were loaded, except we don’t need to register the contract in the test; we use a real contract ID, like in the previous example, because in the ledger snapshot, the value is tied to the specific contract ID.

#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
);

let contract_id = Address::from_str(
&env,
"CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN"
);

let client = ContractClient::new(&env, &contract_id);

let value: u32 = client.get_value(&String::from_str(&env, "count"));

assert_eq!(value, 123456);
}

This example shows there are only minor differences between testing contract functions with the default environment and the snapshot environment.

Read account balance

In this last example, we want to read the balance of an account. It can be the balance of a user account or of a smart contract - any account that can hold a balance. In this example, we check the balance of the user account alice.

In this example, we assume that alice already has a balance in XLM, and since the examples are based on Testnet, the alice account was funded by FriendBot if this guide was followed while creating the account. The address of alice can be looked up by running this command:

stellar keys address alice

Tokens, such as XLM, are wrapped in a contract interface called the Stellar Asset Contract (SAC). This interface provides a convenient way to query an account’s balance. The first step of getting the account balance is to create a contract client for XLM tokens.

The SAC address for XLM is a reserved address and will be the same for all projects. USDC and other assets will have another SAC address, but they do not change. We use the SAC address to define a client using TokenClient.

Then we define the address of the account we want to look up the balance of, and call client.balance() with the account address as the argument.

#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
);

let client = TokenClient::new(
&env,
&Address::from_string(&String::from_str(
&env,
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
))
);

let account = Address::from_string(&String::from_str(
&env,
"GDKOYSOKU4TNHGGR763NOL6VNY52WKJL3XI33TOUKDNF4AZN7SOROBAN"
));

assert_eq!(client.balance(&account), 99602342515);
}

The account can be a user, like in this case, but it can also be a contract holding assets - like a smart contract wallet.