Persisting Data
Ledger entries
Contracts can access ledger entries of type CONTRACT_DATA
. Host functions are provided to probe, read, write, and delete CONTRACT_DATA
ledger entries.
Each CONTRACT_DATA
ledger entry is keyed in the ledger by the contract ID that owns it, its storage type (Persistent
, Temporary
, Instance
) as well as a single user-chosen value, of the standard value type. This means that the user-chosen key may be a simple value such as a symbol, number or binary blob, or it may be a more complex structured value like a vector or map with multiple sub-values.
Each CONTRACT_DATA
ledger entry also holds (in addition to its key) a single value associated with the key. Again, this value may be simple like a symbol or number, or may be complex like a vector or map with many sub-values.
No serialization or deserialization is required in contract code when accessing CONTRACT_DATA
ledger entries: the host automatically serializes and deserializes any ledger entries accessed, exchanging them with the contract as deserialized values. If a contract wishes to use a custom serialization format, it can store a binary-valued CONTRACT_DATA
ledger entry and provide its own code to serialize and deserialize, but Soroban has been designed with the intent to minimize the need for contracts to ever do this.
Access Control
Contracts are only allowed to read and write CONTRACT_DATA
ledger entries owned by the contract: those keyed by the same contract ID as the contract performing the read or write. Attempting to access other CONTRACT_DATA
ledger entries will cause a transaction to fail.
There is no access control for lifetime bump operations. Any user may invoke BumpFootprintExpirationOp
on any LedgerEntry.
Granularity
A CONTRACT_DATA
ledger entry is read or written from the ledger in its entirety; there is no way to read or write "only a part" of a CONTRACT_DATA
ledger entry. There is also a fixed overhead cost to accessing any CONTRACT_DATA
ledger entry. Contracts are therefore responsible for dividing logically "large" data structures into "pieces" with an appropriate size granularity, to use for reading and writing. If pieces are too large there may be unnecessary costs paid for reading and writing unused data, as well as unnecessary contention in parallel execution; but if pieces are too small there may be unnecessary costs paid for the fixed overhead of each entry.
Footprints and parallel contention
Contracts are only allowed to access ledger entries specified in the footprint of their transaction. Transactions with overlapping footprints are said to contend, and will only execute sequentially with respect to one another, on a single thread. Transactions with non-overlapping footprints may execute in parallel. This means that a finer granularity of CONTRACT_DATA
ledger entries may reduce artificial contention among transactions using a contract, and thereby increase parallelism.
Contract Data Best Practices
Account State vs. Shared State
While there is no distinction between "account" state and "shared" state at the protocol level, it can be helpful to think of data in these terms when deciding what storage type and lifetime bump strategy to use for a given use case. As a guideline, account and shared state can be described as follows:
- Most contract state can either be associated with a specific account or shared between multiple stakeholders (“public good”)
- Account specific state
- Balances, positions, allowances, etc.
- Shared state
- Admin entry, pool values, etc.
- Account specific state
- There are two subcategories of shared state
- "Global" state shared by all contract users
- Contract instance, contract wasm, or a global admin
- State shared by only a specific subset of users
- AMM pool values In an AMM monolith contract (Uniswap V4 style)
- "Global" state shared by all contract users
- Note: Sometimes account and shared state can merge when the contract scope is small
- I.e. smart wallet or a single nft, where a new contract instance is generated for each account
Owned Contracts vs. Autonomous Contracts
In addition to the types of state, it is also helpful to consider the type of contract instance being used. Again, these types are not enforced at the protocol level, but can be helpful.
- Owned contracts
- Contract instances that have a clear owner
- A smart wallet or a single nft, where a new contract instance is generated for each account
- Custom reserve backed assets (USDC, etc)
- Contract instances that have a clear owner
- Autonomous contracts
- Contract instance that have not clear owner, or have a decentralized group of owners
- Most DeFi protocols, especially non-upgradable ones
- Contract instance that have not clear owner, or have a decentralized group of owners
Best Practices
- Prefer
Temporary
overPersistent
andInstance
storage- Anything that can have a timeout should be
Temporary
with expiration set to the timeout. Maximum timeout/expiration is currently 1 year - Ideally,
Temporary
entries should be associated with an absolute ledger boundary and thus never bumped- Example: Soroban Auth signatures have an absolute expiration ledger, so nonces can be stored in
Temporary
entries without security risks - Example: SAC allowance that lives only until a given ledger (so that some old allowance signature can not be used in the future if not exhausted)
- Example: Soroban Auth signatures have an absolute expiration ledger, so nonces can be stored in
- Anything that can have a timeout should be
- All global state that cannot be
Temporary
should be inInstance
storage- This guarantees that the lifetime of the contract instance and all relevant globals are tied together
- Since global state is used often, this ensures that global state will never need to be restored, leading to cheaper and more efficient contract invocations
- Autonomous contracts should bump any shared state touched by an invocation via the
bump()
host function- Given that these contracts have no owners, leaving lifetime bumps to benevolent clients might lead to a “tragedy of the commons” situation
- Most users do not bump the lifetime because it is not required, but still benefit from the benevolent client who does bump the lifetime
- Owners of owned contracts should subsidize shared state lifetime extension fees by manually submitting bump operations
- Owners should keep track of expiration ledgers for all shared state
- This could be implemented via something like a cron job where a
BumpFootprintExpirationOp
for all relevant shared state is submitted periodically (i.e. once a month) - Alternatively, this could also be implemented via an admin smart contract function routinely called by the owner
- Clients (Wallets/Dapps) should identify the state that relates to their respective account, present expiration info to the users, and suggest lifetime bumps when necessary
- Lifetime extensions should never be relied on for functionality or safety
- Unsafe example: An entry must be permanent, but instead of using
Persistent
storage, a contract usesTemporary
storage but will continually bump the entry so it never expires- Because there is no automatic lifetime extension interface, and every bump must come from either a smart contract invocation or
BumpFootprintExpirationOp
, it must be assumed that an entry can always expire - Lifetime extension fees are variable, and it may become too expensive to bump pseudo “persistent”
Temporary
entries if fees increase unexpectedly - If the rent bump process is automated (i.e. a cron job) the account automatically sending
BumpFootprintExpirationOps
may run out of funds unexpectedly should lifetime extension fees increase, causing theTemporary
entry to expire and be permanently deleted
- Because there is no automatic lifetime extension interface, and every bump must come from either a smart contract invocation or
- Unsafe example: An entry must be permanent, but instead of using
- Entry expiration should never be relied on for functionality or safety
- Unsafe example: a nonce should expire in 7 days, so a contract creates a
Temporary
entry and bumps the lifetime to 7 days with no other lifetime enforcement in place. - Anyone can submit a rent bump operation on any entry without authorization, meaning that the nonce can have its lifetime extended indefinitely by any user
- If an entry needs to be invalidated after a certain time period, this must be implemented manually by the contract
- Unsafe example: a nonce should expire in 7 days, so a contract creates a