This document describes every storage key used by the TrustLink contract, the data each key holds, which storage tier it lives in, the TTL policy applied to it, and the serialization format. It is intended for developers building indexers, analytics tools, or off-chain integrations that read contract state directly via RPC.
Soroban provides two persistent storage tiers. TrustLink uses both:
| Tier | Used for | TTL behaviour |
|---|---|---|
| Instance | Admin, Version, FeeConfig |
Single shared TTL; refreshed to 30 days on every admin write |
| Persistent | All other keys (see table below) | Per-key TTL; refreshed to 30 days on every write of that key |
30 days is calculated as 17 280 ledgers/day × 30 = 518 400 ledgers
(DAY_IN_LEDGERS = 17_280, INSTANCE_LIFETIME = 518_400).
A key that is never written again will be evicted from the ledger once its TTL reaches zero. Any contract call that writes a key resets that key's TTL to the full 30-day window.
All keys and values are encoded using Soroban's XDR contracttype codec.
Every Rust type annotated with #[contracttype] is automatically serialized to
ScVal XDR when stored and deserialized back when read. There is no custom
serialization logic in TrustLink — the SDK handles it entirely.
The StorageKey enum itself is also #[contracttype], so each variant
serializes to a distinct ScVal discriminant that Soroban uses as the raw
storage key on-chain.
| Property | Value |
|---|---|
| Tier | Instance |
| TTL | Shared instance TTL, 30 days, refreshed on every set_admin call |
| Value type | Address |
| Written by | initialize |
| Read by | get_admin, Validation::require_admin |
Stores the single contract administrator address set during initialize. There
is exactly one Admin entry per contract instance. The key is a unit variant
(StorageKey::Admin) with no parameters.
Rust type:
Address| Property | Value |
|---|---|
| Tier | Instance |
| TTL | Shared instance TTL, 30 days, refreshed on every set_admin call |
| Value type | String |
| Written by | initialize |
| Read by | get_version, get_contract_metadata |
Stores the semver version string set at initialization (currently "1.0.0").
Lives in instance storage alongside Admin and shares the same TTL entry.
Rust type:
String // e.g. "1.0.0"| Property | Value |
|---|---|
| Tier | Instance |
| TTL | Shared instance TTL, 30 days, refreshed on every set_fee_config call |
| Value type | FeeConfig |
| Written by | initialize, set_fee |
| Read by | get_fee_config, create_attestation |
Stores the global fee policy for native attestation creation. The fee is
disabled by default by storing attestation_fee = 0, fee_collector = admin,
and fee_token = None during initialize.
When attestation_fee > 0, create_attestation transfers that amount of the
configured fee_token from the issuer to fee_collector before persisting the
attestation.
Rust type:
pub struct FeeConfig {
pub attestation_fee: i128, // amount charged on create_attestation
pub fee_collector: Address, // recipient of collected fees
pub fee_token: Option<Address>, // token contract used for collection
}| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed on add_issuer |
| Value type | bool (always true when present) |
| Written by | register_issuer |
| Deleted by | remove_issuer |
| Read by | is_issuer, Validation::require_issuer |
One entry exists per registered issuer. The key embeds the issuer's Address
as a parameter. Presence of the key means the address is authorized; absence
means it is not. The stored value is always true — the key acts as a set
membership flag.
Rust type:
bool // always true| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed on add_bridge |
| Value type | bool (always true when present) |
| Written by | register_bridge |
| Read by | is_bridge, Validation::require_bridge |
One entry exists per trusted bridge contract. The key embeds the bridge
contract Address as a parameter. Presence of the key means the contract is
allowed to create bridged attestations; absence means it is not.
Rust type:
bool // always true| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed on every set_attestation |
| Value type | Attestation struct |
| Written by | create_attestation, import_attestation, bridge_attestation, revoke_attestation, renew_attestation, update_expiration, revoke_attestations_batch |
| Read by | get_attestation, get_attestation_status, has_valid_claim, has_any_claim, has_all_claims, get_valid_claims, get_attestation_by_type |
The primary attestation record. The key parameter is the 32-character hex
attestation ID (a SHA-256-derived string). Attestations are never deleted —
revocation sets revoked = true in place.
Rust type:
pub struct Attestation {
pub id: String, // 32-char hex ID
pub issuer: Address, // issuer who created it
pub subject: Address, // address being attested about
pub claim_type: String, // e.g. "KYC_PASSED"
pub timestamp: u64, // ledger timestamp at creation (seconds)
pub expiration: Option<u64>, // optional expiry (seconds); None = no expiry
pub revoked: bool, // true once revoke_attestation is called
pub metadata: Option<String>, // optional issuer-supplied metadata
pub valid_from: Option<u64>, // optional future activation time (seconds)
pub imported: bool, // true when imported from an external source
pub bridged: bool, // true when created by a bridge contract
pub source_chain: Option<String>, // original chain for bridged attestations
pub source_tx: Option<String>, // original transaction/reference for bridged attestations
}Status derivation (computed at query time, not stored):
| Condition | Status |
|---|---|
valid_from is set and now < valid_from |
Pending |
revoked == true |
Revoked |
expiration is set and now >= expiration |
Expired |
| None of the above | Valid |
Priority order: Pending > Revoked > Expired > Valid.
| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed on every add_subject_attestation |
| Value type | Vec<String> — ordered list of attestation IDs |
| Written by | create_attestation, import_attestation, bridge_attestation |
| Read by | get_subject_attestations, has_valid_claim, has_any_claim, has_all_claims, get_valid_claims, get_attestation_by_type |
An append-only index mapping a subject address to all attestation IDs ever
created for that subject (including revoked and expired ones). Used for
pagination (get_subject_attestations) and for scanning all claims during
verification queries. IDs appear in insertion order.
Rust type:
Vec<String> // ordered list of 32-char hex attestation IDs| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed on every add_issuer_attestation |
| Value type | Vec<String> — ordered list of attestation IDs |
| Written by | create_attestation, import_attestation, bridge_attestation |
| Read by | get_issuer_attestations |
An append-only index mapping an attestation creator address to all attestation
IDs that address has ever created. For native/imported attestations this is the
issuer address; for bridged attestations this is the bridge contract address.
Used for pagination via get_issuer_attestations. IDs appear in insertion
order.
Rust type:
Vec<String> // ordered list of 32-char hex attestation IDs| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed on every set_issuer_metadata |
| Value type | IssuerMetadata struct |
| Written by | set_issuer_metadata |
| Read by | get_issuer_metadata |
Optional public profile that a registered issuer can attach to their address.
The key is absent until the issuer calls set_issuer_metadata for the first
time. Subsequent calls overwrite the existing record.
Rust type:
pub struct IssuerMetadata {
pub name: String, // human-readable issuer name
pub url: String, // issuer's website or documentation URL
pub description: String, // short description of the issuer's role
}| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed on every set_claim_type |
| Value type | ClaimTypeInfo struct |
| Written by | register_claim_type |
| Read by | get_claim_type_description |
One entry per registered claim type. The key parameter is the claim type
identifier string (e.g. "KYC_PASSED"). Re-registering an existing claim type
overwrites the description in place without adding a duplicate to
ClaimTypeList.
Rust type:
pub struct ClaimTypeInfo {
pub claim_type: String, // identifier, e.g. "KYC_PASSED"
pub description: String, // human-readable description
}| Property | Value |
|---|---|
| Tier | Persistent |
| TTL | Per-key, 30 days, refreshed whenever a new claim type is registered |
| Value type | Vec<String> — ordered list of claim type identifiers |
| Written by | register_claim_type (only when a new type is added) |
| Read by | list_claim_types |
A global ordered list of all registered claim type identifier strings. New
identifiers are appended on first registration; re-registering an existing type
does not append a duplicate. Used to support paginated listing via
list_claim_types(start, limit).
Rust type:
Vec<String> // ordered list of claim type identifier strings| Key | Tier | Value type | TTL window | Refreshed on write? |
|---|---|---|---|---|
Admin |
Instance | Address |
30 days | Yes (shared) |
Version |
Instance | String |
30 days | Yes (shared) |
FeeConfig |
Instance | FeeConfig |
30 days | Yes (shared) |
Issuer(Address) |
Persistent | bool |
30 days | Yes (per-key) |
Bridge(Address) |
Persistent | bool |
30 days | Yes (per-key) |
Attestation(String) |
Persistent | Attestation |
30 days | Yes (per-key) |
SubjectAttestations(Address) |
Persistent | Vec<String> |
30 days | Yes (per-key) |
IssuerAttestations(Address) |
Persistent | Vec<String> |
30 days | Yes (per-key) |
IssuerMetadata(Address) |
Persistent | IssuerMetadata |
30 days | Yes (per-key) |
ClaimType(String) |
Persistent | ClaimTypeInfo |
30 days | Yes (per-key) |
ClaimTypeList |
Persistent | Vec<String> |
30 days | Yes (on new entry) |
Every extend_ttl call in TrustLink is made inside a storage write helper in
src/storage.rs. There are no read-path TTL extensions in the current
implementation — a key's TTL is only refreshed when that key is written.
The extension target is determined at call time by get_ttl_lifetime():
fn get_ttl_lifetime(env: &Env) -> u32 {
if let Some(config) = env.storage().instance().get::<StorageKey, TtlConfig>(&StorageKey::TtlConfig) {
DAY_IN_LEDGERS * config.ttl_days // operator-configured value
} else {
DEFAULT_INSTANCE_LIFETIME // 30 × 17 280 = 518 400 ledgers
}
}Both the min_ledgers_to_live and extend_to arguments passed to
extend_ttl are set to this same value, so every write unconditionally resets
the TTL to the full window regardless of how much time remains.
Two constants define a 7-day threshold (120 960 ledgers):
| Constant | Defined in | Value |
|---|---|---|
MIN_TTL_THRESHOLD |
src/constants.rs |
7 × DAY_IN_LEDGERS = 120 960 |
MIN_TTL_THRESHOLD_LEDGERS |
src/types.rs |
7 × DAY_IN_LEDGERS = 120 960 |
These constants are reserved for a future lazy-extend pattern — a
read-path guard that would call extend_ttl only when the remaining TTL drops
below the threshold, avoiding unnecessary ledger writes on every read. Neither
constant is wired into any live code path today; all TTL extensions are
currently triggered exclusively by writes.
Instance storage holds a single shared TTL for all instance keys. Any of the following writes refreshes the entire instance TTL to the current TTL window:
| Contract function | Storage write helper | Keys covered |
|---|---|---|
initialize |
set_admin_council |
AdminCouncil, Admin, Version |
initialize / set_fee |
set_fee_config |
FeeConfig |
initialize / set_ttl_config |
set_ttl_config |
TtlConfig |
initialize / admin config calls |
set_contract_config |
ContractConfig |
pause / unpause |
set_paused |
Paused |
| Any admin-council mutation | set_admin_council |
AdminCouncil (and all other instance keys) |
set_global_stats (internal) |
set_global_stats |
GlobalStats |
Because all instance keys share one TTL entry, writing any instance key refreshes the TTL for all of them simultaneously.
Each persistent key has its own independent TTL. The table below lists every
contract function that causes a persistent extend_ttl call and which key(s)
it refreshes.
| Contract function | Storage write helper | Key(s) refreshed |
|---|---|---|
register_issuer |
add_issuer |
Issuer(issuer), IssuerList |
remove_issuer |
remove_issuer |
(key deleted — no TTL extension) |
register_bridge |
add_bridge |
Bridge(bridge), BridgeList |
create_attestation |
set_attestation |
Attestation(id) |
add_subject_attestation |
SubjectAttestations(subject) |
|
add_issuer_attestation |
IssuerAttestations(issuer) |
|
import_attestation |
same three helpers as above | Attestation(id), SubjectAttestations(subject), IssuerAttestations(issuer) |
bridge_attestation |
same three helpers as above | Attestation(id), SubjectAttestations(subject), IssuerAttestations(issuer) |
create_attestations_batch |
set_attestation × N, add_issuer_attestations_bulk |
Attestation(id) × N, IssuerAttestations(issuer) |
revoke_attestation |
set_attestation |
Attestation(id) |
revoke_attestations_batch |
set_attestation × N |
Attestation(id) × N |
renew_attestation / update_expiration |
set_attestation |
Attestation(id) |
transfer_attestation |
set_attestation, remove_issuer_attestation, add_issuer_attestation |
Attestation(id), IssuerAttestations(old_issuer), IssuerAttestations(new_issuer) |
cosign_attestation (on threshold) |
set_attestation, add_subject_attestation, add_issuer_attestation |
Attestation(id), SubjectAttestations(subject), IssuerAttestations(issuer) |
set_issuer_metadata |
set_issuer_metadata |
IssuerMetadata(issuer) |
register_claim_type |
set_claim_type |
ClaimType(claim_type), ClaimTypeList (list only on first registration) |
set_whitelist_mode / enable_whitelist |
set_whitelist_mode |
IssuerWhitelistMode(issuer) |
add_to_whitelist |
add_to_whitelist |
IssuerWhitelist(issuer, subject) |
set_proposal (council) |
set_proposal |
CouncilProposal(id) |
- A key that is never written will be evicted once its TTL reaches zero.
Infrequently-updated keys (e.g.
IssuerMetadata,ClaimType) are at higher risk of eviction on low-activity contracts. - Reads never extend TTLs. Calling
get_attestation,has_valid_claim, or any other read-only function does not reset any TTL counter. - Batch operations extend each key individually.
create_attestations_batchcallsset_attestationonce per attestation, so eachAttestation(id)key gets its own fresh TTL. - The shared instance TTL is a single point of failure. If no admin
operation is performed for the full TTL window (default 30 days), all
instance keys (
Admin,FeeConfig,TtlConfig, etc.) expire together. Operators should schedule a periodic no-op admin write (e.g. re-applying the currentTtlConfig) to keep instance storage alive.
The following example shows how to read an Attestation record directly from
a Soroban RPC node without invoking the contract. This is useful for indexers
and analytics tools that need raw state access.
- A Soroban-compatible RPC endpoint (e.g. Testnet:
https://soroban-testnet.stellar.org) - The contract ID
- The attestation ID (32-char hex string returned by
create_attestation)
The storage key for an attestation is StorageKey::Attestation(id). In XDR
ScVal terms this is a SCV_VEC containing two elements:
- The enum discriminant symbol
"Attestation"asSCV_SYMBOL - The attestation ID string as
SCV_STRING
Using the JavaScript Stellar SDK:
import { xdr, Contract, SorobanRpc } from "@stellar/stellar-sdk";
const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
const contractId = "C..."; // your deployed contract ID
const attestationId = "a3f1..."; // 32-char hex ID from create_attestation
// Build the StorageKey::Attestation(id) ScVal
const key = xdr.ScVal.scvVec([
xdr.ScVal.scvSymbol("Attestation"),
xdr.ScVal.scvString(attestationId),
]);
const ledgerKey = xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract: new Contract(contractId).address().toScAddress(),
key,
durability: xdr.ContractDataDurability.persistent(),
})
);
const response = await server.getLedgerEntries(ledgerKey);
const entry = response.entries[0];
// Decode the value back to a JS object
const val = entry.val.contractData().val();
console.log(val.value()); // raw ScVal — use scValToNative() for a plain objectThe returned ScVal is an SCV_MAP whose fields correspond to the Attestation
struct in declaration order:
| Field | ScVal type | Notes |
|---|---|---|
id |
SCV_STRING |
32-char hex |
issuer |
SCV_ADDRESS |
Stellar strkey (G… or C…) |
subject |
SCV_ADDRESS |
Stellar strkey |
claim_type |
SCV_STRING |
e.g. "KYC_PASSED" |
timestamp |
SCV_U64 |
Ledger timestamp at creation |
expiration |
SCV_VEC or SCV_VOID |
Some(u64) or None |
revoked |
SCV_BOOL |
|
valid_from |
SCV_VEC or SCV_VOID |
Some(u64) or None |
Using scValToNative from @stellar/stellar-sdk will convert the map to a
plain JavaScript object automatically.
Instance storage keys use ContractDataDurability.instance() instead of
persistent(), and the key is a plain symbol with no parameters:
const adminKey = xdr.LedgerKey.contractData(
new xdr.LedgerKeyContractData({
contract: new Contract(contractId).address().toScAddress(),
key: xdr.ScVal.scvSymbol("Admin"),
durability: xdr.ContractDataDurability.instance(),
})
);- Attestations are never deleted. An attestation with
revoked: truestays in storage indefinitely (subject to TTL). Index both active and revoked records if you need a complete history. - TTL eviction. A key that is not touched for 30 days will be evicted. Indexers should snapshot state proactively rather than relying on keys always being present.
- Subject and issuer indexes are maintained for pagination.
SubjectAttestationsandIssuerAttestationsstore ordered attestation IDs used by listing queries. When an attestation is revoked, its ID is removed from both indexes so pagination counts shrink; the attestation record itself remains in storage (withrevoked = true) until TTL eviction. ClaimTypeListis insertion-ordered. The order reflects the sequence in whichregister_claim_typewas first called for each type.- Status is computed, not stored.
AttestationStatus(Valid,Expired,Revoked,Pending) is derived at query time from the stored fields and the current ledger timestamp. Indexers must replicate this logic locally.
This section explains how Soroban handles storage across contract upgrades and how to safely evolve the TrustLink storage schema.
When the admin calls upgrade(new_wasm_hash), Soroban replaces the contract's
executable code atomically. All storage is preserved exactly as-is — no
keys are touched, no values are rewritten. The new WASM starts reading the same
raw XDR bytes that the old WASM wrote.
This means:
- Adding a new storage key is always safe — the key simply doesn't exist yet.
- Removing a storage key from the code is safe — the old bytes remain on-chain until TTL eviction, but the new code ignores them.
- Changing the shape of an existing value type is a breaking change. If the
new WASM tries to deserialize a stored
ScValinto a struct with a different field layout, deserialization will fail at runtime.
A migrate function (called once by the admin immediately after upgrade) is
the standard pattern for rewriting stored values into the new format.
Stable — these keys hold simple scalar values or flat lists. Their shape is unlikely to change across versions:
| Key | Reason stable |
|---|---|
Admin |
Single Address — no fields to add |
Version |
Single String — updated in place |
Issuer(Address) |
bool flag — no fields to add |
Bridge(Address) |
bool flag — no fields to add |
SubjectAttestations(Address) |
Vec<String> — append-only, no struct fields |
IssuerAttestations(Address) |
Vec<String> — append-only, no struct fields |
ClaimTypeList |
Vec<String> — append-only, no struct fields |
May change — these keys hold structs with multiple fields. New fields may be added in future versions:
| Key | Why it may change |
|---|---|
Attestation(String) |
Core data struct; new fields (e.g. valid_from) have already been added once |
FeeConfig |
Fee policy may gain new fields (e.g. per-claim-type fees) |
IssuerMetadata(Address) |
Issuer profile may gain new fields |
ClaimType(String) |
Claim type info may gain metadata fields |
The safest approach is an opt-in default: define the new field as
Option<T>, read existing records without a migrate call, and treat None
as the default value. This requires zero migration work and is backward
compatible.
Use a migrate function only when you need a non-optional field or must
rewrite every record eagerly.
Add the new field as Option<T> with a sensible default. Existing stored
records deserialize successfully because Soroban's XDR codec maps missing map
entries to None for Option fields.
// Before (v1)
pub struct Attestation {
pub id: String,
pub issuer: Address,
pub claim_type: String,
// ...
}
// After (v2) — backward compatible, no migrate() needed
pub struct Attestation {
pub id: String,
pub issuer: Address,
pub claim_type: String,
// ...
pub audit_log: Option<Vec<AuditEntry>>, // None for all pre-v2 records
}Call sites treat None as an empty audit log:
let log = attestation.audit_log.unwrap_or_default();Use this when the new field must be non-optional or when you want to backfill all existing records in one transaction.
pub fn migrate(env: Env, admin: Address) {
admin.require_auth();
Validation::require_admin(&env, &admin);
// Iterate every known attestation ID and rewrite with the new default
let ids: Vec<String> = /* load from an index or a migration manifest */;
for id in ids.iter() {
let mut att: AttestationV1 = storage::get_attestation(&env, &id);
let att_v2 = AttestationV2 {
id: att.id,
issuer: att.issuer,
claim_type: att.claim_type,
// ... copy all existing fields ...
new_field: DefaultValue, // backfill
};
storage::set_attestation(&env, &att_v2);
}
}Call migrate immediately after upgrade in the same deployment window:
# 1. Upgrade the executable
stellar contract invoke --id "$CONTRACT_ID" --source "$ADMIN_SECRET" \
--network mainnet -- upgrade \
--admin "$ADMIN_PUBLIC" --new_wasm_hash <NEW_HASH>
# 2. Run migration (admin only, call once)
stellar contract invoke --id "$CONTRACT_ID" --source "$ADMIN_SECRET" \
--network mainnet -- migrate \
--admin "$ADMIN_PUBLIC"Important: migrate must be idempotent — safe to call more than once in
case of a partial failure. Guard against re-migration by checking a version
flag in instance storage:
pub fn migrate(env: Env, admin: Address) {
admin.require_auth();
Validation::require_admin(&env, &admin);
let current: String = storage::get_version(&env);
if current == "2.0.0" {
return; // already migrated
}
// ... rewrite records ...
storage::set_version(&env, &String::from_str(&env, "2.0.0"));
}| Situation | Recommended approach |
|---|---|
New field has a sensible None / empty default |
Option 1 — optional field |
| New field must be non-optional | Option 2 — migrate function |
| Renaming or removing a field | Option 2 — migrate function |
| Changing a field's type | Option 2 — migrate function; use a new key name to avoid XDR conflicts |
Always test the migration on testnet against a contract that has real stored data before running on mainnet:
- Deploy the current (pre-upgrade) version and create representative records.
- Upgrade to the new WASM.
- Call
migrate(if applicable). - Run
./scripts/verify_deployment.shto confirm all read paths work. - Manually read a pre-existing record and confirm the new field has the expected default value.