Soroban Contract State Management

Soroban Contract State Management

Stellar is an open-source public blockchain originally designed for fast, low-cost cross-border payments and on-chain asset issuance. In 2024, Stellar extended its capabilities with Soroban — a Rust-based smart contract platform that compiles to WebAssembly(Wasm) for high performance. Soroban brings fully programmable on-chain logic to the network, enabling DeFi primitives, tokenization, and other decentralized applications, while preserving the throughput and low-fee characteristics Stellar is known for. A central piece of that design is how contract state is persisted: every byte written to the ledger is carried by every validator on the network, so Soroban's design took a deliberately different path from Ethereum's EVM.

To minimize on-chain data, Soroban adopts a contract state storage model that differs significantly from the EVM. Developers familiar with EVM smart contract development may inadvertently introduce security risks if they lack a comprehensive understanding of Soroban's storage architecture. This article explores potential security issues related to Soroban contract state storage and highlights key considerations during development, helping Soroban smart contract developers avoid storage-related vulnerabilities.

Note: The on-chain contracts referenced in this article were deployed purely to demonstrate the vulnerability. They are isolated proof-of-concepts and should not be used for any purposes.

Overview

The State Bloat Problem

On Stellar, ledger state refers to the current snapshot of all account balances, smart contract code, and contract data that must remain readily accessible on validator nodes — any transaction might read from or write to any part of it.

State bloat is the continuous, unbounded growth of an on-chain state. Left unchecked, as contract data accumulates on the ledger, it degrades state-sync and transaction-execution performance, and pushes the network towards centralization as nodes require more RAM and faster SSDs to keep up.

Soroban's Solution: State Expiration and Rent

Soroban addresses state bloat through a State Expiration and Rent model. Every piece of contract data must pay rent to remain alive on the ledger for a specific Time-To-Live (TTL), which refers to the number of ledgers between the current ledger and the final ledger where the data can still be accessed. Once a data entry's TTL reaches zero, it is evicted from the live state. The main purpose of using this approach is to help a Stellar validator node minimize the amount of storage occupied by the chain live state as much as possible.

To give developers granular control over data lifecycle and cost, Soroban provides three distinct storage types:

1. Temporary Storage — Cost Optimization

Blockchains process vast amounts of ephemeral data — cryptographic nonces, short-lived token allowances, live oracle price feeds etc. Before Soroban, developers had to permanently burden the network with this transient data. Temporary storage lets developers pay a fraction of the cost for data they know will soon become irrelevant.

2. Persistent Storage — Data Safety & Archival

Critical data such as token balance or deposit records must not be permanently deleted simply because a user forgot to interact with the contract. When using persistent storage, the inactive data is evicted from the live network by archiving it, while allowing users to seamlessly restore their data by paying the rent fee.

3. Instance Storage — Developer Convenience & Efficiency

Global contract configuration — an admin's public key, a protocol fee rate, contract metadata — would be burdensome to manage individually. Instance storage groups this core configuration together. As long as the contract itself is kept alive, all instance storage entries remain alive with it.

Instance Storage is essentially a form of Persistent Storage with a fixed key , ledger_key_contract_instance. Through inspection of the on-chain contract storage for CCH3FOSFZYPHNV72RSWJDI6XF7OFURKZAM2DR4TDOKNG4SFZE5E4PKNU, we observed that the durability of the entry keyed by ledger_key_contract_instance is Persistent.

Storage Comparison

Feature Temporary Instance Persistent
Fees Cheap Expensive (same as Persistent) Expensive (same as Instance)
Capacity Unlimited (per-key limit) Limited (single-entry, 64 KB combined) Unlimited (per-key limit)
When TTL Reaches 0 Permanently deleted — cannot be restored Archived — restorable via InvokeHostFunction or RestoreFootprintOp Archived — restorable via InvokeHostFunction or RestoreFootprintOp
TTL Relationship Independent of the contract instance Shares the same TTL as the contract instance Independent — may be archived even while the contract is active
Use Cases Ephemeral data (price oracles, signatures, nonces) Shared contract state (admin accounts, metadata) User data that must survive (e.g., token balances)

By splitting storage into these three categories, Soroban forces developers to reason explicitly about data lifecycle. The network can automatically purge ephemeral data (Temporary), archive forgotten but important data (Persistent), and streamline management of core configuration (Instance) — keeping the Stellar network fast, cheap, and scalable.

While State Expiration and Rent help mitigate state bloat, they also introduce potential smart contract–level security risks.

Potential Security Issues

1. Misuse of Temporary Storage for Critical Data

Storing long-term critical data (such as user balances) in temporary storage causes irreversible state loss when the entry's TTL reaches zero. Unlike persistent and instance storage, expired temporary entries are permanently deleted and cannot be restored.

In the following contract (CCH3FOSFZYPHNV72RSWJDI6XF7OFURKZAM2DR4TDOKNG4SFZE5E4PKNU), user deposit amounts are stored in temporary storage:

#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};

#[contracttype]
pub enum DataKey {
    Balance(Address),
}

#[contract]
pub struct Contract;

#[contractimpl]
impl Contract {
    pub fn deposit(env: Env, user: Address, amount: i128) {
        user.require_auth();

        let key = DataKey::Balance(user);

        let current: i128 = env
            .storage()
            .temporary()
            .get(&key)
            .unwrap_or(0);

        env.storage()
            .temporary()
            .set(&key, &(current + amount));
    }

    // ...

    pub fn balance(env: Env, user: Address) -> i128 {
        env.storage()
            .temporary()
            .get(&DataKey::Balance(user))
            .unwrap_or(0)
    }
}

After depositing 100 2315fcdc123258e4aba9d208de0cffd98e8d2eeb512e7f139699f35bf0591832, balance() correctly returns 100 32e12cc560bc884ef35a1e50cfa31e5e0ef41b971e31e7c7d257b5b0e97d6cf3.

soroban-hello-world % stellar contract invoke \
  --id CCH3FOSFZYPHNV72RSWJDI6XF7OFURKZAM2DR4TDOKNG4SFZE5E4PKNU \
  --source-account alice \
  --network testnet \
  --send yes \
  -- \
  deposit \
  --user GBEA5Z3MBTLHEQHZYU3GUZIKABRADWJSOSD62GHBIVUUAWRMXTU6U2EW \
  --amount 100

✅ Transaction submitted successfully!
soroban-hello-world % stellar contract invoke \
  --id CCH3FOSFZYPHNV72RSWJDI6XF7OFURKZAM2DR4TDOKNG4SFZE5E4PKNU \
  --source-account alice \
  --network testnet \
  --send yes \
  -- \
  balance \
  --user GBEA5Z3MBTLHEQHZYU3GUZIKABRADWJSOSD62GHBIVUUAWRMXTU6U2EW

✅ Transaction submitted successfully!
"100"

However, once the temporary entry expires (1 hour/day on testnet/mainnet by default ), the balance is permanently lost and balance() returns 0 1481cb6af5248183df8d9f281e757f24c77428863faac212702de69726c2a8d3:

soroban-hello-world % stellar contract invoke \
  --id CCH3FOSFZYPHNV72RSWJDI6XF7OFURKZAM2DR4TDOKNG4SFZE5E4PKNU \
  --source-account alice \
  --network testnet \
  --send yes \
  -- \
  balance \
  --user GBEA5Z3MBTLHEQHZYU3GUZIKABRADWJSOSD62GHBIVUUAWRMXTU6U2EW

✅ Transaction submitted successfully!
"0"
Root Cause

Temporary storage has a short default TTL (1 hour/day on testnet/mainnet by default ) . If there is no code logic to call extend_ttl(), when a temporary ledger entry expires, it is permanently deleted — unlike persistent and instance storage entries, which are archived and restorable. Accessing a non-existent entry returns None, which unwrap_or(0) silently converts to a zero balance.

Solution

Use persistent or instance storage for any data that must survive beyond a short window. These storage types have longer default TTLs and, critically, support restoration after archival:

// Persistent storage — individual TTL per entry, restorable
let current: i128 = env
    .storage()
    .persistent()
    .get(&key)
    .unwrap_or(0);

env.storage()
    .persistent()
    .set(&key, &(current + amount));
// Instance storage — shares TTL with contract, restorable
let current: i128 = env
    .storage()
    .instance()
    .get(&key)
    .unwrap_or(0);

env.storage()
    .instance()
    .set(&key, &(current + amount));

For user-specific data like balances, persistent storage is the appropriate choice — it provides independent TTLs per entry and unbounded aggregate capacity.

Comparison with EVM

In the EVM, all contract data shares the same lifecycle as the contract itself and remains active on-chain indefinitely. Developers do not need to worry about storage expiration or risk silent state loss.

2. Security Mechanisms Relying on TTL Expiry

Using TTL expiry as the sole criteria for determining whether data is outdated may introduce the risk of allowing stale data to remain in use.

A common issue is to use a nonce with an expiration time (e.g., 1 hour) in a signed digest. If the nonce expires, the corresponding signature becomes invalid. However, if the short-lived nonce is stored in temporary storage and the contract relies solely on TTL expiry to invalidate it, any user can extend the TTL and keep the signature valid indefinitely.

In the following contract CACIRKIEHSSFS5PGOBUOE5BWJNPJS3HDK2WG3BKEFMTL7THMVCT3KZ2U, a nonce is stored in temporary storage and signature verification depends on the nonce entry still being alive:

#![no_std]
use soroban_sdk::{
    contract, contractimpl, contracttype,
    Address, Bytes, BytesN, Env, String,
};

#[contract]
pub struct Contract;

#[contracttype]
#[derive(Clone)]
pub enum DataKey {
    Nonce(BytesN<32>),
}

#[contractimpl]
impl Contract {
    /// Store a nonce in temporary storage keyed by the signer's ed25519 public key.
    pub fn set_nonce(env: Env, public_key: BytesN<32>, nonce: u64) {
        let allowed = Address::from_string(&String::from_str(
            &env,
            "GBEA5Z3MBTLHEQHZYU3GUZIKABRADWJSOSD62GHBIVUUAWRMXTU6U2EW",
        ));

        allowed.require_auth();

        // Stored in temporary storage (default TTL: 720 ledgers)
        env.storage()
            .temporary()
            .set(&DataKey::Nonce(public_key), &nonce);
    }

    /// Verify an ed25519 signature over the nonce, then consume it.
    pub fn verify_sig(
        env: Env,
        public_key: BytesN<32>,
        signature: BytesN<64>,
        nonce: u64,
    ) {
        let key = DataKey::Nonce(public_key.clone());

        // VULNERABILITY:
        // Relies on TTL expiry to invalidate the nonce.
        let stored: u64 = env
            .storage()
            .temporary()
            .get(&key)
            .unwrap_or_else(|| panic!("no nonce stored for key"));

        if stored != nonce {
            panic!("nonce mismatch");
        }

        let msg = Bytes::from_array(&env, &nonce.to_be_bytes());

        env.crypto()
            .ed25519_verify(&public_key, &msg, &signature);

        // Consume the nonce
        env.storage().temporary().remove(&key);
    }
}

After nonce 7 is stored 139e8bb94a6bded7ee64d44d7984540875c1ecd9ebbc4dbbfaffa92cfebbf2d3 (valid until ledger 2,186,823), a malicious actor (Bob) can extend its TTL 6cbfd177cdbb71dab4eab070f187763ebbf252488315a2d1b3c2ffc05e8723e9 before it expires and is consumed:

$ stellar contract invoke \
  --id CACIRKIEHSSFS5PGOBUOE5BWJNPJS3HDK2WG3BKEFMTL7THMVCT3KZ2U \
  --source-account alice \
  --network testnet \
  --send yes \
  -- \
  set_nonce \
  --public_key ed4928c628d1c2c6eae90338905995612959273a5c63f93636c14614ac8737d1 \
  --nonce 7

✅ Transaction submitted successfully!

$ stellar contract extend \
  --network testnet \
  --source-account bob \
  --id CACIRKIEHSSFS5PGOBUOE5BWJNPJS3HDK2WG3BKEFMTL7THMVCT3KZ2U \
  --key-xdr AAAAEAAAAAEAAAACAAAADwAAAAVOb25jZQAAAAAAAA0AAAAg7UkoxijRwsbq6QM4kFmVYSlZJzpcY/k2NsFGFKyHN9E= \
  --durability temporary \
  --ledgers-to-extend 518400  # 30 days

✅ Transaction submitted successfully!
New ttl ledger: 2,704,647  # original: 2,186,823

The signature that should have expired at ledger 2,186,823 now remains valid until ledger 2,704,647. Verification succeeds well past the intended expiration 6f00c0d36fb278268f83c3d77dd262301b74ce530297d1933d6a243a4420a770. The above transaction was executed at ledger 2,188,611, which is greater than the original TTL ledger and less than the new TTL ledger.

soroban-hello-world % stellar contract invoke \
  --id CACIRKIEHSSFS5PGOBUOE5BWJNPJS3HDK2WG3BKEFMTL7THMVCT3KZ2U \
  --source-account alice \
  --network testnet \
  --send yes \
  -- \
  verify_sig \
  --public_key ed4928c628d1c2c6eae90338905995612959273a5c63f93636c14614ac8737d1 \
  --signature 379d4f0f355d31a25685feea1afb754933ce639495db7db8ca9e938bba48a21c49a67669315e701bb5dd3f489755fd700e1757a2a85569b2d552858bbea00102 \
  --nonce 7

✅ Transaction submitted successfully!
Root Cause

In Stellar, TTL extension is permissionless, anyone can extend any persistent, temporary, or instance ledger entry's TTL, provided they are willing to pay the associated resource fees. TTL expiry is therefore not a reliable security boundary.

Solution

Use a custom timestamp field to enforce expiration within the contract logic, rather than relying on ledger entry TTL.

#[contracttype]
#[derive(Clone)]
pub struct NonceEntry {
    pub value: u64,
    pub expires_at: u64, // ledger sequence or Unix timestamp
}

// During verification:
let entry: NonceEntry = env
    .storage()
    .temporary()
    .get(&key)
    .unwrap();

if env.ledger().sequence() > entry.expires_at {
    panic!("nonce expired");
}
Comparison with EVM

In the EVM, no TTL concept exists — contract data shares the same lifecycle as the contract itself and remains active on-chain indefinitely. This class of vulnerability does not apply.

3. Contract Storage Bloat Attack

A Storage Bloat Attack is a resource-exhaustion vulnerability where an attacker deliberately fills a smart contract's state with massive amounts of junk data, potentially causing a denial-of-service (DoS) condition.

While Stellar's rent and expiration mechanisms mitigate network-wide state bloat, the vulnerability persists at the contract level when developers store unbounded vectors or mappings, such as user lists, token pairs, or withdrawal requests, in a single ledger entry — regardless of whether instance or persistent storage is used.

In the following Contract CCD2UQIC54M6RE3TQBSC5WFOGAWF5YWCINWPMHLSXYFBZNNO75Z4W32U, each call to increment() appends 32 KB of payload data into the contract instance storage under a single COUNTER key:

#![no_std]
use soroban_sdk::{
    contract, contractimpl, contracttype,
    log, symbol_short,
    Bytes, Env, Symbol,
};

const COUNTER: Symbol = symbol_short!("COUNTER");
const PAYLOAD_SIZE: u32 = 32 * 1024;

#[contracttype]
#[derive(Clone)]
pub struct CounterData {
    pub count: u32,
    pub payload: Bytes,
}

#[contract]
pub struct Contract;

#[contractimpl]
impl Contract {
    pub fn increment(env: Env) -> u32 {
        let mut data: CounterData = env
            .storage()
            .instance()
            .get(&COUNTER)
            .unwrap_or_else(|| CounterData {
                count: 0,
                payload: Bytes::new(&env),
            });

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

        data.count += 1;

        let new_payload = build_payload(&env);

        // VULNERABILITY: unbounded payload growth
        data.payload.append(&new_payload);

        // Stores everything under a single ledger entry
        env.storage()
            .instance()
            .set(&COUNTER, &data);

        env.storage()
            .instance()
            .extend_ttl(5, 10);

        data.count
    }
}

fn build_payload(env: &Env) -> Bytes {
    let mut payload = Bytes::new(env);

    let chunk = [0u8; 1024];

    for _ in 0..(PAYLOAD_SIZE / 1024) {
        payload.extend_from_slice(&chunk);
    }

    payload
}

The first invocation 2d4d58554ec360ba3b1d24dfe2c79157388ae00add66a15a9d617b7efdc40cbe after contract deployment succeeds, inserting 32 KB:

$ stellar contract invoke \
  --id CCD2UQIC54M6RE3TQBSC5WFOGAWF5YWCINWPMHLSXYFBZNNO75Z4W32U \
  --source-account alice \
  --network testnet \
  --send yes \
  -- \
  increment

✅ Transaction submitted successfully!
1

The second invocation fails with ResourceLimitExceeded — the instance storage cap is breached:

$ stellar contract invoke \
  --id CCD2UQIC54M6RE3TQBSC5WFOGAWF5YWCINWPMHLSXYFBZNNO75Z4W32U \
  --source-account alice \
  --network testnet \
  --send yes \
  -- \
  increment

❌ error: transaction submission failed:
    InvokeHostFunction(ResourceLimitExceeded)

Using persistent storage instead CAPJXRSUCRW6HVNWQUJ5S756JN3T4EITIWHLBJW6WVVYTQHWTWV2QUUB will produce the same result — the per-entry 64 KB limit applies.

Root Cause

Instance Storage stores all key-value pairs within a single ledger entry (indexed by ledger_key_contract_instance). The total combined size of all keys, values, and XDR overhead cannot exceed 64 KB. Exceeding this limit causes the contract to fail.

Persistent and Temporary Storage allocate a separate ledger entry per key-value pair. The 64 KB limit applies to each pair independently. Aggregate data can be virtually unlimited, provided no single entry exceeds 64 KB.

Archived entries are propagated across validator, RPC, and Archive History System nodes during archiving and restoration. To prevent large entries from degrading the efficiency of these processes, the size of each ledger entry is restricted.

Solution

Distribute data across multiple independent ledger entries by using variable composite keys, rather than continuously expanding a single collection.

#[contracttype]
pub enum DataKey {
    // ...
    Stakers(Address),
}

// Each staker occupies its own ledger entry — no single-entry bloat
env.storage()
    .persistent()
    .set(&DataKey::Stakers(user_address), &true);

env.storage()
    .persistent()
    .get(&DataKey::Stakers(user_address))
    .unwrap_or(false);
Comparison with EVM

In the EVM, unbounded arrays and mappings are stored across a sparse key–value space of 2^{256} slots by default. Values are distributed across multiple independent slots, each capable of storing 32 bytes (256 bits) of data.

For dynamic arrays, the declared slot stores only the array length. The actual elements begin at keccak256(p) and are laid out contiguously from that position. Therefore, element i of a uint256[] located at slot p is stored at keccak256(p) + i.

Code Block

For mappings, the declared slot serves only as a marker and does not store any data directly. For a mapping located at slot p, the value associated with key k is stored at keccak256(k . p), where . denotes the concatenation of the 32-byte-padded key and the 32-byte-padded slot index.

Code Block 2

For a dynamic array nested inside a mapping, such as mapping(uint => uint[]) C, EVM combines the storage layout rules for both types. To find the exact slot number for C[k][i], the complete formula is: keccak256(abi.encode(keccak256(abi.encode(k, p)))) + i

  1. p is the maker slot of the mapping C.
  2. K is the unit key you are looking up.

Code Block 3

Because of this architectural difference between EVM and Soroban, when writing EVM contracts, developers do not need to worry about per-entry size limits.

TTL Extension Management Best Practice

Every Soroban ledger entry — contract instance, contract code, and contract data — carries a TTL (Time-To-Live): the number of ledgers between the current ledger and the final ledger at which the data can still be accessed. Once the TTL reaches zero, the entry is archived and becomes inaccessible to contract logic.

Restoring Archived Entries

Archived ledger entries can be restored through two mechanisms:

  1. RestoreFootprintOp — Submit a standalone restore transaction that explicitly targets the archived entries.
  2. InvokeHostFunctionOp with a restore list — Include the archived entries in the restore list of a contract invocation transaction, enabling automatic restoration as part of normal contract calls. (Available since Protocol 23.)

However, restoration is not free. Archived entries are treated as disk-based data during recovery, making the operation slower and more expensive than accessing live state — subject to rent, read, and write fees.

We recommend that developers adopt the proactive TTL extension approach described in the following section to extend the TTL of contract data before expiration.

Proactive TTL Extension

To ensure continuous availability of Soroban contracts, developers should proactively extend the TTLs of contract instances, contract WASM code, and contract data before expiration. There are following two different strategies:

Strategy 1: In-Contract Extension
  1. For contract instances and contract WASM code, extend the TTL at the beginning of every public user-facing function.
  2. For persistent entries, extend the TTL whenever the data is read or written. Only extend the specific keys accessed within that function, rather than all keys in the contract.
  3. For temporary entries, TTL extension is generally unnecessary. These entries are designed to be ephemeral and will be deleted (rather than archived) upon expiration.

In the following contract example, extend_ttl(threshold, extend_to) is called within every user-facing function so that the cost of TTL renewal is naturally distributed across users during normal interactions. The threshold parameter serves as a safeguard — the extension is triggered only when the remaining TTL falls below the specified value, preventing redundant and unnecessary extensions on every call.

#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, Address, Env};

const LEDGER_THRESHOLD: u32 = 100; // Extend only when TTL < 100 ledgers
const LEDGER_EXTEND_TO: u32 = 500; // Reset TTL to 500 ledgers

#[contracttype]
pub enum DataKey {
    Balance(Address),
    Config,
}

#[contracttype]
#[derive(Clone, Debug)]
pub struct Config {
    pub admin: Address,
    pub max_deposit: i128,
}

#[contract]
pub struct Contract;

#[contractimpl]
impl Contract {
    /// One-time setup: store global config in the instance ledger entry.
    pub fn initialize(env: Env, admin: Address, max_deposit: i128) {
        admin.require_auth();

        assert!(
            !env.storage().instance().has(&DataKey::Config),
            "already initialized"
        );

        env.storage()
            .instance()
            .set(&DataKey::Config, &Config { admin, max_deposit });
    }

    /// Return the current global config and refresh the instance TTL.
    pub fn get_config(env: Env) -> Config {
        env.storage()
            .instance()
            .extend_ttl(LEDGER_THRESHOLD, LEDGER_EXTEND_TO);

        env.storage()
            .instance()
            .get(&DataKey::Config)
            .expect("not initialized")
    }

    /// Deposit an amount into the caller's persistent balance.
    pub fn deposit(env: Env, user: Address, amount: i128) {
        user.require_auth();

        env.storage()
            .instance()
            .extend_ttl(LEDGER_THRESHOLD, LEDGER_EXTEND_TO);

        let config: Config = env
            .storage()
            .instance()
            .get(&DataKey::Config)
            .expect("not initialized");

        assert!(amount <= config.max_deposit, "exceeds max deposit");

        let key = DataKey::Balance(user);

        let current: i128 = env
            .storage()
            .persistent()
            .get(&key)
            .unwrap_or(0);

        env.storage()
            .persistent()
            .set(&key, &(current + amount));

        env.storage()
            .persistent()
            .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_EXTEND_TO);
    }

    /// Withdraw an amount from the caller's persistent balance.
    pub fn withdraw(env: Env, user: Address, amount: i128) {
        user.require_auth();

        env.storage()
            .instance()
            .extend_ttl(LEDGER_THRESHOLD, LEDGER_EXTEND_TO);

        let key = DataKey::Balance(user);

        let current: i128 = env
            .storage()
            .persistent()
            .get(&key)
            .unwrap_or(0);

        assert!(current >= amount, "insufficient balance");

        env.storage()
            .persistent()
            .set(&key, &(current - amount));

        env.storage()
            .persistent()
            .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_EXTEND_TO);
    }

    /// Return the current persistent balance for a user.
    pub fn balance(env: Env, user: Address) -> i128 {
        env.storage()
            .instance()
            .extend_ttl(LEDGER_THRESHOLD, LEDGER_EXTEND_TO);

        let key = DataKey::Balance(user);

        let bal: i128 = env
            .storage()
            .persistent()
            .get(&key)
            .unwrap_or(0);

        if bal > 0 {
            env.storage()
                .persistent()
                .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_EXTEND_TO);
        }

        bal
    }
}

In the example above, we call env.storage().instance().extend_ttl() to extend the TTL of the contract instance. It is important to note that this function simultaneously extends the TTL of both

  1. the contract instance, which contains the WASM code reference and contract configuration data, is relatively small in size
  2. and the contract WASM code, which contains the entire contract bytecode, can be shared among multiple contract instances, is significantly larger.

When the contract WASM code is relatively small and is associated with only one or very few contract instances, directly calling env.storage().instance().extend_ttl() can be a reasonable approach for TTL extension.

However, the following two scenarios may lead to massively overpaying issue — where the contract WASM code is being extended thousands of times more than necessary:

  1. Your WASM is deployed as a shared template across many instances (factory pattern, AMM pools, token contracts, etc.)
  2. Your WASM is particularly large, making code extension disproportionately expensive

In Soroban, the fee paid for TTL extension is proportional to the size of the entry being extended. As a result, extending the TTL of the contract WASM code is significantly more expensive than extending the contract instance.

In the above two scenarios, calling env.storage().instance().extend_ttl() in every contract instance would cause that the same contract WASM code will repeatedly have its TTL extended. This happens because different contract instances may have different expiration schedules, while the contract WASM code TTL is extended every time any associated contract instance extends its TTL. This results in unnecessary fee payments. See here for more details.

The recommended solution is to handle TTL extension for the contract instance and the contract WASM code separately via two functions extend_instance_ttl and extend_code_ttl (Available since Protocol 21).

const INSTANCE_THRESHOLD: u32 = 17_280;  // ~1 day
const INSTANCE_EXTEND_TO: u32 = 120_960; // ~7 days
const CODE_THRESHOLD: u32 = 17_280;      // ~1 day
const CODE_EXTEND_TO: u32 = 34_560;      // ~2 days

pub fn some_user_facing_fn(env: Env) {
    // Bump instance aggressively — it's small and cheap
    env.deployer().extend_instance_ttl(
        env.current_contract_address(),
        INSTANCE_THRESHOLD,
        INSTANCE_EXTEND_TO,
    );

    // If threshold isn't reached, TTL extension doesn't execute.
    env.deployer().extend_code_ttl(
        env.current_contract_address(),
        CODE_THRESHOLD,
        CODE_EXTEND_TO,
    );
}
Strategy 2: External Extension via CLI

We can use the In-Contract Extension approach to ensure the contract TTL does not expire, leveraging the frequency of user interactions with the contract. However, in the special scenarios listed below, the External Extension via CLI approach becomes useful: periodically submit ExtendFootprintTTLOp transactions (e.g., through a cron job or monitoring service) to keep shared state alive.

Low-Traffic or Dormant Contracts

Even if a contract includes self-extension logic, it only executes when the contract is actively invoked.

For example, consider a governance contract that is used only once every three months for voting. With a 7-day extend_to setting, the contract could expire long before the next interaction occurs. In such cases, periodically submitting CLI-based extension transactions (e.g., through a cron job or automated monitoring service) helps maintain the contract’s TTL during inactive periods.

# 1. Extend the contract instance
$ stellar contract extend \
  --source alice \
  --network testnet \
  --id CABC...XYZ \
  --ledgers-to-extend 535679 \
  --durability persistent

# 2. Extend the contract code (Wasm)
$ stellar contract extend \
  --source alice \
  --network testnet \
  --wasm-hash abc123...def \
  --ledgers-to-extend 535679 \
  --durability persistent

# 3. Extend contract persistent data
$ stellar contract extend \
  --network testnet \
  --source-account bob \
  --id CACIRKI***VCT3KZ2U \
  --key-xdr AAAAEAAA*****QM4kFmVYSlZJzpcY/k2NsFGFKyHN9E= \
  --durability temporary \
  --ledgers-to-extend 535679

Initial Deployment Bootstrap

Immediately after deployment, a contract may require an extended TTL window before any user interaction takes place. Since self-extension logic cannot execute until the first invocation, the contract may remain inactive for days or even weeks while awaiting frontend launch, marketing rollout, audits, or initial adoption.

# Deploy
$ stellar contract deploy \
    --wasm target/wasm32v1-none/release/my_contract.wasm \
    --source-account alice \
    --network testnet

# Immediately give it a long TTL so it survives until launch
$ stellar contract extend \
    --network testnet \
    --source-account alice \
    --id CABC...NEW_CONTRACT \
    --ledgers-to-extend 535679

Managing Shared WASM Code Independently

When hundreds of pool instances share the same WASM code, projects may prefer to minimize in-contract extension operations to reduce user costs. Instead, a dedicated operator or keeper bot can manage WASM TTL extensions externally and independently from regular user interactions.

# Extend the contract code (Wasm)
$ stellar contract extend \
  --source alice \
  --network testnet \
  --wasm-hash abc123...def \
  --ledgers-to-extend 535679 \
  --durability persistent

Conclusion

The article walked through Soroban's different storage tiers and the security vulnerabilities that arise from misusing them. With more assets flowing onto Stellar, ensuring smart contract security has never been more critical. CertiK is actively researching the Stellar ecosystem and will continue to publish more of our research to help strengthen its security.

Related Blogs

Skynet State of Digital Asset Regulations Report

Skynet State of Digital Asset Regulations Report

For companies operating or planning to scale globally, the implications are that multi-jurisdictional licensing is now a baseline requirement; AML compliance budgets must align with the scale of enforcement; and security audits are recurring, jurisdiction-specific costs, rather than one-time exercises.

Navigating the 2026 Winter of U.S. Crypto Legislation

Navigating the 2026 Winter of U.S. Crypto Legislation

An overview of regulatory developments in the United States in January 2026, including the Senate Banking draft, GENIUS Act implementation, and the SEC “Task Force” transition.

From Foundations to Frameworks: A Look Back at 2025 and the 2026 Crypto Roadmap

From Foundations to Frameworks: A Look Back at 2025 and the 2026 Crypto Roadmap

As we begin 2026, the crypto industry is no longer fighting for the right to exist; instead, it is racing against the legislative clock to finalize the rules of the game before the political tides could shift once again.