State Expiration
Contract data is made up of three different types: Persistent
, Temporary
, and Instance
. In a contract, these are accessed with env.storage().persistent()
, env.storage().temporary()
, and env.storage().instance()
respectively; see the storage()
docs.
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". Each type of storage functions similarly, but has different fees and expiration behavior:
- When a
Temporary
entry expires, it is deleted from the ledger and is permanently inaccessible. - When a
Persistent
orInstance
entry expires, it is inaccessible, but can be "restored" and used again via theRestoreFootprintOp
.
Contract Data Type Descriptions
The general usage and interface are identical for all storage types. They differ only in fees and expiration behavior as follows:
Temporary
- Cheapest fees.
- Permanently deleted on expiration, cannot be restored.
- Suitable for time-bounded data (i.e. price oracles, signatures, etc.) and easily recreateable data.
- Unlimited amount of storage.
Instance
- Most expensive fees (same price as
Persistent
storage). - Recoverable after expiration, can be restored using the
RestoreFootprintOp
operation. - Shares the same lifetime as the contract instance. If the contract instance has not expired, instance data is guaranteed to be accessible and not expired.
- Limited amount of storage available.
- Suitable for "shared" contract state that cannot be
Temporary
(i.e. admin accounts, contract metadata, etc.).
Persistent
- Most expensive fees (same price as
Instance
storage). - Recoverable after expiration, can be restored using the
RestoreFootprintOp
operation. - Does not share the same lifetime as the contract instance. If the contract instance is not expired,
Persistent
data may be expired and need to be restored before invoking the contract. - Unlimited amount of storage.
- Suitable for user data that cannot be
Temporary
(i.e. balances).
Contract Data Best Practices
As a general rule, Temporary
storage should only be used for data that can be easily recreated or is only valid for a period of time, whereas Persistent
or Instance
storage should be used for data that cannot be recreated and should be kept permanently, such as a user's token balance.
Each storage type is in a separate key space. To demonstrate this, see the code snippet below:
const EXAMPLE_KEY: Symbol = symbol_short!("KEY");
env.storage().persistent().set(&EXAMPLE_KEY, 1);
env.storage().temporary().set(&EXAMPLE_KEY, 2);
env.storage().persistent().get(&EXAMPLE_KEY); // Returns Ok(1)
env.storage().temporary().get(&EXAMPLE_KEY); // Returns Ok(2)
All Instance
storage is stored in a single contract instance LedgerEntry
and shares a single lifetime. This means that one call to Env.storage().instance().bump()
will extend the lifetime of all Instance
entries, as well as the contract instance itself. For Temporary
and Persistent
storage, each entry has its own lifetime and must be bumped individually. The interface is slightly different and takes the key
of the entry being bumped as well as the new lifetime.
A call to bump(N)
ensures that the current lifetime of the contract instance entry is at least N ledgers. For example, if bump(100)
is called and the contract instance entry has a current lifetime of 50 ledgers, the lifetime will be extended to 100 ledgers. If bump(100)
is called and the contract instance entry has a current lifetime of 150 ledgers, the lifetime will not be extended and the bump()
call is a no-op.
In addition to contract-defined lifetime extensions using the bump()
function, a contract data entry's lifetime can be extended via the BumpFootprintExpirationOp
operation.
Terms and Semantics
Expiration Ledger
Each ContractData
and ContractCode
entry has an expirationLedger
field stored in its LedgerEntry
.
The entry is considered expired when current_ledger > expirationLedger
.
Lifetime
An entry's lifetime is defined as how many ledgers remain until the entry expires. For example, if the current ledger is 5 and an entry's expiration ledger is 15, then the entry's lifetime is 10 ledgers.
Minimum Lifetime
For each entry type, there is a minimum lifetime that the entry must have when being created
or updated. This lifetime minimum is enforced automatically at the protocol level. This minimum
is a network parameter and defaults to 16 ledgers for Temporary
entries and 4,096 ledgers for
Persistent
and Instance
entries.
Maximum Lifetime
On any given ledger, an entry's lifetime can be extended up to the maximum lifetime. This is a network parameter and defaults to 1 year's worth of ledgers. This maximum lifetime is not enforced based on when an entry was created, but based on the current ledger. For example, if an entry is created on January 1st, 2024, its lifetime could initially be bumped up to January 1st, 2025. After this initial lifetime bump, if the entry received another lifetime bump later on January 10th, 2024, the lifetime could be extended up to January 10th, 2025.
Operations
BumpFootprintExpirationOp
Semantics
XDR:
/*
Threshold: med
Result: BumpFootprintExpirationResult
*/
struct BumpFootprintExpirationOp
{
ExtensionPoint ext;
uint32 ledgersToExpire;
};
BumpFootprintExpirationOp
is a Soroban operation that will bump the expiration
ledger of the entries specified in the read-only set of the footprint. The
read-write set must be empty. The bump will make sure that the entries will not
expire before ledgersToExpire ledgers from now.
Let's look at this example below.
Ex. Last closed ledger (LCL) = 5, Current Ledger = 6, ledgersToExpire = 8
entry1.expirationLedger = 10
entry2.expirationLedger = 14
entry3.expirationLedger = 10000
entry1.expirationLedger will be updated to 14 so it will live for 8 more ledgers
after the current one closes and the entry can be accessed in ledgers [6, 14]. Note: This is going to be updated to count
Current Ledger, so it'll become [6, 13].
entry2 and entry3 will not be updated because they already have an
expirationLedger that is large enough.
Transaction resources
BumpFootprintExpirationOp
is a Soroban operation, and therefore must be the
only operation in a transaction. The transaction also needs to populate
SorobanTransactionData
transaction extension explained
here. To fill
out SorobanResources
, use preflight mentioned in the provided link, or make
sure readBytes
includes the key and entry size of every entry in the
readOnly
set.
RestoreFootprintOp
XDR:
/*
Threshold: med
Result: RestoreFootprintOp
*/
struct RestoreFootprintOp
{
ExtensionPoint ext;
};
RestoreFootprintOp
is a Soroban operation that will restore expired entries
specified in the read-write set of the footprint and make them accessible
again. The read-only set of the footprint must be empty. An expired entry is one
where its expirationLedger is less than the current ledger number. Only persistent entries can be restored.
The restored entry will have its expiration ledger bumped to the minimum the
network allows for newly created entries, which is current_ledger_number + 4095
for
persistent entries. The minimum expiration value is a network configuration
parameter and is subject to be updated (likely increased) via network upgrades.
Transaction resources
RestoreFootprintOp
is a Soroban operation, and therefore must be the only
operation in a transaction. The transaction also needs to populate
SorobanTransactionData
transaction extension explained
here. To fill
out SorobanResources
, use preflight mentioned in the provided link, or make
sure writeBytes
includes the key and entry size of every entry in the
readWrite
set and make sure extendedMetaDataSizeBytes
is at least double of
writeBytes
.
Examples
We've done our best to build tooling around state expiration in both the Soroban RPC server as well as the JavaScript SDK to make it easier to deal with, and this set of examples demonstrates how to leverage it.
Overview
Both restoring and bumping the expiration of ledger entries follows a three-step process regardless of their nature (contract data, instances, etc.):
Identify the ledger entries. This usually means acquiring them from a Soroban RPC server as part of your initial transaction simulation (see the preflight docs and the
simulateTransaction
method).Prepare your operation. This means describing the ledger entries within the corresponding operation (i.e.
bumpFootprintOp
orrestoreFootprintOp
) and its ledger footprint (theSorobanTransactionData
field), then simulating it to fill out fee and resource usage information (when restoring, you usually have simulation results already).Submit the transaction and start again with what you were trying to do in the first place.
Each of the examples below will follow a structure like this. We'll work our way through two different scenarios:
Remember, though, that any combination of these scenarios can occur in reality.
Preparation
In order to help the scaffolding of the code, we'll introduce some reusable components. The following is a simple, rudimentary looping mechanism to submit a transaction to Soroban RPC and wait for a result:
import {
Server,
SorobanRpc,
Transaction,
FeeBumpTransaction,
} from "soroban-client";
const RPC_SERVER = "https://soroban-testnet.stellar.org/";
const server = new Server(RPC_SERVER);
// Submits a tx and then polls for its status until a timeout is reached.
async function yeetTx(
tx: Transaction | FeeBumpTransaction,
): Promise<SorobanRpc.GetTransactionResponse> {
return server.sendTransaction(tx).then(async (reply) => {
if (reply.status !== "PENDING") {
throw reply;
}
let status;
let attempts = 0;
while (attempts++ < 5) {
const tmpStatus = await server.getTransaction(reply.hash);
switch (tmpStatus.status) {
case "FAILED":
throw tmpStatus;
case "NOT_FOUND":
await sleep(500);
continue;
case "SUCCESS":
status = tmpStatus;
break;
}
}
if (attempts >= 5 || !status) {
throw new Error(`Failed to find transaction ${reply.hash} in time.`);
}
return status;
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
We'll use this helper below to submit transactions and report their status reliably.
Remember: You should always handle errors gracefully! This is a fail-hard and fail-fast approach that should only be used in these examples.
In the following code, we will also leverage Server.prepareTransaction
. This is a helpful method that, given a transaction, will simulate it, then amend the transaction with the simulation results (fees, etc.) and return that. Then, it can just be signed and submitted. We will also use SorobanDataBuilder
, a convenient abstraction that lets us use a builder pattern to set the appropriate storage footprints for a transaction.
Example: My data expired!
We'll start with the likeliest occurrence: my piece of persistent data expired off of the ledger because I haven't interacted with my contract in a while. How do I get it back?
In this example, we will assume two things: the contract itself is still alive (i.e. others have been bumping its expiration while you've been away) and you don't know how your expired data is represented on the ledger. If you did, you could skip the steps below where we figure that out and just set up the restoration footprint directly. The process involves three discrete steps:
- Simulate our transaction as we normally would.
- If the simulation indicated it, we perform restoration via
Operation.restoreFootprint
using its hints. - We retry running our initial transaction.
Let's see that in code:
import {
BASE_FEE,
Networks,
Keypair,
TransactionBuilder,
SorobanDataBuilder,
assembleTransaction,
xdr,
} from "soroban-client"; // add'l imports to preamble
// assume that `server` is the Server() instance from the preamble
async function submitOrRestoreAndRetry(
signer: Keypair,
tx: Transaction,
): Promise<SorobanRpc.GetTransactionResponse> {
// We can't use `Server.prepareTransaction` here because we want to do
// restoration if necessary, basically assembling the simulation ourselves.
const sim = await server.simulateTransaction(tx);
// Other failures are out of scope of this tutorial.
if (!SorobanRpc.isSimulationSuccess(sim)) {
throw sim;
}
// If simulation didn't fail, we don't need to restore anything! Just send it.
if (!sim.restorePreamble) {
const prepTx = assembleTransaction(tx, Networks.TESTNET, sim);
prepTx.sign(signer);
return yeetTx(prepTx);
}
//
// Build the restoration operation using the RPC server's hints.
//
const account = await server.getAccount(signer.publicKey());
let fee = parseInt(BASE_FEE);
fee += parseInt(sim.restorePreamble.minResourceFee);
const restoreTx = new TransactionBuilder(account, { fee: fee.toString() })
.setNetworkPassphrase(Networks.TESTNET)
.setSorobanData(sim.restorePreamble.transactionData.build())
.addOperation(Operation.restoreFootprint({}))
.build();
restoreTx.sign(signer);
const resp = await yeetTx(restoreTx);
if (resp.status !== SorobanRpc.GetTransactionStatus.SUCCESS) {
throw resp;
}
//
// now that we've restored the necessary data, we can retry our tx using
// the initial data from the simulation (which, hopefully, is still
// up-to-date)
//
const retryTxBuilder = TransactionBuilder.cloneFrom(tx, {
fee: (parseInt(tx.fee) + parseInt(sim.minResourceFee)).toString(),
sorobanData: sim.transactionData.build(),
});
// because we consumed a sequence number when restoring, we need to make sure
// we set the correct value on this copy
retryTxBuilder.source.incrementSequenceNumber();
const retryTx = retryTxBuilder.build();
retryTx.sign(signer);
return yeetTx(retryTx);
}
Notice that when restoration is required, simulation still succeeds. The way that we know that something needs to be restored is the presence of a restorePreamble
structure in the RPC's response. This contains both the footprint and fee needed for restoration, while the rest of the response contains the invocation simulation as if that restoration was done first.
This is great, as it means fewer round-trips to get going again!
Example: My contract expired!
As you can imagine, if the ledger cannot find your deployed contract instance or the code that backs it, it can't load it to execute your invocations. Remember, there's a distinct, one-to-many relationship on the chain between a contract's code and deployed instances of that contract:
We need both to stay on the ledger for our contract calls to work.
Let's work through how these can be recovered. The recovery process is slightly different for a convenient reason: we don't need simulation to figure out the footprints. Instead, we can leverage Contract.getFootprint()
, which prepares a footprint with the ledger keys used by a given contract instance (including its backing WASM code).
Unfortunately, we still need simulation to figure out the fees for our restoration. This, however, can be easily covered by the SDK's Server.prepareTransaction
helper, which will do simulation and assembly for us:
import {
BASE_FEE,
Contract,
Keypair,
Networks,
TransactionBuilder,
SorobanDataBuilder,
Operation,
SorobanRpc,
} from "soroban-client";
async function restoreContract(
signer: Keypair,
c: Contract,
): Promise<SorobanRpc.GetTransactionResponse> {
const account = await server.getAccount(signer.publicKey());
const restoreTx = new TransactionBuilder(account, { fee: BASE_FEE })
.setNetworkPassphrase(Networks.TESTNET)
.setSorobanData(
// Set the restoration footprint (remember, it should be in the
// read-write part!)
new SorobanDataBuilder().setReadWrite(c.getFootprint()).build(),
)
.addOperation(Operation.restoreFootprint({}))
.build();
const preppedTx = await server.prepareTransaction(
restoreTx,
Networks.TESTNET,
);
preppedTx.sign(signer);
return yeetTx(preppedTx);
}
The nice part about this approach is that it will restore both the instance and the backing WASM code if necessary, skipping either if they're already in the ledger state.