Skip to content

Latest commit

 

History

History
725 lines (564 loc) · 29.9 KB

File metadata and controls

725 lines (564 loc) · 29.9 KB

TrustLink — On-Chain Storage Layout

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.


Storage tiers

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.


Serialization format

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.


Storage key reference

1. Admin

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

2. Version

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"

3. FeeConfig

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
}

4. Issuer(Address)

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

5. Bridge(Address)

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

6. Attestation(String)

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.


7. SubjectAttestations(Address)

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

8. IssuerAttestations(Address)

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

9. IssuerMetadata(Address)

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
}

10. ClaimType(String)

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
}

11. ClaimTypeList

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

Summary table

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)

TTL Extension Triggers

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.

TTL window

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.

MIN_TTL_THRESHOLD / MIN_TTL_THRESHOLD_LEDGERS

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 triggers

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.

Persistent storage triggers

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)

Implications for archival node operators

  • 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_batch calls set_attestation once per attestation, so each Attestation(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 current TtlConfig) to keep instance storage alive.

Reading storage via RPC

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.

Prerequisites

  • 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)

Step 1 — Encode the storage key as XDR

The storage key for an attestation is StorageKey::Attestation(id). In XDR ScVal terms this is a SCV_VEC containing two elements:

  1. The enum discriminant symbol "Attestation" as SCV_SYMBOL
  2. 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 object

Step 2 — Decode the result

The 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.

Reading instance storage (Admin / Version)

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(),
  })
);

Notes for indexer developers

  • Attestations are never deleted. An attestation with revoked: true stays 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. SubjectAttestations and IssuerAttestations store 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 (with revoked = true) until TTL eviction.
  • ClaimTypeList is insertion-ordered. The order reflects the sequence in which register_claim_type was 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.

Storage migration guide

This section explains how Soroban handles storage across contract upgrades and how to safely evolve the TrustLink storage schema.

How Soroban handles storage across upgrades

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 ScVal into 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 vs. potentially changing keys

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

Migration pattern for adding new fields to existing structs

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.

Option 1 — Optional field (no migration needed)

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();

Option 2 — Eager migration with a migrate function

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"));
}

Choosing between the two options

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

Testing migrations

Always test the migration on testnet against a contract that has real stored data before running on mainnet:

  1. Deploy the current (pre-upgrade) version and create representative records.
  2. Upgrade to the new WASM.
  3. Call migrate (if applicable).
  4. Run ./scripts/verify_deployment.sh to confirm all read paths work.
  5. Manually read a pre-existing record and confirm the new field has the expected default value.