Skip to main content

Test TTL extension logic in Smart Contracts

In order to test contracts that extend the contract data TTL via extend_ttl storage operations, you can use the TTL getter operation (get_ttl) in combination with manipulating the ledger sequence number. Note, that get_ttl function is only available for tests and only in Soroban SDK v21+.

Example

Follow along the example that tests TTL extensions. The example has extensive comments, this document just highlights the most important parts.

We use a very simple contract that only extends an entry for every Soroban storage type:

#[contractimpl]
impl TtlContract {
/// Creates a contract entry in every kind of storage.
pub fn setup(env: Env) {
env.storage().persistent().set(&DataKey::MyKey, &0);
env.storage().instance().set(&DataKey::MyKey, &1);
env.storage().temporary().set(&DataKey::MyKey, &2);
}

/// Extend the persistent entry TTL to 5000 ledgers, when its
/// TTL is smaller than 1000 ledgers.
pub fn extend_persistent(env: Env) {
env.storage()
.persistent()
.extend_ttl(&DataKey::MyKey, 1000, 5000);
}

/// Extend the instance entry TTL to become at least 10000 ledgers,
/// when its TTL is smaller than 2000 ledgers.
pub fn extend_instance(env: Env) {
env.storage().instance().extend_ttl(2000, 10000);
}

/// Extend the temporary entry TTL to become at least 7000 ledgers,
/// when its TTL is smaller than 3000 ledgers.
pub fn extend_temporary(env: Env) {
env.storage()
.temporary()
.extend_ttl(&DataKey::MyKey, 3000, 7000);
}
}

The focus of the example is the tests, so the following code snippets come from test.rs.

It's a good idea to define the custom values of TTL-related network settings, since the defaults are defined by the SDK and aren't immediately obvious for the reader of the tests:

env.ledger().with_mut(|li| {
// Current ledger sequence - the TTL is the number of
// ledgers from the `sequence_number` (exclusive) until
// the last ledger sequence where entry is still considered
// alive.
li.sequence_number = 100_000;
// Minimum TTL for persistent entries - new persistent (and instance)
// entries will have this TTL when created.
li.min_persistent_entry_ttl = 500;
// Minimum TTL for temporary entries - new temporary
// entries will have this TTL when created.
li.min_temp_entry_ttl = 100;
// Maximum TTL of any entry. Note, that entries can have their TTL
// extended indefinitely, but each extension can be at most
// `max_entry_ttl` ledger from the current `sequence_number`.
li.max_entry_ttl = 15000;
});

You could also use the current network settings when setting up the tests, but keep in mind that these are subject to change, and the contract should be able to work with any values of these settings.

Now we run a test scenario that verifies the TTL extension logic (see test_extend_ttl_behavior test for the full scenario). First, we setup the data and ensure that the initial TTL values correspond to the network settings we've defined above:

client.setup();
env.as_contract(&contract_id, || {
// Note, that TTL doesn't include the current ledger, but when entry
// is created the current ledger is counted towards the number of
// ledgers specified by `min_persistent/temp_entry_ttl`, thus
// the TTL is 1 ledger less than the respective setting.
assert_eq!(env.storage().persistent().get_ttl(&DataKey::MyKey), 499);
assert_eq!(env.storage().instance().get_ttl(), 499);
assert_eq!(env.storage().temporary().get_ttl(&DataKey::MyKey), 99);
});

Notice, that we use env.as_contract in order to access the contract's storage.

Then we call the TTL extension operations and verify that they behave as expected, for example:

// Extend persistent entry TTL to 5000 ledgers - now it is 5000.
client.extend_persistent();
env.as_contract(&contract_id, || {
assert_eq!(env.storage().persistent().get_ttl(&DataKey::MyKey), 5000);
});

In order to test the extension thresholds (i.e. maximum current TTL that requires extension), we need to increase the ledger sequence number:

// Now bump the ledger sequence by 5000 in order to sanity-check
// the threshold settings of `extend_ttl` operations.
env.ledger().with_mut(|li| {
li.sequence_number = 100_000 + 5_000;
});
// Now the TTL of every entry has been reduced by 5000 ledgers.
env.as_contract(&contract_id, || {
assert_eq!(env.storage().persistent().get_ttl(&DataKey::MyKey), 0);
assert_eq!(env.storage().instance().get_ttl(), 5000);
assert_eq!(env.storage().temporary().get_ttl(&DataKey::MyKey), 2000);
});

Then we can extend the entries again and ensure that only entries that are below threshold have been extended (specifically, persistent and temporary entries in this example):

client.extend_persistent();
client.extend_instance();
client.extend_temporary();
env.as_contract(&contract_id, || {
assert_eq!(env.storage().persistent().get_ttl(&DataKey::MyKey), 5000);
// Instance TTL hasn't been increased because the remaining TTL
// (5000 ledgers) is larger than the threshold used by
// `extend_instance` (2000 ledgers)
assert_eq!(env.storage().instance().get_ttl(), 5000);
assert_eq!(env.storage().temporary().get_ttl(&DataKey::MyKey), 7000);
});

Soroban SDK also emulates the behavior for the entries that have their TTL expired. Temporary entries behave 'as if' they were deleted (see test_temp_entry_removal test for the full scenario):

client.extend_temporary();
// Bump the ledger sequence by 7001 ledgers (one ledger past TTL).
env.ledger().with_mut(|li| {
li.sequence_number = 100_000 + 7001;
});
// Now the entry is no longer present in the environment.
env.as_contract(&contract_id, || {
assert_eq!(env.storage().temporary().has(&DataKey::MyKey), false);
});

Persistent entries are more subtle: when a transaction that is executed on-chain contains a persistent entry that has been archived (i.e. it has it's TTL expired) in the footprint, then the Soroban environment will not even be instantiated. Since this behavior is not directly reproducible in test environment, instead an irrecoverable 'internal' error will be produced as soon as an archived entry is accessed, and the test will panic:

#[test]
#[should_panic(expected = "[testing-only] Accessed contract instance key that has been archived.")]
fn test_persistent_entry_archival() {
let env = create_env();
let contract_id = env.register_contract(None, TtlContract);
let client = TtlContractClient::new(&env, &contract_id);
client.setup();
// Extend the instance TTL to 10000 ledgers.
client.extend_instance();
// Bump the ledger sequence by 10001 ledgers (one ledger past TTL).
env.ledger().with_mut(|li| {
li.sequence_number = 100_000 + 10_001;
});
// Now any call involving the expired contract (such as `extend_instance`
// call here) will panic as soon as that contract is accessed.
client.extend_instance();
}

Testing TTL extension for other contract instances

Sometimes a contract may want to extend TTL of another contracts and/or their Wasm entries (usually that would happen in factory contracts). This logic may be covered in a similar fashion to the example above using env.deployer().get_contract_instance_ttl(&contract) to get TTL of any contract's instance, and env.deployer().get_contract_code_ttl(&contract) to get TTL of any contract's Wasm entry. You can find an example of using these function in the SDK test suite.